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
dtourolle 61df2730bc chore(release): 0.8.2
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 24m47s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m40s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
Build & Release / Run Tests (push) Successful in 25m19s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 11m18s
Build & Release / Build Linux (push) Successful in 30m51s
Build & Release / Build Windows (push) Successful in 14m55s
Build & Release / Build Android (push) Successful in 32m54s
Build & Release / Create Release (push) Successful in 20s
2026-08-19 17:29:42 +02:00
dtourolle c18d79c656 fix(android): stop background audio rewinding to where it started
A video handed off to background audio (UR-040) streams a live mp3 transcode
over plain HTTP. That response is chunked, so there is no Content-Length, and a
live encode carries no Xing header, so the extractor establishes no duration —
on device every position tick reads "<position> / 0.0".

ProgressiveMediaPeriod.configureRetry resumes a failed load in place only when
the content length is known or the seek map has a duration. With neither it
assumes the source is live, sets pendingDeferredRetry, and when the sample
queues next run dry resets them and re-requests the URL from offset 0. Our URL
carries StartTimeTicks = the handoff point, so "offset 0" is where audio-only
mode began: a transient load error armed a retry that fired minutes later, when
the buffer finally drained, and playback resumed at the handoff point and ran
on from there. A successful retry raises no error and ends nothing, so neither
arm of DR-129 was consulted and no discontinuity handler existed — the only
trace was a position that went backwards, which is why it read as random, and
why the two earlier fixes for the same symptom (DR-129's phantom end, DR-159's
relative-timeline leak) left it standing.

A retry that can only restart the stream is worth less than no retry at all.
player_retry_restarts_stream marks a Remote audio-only video item,
loadWithMetadata carries the answer to Kotlin, and the pure StreamRetryDecision
holds it for a DefaultLoadErrorHandlingPolicy that returns C.TIME_UNSET —
making onLoadError answer DONT_RETRY_FATAL before it reaches configureRetry.
The rewind becomes a recoverable error, which recoverable_error_resume already
answers by re-opening at the position playback reached, StartTimeTicks
rewritten so the selected audio track survives. Every other source keeps the
player's retry: a static file and an HLS playlist declare their timeline and
are resumed where the load stopped. onPositionDiscontinuity is added for its
log line alone, loud for DISCONTINUITY_REASON_INTERNAL, which is the rewind's
own signature.

Verified on device (FP5), same procedure both runs — handoff, 60s to fill the
buffer, a 45s radio outage:

  before  13:54:52 BUFFERING, then "Media ready! Duration: -9.22e15"
          (C.TIME_UNSET) and position 1165.4s -> 840.349s, exactly the handoff
          base, 3.5 minutes after the outage with nothing logged between
  after   14:05:08 "declining the player's retry", playback undisturbed off the
          buffer for 69s (a fatal load error is only raised when the renderer
          next needs data), then ERROR_CODE_IO_NETWORK_CONNECTION_FAILED ->
          re-opening at 785.6s -> READY, and no rewind in the following 7 min

Kotlin tests run with ./gradlew :app:testUniversalDebugUnitTest.

TRACES: UR-040, UR-004 | DR-203 | UT-200
2026-08-19 17:29:31 +02:00
dtourolle 69c2498cf7 docs(traceability): record DR-202 device verification
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m40s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
FP5, native ExoPlayer path: the hold follows IS PLAYING CHANGED within 17 ms,
dumpsys shows fl=KEEP_SCREEN_ON on the window, and a pause/resume round-trip
releases and re-takes it. The webview <video> path is still unverified.
2026-08-18 15:06:43 +02:00
dtourolle 73dd0ef68b chore(release): 0.8.1
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 17m3s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Failing after 16m26s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
Patch: one Android fix — the display no longer sleeps mid-video.
2026-08-18 14:56:56 +02:00
dtourolle caebf2d139 fix(android): keep the display awake while video plays
Android counts its display timeout from the last user input, and watching
something is exactly the case where there is none — so the screen dimmed and
slept mid-playback unless the user kept tapping it.

Nothing held it. FLAG_KEEP_SCREEN_ON appeared nowhere in the app, and neither
renderer supplies a hold for free: ExoPlayer's setWakeMode is a CPU/wifi wake
lock that says nothing about the display, and it draws into the TextureView we
own (DR-192) rather than media3's PlayerView, which is the widget that would
otherwise set keepScreenOn itself; the webview <video> path is no better,
because the display wake lock Chrome takes for video lives in the browser layer
and not in an embedded WebView.

ScreenWakeManager toggles FLAG_KEEP_SCREEN_ON on the Activity window — window
scoped, so it stops applying the moment the app is not visible and cannot
outlive a crash the way an acquired PowerManager.WakeLock can, and it needs no
permission. The two rendering paths are independent holders OR-ed in the pure
ScreenWakeState: the native path follows onIsPlayingChanged plus surface
teardown, so the hold tracks what ExoPlayer reports rather than what the UI
intends, and the webview path reuses the setHtml5VideoState report the frontend
already sends for PiP. Audio is deliberately not a holder — screen-off music is
the point of that path.

Also the repo's first Kotlin JVM unit tests: ScreenWakeState is framework-free,
so the decision is testable off-device with

    ./gradlew :app:testUniversalDebugUnitTest

(note the variant — plain testDebugUnitTest is ambiguous here). sync-android
-sources.sh mirrors src/test into the gen tree alongside the main sources.

TRACES: UR-003, UR-004 | DR-202 | UT-199
2026-08-18 14:56:08 +02:00
dtourolle d5d0e35bca docs(debt): close the R8 release-APK validation item
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 4m56s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Validated on device. It was the last item gating confidence in v0.8.0 itself:
R8 stripping JNI-loaded classes has broken release builds here before, and this
release added a new Kotlin path the unminified debug pass did not exercise.
Recorded as closed rather than deleted, so it is not re-raised.
2026-08-17 07:17:33 +02:00
dtourolle a1cb142df4 docs(debt): record the 12 open items from the codebase audit
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m14s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Findings not addressed in v0.8.0, plus items the device-verification pass turned
up, ordered by what would hurt most if left. Highest are the Android 16 Local
Network Protections exposure (LAN Jellyfin access is the app's core function and
enforcement is coming) and the traceability extractor's blindness to the Kotlin
tree, which means the 90% figure excludes a whole platform.
2026-08-17 06:58:33 +02:00
dtourolle 2c52077b1d chore(release): 0.8.0
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 25m14s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m47s
Traceability Validation / Check Requirement Traces (push) Successful in 36s
Build & Release / Run Tests (push) Successful in 26m3s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 11m2s
Build & Release / Build Linux (push) Successful in 32m23s
Build & Release / Build Windows (push) Successful in 14m59s
Build & Release / Build Android (push) Successful in 31m22s
Build & Release / Create Release (push) Successful in 31s
Minor rather than patch: three user-visible behaviour changes — cloud/D2D backup
disabled, the Android TV launcher entry withdrawn, and lockscreen skip scrubbing
rather than advancing during background audio.
2026-08-16 23:59:54 +02:00
dtourolle 6dfc6b259a fix(player): lockscreen skip scrubs instead of advancing in background audio
onSkipToNext/onSkipToPrevious forwarded a bare next/previous to Rust, which
always advanced the queue. Correct for music, wrong for a video whose audio is
running through a background-audio handoff (UR-040): pressing skip to re-hear a
line jumped to the next episode instead of scrubbing.

resolve_skip_action in player/seek.rs maps the command to Advance or SeekTo, and
is_background_audio_active() is the whole test — the handoff exists only for
video, and an episode played through it reports MediaType::Audio, so media type
cannot distinguish the case. Forward 30s, back 10s, both clamped to [0, duration]
so a skip near either end cannot seek negative or read as EOF and advance.

Routed through the same spawn-then-seek_absolute path as the scrubber, because a
handoff seek re-opens the stream and must not run under the blocking lock
(DR-159). Kotlin keeps sending the opaque command; it only gains FAST_FORWARD/
REWIND in the PlaybackStateCompat so the system stops drawing skip arrows for a
control that scrubs. The remote-volume action block is deliberately untouched:
the handoff never applies to cast sessions, where skip really does mean advance.

Tests written first and watched fail (left: Advance, right: SeekTo). 706 Rust
tests pass, clippy 0, coverage 90%.
2026-08-16 23:54:43 +02:00
dtourolle 42e7d86ec4 docs(audit): record device-verification results and the asset-protocol finding
Device pass on HONOR ROD2-W09 (Android 16 / SDK 36) confirms B2, B4, B5, B7 and
finds no CSP violations across a full browsing session.

C2 was aimed at the wrong thing: the asset protocol is not narrowly used but
entirely unused. getCachedImageUrl has no production callers, images arrive as
base64 data URIs from Rust via imageGetUrl, and the device saw zero
asset.localhost requests. Both protocol-asset and the CSP's img-src http:/https:
grant can likely be dropped.
2026-08-16 23:22:47 +02:00
dtourolle 4e451bb534 chore(bindings): regenerate specta output for the new TRACES doc comments
tauri-specta propagates Rust doc comments into bindings.ts as JSDoc, so adding
TRACES comments to command functions changes generated output. Regeneration
happens at build time, so this was left dirty by the branch that added them.
Doc-comment-only: no signature or exported-symbol changes.

Also records the audit corrections made during device verification (B1 mechanism,
B7 re-framing, B8, D3 magnitude).
2026-08-16 23:15:58 +02:00
dtourolle 889289286b merge: clear the clippy backlog and unify lock helpers (D1-warnings, D3)
51 clippy warnings -> 0, with 8 justified #[allow]s (IPC arity, specta wire
types, and the 9 test-only await-holding-lock sites). 27 raw lock calls moved to
the poison-tolerant helpers - all of them test code; production was already
clean.

Caught a non-neutral clippy --fix: removing the redundant 'use hostname;' in
credentials.rs orphaned its #[cfg(target_os = "linux")] onto SERVICE_NAME,
which would have cfg'd the constant out of every non-Linux build. Compiles clean
on Linux, so only Windows/macOS CI would have caught it.
2026-08-16 23:06:33 +02:00
dtourolle 8500da1a42 chore(rust): clear the clippy backlog and finish the poison-tolerant lock sweep
`cargo clippy --all-targets` went from 51 warnings (23 in the lib) to zero.
Most were mechanical — needless borrows, `assert_eq!` against a bool literal,
`vec!` where an array does, `or_insert_with(Vec::new)`, a loop index used only
to index — and were applied with `clippy --fix`, then reviewed line by line.
That review caught one auto-fix that was *not* semantically neutral: dropping
the redundant `use hostname;` left its `#[cfg(target_os = "linux")]` orphaned
directly above `SERVICE_NAME`, which would have silently cfg'd the constant out
of every non-Linux build. Removed the stray attribute with the import.

Where a lint asked for a risky change rather than a better one, it is suppressed
with a comment saying why:

- `too_many_arguments` on five `#[tauri::command]` handlers and
  `ThumbnailCache::save_thumbnail` — most of the arity is `State<'_, _>`
  injection, and a parameter struct would change the IPC contract and the
  generated TypeScript for no readability gain.
- `large_enum_variant` on `PlayerStatusEvent` and `AutoplayDecision` — both are
  serde + specta wire types emitted a handful of times a second, never bulk
  allocated; boxing would have to stay invisible to the generated bindings while
  every match arm gained a deref.
- `await_holding_lock` on the `hybrid`/`offline` test modules — the guard is a
  test-only serialisation lock for the process-global `INCLUDE_CATALOG_BROWSE`
  flag, and the await it spans *is* the critical section. Each `#[tokio::test]`
  gets its own single-threaded runtime, so this is not the production deadlock
  class the lint targets; restructuring would reintroduce the flag race.

Real fixes elsewhere: `JellyfinItem::to_media_item` takes `self` by value, so it
is now `into_media_item`; the five-tuple episode row in the download commands
has a named `EpisodeRow` alias; the mpv `PropertyChange` arm matches
`name: "pause"` instead of guarding on it.

Also converted the last 27 raw `.lock().unwrap()` call sites to `lock_safe()`,
completing the `MutexSafe`/`RwLockSafe` convention. All of them turned out to be
in test modules — production code was already clean — so this is consistency
rather than a fix. The two raw locks in `utils/lock.rs` stay raw on purpose:
those tests deliberately poison a mutex to prove the helpers recover from it.

Pure refactoring: all 698 tests still pass.
2026-08-16 23:05:13 +02:00
dtourolle 88e15e3e12 merge: Android runtime security (B1, B3)
Correct the POST_NOTIFICATIONS mechanism: the lockscreen notification is exempt
because of the MediaSession token, not because it belongs to a foreground
service — FGS notifications are explicitly NOT exempt. So no permission prompt
and no checkSelfPermission gate; instead both notification builders bind the
token once and log loudly if it is ever null, turning a silent failure into a
logcat line. Stop the webview undoing the network security config:
mixedContentMode COMPATIBILITY, allowFileAccess/allowContentAccess false.

Conflict resolution: this branch's DR-198 collided with the Tauri branch's, so
it was renumbered DR-200 (3 TRACES in JellyTauPlaybackService.kt and the UR-006
matrix row updated). DR-199 was uncontested. Pinned counts summed to DR 191 /
total 334; UR-071 takes both DR-198 and DR-199.
2026-08-16 23:03:29 +02:00
dtourolle c9f33ae6a4 merge: restrictive CSP and narrowed asset scope (C1, C2)
Set a CSP with script-src 'self' (Tauri nonces the one inline bootstrap script),
object-src/frame-src 'none', and necessarily-permissive img/media/connect for the
user-supplied Jellyfin origin. Narrow assetProtocol $APPDATA/** -> thumbnails/**,
which is convertFileSrc's only remaining caller.

Conflict resolution: scripts/extract-traces.test.ts pinned counts summed rather
than side-picked — DR-189 and DR-198 were added independently on two branches,
so DR 187 -> 189 and total 330 -> 332. docs/traceability.md regenerated.
2026-08-16 23:01:50 +02:00
dtourolle a93cee9241 merge: stop backing up credentials no key can ever open (B2, B4, B5)
allowBackup=false plus data_extraction_rules covering device-transfer, not just
cloud-backup; treat an undecryptable credential blob as a logout rather than a
hard error; drop the half-declared leanback/TV entries; jvmTarget 1.8 -> 17.
2026-08-16 23:01:05 +02:00
dtourolle 4996727ca9 merge: enforce CI gates the contributor rules already required (D1, A3, A4, D2)
Add cargo fmt --check (strict) and cargo clippy (advisory) to CI, ratchet the
traceability threshold 50 -> 82, add a dangling-ID gate, and fix the
offlineCatalog flake (cold dynamic import, not a timer).
2026-08-16 23:00:58 +02:00
dtourolle e3cdb12967 merge: traceability matrix repair (A1, A2, A4)
Tag the twelve Done-but-untraced requirements, re-scope the stale libmpv IRs
against the backends that actually deliver them, and define the two dangling
IDs (DR-189, UT-188).

Coverage 285/330 (86%) -> 301/331 (91%); IR 19/32 -> 25/32.
2026-08-16 23:00:35 +02:00
dtourolle 2d21f092d5 fix(android): stop the webview undoing the network security config
MainActivity set mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW together with
allowFileAccess/allowContentAccess = true, which is a blanket cleartext opt-in
reached by hand — the exact thing network_security_config.xml exists to prevent
and its own comment warns against. Nothing needed any of the three:

- file:// is never loaded. Cached thumbnails go through convertFileSrc, which
  on Android resolves to http://asset.localhost/... and is answered by wry's
  request interceptor rather than the filesystem; downloaded media goes over the
  loopback HTTP server (DR-137), which exists precisely because the asset/file
  route cannot stream a large file.
- content:// is never loaded. The manifest's FileProvider is for outbound share
  intents, not webview navigation.
- Mixed content never arises. Tauri serves the UI from http://tauri.localhost
  (use_https_scheme defaults false and is not set), and both 127.0.0.1 and
  asset.localhost are loopback/.localhost origins Chromium treats as potentially
  trustworthy. A plain-HTTP remote server would be mixed content, but the network
  security config already rejects it first — so ALWAYS_ALLOW bought nothing.

COMPATIBILITY_MODE rather than NEVER_ALLOW is a deliberate hedge: the platform
default at targetSdk 21+ is NEVER_ALLOW, so this is still one step looser, and it
keeps passive content working if the analysis missed a path. The two files now
cross-reference each other so the pair cannot drift apart again.

Also records why POST_NOTIFICATIONS is declared but never requested. An audit
read the missing runtime request as a threat to the lockscreen controls; it is
not. A foreground-service notification is explicitly NOT exempt, but a
media-session one is, and the platform predicate (Notification.isMediaNotification)
requires MediaStyle AND a non-null session token. Confirmed on device: appops
POST_NOTIFICATION: ignore with the transport notification live. So no permission
prompt is added and startForeground stays ungated — a guard there would trade a
cosmetic problem for the "did not then call Service.startForeground()" kill.
What is added is the guard matching the real precondition: both builders bind the
token once and log an error if it is ever null, since SystemUI's media carousel
is gated on the same predicate and a token-less notification loses the lockscreen
controls entirely, silently.

TRACES: UR-006, UR-071 | DR-198, DR-199
2026-08-16 22:59:47 +02:00
dtourolle ebf9a99b80 docs(traces): tag the twelve "Done but untraced" requirements, and stop the matrix over-reporting
Twelve requirements were marked Done in docs/requirements.md with zero TRACES
anywhere in the tree. The features work — the tags were simply never written —
so the matrix over-reported on exactly the requirements a reviewer would most
want to verify. Each is now tagged at the code that actually implements it:

- JA-006 / JA-009 / JA-013 / JA-014 / JA-015 / JA-018 and IR-022 / IR-024 at
  their Jellyfin call sites in repository/online.rs (search, get_item's
  MediaStreams/People fields, Items/Resume, Shows/NextUp, FavoriteItems DELETE,
  get_person/get_items_by_person), plus the commands that expose them.
- UR-006 / IR-006 across the lockscreen spine: JellyTauPlaybackService (the
  MediaSessionCompat owner), the nativeOnMediaCommand JNI intake, and
  LockscreenMetadata / update_lockscreen_metadata.
- IR-008 at both audio-focus mechanisms — ExoPlayer-managed for audio, the
  manual AudioFocusRequest listener for video — and at the media-type string
  that chooses between them.
- UR-037 (with DR-042, also untraced) on the video-library poster grid:
  LibraryGrid, MediaCard, and the tv/movies routes.

Resolve contradictory statuses across layers, evidence first:

- IR-018/IR-019 were Planned under Done URs because they were scoped to libmpv.
  MpvBackend is the audio-only backend and overrides neither
  set_subtitle_track nor set_audio_track — the trait's not_implemented()
  default still stands — so UR-020/UR-021 are met by ExoPlayer and by the
  HTML5 <video> path instead. Both IRs are re-scoped to those backends and
  marked Done; IT-008/IT-009 and the stale @req-planned markers in backend.rs
  follow.
- IR-005 (MPRIS) stays Planned: there is no MPRIS/D-Bus code or dependency in
  the project and update_lockscreen_metadata is a no-op off Android. UR-006 is
  corrected to Done (Android) rather than the IR being marked Done.
- A note under the IR table records where a UR is met by a different mechanism
  than its IR anticipated.

Define the two dangling IDs the source already referenced: DR-189 (the control
bar never auto-hid on a touchscreen, because its timer was armed only from
onmousemove) and UT-188 (its rule test). The live-denominator assertion in
extract-traces.test.ts moves 187/330 to 188/331 accordingly.

Traced requirements 444 to 459; IR coverage 19/32 to 25/32.
2026-08-16 22:58:55 +02:00
dtourolle 38dd1129e5 feat(security): set a restrictive CSP and scope the asset protocol to thumbnails
`app.security.csp` was `null`, so the webview ran with no Content-Security-Policy
at all: any script that reached the web layer would have inherited the whole IPC
surface. There is no known injection path today (one app-owned `{@html}`, no
`innerHTML`/`eval`), so this is defence in depth rather than a fix for an open
hole.

`script-src 'self'` is the restrictive half — Tauri nonces SvelteKit's inline
bootstrap script at build time, so no `'unsafe-inline'` is needed — together with
`object-src`/`frame-src 'none'` and `base-uri 'self'`. `img-src`/`media-src`/
`connect-src` cannot be restrictive: the Jellyfin origin is typed in by the user
at run time and is routinely plain http on a LAN, so they allow `http:`/`https:`.
That is a wide grant for data, but it still bars `file:`/`filesystem:` and does
not touch script execution. A run-time policy naming the server exactly was
rejected: Tauri derives the header from immutable config when it serves the HTML,
so it would mean rebuilding config and reloading the webview on every server
change. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"`
attributes into markup; `worker-src`/`media-src` keep `blob:` for hls.js's
demuxer worker and its MSE object URL; `ipc:`/`http://ipc.localhost` keeps
`invoke` working. `devCsp` mirrors it with the eval/inline/websocket allowances
Vite's dev server needs.

The asset-protocol scope narrows from `$APPDATA/**` — the storage root holding
the SQLite database and the encrypted-token fallback file — to
`$APPDATA/thumbnails/**`. Since DR-137 moved downloaded media to the loopback
media server, `imageCache` is the only `convertFileSrc` caller left.

Needs manual verification on both platforms: thumbnails, online HLS video and
offline downloaded video cannot be exercised headlessly.
2026-08-16 22:58:53 +02:00
dtourolle 4c9361d020 fix(android): stop backing up credentials no key can ever open
The app's data dir was eligible for Google cloud backup: the manifest set
neither allowBackup nor any extraction rules, so the SQLite catalogue
(library metadata, watch history) and the jellytau_secure_prefs
credential blob were shipped to the user's Google account. Restoring that
is worse than not having it — SecureStorage encrypts under an Android
Keystore key, and Keystore keys are never backed up, so a restored
install gets ciphertext with nothing to open it and fails auth silently
while looking signed in.

Backup and device-to-device transfer are both turned off. allowBackup
="false" covers API 24-30 outright and kills cloud backup on 31+; it does
NOT stop D2D there, so @xml/data_extraction_rules excludes every domain
from both channels. Nothing is lost: the catalogue is a rebuildable
mirror of the Jellyfin server, and watch state lives on the server.

The credential-load path degrades instead of erroring, because a device
can still arrive at undecryptable ciphertext (an older install's backup,
a Keystore key invalidated by a lockscreen change). Both backends now
distinguish "nothing stored" from "stored but unreadable" and answer the
second as the first: CredentialStore::load_credentials_file logs and
returns an empty map rather than CredentialError::Encryption — which
storage_get_access_token was turning into a hard Err and
storage_get_active_session into a warning — and SecureStorage.getCredential
discards the dead blob so it cannot fail every subsequent read. The
result is a login screen rather than a broken session, and the next
successful sign-in rewrites the store.

Also removes the half-declared Android TV support: the manifest offered
LEANBACK_LAUNCHER and the leanback uses-feature with no D-pad focus
model, no TV layouts, and neither of the two declarations Play's TV
validation also requires (touchscreen required="false", android:banner).
That fails review while advertising the app to TV launchers. All four go
back together when a focus pass is actually done.

And raises jvmTarget from 1.8 to 17 under compileSdk 36, with matching
compileOptions — AGP 8.11 already requires a JDK 17 toolchain, so 1.8 was
only capping emitted bytecode. Nothing else in the build assumed 1.8.

TRACES: UR-012 | IR-014
2026-08-16 22:56:32 +02:00
dtourolle b9dab56379 ci: enforce the checks the contributor rules already required
Four gates that were documented but unenforced, plus the flaky test that
made a full-suite run untrustworthy.

Rust lint/format: CLAUDE.md has required `cargo fmt` and `cargo clippy`
before every commit for as long as the rule existed, yet neither ran
anywhere in CI — the requirement rested on memory alone. Both now run in
build-and-test.yml and build-release.yml. rustfmt and clippy are already
baked into the builder image, so nothing is installed at job time.
`cargo fmt --all -- --check` is strict immediately (the tree is clean).
Clippy is advisory for now: ~51 pre-existing warnings mean `-D warnings`
would fail on unrelated work, so the step carries a TODO to flip the flag
once the backlog clears. A compile error still fails it, so it is not a
no-op.

Traceability threshold: MIN_THRESHOLD sat at 50 while real coverage was
86%, so nearly half the matrix could rot before the gate objected.
Ratcheted to 82 with the policy written down — it only ever goes up, and
is never lowered to make a red build pass. The same figure lives in
MIN_COVERAGE_PERCENT so `traces:coverage` gates locally on the same bar,
and a test fails if the two drift.

Dangling IDs: a TRACES comment could name any well-formed ID and the
extractor accepted it silently, so typos and renames that missed a call
site passed unnoticed. `bun run traces:validate` cross-checks every
traced ID against the table rows in requirements.md and fails with the
referencing files listed. It spans UT/IT as well, which the coverage
orphan list ignores by design. This currently reports DR-189 and UT-188,
which are being defined separately.

Flaky offlineCatalog test: the first dynamic import of the service paid
~1s to transform its dependency graph, charged to a test body against
vitest's 5s default. Alone it passed; under suite-wide contention it
timed out. The import is now warmed at collection time, so no test is
timing the compiler — the timeout is deliberately unchanged. The store
shim also drops subscribers from module instances discarded by
resetModules, which previously leaked across tests.
2026-08-16 22:51:44 +02:00
dtourolle 73641e192c chore(release): 0.7.0
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 21m50s
Traceability Validation / Check Requirement Traces (push) Successful in 44s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m50s
Build & Release / Run Tests (push) Successful in 18m46s
Build & Release / Build Linux (push) Successful in 30m52s
Build & Release / Build Windows (push) Successful in 15m13s
Build & Release / Build Android (push) Successful in 31m53s
Build & Release / Create Release (push) Successful in 12s
Version bumped across package.json, tauri.conf.json and Cargo.toml (+ lock),
CHANGELOG entry written from the five commits in the range rather than from the
trace extractor's output — VideoPlayer.svelte alone carries dozens of TRACES, so
the generated draft named most of the app's requirements for a five-commit
release.

DR-188 is retargeted: it recorded the native-video default as waiting on the
background-audio handoff, which is now fixed (DR-196), so it records the
completed flip and the evidence for it instead.

Minor, not patch: the rendering path changes underneath every Android user.
2026-08-16 22:28:05 +02:00
dtourolle be907b4945 fix(home): stop Next Up repeating Continue Watching
Jellyfin's /Shows/NextUp defaults EnableResumable=true, which returns a
partially-watched episode as its own series' next up — precisely the
episode /Items/Resume already returns. Home's "Next Episode" row and the
TV landing's Next Up row therefore duplicated Continue Watching card for
card.

build_next_up_endpoint now sends EnableResumable=false, and because
servers predating that parameter ignore it, filterInProgressNextUpItems
also drops any next-up entry whose id appears in the resume list. It is
the mirror of DR-089 and sits beside it: presentation-layer de-duplication
over two lists the frontend already holds. The resume filter still reads
its frontier from the unfiltered Next Up list, so pruning in-progress
entries cannot resurrect a stale resume card.

The code changes were swept into 5e8efa25 by a concurrent `git add -A`;
this carries the remainder — DR-197 / JA-036 / UT-190..192, the
renumbering off the DR-196 collision that commit created, the regenerated
matrix, and the requirement-count guard.

TRACES: UR-059 | DR-197, JA-036 | UT-190, UT-191, UT-192
2026-08-16 22:18:06 +02:00
dtourolle 3b9a8ad695 test(player): pin the native-video default and the opt-out that must survive it
The default has moved four times, so the risk is not which way it points but
that a flip silently overrides people who chose. The previous reader was
getItem(KEY) === "true", which conflates "never chose" with "chose off" — under
it, flipping the default re-enables the native path for everyone who had
deliberately turned it off. The three cases are pinned separately so that
conflation cannot come back.
2026-08-16 22:16:39 +02:00
dtourolle ab95f5013d feat(player): make native Android video the default
The two defects that were holding the flip back are fixed and verified on a
device, which is the standard this default has been held to since DR-161 shipped
a verified sub-path over an unverified one:

  - returning from background audio restarts the renderer that is actually on
    screen, instead of only ever reloading the <video> element (DR-196)
  - the letterbox bars are painted, instead of retaining whatever was last in
    the framebuffer (DR-194)

Evidence: handoff to audio-only at 69:54 returning to video playing at 70:18,
and clean bars across playback, the control bar and a rotation round-trip.

An explicit stored choice still wins in both directions, so anyone who turned the
flag off keeps it off — hence the null check on the stored value rather than a
bare === "true", which would silently re-enable it for people who opted out.

The Settings copy no longer tells users to leave it off; it now describes the
toggle as the fallback to the built-in web player.

The flag keeps its "experimental" name because it remains a suppressor of Rust's
backend choice, never a promoter: turning it on cannot produce a native backend
where Rust says HTML5.
2026-08-16 22:14:43 +02:00
dtourolle 5e8efa252e fix(player): restart the native renderer when returning from background audio
With native video on, coming back from background audio left a black screen: a
play overlay pinned at 0:00, a seek bar at zero, and a play button that did
nothing. Nothing crashed — the process stayed up and the frontend kept logging —
the transition was simply dropped.

The two render paths resume by different means, and exitBackgroundAudioHandoff
only ever performed one of them. The webview <video> reloads off its stream URL:
an $effect watches it, reinitialises HLS or sets element.src, and canplay drives
the seek and play. ExoPlayer owns no element and nothing watches the URL on its
behalf — native playback is only ever started by an explicit player_play_item
plus adapter load, which the component issues once, from onMount. So reassigning
the URL restarted precisely nothing, and since player_exit_background_audio had
already stopped the handoff's audio player, the backend came back holding no item
at all. That is why the play button was inert: there was nothing loaded to play.

The return now re-issues that pair on the native path, in the same order as the
initial load, carrying the position the audio reached. Subtitle configurations are
reused from the ones resolved at mount — ExoPlayer sideloads them as
MediaItem.SubtitleConfigurations and cannot accept one after prepare().

Which path to take is decided by planHandoffReturn, a pure helper in
backgroundAudioHandoff.ts, so the branch is unit-testable without mounting the
player. It also folds in shouldResumeOnForeground, so a pause taken on the
lockscreen during the handoff still wins over the snapshot captured on the way
out.

Verified on device (HONOR ROD2-W09, Android 16): handoff to audio-only at 69:54,
return restored native video playing at 70:18. Previously the same sequence left
the player idle and black.

The requirements count pin in extract-traces.test.ts moves with the new DR-196.
2026-08-16 22:10:14 +02:00
dtourolle 1285908733 fix(android): paint the letterbox bars, so stale pixels stop surviving in them
Native video left debris in the padding around the video: the "previous frame"
flash on rotation, a ghost copy of the control bar stranded in the top bar, each
new clock digit drawn over the one before it (35:42 with the 1 still showing
through the 2), and the sleep/quality menus leaving their imprint after closing.
One cause under all of it — nothing painted those bars.

The window surface is opaque; the theme is not translucent and dumpsys window
shows no translucency flag. For an opaque surface HWUI deliberately does NOT
clear the damaged region before replaying a frame: it assumes the view hierarchy
covers every pixel it owns. Here 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, and setTransparent(true) cleared it to TRANSPARENT — leaving them painted
by nobody, with whatever was last in the framebuffer surviving there.

The window background now stays opaque black while compositing. It cannot hide
the video: the TextureView is drawn on top of it, and the WebView's own
background is what lets the picture through.

Three previous attempts missed because they aimed at the window's rotation
animation and at TextureView frame-retention — two postOnAnimation hops, an
onSurfaceTextureUpdated reveal, then ROTATION_ANIMATION_JUMPCUT with
FLAG_FULLSCREEN to make it stick. The pixels were never the animation's, which is
also why the artefact reproduces standing still, with no rotation involved. Those
are removed. The alpha-hiding among them actively made things worse: it blanked
the one view that reliably paints its own rect. FLAG_FULLSCREEN goes too — it
fought edge-to-edge insets for no gain.

Verified on device (HONOR ROD2-W09, Android 16): reproduced with native video on
— ghost control bar in the top bar, doubled clock digit — then absent after the
fix across playback, the control bar and a rotation round-trip.

DR-194 is rewritten to record the real mechanism and marked Done.
2026-08-16 21:51:14 +02:00
dtourolle 8e98e1c37a test(player): answer the commands the tap-surface tests actually render
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 22m57s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m50s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
Build & Release / Run Tests (push) Successful in 7m21s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 20m32s
Build & Release / Build Windows (push) Successful in 14m29s
Build & Release / Build Android (push) Successful in 31m5s
Build & Release / Create Release (push) Successful in 12s
VideoPlayer.tapSurface.test.ts deliberately does not mock $lib/api/bindings — it
renders the real component against the real bindings, which bottom out in the
globally mocked `invoke`. That mock resolves `undefined` for every command, so
any command whose result is *rendered* blows up: the quality picker assigns the
result straight to state and the template then reads `streamingQualities.length`,
which throws on undefined.

It threw asynchronously, outside any test, so the suite reported 4 unhandled
errors while every test still passed — the state vitest warns "might cause false
positive tests". Answering the two rendered commands removes them.

Authored in the main checkout; brought in here and verified: 83 files, 1009
tests, and the unhandled-error count drops from 4 to 0.
2026-08-16 21:20:55 +02:00
dtourolle 440d7a01a9 chore(release): 0.6.0
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 6m2s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 32s
Traceability Validation / Check Requirement Traces (push) Successful in 13s
Build & Release / Run Tests (push) Failing after 6m7s
Build & Release / Build Linux (push) Skipped
Build & Release / Build Windows (push) Skipped
Build & Release / Build Android (push) Skipped
Build & Release / Create Release (push) Skipped
Android native video renders a picture, and its transport works.

The path shipped once as audio with no picture and was reverted with the
compositing named as the suspect. It was not the compositing: five independent
defects sat between ExoPlayer and the screen, each able to produce that symptom
on its own — the app shell painting over the surface through a CSS rule aimed at
an attribute nothing set, a poster card with no way to lift on a path that
renders no <video>, JS bridges racing the page load and losing permanently, a
SurfaceView that was never detached, and a frontend that told Rust a webview
element was playing when none existed, so every play/pause intent was aimed at
something that was not there.

Native video stays opt-in. Turning it on surfaced a further unverified path —
the background-audio return is written only for the webview element — and
rotation still needs device confirmation.

Minor rather than patch: the player's touch behaviour changes for everyone (the
control bar now auto-hides on touchscreens, and the system bars go away with the
player), not only for those who opt into native video.
2026-08-16 21:14:08 +02:00
dtourolle dccb5f53dd fix(android): stop the rotation cross-fade replaying the old video frame
Rotating with native video on shows the previous frame flashing in what become
the letterbox bars. It reads as a TextureView artefact — the view retains its
last frame, so between the rotation and fitSurfaceToScreen() landing that frame
sits at the old size — and two fixes were built on that reading:

  1. reveal after two postOnAnimation hops. An animation frame is not a video
     frame; at 24fps the next decoded frame can be several vsyncs away.
  2. reveal on onSurfaceTextureUpdated, i.e. when a real frame lands. This meant
     owning the SurfaceTextureListener and handing ExoPlayer the Surface directly
     instead of via setVideoTextureView, which installs its own and leaves us
     blind to frame arrival.

Neither stopped the flash. The mechanism is the WINDOW's rotation animation:
Android cross-fades a screenshot of the old orientation, that screenshot holds
the old video frame at the old size, and nothing at the TextureView level can
reach it. The app cannot pre-empt the screenshot either — onConfigurationChanged
fires after it is taken.

So the animation itself has to go: ROTATION_ANIMATION_JUMPCUT. That was accepted
and silently ignored, and the platform said why out loud —
"VRI[MainActivity]: setLayoutParams: not fullscreen" — because the attribute is
honoured only for a fullscreen window. FLAG_FULLSCREEN is therefore set with it,
scoped to while native compositing is active so the rest of the app keeps its
normal animation. After the change that complaint is gone from logcat.

The frame-arrival reveal is kept: it replaces a fixed-timeout guess with a real
signal, and its timeout is required rather than defensive — a resize while paused
means no new frame is ever coming, and revealing a stale frame beats a
permanently black player.

NOT CONFIRMED FIXED on device. The forced-rotation harness
(settings put system user_rotation) proved unreliable here, and screenrecord
fixes its canvas at start, so a rotation inside a recording never changes frame
dimensions — which defeated two separate attempts to measure this. DR-194 is
recorded as "Needs device verification" rather than Done.
2026-08-16 18:46:15 +02:00
dtourolle c142568230 fix(player): make transport reach the player that is actually rendering
Play/pause did nothing on the Android native video path — from the on-screen
tap, from the control bar, and from a direct player_toggle invocation — while
seek and skip kept working. That asymmetry was the whole clue: seek decides in
player_seek_video, transport decides in toggle_playback.

DR-195 is the cause. `html5_playing` is Rust's record of "a webview <video> is
active and in this state", and toggle_playback/play/pause all route transport to
that element whenever it is set. The player route mirrored element state into it
UNCONDITIONALLY — from handleReportStart and, fatally, from handleReportProgress,
which VideoPlayer calls on a 10-second interval. So on the native path the
frontend re-declared every ten seconds that an element was playing when none
existed, and every transport intent was emitted into the void. It also explains
the flashing: the control bar and the JRay overlay both key off isPlaying, which
was being contradicted on every tick. The mirror now lives in
mirrorElementStateToRust() in VideoPlayer, gated on useHtml5Element — the only
place that knows whether an element renders at all. The route cannot tell the
paths apart, which is exactly how it came to lie.

DR-193 hands transport authority back to the native backend when an item loads
into it. Necessary but insufficient alone: the progress interval put the flag
straight back, which is why the first device test after it still failed.

DR-192 presents native video through a TextureView instead of a SurfaceView. A
SurfaceView renders on its own layer outside the app window and punches a
transparent region through it, and everything drawn above that hole — here, the
entire Svelte UI — depends on that composition path. The overlay dropped its
incremental damage: the DOM advanced (slider 476 -> 479 across three seconds)
behind a screen showing neither, so the progress bar froze, controls would not
fade and rotation lost the transport UI, while structural DOM changes got
through, which is why the play overlay always appeared to work. It supersedes
DR-191, which forced redraws in a loop and treated the symptom.

DR-194 hides the video view across a resize and reveals it two frames later. A
TextureView retains its last frame, so between a rotation and the re-fit landing
that frame is stretched across the old rect and the previous frame flashes in
what should be the letterbox bars.

Verified on device (Honor ROD2-W09, Android 16) by driving ADB and reading the
live DOM over the devtools socket: surface tap pauses (position frozen across 12
seconds, overlay raised, transport flipped) and resumes; the control bar does
both. UT-189 drives the real 10-second interval under fake timers — an earlier
version asserted on a freshly mounted player, passed with the guard deleted, and
guarded nothing.

Still open, and deliberately not claimed: DR-192's effect on the overlay repaint
is unverified on device, DR-194's letterbox reset is untested, and the native
default (DR-188) stays off pending DR-190, the background-audio return.
2026-08-16 18:03:22 +02:00
dtourolle 95129d04a3 fix(player): make Android native video actually visible, and usable
DR-172 reverted native video to opt-in after it shipped as audio with no
picture, naming the compositing as the suspect. The compositing was fine. Five
separate defects sat between ExoPlayer and the screen, each able to produce that
exact symptom on its own, and each invisible to the others.

DR-185 — the app shell painted over the surface. app.css clears the page's
opaque layers through three selectors, one of which targets `[data-app-shell]`,
an attribute NO component has ever set, in any commit. The shell paints
--color-background across the whole viewport and VideoPlayer stacks above it, so
the WebView composited opaque no matter what else was cleared. Invisible three
ways over: the CSS is valid, the selector is plausible, and a rule matching
nothing looks exactly like a rule matching something already transparent.

DR-182 — nothing could lift the poster card. Every markMediaReady() call site is
an HTML5 <video> event, and the native branch renders no element, so the black
title card covered the surface for the entire session. The first fix hooked
`player://position-update` / `player://state-changed`; those channels are never
emitted by the backend, so it passed a test that fired them by hand and did
nothing on a device. Driven from the player store now, as the seek bar already
was.

DR-183 — the JS bridges raced the page load. Installed 500ms after onCreate by
walking the view tree, while WebView binds injected objects at page-load time,
and the identity guard then declined to re-inject forever. setTransparent(true)
could never arrive. Installed from WryActivity.onWebViewCreate instead, which
wry calls immediately before the first loadUrl.

DR-184 — the SurfaceView was never detached. detachVideoSurface had no callers
anywhere, mirroring the DR-151 defect: every native video left its surface
parented to the content view and the next one stacked another beneath it.

DR-191 — the overlay stopped repainting. Incremental damage (the clock's text,
the control bar's opacity) never reached the screen while structural changes did,
so the progress bar froze, the controls would not fade, and the play overlay
appeared to work because it is added and removed from the DOM. Driven from the
Activity via postInvalidateOnAnimation while compositing is on.

Two UI defects only this path could reveal came with them: isPlaying froze at
its initial value, leaving the play overlay dimming and covering the video
(DR-186), and the control bar's auto-hide was armed solely by mousemove, which a
touchscreen never fires (DR-189). Immersive mode now applies on entering the
player rather than only via the fullscreen button (DR-187).

Verified on a device (Honor ROD2-W09, Android 16): logcat carries
`WebView transparent = true` and `Marking media ready` with video on screen —
the pair DR-172 went looking for and could not find — and skip, seek, rotation
and subtitle rendering were exercised by hand.

The default stays OFF (DR-188). Turning it on surfaced a further unverified
sub-path: returning from background audio is HTML5-only, so playback stays dead
(DR-190, proposed). Shipping it would have repeated DR-161 exactly — a verified
sub-path made default over an unverified one.
2026-08-16 15:28:10 +02:00
dtourolle f0f98feae8 fix(player): strip the burn-in the server puts back into its own transcode URL
The negotiation asks for no subtitle stream (DR-176), but when PlaybackInfo
answers with a TranscodingUrl we played that URL verbatim — and the server
built it from its own subtitle verdict. Jellyfin's StreamInfo.ToUrl appends
SubtitleStreamIndex and SubtitleMethod whenever it picked a track, so the
burn-in we had just declined came straight back through the URL, turning a
remux into a full frame-by-frame re-encode.

Live TV never declined it at all: open_live_stream sent no index, so the
server applied the channel's default track, and broadcast subtitles are DVB
bitmaps that NormalizeSubtitleEmbed converts to burn-in on sight.

without_server_chosen_subtitle() drops SubtitleStreamIndex, SubtitleMethod,
SubtitleCodec and alwaysBurnInSubtitleWhenTranscoding from any URL the server
built — matched case-insensitively, as Jellyfin binds query keys — and
re-appends the -1 sentinel, because an absent index is not "none", it is
"you choose". Applied at both adoption points, plus the sentinel in the
live-stream negotiation body and its fallback URL.
2026-08-16 14:56:40 +02:00
dtourolle d9e1e256e9 fix(auth): trim the username before authenticating
The login form guarded on `username.trim()` but sent the raw value, so a
trailing space from a soft keyboard reached the server verbatim. Jellyfin
reports that as an unknown user, which surfaces as a 401 indistinguishable
from a wrong password — the user is certain of their credentials and the app
insists otherwise.

Normalising in AuthManager rather than the form keeps it on the path every
caller uses, alongside normalize_url. Only surrounding whitespace is
stripped; interior spaces are legal in Jellyfin usernames.
2026-08-16 11:31:34 +02:00
dtourolle 42868fc2e6 feat(login): reveal-password toggle, and stop the keyboard editing credentials
Add an eye/eye-off button inside the password field so a typed password can
be checked against what was intended — the difference between "wrong
password" and "wrong keyboard" was previously invisible.

`bind:value` is not allowed alongside a dynamic `type`, so the field is wired
manually via value/oninput; unlike branching on two separate inputs, this
keeps focus and caret position when the toggle is pressed.

Both fields also get autocapitalize/autocorrect/spellcheck off and proper
autocomplete hints. The Android soft keyboard was free to capitalise or
autocorrect the username, which silently changes a credential the user
believes they typed correctly.
2026-08-16 11:31:27 +02:00
dtourolle c0c6c5023e fix(player): resume a transcoded video by seeking, not by asking for a stream that starts mid-item
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m30s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
A resumed transcode played nothing at all: every segment came back 400, hls.js
exhausted its retries and gave up, while the same episode from the beginning was
fine.

Jellyfin builds each segment URI by echoing the master playlist's query string
into it, and its segment handler opens by rejecting any request carrying
StartTimeTicks > 0 (ArgumentException → 400). So one resume position on the
playlist is copied onto every hls1/main/N.ts and 400s all of them — the `> 0`
being exactly why starting from the beginning survived.

HLS does not need the parameter: a playlist spans the whole item and asking for
segment N *is* the seek. It is removed from the URL builder entirely rather than
conditionalised — the builder cannot know whether its response will be
segmented — and the position becomes a seek issued once the player has loaded.
The progressive /Audio/universal builder behind the background-audio handoff has
no segments and keeps its StartTimeTicks, which is why audio-only handoffs
resumed correctly and video ones did not.

Completing that across the boundary, since the URL no longer starts where the
caller asked:

- reloadSource(url, position) now means "reload and resume AT this absolute
  position": it seeks the element once the source is playable and clears the
  transcode offset to zero. It previously set the offset to the position and
  seeked nothing, which was correct only while the URL itself began there —
  left in place it would have shown 20:00 on the scrubber while the opening
  titles played, with no seek ever happening.
- The transcoded resume path in the player page collapses into the same
  "seek after load" branch direct streams already used.
- VideoPlayer's background-audio return does the same: no base, seek to the
  absolute position.
- The stale test asserting StartTimeTicks is present is rewritten to keep its
  other half (an HLS master playlist, never a progressive stream.mp4, carrying
  the chosen source and audio track).

TRACES: UR-004, UR-005, UR-019, UR-021, UR-074 | DR-181 | UT-182, UT-183
2026-08-16 11:08:42 +02:00
dtourolle 521acc75fd build(android): add a side-by-side release build for validating R8
R8 has broken release APKs here before by stripping the JNI-loaded player
and security classes, and the only way to reproduce that was to build with
the real signing key and clobber the install you actually use.

`./scripts/build-and-deploy.sh release --device --debug` now builds a
fully minified release APK — exactly what ships — into the .debug
applicationId slot, signed with the local debug keystore:

  release            com.dtourolle.jellytau        0.5.5
  release --debug    com.dtourolle.jellytau.debug  0.5.5-debug-release
  debug              com.dtourolle.jellytau.debug  0.5.5-debug

It shares the applicationId *and* the signature with the plain debug
build, so the two replace each other cleanly rather than colliding, and
the versionName suffix says which is currently installed. No real key is
needed, so the side-by-side path deliberately skips
write-keystore-properties.sh.

The flag reaches Gradle as JT_SIDE_BY_SIDE=1. CI never sets it, and the
release manifest merges byte-identical without it — verified both ways
through processUniversalReleaseMainManifest.

deploy-android.sh and build-and-deploy.sh learned the flag too, since the
APK path is unchanged but the package to launch is not.
2026-08-16 10:42:59 +02:00
dtourolle 886cbcb29a docs(changelog): backfill every release, and date each fixed defect
CHANGELOG.md stopped at v0.5.0 and had gaps below it. Every tag from
v0.0.1 to v0.5.5 now has an entry, written from the commit bodies rather
than the subjects. Entries before v0.1.2 are shorter and marked as
reconstructed after the fact -- the commit messages of that era ("many
changes", "Playback fix") do not record causes.

docs/defect-windows.md is new: for each fixed defect, the releases it was
actually present in, with the evidence for the dating recorded per row so
a row can be disputed. Dated with `git log -S` on the defective token, not
by blaming the lines a fix removed -- that reliably lands on whatever last
touched the adjacent lines rather than on the defect's origin, and was
used only to shortlist.

Twelve defects date to the v0.0.1 proof of concept and shipped for seven
to eight weeks. They are not regressions but original assumptions nothing
exercised, four of them outright latent: the videoBitrate casing was
harmless until a quality picker existed to select against, and the
unconditional Range header was inert until that fix made transcoded
downloads actually transcode -- so DR-170's code dates to v0.0.1 while its
corruption window is the single release v0.5.1.

Three others are plumbing built and never connected: get_next_up_episodes
accepted a series_id with no caller until v0.3.0, the sync queue ran with
neither producer wired, and both watched-state backend halves sat unused.
No automated check sees these; the code is present, tested and reachable
in principle.

Also corrects the v0.5.5 entry. fa7cb6e9 and dcf08f30 are the same diff
off the same parent -- a local commit and its Gitea PR-merge twin -- and a
merge chain pulled the local one into master during v0.5.5. git log
v0.5.4..v0.5.5 therefore lists an autoplay fix that changed no file in the
release; nextEpisodeService.ts is byte-identical across the tag boundary.
That fix shipped in v0.0.2 and has not regressed. It is the one case where
reading the changelog off commit subjects would have produced a false
entry.

scripts/build-android.sh and src-tauri/src/repository/online.rs are also
modified in this tree by a concurrent session and are deliberately left
uncommitted.
2026-08-16 10:40:33 +02:00
dtourolle 2cc39cd7fd build(android): install the debug build alongside release as its own app
Testing a debug build meant uninstalling the real one first: same
applicationId signed with a different key is INSTALL_FAILED_UPDATE_
INCOMPATIBLE, so every experiment cost the app's settings, credentials
and offline cache.

The debug build type now carries applicationIdSuffix ".debug" and
versionNameSuffix "-debug", so it installs as com.dtourolle.jellytau.debug
("JellyTau Debug", 0.5.5-debug) with its own data directory — two
independent apps on one device.

Only the *application* id is suffixed. Kotlin classes stay in the
`namespace` package com.dtourolle.jellytau, so the JNI loadClass lookups
in player/android/mod.rs, the manifest <service> entry and the R8 keep
rules are untouched, and the FileProvider authority was already
${applicationId}-relative. Launcher names come from the appLabel /
activityLabel manifestPlaceholders rather than resValue, which would
collide with Tauri's generated strings.xml; release resolves them back to
@string/app_name and merges byte-identical.

deploy-android.sh reports the target package and explains an
UPDATE_INCOMPATIBLE failure instead of leaving it raw; logcat.sh takes a
debug|release argument (it was filtering on com.jellytau.app, a package
that has never existed) and attaches by pid when the app is running.

Verified: aapt2 badging on the built APK reports
com.dtourolle.jellytau.debug / 0.5.5-debug / "JellyTau Debug", and the
release manifest merge is unchanged.
2026-08-16 10:36:48 +02:00
dtourolle e457a9884c chore(release): 0.5.5 2026-08-16 10:23:38 +02:00
dtourolle de1c13e72f fix(player,reporting): report real positions, and count an audio-only episode as watched
Returning to the foreground before the background-audio stream had started
playing handed the frontend 0.0s, so the video reloaded at StartTimeTicks=0 —
the episode restarted from the beginning — and the stop report that followed
wrote that zero to Jellyfin as the resume point. Caught on device: locked at
18.4s, unlocked 3.5s later with ExoPlayer still IDLE.

The base that turns a handoff's relative timeline into the episode's is applied
once at the native tick boundary (DR-159), so before the first tick nothing has
applied it. The same blind spot covers webview-rendered media, where nothing is
loaded into the native backend at all and its position is a permanent 0 — which
is why 14 of 14 stop reports in a 35-minute trace were zeroes, one landing 40s
after the frontend had correctly reported 15:22 for the same episode.

- absolute_position(): the maximum of the backend's reading, the last position
  webview media reported, and the handoff base. Exact rather than heuristic —
  at most one term is ever meaningful, and the base is a floor the stream
  cannot physically be behind. duration() gains the same fallback.
- Withhold zero-position stop reports. A zero is never information, and
  Jellyfin stores the reported position as the resume point, so sending one
  only ever destroys a real one.
- Report progress from the controller's own position ticks, through the 30s
  throttler it already shared with the native audio path.
  /Sessions/Playing/Progress was previously requested zero times in 35 minutes.
- Report a finished audio-only episode stopped at its runtime before advancing,
  so Jellyfin's 90% rule marks it played. Nothing else can: the webview is
  suspended and its <video> was torn down at the handoff.
- Split the handoff by source — a downloaded file takes no base and a real
  seek, a stream keeps its StartTimeTicks base and no seek — and stop routing a
  downloaded handoff's absolute seek through the stream rebuild, which refuses
  a non-remote source outright.

Reports go through a PlaybackReportSink, which also collapses three copies of
spawn-a-task-and-hope into one and is what let each of these be written as a
failing test first.

TRACES: UR-005, UR-025, UR-040, UR-071 | DR-178, DR-179, DR-180 |
        UT-176, UT-177, UT-178, UT-179, UT-180, UT-181
2026-08-16 10:23:00 +02:00
dtourolle 5096c01960 fix(player): restore the subtitle sidecar work dropped by the previous commit
The previous commit was assembled from a tree read before 13264e22 landed,
so committing it reverted that commit's changes: the image-based subtitle
filtering in device_profile/types, subtitleTracks and its tests, the
regenerated bindings, and the VideoPlayer menu wiring.

Nothing was lost — the working tree held both changes throughout. This
restores those files to the merged state, leaving both the subtitle fix and
the play-session fix in place.

TRACES: UR-020, UR-004 | DR-176 | UT-168
2026-08-16 10:20:22 +02:00
dtourolle 2d67b0e4f5 fix(player): give every transcode its own play session, and stop the one it replaces
Switching bitrate mid-film stalled playback. The server served the new
playlist and then rejected its segments: 400 on hls1/main/0.ts, six times
over 25 seconds, never recovering, while the UI logged "Streaming quality
changed" as if nothing were wrong.

Jellyfin keys a transcode job by device and play session. Every stream URL
this app built carried the same hardcoded DeviceId and no PlaySessionId at
all, so the second stream for an item was indistinguishable from the first
and nothing ever stopped the old ffmpeg. Re-opening a stream is not rare —
a quality switch, a transcoded seek and an audio-track switch all do it.
Replayed against the server, a second stream opened for a live job's item
alternates per attempt between serving bytes and 400ing, which is why it
read as flaky rather than broken.

begin_video_play_session mints a session id per open and reports the one it
supersedes; the URL builder stops that job (DELETE /Videos/ActiveEncodings,
un-retried — a slow stop must not delay playback) before returning. Putting
it in the builder rather than in each caller covers every re-open path by
construction. adopt_video_play_session takes ownership of the job the server
starts itself when PlaybackInfo answers with a TranscodingUrl: without it the
first switch on a stream has nothing to stop and collides with what is
playing.

Two client faults made the same incident worse and go with it:

- The fatal-HLS-error handler added the transcode seek offset to a position
  that already included it. Past roughly the halfway mark of a film the
  doubled value cleared the "near end" threshold, so any transient network
  error was reported as end-of-stream and autoplay skipped to the next item
  — precisely when a quality switch had just made the offset large. The
  decision now lives in hlsRecovery.ts, against the absolute position.
- The HTML5 reload primitive resolved on its own canplay timeout, so a
  reload the server never served reported success. The picker showed a
  quality that was not playing and the caller had nothing to revert.

TRACES: UR-074, UR-004 | DR-177 | UT-173, UT-174, UT-175
2026-08-16 09:47:27 +02:00
dtourolle 13264e225b fix(player): never let the server burn a subtitle in, and never offer one we cannot draw
Reported as "subtitles are shown even when off", and no toggle in the app
cleared them — because they were not the app's subtitles at all. The server was
painting them into the video.

`PlaybackInfo` omitted `SubtitleStreamIndex`, which does not mean "none": the
server then honours the source's own default/forced flag. On the reported
episode that default is a PGS track — a bitmap, which cannot go out as a
sidecar — so the server fell back to `SubtitleMethod=Encode` and composited it
onto every frame. Confirmed against the live server, which answered the same
PlaybackInfo request two ways: with the index omitted it returned
`SubtitleStreamIndex=2` + `SubtitleMethod=Encode` and a
`SubtitleCodecNotSupported` transcode reason, and its ffmpeg command carried
`[0:2]…[sub];[main][sub]overlay_qsv=…`; with `-1` it selected no subtitle stream
at all. The cost landed on the video, not the subtitle: burn-in rules out
remuxing, so a stream that only needed its audio transcoded was re-encoded frame
by frame.

Three parts:

- The negotiation asks for `SubtitleStreamIndex=-1` and advertises every text
  format we can render (srt/subrip/ass/ssa/vtt) as `External`.
- The stream URL says the same thing, because the negotiation is not what opens
  most streams: a quality switch, a transcoded seek and an audio-track switch
  each rebuild the URL on their own, and an omitted index there lets the server
  pick the default track back up out of whatever session state it still holds.
- The picker offers only subtitles the app can actually draw. Each subtitle
  stream now crosses the boundary carrying `supports_external_delivery`, decided
  in Rust where the codec vocabulary belongs, and `None` for anything that is
  not a subtitle so a `false` cannot be misread as a verdict.
  `subtitleStreamsOf()` drops the rejected ones — and since that one function
  feeds the menu, the `<track>` children and the native play request alike, a
  bitmap track disappears from all three without its URL ever being fetched.
  Only an explicit "no" hides a track; a stream carrying no verdict behaves
  exactly as before.

Nothing is lost by refusing burn-in: the app already fetches the text tracks and
draws them itself (UR-020), so the server's composited copy was always
redundant. Image-based tracks are consequently not offered, which is honest
rather than a regression — the renderer cannot composite a bitmap, and the old
behaviour paid for them by making the whole stream unwatchable.

Tests were written first and observed failing: the Rust one would not compile
against a field that did not exist, and the frontend one resolved a URL for the
PGS track it was supposed to drop.

Carries with it the in-flight per-stream `PlaySessionId` work in online.rs,
whose hunks sit inside the same request builder and could not be separated from
these.

TRACES: UR-020, UR-004 | DR-176 | UT-168
2026-08-16 09:47:09 +02:00
dtourolle 041969f446 fix(player): stop the server burning subtitles into the picture
A transcoded episode stalled every few seconds and seeking took five to
nine seconds to produce a frame. Neither was a seek bug: both seeks in the
capture landed correctly. The stream itself could not keep up.

The episode was HEVC video, E-AC-3 audio, and a PGSSUB subtitle track.
Only the audio needed transcoding — the device profile supports HEVC and
the server would have remuxed the video untouched. But the PlaybackInfo
request omitted SubtitleStreamIndex, and omitting it does not mean "no
subtitles": the server then honours the source's default/forced flag and
picks a track itself. It picked the PGS one. PGS is a bitmap, and the
profile advertised only srt/vtt as External, so it could not go out as a
sidecar — leaving SubtitleMethod=Encode, burn-in.

Burn-in is a video cost, not a subtitle cost. Compositing rules out
remuxing, so the whole HEVC stream was re-encoded to h264 frame by frame.
The server could not sustain that in real time: the buffer never grew past
one segment and playback ran waiting -> HLS error -> canplay -> three
seconds of picture, indefinitely, while each seek restarted the encoder
from scratch. TranscodeReasons named it — SubtitleCodecNotSupported — but
nothing in the log connected that to the stall, so the diagnostic now says
which track it is declining and why.

Ask for SubtitleStreamIndex=-1 explicitly, and advertise every text format
we can render (srt/subrip/ass/ssa/vtt) as External so a subtitle can only
ever arrive as a sidecar. Nothing is lost: the app already fetches subtitle
tracks itself and draws them over the video (UR-020), so the server's
composited copy was always redundant. Image-based tracks are consequently
not offered, which is honest rather than a regression — the renderer cannot
composite a bitmap, and the previous behaviour paid for them by making the
stream unwatchable.

The policy lives beside the other device-profile rules in Rust, where it is
testable without a device.

TRACES: UR-020, UR-004 | DR-176 | UT-168
2026-08-16 09:23:39 +02:00
dtourolle 1a9805f0f3 fix(downloads): queue the whole album, and make every queued track findable offline
An album download put a handful of its tracks on the device while the button
reported the album as downloaded. Two independent gaps, one shared cause.

- `download_album` read its track list from `items WHERE album_id = ?` — the
  local catalog cache. Jellyfin does not return `AlbumId` on every listing
  endpoint, so tracks cached from one of those sit in `items` with a NULL
  `album_id` and are invisible to that query. On the reported database three
  whole albums (18, 12 and 9 tracks) had it NULL on every track; a partially
  linked album queued only the linked subset.
- The frontend then resolved one stream URL per track from its own list and
  paired it with the returned row ids by position. The ids came back in the
  backend's `index_number` order over a different set of rows, so a row could
  be handed another track's URL and any track past the end of the shorter list
  was never started. On Android that loop also stopped wherever the webview was
  suspended.
- `album_id` is what `OfflineRepository::get_items` joins a track to its album
  on, so a track that did download stayed invisible under its album offline —
  the same missing link seen from the other side.

The operation now belongs to Rust end to end:

- `HybridRepository::get_album_tracks` asks the server what the album contains.
  Cache-first `get_items` is right for browsing and wrong for deciding what to
  download; it errors offline so the caller falls back to the ungated local
  catalog, keeping the queue-while-offline flow.
- `queue_album_tracks` writes the album link onto every track it queues, and
  creates an `items` row for tracks the cache has never seen.
- Stream URLs resolve here, through the existing reconnect resolver, now scoped
  to the rows just queued so one album cannot start every unrelated pending row.
  Only the album id crosses the IPC boundary.
- `album_file_names` gives each track its own file. A title repeated inside one
  album (deluxe edition, two discs) mapped to one path, so those downloads
  overwrote each other.

Re-tapping download on a broken album heals it: missing tracks are queued and
the tracks already on disk get their link.

`download_series`/`download_season` still derive their episode lists from the
cache the same way and want the same treatment.

DR-173, UT-170..172. Rust 673 tests, frontend 975 tests, svelte-check and
check:boundary clean.

Note: this tree is shared with a concurrent session. Only the files above are
committed; docs/traceability.md is left to be regenerated once that work lands.
2026-08-16 09:20:32 +02:00
dtourolle 82b6982d68 fix(player): use a speedometer icon for the streaming quality selector
The bitrate ceiling button reused a cloud-download glyph, which read as a
download action rather than a bandwidth setting.
2026-08-16 08:25:43 +02:00
dtourolle 3363ff7f08 Merge branch 'master' into worktree-mosaic-library
# Conflicts:
#	scripts/extract-traces.test.ts
2026-08-16 00:51:46 +02:00
dtourolle 9858b7cb92 chore(release): 0.5.4
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 2m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m37s
Traceability Validation / Check Requirement Traces (push) Successful in 26s
Build & Release / Run Tests (push) Failing after 5m56s
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-16 00:46:17 +02:00
dtourolle f46d7bf676 fix(player): make native Android video opt-in again — it shipped as audio with no picture
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 4m55s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Traceability Validation / Check Requirement Traces (push) Successful in 20s
DR-161 flipped experimentalNativeVideo on by default so picture-in-picture could
shrink a real video surface. On a device that shipped sound with a blank screen.

The decode path was never at fault. Logcat shows ExoPlayer running and feeding a
live SurfaceView with an active BufferQueue. The compositing was: the SurfaceView
sits behind the WebView, and the step that clears the opaque layers above it
never took effect — `WebView transparent = false` is logged, `= true` never
appears. The video was rendering correctly the whole time, behind an opaque page.

This is precisely the defect the flag existed to contain;
VideoPlayer.scrubRegression.test.ts had already recorded that "the native
SurfaceView has never been visible through the webview". Enabling it by default
shipped a verified decode path on top of an unverified display path.

Reverting costs nothing that matters: PiP does not depend on it — DR-160 drives
PiP from the WebView <video> — and working video outranks PiP showing a native
surface. The flag stays in Settings, now described as incomplete rather than as a
performance win, so anyone helping test it still can.

Fixing the compositing is the prerequisite for trying this default again (DR-172).
2026-08-16 00:42:56 +02:00
dtourolle 74bffea650 Merge branch 'fix/autoplay-issues'
Records ancestry only: all three of its changes are already on master,
content-identical, having been applied by cherry-pick rather than merge —
the reportMediaId snapshot in VideoPlayer, the `?restart=true` hand-off in
nextEpisodeService, and the POSIX-sh rewrite of the traceability CI loop.

The branch is 167 commits behind, so the files it touched conflicted with
their own newer selves; every conflict resolved to master's version. The
resulting tree is byte-identical to the pre-merge tree.
2026-08-16 00:14:25 +02:00
dtourolle 7e1f0e0547 Merge branch 'master' into worktree-mosaic-library
# Conflicts:
#	docs/traceability.md
2026-08-16 00:06:15 +02:00
dtourolle 99ceeadb83 docs: regenerate the traceability matrix
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m7s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m26s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Failing after 5m5s
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 generated matrix had drifted well behind the code — this pass picks up
DR-171/UT-166 along with everything else that had accumulated since it was last
run, which is why the diff is large for a mechanical regeneration.

Coverage 87% (265/303), no orphaned IDs, comfortably above the workflow's 50%
floor. No hand edits: `bun run traces:markdown` output as-is.
2026-08-16 00:04:58 +02:00
dtourolle e015c4c9b1 Merge branch 'master' into worktree-mosaic-library
Renumbers the mosaic's requirement IDs out of the way of the download work
that landed on master in parallel: it had already claimed DR-163/DR-164 and
UT-162, so the mosaic layout is now DR-172, the library favourites scope
DR-173, and its composition test UT-167.

Note for the download branch: its UT-162..UT-165 rows trace to DR-163..DR-166,
none of which are defined in requirements.md — that branch defined DR-167..171
instead. Those references are orphaned and want a look; nothing here touches
them.
2026-08-16 00:04:26 +02:00
dtourolle 7387f35c7e docs(player): correct the stale "native video defaults to off" comments
DR-161 made `experimentalNativeVideo` default to on, but three comments still
described the pre-flip world and one of them was load-bearing:

- `nativeVideo.ts` labelled the store "Default off" directly above a `load()`
  that returns true when nothing is stored.
- The two PiP comments explained themselves as "what makes PiP work in the
  shipping configuration", which stopped being true when Android started
  shrinking the real ExoPlayer surface. They still describe the Linux path and
  the flag-off case, so they say that instead.
- `video_audio_codecs` justified its narrow codec list with "video does not play
  through ExoPlayer", which is no longer so on Android. The narrow list is still
  right, for a different reason now recorded: the flag is a user setting and a
  download outlives it, so only the intersection holds on both sides of the
  switch. DR-171 carries the same caveat.

No behaviour change.
2026-08-16 00:00:10 +02:00
dtourolle 0861523015 feat(library,home): lay libraries out as a mosaic, with favourites per category
The library overview and the home shortcut strip showed artwork of three
different shapes — square music covers, 16:9 library backdrops, 2:3 posters —
in grids that pick one box and crop everything to it. The home strip said so
in a comment: it forced `aspect="video"` on music libraries so the row would
line up, which lined it up by cutting the covers down.

Both surfaces are now justified mosaics: rows share one height and each tile is
as wide as its own artwork. `layoutMosaic` is a pure module — it packs tiles
until the height needed to fill the container drops to the target, justifies the
row by absorbing the rounding remainder into its widest tile, and deliberately
leaves the last row unstretched so one leftover tile does not inflate into a
banner. The component supplies only what the DOM knows: the measured container
width, and the artwork's *decoded* aspect ratio (via a new `onNaturalSize` on
CachedImage), committed in one debounced batch so the grid does not reshuffle
once per image as artwork lands.

Favourites gain a tile per category beside the library it belongs to, alongside
the existing cross-library entry. Which collection type maps to which category
is Jellyfin vocabulary, so it is derived in Rust — `SearchScope::for_collection_type`,
stamped onto every `Library` by a new constructor and carried over as an optional
`favoritesScope`. Deriving it in Svelte would have rebuilt the exact leak
`SearchScope::item_types` was extracted to close. A category shows one tile
however many libraries share it, and a library kind favourites do not carve up
(Live TV, channels, books) gets none.

Also corrects the requirements-count test, which the UR-074 commit left one
behind.

Spec: docs/specs/library-mosaic.md
TRACES: UR-075, UR-067 | DR-163, DR-164 | UT-158..UT-162
2026-08-15 23:57:09 +02:00
dtourolle ac4fccd499 fix(downloads,playback): re-encode undecodable audio and carry the media source through
Work from a parallel session in the same working tree, committed here so the
branch is not left half-written. Attribution note: authored in a concurrent
Claude session, not by the author of the preceding commit.

- DR-171: a downloaded video keeps audio the device can actually decode.
  `original` quality asked for a straight copy, so an E-AC-3/AC-3/DTS/TrueHD
  track came down untouched and the webview had nothing to play it with.
- `get_video_download_url` gains the media source, so the URL is built against
  the source actually chosen rather than the item's default.
- Device profile and repository plumbing updated to match.

Verified green as a whole: 656 Rust tests, 945 frontend tests, svelte-check clean.
2026-08-15 23:54:00 +02:00
dtourolle a5535f2941 fix(downloads): stop libraries mixing, make pause/resume real, reap partials, end bitrate corruption
Four defects behind "downloads still flaky", each with its own cause.

Libraries mixed their media (DR-167). Cached items carry no link back to their
library — library_id and parent_id are NULL on every row — so the library branch
of get_downloaded_items matched `EXISTS (SELECT 1 FROM libraries WHERE id = ?)`,
which asserts only that the library exists and never constrains the item to it.
Opening any downloaded library listed every downloaded top-level item on the
server: films under Music, albums under TV. The query deciding which libraries
appear already had the right rule, so the two disagreed about the same question;
that collection_type <-> item_type mapping is now one constant used by both.

Pause and resume did nothing (DR-168). pause_download wrote status = 'paused'
and stopped there — no cancellation existed anywhere in the download stack, so
the streaming task ran on and overwrote the row with completed/failed when it
finished. The row flicked to "paused" and undid itself. resume_download had the
mirror defect: it flipped the row to 'pending' without pumping, and the pump is
not a poller, so a resumed download sat until some unrelated event pumped the
queue. Adds a per-download stop flag the worker reads between chunks and on
retry, returning Stopped — not retryable, not recorded as a failure, and the
.part file is kept because that is what the resume continues from. Registering
returns a fresh flag so a resumed download does not inherit the pause that
stopped it. Cancel and clear_stale_downloads signal it too, so neither deletes a
file still being written.

Partial files were never reaped (DR-169). The worker named its sidecar with
with_extension("part"), which replaces: movie.mp4 became movie.part. Every
cleanup path deleted "{file_path}.part" — movie.mp4.part. They never matched, so
the partial of every cancelled or failed download stayed on disk forever,
invisible to disk-usage totals because no row pointed at it. One partial_path
helper now serves the writer and the cleaners.

Bitrate downloads corrupted themselves (DR-170). Only `original` asks for
Static=true; every other rung requests a transcode, which Jellyfin serves
chunked with no Content-Length and cannot byte-seek — it ignores Range and
answers 200 with the whole stream, not 206 with the tail. The worker sent the
header whenever a .part existed and appended the body regardless, so each retry
concatenated another full copy onto what was on disk. The file grew past its
real size and would not play, which is why bitrate downloads stayed broken after
the videoBitRate casing fix corrected the request. resume_offset now lets the
response decide: append only on 206, otherwise truncate and take it from the top.

docs/requirements.md also carries DR-171/UT-166, written by a parallel session
working in the same tree; its code lands separately.
2026-08-15 23:52:02 +02:00
dtourolle d49d027020 docs(player): allocate UR-074/DR-162 for the streaming bitrate cap
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 5m10s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
The feature shipped tagged against DR-160, which a parallel session had
claimed for picture-in-picture in the meantime. Renumbered to DR-162
across the Rust and frontend TRACES comments (the PiP tags in
VideoPlayer.svelte, pictureInPicture.ts and nativeVideo.ts keep DR-160)
and regenerated bindings.ts.

Adds the requirement rows the tags point at: UR-074 for the user need, and
DR-162 covering why the cap has to reach the PlaybackInfo negotiation and
not only the transcode URL, why the ceiling is process-wide, and why the
Settings default persists while the in-player override does not. Notes
that this gives UR-070 its resume-at-the-same-point mechanism while the
server-offered rendition list that requirement also asks for stays
proposed. UT-156/157 record what the tests pin.

docs/specs/streaming-bitrate-cap.md carries the layer assignment — the
step definitions, the video/audio split, the resolution pairing and the
reload decision are all Rust; the frontend holds a serde token and the
labels it was handed.

TRACES: UR-074 | DR-162 | UT-156, UT-157
2026-08-15 16:39:53 +02:00
dtourolle 9c352fdb77 Merge branch 'fix/android-versioncode-floor' 2026-08-15 16:35:27 +02:00
dtourolle dda2ff86a3 feat(player): cap streaming bandwidth with a user-chosen bitrate ceiling
Video streams were opened at a fixed allowance nobody could change:
MaxStreamingBitrate=20000000/VideoBitrate=18000000 on the HLS transcode
URL, 20 Mbps in the PlaybackInfo negotiation, and a 999999999 device
profile that let the server direct-play a source of any size. On a
metered or slow connection there was no way to spend less.

StreamingQuality is a ladder of bandwidth ceilings — Original, 20/10/8/
4/2/1 Mbps and 720 kbps — where a step bundles the total ceiling, the
audio share of it and the resolution that budget can carry. Those
numbers are Jellyfin encoding vocabulary, so they live in Rust and the
frontend only names a variant; labels and details come back over IPC
from player_get_streaming_qualities, the same arrangement as the EQ
presets.

The cap has to reach the *negotiation*, not just the transcode URL:
max_static_bitrate in the device profile is what makes the server refuse
to direct-play a file fatter than the cap, and without it a 30 Mbps
remux is handed over untouched and every URL parameter downstream is
moot. So it is applied at all four places that decide bandwidth — the
HLS URL builder, PlaybackInfo, the Live TV stream, and the
background-audio handoff (which takes the lower of the cap and its own
384 kbps). Video bitrate is the total minus the audio share so the two
together honour the ceiling rather than overshooting it.

The ceiling is process-wide rather than a repository field: it is a
preference about this device's connection, must survive a repository
rebuilt on re-login, and every URL builder plus the negotiation have to
agree on it or the cap leaks. Same shape as INCLUDE_CATALOG_BROWSE.

Two ways in. Settings holds the durable default, persisted to
app_settings and restored at startup — unlike the rest of VideoSettings,
because a limit set for a metered connection that silently reverts to
uncapped on the next launch spends the user's data with no changed
setting to see. The in-player menu is the "this film, this connection"
override: a cap is a property of the stream the server is producing, so
it cannot apply to one already in flight — player_set_stream_quality
re-opens the stream at the new quality and resumes at the current
position, reloading the native backend itself and handing HTML5 a URL
for the same reloadSource primitive the audio-track switch uses.

Tests pin the URL parameters at a capped and an uncapped step, the
handoff taking the lower of the two, the ladder's internal consistency
(video + audio == cap, resolution descending with bitrate) and the
persisted token's round trip. The ceiling is process-wide, so the tests
that depend on it serialise on a guard that restores the default.

TRACES: UR-074 | DR-160 | UT-156, UT-157
2026-08-15 16:34:56 +02:00
dtourolle 8ad3dc5c4f fix(android): raise the versionCode floor so 0.5.x can install over v0.5.2
v0.5.2 shipped Android versionCode 5002, from an earlier `minor*1000` scheme.
The `minor*100` formula that replaced it yields only 1502 for that same version,
and 1503 for 0.5.3 — lower than what is already installed, so Android refuses
the update as a downgrade. Every 0.5.x release built from this script was
un-installable for anyone already on v0.5.2.

This is the exact failure the block was written to prevent; its floor simply
went stale. The floor tracked "codes below 1000 are already in the field", which
was true when written, but a 5002 build has shipped since — and the highest code
this formula has *produced* is not the same as the highest code in the field.

Widen the multipliers and raise the floor past 5002:

    code = 10000 + major*1000000 + minor*1000 + patch

    0.0.14 -> 10014    0.5.2 -> 15002    0.6.0 -> 16000
    0.1.0  -> 11000    0.5.3 -> 15003    1.0.0 -> 1010000

Still strictly monotonic across the upgrade sequence. The guard test gains a
case pinning 0.5.3 above the 5002 in the field, so the floor is expressed as
"clears what shipped" rather than a literal that can silently go stale again.
2026-08-15 16:31:38 +02:00
dtourolle 9f5f57cba4 fix(ui,player): scroll restore, immersive fullscreen, watched toggle, handoff timeline, PiP
Batch of reported bugs and enhancements.

UI
- Pages no longer inherit the previous page's scroll position (DR-156, UR-072).
  The shell keeps its scrollers alive across navigation by design, so the
  element never remounts and its scrollTop survived the route change; SvelteKit
  restores window scroll, which this app never uses. ScrollMemory records the
  offset per route and per container: forward moves reset to the top, Back
  restores where the route was left.
- Season header stacks on narrow screens, and the title span gets min-w-0 so it
  actually truncates instead of overflowing under the action buttons.
- Favourites gets a labelled tile at the head of the library grid rather than
  only an unlabelled heart icon in the header.

Playback
- Full-screen video on Android hides the system bars (DR-157, UR-066).
  requestFullscreen() cannot touch the Activity window from inside a WebView, so
  the control did nothing visible while the bars stayed painted over the video.
  ImmersiveModeBridge hides them, restored on exit, Escape and teardown.
- Background-audio handoff stops leaking its relative timeline (DR-159).
  background_audio_base was a display-only correction applied in two places
  while progress reports to Jellyfin, the frontend and media3's own seeks all
  worked in the relative timeline treating it as absolute — each crossing losing
  exactly `base` seconds. The conversion now happens once, in the position tick,
  and inbound seeks resolve through seek_absolute, which re-opens the stream at
  the requested position because the handoff transcode cannot seek.
- Picture-in-picture works on the path that actually plays video (DR-160).
  canEnterPip demanded a native ExoPlayer surface, but that path is behind a
  flag defaulting to off, so PiP could never engage. It now accepts the WebView
  <video> too, keeping the WebView visible and routing play/pause to the element.
- Native video is now the default so PiP has a real surface (DR-161). The
  scrub-regression tests pinned the flag-off path implicitly; they now mock it
  off explicitly. The native scrub/seek path is not covered by the suite and
  needs device verification.

Watched state
- Watched toggle on the episode row, season header, series and movie hero, and
  the Episode Focus View (DR-158, UR-073). Both backend halves already existed
  with no caller. storage_set_watched covers a container's episodes so the
  toggle is honest offline, and QueuedOp::MarkUnplayed gives the sync queue the
  missing direction.

Release
- Fix the Android versionCode floor (set-version.sh). v0.5.2 shipped code 5002
  under an earlier minor*1000 scheme, but the current minor*100 formula yields
  1502 for that version and 1503 for 0.5.3 — so every 0.5.x release built from
  it was an un-installable downgrade for anyone already on v0.5.2. Widened to
  10000 + major*1000000 + minor*1000 + patch (0.5.3 -> 15003).
- Bump to 0.5.3.
2026-08-15 16:26:31 +02:00
dtourolleandClaude Opus 5 50934e2ac6 ci(android): ship Gradle in the builder image instead of downloading it
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 10m7s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m28s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
Build & Release / Run Tests (push) Successful in 7m35s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 3m2s
Build & Release / Build Linux (push) Successful in 20m0s
Build & Release / Build Windows (push) Successful in 8m36s
Build & Release / Build Android (push) Successful in 30m30s
Build & Release / Create Release (push) Successful in 21s
The release APK job died at the Gradle wrapper step, after the 11-minute
Rust compile had already succeeded:

    Downloading https://services.gradle.org/distributions/gradle-8.14.3-bin.zip
    java.net.SocketException: Unexpected end of file from server

`tauri android init` regenerates gen/android with a wrapper pointing at
services.gradle.org, so every Android job re-downloaded ~130MB of Gradle at
build time. That is slow on a good day and a hard build failure when the CDN
drops the connection mid-transfer. It was also a standing violation of the
rule that every build tool must already live in the builder image.

Dockerfile.builder installs Gradle 8.14.3, keeping both the unpacked
distribution (on PATH) and the original zip under /opt/gradle/dist. A
`gradle --version` smoke-test fails the image build on a bad version rather
than letting CI discover it.

sync-android-sources.sh then repoints the regenerated wrapper at that local
zip, which is the established place for fixing up the generated project.
It parses the version the wrapper actually requests, so a future Tauri Gradle
bump logs "not in image, will download" instead of pointing at a missing
file. On dev machines /opt/gradle/dist does not exist and the properties file
is left untouched.

Verified by running the project's own wrapper jar inside a network namespace
with no connectivity: it resolved and unpacked the local zip to 100% and
proceeded into build-script evaluation.

Note: this is inert until the builder image is rebuilt and pushed
(scripts/build-builder-image.sh).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 07:47:43 +02:00
dtourolleandClaude Opus 5 8fbc080733 Merge branch 'fix/android-resume-position'
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 8m2s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m34s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 2m58s
Build & Release / Run Tests (push) Successful in 8m22s
Build & Release / Build Linux (push) Successful in 19m40s
Build & Release / Build Windows (push) Successful in 8m6s
Build & Release / Build Android (push) Failing after 13m33s
Build & Release / Create Release (push) Skipped
Resume-playback fixes across the three layers where the position was lost:

- DR-150 path: the Android native (ExoPlayer) surface never applied the
  resume seek, so resume always played from the start on device.
- DR-154: a stop-report the server could not be told about was logged and
  dropped, even though sync_queue and its drain were built and running.
- DR-155: the server's watch position was never mirrored into the local
  user_data row the resume check reads, so resume never crossed devices.

Also carries concurrent fixes merged in from parallel work: download
bitrate, series resume ordering, Recently Added grouping, remote-session
volume handoff, and home library card heights.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:14:44 +02:00
dtourolleandClaude Opus 5 ba5fd55204 fix(sync): mirror the server's watch position so resume crosses devices (DR-155)
The resume check reads the local user_data row and nothing else, but
mirror_user_data -- the only path by which server UserData lands in that
table -- mirrored is_favorite alone, and returned early whenever that
field was absent, which is exactly the shape of an ordinary watched
episode. playback_position_ticks was therefore write-only from this
device's perspective: watch 40 minutes in a browser, open JellyTau, and
it resumed from whatever this device last saw, or offered no resume at
all. Same user-visible symptom as the Android bug fixed earlier on this
branch, from an unrelated cause -- which is why resume read as broadly
flaky rather than as one defect.

The mirror now carries the position alongside the favourite flag under
the same pending_sync = 0 conflict rule, so a local position still
waiting to be pushed is never pulled backwards by a server that has not
yet heard where we got to. COALESCE(excluded.x, user_data.x) keeps the
stored value for a field the server omitted rather than nulling it, and
a row with neither field is still skipped rather than fabricated as
zeroes.

Mirroring alone was not sufficient. get_item -- the call the player route
makes -- returned the cached copy on a hit and never consulted the
server, so for an already-cached item the mirror never ran. It now
refreshes in the background on a cache hit via race_with_refresh, the
reusable form of what get_items already did inline. That asymmetry is
why browsing a season picked up other devices' state while opening the
episode directly did not. The refreshed value lands for the next read;
the cache-first race still answers immediately.

The DR/total counts in extract-traces.test.ts are updated for DR-154 and
DR-155 -- that edit is the test's intended signal that the CI gate's
denominator is live rather than frozen.

Verified red->green in the jellytau-builder image: both new tests failed
before the fix. Full Rust suite passes (634), cargo fmt clean, clippy
adds no new warnings; frontend suite (933) and svelte-check clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 22:14:20 +02:00
dtourolle fec4b7ae8c Merge branch 'fix/download-bitrate-param' into HEAD 2026-08-12 20:11:12 +02:00
dtourolleandClaude Opus 5 2ca2174cea fix(player): return volume control to the local speaker when a remote session stops
Stopping a remote session left Android stuck on the remote volume slider
with no way back to the device speaker.

Two causes:

1. `player_stop`'s remote branch sent "Stop" to the session and returned
   without touching the playback mode, so the manager stayed in Remote.
   It now drops to Idle, mirroring what the local branch already does.

2. Volume routing was torn down at a single call site
   (`transfer_to_local_inner`), so every *other* exit from remote mode
   leaked the Android VolumeProviderCompat. Routing is now derived from
   the transition inside `set_mode`: entering remote attaches control,
   any exit from remote hands it back to the local media stream. This
   also covers the frontend `disconnect()` path (Remote -> Idle) and the
   local-playback-start paths (Remote -> Local).

Adds a `RemoteVolumeControl` trait so the routing rule is unit-testable
off-device — the real implementation is Android JNI. Tests cover
remote->idle, remote->local, remote->remote (re-arms, never releases),
and that local/idle transitions leave routing untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:09:17 +02:00
dtourolleandClaude Opus 5 0ca2857c3a fix(catalog): show a new album once in Recently Added, not once per track
Recently Added listed every newly-added track individually, so importing a
14-track album filled the whole row with that one album and buried everything
else. Both code paths that build the row had the same symptom from separate
causes:

- Online: Jellyfin's /Items/Latest defaults to GroupItems=false, returning each
  new leaf on its own. Send GroupItems=true so the server collapses children
  into the container that was added.
- Offline: the downloaded-items CTE deliberately matches leaves *and* their
  container (right for browsing, wrong here), so a downloaded album returned the
  album plus each of its tracks. Drop a leaf only when its own container is in
  the same result.

Items with no container (movies, standalone tracks) are unaffected in both
paths. The online URL is extracted into build_latest_items_endpoint so it can be
asserted without an HTTP server, matching build_favorites_endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 20:07:57 +02:00
dtourolleandClaude Opus 5 1f32e4040b Merge branch 'fix/home-library-card-heights' from origin
Local and remote had both advanced two commits from 3619f71 with no
overlapping files:

  remote: uniform card heights; resume after furthest-watched episode
  local:  Android native-path resume; queued watch-position sync (DR-154)

Merged cleanly with no conflicts. The series_progress policy tests pass
against the merged file (19/19).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:21:18 +02:00
dtourolleandClaude Opus 5 e4632bb2b2 fix(sync): queue a watch position the server could not be told about (DR-154)
sync_queue and its drain (DR-131) were built, tested and running, but the
stop-report path never fed them, so closing a video while the server was
unreachable lost the resume point outright.

HybridRepository::report_playback_stopped is a bare pass-through to the
online repository ("Playback reporting goes directly to server"), and on
failure the error surfaced to a frontend catch whose own comment read
"Server error - could queue, but for now just log". Both producers that
would have queued it -- PlaybackReporter::queue_for_sync in Rust and
syncService.queuePlaybackProgress on the frontend -- have no callers on
the playback path. user_data.pending_sync was dutifully set to 1, but
nothing drains that flag for positions the way favourites do (DR-120).

The command layer now enqueues a report_playback_stopped row whenever the
push fails; the existing drain already parses and replays that operation.

The pending row for an item is superseded in place rather than appended
to: progress is reported every 10s, so a server that stays down would
otherwise add a row per tick, all obsoleted by the newest -- the
unbounded queue DR-131 exists to prevent. Only pending/failed rows are
superseded, since reviving an abandoned row restores that same growing
counter. Queueing is best-effort and never fails the command: the local
position is already saved, so a failed queue write must not be reported
as a lost position.

Verified red->green in the jellytau-builder image: the four new tests
failed to compile (enqueue_playback_stopped not found) before the fix.
Full Rust suite passes (627 tests), cargo fmt clean, clippy adds no new
warnings; frontend suite (933) and svelte-check also clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 19:18:11 +02:00
dtourolleandClaude Opus 5 2d50744320 fix(player): resume at saved position on the Android native path
The native (ExoPlayer) video path never applied the resume position, so
"resume from where you left off" always played from the start on Android.

Two layers each assumed the other did the seek:

- The only code acting on `initialPosition` was handleCanPlay, an HTML5
  <video> event handler. The native path has no <video> element, so
  `canplay` never fires and that seek never ran.
- NativePlayerAdapter.load() had an initialPosition branch, but it only
  recorded the number, claiming "the native backend performs the actual
  seek internally". It does not: PlayItemRequest carries no start
  position, and loadWithMetadata -> prepare() always starts ExoPlayer at 0.
- VideoPlayer never called adapter.load() at all, so even that branch was
  unreachable.

The frontend therefore believed it had resumed (the seek bar showed the
resume point) while ExoPlayer played from the beginning.

NativePlayerAdapter.load() now issues the backend seek, excluding live
streams (no resume point; seeking knocks the HLS window off its live
edge). VideoPlayer calls it on the native branch and marks the initial
seek as performed so the existing $effect does not fire a duplicate.
The HTML5 path is untouched: seeking before metadata is clamped to 0,
which is exactly what handleCanPlay waits for.

Verified red->green: the new test failed with "Number of calls: 0"
before the fix. Full frontend suite passes (933 tests); svelte-check
and check:boundary are clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:51:37 +02:00
dtourolleandClaude Opus 5 adc460f35d fix(downloads): honor the selected bitrate (videoBitRate, capital R)
Downloading at a specific quality silently returned the full-size
original. The download URL builder spelled the transcode params
`videoBitrate`/`audioBitrate`, but Jellyfin binds `videoBitRate`/
`audioBitRate` — with a capital R.

Query-key binding is case-insensitive, so this is not a casing
preference: the lowercase-r form is a different token that fails to
bind. The server discards it without error and then stream-copies the
source, so picking "480p" produced an original-quality file with no
failure surfaced anywhere. `maxHeight`/`videoCodec` were unaffected
(case-insensitive binding covers them), which is why the height cap
applied while the bitrate cap vanished.

Also set `allowVideoStreamCopy=false` on the transcode presets to force
a real re-encode. Video stream-copy is gated by `allowVideoStreamCopy`,
not `enableAutoStreamCopy` — the latter governs audio only.

`original` is unchanged: it stays a deliberate direct static copy, now
pinned by a test.

The pre-existing unit tests asserted the broken lowercase-r spellings,
so they passed against broken code; corrected. Verified red -> green by
extracting the pre-fix and post-fix builder bodies into an isolated
harness: 15 assertion failures before, 0 after.

TRACES: UR-071 | DR-123

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:44:54 +02:00
dtourolleandClaude Opus 5 9d7cb085e9 fix(series): resume after the furthest-watched episode, not the first gap
`pick_current_episode` rung 3 returned the first unwatched episode in
series order. A viewer who skipped the pilot but is three seasons deep
was sent back to S1E1: the gap was a deliberate skip, not the place they
stopped.

This read as flaky rather than consistently wrong because rung 3 only
fires when the server's Next Up (rung 2) yields nothing, and
`resolve_current_episode` swallows that call's errors with
`.unwrap_or_default()`. `HybridRepository::get_next_up_episodes`
delegates unconditionally to the online repo, so any unreachable-server
moment silently degraded to the empty vec — same series, same watch
state, different answer depending on one request's outcome.

Rung 3 now scans the ordered list from the end with `rposition(is_played)`
and returns the episode after the furthest-watched one, falling back to
the previous first-unwatched behaviour when nothing is watched or the
series is finished. Season crossing comes free from the already-flat
series ordering, and `season_rank` keeps specials last so a watched
special cannot mark a show finished.

Tests written first and confirmed failing (S1E1 where S3E4 was
expected), covering the skipped-pilot case, rolling into the next season
past a skipped episode, and the watched-special case. All 17 existing
tests still pass.

Note: cargo test could not run locally (javascriptcoregtk-4.1 /
webkit2gtk-4.1 absent on this host). The pure policy half plus its
verbatim test module were extracted into a standalone crate to get real
red/green; the full crate suite still needs a run on a complete
toolchain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:23:26 +02:00
dtourolleandClaude Opus 5 85bd227714 fix(home): uniform card heights in the Your Libraries row
MediaCard derives its artwork aspect ratio from the item, so a music
library rendered aspect-square (144px tall at w-36) next to video
libraries at aspect-video (81px), leaving the home row ragged.

Add an optional `aspect` prop that overrides the derived ratio, and pass
aspect="video" from the home Libraries strip. Unset, behaviour is
unchanged, so the /library overview grid and the media carousels keep
their per-type ratios. Artwork already uses object-cover, so square music
art crops rather than distorts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 18:13:27 +02:00
dtourolleandClaude Opus 5 3619f71aba build: make the git tag the single source of truth for the version (DR-153)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 6m55s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m21s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 7m36s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 2m57s
Build & Release / Build Linux (push) Successful in 20m4s
Build & Release / Build Windows (push) Successful in 8m42s
Build & Release / Build Android (push) Successful in 30m30s
Build & Release / Create Release (push) Successful in 17s
The version lived in four files — package.json, tauri.conf.json, Cargo.toml and
Cargo.lock — that had to be hand-edited in lockstep, and the release workflow
rewrote exactly one of them. A tagged build therefore produced an installer
named for the tag wrapped around package metadata naming the previous release,
and the Linux job, which had no version step at all, shipped whatever happened
to be committed.

scripts/set-version.sh now writes all four from one argument and is the only
thing that does. Every release job calls it with the tag, including the Linux
job that was missing one. The committed versions become a placeholder for dev
builds rather than something to maintain by hand.

The Android versionCode moves into the same script, unchanged in formula
(1000 + major*10000 + minor*100 + patch). It stays inline-documented because the
reasoning is not obvious: builds already in the field shipped code 1000, and
Android refuses an update whose code is lower than the installed one, so a
formula that can emit a smaller number for a newer release bricks updates
irreversibly. UT-150 asserts that property directly — monotonic across an
upgrade sequence, and always above the floor.

Two edge cases the previous inline version got wrong:

- A prerelease tag (v0.6.0-rc1) made $(( 0-rc1 )) abort the step under set -e.
  The suffix is stripped before the arithmetic; the manifests keep it.
- CI passes "${GITHUB_REF#refs/tags/}" unconditionally, which on a branch build
  is still a full ref. That reached the validator verbatim and would have failed
  every untagged Android build; a non-tag ref now falls back to git describe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:21:58 +02:00
dtourolle 8e081845d0 Merge pull request 'Feat/android native video' (#13) from feat/android-native-video into master
Reviewed-on: #13
2026-08-11 19:03:45 +00:00
dtourolleandClaude Opus 5 5fa74d9e34 docs: renumber to DR-150/151/152 after rebase onto master
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 7m56s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 24s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 2m49s
master landed DR-148 and DR-149 for unrelated audio-decode work (0.4.7/0.4.8)
while this branch was in flight, and both sides claimed the same two IDs. The
native-video requirements move to DR-150 (native rendering behind the flag),
DR-151 (the severed SurfaceView attach chain) and DR-152 (capabilities reported
by Rust). UT-090 was likewise already taken by the seek-bar test, so the adapter
selection test moves to UT-149 and is registered in the table.

The spec header also cited DR-023/DR-024, which are the subtitle and audio-track
selection UI requirements — unrelated to this work. Corrected, with a note so the
wrong IDs are not reintroduced from the draft.

extract-traces.test.ts asserts the live requirement counts on purpose, so adding
three DRs moves DR 144→147 and total 282→285.

Subtitles on the native path are not a regression from this branch: master's
6a712c4 already fixed the root cause (MediaItem.subtitles was hardcoded to
vec![], so ExoPlayer always received zero SubtitleConfigurations) and that fix is
now underneath these commits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:57:58 +02:00
dtourolleandClaude Opus 5 c480276a97 docs(spec): native video confirmed working on device
The spike's central question — can a SurfaceView be composited behind a
transparent Tauri WebView on Android — is answered yes, verified on a physical
device. No upstream issue blocked it and none demonstrated it; this appears to
be the first working instance.

Marks DR-148 done behind the flag and records what is confirmed versus what is
still open: playback and positioning are verified, but the individual native
controls (seek, audio-track, subtitle), the mini-player transition, and the
MediaCodec hardware-decode claim are not yet each measured. The mini-player
transition is called out as the known gap, since it is the one case where the
fullscreen assumption behind "no rect plumbing needed" does not hold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:57:58 +02:00
dtourolleandClaude Opus 5 ca490c34ec docs(traceability): regenerate matrix for the native-video requirements
DR-148/149/150 now resolve; coverage 86% (243/283), no orphans.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:57:58 +02:00
dtourolleandClaude Opus 5 e144e62b31 feat(player): render Android video natively behind a transparent webview (DR-150, DR-151, DR-152)
Rust already reported `use_html5_element: false` on Android, but two frontend
overrides threw that answer away, so ExoPlayer's video path had never actually
run. Both are lifted behind an `experimentalNativeVideo` opt-in (default off).

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, and promoting there would be a black screen.

Two blockers the spec did not anticipate, both in code assumed to be merely
unreachable rather than broken:

- `JellyTauPlayer.setActivity()` had zero callers, so `currentActivity` was
  always null and `autoAttachSurface()` bailed. 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, whatever the webview did.
  This also revives PiP on the video path, which gated on the same flag.
- `createAdapter()` was not the real gate; it is never called in production.
  The actual override was in VideoPlayer.svelte, which forced HTML5 and stopped
  the native backend `player_play_item` had just started. Both sites now route
  through `createAdapter()`.

Compositing needs two independent opaque layers cleared, not one. Clearing only
the page leaves the WebView widget opaque — audio over a black picture, exactly
the symptom the old INTERIM comment described. `videoSurface.ts` toggles both:
the widget background and window drawable from Kotlin, the page backgrounds via
a `data-native-video` attribute keyed by app.css. Transparency lives in
`tauri.android.conf.json` so Linux keeps an opaque window, and is scoped to the
playback session so the launcher never shows through the rest of the app.

Phase 3's rect plumbing turned out to be unnecessary: video is fullscreen on the
player route, and `fitSurfaceToScreen()` already letterboxes and re-fits on
rotation. The mini-player transition remains unverified on device.

Also removes the `navigator.userAgent` sniffing in webviewAudio.ts, which was a
second copy of the Rust cfg gate free to drift from it. `player_get_capabilities`
now reports `usesWebviewAudio` and `supportsNativeVideo` from those same gates.

Tests: adapter selection covers the full matrix, including the regression guard
that the flag off beats Rust. Written first and confirmed failing (2 of 7) before
the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 20:57:58 +02:00
dtourolle 07d10dfed7 docs(traceability): land the DR-149 requirement rows and settle a UT collision
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 19m31s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m28s
Traceability Validation / Check Requirement Traces (push) Successful in 22s
Build & Release / Run Tests (push) Successful in 6m47s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m6s
Build & Release / Build Linux (push) Successful in 19m57s
Build & Release / Build Windows (push) Successful in 14m14s
Build & Release / Build Android (push) Successful in 30m26s
Build & Release / Create Release (push) Successful in 19s
The DR-149 row lost an index race with a parallel session's edit of the same
file, so the previous commit carried the count assertion (DR 144, total 282)
without the requirement it counts — a clean checkout of that commit failed
`bun run test` against its own requirements.md.

The parallel session also reached UT-143 and UT-147 for subtitle work, which
collided with the UT-143 used for the client-side transcode tests. Those move
to UT-148, in the table and in the device_profile TRACES comments, so no two
requirements share an ID.
2026-08-11 20:11:22 +02:00
dtourolle acddcdd6fa fix(playback): force a transcode when the webview cannot decode the audio (DR-149, 0.4.8)
Advertising a webview-shaped profile (DR-148) was necessary but not
sufficient. Probing the server directly showed Jellyfin 10.11.5 enforces a
DirectPlayProfile's Container and VideoCodec — excluding either returns
SupportsDirectPlay:false with TranscodeReasons=ContainerNotSupported /
VideoCodecNotSupported — but ignores its AudioCodec entirely: an E-AC-3
track is still offered for direct play against a profile listing only
aac,flac,mp3,opus,vorbis. Neither a VideoAudio CodecProfile forbidding the
codec nor MaxAudioChannels:2 against a 6-channel track changes the answer,
so no profile the client can send fixes this and the picture plays silent.

The client therefore stops delegating a question it can answer itself. The
negotiated source's audio is checked against what the webview decodes, and
an undecodable track forces the existing h264/aac HLS transcode regardless
of the server calling direct play 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 that would be served —
the default, else the first — since a supported track further down is not
the one that plays. A source with no audio, or a codec the server did not
name, is left alone rather than transcoded on a guess.

Test-first: the new tests failed against the old behaviour before the
decision existed. Verified on a motorola edge 30 by the audio HAL, not by
ear — the same E-AC-3 episode logged isMusicActive=true once and 58
ACDB-LOADER lines under this build, against 0 and 0 on 0.4.6, where an AAC
file in the same session produced 16 and 116. No FATAL EXCEPTION, so R8 on
the signed release build is unaffected.

Also carries in-flight subtitle-track work authored in a parallel session
(subtitleTracks, VideoPlayer, player/media, bindings) at the user's
request, so the tag matches the APK verified on device.
2026-08-11 20:07:11 +02:00
dtourolle 6a712c46cb fix(player): send subtitle tracks to ExoPlayer on Android (UR-020)
Selecting a subtitle on Android did nothing. The Kotlin side has been
complete for a long time — JellyTauPlayer.load() parses a subtitles JSON
array into MediaItem.SubtitleConfigurations and setSubtitleTrack() drives a
TrackSelectionOverride — but nothing ever reached it.

VideoPlayer built the list and then threw it away: it resolved every
subtitle stream's URL into a subtitleTracks array and the
commands.playerPlayItem({...}) call two lines below passed only streamUrl,
title, id, videoCodec and needsTranscoding. PlayItemRequest had no subtitle
field to put them in, so create_media_item hardcoded subtitles: vec![],
android/mod.rs serialized "[]" across JNI, and every MediaItem reached
ExoPlayer with zero SubtitleConfigurations. A later set_subtitle_track then
found no text track groups and logged "Invalid subtitle track index".

PlayItemRequest now carries the tracks (defaulted, so the background-audio
handoff and next-episode callers are unchanged) and create_media_item
threads them onto the MediaItem.

Serialization: SubtitleTrack is reused verbatim rather than given an
IPC-specific twin, and deliberately keeps snake_case. The same struct feeds
two consumers that both spell mime_type — the JNI JSON that
JellyTauPlayer.load() reads with optString("mime_type"), and the generated
binding the frontend types against. camelCasing it would not fail the build
or the IPC; Kotlin would silently fall back to its default MIME type for
every track. UT-146 asserts the exact serialized keys so a future
rename_all cannot pass unnoticed.

The index mapping was NOT already correct. setSubtitleTrack(n) indexes
ExoPlayer's filtered text track groups, i.e. the position of the sideloaded
configuration — but the menu passed its own {#each} row number, which counts
every subtitle *stream*, including ones whose URL failed to resolve and were
therefore never sideloaded. One failed URL and every track below it selected
the wrong subtitle. The position is now looked up in the exact array that
was sent (nativeSubtitleArrayIndex), and a stream that was never sent maps
to "off" rather than to a guessed position.

The resolution loop also reuses resolveSubtitleTracks() from the Linux fix
instead of duplicating it, which fans the URL requests out in parallel
rather than awaiting them one per stream before playback can start. The
awaits are safe where they sit: the native-mode pitfall is about Svelte
lifecycle calls after an await, and nothing is registered here — the
background-audio subscriptions above still run synchronously.

No Kotlin change was needed.

Tests (UT-145, UT-146, UT-147) were written first and failed: PlayItemRequest
had no subtitles field to compile against, nativeSubtitleTracks and
nativeSubtitleArrayIndex did not exist, and the playerPlayItem call carried
no subtitles key.

TRACES: UR-020 | IR-016, JA-008 | UT-145, UT-146, UT-147
2026-08-11 20:03:19 +02:00
dtourolle 211792947d fix(player): render subtitle tracks on the Linux HTML5 path (UR-020)
Selecting a subtitle on Linux did nothing. VideoPlayer rendered no <track>
children at all — the block was commented out as "temporarily disabled to
debug playback issues" (it has been that way since the POC) — so
Html5PlayerAdapter.selectSubtitle() walked an empty textTracks list and the
menu, which is built from media.mediaStreams, was purely decorative.

The reason it had to be disabled is still visible in the dead markup:
getSubtitleUrl() is async, so src={getSubtitleUrl(track.index)} bound a
Promise to the attribute and every track pointed at "[object Promise]" — an
unloadable resource hanging off the media element.

Subtitle URLs are now resolved off the render path into component state
(subtitleTracks.ts), and only streams whose URL actually resolved are
rendered; a per-track failure drops that track instead of emitting a dead
src. data-stream-index is kept, since that is what the adapter matches on.

Subtitles stay OFF unless the user asks for them: the server's isDefault flag
is shown in the menu but is never promoted to a selection, and the `default`
attribute is deliberately not emitted. A <track default> auto-shows, so the
menu would open on "Off" while subtitles were burned over the picture, and
every user who never wanted subtitles would suddenly get them. That matches
the existing initial state (selectedSubtitleIndex = null).

Selection and rendered tracks are reconciled whenever the list changes: a
selection that no longer resolves collapses to "Off", and a surviving one is
re-applied after the new <track> elements exist. "Off" disables every text
track, as before.

Cross-origin text-track fetches use the media element's CORS setting, so the
element opts in with crossorigin="anonymous" — but only for an http(s)
stream, never for a local/offline file:/asset: source, where forcing CORS
onto the video fetch could break playback. It is keyed on the subtitle stream
count, known at first render, so the attribute cannot flip under an in-flight
media load.

Android/native is untouched: the ExoPlayer branch still goes through
player_set_subtitle_track.

Tests (UT-143, UT-144) were written first and failed against the old markup:
the commented-out block, the Promise bound to src, and the default attribute.

TRACES: UR-020 | DR-023 | UT-143, UT-144
2026-08-11 19:25:39 +02:00
dtourolle 2c3955914e fix(playback): advertise only webview-decodable audio for video (DR-148, 0.4.7)
The audio codec list sent to Jellyfin comes from MediaCodecList, which
describes ExoPlayer — but video does not play through ExoPlayer. Android
force-renders every video in the webview <video> element (the interim
override in VideoPlayer.svelte) and Linux always has, and Chromium/WebKit
decode a far narrower set than the platform does.

A motorola edge 30 ships /vendor/etc/media_codecs_dolby_audio.xml, so it
reported ac3,eac3; the server 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 rather
than the lack of it, which is why a Fairphone and an Honor tablet play the
same file on the same build: without the Dolby decoder they never claim the
codec, so the server transcodes to AAC. Confirmed by A/B on the failing
device — hevc+eac3 silent, hevc+aac audible, same session, same profile,
same direct-play path, audio codec the only variable.

video_audio_codecs narrows the platform list to the webview-decodable set
for the video direct-play profile only. Audio-only playback really is the
native player's, so that profile keeps the full list rather than
transcoding music that plays perfectly well. A list with nothing decodable
still claims aac, since a profile claiming 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.

Test-first: the tests failed against the old behaviour before the filter
existed, including the case built from the phone's real codec list. The
requirement-count assertion in extract-traces.test.ts moves 280 -> 281 for
the added DR, which is the deliberate edit that test exists to force.

Not yet verified on device — the 0.4.7 APK was still building.
2026-08-11 19:13:46 +02:00
dtourolle 1b70926c36 feat(offline): play downloaded video, and drain the offline sync queue (0.4.6)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 20m34s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m6s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 20m26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m3s
Build & Release / Build Linux (push) Successful in 37m59s
Build & Release / Build Windows (push) Successful in 23m0s
Build & Release / Build Android (push) Successful in 40m26s
Build & Release / Create Release (push) Successful in 1m20s
Bundles this session's work plus the concurrent search/offline/player changes.
Every gate passes on the combined tree: 885 frontend tests, 610 Rust tests,
clippy clean, boundary clean, trace coverage 86%.

Offline video playback — four separate defects, each of which alone stopped it:

  DR-133  A completed download's file_path is already absolute (the worker
          rewrites it on completion), but the player rooted it a second time and
          handed the webview /data/user/0/app//data/user/0/app/videos/x.mp4.
  DR-134  The asset protocol was never enabled: no protocol-asset feature and no
          assetProtocol config, so convertFileSrc produced URLs nothing answered.
          Also silently defeated the cached-thumbnail path, which fails soft to
          the server copy and hid it whenever the server was reachable.
  DR-137  Tauri's asset protocol answers a range-less request by reading the
          whole file into memory, and only advertises Accept-Ranges from inside
          its range branch, so the first request never learns ranges exist.
          Chromium gave up with PIPELINE_ERROR_READ after ~31s. Local media is
          now served by a loopback HTTP server: bounded 4 MiB chunks streamed
          from the file handle, every response length-delimited, and a range-less
          request answered with one chunk rather than the file. Confined by a
          per-session token and to the app data directory, because loopback is
          shared between apps on Android.
  DR-138  Release builds set usesCleartextTraffic=false, so Android rejected the
          request to that server before any I/O. A network-security-config
          exempts 127.0.0.1 only; a remote server must still be HTTPS.

Downloads:

  DR-135  download_item never records media_type and the reconnect resolver read
          that NULL as 'audio', so a movie queued from a media card had its URL
          resolved by get_audio_stream_url and completed as an audio-only
          transcode. The item's own type now decides.
  DR-136  Rows already downloaded that way are requeued on reconnect, since
          prevention alone leaves them reading "downloaded" and still unplayable.

Known limitation: a download taken at `original` quality is a byte copy of the
source, so it can be any container. One such file is an AVI holding XVID, which
the webview cannot play in any case — the media server serves it correctly and
Chromium refuses it. That needs either a transcoded download preset or the
native ExoPlayer surface work, and is not addressed here.

Also fixes two ID collisions between concurrent work: DR-143 defined twice
(search vs offline gate) and UT-131 defined twice (Episode Focus hero vs channel
cap). The search requirement is now DR-147 and the channel-cap test UT-141, with
their code references and matrix rows updated.
2026-08-09 16:38:07 +02:00
dtourolle 7b531a40be fix(player): pick a decodable track in the no-audio fallback (DR-146)
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.

Scan the groups for the first isTrackSupported track and override to
that. Also clear setTrackTypeDisabled(TRACK_TYPE_AUDIO), since audio may
equally have been off at the type level, which an override alone does not
undo. When no group holds a supported track, log it as an error — the
server was expected to transcode — instead of leaving a silent video with
no explanation in the log.

Verified by compiling :app:compileArm64DebugKotlin. Not unit-tested: this
tree has no Kotlin test source set, as noted in the previous commit.
2026-08-09 15:07:18 +02:00
dtourolle 19bc265a8d fix(player): do not start a video before audio focus is granted (DR-145)
Video manages audio focus by hand (handleAudioFocus=false, since
ExoPlayer's automatic handling is reserved for the audio path), and all
three outcomes of the request were treated as success. AUDIOFOCUS_
REQUEST_DELAYED — which setAcceptsDelayedFocusGain(true) explicitly
invites, and which means the system is withholding our audio until it
calls back — and an outright REQUEST_FAILED were logged and then followed
by playWhenReady = true. The picture rolled with no sound, which to the
user is indistinguishable from a broken stream.

Hold playback when focus is not granted and start it from the
AUDIOFOCUS_GAIN callback. An explicit play() re-requests focus instead of
resuming into a stream the system is still muting, guarded by a
held-focus flag so repeated plays do not leak focus requests. LOSS clears
the pending flag so an unrelated later GAIN cannot start playback the
user never asked for.

Verified by compiling :app:compileArm64DebugKotlin. Not unit-tested: this
tree has no Kotlin test source set (the Gradle project lives in the
generated, gitignored gen/ tree), so the logic cannot be exercised off
device without restructuring the Android build.
2026-08-09 15:06:49 +02:00
dtourolle cc7f1cece0 fix(player): play downloaded video offline (DR-133, DR-134)
Offline video never started: the <video> element reported NETWORK_NO_SOURCE
one millisecond after loadstart, which the UI mislabelled as "may need
transcoding" even though nothing had been fetched. Two independent causes,
both required for playback.

The path was doubled. `downloads.file_path` is stored relative to the storage
root while a download is queued, but the worker rewrites it to the absolute
path it actually wrote once the transfer completes — so a completed row is
already rooted. The player's offline branch rooted it a second time, producing
/data/user/0/app//data/user/0/app/videos/x.mp4. Audio was unaffected because it
resolves the same column through Rust's resolve_local_media_path, which does
not re-root. The join is now absolute-aware (POSIX, Windows drive letters, UNC)
so rows written before completion still resolve.

The asset protocol was never enabled. convertFileSrc rewrites a path to
http://asset.localhost/… unconditionally, but Tauri only answers that origin
when the protocol-asset cargo feature is compiled in *and*
app.security.assetProtocol.enable is set — neither was, so even a correct path
resolved to nothing. This also silently defeated the cached-thumbnail path in
imageCache, which fails soft to the server copy and so hid the breakage
whenever the server was reachable. Scoped to $APPDATA/** — the storage root
holding the database, downloads/ and the thumbnail cache — rather than an
unrestricted grant.

Diagnosed from logcat on device; UT-124 reproduces the doubled path.
2026-08-09 15:05:16 +02:00
dtourolle a53042fe80 fix(playback): bound the device profile by the audio route's channels (DR-141)
MediaCodecList answers "can this device decode 5.1", which is not the
question that decides whether the user hears anything: a phone decodes an
AC-3 5.1 track happily and still has two channels to play it out of. The
DeviceProfile carried no MaxAudioChannels, so Jellyfin was free to
direct-play the multichannel track to a two-channel sink — silence or
dialogue folded into surround channels that go nowhere, depending on the
device.

Report media3 AudioCapabilities.maxChannelCount for the current route over
JNI alongside the codec lists, and bound the direct-play and transcoding
profiles (and the HLS URL's TranscodingMaxAudioChannels, previously
hardcoded to 2) by it. No codec is ever removed, so a device with genuine
surround output keeps direct-playing it. A missing or zero reading means
"route not yet established", not "no audio", and falls back to stereo.
2026-08-09 15:01:57 +02:00
dtourolle db520c6551 fix(playback): stop pinning the video stream as the audio track (DR-140)
Jellyfin's MediaStream.Index is global across every stream in a media
source, so index 0 is the video stream on virtually all files. We sent
AudioStreamIndex=0 as "the first audio track" on the HLS transcode URL,
the background audio-only handoff URL, the direct-play fallback URL and
the PlaybackInfo negotiation body — asking the server to use the video
stream as audio. Servers that honour it produce a picture with no sound;
only those that silently correct the index hid the bug, which is why it
surfaced as "some videos have no audio".

Omit the parameter unless a track was actually chosen, so the server
resolves the source's DefaultAudioStreamIndex. An explicit selection from
player_switch_audio_track still passes through unchanged. Dropped
outright from the static=true direct-play URL, which serves the original
file untouched.
2026-08-09 14:47:03 +02:00
dtourolle 1ef6180776 chore(release): bump to 0.4.1
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 18m39s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m40s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Successful in 6m1s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 9m26s
Build & Release / Build Linux (push) Successful in 19m41s
Build & Release / Build Windows (push) Successful in 13m57s
Build & Release / Build Android (push) Successful in 30m6s
Build & Release / Create Release (push) Successful in 24s
2026-08-05 12:31:16 +02:00
dtourolle 878ac5fa59 fix(player): make lockscreen transport reach background audio (DR-097)
Pausing from the lockscreen did nothing while a video's audio played in
the background. The handoff starts native ExoPlayer audio and only then
tears the WebView <video> down, and that teardown fires a DOM `pause`
the frontend reports like any other — leaving html5_playing = Some(false).
Transport therefore stayed aimed at the element: the lockscreen pause
emitted a ControlCommand into a <video> that no longer existed while the
native player carried on.

The controller now tracks a background-audio handoff explicitly. Entering
one hands transport authority to the native backend and drops the dying
element's state/position/media-loaded reports, which also stop flipping
the UI to paused and dragging the position backwards. Exiting restores
the element as the player.

A lockscreen pause also has to survive the return to the foreground: the
video used to resume from a snapshot taken at handoff time, undoing the
pause on the way back in. shouldResumeOnForeground() lets an explicit
`paused` from the player override that snapshot.

TRACES: UR-040, UR-005 | DR-052, DR-097
2026-08-05 12:26:25 +02:00
dtourolle 6aaa80ff92 chore(release): bump to 0.4.0
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 17m21s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m46s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Successful in 5m38s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 19m22s
Build & Release / Build Windows (push) Successful in 13m55s
Build & Release / Build Android (push) Successful in 30m12s
Build & Release / Create Release (push) Successful in 15s
2026-08-04 20:22:01 +02:00
dtourolle f7bcfe521d fix(favorites): save server favourites through to the cache (DR-115)
The hybrid favourites read went straight to the online repository on a
cache miss and dropped the result on the floor. Every other read path
persists what it fetches, so this one made the favourites page re-query
the server on every visit — and left the offline mirror (DR-114) empty on
a fresh install, since this is the path that fills it.

It now goes through get_favorites_server_only, which saves through on the
way back.

The command had a matching hole: with nothing cached it returned the empty
result, painting "Nothing favourited yet" at a viewer whose favourites
were simply marked on another client. It now asks the repository for a
real answer instead of an empty state it would correct a round trip later.

TRACES: UR-067 | DR-115
2026-08-04 20:20:36 +02:00
dtourolle 30dc3ba7f6 fix(player): recover a failed stream on Linux instead of stopping (DR-130)
A recoverable player error meant "playback is over": the frontend's error
handler stopped the player unconditionally, so a wifi blip killed the
track. Android already decides in its JNI callback, but MpvBackend is
constructed before PlayerController exists, so its event thread has no
controller to ask.

So MPV reports the failure and the frontend echoes it into the new
player_recover_stream command — the same shape as PlaybackEnded ->
player_on_playback_ended, keeping the decision in Rust. The command
re-opens the stream where it stopped, with the existing attempt budget
and backoff, and returns whether it handled it; only a false answer
falls through to the old stop path.

Android now reports the errors it has already declined as
*unrecoverable*, so the echo never asks the same question twice.

TRACES: UR-004, UR-040 | DR-130 | UT-117
2026-08-04 20:15:28 +02:00
dtourolle 32f8de5c91 fix(player): survive a flaky stream, and don't read 0:00 at EOF
Two MPV-side fixes for the same failure story — a wifi blip during
playback.

The demuxer gave up the moment a read failed and MPV raised
EndFile(ERROR), so a momentary outage killed the track outright. Enabling
ffmpeg's reconnect options handles the common case entirely below our
level, so most outages never reach the recovery path at all. Set
non-fatally: their availability varies with the libmpv/ffmpeg build, and
losing resilience is not a reason to refuse to play anything.

Separately, `time-pos` and `duration` are live properties of the *loaded*
file: at EOF MPV unloads it and both stop resolving. Reading them straight
through returned 0.0/unknown at exactly the moment end-of-file handling
needed to know where playback had reached, so the player appeared to
rewind to 0:00 as a track ended. `ObservedTime` records the last reading
seen while media was loaded and the accessors fall back to it.
2026-08-04 19:39:14 +02:00
dtourolle 62873cab3d feat(search): answer search from a local index; tier downloads by lifetime
Search's instant leg read only downloaded items, so with no downloads it
returned nothing and every keystroke fell through to a full Recursive=true
server query. It now reads the whole synced catalog through the same
availability CTE get_items uses, gated on the same include_catalog_browse
flag so search and browse cannot diverge. (UR-065, DR-108)

Also fixes three defects found while confirming that:

- items_fts grew by a full duplicate index every catalog pass. INSERT OR
  REPLACE fires no AFTER DELETE trigger without recursive_triggers, so the
  old index row was orphaned, and a TEXT PRIMARY KEY meant the replacement
  took a fresh rowid and inserted a second entry. Now a real upsert, with
  migration 021 rebuilding existing indexes. (DR-110)
- DELETE FROM items existed nowhere, so server-side deletions never
  propagated. Adds a post-crawl mark-and-sweep, scoped to crawled types,
  skipping downloaded items, and refusing to run after a partial crawl
  because items.parent_id cascades. (DR-110)
- The index omitted MusicArtist, Playlist and People, which search groups
  results by. Adds them plus people_fts (migration 022). (DR-111)

Re-indexing moves from a frontend startup call to a Rust background task
with a 6h TTL, so a long session no longer searches a stale catalog and a
restart no longer forces a crawl regardless of freshness. (DR-109, IR-030)

Downloads gain a lifetime tier. Eviction selected every completed row by
age with no download_source filter, so hitting the storage limit deleted
the oldest download -- typically one saved deliberately for offline -- to
make room for a precached track. It now reclaims only 'auto' rows, and
expired ones are reclaimed first, before live cache is evicted.
(DR-126, DR-127)

Downloaded video and audio-only handoffs now play from disk instead of
streaming; the video path had never consulted downloads at all. No
transcode is involved: MPV runs video=no and ExoPlayer has no surface for
an Audio item. (DR-123 in part, DR-128)

FTS queries are built as quoted phrases so apostrophes, hyphens and
slashes are data rather than operator syntax, and the item-type filter is
bound rather than interpolated.

Specs: docs/specs/catalog-index-search.md,
docs/specs/read-through-media-cache.md

Includes concurrently-developed favourites browsing and background-audio
stream-end handling; the two workstreams share offline.rs, lib.rs and
online.rs, so no subset of files builds independently.
2026-08-04 17:35:17 +02:00
dtourolle c55ff45692 fix(android): clear the system bars and display cutout (UR-066)
The bottom nav rendered under the Android navigation bar, and full-screen
playback controls spilled into unusable screen edges. It looked device-specific
(Motorola bad, Fairphone fine) but every device was equally unpadded — only the
intrusion differed: a tall opaque 3-button bar swallows the nav, a thin
translucent gesture pill overlaps harmlessly.

None of the app's safe-area handling was ever active, for two independent
reasons:

  1. app.html had no `viewport-fit=cover`, so every `env(safe-area-inset-*)`
     resolved to 0px — the padding in app.css and BottomUi was a no-op.
  2. Android WebView maps only the *display cutout* into `env()`; the status bar
     and navigation bar are never reported. With enableEdgeToEdge() and
     targetSdk 36 (enforced from 35, opt-out ignored from 36) the WebView always
     spans them, so CSS could not learn about them by any route.

WindowInsetsBridge now reads `systemBars() | displayCutout()` and publishes
`--jt-inset-*` CSS custom properties, both pushed on every inset change
(rotation, nav-mode switch, PiP) and pullable via `AndroidInsets.get()` — the
pull is required because the first inset pass lands before the document exists
and a page load wipes the pushed inline style. app.css folds them with `env()`
via `max()` into `--safe-*`, the only thing components may pad from.

Exactly one element owns each edge: the shell takes top/left/right, BottomUi
takes bottom (inside its surface box, so the colour extends behind the gesture
bar), and shellReservesBottomInset hands bottom back to the shell on routes with
no bottom UI. The full-screen players inset their control layers only, leaving
video and artwork edge-to-edge.

The theme's `fitsSystemWindows=true` claimed the opposite of what actually
happened — overridden at runtime, ignored at this target SDK — and is removed.

Also converts six nested `h-screen`/`min-h-screen` boxes to `h-full`: the shell
is `h-screen` *and* inset-padded, so its content box is `100vh - safe-top` and
any nested 100vh box overflows by exactly the inset (the library column would
have clipped its own BottomUi). A test guards against reintroduction.
2026-08-04 14:45:10 +02:00
dtourolle 58f2506966 feat(series): land on the current episode, not season 1 (UR-062, UR-063, UR-064)
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m59s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m36s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 5m9s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m54s
Build & Release / Build Linux (push) Successful in 18m49s
Build & Release / Build Windows (push) Successful in 14m4s
Build & Release / Build Android (push) Successful in 30m17s
Build & Release / Create Release (push) Successful in 18s
Opening a series dumped the viewer at the top of season 1, and its Play
button played nothing at all: it resolved `$libraryItems[0]` — the first
*season* by SortName — and navigated to `/player/<seasonId>`, which the
player route bounced straight back to `/library/<seasonId>`.

The backend could already answer "where is this viewer in this show":
`repository_get_next_up_episodes` has accepted a `series_id` since it was
written and no caller had ever passed one.

Backend (DR-101, DR-106)
- `repository/series_progress.rs`: `pick_current_episode` — in progress,
  else Next Up, else first unwatched, else the premiere. The third rung is
  the offline path, where Next Up is always empty. `sort_series_order` puts
  specials (season 0) after the numbered seasons.
- `repository_get_series_episodes` takes over the season fan-out and the
  flat-series fallback, which were domain knowledge living in the frontend.
- `clear_watch_history` maps to Jellyfin's mark-unplayed (recursive over a
  container, also zeroes resume). Offline it refuses rather than diverging
  state the next sync would undo.

Frontend (DR-102, DR-103, DR-104, DR-107)
- Seasons collapse; only the current one is expanded, and the current
  episode is badged and scrolled into view.
- Hero button reads `Resume S2E4` / `Play S1E1` and opens that episode's
  focus view, where Play commits (ux-flows §5B.5).
- Seasons are no longer a destination: `/library/<seasonId>` redirects to
  `/library/<seriesId>#season-N`, and every inbound link follows.
- The "More Episodes" strip spans the whole series, so a season finale
  offers the next premiere instead of dead-ending (§5B.2).
- Clear-history buttons on the series hero and each season header.

Routes (DR-105)
- `/library/tv` and `/library/movies` absorb their all-titles and genres
  pages as `?view=` tabs; the four legacy routes redirect. 6 video routes
  become 2, and `/library/shows/genres` stops being the odd one out.

Logic extracted to `seriesNavigation.ts`, `episodeStrip.ts` and
`libraryView.ts` so it is unit-tested rather than buried in components.
Spec: docs/specs/series-current-episode-navigation.md
2026-08-03 20:37:43 +02:00
dtourolle a818fee297 fix(player): re-entering a video no longer opens the audio player (DR-100)
Leaving a video and returning to it rendered the movie/episode in
AudioPlayer. Closing a webview-rendered video deliberately emits no
"stopped" state (that would break the autoplay handoff), and the
direct-play path does not stop the backend on unmount, so the Rust
controller still reported that item as its loaded media. Re-entering the
route therefore took the "already playing, just show the UI" shortcut,
which returns before a stream URL is fetched, and the render fell
through to the audio surface. Mostly visible on Android, where video
direct-plays; Linux transcodes and stops the backend on unmount.

Both decisions move into playerSurface.ts as pure functions:
shouldReuseActivePlayback excludes video, so video always takes the full
load path and gets its stream URL and resume position;
resolvePlayerSurface maps video-without-a-stream-URL to "pending"
(spinner) rather than falling through to audio.
2026-08-03 18:12:37 +02:00
dtourolle a26a853f01 fix(player): advance background audio-only episodes in the backend (UR-040)
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m33s
Traceability Validation / Check Requirement Traces (push) Successful in 25s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 17m25s
Build & Release / Run Tests (push) Successful in 6m7s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 8m38s
Build & Release / Build Linux (push) Successful in 19m23s
Build & Release / Build Windows (push) Successful in 13m43s
Build & Release / Build Android (push) Successful in 29m47s
Build & Release / Create Release (push) Successful in 19s
An episode played audio-only while the app was backgrounded stalled at the
episode boundary instead of advancing, and ExoPlayer parked in STATE_ENDED —
where any later play intent (lockscreen, headset, Bluetooth reconnect) replays
the ended item, surfacing as the episode randomly restarting.

End-of-playback is dispatched from two places and they disagreed. The Android
JNI callback carried the background-audio branch but can never reach it:
load_and_play sets EndReason::NewTrackLoaded at every load and nothing clears
it, so the first real end consumes it and the decision is always Stop. The call
that actually decides is the frontend's echo of the resulting PlaybackEnded into
player_on_playback_ended — and that path had no background-audio case at all, so
it started a countdown whose advance is a webview goto() that cannot start audio
while backgrounded.

Both dispatchers now share PlayerController::auto_advance_to_next_episode, so
they cannot drift apart again.

The handoff base offset moves from the BackgroundAudioOffset Tauri state onto
the controller, and the advance clears it: the next episode's stream is built
without StartTimeTicks, so its timeline is already absolute and a stale base
made player_exit_background_audio return old_base + position_in_new_episode.
Unreachable until the advance actually worked.

Tests (red before the fix):
- test_auto_advance_background_audio_episode_advances_in_backend
- test_auto_advance_foreground_video_episode_uses_countdown
- test_advance_to_next_episode_audio_only_clears_handoff_base

Bump to 0.2.9.
2026-08-02 18:10:18 +02:00
dtourolle 9d099268b9 fix(player): make the video seek bar work by touch (DR-099)
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 6m30s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m25s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Failing after 6m4s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Windows (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped
On Android, dragging or tapping the progress bar moved the thumb but
playback stayed where it was. Two separate defects, both touch-only,
which is why the mouse-driven scrub tests never caught either.

1. Gesture hijack. DR-098 taught handleTouchStart to ignore touches that
   land on a control, but handleTouchMove kept running. It measures
   against touchStartX/Y, which that early return leaves at the PREVIOUS
   gesture's values, so a seek-bar drag produced a huge bogus vertical
   delta: read as a brightness swipe, it dimmed the screen to the 0.3
   floor and fired a spurious play/pause "correction" mid-drag. A gesture
   is now latched at touchstart (playerGestureActive) and touchmove
   ignores anything unlatched — re-checking the move target cannot
   recover a start point that was never recorded.

2. Commit signal. The seek was committed only from `change`, which
   Android's WebView does not reliably fire for a touch interaction on a
   range input, so the thumb moved to the tapped position and no seek
   ever ran. touchend/mouseup now commit too; `input` arms a one-shot
   latch so whichever release signal arrives first commits and the other
   is a no-op. seekRelative shares the same commitSeek entry point
   instead of fabricating a synthetic change event.

Tests drive the slider with real touch events (UT-089, UT-090) and fail
against the pre-fix component.
2026-08-01 10:41:23 +02:00
dtourolle 1e599627b5 Fix tracability check
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m16s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 21s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m9s
2026-06-23 23:05:30 +02:00
dtourolle fa7cb6e908 fix: Autoplay now resets time to zero and ignores trigger if episode already started
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m12s
Traceability Validation / Check Requirement Traces (pull_request) Failing after 18s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m42s
2026-06-23 21:10:50 +02:00
433 changed files with 54191 additions and 12922 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)
+187 -13
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,99 @@ 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,
# so the requirement rested entirely on memory. Both components are baked
# into the builder image (Dockerfile.builder: `rustup component add
# rustfmt clippy`) — nothing is installed at job time.
- name: Check Rust formatting
run: |
cd src-tauri
cargo fmt --all -- --check
# 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.
#
# 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 -- -D warnings
- name: Run Rust tests
run: |
@@ -79,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
@@ -93,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
@@ -122,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"
+406 -135
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
@@ -54,6 +65,22 @@ jobs:
bun run test --run
continue-on-error: false
# Same gate as build-and-test.yml. A release must not ship from a tree
# that would fail the per-commit checks. rustfmt/clippy come from the
# builder image; nothing is installed here.
- name: Check Rust formatting
run: |
cd src-tauri
cargo fmt --all -- --check
continue-on-error: false
# Advisory until the ~51 pre-existing warnings are cleared; see the longer
# note in build-and-test.yml. Tighten both to `-- -D warnings` together.
- name: Run clippy (advisory)
run: |
cd src-tauri
cargo clippy --all-targets
- name: Run Rust tests
run: bun run test:rust
continue-on-error: false
@@ -67,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
@@ -75,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
@@ -96,21 +132,88 @@ jobs:
- name: Install dependencies
run: bun install
# The Linux job previously had no version step at all, so a tagged release
# built Linux packages from whatever version happened to be committed.
- name: Set app version from tag
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/
@@ -119,7 +222,7 @@ jobs:
with:
name: jellytau-linux
path: dist/linux/
retention-days: 30
retention-days: 7
build-windows:
name: Build Windows
@@ -129,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
@@ -137,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
@@ -156,18 +276,19 @@ jobs:
restore-keys: |
${{ runner.os }}-bun-
# The tag is the single source of truth for a release version; the script
# stamps every file that carries it (package.json, tauri.conf.json,
# Cargo.toml, Cargo.lock). This step used to sed only tauri.conf.json, so
# the other three shipped whatever was committed.
- name: Set app version from tag
run: |
# On a tag build the tag is the single source of truth for the version.
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
VERSION="${GITHUB_REF#refs/tags/v}"
echo "Setting version to $VERSION"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
fi
grep '"version"' src-tauri/tauri.conf.json
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
if: startsWith(github.ref, 'refs/tags/v')
- 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/
@@ -177,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
@@ -196,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
@@ -217,48 +347,22 @@ jobs:
- name: Install dependencies
run: bun install
# Stamp before `android init`: it derives its generated project (including
# the initial versionCode) from tauri.conf.json.
- name: Set app version from tag
run: |
# On a tag build, the tag is the single source of truth for the
# version name. On non-tag runs keep whatever is in tauri.conf.json.
if echo "$GITHUB_REF" | grep -q '^refs/tags/v'; then
VERSION="${GITHUB_REF#refs/tags/v}"
echo "Setting version to $VERSION"
sed -i "s/\"version\": \"[^\"]*\"/\"version\": \"$VERSION\"/" src-tauri/tauri.conf.json
fi
grep '"version"' src-tauri/tauri.conf.json
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
if: startsWith(github.ref, 'refs/tags/v')
- name: Initialize Android project
run: bun run tauri android init
# Re-run after init: tauri.properties only exists now, and its
# autogenerated versionCode (0.0.15 -> 15) is both tiny and NOT monotonic
# against the 1000 floor already shipped in the field. The script rewrites
# it as 1000 + major*10000 + minor*100 + patch. Runs unconditionally so
# untagged builds get a sane code too, derived from git describe.
- name: Pin a monotonic Android versionCode
run: |
# `tauri android init` autogenerates src-tauri/gen/android/app/tauri.properties
# with a versionCode derived from the semver (e.g. 0.0.15 -> 15). That
# number is (a) tiny and (b) NOT monotonic across our history: earlier
# local/dev builds shipped versionCode 1000 (from a 0.1.0 config), so a
# plain 15 would be a *downgrade* and Android would refuse the update.
#
# Derive an explicit code that is both monotonic in semver order and
# always above the 1000 floor already in the field:
# code = 1000 + major*10000 + minor*100 + patch
# e.g. 0.0.14 -> 1014, 0.0.15 -> 1015, 0.1.0 -> 1100, 1.0.0 -> 11000.
# POSIX sh only (the runner uses dash): no here-strings, no \s in sed.
PROPS="src-tauri/gen/android/app/tauri.properties"
VERSION=$(grep '"version"' src-tauri/tauri.conf.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
MAJ=$(echo "$VERSION" | cut -d. -f1)
MIN=$(echo "$VERSION" | cut -d. -f2)
PAT=$(echo "$VERSION" | cut -d. -f3)
# Guard against a malformed/missing component so we never emit code 0.
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
CODE=$(( 1000 + MAJ*10000 + MIN*100 + PAT ))
echo "version=$VERSION -> versionCode=$CODE"
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
else
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
fi
cat "$PROPS"
run: ./scripts/set-version.sh "${GITHUB_REF#refs/tags/}"
- name: Sync custom Android sources & gradle config
run: ./scripts/sync-android-sources.sh
@@ -273,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: |
@@ -292,7 +400,7 @@ jobs:
with:
name: jellytau-android
path: dist/android/
retention-days: 30
retention-days: 7
create-release:
name: Create Release
@@ -300,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
@@ -329,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:
@@ -425,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
+25 -4
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)
@@ -81,8 +81,20 @@ jobs:
exit 1
fi
# Check minimum threshold
MIN_THRESHOLD=50
# Minimum coverage. RATCHET POLICY: this number only ever goes UP.
#
# It sits a few points under the coverage actually achieved, so a real
# regression trips it. It was 50 while true coverage was 86%, which
# meant nearly half the matrix could rot before CI said a word — a
# gate that cannot fail is not a gate.
#
# When coverage rises durably, raise this to just under the new figure
# (`bun run traces:coverage` prints it). Never lower it to make a red
# build pass — add the missing TRACES comments instead.
#
# 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=89
if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then
echo "❌ ERROR: Coverage ($COVERAGE%) is below minimum threshold ($MIN_THRESHOLD%)"
exit 1
@@ -90,6 +102,15 @@ jobs:
echo "✅ Coverage is acceptable ($COVERAGE% >= $MIN_THRESHOLD%)"
# Every ID named by a TRACES comment must be defined as a table row in
# docs/requirements.md. The extractor used to accept any well-formed ID
# silently, so a typo or a rename that missed a call site passed CI
# unnoticed (DR-189 and UT-188 lived in three source files, defined
# nowhere, for months). This covers UT/IT too, which the coverage
# orphan list above deliberately ignores.
- name: Validate requirement IDs
run: bun run traces:validate
- name: Check modified files
if: github.event_name == 'pull_request'
run: |
-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" }
}
]
}
+1236 -7
View File
File diff suppressed because it is too large Load Diff
+59 -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
@@ -31,6 +32,16 @@ bun run android:dev # build + deploy
bun run android:logs # logcat
```
The **debug** build type carries `applicationIdSuffix ".debug"`, so
`com.dtourolle.jellytau.debug` ("JellyTau Debug") installs *alongside* a release
build with its own data dir — never uninstall the release app to test a debug
one. `./scripts/build-and-deploy.sh release --device --debug` puts an
R8-minified *release* build in that same slot, signed with the local debug
keystore, for validating minification without the real key. Only the
applicationId is suffixed; Kotlin classes stay in the `namespace` package
`com.dtourolle.jellytau`, so JNI lookups and R8 keep rules are unaffected. See
[README_ANDROID_BUILD.md](src-tauri/android/README_ANDROID_BUILD.md).
CI runs on **Gitea Actions** (`.gitea/workflows/`), not GitHub. Use the `gh` CLI
only against the mirror if one exists; the canonical remote is
`gitea.tourolle.paris`.
@@ -51,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.
@@ -91,14 +103,24 @@ Tooling:
bun run traces # extract traces (default format)
bun run traces:json # JSON — e.g. | jq '.byType' or '.requirements."UR-005"'
bun run traces:markdown # regenerate docs/traceability.md
bun run traces:coverage # coverage gate — exits non-zero below the threshold
bun run traces:validate # dangling-ID gate — every traced ID must be defined
git diff --name-only | xargs grep -L "TRACES:" # find untraced changed files
```
Every ID a `TRACES:` comment names must exist as a table row in
`docs/requirements.md``traces:validate` fails otherwise, so a typo or a
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
**50%** (`MIN_THRESHOLD`); `build-and-test.yml` runs frontend + Rust tests 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).
**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 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
@@ -152,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)
@@ -194,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.
@@ -203,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.
+63 -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 && \
@@ -87,6 +108,22 @@ RUN $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --sdk_root=$ANDROID_HOME \
# Set NDK environment variable
ENV NDK_HOME=$ANDROID_HOME/ndk/$NDK_VERSION
# Gradle distribution. `tauri android init` regenerates gen/android with a
# wrapper pointing at services.gradle.org, so every Android job would otherwise
# download ~130MB of Gradle at build time — slow, and a hard failure when the
# CDN hiccups ("Unexpected end of file from server"). Ship the distribution in
# the image instead; scripts/sync-android-sources.sh repoints the regenerated
# wrapper at this local copy. Keep GRADLE_VERSION in sync with the version
# Tauri's generated wrapper requests.
ENV GRADLE_VERSION=8.14.3 \
GRADLE_HOME=/opt/gradle/gradle-8.14.3
RUN mkdir -p /opt/gradle/dist && \
wget -q "https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" \
-O "/opt/gradle/dist/gradle-${GRADLE_VERSION}-bin.zip" && \
unzip -q "/opt/gradle/dist/gradle-${GRADLE_VERSION}-bin.zip" -d /opt/gradle && \
"$GRADLE_HOME/bin/gradle" --version
ENV PATH="$GRADLE_HOME/bin:$PATH"
# ---------------------------------------------------------------------------
# Desktop packaging tools — kept in a trailing layer ON PURPOSE so that adding
# or changing a packaging tool doesn't invalidate the expensive apt/rust/Android
@@ -115,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
+1 -1
View File
@@ -1,4 +1,4 @@
version: '3.8'
version: "3.8"
services:
# Test service - runs tests only
+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
+98
View File
@@ -50,6 +50,68 @@ pub struct EncryptedFileStorage; // AES-256-GCM fallback
| Certificate Validation | System CA store (configurable for self-signed) |
| Token Transmission | Bearer token in `Authorization` header only |
| Token Refresh | Handled by Jellyfin server (long-lived tokens) |
| Android cleartext | `res/xml/network_security_config.xml` blocks cleartext everywhere except `127.0.0.1` (the loopback media server, DR-137/DR-138). The manifest's `usesCleartextTraffic` is ignored once the config is present, so the config is the single authority |
| Android WebView | `mixedContentMode = COMPATIBILITY` with `allowFileAccess`/`allowContentAccess` both `false` (DR-199). These are the second half of the cleartext policy: `ALWAYS_ALLOW` re-opened by hand what the network security config closes. Change the two together |
## Webview Content Security Policy
`app.security.csp` in `tauri.conf.json` (TRACES: UR-012, UR-071 | DR-198). It was
`null` — CSP disabled — which meant any script that reached the web layer
inherited the full IPC surface. Tauri computes the header from this value when it
serves the embedded HTML, injecting a nonce for SvelteKit's inline bootstrap
script, so `script-src` needs no `'unsafe-inline'`.
```
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
font-src 'self' data:;
img-src 'self' data: blob: asset: http://asset.localhost http: https:;
media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:;
connect-src 'self' ipc: http://ipc.localhost http: https:;
worker-src 'self' blob:;
object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
```
| Directive | Why |
|-----------|-----|
| `default-src 'self'` | Everything not named below is same-origin only. |
| `script-src 'self'` | The genuinely restrictive half. Bundled JS only; Tauri's build-time nonce covers the one inline `<script>` in `index.html`. Adding `'unsafe-inline'` here would silently do nothing anyway — a nonce in a directive voids it. |
| `style-src 'self' 'unsafe-inline'` | Svelte compiles `style="…"` attributes into markup, including `app.html`'s `display: contents` wrapper, and CSP treats a style *attribute* as inline. Safe only while no `<style>` **element** survives into `index.html`: Tauri would nonce it, and the nonce would then void `'unsafe-inline'`. The production build extracts all CSS to files, so it currently has none. |
| `img-src` | Thumbnails come from two places: the asset protocol (`asset://localhost/…` on Linux/macOS, `http://asset.localhost/…` on Windows/Android — the same protocol, named differently by `convertFileSrc`) and, on a cache miss, straight from the Jellyfin server. `data:`/`blob:` cover inline and generated images. |
| `media-src` | `<video>`/`<audio>` sources: HLS transcodes and progressive streams from the server, the token-guarded loopback media server on `http://127.0.0.1:<random port>` (DR-137), and `blob:` for the MSE object URL hls.js attaches. |
| `connect-src` | `ipc:` / `http://ipc.localhost` is Tauri's `invoke` transport (custom scheme on Linux/macOS, `http` host on Windows/Android) — without it every command is blocked. `http:`/`https:` is hls.js fetching manifests and segments; ordinary API traffic goes through Rust and is not subject to CSP. |
| `worker-src 'self' blob:` | hls.js runs its demuxer in a worker built from a blob (`enableWorker: true`). Without `blob:` it falls back to main-thread demuxing — playback survives but costs more CPU. |
| `object-src`, `frame-src` = `'none'` | No plugins, no iframes; both are classic injection sinks. |
| `base-uri 'self'`, `form-action 'self'`, `frame-ancestors 'none'` | Block `<base>` hijacking, form exfiltration and framing. `frame-ancestors` is only honoured when the policy is delivered as a header, which is platform-dependent; it is harmless where it is not. |
**`img-src`/`media-src`/`connect-src` are deliberately permissive.** The Jellyfin
origin is typed in by the user at run time and is routinely plain `http` on a
LAN, so it cannot be enumerated at build time. `http: https:` is a wide grant for
*data* — but it still bars `file:`, `filesystem:` and scripting schemes, and it
does not touch `script-src`, which is where an injected origin would actually
hurt. A run-time policy naming the server exactly was considered and rejected:
Tauri derives the header from immutable config at the moment it serves the HTML,
so it would mean rebuilding the config and reloading the webview whenever the
user adds or switches a server, to constrain a destination the user chooses
anyway.
`devCsp` mirrors the policy with `'unsafe-inline' 'unsafe-eval'` on `script-src`
and `ws:`/`wss:` on `connect-src`, because the Vite dev server injects styles and
code and drives HMR over a websocket. It applies only to `tauri dev`.
### Asset protocol scope
`app.security.assetProtocol.scope` is `$APPDATA/thumbnails/**` — not the storage
root. `imageCache.ts` is the only `convertFileSrc` caller left in the frontend:
downloaded media moved to the loopback media server in DR-137, and downloaded
audio is opened by MPV/ExoPlayer directly from its path. The old `$APPDATA/**`
grant let the webview read the SQLite database and the encrypted-token fallback
file alongside the thumbnails it actually needs.
If a new feature hands the webview a local file, widen this scope to that
subdirectory specifically; a path outside it resolves to nothing and the webview
reports `NETWORK_NO_SOURCE` (which is exactly how DR-134's failure presented).
## Local Data Protection
@@ -60,6 +122,41 @@ pub struct EncryptedFileStorage; // AES-256-GCM fallback
| 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
@@ -67,3 +164,4 @@ pub struct EncryptedFileStorage; // AES-256-GCM fallback
3. **Logout Cleanup**: Token deletion from secure storage on logout
4. **No Token Logging**: Tokens are never written to logs or debug output
5. **IPC Security**: Tauri's IPC uses structured commands, not arbitrary code execution
6. **Webview Containment**: A restrictive `script-src` keeps injected script off the IPC surface; the asset protocol is scoped to the thumbnail cache only (see above)
+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.
+152
View File
@@ -0,0 +1,152 @@
# Defect windows — which bugs were present when
For each fixed defect, the releases it was actually present in. Companion to
[CHANGELOG.md](../CHANGELOG.md), which says what changed; this says how long each
fault had been shipping before it did.
**"Present since"** is the first *release* containing the defective code, not the
first release where a user could hit it — those differ, sometimes by months, and
the gap is called out where it matters. **"How dated"** records the evidence, so a
row can be re-checked or disputed:
| Method | Meaning |
|--------|---------|
| `pickaxe` | `git log -S<token>` on the defective token — the commit that introduced the exact string, then the earliest tag containing it. Strongest evidence. |
| `feature` | The defect is inseparable from a feature that landed whole (bad rung in a new algorithm, missing caller in new plumbing), dated to that feature's release. |
| `absence` | The fix *adds* something that was never there. Dated to when the surrounding code was built, since there is no introducing commit to find. Weakest — treat as "no later than". |
## Present since the first release
Nine defects date to the initial proof of concept (v0.0.1, 2026-06-23) and shipped
for between two weeks and seven weeks short of two months before anyone hit them.
That is the dominant pattern here: not regressions, but original assumptions that
went unexercised until a later feature leaned on them.
| Defect | Present since | Fixed in | Shipped broken for | How dated |
|---|---|---|---|---|
| `AudioStreamIndex=0` pinned the video stream as the audio track (DR-140) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
| Download URL spelled `videoBitrate`, which Jellyfin does not bind (DR-123) | v0.0.1 | **v0.5.1** | ~7 weeks | pickaxe |
| `pause_download` / `resume_download` were no-ops (DR-168) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
| `.part` sidecar named by `with_extension`, so no cleanup path matched it (DR-169) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
| `Range` sent on every retry regardless of the response (DR-170) | v0.0.1 | **v0.5.3** | ~7.5 weeks | pickaxe |
| `/Items/Latest` requested with the default `GroupItems=false` | v0.0.1 | **v0.5.1** | ~7 weeks | pickaxe |
| `SubtitleStreamIndex` omitted from PlaybackInfo, letting the server burn in (DR-176) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
| No `PlaySessionId`, and one hardcoded `DeviceId`, on every stream URL (DR-177) | v0.0.1 | **v0.5.5** | ~8 weeks | pickaxe |
| `download_item` never recorded `media_type`; NULL read as `'audio'` (DR-135) | v0.0.1 | **v0.4.6** | ~7 weeks | pickaxe |
| `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
Four of these were **latent until a later feature exercised them**, which is why
the fix lands so far from the cause:
- The `videoBitrate` casing was harmless while every download was `original`. It
became visible only once a quality picker existed to select against — and then
produced no error, just a full-size file, because Jellyfin discards an unbound
query key silently.
- The unconditional `Range` header was inert for the same reason: `original` is
the one rung served with a `Content-Length` and real byte-range support. It
started corrupting files in **v0.5.1**, the moment the casing fix made
transcoded downloads actually transcode. So the *code* dates to v0.0.1 and the
*corruption* to v0.5.1 — a one-release window for the visible symptom.
- The missing `PlaySessionId` only bites when a stream is re-opened for the same
item. Nothing re-opened one until quality switching, transcoded seek and
audio-track switching existed.
- The omitted `SubtitleStreamIndex` only bites on sources whose own default
subtitle track is image-based, since that is what forces the server from
sidecar to burn-in.
Two were **masked by soft failure**: the asset protocol being disabled (DR-134)
was hidden by the thumbnail cache falling back to the server copy whenever the
server was reachable, and `AudioStreamIndex=0` was hidden by servers that
silently correct an out-of-range index — which is exactly why it was reported as
"*some* videos have no audio" rather than as a bug in the client.
## Introduced by a feature, fixed later
| Defect | Present since | Fixed in | How dated |
|---|---|---|---|
| Native-path resume position never applied (both layers assumed the other seeked) | v0.0.9/v0.0.10 | **v0.5.1** | feature (`PlayerAdapter` contract) |
| `get_downloaded_items` matched "this library exists" rather than constraining the item to it (DR-167) | v0.0.17 | **v0.5.3** | feature (browsable downloaded library) |
| `SCOPE_ITEM_TYPES` — the frontend/backend boundary leak (DR-063) | v0.0.17 | **v0.2.1** | pickaxe |
| `check:boundary` anchored to the query site, blind to a named const (DR-094) | v0.0.17 | **v0.2.1** | feature (tripwire landed with the leak it missed) |
| Coverage gate divided by hardcoded denominators, reporting 158% (DR-093) | v0.0.1 | **v0.2.1** | pickaxe |
| Tap deferral raced the WebView's synthesized click (DR-092 → DR-098) | v0.1.5 | **v0.2.7** | feature (the deferral itself) |
| Transport for webview media decided from `el.paused` in the DOM (DR-097) | v0.0.9/v0.0.10 | **v0.2.7** | feature (`Html5PlayerAdapter`) |
| `pick_current_episode` rung 3 returned the first *gap*, not the furthest watched | v0.3.0 | **v0.5.1** | feature |
| `mirror_user_data` mirrored `is_favorite` alone and returned early (DR-155) | v0.4.0 | **v0.5.1** | pickaxe |
| Stop-report path never fed the sync queue that existed for it (DR-154) | v0.4.6 | **v0.5.1** | feature (queue + drain landed with no producer) |
| Background-audio base applied in two display-only places (DR-159) | v0.2.9 | **v0.5.3** | pickaxe |
| Positions reported as 0 before the first tick, and always 0 for webview media (DR-178/179/180) | v0.5.3 | **v0.5.5** | feature (DR-159's tick boundary) |
| Length-less handoff transcode left to the player's own load-error retry, which can only restart it (DR-203) | v0.0.16 | **v0.8.2** | feature (the handoff's progressive-mp3 choice) |
Three of these are worth separating out, because the defect is not a mistake in
the code so much as **plumbing that was built and never connected**:
- `repository_get_next_up_episodes` accepted a `series_id` from the day it was
written, and no caller passed one until v0.3.0.
- The sync queue and its drain were built, tested and running in v0.4.6 with
neither of its two would-be producers ever called.
- Both halves of the watched-state backend existed with no caller before v0.5.3.
An automated check cannot see any of these — the code is present, tested and
reachable in principle. Only tracing a requirement to a *call site* catches it.
## Short windows (one release or less)
| Defect | Present since | Fixed in | Note |
|---|---|---|---|
| `experimentalNativeVideo` defaulted on, shipping audio with a blank screen (DR-161 → DR-172) | v0.5.3 | **v0.5.4** | One release. The decode path was fine; the compositing step never ran. |
| Webview-shaped audio profile insufficient — server ignores a profile's audio codec (DR-149) | v0.4.7 | **v0.4.8** | The v0.4.7 fix for DR-148 was necessary and not sufficient. |
| Android `versionCode` floor went stale (`minor*100` yielding less than the 5002 already in the field) | v0.5.0 | **v0.5.3** | Caught before a broken APK shipped; no released build was un-installable. |
| Subtitle sidecar work reverted by a commit assembled from a stale tree | v0.5.5 | **v0.5.5** | Never released broken — both commits are in v0.5.5. |
## Fixed twice / never actually broken
- **Autoplay time reset (v0.0.2).** Two commit objects carry this identical
change: `dcf08f30` (merged via Gitea PR #3, tagged v0.0.2) and `fa7cb6e9` (the
local original). Both have the same parent `674c8e5c` and the same diff. A merge
chain pulled `fa7cb6e9` and its follow-up `1e599627` into master's history
during v0.5.5, so `git log v0.5.4..v0.5.5` lists an autoplay fix that changed no
file in that release — `nextEpisodeService.ts` is byte-identical across the tag
boundary. The fix shipped in **v0.0.2** and has not regressed.
This is the one case where reading the changelog off `git log` subjects would
have produced a false entry, and it is a good argument for the project's
practice of deriving release notes from TRACES rather than commit subjects.
## Recurring shapes
Four causes account for most of the table:
1. **An omitted parameter is not a neutral default.** `SubtitleStreamIndex`,
`AudioStreamIndex`, `GroupItems` and `MaxAudioChannels` all had a server-side
default that was actively wrong, and in three of the four the server's choice
was more expensive than the one intended — burn-in forcing a full re-encode
being the extreme case.
2. **Silent binding failures.** `videoBitRate` produced no error, no warning and a
plausible-looking file. So did an unbound `Range`, and so did the coverage gate
dividing by a stale denominator.
3. **Two layers each assuming the other acts.** Native resume (adapter recorded
the position, backend never seeked), end-of-playback dispatch (two paths, one
unreachable), and the surface/attach split in v0.5.0's native video.
4. **A guard keyed on state that moves.** The tap deferral keyed suppression on a
timer handle the callback had already cleared; the HTML5 toggle keyed
play-vs-pause on `el.paused`, which flips while buffering.
## Reproducing this
The pickaxe rows can be re-derived directly:
```bash
git log --oneline --reverse -S'<defective token>' -- src-tauri/src # introducing commit
git tag --contains <sha> | sort -V | head -1 # first release with it
```
Blaming the lines a fix removed (`git blame` at the fix's parent) is faster to run
across many commits but was **not** used for the rows above: it reliably lands on
whichever commit last touched the adjacent lines, which is usually not the commit
that introduced the defect. It was used only to shortlist candidates.
+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 🦀
+379 -37
View File
@@ -16,7 +16,7 @@ For a narrative overview of the system design, see
| UR-003 | Play videos | High | Done |
| UR-004 | Play audio uninterrupted | High | Done |
| UR-005 | Control media playback (pause, play, skip, scrub) | High | Done |
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | Done |
| UR-006 | Control media when device is on lock screen or via BLE headsets | Medium | Done (Android); **not implemented on Linux** — see IR-005 |
| UR-007 | Navigate media in library | High | Done |
| UR-008 | Search media across libraries | High | Done |
| UR-009 | Connect to Jellyfin to access media | High | Done |
@@ -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 |
@@ -72,6 +72,23 @@ For a narrative overview of the system design, see
| UR-059 | Skipping to the next episode records the episode left behind as **fully watched** rather than saving a mid-episode resume point — skipping means "done with this one", not "stopped here" — and Continue Watching hides episodes the viewer has already moved past (a partial position behind that series' next-up episode), so the row only ever offers genuinely unfinished media | Medium | Done |
| UR-060 | Search results are ordered by how well they match: a name that *starts* with the query outranks one matching mid-word (typing "parks" finds "Parks and Recreation" before "Sparks of Love"), and at equal match quality a container outranks its contents (a series before its episodes). Results are grouped into distinct categories — TV Shows, Episodes, Movies, Songs, Albums, Artists and People — so a show never competes with its own episodes for the same slot, and searching an actor's name reaches their bio | High | Done |
| UR-061 | Double tapping the video skips within it — right half jumps **forward 30 seconds**, left half jumps **back 10 seconds** — with an on-screen indicator naming the amount. A double tap leaves the play state unchanged — playing jumps and keeps playing, paused jumps and stays paused — because the second tap re-toggles what the first tap toggled (see DR-098); the skip lands relative to the position the player actually reports, and repeated double taps accumulate rather than all skipping from the same spot | Medium | Done |
| UR-062 | Opening a TV series lands the viewer **where they are in it**, not at season 1: the series page scrolls the current season into view and highlights the current episode, and the hero button opens that episode (labelled `Resume S2E4` / `Play S1E1`). "Current" means the episode in progress, else the server's Next Up for that series, else the first unwatched episode, else the first — resolved by the backend so it also works offline. A season is **never a page of its own**: every route that names a season lands on the series with that season in view, so the episodes of all seasons are always one continuous scrollable list | High | Done |
| UR-063 | Each video library is **one page**, not three. Browsing (hero, Continue Watching, Next Up, Recently Added, genre rows), the full title grid, and the genre browser are tabs of `/library/tv` and `/library/movies` rather than separate routes with inconsistent names (`/library/tv/shows` vs `/library/movies/all`, `/library/shows/genres` vs `/library/movies/genres`). The old routes redirect so existing links keep working | Medium | Done |
| UR-064 | Watch history can be **erased**, per series and per season, from the series page. Clearing marks every episode inside unwatched and clears resume positions, so the show returns to "never watched" and reopens on its premiere. It asks for confirmation first (it cannot be undone) and requires a connection to the server, since history cleared only locally would be undone by the next sync | Medium | Done |
| UR-065 | Search answers from a **locally indexed copy of the library**, so results appear as fast as the device can query rather than at the speed of a round trip to the server, and the same results are found with the server unreachable. A background job keeps the index current — refreshing on a schedule rather than only at app start, dropping media removed from the server, and covering everything the result groups can show (including artists and people). The server is still queried in the background so media added since the last index still turns up, merged in without reordering what is already on screen | High | Implemented |
| UR-066 | The app's own chrome stays clear of the device's system chrome. On Android the bottom navigation sits above the navigation/gesture bar instead of underneath it, the header clears the status bar, and full-screen video and audio playback keep their controls inside the usable screen — clear of the gesture bar and, in landscape, of the display notch. This must hold across navigation modes (gesture and 3-button) and rotation, not only on the handsets it happened to be tested on | High | Done |
| UR-067 | Favourited media can be **found again**. A Favourites page lists everything favourited across all libraries, scoped by tabs (All / Movies / Shows / Music); the home screen carries favourite rows for movies, shows and music, hidden when a category is empty; and each library page can be filtered to favourites in place. Without this the like button writes to a store nothing reads | Medium | Done |
| UR-068 | Anything the app shows can be favourited where it is shown — from a movie, series, episode, album, artist or playlist page, and from any card in a grid or carousel — not only from the player while the item happens to be playing | Medium | Done |
| UR-069 | Favourite state agrees with the server in both directions. An item favourited in another Jellyfin client shows as favourited here without being touched, and an item favourited here while the server is unreachable reaches the server once it returns — without the user going back to the screen where they marked it | Medium | Done |
| UR-070 | Playback quality is the viewer's choice: the player offers the bitrates the server can produce for what is playing, and changing one resumes at the same point with the same audio and subtitle tracks. Because the chosen rendition can change at any moment, nothing that streams for playback is treated as a stored copy unless it happens to be byte-identical to the real file | Medium | Proposed |
| UR-071 | Media the viewer is watching can be **kept**, by a whole-file download that runs in the background independently of playback and at its own quality, so it is unaffected by bitrate changes. Where the streamed bytes already are that file (direct play), they are kept rather than fetched twice. A completed download is then played from disk rather than streamed again | Medium | Proposed |
| 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 |
---
@@ -87,7 +104,7 @@ External system integrations and platform-specific implementations.
| IR-002 | Build scripts for Android and Linux | Build | UR-001 | Done |
| IR-003 | Integration of libmpv for Linux playback | Playback | UR-003, UR-004 | Done |
| IR-004 | Integration of ExoPlayer for Android playback | Playback | UR-003, UR-004 | In Progress (basic playback works, audio settings missing) |
| IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned |
| IR-005 | MPRIS D-Bus integration for Linux lockscreen/media controls | Platform | UR-006 | Planned — genuinely absent: no `mpris`/`souvlaki`/`zbus`/`dbus` code or dependency in the project (`zbus` appears in `Cargo.lock` only transitively, via `tauri-plugin-opener`), and no `navigator.mediaSession` use in the frontend. `player::update_lockscreen_metadata` is a no-op off Android. UR-006 is therefore Android-only |
| IR-006 | Android MediaSession integration for lockscreen controls | Platform | UR-006 | Done |
| IR-007 | Bluetooth AVRCP integration via system media session | Platform | UR-006 | Planned |
| IR-008 | Android audio focus handling (pause on call) | Platform | UR-004, UR-006 | Done |
@@ -101,9 +118,9 @@ External system integrations and platform-specific implementations.
| IR-015 | Jellyfin API client for playback progress reporting | API | UR-019, UR-025 | Done |
| IR-016 | Jellyfin API client for subtitle/audio track info | API | UR-020, UR-021 | Done |
| IR-017 | Jellyfin API client for transcoding parameters | API | UR-022 | Planned |
| IR-018 | libmpv subtitle rendering and selection | Playback | UR-020 | Planned |
| IR-019 | libmpv audio track selection | Playback | UR-021 | Planned |
| IR-020 | libmpv/ExoPlayer equalizer integration | Playback | UR-027 | Done (Linux/MPV; Android parity pending) |
| 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 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 |
@@ -112,6 +129,29 @@ External system integrations and platform-specific implementations.
| IR-027 | Jellyfin `/System/Info/Public` reachability probe used as an offline→online recovery detector | API | UR-043 | Done |
| IR-028 | Jellyfin/LMS SyncGroups API client (list, create, join, unsync, dissolve sync groups) | API | UR-046 | Done |
| IR-029 | Android `ConnectivityManager`/`NetworkCapabilities` transport probe with a `NetworkCallback` change subscription, surfaced to the frontend via the `AndroidNetworkType` JS bridge and the `jellytau-network-changed` WebView event (requires `ACCESS_NETWORK_STATE`) | Platform | UR-053 | Done (pending device verification) |
| IR-030 | Scheduled full-catalog crawl of every library (`Recursive=true`, paged) feeding the local index, driven by a Rust background task and the `ConnectivityMonitor` reconnect signal rather than by the frontend | Storage | UR-065 | Implemented |
| IR-031 | Android `WindowInsets` bridge: an `OnApplyWindowInsetsListener` on the decor view reports `systemBars() | displayCutout()` in CSS pixels, pushed into the WebView as `jt-inset` CSS custom properties plus a `jellytau-insets-changed` event, and pullable via the `AndroidInsets` JS bridge | Platform | UR-066 | Done (pending device verification) |
| IR-032 | Whole-file background download of the item being played, reusing the existing resumable download worker and the Range-capable `/Videos/{id}/stream.mp4` endpoint; plus per-platform read-through caching hooks (ExoPlayer `CacheDataSource`, mpv `stream-record`) for direct-play sessions only | Storage | UR-071 | Proposed |
> **Where a UR is met by a different mechanism than its IR anticipated.** Several
> integration requirements were written when libmpv was expected to be the single
> playback backend. It is not: `MpvBackend` is the **audio-only** backend, Linux
> plays video through a WebKitGTK HTML5 `<video>` element (HLS/h264), and Android
> plays through ExoPlayer. So:
>
> * **UR-020 / UR-021** (subtitle and audio track selection) are Done, but not by
> MPV — `MpvBackend` overrides neither `PlayerBackend::set_subtitle_track` nor
> `set_audio_track`, leaving the trait's `not_implemented()` default. IR-018 and
> IR-019 have been **re-scoped to the backends that actually deliver them**
> (ExoPlayer sideloaded `SubtitleConfiguration`s and native track switching;
> HTML5 `<track>` children and stream re-open at the chosen `AudioStreamIndex`)
> and marked Done on that basis. IT-008 / IT-009 were re-worded to match.
> * **UR-006** (lockscreen / BLE headset control) is Done **on Android only**, via
> `MediaSessionCompat` (IR-006) and ExoPlayer/`AudioManager` focus (IR-008).
> IR-005 (MPRIS) remains Planned because it genuinely does not exist — there is
> no MPRIS/D-Bus code or dependency in the project, and
> `player::update_lockscreen_metadata` is a no-op off Android. UR-006's status
> was corrected rather than IR-005's.
### 2.2 Jellyfin API Requirements
@@ -151,6 +191,10 @@ API endpoints and data contracts required for Jellyfin integration.
| JA-030 | Get person details and filmography | Persons | UR-036 | Done |
| JA-031 | Get items by person (actor/director filmography) | Items | UR-036 | Done |
| JA-032 | Get audio-only stream URL for a video item (selected audio-stream index) | MediaInfo | UR-040 | Done |
| JA-033 | Query favourite items (`Filters=IsFavorite`, recursive, scoped by item type) | Items | UR-067 | Done |
| JA-034 | Read `UserData` (favourite, played, resume position) from item responses | UserData | UR-069 | Done |
| JA-035 | Mark item played (`POST /Users/{userId}/PlayedItems/{itemId}`) | UserData | UR-025 | Done |
| JA-036 | Query next-up episodes excluding in-progress ones (`/Shows/NextUp` with `EnableResumable=false`) | Shows | UR-059 | Done |
### 2.3 Development Requirements
@@ -194,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 |
@@ -249,10 +293,129 @@ Internal architecture, components, and application logic.
| DR-092 | Video tap gestures resolve in `tapGestures.ts` (pure, unit-tested) rather than inline in `VideoPlayer.svelte`: `registerTap` classifies each tap and the component acts on it immediately — `togglePlayPause` for a first tap, or `seek` (+30 s right / 10 s left) plus a re-toggle for a second tap inside `DOUBLE_TAP_WINDOW_MS` (300 ms). A consumed pair resets the state, and a swipe forgets the tap. The deferral this originally used was removed in DR-098, which also covers suppressing the compatibility `click` the browser synthesizes after a touch tap. `resolveSeekTarget` converts the delta to the absolute position the facade requires, clamped per DR-095 and chained off a still-in-flight `pendingSeekTarget` so back-to-back skips accumulate instead of all resolving against a not-yet-updated position | UI | UR-061 | Done |
| DR-094 | Frontend boundary tripwire (`scripts/check-frontend-boundary.sh`) detects Jellyfin item-type array literals **anywhere** in `src/` rather than only inline at an `includeItemTypes:` query site, so a category→type mapping cannot evade the check by being assigned to a named const (the evasion that let the `scoped-search` leak pass CI); requires two adjacent type literals so single-type presentation and `item.type ===` inspection stay legal, and caps the allowlist to force taxonomy into Rust instead of accumulating exceptions | Tooling | - | Done |
| DR-098 | Video tap gestures act **immediately** — no deferral, no timer, and only first/second taps exist. A first tap toggles play/pause; a second tap inside `DOUBLE_TAP_WINDOW_MS` seeks *and* toggles again, so the two toggles cancel and a double tap preserves the play state (playing → jump and keep playing; paused → jump and stay paused). This replaces a design that deferred the first tap behind a 300 ms timer so a second tap could cancel it: the timer cleared its own handle *before* invoking the toggle, which reopened the `tapTimeout !== null` guard in `handleVideoClick` meant to suppress the compatibility `click` Android's WebView synthesizes after a touch — the late click then toggled a second time, producing a pause/unpause loop (long-press was unaffected, which is what identified the tap path). Click suppression no longer depends on the timer: `handleVideoClick` ignores `detail === 0` *and* any click within `TOUCH_CLICK_SUPPRESS_MS` of a touch tap. A swipe undoes the touchstart toggle exactly once (latched on `swipeGestureActive`) so brightness swipes never change play state. Click suppression is shared by **every** click target layered over the video via `isSynthesizedTouchClick`, not just the `<video>`: pausing renders a full-screen play-overlay button, so the synthesized click lands on *that* and an unguarded handler there resumed immediately — pausing appeared impossible while unpausing worked, because unpausing removes the overlay | UI | UR-061 | Done |
| DR-099 | The video seek bar is usable by touch. Two Android-only defects made dragging or tapping it move the thumb without moving playback. (a) *Gesture hijack*: the container-level gesture layer skips `touchstart` on a control (DR-098) but kept handling `touchmove`, so a seek-bar drag was measured against the **previous** gesture's start point — a huge bogus vertical delta that read as a brightness swipe, dimmed the screen to the 0.3 floor, and fired a spurious play/pause "correction" mid-drag. A gesture is now latched at `touchstart` (`playerGestureActive`) and `touchmove` ignores anything not latched, since re-checking the move target cannot recover a start point that was never recorded. (b) *Commit signal*: the seek was committed **only** from `change`, which Android's WebView does not reliably fire for a touch interaction on a range input — the thumb moved to the tapped position and no seek ever ran. `touchend`/`mouseup` now commit as well; `input` arms a one-shot latch so whichever release signal arrives first commits and the other is a no-op. `seekRelative` shares the same `commitSeek` entry point instead of fabricating a synthetic `change` event | UI | UR-005, UR-061 | Done |
| DR-097 | Transport authority (play/pause/toggle) lives in Rust for **webview-rendered** media, not just native. The controller tracks the state the HTML5 element reports (`html5_playing`, fed by `report_html5_state`, which now *stores* rather than only re-emitting); `play`/`pause`/`toggle_playback` consult it and drive the element by emitting a `ControlCommand` that `playerEvents.handleControlCommand` executes against the active adapter. A `stopped`/`idle` report clears it so the native backend (MPV/ExoPlayer) regains authority for music. The frontend facade no longer short-circuits transport into the adapter: `adapter.toggle()` previously decided play-vs-pause by reading `el.paused` off the DOM, a value that flips transiently while an element buffers or settles a seek — so two intents ~150 ms apart read *different* values, performed *opposing* actions, and self-sustained a play/pause loop needing no further input (observed on Android with a fully-buffered `readyState=4 networkState=1` element). Same "backend decides, adapter executes the primitive" split as `player_seek_video` | Player | UR-005 | Done |
| DR-096 | `Html5PlayerAdapter.play()` is resilient to stall recovery: an in-flight attempt is memoised so concurrent callers (UI plus hls.js gap-controller recovery) share one `element.play()` instead of stacking calls, and an `AbortError` ("play() request was interrupted by a call to pause()") is logged at debug rather than pushed to `host.onError`. The browser raises it whenever a pending play promise is superseded by a pause/seek/source change, which hls.js does routinely while nudging past a stall — reporting it surfaced a player error roughly once per second for the whole stall and left the UI stuck showing paused | Player | UR-005 | Done |
| DR-095 | Seek targets clamp strictly *inside* the media (`clampSeekTarget`, `END_SEEK_MARGIN_SECONDS` = 6 s ≈ one HLS segment) instead of to the exact `duration`. Landing on the duration makes hls.js request the segment whose start time lies past the end of the media (e.g. a 6330.324 s item → segment 1055 starting at 6336.33 s), which Jellyfin never produces; the fetch times out and hls.js' gap-controller stalls at the last buffered position, presenting as "unpausing or skipping bounces straight back to paused". Applied on both seek paths — the relative-skip `resolveSeekTarget` and the seek-bar drag, whose range input `max` is the duration itself — and floored at 0 so media shorter than the margin still seeks to the start | UI | UR-061 | Done |
| DR-100 | Leaving a video and re-entering it renders the **video** player, never the audio one. Both halves of the `/player/[id]` decision are pure and unit-tested in `playerSurface.ts`. (a) `shouldReuseActivePlayback` excludes video: the "already playing, just show the UI" shortcut (added for expanding the audio mini player) returns *before* a stream URL is fetched, which is fine for audio — the backend owns the stream and the route only mirrors it — but leaves `<VideoPlayer>` with nothing to render. Closing a webview-rendered video deliberately emits no `stopped` state (that would break the autoplay handoff, see DR-047), so the Rust controller still reports that movie/episode as its loaded media and re-entering the same item hit the shortcut. (b) `resolvePlayerSurface` maps video-without-a-stream-URL to `pending` (spinner) instead of falling through to `<AudioPlayer>`, so no future path can put video content in the audio surface. Video now always takes the full load path, which fetches the stream URL and applies the stored resume position | UI | UR-005 | Done |
| DR-101 | "Where is this viewer in this series" is resolved in **Rust**, not the frontend. `repository_get_series_episodes` performs the season fan-out (`get_items(series_id)` → seasons → `get_items(season_id)`, plus the flat-series fallback for shows whose children are episodes rather than season folders) and returns them in series order — season index ascending, episode index ascending, specials (season 0) after every numbered season. `repository_get_series_current_episode` layers the pure policy `pick_current_episode` over that list: an **in-progress** episode wins (earliest in series order on a tie — it is literally where playback stopped, and Next Up would skip past it), then the server's **Next Up** for that series, then the **first unwatched** episode, then the first. The third rung is the offline path, not dead code: `OfflineRepository::get_next_up_episodes` returns an empty vec, so without it the feature would be online-only. A failing Next Up or resume lookup degrades to empty rather than failing the call. `repository_get_next_up_episodes` had accepted a `series_id` since it was written and **no caller had ever passed one** | Repository | UR-062 | Done |
| DR-102 | The series detail page anchors on that answer. It calls `repositoryGetSeriesEpisodes` once instead of fanning out over seasons in TypeScript (the fan-out *and* its flat-series fallback were domain knowledge in the presentation layer), groups the returned episodes under season headers by `parentIndexNumber`, and passes the resolved current episode to `SeasonSection``EpisodeRow`, which renders a highlight ring and scrolls itself into view. The hero button navigates to `/library/<seriesId>?episode=<currentId>` — the Episode Focus View, where an explicit Play/Resume commits — per ux-flows §5B.5: Play on a *container* is navigation, Play on a *leaf* commits. It previously resolved `$libraryItems[0]`, the first **season** by `SortName`, and navigated to `/player/<seasonId>`, which the player route bounced back to `/library/<seasonId>` — so Play on a series played nothing and landed on the season-1 page | UI | UR-062 | Done |
| DR-103 | A season is not a destination. `/library/<seasonId>` redirects to `/library/<seriesId>#season-<indexNumber>`, the anchor `SeasonSection` renders, so a season link scrolls the series' continuous episode list rather than opening a page. Every inbound link follows: the episode breadcrumb, `handleItemClick case "season"`, the TV landing page's `case "Season"`, and `DownloadedBrowse`. A season carrying no `seriesId` (deep link into a stale cache) still renders the generic view so the user is never stranded. This removes a surface that had no route of its own — it fell through the detail page's `kind` chain to the generic "Contents" poster grid, contradicting ux-flows §5A.2 (episodes must be a row list), and clicking an episode there opened a bare Episode page, which §5B.1 forbids | UI | UR-062 | Done |
| DR-104 | The "More Episodes" strip spans the **whole series** in series order, per ux-flows §5B.2's cross-season continuity rule: at the end of a season the window runs on into the next season's first episodes instead of dead-ending. `adjacentEpisodes` previously filtered the pool to `parentIndexNumber === current.parentIndexNumber` and sorted by `indexNumber` alone, so the window could never leave the current season — and, when episodes of several seasons did reach it, sorting by episode number alone interleaved them. Cards crossing a season boundary are labelled `SxEy` rather than a bare episode number so the jump is legible | UI | UR-062 | Done |
| DR-105 | Video library routes collapse to one per library. `/library/tv` and `/library/movies` render browse / all-titles / genres as in-page tabs driven by `?view=`, omitted for the default `browse` (the convention `searchRouteUrl` already uses for the `all` scope); `resolveLibraryView` is pure and unit-tested. The four legacy routes become redirect-only `+page.ts` loads rather than deletions, because `GenreTags` links to them and users 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 — it was a second navigation affordance to the same destinations the carousels' "Show all" links already reach | UI | UR-063 | Done |
| DR-106 | Erasing watch history goes through the repository, not the local cache: `clear_watch_history(item_id)` maps to Jellyfin's `DELETE /Users/{userId}/PlayedItems/{itemId}`, which clears the played flag *and* zeroes the resume position, and which the server applies recursively to a folder — so one call handles a whole series or season. `OfflineRepository` returns `RepoError::Offline` rather than clearing locally, because history diverged only on the device would be silently undone by the next sync; the button disables itself while the server is unreachable. `ClearHistoryButton` is shared by the series hero and each `SeasonSection` header, confirms before acting (there is no undo), and reloads the page on success so the recomputed current episode — the premiere, for a fully cleared series — is what the viewer sees | Repository | UR-064 | Done |
| DR-107 | Seasons on the series page are collapsible, and **only the current season is expanded** on load — the one holding the episode DR-101 resolved. A show with ten seasons otherwise renders every episode of every season at once, burying the one episode the viewer came for under hundreds of rows. Expansion state is per season and pure (`initialExpandedSeasons` in `seriesNavigation.ts`): the current season, or the first season when there is no current episode, so a never-watched show still opens on season 1 rather than fully collapsed. A `?episode=` deep link expands that episode's season too. Toggling is local and not persisted — it is a reading position, not a preference | UI | UR-062 | Done |
| DR-108 | The instant (cache) leg of `repository_search` searches the **synced catalog**, not just downloads. `OfflineRepository::search` replaces its `downloaded_items` CTE with the `available_items` CTE `get_items` already uses — the same downloads branches plus a `synced_at IS NOT NULL` branch gated on the same `include_catalog_browse()` flag — so search and browse cannot diverge on what is visible. Online (flag true) search reads the whole index and answers before any HTTP request completes; offline with "Show all server media" off (flag false) it stays downloads-only, unchanged. Requires no frontend change, since the flag is already set correctly for all three states. The `include_item_types` filter is switched from string interpolation to bound parameters, as `SearchOptions` is settable from the frontend and not only from `SearchScope` | Backend | UR-065 | Implemented |
| DR-109 | Index freshness is a Rust-owned policy, not a frontend startup call. A tokio task ticks every 30 min and runs a full pass when a repository is active, the server is reachable, and `last_catalog_sync` (already persisted to `app_settings`, previously read only for a UI hint) is older than `CATALOG_INDEX_TTL` (6 h); the `ConnectivityMonitor` reconnect signal re-evaluates the same condition immediately. An `AtomicBool` prevents concurrent passes, replacing `offlineCatalog.ts`'s `syncInProgress` — the frontend trigger is removed rather than left alongside, since two triggers with one guard each is how double-crawls happen. `RepositoryManager` gains an active-handle slot so the task has something to run against. Progress is emitted as the kebab-case `catalog-index-event` | Backend | UR-065 | Implemented |
| DR-110 | Index hygiene. `save_to_cache` switches from `INSERT OR REPLACE INTO items` to `ON CONFLICT(id) DO UPDATE`: REPLACE fires no `AFTER DELETE` trigger unless `recursive_triggers` is on (it is not — only `foreign_keys` and `journal_mode` are set), so `items_ad` never ran, and because `items.id` is a `TEXT PRIMARY KEY` each replacement also took a fresh rowid and appended a second `items_fts` entry — a duplicate index per sync, invisible in results but permanently degrading `MATCH`. The upsert preserves the rowid `items_fts` keys on and fires `items_au`; migration `021_rebuild_items_fts` clears orphans on existing installs. Separately, a post-crawl sweep deletes synced-but-not-downloaded rows a successful library crawl did not return, so media removed from the server stops being searchable; it skips items with completed downloads and skips any library whose crawl errored, because `items.parent_id` is `ON DELETE CASCADE` and a partial crawl would cascade away a whole series | Storage | UR-065 | Implemented |
| DR-111 | The index covers what the result groups render: `CATALOG_ITEM_TYPES` gains `MusicArtist` and `Playlist`, and migration `022_people_fts` adds a `people_fts` virtual table over the existing `people` table (which had no FTS, and is populated incidentally by item-detail fetches) with the same trigger pattern as `items_fts`. `OfflineRepository::search` UNIONs `people_fts` matches in as `Person` items when the resolved scope admits them — i.e. `SearchScope::All`, which expands to no filter (DR-063). Without this, the Artists and People groups UR-060 mandates can only ever be filled by the server leg | Storage | UR-065, UR-060 | Implemented |
| DR-112 | Safe-area insets come from **native**, not from `env()` alone. `env(safe-area-inset-*)` is 0px without `viewport-fit=cover` (missing from `app.html`, so every safe-area rule in the app was already a no-op), and even with it Android WebView maps only the *display cutout* — never the status bar or navigation bar. Since `enableEdgeToEdge()` plus `targetSdk 36` make edge-to-edge unconditional, the WebView always spans the system bars, so CSS could not learn about them by any route. `WindowInsetsBridge` reads the real insets and publishes `jt-inset` custom properties; `app.css` folds them with `env()` via `max()` into `--safe-*`, which is the only thing components may pad from. Ownership is exactly one element per edge: the app shell takes top/left/right, and BottomUi takes bottom wherever it renders (`shellReservesBottomInset` hands it back to the shell on routes with no bottom UI) so the padding sits inside BottomUi's surface box and the colour extends behind the gesture bar. The full-screen players inset their control layers only, leaving video and artwork edge-to-edge. The theme's `fitsSystemWindows=true` — which claimed the opposite and was overridden at runtime and ignored at this target SDK — is removed | UI | UR-066 | Done |
| DR-113 | `MediaItem.user_data` is populated from the server instead of being hardcoded `None`. `JellyfinItem` gains a `UserData` field (`#[serde(alias = "UserData")]` → the existing `UserData` type) and `to_media_item` maps it, so every list and detail response carries favourite/played/resume state. `UserData` is named explicitly in the `Fields=` list rather than relying on Jellyfin's default. Without this no card or detail page can render a favourite it did not itself set, and the mini player's per-track `storageGetPlaybackProgress` fetch is the only way to colour one heart | Repository | UR-069 | Done |
| DR-114 | Server favourite state is mirrored into the local `user_data` table by `OfflineRepository::save_to_cache` — the single choke point every cached server result passes through — so offline browsing and the offline Favourites page see the same favourites as the server. The upsert carries `pending_sync = 0` and is guarded by `WHERE user_data.pending_sync = 0`, which is the conflict rule: a toggle made offline is never overwritten by a stale server value before it has been pushed | Storage | UR-069 | Done |
| DR-115 | Cross-library favourites query: a `get_favorites(scope, options)` repository method plus the `repository_get_favorites` command. Online issues `Filters=IsFavorite&Recursive=true` with `IncludeItemTypes` expanded from `SearchScope::item_types()` in Rust (the frontend sends the opaque scope, never a type list — DR-063); offline reads `items ⨝ user_data (is_favorite = 1)` under the same `include_catalog_browse()` gate as browsing; hybrid races cache against server like `get_items` — saving server results through to the cache on a miss, so the favourites page does not re-query the server every visit and the DR-114 mirror is filled on a fresh install — and applies the DR-080 rule that an empty offline result is authoritative when the gate is off. The command falls back to this read when nothing is cached, rather than painting an empty state it will correct a round trip later. A separate method rather than `get_items` because favourites span libraries and `get_items` is `ParentId`-shaped | Repository | UR-067 | Done |
| DR-116 | `GetItemsOptions.favorites_only` filters an existing library listing in place — online by appending `Filters=IsFavorite`, offline by joining `user_data` into the existing `available_items` CTE so the downloads-only gate still applies. This is what backs the per-library favourites toggle, and composes with the genre and item-type filters already there | Repository | UR-067 | Done |
| DR-117 | The Favourites page (`/library/favorites`) renders favourites across libraries with All / Movies / Shows / Music scope tabs, reusing `LibraryViewTabs` + `LibraryGrid` + `MediaCard` so card shape still follows the media (§5A.1) and a mixed All tab reads as posters, squares and thumbnails side by side. Each tab sends a `SearchScope` value and nothing else. Reached from the library overview and from "See all" on the home rows | UI | UR-067 | Done |
| DR-118 | Home carries favourite rows for movies, shows and music, loaded via `repository_get_favorites` per scope and rendered below Recently Added. A row with no items does not render at all, so a fresh install shows no empty favourite rows | UI | UR-067 | Done |
| DR-119 | `FavoriteButton` is mounted wherever a whole item is shown — movie/series/episode detail heroes, album/artist/playlist headers, and as a `MediaCard` artwork overlay — and a `favorites` store holds in-session optimistic state so un-hearting on one surface updates every other without a refetch. Resolution order is `store override ?? item.userData?.isFavorite ?? false`. On a card the heart is its own button and stops propagation, so hearting never also opens, plays, or triggers the §5B.5 long-press; it is suppressed on server-only (greyed) cards | UI | UR-068 | Done |
| DR-120 | Favourite toggles made while offline reach the server. A Rust drain, triggered by the `ConnectivityMonitor` offline→online transition, pushes every `user_data` row with `pending_sync = 1` and clears the flag on success, leaving failures pending for the next transition. It lives in Rust rather than the frontend because a frontend drain dies with the component that started it. Both the drain and the hybrid background refresh emit the kebab-case `favorites-changed` event (`{ itemIds }`) so open views update — without it a favourite marked on another client appears only on the *second* visit to a page, since the cache-first read returns local rows and the server refresh is invisible to the frontend. Supersedes the unused `syncService.queueFavorite`, which is deleted rather than left as a second queue | Backend | UR-069 | Done |
| DR-121 | Player quality selector: Rust reports the bitrates available for the current media source and owns the quality→transcode-parameter mapping (the one `get_video_download_url` already holds — playback calls into it rather than restating it, or the two tables drift). Changing quality re-negotiates the stream URL and resumes at the current position with audio/subtitle selection preserved. On Linux, video re-negotiates *within* HLS: returning `stream.mp4` is the documented cause of transcoded playback never starting. The frontend renders the list and remembers the choice; it does not decide what the choice resolves to | UI | UR-070 | Proposed |
| DR-122 | The playback path is ephemeral. Streamed bytes are never persisted unless DR-124 rules them keepable, and any in-flight capture is abandoned — partial file deleted, never promoted — the moment the viewer changes quality, because a capture spanning a rendition change is a splice of two encodings rather than a playable file | Playback | UR-070 | Proposed |
| DR-123 | The download path is independent of playback: a whole-file fetch through the existing download manager at one canonical quality (default `original`, the direct static copy) over the Range-capable `/Videos/{id}/stream.mp4`, unaffected by bitrate changes and completing into an ordinary `downloads` row so offline browsing and `refresh_queue_local_sources` pick it up unchanged. Prerequisite: downloaded video is currently never played locally — `repository_get_video_stream_url` goes straight to the online repo and the player route calls it with no local check, so a completed video download is still streamed. Without that fix nothing in this spec is observable for video | Repository | UR-071 | In Progress |
| DR-124 | Streamed bytes are kept only where they *are* the download artifact — a direct-play session. Android uses ExoPlayer `SimpleCache`/`CacheDataSource` keyed by item **and** media-source id so renditions cannot collide, sharing the existing smart-cache storage budget rather than opening a second one over the same disk; Linux audio uses mpv `stream-record`, abandoned on seek because it is documented as intended for linear streams and seeking breaks the recording. Transcoded Linux video is **not** captured: HLS segments are not a file, and assembling one needs ffmpeg, which is not a dependency and which CI may not install at job time — DR-123 covers that case instead | Playback | UR-071 | Proposed |
| DR-125 | A capture is promoted to a completed `downloads` row only when it covers the whole resource; partials stay evictable cache. A new `downloads.source_rendition` column records the negotiated quality/container/codec (`NULL` for the existing paths, which are always `original`) so a captured transcode and a real download are distinguishable rows and an "upgrade to original" remains possible. A quality change never touches a file that already exists — not a permanent download, and not a completed temporary one, both of which stay valid copies of the rendition they hold. It invalidates only an **in-flight** capture or background download of cached media, which is abandoned and restarted at the newly chosen quality, because a capture spanning a rendition change is a splice of two encodings rather than a playable file | Storage | UR-071 | Proposed |
| DR-126 | Cache eviction only reclaims the *temporary* tier. `evict_lru_async` selected every completed download ordered by `completed_at ASC` with no `download_source` filter, so hitting the 10 GB 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 because rows predating migration 012 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 space" instead of silently deleting them | Storage | UR-071 | Done |
| DR-127 | A cache entry *is* a download with a shorter life: same `downloads` row and same file handling, distinguished by `download_source = 'auto'` plus an expiry, so there is one storage model rather than a cache and a download library that can disagree. Temporary rows are reclaimed on whichever comes first — the life limit elapsing, or eviction under space pressure (DR-126). Permanent (`'user'`) rows have no expiry. A temporary row can be promoted to permanent by the user choosing to keep it, which only clears the expiry and flips the source; the bytes never move | Storage | UR-071 | Done |
| DR-128 | Audio-only playback of *downloaded* media reads the local file rather than fetching an audio-only stream. No transcode is involved or wanted: the Linux backend already runs MPV with `video: no`, so handing it the downloaded video file decodes the audio track and ignores the video, and ExoPlayer disables its video renderer equivalently. Transcoding to a separate audio artifact would cost CPU and battery, need an encoder the project does not ship, and produce a second file to keep in step — for no gain over simply not decoding the video | Playback | UR-071 | Done |
| DR-129 | A stream that stops delivering is recovered, not treated as terminal. Two failure shapes, because the streams differ. (a) *Phantom end* — the background audio-only handoff uses a progressive mp3 transcode over plain HTTP, chunked and therefore length-less, so a dropped connection reaches the player as end-of-input and ExoPlayer reports `STATE_ENDED` indistinguishably from the real end. The item's runtime is the only thing that can tell them apart: an end reported more than a tolerance short of it (comparing the *absolute* position — handoff base plus the player's relative position) is a truncation. Left unhandled, playback parked in `STATE_ENDED` and the next play intent from the lockscreen, notification or a Bluetooth reconnect seeks an ended player to position 0 — the user-visible "the episode randomly restarted". (b) *Recoverable error* — music (`/Audio/{id}/stream?Static=true`) and video (`/Videos/{id}/master.m3u8`) declare their length, so the player detects the truncation itself and raises an error; the frontend's handler stopped playback outright, turning a hiccup into silence. Both resume the current item **in place** (never via `play_item`, which would replace the queue with a single item and lose the album), the error path after a per-attempt backoff. Seekable streams are re-prepared at the URL they already have and seeked; the length-less transcode, which cannot be seeked, has `StartTimeTicks` rewritten into its existing URL so the user's audio-track selection survives and recovery needs no network round-trip. Only `Remote` sources qualify — a local file cannot fail from the network. A shared budget of consecutive attempts at the same position, refilled whenever playback progresses, stops an unreachable server from looping | Playback | UR-040, UR-004 | Done |
| DR-130 | A backend's position and duration must survive the end of the file they describe. MPV exposes `time-pos`/`duration` as properties of the *loaded* file, so at EOF it unloads and both stop resolving — the accessors reported `0.0`/unknown at exactly the moment end-of-file handling asks where playback reached, and any position-versus-runtime check would have read every natural end as a truncation. The poll thread records the last reading and the accessors fall back to it. Linux resilience is layered on the same principle that the stream, not the player, is what failed: MPV is configured with ffmpeg reconnection (`stream-lavf-o`, `network-timeout`) so ordinary blips never surface, and `EndFile(ERROR)` — previously a bare log, which left playback halted while the UI still showed "playing" — is emitted as a *recoverable* error. Because MpvBackend is constructed before `PlayerController` exists, it cannot decide in-process like the Android JNI callback: the frontend echoes the error into `player_recover_stream`, which keeps the decision in Rust (the same shape as `PlaybackEnded``player_on_playback_ended`). Android reports errors it has already declined as *unrecoverable*, so the echo never asks twice | Playback | UR-004, UR-040 | Done |
| DR-131 | The offline mutation queue is drained. `sync_queue` had producers and no consumer: `PlaybackReporter::queue_for_sync` writes a row for every start/stop/mark-played that cannot reach the server, `sync_mark_processing`/`_completed`/`_failed` were registered commands with no callers, and no Rust task processed the table — so queued watch positions never reached Jellyfin and the offline banner's count only ever grew. A drain hangs off the same `connectivity:reconnected` transition as DR-120 (in Rust, because a drain started by a component dies with it) and replays rows oldest-first, so a stale start cannot move the server's resume position backwards after a later stop. `update_progress` replays as *stopped at N* rather than as progress — replaying a mid-playback report hours later would claim the item is still playing — and payloads are read in both dialects that exist in users' databases (`position_ticks` from Rust, camelCase `positionMs` from the frontend helper). A failed row stays queued for the next reconnect; after `MAX_SYNC_ATTEMPTS` it is `abandoned` and stops counting, because a row nothing can ever push is what turns the queue into a counter that only grows. An *unreachable* server is not counted as an attempt at all — the row goes back to `pending` untouched — so opening the app offline a few times cannot abandon good rows; only a server that answers and refuses spends the budget. The drain also runs once at startup, because a queue built in a previous session would otherwise sit untouched for a whole run whenever the server was reachable the entire time and no offline→online transition ever fired. Requires `MediaRepository::mark_played` (JA-035) — the previous stand-in reported a stop at `i64::MAX` | Backend | UR-025, UR-002 | Done |
| DR-132 | The pending-sync count is answerable. The offline banner's badge read "N pending sync(s)" and led nowhere, so it was taken for pending *transfers* and looked for on the Downloads page — which lists the `downloads` table and structurally cannot show `sync_queue` rows. The badge becomes a button opening the queue it counts: each row's operation, the item's title (resolved by a `LEFT JOIN items` in `sync_get_pending`, not a per-row frontend fetch), when it was queued, and the error of anything failing, plus a "Sync now" that runs the DR-131 drain on demand. The same list is a Settings section, because a row that keeps failing is still queued when the server is reachable and no banner is on screen. The drain emits `sync-queue-changed` so the badge updates on reconnect instead of lagging by up to one 10s poll | UI | UR-025 | Done |
| DR-133 | A downloaded file has exactly one on-disk path, and the row that names it is authoritative. `downloads.file_path` starts relative to the storage root, but the worker rewrites it to the absolute path it actually wrote when the transfer completes — so a *completed* row is already rooted. The video player's offline branch rooted it a second time, handing the asset protocol `/data/user/0/app//data/user/0/app/videos/x.mp4`; the webview reported `MEDIA_ERR_SRC_NOT_SUPPORTED` with `NETWORK_NO_SOURCE`, so every downloaded video failed to play while audio — which resolves the same column through Rust's `resolve_local_media_path`, without re-rooting — played fine. The join is absolute-aware (POSIX, Windows drive letters and UNC) so rows written before completion still resolve | Playback | UR-071 | Done |
| DR-134 | The webview can actually fetch the local files it is handed. `convertFileSrc` rewrites a path to `http://asset.localhost/…` unconditionally, but Tauri only answers that origin when the `protocol-asset` cargo feature is compiled in *and* `app.security.assetProtocol.enable` is set — neither was, so every such URL reached a protocol with no handler and the webview reported `NETWORK_NO_SOURCE`. This silently defeated both offline video (`<video src>`) and the cached-thumbnail path in `imageCache`, which fails soft to the server copy and so hid the breakage whenever the server was reachable. The scope was `$APPDATA/**` — the storage root under which the database, `downloads/` and the thumbnail cache all live — rather than an unrestricted grant; DR-198 narrows it further to `$APPDATA/thumbnails/**`, since DR-137 moved downloaded media off this protocol and thumbnails are all it still serves | Security | UR-071 | Done |
| DR-140 | An audio track is pinned only when the user picked one. Jellyfin's `MediaStream.Index` is global across every stream in a media source, so index 0 is the *video* stream on virtually all files — yet `AudioStreamIndex=0` was sent as "the first audio track" on the HLS transcode URL, the background audio-only handoff URL, the direct-play fallback URL, and the `PlaybackInfo` negotiation body. A server that honours the request literally then transcodes the video stream into the audio slot and the result plays as a picture with no sound; only servers that silently correct the index hid the bug, which is why it presented as "some videos have no audio". The parameter is now omitted whenever no track has been chosen, so the server resolves the source's `DefaultAudioStreamIndex`; an explicit selection from `player_switch_audio_track` is still carried through unchanged. On the `static=true` direct-play URL it is dropped outright — the original file is served untouched, so the parameter could only mislead | Playback | UR-004, UR-040 | Done |
| DR-147 | One search input per screen, and the URL is the search's single source of truth. The header bar rendered only under `/library/**` and merely *navigated* to `/search` (DR-063), so a desktop search handed the user to a screen whose input was a different element — the header box cleared itself and vanished, and the page's own box took over mid-word. That page then re-derived its input from `?q=` against `library.searchQuery` on every store write, so the next keystroke re-ran the effect and snapped the text back to the query the header had sent (and a scope chip back to the URL's scope); entering from the bottom-nav Search tab skipped it only because the effect early-returned on an empty query. The bar now renders on `/search` too (`showHeaderSearch`) and is the sole md+ input — the page's own input is `md:hidden` — and on that route it republishes the query into the URL with `replaceState`, so a whole session of typing costs one history entry. The page *consumes* that URL once per distinct value (`seedFromSearchUrl` against a non-reactive `applied` marker) instead of continuously reconciling it, and the scope chips publish through the same URL so the bar and the chips cannot disagree. Landing on `/search` with a seeded query focuses the bar and puts the caret at the end, because the box the user was typing in belonged to the unmounted route | UI | UR-049, UR-054 | Done |
| DR-142 | An episode has exactly **one** surface, and it is complete. Two divergent renderings existed: `EpisodeFocusView` (reached from Continue Watching, the series episode list, the TV landing page and Downloads — i.e. every real entry point) offered only Play and Favourite, while the bare `/library/<episodeId>` page nobody routed to carried the download button, the series/season breadcrumbs and the cast section. Opening an episode the normal way therefore silently lost the ability to download it. The Focus View is now the single surface and carries the full §5B.2 composition — hero action row `Play / Download / Favourite`, series name and `SxEy` badge as links back to the series and to that season's anchor, then genres → cast → similar shows *below* the episode strip, never above it (DR-062). `/library/<episodeId>` redirects into it (`episodeRedirectTarget`, the same rule seasons follow under DR-103), and an episode with no `seriesId` renders the same component series-less rather than falling back to a second, lesser page. The focused episode is fetched in full rather than reused from the season fan-out, because that is a *list* query and carries neither cast nor genres — the sections would have rendered empty. The strip hides itself when the episode has no siblings, a card that only shows the episode you are already on being noise | UI | UR-048, UR-058 | Done |
| DR-141 | The device profile states how many channels the audio route can actually voice. `MediaCodecList` answers "can this device *decode* 5.1", which is not the question that decides whether the user hears anything — a phone decodes an AC-3 5.1 track happily and still has two channels to play it out of. With no `MaxAudioChannels` in the profile, Jellyfin was free to direct-play the multichannel track, and the result is device dependent: a failed `AudioSink` configuration (silence) or dialogue folded into surround channels that go nowhere. media3's `AudioCapabilities.maxChannelCount` for the current route is reported over JNI alongside the codec lists, and bounds both the direct-play profile and the transcoding profiles, so the server downmixes rather than shipping channels the sink cannot take. Codecs are never removed from the profile — a device with genuine surround output keeps direct-playing it. A missing or zero reading means "route not yet established", not "no audio", and falls back to stereo, the one capability every sink has | Playback | UR-004 | Done |
| DR-145 | Video playback starts only once the app actually holds audio focus. Video manages focus by hand (`handleAudioFocus=false`, because ExoPlayer's automatic handling is reserved for the audio path), and the request's three outcomes were all treated as success: `AUDIOFOCUS_REQUEST_DELAYED` — which `setAcceptsDelayedFocusGain(true)` explicitly invites, and which means the system is *withholding our audio* until it calls back — and an outright `REQUEST_FAILED` were logged and then followed by `playWhenReady = true`. The picture rolled with no sound, indistinguishable to the user from a broken stream. Playback is now held when focus is not granted and started from the `AUDIOFOCUS_GAIN` callback; an explicit `play()` re-requests focus rather than resuming into a stream the system is still muting, guarded by a held-focus flag so repeated plays do not leak focus requests. A `LOSS` clears the pending flag, so an unrelated later `GAIN` cannot start playback the user never asked for | Playback | UR-004 | Done |
| 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 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 |
| DR-154 | A watch position that cannot reach the server is queued, not dropped. `sync_queue` and its drain (DR-131) were built, tested and running, but the stop-report path never fed them: `HybridRepository::report_playback_stopped` is a bare pass-through to the online repository ("Playback reporting goes directly to server"), and on failure the error surfaced to a frontend `catch` whose own comment read "Server error - could queue, but for now just log". Both producers that *would* have queued it — `PlaybackReporter::queue_for_sync` in Rust and `syncService.queuePlaybackProgress` on the frontend — have no callers on the playback path, so closing a video while the server was unreachable lost the resume point outright even though `user_data.pending_sync` was dutifully set to 1 and nothing ever drains that flag for positions (unlike favourites, DR-120). The command layer now enqueues a `report_playback_stopped` row whenever the push fails, which the existing drain already knows how to parse and replay. The pending row for an item is **superseded in place** rather than appended to: progress is reported every 10s, so a server that stays down would otherwise add a row per tick, all of them obsoleted by the newest — the unbounded queue DR-131 exists to prevent. Only `pending`/`failed` rows are superseded, because an `abandoned` row has been given up on and reviving it would restore that same growing counter. Queueing is best-effort and never fails the command: the local position is already saved, so a failed *queue* write must not be reported as a lost position | Backend | UR-025, UR-002 | Done |
| DR-155 | A watch position set on another device reaches this one. The resume check reads the local `user_data` row and nothing else, but `mirror_user_data` — the only path by which server `UserData` lands in that table — mirrored `is_favorite` alone, and returned early whenever that field was absent, which is exactly the shape of an ordinary watched episode. So `playback_position_ticks` was write-only from this device's perspective: watch 40 minutes in a browser, open JellyTau, and it resumed from whatever *this* device last saw or offered no resume at all — the same user-visible symptom as DR-150's Android bug, from an unrelated cause, which is why resume read as broadly flaky. The mirror now carries the position alongside the favourite flag under the same `pending_sync = 0` conflict rule, so a local position still waiting to be pushed is never pulled *backwards* by a server that has not yet heard where we got to; `COALESCE(excluded.x, user_data.x)` means a field the server omitted keeps its stored value rather than being nulled, and a row with neither field is still skipped rather than fabricated as zeroes. Mirroring alone was not sufficient: `get_item` — the call the player route makes — returned the cached copy on a hit and never consulted the server, so for an already-cached item the mirror never ran. It now refreshes in the background on a cache hit (`race_with_refresh`, the reusable form of what `get_items` already did inline), which is why browsing a season picked up other devices' state while opening the episode directly did not. The refreshed value lands for the next read, the cache-first race still answering immediately | Backend | UR-025, UR-002 | Done |
| DR-156 | A page no longer inherits the previous page's scroll position. The shell keeps its scrollers alive across navigation by design — the root layout, the home page and the library layout each own a `flex-1 overflow-y-auto` box that outlives the route inside it, which is what lets `BottomUi` be a flex sibling rather than a measured overlay — but the element therefore never remounts and its `scrollTop` survives the route change. SvelteKit's own scroll restoration could not help: it saves and restores `window` scroll, and in this app the window never scrolls at all, so there was no scroll handling of any kind. The symptom was that opening an item from half-way down a library grid dropped the viewer half-way down the detail page, and returning to the grid landed at the top of it — exactly backwards. `ScrollMemory` (pure, one instance per container, keyed on path + query so a genre-filtered grid keeps its own place) records the offset a route is left at in `beforeNavigate` and decides in `afterNavigate`: `link`/`goto`/`form` reset to the top, `popstate` restores that route's saved offset, and the initial `enter` is left alone. Deciding does not consume the offset, so a route returned to more than once restores each time. Applied via the `scrollContainer` action on all three scrollers | UI | UR-072 | Done |
| DR-160 | Picture-in-picture works on the path that actually plays video. PiP shrinks the whole *Activity*, so `canEnterPip` demanded a native ExoPlayer `SurfaceView` be attached and rendering — `isPlayingVideo() && getSurfaceView() != null && isVideoSurfaceAttached()`. But the native path sits behind `experimentalNativeVideo`, which defaulted to **off**, so in the shipping configuration video played in the WebView's `<video>` element and all three conditions were false. `enterPip` bailed with "Not entering PiP: no local video playing" every single time: the button was offered (gated only on OS capability) and could not work, however it was pressed. The manager now accepts either surface. The frontend reports the element through `AndroidPictureInPicture.setHtml5VideoState(active, width, height, playing)` — intrinsic size because the PiP window's aspect ratio came from the letterboxed surface's measured bounds, which do not exist here, and play state because `ExoPlayer.isPlaying` is false on this path and the PiP play/pause action would be frozen on "Play" mid-playback. Two behaviours invert when the WebView *is* the video: it must stay visible in PiP rather than be hidden (`hideWebView` is now gated on the native path — hiding it would leave an empty black window), and the play/pause `RemoteAction` has to reach the element, so the receiver dispatches `jellytau-pip-play`/`jellytau-pip-pause` DOM events instead of driving ExoPlayer. `jellytau-pip-entered`/`-exited` let the player strip its own chrome, since controls, title and gradients would otherwise be rendered into a window a couple of inches wide. The `<video>` is deregistered on teardown so PiP is never offered over a video that has gone | UI | UR-041 | Done (pending device verification) |
| DR-167 | Each downloaded library shows only its own media. Cached items carry no link back to their library — `library_id` and `parent_id` are NULL on every row ([[offline-libraries-never-cached]]) — so `get_downloaded_items` matched the library branch with `EXISTS (SELECT 1 FROM libraries l WHERE l.id = ?)`, which asserts only that the requested library *exists* and never constrains the item to it. Opening any downloaded library therefore listed every downloaded top-level item on the server: films under Music, albums under TV. The sibling query that decides which libraries *appear* already carried the right rule — a `collection_type``item_type` mapping — so the two disagreed about the same question. That mapping is now the named constant `LIBRARY_HOLDS_ITEM`, used by both, and a library of unknown collection type still keeps everything rather than being emptied by a rule that cannot classify it. The taxonomy stays in Rust, never the frontend | Downloads | UR-055 | Done |
| DR-168 | Pause and resume actually stop and restart the bytes. `pause_download` wrote `status = 'paused'` and did nothing else, and no cancellation existed anywhere in the download stack — no token, no flag, no abort — so the streaming task ran on, kept writing, and overwrote the row with `completed`/`failed` when it finished: the row flicked to "paused" and undid itself. `resume_download` had the mirror defect, flipping the row to `pending` without calling `pump_download_queue`; the pump runs when something calls it rather than polling, so a resumed download sat untouched until an unrelated event happened to pump the queue. A per-download stop flag (`download::stop`) is the missing half — a module-level registry because the two sides never meet, the command holding Tauri state and the worker running detached in `async_runtime::spawn`. The worker reads it between chunks and on retry (so a pause is not swallowed by a 45-second backoff), flushes, and returns `Stopped`, which is deliberately **not** retryable and **not** recorded as a failure: the `.part` file is left intact because that is exactly what the resume's Range request continues from. Registering returns a *fresh* flag, or a resumed download would inherit the pause that stopped it and halt instantly. Cancel and `clear_stale_downloads` signal it too, so neither deletes a file still being written | Downloads | UR-055 | Done |
| DR-169 | Partial files are actually reaped. The worker named its sidecar with `Path::with_extension("part")`, which *replaces* the extension — `movie.mp4` became `movie.part` — while every cleanup path deleted `"{file_path}.part"`, i.e. `movie.mp4.part`. The two never matched, so the partial file of every cancelled or failed download stayed on disk indefinitely, invisible to the disk-usage totals because no `downloads` row pointed at it. `partial_path` appends instead, is the single definition both the writer and the cleaners use, and incidentally removes a collision the old form had, where `movie.mp4` and `movie.mkv` mapped to one `movie.part` | Downloads | UR-055 | Done |
| DR-173 | Downloading an album queues the **whole** album, and every track it queued is findable offline afterwards. Two independent gaps left an album with a handful of its tracks on the device while the button reported the album as downloaded. First, `download_album` took its track list from `items WHERE album_id = ?` — the local catalog cache. Jellyfin does not return `AlbumId` on every listing endpoint, so tracks cached by one of those endpoints sit in `items` with a NULL `album_id` and are invisible to that query; on the reporter's database three whole albums (18, 12 and 9 tracks) had it NULL on *every* track, so "download album" would have queued nothing for them, and a partially-linked album queued only the linked subset. Second, the frontend then resolved one stream URL per track from its own list and paired it with the returned row ids **by position** — a pairing with no basis, since the ids came back in the backend's `index_number` order over a different set of rows, so a row could be handed another track's URL and any track past the end of the shorter list was never started at all; on Android that loop also stopped wherever the webview was suspended. The same `album_id` is what `OfflineRepository::get_items` joins a track to its album on, so a track that did download stayed invisible under its album offline — the two halves of the same missing link. The operation now belongs to Rust end to end: `HybridRepository::get_album_tracks` asks the **server** what the album contains (cache-first `get_items` is right for browsing and wrong for deciding what to download) and errors offline so the caller falls back to the ungated local catalog, keeping the queue-while-offline flow; `queue_album_tracks` writes the album link onto every track it queues — queuing a track *is* the statement that it belongs to the album, rather than something to hope a listing endpoint recorded — and the stream URLs are resolved here through the existing reconnect resolver, now scoped to the rows just queued so one album cannot start every unrelated pending row. Nothing crosses the IPC boundary but the album id. Re-queuing a broken album heals it: the missing tracks are added and the tracks already on disk get their link. `download_series`/`download_season` still derive their episode lists from the cache the same way and want the same treatment | Downloads | UR-018, UR-055 | Done |
| DR-170 | Downloads at a chosen bitrate are no longer corrupted by their own retries. Only the `original` preset asks for `Static=true`; every other rung requests a **transcode**, which Jellyfin serves chunked, with no `Content-Length`, and cannot byte-seek — so it ignores `Range` and answers `200` with the whole stream from the beginning rather than `206` with the requested tail. The worker sent the Range header whenever a `.part` existed and appended the body unconditionally, so each retry and each resume concatenated a fresh copy of the entire transcode onto the bytes already on disk: the file grew past its real size and would not play, which is why "downloads for different bitrates" stayed broken after the `videoBitRate` casing fix (DR-adc460f3) corrected the *request*. `resume_offset` makes the response decide — append only on a `206`, otherwise truncate and take the stream from the top — and the total size is computed from that offset rather than from a partial length the server never agreed to | Downloads | UR-071 | Done |
| DR-172 | Native Android video is opt-in again, because as a default it shipped as **audio with no picture**. DR-161 flipped `experimentalNativeVideo` on so picture-in-picture could shrink a real video surface; on a device that produced sound and a blank screen. The decode path was never the problem — logcat showed ExoPlayer running (`Position update` ticks) and feeding a live `SurfaceView` with an active BufferQueue. The compositing was: the SurfaceView sits *behind* the WebView, and the step that clears the opaque layers above it never took effect, with `WebView transparent = false` logged and `= true` never appearing. So the video rendered correctly the whole time, behind an opaque page. This is exactly the defect the flag existed to contain — `VideoPlayer.scrubRegression.test.ts` had recorded that "the native SurfaceView has never been visible through the webview" — and enabling it by default shipped a verified decode path on top of an unverified display path. Reverting costs nothing that matters: PiP does not depend on it (DR-160 drives PiP from the WebView `<video>`), and working video outranks PiP showing a native surface. The flag stays available in Settings, now described as incomplete rather than as a performance win, and the scrub-regression mocks that were made explicit under DR-161 are kept explicit so those tests state which path they guard rather than inheriting a default that has now moved twice. Fixing the compositing is the prerequisite for trying this default again | UI | UR-003, UR-004, UR-041 | Done |
| DR-171 | A downloaded video keeps audio the device can actually decode. `original` quality asked for `Static=true`, which hands back the source file byte-for-byte — E-AC-3/AC-3/DTS/TrueHD track included — and video is rendered on both platforms by the webview `<video>` element, which decodes none of them. Streaming already knew this: DR-149 judges the track the server would serve against `WEBVIEW_AUDIO_CODECS` and forces a transcode over Jellyfin's own direct-play offer, because 10.11.5 honours a `DirectPlayProfile`'s container and video codec but ignores its audio codec. The download path never consulted that policy, so the *same film* had sound when streamed and played as picture in silence once downloaded — and offline a download is the only source a video has, so there was no working path left to fall back to. The rule is now one rule: `served_audio_codec` picks the track the server will serve (the default, or the first when none is marked) and both callers judge it, the streaming verdict staying a bool and the download path needing the codec itself so it can say what to re-encode. Only the audio is re-encoded — `allowVideoStreamCopy=true` keeps an h264 source's picture byte-for-byte and no bitrate or resolution cap is added, so `original` still means original quality; a source the webview could not have rendered anyway (HEVC) becomes h264 as a side effect, which is the only form of it that would have played. The decision is per item rather than blanket because the transcode costs the byte-range resumability `Static=true` gives the download worker (see DR-170 for what a chunked, length-less response does to a resume), so a file whose audio already plays keeps the direct copy. An unknown codec — item not fetchable, or the server named none — changes nothing: the policy only ever *adds* a transcode, so it cannot make a working download worse. The codec set judged against is the **webview's**, not the platform's, even though DR-161 made ExoPlayer the Android default: `experimentalNativeVideo` is a user setting, a downloaded file outlives whatever it was set to when the file arrived, and the narrow list is the only one that holds on both sides of it — at the cost of a Dolby-licensed device re-encoding a track its ExoPlayer could have played. `resolve_video_download_url` is the single entrance for all three resolution sites (the frontend's per-item command, the bulk series/season enqueue, and the offline-queued resume), since the pure builder cannot look a codec up and a caller that forgets to is exactly how the silent downloads shipped. **Files already downloaded stay silent** — the bytes on disk are the wrong bytes and only a re-download replaces them | Downloads | UR-071, UR-004 | Done |
| DR-162 | Video streams are opened against a **bandwidth ceiling the user chose**, instead of a fixed allowance nobody could change. Every video URL carried `MaxStreamingBitrate=20000000`/`VideoBitrate=18000000`, `PlaybackInfo` negotiated at 20 Mbps, and the device profile advertised `999999999` — so on a metered or slow connection the only lever was not watching. `StreamingQuality` is a ladder of ceilings (Original, 20/10/8/4/2/1 Mbps, 720 kbps) in which a step is not a label but a bundle of transcode parameters: the total ceiling, the audio share of it, and the resolution that budget can carry. It lives in Rust because those numbers are Jellyfin encoding vocabulary — the frontend names a variant and reads labels back over `player_get_streaming_qualities`, the same arrangement as the EQ preset curves. The video bitrate is the total *minus* the audio share, so the two together honour the cap rather than overshooting it by the size of the audio track, and `MaxHeight` falls with the ladder so a small budget is not spent on pixels it cannot afford. The cap has to reach the **negotiation**, not only the transcode URL: `max_static_bitrate` in the device profile is what makes the server refuse to direct-play a source fatter than the ceiling, and without it a 30 Mbps remux is handed over untouched and every URL parameter downstream is moot — which is why it is applied at all four places that decide bandwidth (the HLS builder, `PlaybackInfo`, `open_live_stream`, and the background-audio handoff, which takes the lower of the cap and its own 384 kbps). The ceiling is process-wide rather than a field on `OnlineRepository`, mirroring `INCLUDE_CATALOG_BROWSE`: it is a preference about *this device's connection*, it must survive a repository rebuilt on re-login, and every builder plus the negotiation have to agree on it or the cap leaks. Settings owns the durable default and is the only writer to `app_settings` — persisted unlike the rest of `VideoSettings`, because a limit set for a metered connection that silently reverts to uncapped on the next launch spends the user's data with no changed setting to show for it — and it is restored at startup from the async runtime, defaulting to uncapped if the read fails so a database problem degrades to the old behaviour rather than to an arbitrary limit. The in-player menu is the per-video override: a cap is a property of the stream the server is producing, so it cannot be applied to one already in flight — `player_set_stream_quality` re-opens the stream at the new quality and resumes at the current position, reloading a native backend itself and handing HTML5 a URL for the same `reloadSource` primitive the audio-track switch uses, so no strategy branch lives in the UI. It deliberately does not persist. This gives UR-070 its resume-at-the-same-point mechanism; the server-offered per-item rendition list that requirement also asks for remains proposed | Playback | UR-074, UR-070 | Done |
| DR-174 | Tiles of mixed shapes are laid out **justified** rather than gridded. A CSS grid gives every cell one box, so on a page holding square music covers, 16:9 library backdrops and 2:3 posters at once, everything that is not the chosen shape is cropped to it — the home shortcut strip was explicitly forcing `aspect="video"` on music libraries for exactly this reason, which lined the row up by cutting the covers down. `layoutMosaic` packs tiles into rows of a **shared height** and gives each its own width from its own aspect ratio: it adds tiles to a row until the height needed to fill the container has fallen to the target, closes the row there (so rows land at or below the target, never above), and justifies the row to the container width by absorbing the rounding remainder into its widest tile, where a pixel is least visible. The last row is deliberately *not* justified — with one tile left over, filling the width would inflate it to a banner — so it sits at the target height, left-aligned. Ratios are clamped to a band, which costs a crop on genuine outliers and stops one panorama owning a row or one very tall image shrinking to a sliver. It is a pure module with no DOM: the component supplies only the two things the DOM knows — the measured container width, and the artwork's *decoded* aspect ratio, reported by `CachedImage` so the layout uses the shape an image actually has rather than the one its item type implies. Those measurements are committed in one debounced batch rather than per image, because artwork arrives over several hundred milliseconds and re-packing on each arrival would shuffle the grid under the pointer repeatedly. Labels are drawn *over* the bottom of each tile rather than beneath it: a caption below sits outside the computed box, and one that wraps to two lines would break the row alignment the layout exists to provide | UI | UR-075 | Done |
| DR-175 | A library knows which favourites category it belongs to, and the frontend does not work it out. The mosaic offers a favourites tile per category beside its library, which needs a collection-type → category answer; deriving it in Svelte would have re-created the exact leak `SearchScope::item_types` was extracted to close (docs/specs/scoped-search-boundary.md) — one table of Jellyfin vocabulary, differing only in which vocabulary. `SearchScope::for_collection_type` maps `movies`/`tvshows`/`music` and returns `None` for everything else, so a Live TV or books library gets no tile at all rather than one opening an unfiltered list; `All` is never derived from a library, being the cross-library entry offered beside them rather than a property of one. `Library::new` stamps the result onto every library at construction — a constructor rather than a struct literal precisely so a derived field cannot be forgotten at one of the four sites — and it rides to the frontend as an optional `favoritesScope`, absent rather than null when there is none. The UI's remaining share is presentation only: what to call the tile, where to put it, and showing a category's tile **once** however many libraries share it, since two movie libraries have one favourites list between them | UI | UR-075, UR-067 | Done |
| DR-176 | The server is never asked to burn a subtitle into the picture. `PlaybackInfo` omitted `SubtitleStreamIndex`, which does not mean "none" — the server then honours the source's default/forced flag and picks a track itself. On a source whose default subtitle is image-based (PGS/DVD/DVB) that track cannot go out as a sidecar, so the server falls back to `SubtitleMethod=Encode` and composites it into the video. The cost lands on the *video*, not the subtitle: burn-in rules out remuxing, so an HEVC stream the device could have taken untouched is re-encoded frame by frame. Observed on an HEVC + E-AC-3 + PGSSUB episode, where only the audio actually needed transcoding: the server could not sustain the re-encode in real time, the buffer never grew past a single segment, and playback stalled every few seconds — taking seeking with it, since each seek restarted the encoder and cost seconds before the first frame. The fix is to request `SubtitleStreamIndex=-1` explicitly and to advertise every *text* format we can render (`srt`/`subrip`/`ass`/`ssa`/`vtt`) as `External`, so a subtitle can only ever arrive as a sidecar. Nothing is lost, because the app already fetches subtitle tracks itself and draws them over the video (UR-020) — the server's composited copy was always redundant. Image-based tracks are consequently not offered, which is honest rather than a regression: the renderer cannot composite a bitmap, and the previous behaviour paid for them by making the whole stream unwatchable. Both halves of that hold at the layer that can enforce them. The sentinel travels on the stream URL as well as in the negotiation, because the negotiation is not what opens most streams — a quality switch, a transcoded seek and an audio-track switch each rebuild the URL on their own, and an omitted index there lets the server pick the default track back up out of whatever session state it still holds. And "not offered" is enforced where the offer is made: each subtitle stream crosses the boundary carrying the backend's verdict on whether it can arrive as a sidecar, so the picker lists only tracks the app can draw instead of showing an entry that ticks and displays nothing. Only an explicit "no" hides a track, so a stream carrying no verdict behaves as before | Playback | UR-020, UR-004 | Done |
| DR-177 | Each video transcode this device opens is its own server-side job, and the one it replaces is stopped. Jellyfin keys a transcode job by device **and** play session, and every stream URL the app built carried the same hardcoded `DeviceId` with no `PlaySessionId` at all — so the second stream for an item was indistinguishable from the first. Re-opening a stream is not rare: a mid-playback quality switch (UR-074), a transcoded seek and an audio-track switch all do it, each leaving the previous ffmpeg running. Observed on-device when switching bitrate mid-film: the server served the new playlist, then rejected the new job's segments with `400 hls1/main/0.ts` while the two jobs contended for one transcode path, and playback stalled — reproducible against the server, where a second stream for a live job's item alternates between serving bytes and 400ing per attempt, which is what made it read as flaky rather than broken. `begin_video_play_session` mints a session id per open and reports the one it supersedes; the URL builder stops that job (`DELETE /Videos/ActiveEncodings`, un-retried and best-effort — a slow stop must not delay playback, and the new stream no longer collides either way) before returning. Placing it in the URL builder rather than in each caller means every re-open path is covered by construction. Two client faults made the same incident worse and are fixed with it: the fatal-HLS-error handler added the transcode seek offset to a position that already included it, so past roughly the halfway mark of a film any transient network error cleared the "near end" threshold and was reported as end-of-stream — turning a recoverable stall into a skip to the next item, exactly when a quality switch had just made the offset large; and the HTML5 reload primitive resolved on its own `canplay` timeout, so a reload the server never served reported success, leaving the picker showing a quality that was not playing and the caller with nothing to revert | Playback | UR-074, UR-004 | Done |
| DR-178 | Every position that leaves the app is read from the controller, not from a backend that may not be playing anything. `PlayerController::position()` forwards to the native backend, which is authoritative for exactly one of the three ways this app renders media. On the **webview** path — the shipping default for video on both platforms — nothing is loaded into that backend at all: the `<video>` element is the player, its ticks were re-emitted to the frontend and then dropped, and the backend answered 0 forever. During a **background-audio handoff** the base that converts the stream's relative timeline to the episode's is applied once at the native tick boundary (DR-159), so before ExoPlayer's first tick nothing has applied it and the reading is 0 there too. Both holes surfaced as the same user-visible bug through different doors: returning to the foreground while the audio-only transcode was still opening handed the frontend `0.0`, and the video reloaded at `StartTimeTicks=0` — the episode restarting from the beginning — while the `Stopped` report that followed wrote that zero to Jellyfin as the resume point. `absolute_position()` answers for all three paths: the maximum of the backend's reading, the last position webview-rendered media reported, and the handoff base. The maximum is exact rather than a heuristic, because at most one term is ever meaningful at a time and the base is a floor the stream cannot physically be behind. `duration()` gains the same fallback for the same reason. The element's reading is cleared wherever it stops being the player — teardown, a handoff taking over, a different item loading — so it can never be attributed to what plays next | Player | UR-005, UR-025, UR-040 | Done (pending device verification) |
| DR-179 | Jellyfin is told what was played: progress while it plays, and a stop when it ends. A device trace of 35 minutes' playback requested `/Sessions/Playing/Progress` **zero** times and sent 14 `Stopped` reports, every one of them at position 0. Three faults, one subject. *Progress never left the device*: the frontend service writes it to the local DB by design, and nothing on the Rust side reported it for webview-rendered media — so the server learned a position only when the player was closed, and a crash or a swipe-away cost the session. It is now reported from the controller's own position ticks, through the 30s throttler it already owned and shares with the native audio path, which covers all three rendering paths in one place instead of adding a second frequent IPC caller. *Zero-position stops were sent*: Jellyfin stores the reported position as the resume point, so a zero does not merely fail to inform, it instructs the server to forget — and no zero was ever real, each one coming from asking a player that was not rendering the media (see DR-178). They are withheld; one landed 40s after the frontend had correctly reported 15:22 for the same episode, overwriting it. *A finished episode reported nothing at all*: Jellyfin decides "watched" from the stop report and its percentage, and in background audio-only mode nobody sends one — the webview is suspended and its element was torn down at the handoff, while the backend advances to the next episode without a word about the one that ended, so an episode listened to end-to-end on the lockscreen never counted as watched. `on_playback_ended` now reports it stopped at its **runtime** (not the last tick, which can be seconds short or, on a handoff whose ticks stopped early, nowhere near the end) before any advance, since after one the queue's current item is the next episode. Scoped to the audio-only handoff, the case the frontend provably cannot cover, so foreground playback keeps its single existing report; music ending natively remains unreported and wants its own change. The reporting seam is a `PlaybackReportSink` the controller sends to, which also collapses three copies of the spawn-a-task-and-hope block into one and is what let all of this be written as failing tests rather than found on a device a second time | Player | UR-025, UR-005, UR-040 | Done (pending device verification) |
| DR-180 | A background-audio handoff of a **downloaded** episode starts where the video left off. The handoff prefers a local file over the audio-only stream (DR-128), but the two begin in different places and were treated alike: a stream is built with `StartTimeTicks`, so the server makes the handoff point that stream's zero and the base is the handoff position with no seek — while a file has no such parameter and begins at the episode's own zero, so basing it at the handoff position claimed minutes of audio that were about to play from the beginning. Backgrounding a downloaded episode therefore restarted it while the lockscreen scrubber, dutifully adding the base, showed the position it should have been at. `background_audio_plan` splits the two: a file gets no base and a real seek, a stream keeps the base and no seek (seeking one would skip *past* the content by the handoff position again). The same distinction settles an inbound seek — `seek_absolute` re-opens a *streamed* handoff at the requested position because a chunked length-less transcode cannot honour a seek, which is not true of local media, and `resume_stream_at` refuses a non-remote source outright, so routing a lockscreen scrub of a downloaded episode through it failed the seek rather than performing it | Player | UR-040, UR-071 | Done (pending device verification) |
| DR-181 | A resumed transcode plays. Every video stream URL carried the resume position as `StartTimeTicks`, which is correct for a progressive response and fatal for an HLS one: Jellyfin builds each segment URI by echoing the **master playlist's** query string into it, and its segment handler opens by rejecting any request carrying `StartTimeTicks > 0` (`ArgumentException``400`). One position on the playlist therefore 400s every `hls1/main/N.ts` behind it, so hls.js exhausted its retries and gave up — presenting as an episode that will not resume while the same episode from the beginning is fine, the `> 0` being exactly why the beginning survived. The parameter is also unnecessary there: a playlist spans the whole item and asking for segment N *is* the seek, which the server transcodes from. So it is removed from the URL builder entirely rather than conditionalised — the builder has one caller shape and no way to know whether the response will be segmented — and the position becomes what it always was for HLS, a seek issued once the player has loaded: the seek path reloads at zero and seeks the element, and the resume path lets the player seek itself. The progressive `/Audio/universal` builder used by the background-audio handoff is a different endpoint with no segments and keeps its `StartTimeTicks`, which is why an audio-only handoff resumes correctly and a video one did not | Playback | UR-004, UR-074 | Done |
| DR-182 | Native video shows a picture. The poster/title card is an opaque `bg-black` overlay drawn over the whole video area while `isMediaReady` is false, and **every** signal that clears it is emitted by the HTML5 `<video>` element — `canplay`, `loadedmetadata`, hls.js `FRAG_BUFFERED`, the `playing` event, and two `readyState` timeouts. The native path renders no such element (`{#if !!useHtml5Element}`), so on Android nothing could ever clear it: ExoPlayer decoded to a live SurfaceView behind a black div for the entire session. That is DR-172's "audio with no picture" report, and it is indistinguishable on screen from the compositing failure DR-172 attributed it to — which is why the flag was reverted rather than fixed. Both the overlay and the native branch date from the original POC commit, so the native path has never been able to reveal itself; the 2026-08-11 device verification predates neither and does not contradict this, since a spike run that never reached a steady state would not have shown it. The backend's own events are the equivalent signals and `nativeSignalRevealsVideo` is the rule for reading them: `state === "playing"` mirrors the element's `playing` event, and a position tick carrying a real position or duration mirrors the `readyState` backstops, covering a first state event that is dropped or arrives before the listener is attached. `buffering`/`paused`/`stopped`/`error` deliberately do not qualify — revealing on `error` would replace the title card with a transparent hole showing the launcher through the app. The rule is a pure module rather than a branch inside the component because the decision that was missing is exactly the part worth guarding, and the component needs a DOM and a mounted player to exercise | UI | UR-003, UR-004, UR-041 | Done |
| DR-183 | The JavaScript bridges are installed before the page that uses them loads. WebView binds an injected object into JS at **page-load time**: an `addJavascriptInterface` call landing after the page has loaded does not appear to that page. They were installed from `configureWebViewForMedia`, which finds the WebView by walking the view tree 500 ms after `onCreate` — a race against Tauri's own page load, and one that is *permanent* when lost, because the identity guard added for DR-097's stale-proxy bug then declines to re-inject on every later resume pass. The whole set (`AndroidVideoSurface`, `AndroidPictureInPicture`, `AndroidBackgroundAudio`, `AndroidNetworkType`, `AndroidImmersive`, `AndroidInsets`) would simply be absent from `window`, and silently: every call site optional-chains the bridge, so a missing one is a no-op rather than an error. This is a candidate explanation for DR-172's other piece of evidence — `WebView transparent = false` logged, `= true` never appearing, i.e. the enable call never reaching Kotlin at all. `WryActivity.setWebView()` calls the `onWebViewCreate` hook immediately before wry issues the first `loadUrl` (confirmed in wry 0.55's `main_pipe.rs`, where the `setWebView` JNI call precedes `load_url`), so a bridge installed there is bound by the time any page runs. The hook can fire during `super.onCreate()`, before the rest of our own `onCreate`, so only work needing nothing but the WebView moves into it — insets stay in `configureWebViewForMedia`, which runs later and on every resume. The tree-walk path is kept as a fallback, and `enableNativeVideoCompositing` now logs an explicit error when the bridge is missing, so the ambiguity that left DR-172 unresolved cannot recur silently | Android | UR-003, UR-004, UR-040, UR-041 | Done |
| DR-184 | The video SurfaceView leaves the view hierarchy when the video does. `VideoOverlayManager.detachVideoSurface` had **no callers anywhere in the tree** — the mirror of the DR-151 defect, where `setActivity` had none — so `attachVideoSurface` was one-way: `JellyTauPlayer.clearVideoSurface()` dropped its `surfaceView` reference and cleared ExoPlayer's without removing the view, leaving it parented to the content view for the life of the process, with the next native video adding another SurfaceView beneath it. The stack was invisible while the WebView was opaque, which is why it went unnoticed. Two consequences outlive the leak: `isVideoSurfaceAttached()` gates `PictureInPictureManager.canEnterPip` through `isNativeVideoPath()`, so it reported an attached surface forever after the first native video (saved from offering PiP over nothing only by the `isPlayingVideo()` check beside it), and every abandoned surface held its `OnLayoutChangeListener` on the content view. Detach is called from `clearVideoSurface`, which covers stop, the switch to audio, and the background-audio handoff, and always runs on the main thread because every caller is already inside a `mainHandler.post`. It removes the view from its *own* parent rather than looking the content view up from an Activity reference, so an Activity recreated underneath it cannot strand the view | Android | UR-003, UR-041 | Done |
| DR-185 | The app shell stops painting over the video surface. `app.css` clears the page's opaque layers for native video through three selectors, and one of them — `html[data-native-video="active"] [data-app-shell]` — was written against an attribute **no component has ever set, in any commit**. The shell is `+layout.svelte`'s root `div`, which paints `--color-background` across the entire viewport; VideoPlayer is `fixed inset-0 z-50` and correctly makes *itself* transparent on the native path, but it stacks *above* the shell, so the WebView still composited the shell's opaque background over the whole screen and the SurfaceView behind it could never be seen. This is the missing half of the compositing DR-172 went looking for: the spec's own layer table lists this layer as "cleared by `data-native-video` → app.css", which was written but never wired, and `html`/`body` being genuinely transparent made the CSS look correct in isolation. The failure is invisible three ways over — the CSS is valid, the selector is plausible, and a rule matching nothing looks exactly like a rule matching something already transparent — while the symptom (black screen, audio fine) is identical to a real compositing failure, which is how it survived DR-150 through DR-172. Fixed by setting the attribute the rule was written for, and guarded by asserting the *relationship* rather than the rule: every attribute the compositing block targets must be set somewhere in the app, so a selector aimed at nothing fails the suite instead of failing silently on a device | UI | UR-003, UR-004, UR-041 | Done |
| DR-186 | The play overlay comes down when the backend plays. `isPlaying` was assigned once from the `player_play_item` response and thereafter only by the `player://state-changed` listener — a channel the backend never emits, the same dead wire that DR-182's first fix was mistakenly hung on. On the native path the flag therefore froze at whatever the initial response said: with ExoPlayer playing, the UI still believed it was paused, so the `bg-black/30` play-button overlay stayed raised across the whole video area and the transport button kept showing ▶. The video was simultaneously dimmed and covered while it played, which reads as "the overlay never goes away" and is easily mistaken for a second compositing fault. The mirror reads the same `player` store `playerEvents.ts` feeds, which is what the architecture already says is authoritative — the player reports state, the UI consumes it — and is gated to the native path so HTML5 keeps its element-event wiring, which is authoritative there | UI | UR-003, UR-005 | Done |
| DR-187 | The system bars go away with the player, not only with the fullscreen button. `enterImmersive()` had exactly one caller, `toggleFullscreen()`, so opening the player left the status and navigation bars painted over it until the user pressed a button most never press. On the native path this is worse than cosmetic: the SurfaceView fills the content view, so the bars sit directly on top of the video. The player is a full-screen surface by construction — `fixed inset-0 z-50` over a `MATCH_PARENT` surface — so entry is the right moment. Called synchronously in `onMount` before any `await`, per the native-mode pitfall, and paired with the `exitImmersive()` already unconditional in `onDestroy`, so a player torn down while immersive cannot leave the rest of the app without bars | UI | UR-066, UR-003 | Done |
| DR-188 | Native Android video is **ready to be the default except for the background-audio handoff**, and the flip therefore waits. The picture defects behind DR-172 are all found, fixed and device-verified — DR-185 (the app shell painted over the surface through a CSS rule targeting an attribute nothing set), DR-182 (nothing could lift the poster card on a path with no `<video>` element), DR-183 (the JS bridges raced the page load, so `setTransparent(true)` could never arrive), DR-184 (the SurfaceView was never detached), plus DR-186 and DR-187, the two UI defects only this path could reveal. On a device logcat now carries `WebView transparent = true` and `Marking media ready` with video on screen, which is the pair DR-172 went looking for and could not find, and skip, seek and rotation were exercised by hand. Turning the default on then surfaced a *different* unverified sub-path: the background-audio handoff could only *return* through the HTML5 element, so coming back from the lockscreen left playback dead, and the flip waited for that rather than shipping a verified sub-path over an unverified one as DR-161 had. **The default is now on.** The two defects holding it back are fixed and device-verified — DR-196 (the handoff return restarts the renderer that is actually on screen) and DR-194 (the letterbox bars are painted rather than retaining stale framebuffer content) — with the evidence this default has been held to since DR-161: an audio handoff at 69:54 returning to video playing at 70:18, and clean bars across playback, the control bar and a rotation round-trip. An explicit stored choice still wins in both directions, so an opt-out survives the flip (the stored value is null-checked rather than compared to "true", which would have silently re-enabled it for everyone who turned it off) | Android | UR-003, UR-004 | Done |
| DR-189 | The control bar comes down on a touchscreen. Its hide timer was armed from exactly one place — the player container's `onmousemove` — and a touchscreen never fires `mousemove`, so on Android the bar was never scheduled to hide and sat over the video for the whole film. It went unnoticed for as long as the native video surface was itself invisible (DR-172/DR-185): with nothing behind it to obscure, a permanent control bar reads as the UI rather than as a defect. Two changes, because there were two faults. `revealControls()` replaces `handleMouseMove` and is called on entry and on every touch interaction as well as on mouse movement, so touch arms the countdown. And the countdown became an `$effect` over the state rather than a one-shot timer armed by the input event: the first attempt armed a timer on entry, three seconds later playback had not started, `shouldHideControls` correctly declined, and nothing ever re-armed it — the timer has to follow the conditions that *permit* hiding, which arrive on their own schedule. The decision itself is `shouldHideControls` in `controlsVisibility.ts`, pure and separated from the clock and the DOM, because what was wrong here was the conditions and not the `setTimeout`: the bar stays up while paused (a user who paused by tapping the surface has no other way back), mid-seek (the position readout is the point of the bar then), and while any track/subtitle/quality menu is open (the menus are anchored to the bar, so hiding it would take the open menu with it) | UI | UR-003, UR-066 | Done |
| DR-191 | Forcing the WebView overlay to redraw from the Activity, because with the ExoPlayer **SurfaceView** beneath it the overlay's ordinary damage stopped reaching the screen: the page kept mutating — the clock text every second, the control bar's opacity going to 0 — while the display held whatever frame it last presented, over video that animated perfectly. Not a state defect; the live DOM showed the slider advancing 476 → 479 across three seconds behind a screen showing neither. Only **structural** changes got through, which is why the play overlay always appeared to work (an `{#if}` block, added and removed) while the progress bar never did, and why rotation lost the transport UI. A CSS animation cannot help, since opacity animates on the compositor without repainting the layer. **Superseded by DR-192**: this drove `postInvalidateOnAnimation` in a loop, which treats the symptom — the cause is the SurfaceView's separate layer, and removing that removes the need. Kept as the record of how the mechanism was identified | Android | UR-003, UR-004 | Superseded by DR-192 |
| DR-195 | Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. `html5_playing` is Rust's record of "a webview `<video>` is active and in this state", and `toggle_playback`, `play` and `pause` all route transport to that element whenever it is set. The player route mirrored element state into it **unconditionally** — from `handleReportStart` and, fatally, from `handleReportProgress`, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct `player_toggle` invocation, while seek and skip kept working because `player_seek_video` decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off `isPlaying`, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in `mirrorElementStateToRust` in VideoPlayer, gated on `useHtml5Element`, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resume | Playback | UR-005, UR-003 | Done |
| DR-196 | Returning from background audio brings the picture back on the **native** path, because the return now restarts the renderer that is actually on screen. The two paths resume by different means: the webview `<video>` reloads off its stream URL, watched by an `$effect` that reinitialises HLS and lets `canplay` drive the seek — while ExoPlayer owns no element and nothing watches the URL on its behalf, so its playback is only ever started by an explicit `player_play_item` + adapter load, issued once from `onMount`. `exitBackgroundAudioHandoff` did only the URL assignment, for both paths, so on the native path it restarted nothing: `player_exit_background_audio` had already stopped the handoff's audio player, leaving the backend holding no item at all. The symptom is a black screen with a play overlay pinned at 0:00, a seek bar at zero, and a play button that does nothing — the process alive and the frontend still logging, since nothing crashed; the transition was simply dropped. The branch is decided by `planHandoffReturn` (pure, in `backgroundAudioHandoff.ts`), which also folds in `shouldResumeOnForeground` so a lockscreen pause during the handoff still wins over the snapshot taken on the way out. Subtitle configurations are reused from the ones resolved at mount, since ExoPlayer sideloads them as `MediaItem.SubtitleConfiguration`s and cannot accept one after `prepare()`. Verified on device: handoff to audio at 69:54, return restored video playing at 70:18 | Playback | UR-040, UR-003 | Done |
| DR-197 | Continue Watching and Next Up stop showing the same episode. Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns a partially-watched episode as its own series' next up — precisely the episode `/Items/Resume` already returns — so the Home "Next Episode" row and the TV landing's Next Up row duplicated Continue Watching card for card. `build_next_up_endpoint` sends `EnableResumable=false`, and because servers predating that parameter ignore it, `filterInProgressNextUpItems` also drops any next-up entry whose id appears in the resume list. It is the mirror of DR-089 and lives beside it: same presentation-layer de-duplication over two lists the frontend already holds, no Jellyfin taxonomy involved. The resume filter still reads its frontier from the *unfiltered* Next Up list, so removing in-progress entries cannot resurrect a stale resume card. The division is then exact: Continue Watching offers episodes the viewer has started and not finished, Next Up offers the episode after the ones they finished | Repository | UR-059 | Done |
| DR-200 | The lockscreen notification is exempt from `POST_NOTIFICATIONS`, because of the **session token**, not because it belongs to a foreground service — and the difference is what the code now records. `POST_NOTIFICATIONS` was declared in the manifest and requested nowhere, so on Android 13+ it sat permanently denied; an audit read that as a threat to UR-006, since the media notification is what carries the lockscreen transport controls. It is not. Android's own wording is that the permission covers "non-exempt (including Foreground Services (FGS)) notifications", with denied users seeing FGS notices "in the Task Manager but [not] in the notification drawer" — so an FGS notification is explicitly *not* exempt — while separately "Notifications related to media sessions are exempt from this behavior change". The platform predicate is `Notification.isMediaNotification()`, which requires `MediaStyle` **and** a non-null `EXTRA_MEDIA_SESSION`, and it is byte-identical across API 3336. `NotificationManagerService` uses it to decide whether to drop the post, and SystemUI's media carousel (`MediaDataProcessor.onNotificationAdded`) is gated on the *same* predicate — so a token-less notification is not merely absent from the shade, it never reaches the notification listener and the lockscreen/Quick-Settings controls do not exist at all. Confirmed on device (HONOR ROD2-W09, Android 16 / SDK 36): appops `POST_NOTIFICATION: ignore`, `granted=false`, and the service simultaneously `isForeground=true` with `foregroundNoti=Notification(category=transport actions=3 vis=PUBLIC)`. So **no runtime permission request is added** — a prompt the app does not need is a prompt that can be permanently denied for nothing — and no `checkSelfPermission` gate is placed on `startForeground`, which would trade a cosmetic problem for the "did not then call Service.startForeground()" kill. What is added is the guard that matches the real precondition: `mediaSessionCompat?.sessionToken` is a null-safe call, and the exemption hangs entirely on it, so both builders now bind the token once and log an error if it is ever null while the permission is denied — converting a failure that is invisible unless the tester happened to deny the permission (most grant it reflexively) into a logcat line. The manifest declaration is *kept*, unrequested, and documented: media3 does not need it (media3-session declares no permissions and the `MediaSessionService` guide asks only for the two `FOREGROUND_SERVICE` ones), but the exemption covers media and self-managed-call notifications only, so a download-completion notice (UR-011) would be an ordinary notification and silently dropped — keeping the declaration is what makes adding one a one-file change | Android | UR-006 | Done |
| DR-201 | A lockscreen skip means different things depending on what is playing, and the backend decides which. `onSkipToNext`/`onSkipToPrevious` forwarded a bare `"next"`/`"previous"` to Rust, which always advanced the queue — correct for music, wrong for a video whose audio is running through a background-audio handoff (UR-040), where the buttons should scrub. Pressing skip to re-hear a line jumped to the next *episode* instead. `resolve_skip_action` in `player/seek.rs` maps the command to either `Advance` or `SeekTo`, and `is_background_audio_active()` is the whole test: the handoff exists only for video, and an episode played through it reports `MediaType::Audio`, so media type cannot distinguish the case. Forward jumps 30s, back 10s — asymmetric because the back button replays dialogue just missed rather than travels — and both clamp to `[0, duration]`, since a negative offset is rejected by backends and a seek past the end reads as EOF and would advance, the very outcome being prevented. Routed through the same spawn-then-`seek_absolute` path as the scrubber, because a handoff seek re-opens the stream and must not run under the blocking lock (DR-159). The Kotlin keeps sending the same opaque command; only the `PlaybackStateCompat` gains `ACTION_FAST_FORWARD`/`ACTION_REWIND` so the system draws seek affordances rather than skip arrows that lie about what they do | Playback | UR-040, UR-006 | Done |
| DR-202 | Video keeps the display awake. Android counts its display timeout from the last *user input*, and watching something is exactly the case where there is none, so the screen dimmed and slept mid-film unless the user kept tapping it. Nothing held it: `FLAG_KEEP_SCREEN_ON` appeared nowhere in the app, and neither renderer supplies a hold for free — ExoPlayer's `setWakeMode` is a CPU/wifi wake lock that says nothing about the display, and it draws into the `TextureView` this app owns (DR-192) rather than media3's `PlayerView`, which is the widget that would otherwise set `keepScreenOn` itself; the WebView `<video>` path is no better, because the display wake lock Chrome takes for video lives in the browser layer and not in an embedded WebView. `ScreenWakeManager` toggles `FLAG_KEEP_SCREEN_ON` on the Activity window — window-scoped, so it stops applying the moment the app is not visible and cannot outlive a crash the way an explicitly acquired `PowerManager.WakeLock` can, and it needs no permission (the manifest's `WAKE_LOCK` is the media service's). The two rendering paths are independent holders OR-ed in the pure `ScreenWakeState`: the native path follows `onIsPlayingChanged` plus surface teardown, so the hold tracks what ExoPlayer *reports* rather than what the UI intends, and the webview path reuses the `setHtml5VideoState` report the frontend already sends for PiP (DR-160) rather than adding a bridge. Audio is deliberately not a holder — playing music with the screen off is the point of that path — so the hold is gated on the media type being video, and it is dropped on pause, on stop, on surface teardown, and on a new WebView, since a page that goes away never sends its own final `active = false`. Also the repo's first Kotlin JVM unit tests: `ScreenWakeState` is framework-free so the decision is testable off-device with `./gradlew :app:testUniversalDebugUnitTest`. Verified on device (FP5, native path): `IS PLAYING CHANGED: true``keepScreenOn = true` 17 ms later and `fl=KEEP_SCREEN_ON` on the window in `dumpsys`, a pause releasing it and the resume re-taking it. The webview path is unverified | Android | UR-003, UR-004 | Done |
| DR-203 | The background-audio handoff stops silently rewinding to the point it started. A player retry is only a *retry* if it can resume where the load failed, and ExoPlayer decides that in `ProgressiveMediaPeriod.configureRetry`: it keeps the load position when the content length is known or the extractor produced a seek map with a duration, and otherwise assumes the source is live — the data at the URL is taken to have changed, so every sample queue is reset and the URL is re-requested from offset 0. The handoff transcode (`/Audio/{id}/universal?Container=mp3&TranscodingProtocol=http`, DR-129) satisfies neither condition: chunked, so no `Content-Length`, and a live mp3 encode carries no `Xing` header, so the duration is unset — on device every position tick reads `<position> / 0.0`. Its URL carries `StartTimeTicks` = the handoff point, so "from offset 0" is the handoff point, and after any transient load error playback resumed there and ran on normally. Nothing was reported: a successful retry raises no error and no `STATE_ENDED`, so neither arm of DR-129 was ever consulted, no `onPositionDiscontinuity` handler existed, and the app's only trace of it was a position that went backwards — which is why it read as random, since it needs a network blip to land while a load is in flight rather than while the ~50s buffer covers it, and why it survived the two earlier fixes for the same *symptom* (DR-129's phantom end, DR-159's relative-timeline leak). The decision is Rust's: `player_retry_restarts_stream` marks a `Remote` audio-only video item, and `loadWithMetadata` carries the answer to Kotlin, where the pure `StreamRetryDecision` holds it for a `DefaultLoadErrorHandlingPolicy` subclass that returns `C.TIME_UNSET` — which makes `onLoadError` answer `DONT_RETRY_FATAL` *before* reaching `configureRetry`. The rewind therefore becomes a recoverable error, and `recoverable_error_resume` already knows what to do with one: re-open at the position playback actually reached, `StartTimeTicks` rewritten, with backoff and the shared attempt budget. Every other source keeps the player's retry, because a static file and an HLS playlist both declare their timeline and are resumed in place. A `onPositionDiscontinuity` handler is added for the log line alone, so a recurrence is visible rather than invisible — loud for `DISCONTINUITY_REASON_INTERNAL`, which is the rewind's own signature, and quiet for the backwards jump a resume's re-prepare legitimately makes. Reproduced and verified on device (FP5), same procedure both times: background-audio handoff, 60s to fill the buffer, a 45s radio outage, then watch. **Before** — the outage passed unnoticed and 3.5 minutes later, with nothing logged in between, `BUFFERING``READY` → position `1165.4s``840.3s`, exactly the handoff base, no error and no `STATE_ENDED`; the same log line reports `Media ready! Duration: -9.223372036854776E15`, which is `C.TIME_UNSET` and the precondition itself. **After**`Load error on a stream that cannot be resumed in place — declining the player's retry` at the outage, playback continuing undisturbed off the buffer for 69s (a fatal load error is only raised when the renderer next needs data), then `ERROR_CODE_IO_NETWORK_CONNECTION_FAILED``re-opening at 785.6s in 2s``READY`, playing on from 785.6s with no rewind in the following 7 minutes | Playback | UR-040, UR-004 | Done |
| DR-199 | The webview stops undoing the network security config. `MainActivity.configureWebViewSettings` set `mixedContentMode = MIXED_CONTENT_ALWAYS_ALLOW` together with `allowFileAccess = true` and `allowContentAccess = true`, which is a blanket cleartext opt-in reached by hand — exactly the thing `network_security_config.xml` exists to prevent and its own comment warns against (DR-138). Nothing needed any of the three. `file://` is never loaded: cached thumbnails go through `convertFileSrc`, which on Android resolves to `http://asset.localhost/…` and is answered by wry's request interceptor rather than the filesystem, and downloaded media goes over the loopback HTTP server (DR-137), which exists precisely because the asset/file route cannot stream a large file. `content://` is never loaded either — the manifest's `FileProvider` is for outbound share intents, not webview navigation. And mixed content never arises: Tauri serves the UI from `http://tauri.localhost` (`use_https_scheme` defaults false and is not set in `tauri.conf.json`), while both `127.0.0.1` and `asset.localhost` are loopback/`.localhost` origins that Chromium treats as potentially trustworthy, so they are not mixed content to begin with. A plain-HTTP *remote* Jellyfin server would be, but the network security config already rejects it before any mixed-content check runs — so `ALWAYS_ALLOW` bought nothing and only widened the hole. `COMPATIBILITY_MODE` rather than `NEVER_ALLOW` is a deliberate hedge and not the default — the platform default at targetSdk 21+ *is* `NEVER_ALLOW` — because none of this can be verified anywhere but a device, and compatibility mode keeps passive content (images) working if the analysis missed a path. `allowFileAccess = false` restores the targetSdk-30+ default; `allowContentAccess = false` is a genuine tightening (its default is true) and is the first thing to look at if something that used to render stops. The two files now cross-reference each other so the pair cannot drift apart again | Security | UR-071 | Done (pending device verification) |
| 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 | 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 |
| DR-157 | Full-screen video on Android actually goes full screen. `toggleFullscreen` called `document.documentElement.requestFullscreen()` and nothing else, which inside an Android WebView does not touch the Activity window — it expands the element within a viewport that already spans the whole screen, because `enableEdgeToEdge()` is called in `onCreate` and SDK 36 ignores the opt-out. So the control did nothing visible while the status bar and navigation/gesture bar stayed painted over the video, and (unlike DR-112's chrome-clearance work, which is about *reserving* space for the bars) here the bars should not be there at all. `ImmersiveModeBridge` hides them via `WindowInsetsControllerCompat` with `BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE`, so an edge swipe brings them back transiently over the video instead of resizing the window mid-playback, and the system's own gestures stay reachable. Exposed as the `AndroidImmersive` bridge and posted to the main thread, since `@JavascriptInterface` methods arrive on a WebView binder thread. `requestFullscreen()` is kept for the platforms where it does work, but its rejection is caught rather than allowed to abort the immersive call. Restoring is wired to three paths, not one: leaving fullscreen, Escape (which previously called `document.exitFullscreen()` directly, bypassing the flag and the bars), and `onDestroy` — the bars belong to the Activity, so a player torn down while immersive would strand every screen behind it without them. The `--jt-inset-*` properties need no special handling: hiding the bars fires the decor view's inset listener with zeroes and `WindowInsetsBridge` republishes them | UI | UR-066 | Done |
| DR-143 | Flipping the offline downloaded-only gate actually re-queries the listing. The gate (DR-078) is a process-wide flag in Rust consulted only *while a query runs*, but no library surface re-queried when its inputs changed: `useServerReachabilityReload` fires only on the offline → **online** transition, and `GenericMediaListPage`, `GenericGenreBrowser` and the favourites page never even called its `checkServerReachability`. So going offline left the full server catalog on screen under a now-closed gate, and toggling "Show all server media" only greyed cards — `MediaCard.isServerOnly` is a pure frontend derivation that updates instantly — without adding or removing a single row. The filter therefore read as "shows everything until I filter, then greys some of it" while the backend gate was correct and simply never exercised. `catalogFilterVersion` is the refetch signal: `pushCatalogVisibility` now awaits `set_show_server_catalog` and bumps the version only **after** the backend accepts the new flag, since a reload racing the push would re-query under the old gate and undo itself. A failed push clears `lastIncludeCatalog` instead of latching it, so the next identical transition is retried rather than skipped as a no-op and left permanently disagreeing with the backend. `useOfflineFilterReload` subscribes pages to that signal, skipping the value they already loaded under; it is wired into both generic list components and the movies/music/tv/favourites landing pages and the `/library/[id]` detail page | UI | UR-052 | Done |
| DR-135 | A download's media type comes from the item, not a default. `download_item` — the path a media card uses to queue an item while offline — never records `media_type`, and the reconnect resolver read that NULL as `'audio'`, so a **movie** queued from a card had its URL resolved by `get_audio_stream_url`. The file that landed on disk was an audio-only transcode, which is why a "downloaded" film could never play offline no matter how the path or protocol was fixed. The resolver now falls back to the item's own `item_type` (`VIDEO_ITEM_TYPES` in Rust, so the frontend never learns which types are video) and only defaults to audio when the item is not cached locally. An explicit `media_type` on the row still wins | Downloads | UR-071, UR-052 | Done |
| DR-136 | Rows already downloaded under the audio default are repaired, not just prevented. They are identifiable after the fact — no `media_type`, but a video item — so on reconnect they are reset to `pending` with their audio URL cleared and re-resolved by DR-135's corrected logic, overwriting the audio file in place. Without this the fix is invisible to anyone who had already queued a film: the row still reads "downloaded" and still fails to play. Rows carrying an explicit `media_type` and genuine audio downloads are left untouched | Downloads | UR-071 | Done |
| 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 |
---
@@ -264,29 +427,29 @@ Internal architecture, components, and application logic.
|----------|-------------------------|-------------------------|
| UR-001 | IR-001, IR-002 | - |
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006 |
| UR-005 | - | DR-001, DR-005, DR-009 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | - |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203 |
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
| UR-007 | IR-010 | DR-007, DR-008, DR-016 |
| UR-008 | IR-010 | DR-007, DR-011 |
| UR-009 | IR-009, IR-010, IR-011 | - |
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
| UR-012 | IR-009, IR-014 | - |
| UR-012 | IR-009, IR-014 | DR-198 |
| UR-013 | IR-013 | DR-017 |
| UR-014 | IR-010 | DR-014, DR-019 |
| UR-015 | - | DR-005, DR-020 |
| UR-016 | - | - |
| UR-017 | - | DR-014, DR-021 |
| UR-018 | IR-013 | DR-015, DR-018 |
| UR-018 | IR-013 | DR-015, DR-018, DR-173 |
| UR-019 | IR-015 | DR-022 |
| UR-020 | IR-016, IR-018 | DR-023 |
| UR-021 | IR-016, IR-019 | DR-024 |
| UR-020 | IR-016, IR-018 | DR-023, DR-176 | <!-- IR-018 delivered by ExoPlayer + HTML5 `<track>`, not libmpv -->
| UR-021 | IR-016, IR-019 | DR-024 | <!-- IR-019 delivered by ExoPlayer + HLS stream re-open, not libmpv -->
| UR-022 | IR-017 | DR-025 |
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049 |
| UR-024 | IR-010 | DR-027 |
| UR-025 | IR-015 | DR-028 |
| UR-025 | IR-015 | DR-028, DR-131, DR-132, DR-178, DR-179 |
| UR-026 | - | DR-029, DR-048, DR-050 |
| UR-027 | IR-020 | DR-030 |
| UR-028 | - | DR-031 |
@@ -301,27 +464,44 @@ Internal architecture, components, and application logic.
| UR-037 | IR-010 | DR-042 |
| UR-038 | IR-010 | DR-043 |
| UR-039 | - | DR-045, DR-046 |
| UR-040 | IR-025 | DR-051, DR-052 |
| UR-041 | IR-026 | DR-053 |
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203 |
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188 |
| UR-042 | IR-009, IR-014 | DR-054 |
| UR-043 | IR-027 | DR-055 |
| UR-044 | - | DR-056 |
| UR-045 | - | DR-057 |
| UR-046 | IR-028 | DR-058 |
| UR-047 | IR-013 | DR-060 |
| UR-048 | - | DR-061, DR-062 |
| UR-049 | IR-010 | DR-063, DR-064, DR-065 |
| UR-048 | - | DR-061, DR-062, DR-142 |
| UR-049 | IR-010 | DR-063, DR-064, DR-065, DR-147 |
| UR-050 | - | DR-066, DR-067 |
| UR-051 | - | DR-068, DR-069, DR-070 |
| UR-052 | IR-027 | DR-078, DR-079, DR-080 |
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 |
| UR-053 | IR-029 | DR-074 |
| UR-054 | - | DR-075, DR-076, DR-077 |
| UR-055 | - | DR-081, DR-082, DR-083, DR-084 |
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173 |
| UR-056 | - | DR-085 |
| UR-057 | - | DR-086 |
| UR-058 | - | DR-087 |
| UR-060 | - | DR-090, DR-091 |
| UR-058 | - | DR-087, DR-142 |
| UR-060 | - | DR-090, DR-091, DR-111 |
| UR-061 | - | DR-092 |
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107 |
| UR-063 | - | DR-105 |
| UR-064 | - | DR-106 |
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
| UR-066 | IR-031 | DR-112, DR-157, DR-187, DR-194 |
| UR-067 | - | DR-115, DR-116, DR-117, DR-118 |
| UR-068 | - | DR-119 |
| UR-069 | - | DR-113, DR-114, DR-120 |
| UR-070 | - | DR-121, DR-122 |
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171, DR-180, DR-198, DR-199 |
| UR-072 | - | DR-156 |
| 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 |
---
@@ -416,7 +596,125 @@ Internal architecture, components, and application logic.
| UT-086 | A second tap inside the window seeks (+30 s right half, 10 s left half) with the matching feedback side **and** re-toggles play/pause, so the two toggles cancel and the play state is unchanged by a double tap | DR-092, DR-098 | Done |
| UT-087 | A tap after the window, and the tap following a consumed pair, are each fresh first taps that toggle (there is no third-tap case); repeated double taps keep seeking; `cancel()` makes the next tap a first tap so an interpreted swipe cannot seek | DR-092, DR-098 | Done |
| UT-088 | `resolveSeekTarget` applies the delta to the reported position, clamps into `[0, duration - END_SEEK_MARGIN_SECONDS]`, chains off an in-flight pending target so rapid skips accumulate, and ignores that target once the player reports past it | DR-092, DR-095 | Done |
| UT-089 | A touch drag on the video seek bar seeks to the dragged position, never toggles play/pause, and never alters brightness — the container gesture layer stays out of a control drag entirely | DR-098, DR-099 | Done |
| UT-090 | The seek bar commits its seek on `touchend` even when the engine never fires `change`, and commits exactly once when both signals arrive | DR-099 | Done |
| UT-091 | Transport intents (play/pause/toggle) reach the backend even while a video adapter is registered, and never call the adapter's own `play`/`pause`/`toggle` — the webview must not decide play-vs-pause from the DOM | DR-097 | Done |
| UT-092 | `shouldReuseActivePlayback` reuses backend playback for an already-loaded audio track but never for video, and never when an explicit start position or a next-episode restart was requested | DR-100 | Done |
| UT-093 | `resolvePlayerSurface` returns `video` only with a stream URL, `pending` for video whose stream URL is still missing (never `audio`), and `audio` for audio content | DR-100 | Done |
| UT-094 | `parseNativeInsets` accepts the bridge's JSON or a decoded object, and coerces missing/negative/non-finite edges to 0 rather than emitting `NaNpx` (which would invalidate the whole padding declaration) | DR-112 | Done |
| UT-095 | `safeAreaCssVars`/`applySafeAreaInsets` emit px-suffixed `jt-inset` custom properties for all four edges | DR-112 | Done |
| UT-096 | `readNativeInsets` returns null with no bridge and survives a stale WebView proxy (missing or throwing `get`) instead of throwing out of layout init | IR-031, DR-112 | Done |
| UT-097 | `initSafeArea` primes the document on start, re-applies on `jellytau-insets-changed` (rotation, nav-mode switch), unsubscribes on teardown, and writes nothing without a bridge so `env()` still wins on iOS/desktop | IR-031, DR-112 | Done |
| UT-098 | `shellReservesBottomInset` gives the bottom inset to BottomUi wherever one renders and to the app shell only on routes without one, so the gesture bar is never ignored nor double-padded | DR-112 | Done |
| UT-099 | A Jellyfin item payload carrying `UserData.IsFavorite` maps to `MediaItem.user_data.is_favorite` | DR-113, JA-034 | Done |
| UT-100 | `OnlineRepository::get_favorites` builds `Filters=IsFavorite` + `Recursive=true` + the scope's `IncludeItemTypes`, and omits the type filter entirely for `SearchScope::All` | DR-115, JA-033 | Done |
| UT-101 | `OfflineRepository::get_favorites` returns only `is_favorite = 1` rows, honours the scope type filter, and stays downloads-only when the catalog-browse gate is off | DR-115 | Done |
| UT-102 | The `save_to_cache` favourite mirror does not overwrite a row with `pending_sync = 1` | DR-114 | Done |
| UT-103 | The reconnect drain pushes pending favourites, clears `pending_sync`, and leaves failed rows pending | DR-120 | Done |
| UT-104 | `get_items` with `favorites_only` filters online (endpoint) and offline (SQL) | DR-116 | Done |
| UT-105 | `favorites` store precedence: override beats `userData.isFavorite` beats `false` | DR-119 | Done |
| UT-106 | Un-favouriting removes an item from a favourites listing view | DR-117, DR-119 | Done |
| UT-107 | The hybrid background refresh emits `favorites-changed` only for ids whose favourite state actually flipped | DR-120 | Done |
| UT-109 | Search covers synced-but-not-downloaded items when catalog browse is on, and stays downloads-only when off | DR-108 | Done |
| UT-110 | Search item-type filter is bound, not interpolated: a quote-bearing type neither errors nor widens results | DR-108 | Done |
| UT-111 | FTS prefix queries quote each token, so apostrophes/hyphens/slashes are data; empty or punctuation-only input returns no rows rather than erroring | DR-108 | Done |
| UT-112 | Repeated catalog passes leave one `items_fts` entry per item, not one per pass | DR-110 | Done |
| UT-113 | The stale-catalog sweep removes vanished synced rows, keeps downloaded ones, keeps uncrawled types, and stays scoped to one server | DR-110 | Done |
| UT-114 | Cached people are reachable from unscoped search and excluded from scoped search | DR-111 | Done |
| UT-115 | Re-index staleness policy: never-indexed and unparseable timestamps are due, fresh ones are not, future ones are not | DR-109 | Done |
| UT-116 | `resolve_local_media_path` returns a completed download's file, and `None` for an in-progress download, a row whose file has been deleted, or an unknown item | DR-123 | Done |
| UT-118 | `resolveVideoSource` prefers a downloaded file, never marks a local file as needing transcoding, and falls back to streaming for a blank path | DR-123 | Done |
| UT-119 | The audio-only handoff picks a downloaded file over the audio-only stream URL, preserving the Jellyfin id for progress sync | DR-128 | Done |
| UT-120 | Expiry reclaim takes only expired temporary entries: derived from `completed_at`+TTL, honouring an `expires_at` override, never a user download, and disabled by a zero TTL | DR-127 | Done |
| UT-108 | LRU eviction reclaims only `'auto'` downloads and never a user's own, even when the user's is the oldest | DR-126 | Done |
| UT-117 | A background audio-only stream cut short resumes where it died instead of ending the episode; a real end still advances; the absolute position is compared against the runtime; retries at a stuck position give up. A recoverable error resumes music and video too, with growing backoff, leaving the rest of the queue intact and the seekable stream's URL untouched; local and DirectUrl sources are excluded | DR-129 | Done |
| UT-124 | `downloadedFilePath` leaves a completed download's absolute path alone (POSIX and Windows) and only roots one that is still relative | DR-133 | Done |
| UT-125 | A NULL `media_type` resolves from the item type — Movie and Episode as video, a track as audio — an uncached item still defaults to audio, and an explicit `media_type` overrides the item | DR-135 | Done |
| UT-126 | Requeueing takes only video rows downloaded under the audio default, clearing their URL, and leaves correctly-typed video rows and real audio downloads alone | DR-136 | Done |
| UT-127 | The media server bounds and confines every response: a range-less request yields one chunk rather than the whole file, no range exceeds the chunk cap, explicit/open-ended/suffix ranges resolve correctly, a range past the end is unsatisfiable rather than clamped, a malformed header falls back to the first chunk, path traversal and unrelated absolute paths are refused, a wrong or absent token is rejected, and content type comes from the extension then the magic bytes | DR-137 | Done |
| UT-121 | An EOF reads as the last observed timestamp, not zero: live readings win while the file is loaded, a not-yet-established duration is not recorded as a real zero, a seek updates the position before the next poll, and loading a new file clears the previous one's | DR-130 | Done |
| UT-122 | The sync-queue drain pushes queued playback reports oldest-first, defers failures for the next reconnect, abandons a row after `MAX_SYNC_ATTEMPTS`, ignores other users' rows, and parses both payload dialects | DR-131 | Done |
| UT-123 | Pending-sync rows describe themselves: every queueable operation has a label, an unknown one still renders, the item title falls back to its id, and rows list oldest-first | DR-132 | Done |
| UT-130 | Video and background-audio stream URLs omit `AudioStreamIndex` when no track was chosen, and carry the exact index when one was | DR-140 | Done |
| UT-131 | The Episode Focus View hero offers a download control | DR-142 | Done |
| UT-132 | The series name links to the series and the `SxEy` badge to that season's anchor | DR-142 | Done |
| UT-133 | Cast renders below the "More Episodes" strip, never above it | DR-062, DR-142 | Done |
| UT-134 | The episode strip is hidden when the episode has no siblings | DR-142 | Done |
| UT-135 | An episode with no `seriesId` still renders the Focus View, with title, Play and download | DR-142 | Done |
| UT-136 | `episodeRedirectTarget` sends a bare episode page into its series' Focus View, and returns null with no series | DR-142 | Done |
| UT-137 | Going offline with the toggle off pushes the closed gate and bumps `catalogFilterVersion` | DR-143 | Done |
| UT-138 | The version bumps only after `set_show_server_catalog` resolves, never before | DR-143 | Done |
| UT-139 | A failed visibility push is retried on the next identical transition rather than latched | DR-143 | Done |
| UT-140 | `useOfflineFilterReload` skips the value a page already loaded under and reloads on each later change | DR-143 | Done |
| UT-141 | The advertised channel cap: an unknown or zero reading falls back to stereo, a real route keeps its channels, an absurd driver reading is capped at 7.1, and mono is taken at its word | DR-141 | Done |
| UT-148 | Forcing a transcode from the client: an undecodable default track forces one, a decodable track does not, the default track decides rather than the first, the first decides when nothing is marked default, and neither an audio-less source nor an unnamed codec is second-guessed | DR-149 | Done |
| UT-149 | `createAdapter` returns the native adapter only when Rust reports native AND `experimentalNativeVideo` is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 | DR-150 | Done |
| UT-150 | `set-version.sh` stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffix | DR-153 | Done |
| UT-151 | An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later report | DR-154 | Done |
| UT-152 | Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards | DR-155 | Done |
| UT-162 | Each downloaded library lists only its own media: the music library shows the album and neither the film nor the series, the movie library only the film, the TV library only the series | DR-163 | Done |
| UT-163 | `partial_path` appends rather than replacing the extension, so it matches what the cleanup paths delete, keeps two sources for one title apart, and still produces a sidecar for an extension-less target | DR-165 | Done |
| UT-170 | `queue_album_tracks` queues a row for every track of the album — including tracks the cache holds without an `album_id` and tracks it has never seen at all — links each one to its album so offline browsing can find it, returns the row ids in track order, and is idempotent: re-queuing fills the gaps without duplicating rows or resetting a completed track. `cached_album_tracks` (the offline fallback) finds tracks by either album link and does not sweep in another album's | DR-173 | Done |
| UT-171 | `resolve_pending_download_urls` restricted to a set of row ids resolves only those rows and leaves other pending rows untouched, and an empty id set resolves nothing rather than sweeping everything | DR-173 | Done |
| UT-172 | `album_file_names` gives every track of an album its own file: a title repeated within the album (deluxe edition, two discs) is disambiguated by track number and item id instead of the second download overwriting the first, an unambiguous title keeps its own name, and path separators in a title are sanitised so a track cannot escape the album directory | DR-173 | Done |
| UT-164 | `resume_offset` appends only when the server answered `206`; a `200` after a Range request restarts the file, because that body is the whole stream | DR-166 | Done |
| UT-165 | A registered download starts unflagged, `signal` sets the flag its worker reads, signalling an unregistered id reports not-in-flight, `clear` forgets it, and re-registering drops a previous stop so a resumed download does not halt instantly | DR-164 | Done |
| UT-166 | `original` quality re-encodes audio the webview cannot decode (E-AC-3/AC-3/DTS/TrueHD) to AAC without capping bitrate or resolution, keeps the `Static=true` direct copy for audio that plays here (AAC/MP3/Opus/Vorbis/FLAC) and for an unknown codec, leaves the explicit quality presets untouched, and picks the served track by the same default-or-first rule the streaming verdict uses | DR-171 | Done |
| UT-155 | A seek during a background-audio handoff re-opens the stream at the requested absolute position (`StartTimeTicks`) and rebases the handoff to it, while a seek outside a handoff stays an ordinary seek and invents no base | DR-159 | Done |
| UT-154 | `mark_unplayed` parses to `QueuedOp::MarkUnplayed` and is rejected without an item id, and a queued un-mark drains to the server as `clear_watch_history` | DR-158 | Done |
| UT-156 | A capped step reaches the transcode URL as all four of its parts (total ceiling, the video/audio split summing to the cap, and a `MaxHeight`), the uncapped default keeps the historical 20/18 Mbps allowance and constrains no resolution, and the background-audio handoff takes the lower of the cap and its own 384 kbps | DR-162 | Done |
| UT-157 | The quality ladder is internally consistent — video + audio equals the cap at every step, audio never consumes the budget, only `Original` is uncapped — descends in bitrate, resolution and audio share together, and round-trips through the serde token it is persisted as | DR-162 | Done |
| UT-158 | Justified rows fill the container width exactly and never overflow it, every tile in a row shares one height, and each tile's width follows its own aspect ratio — a 16:9 tile coming out more than twice the width of a 2:3 tile at the same height | DR-174 | Done |
| UT-159 | The awkward cases of the packing: a short last row is left at the target height rather than stretched across the container, a last row that would overflow is brought down, an extreme ratio is clamped instead of taking a row to itself, a missing or nonsensical ratio falls back to square instead of collapsing the tile, an unmeasured container renders nothing rather than 1px tiles, and every tile is placed exactly once in order | DR-174 | Done |
| UT-160 | The default row height suits its container: it grows with the width, stays inside its bounds, and at phone width still fits two 16:9 tiles side by side | DR-174 | Done |
| UT-161 | A collection type maps to its favourites scope (`movies`/`tvshows`/`music`), every other kind — Live TV, channels, box sets, books, unknown — maps to none rather than to `All`, and a constructed library carries the scope across the wire as `favoritesScope`, omitted entirely when it has none | DR-175 | Done |
| UT-167 | The mosaic's composition: the cross-library favourites entry leads, each library is followed by its own category tile pointing at that category's tab, a category shared by two libraries still yields one tile, a library kind favourites do not carve up yields none, a scope the page offers no tab for is ignored, and every tile is uniquely keyed | DR-174, DR-175 | Done |
| UT-168 | Subtitles are negotiated as sidecars, never burned in: the requested `SubtitleStreamIndex` is the explicit "none" sentinel (`-1`) rather than omitted, every text format we can render (`srt`/`subrip`/`ass`/`ssa`/`vtt`) is advertised as `External`, and the burn-in verdict is by format — text never forces it, image formats (PGSSUB, dvdsub) always do, case-insensitively. The same sentinel rides the stream URL itself, so a stream re-opened without a fresh negotiation cannot inherit a subtitle. And the verdict reaches the picker: a subtitle stream carries `supportsExternalDelivery` — set only for subtitles, `false` for a bitmap format and for one the server left unnamed — which drops the tracks the app could never draw from the menu, the `<track>` children and the native play request alike, without even fetching their URLs, while a stream carrying no verdict at all is still offered | DR-176 | Done |
| UT-173 | Every video stream URL carries a `PlaySessionId`, each open mints a fresh one, and the open reports the session it superseded so that job can be stopped | DR-177 | Done |
| UT-174 | A fatal HLS network error is read against the *absolute* position: mid-film — including after a quality switch, where the seek offset carries the whole resume position — it is retried rather than reported as the end of the stream, the last tenth of a known runtime is treated as the end, an unknown runtime retries, and retries stop once the budget is spent | DR-177 | Done |
| UT-175 | A stream reload that never becomes playable is reported as a failure instead of resolving as success, so the caller can revert its selection rather than leave the UI claiming a stream that is not playing | DR-177 | Done |
| UT-176 | A handoff's position is floored at its base: with no tick yet landed the exit position is the point the screen was locked at rather than 0, and once ticks are flowing (the base already applied natively) it is not added twice | DR-178 | Done |
| UT-177 | Webview-rendered media's reported position and duration are the controller's, and are dropped the moment that element stops being the player — on teardown, and when a handoff takes over | DR-178 | Done |
| UT-178 | A stop report at position 0 is withheld rather than sent (it would clear the resume point), while a real position is still reported from either rendering path — the element's on the webview path, the backend's on the native one | DR-179 | Done |
| UT-179 | An audio-only episode that ends naturally is reported stopped at its runtime, so Jellyfin marks it played; a truncated stream, which is about to be re-opened, reports nothing | DR-179 | Done |
| UT-180 | Position ticks report progress to the server, throttled to one report per item per window rather than one per tick | DR-179 | Done |
| UT-181 | The handoff plan matches its source: a downloaded file takes no base and a seek, a stream takes the base and no seek, and a handoff at 0:00 takes neither; a downloaded handoff's absolute seek stays an ordinary seek instead of a stream rebuild | DR-180 | Done |
| UT-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done |
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done |
| UT-145 | The frontend's subtitle payload survives the IPC hop: a camelCase `PlayItemRequest` carrying `subtitles` deserializes, `create_media_item` lands them on `MediaItem.subtitles` in the order sent, and a request without the field still defaults to empty | UR-020, IR-016 | Done |
| UT-146 | The subtitle JSON serialized across the JNI boundary uses the keys `JellyTauPlayer.load()` reads — `url`, `language`, `label` and `mime_type`, never `mimeType` | UR-020, IR-016, JA-008 | Done |
| UT-147 | The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps `mime_type` and stream order, `playerPlayItem` actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number | UR-020, IR-016 | Done |
| UT-182 | An HLS video URL never carries `StartTimeTicks` — with a position supplied or not — while the master playlist, codec, media source and chosen audio track still ride on it | DR-181 | Done |
| UT-183 | A reloaded stream is resumed by seeking the element to the absolute position with the transcode offset cleared to zero — never by carrying the position as an offset base, which since DR-181 would display the position while playing the item from its start — and a reload to 0:00 waits for no seek | DR-181 | Done |
| UT-184 | The native reveal rule fires on `state === "playing"` and on a position tick carrying a position or a duration, and on nothing else — not `buffering`, `paused`, `stopped`, `ended` or `error`, not an empty tick, and not a negative position | DR-182 | Done |
| UT-188 | The control-bar auto-hide rule permits hiding only during uninterrupted playback: it declines while paused, while a seek is in flight, and while a track/subtitle/quality menu is open — asserted against the pure `shouldHideControls` rule rather than a clock or a DOM | DR-189 | Done |
| UT-189 | On the native path the player never calls `player_report_state` — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothing | DR-195 | Done |
| UT-187 | On the native path the play overlay follows the backend: it clears when the backend resumes after a pause and is raised again when the backend pauses, and the system bars are hidden on player entry rather than only by the fullscreen button | DR-186, DR-187 | Done |
| UT-186 | Every attribute the native-video compositing block in app.css targets is set somewhere in the app — `[data-app-shell]` in particular — so a selector aimed at nothing fails the suite instead of failing silently on a device | DR-185 | Done |
| UT-185 | Mounted on the native path (backend reports native, opt-in flag on, no `<video>` element rendered and the backend not stopped), VideoPlayer keeps the poster card up until the backend reports something, drops it on a playing state or a position tick with a duration, and keeps it up through `error` and `stopped` | DR-182 | Done |
| UT-190 | `build_next_up_endpoint` sends `EnableResumable=false` with the user and limit, and no `SeriesId` filter when none was requested | DR-197, JA-036 | Done |
| UT-191 | A per-series next-up query keeps `SeriesId` and the resumable exclusion, and defaults the limit | DR-197 | Done |
| UT-192 | `filterInProgressNextUpItems` drops an episode present in the resume list, keeps the genuinely unstarted next episode, leaves the rest of the row intact, and is a no-op when nothing is in progress | DR-197 | Done |
| UT-193 | The shipped Tauri security config stays restrictive: `csp` is set, `script-src` carries no `'unsafe-inline'`/`'unsafe-eval'`/wildcard, `object-src`/`frame-src` are `'none'`, the directives playback needs (asset scheme, loopback, `blob:`, `ipc:`) are present, and the asset-protocol scope covers only the thumbnail cache — never the storage root that holds the database | DR-198 | Done |
| UT-194 | Normal audio (no background-audio handoff) keeps queue advance on both skip buttons | DR-201 | Done |
| UT-195 | In background-audio mode a skip scrubs +30s/-10s instead of advancing the queue — the reported defect | DR-201 | Done |
| UT-196 | Skipping back near the start clamps to zero rather than seeking negative | DR-201 | Done |
| 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
@@ -429,8 +727,8 @@ Internal architecture, components, and application logic.
| IT-005 | MPRIS lockscreen controls on Linux | IR-005, UR-006 | Pending |
| IT-006 | Offline mode with local database | IR-013, UR-002 | Pending |
| IT-007 | Media download and local playback | DR-015, UR-011 | Pending |
| IT-008 | Subtitle track selection via libmpv | IR-018, UR-020 | Pending |
| IT-009 | Audio track selection via libmpv | IR-019, UR-021 | Pending |
| IT-008 | Subtitle track selection on the video backends (ExoPlayer sideloaded tracks; HTML5 `<track>` children) — *not* via libmpv, which does not implement it | IR-018, UR-020 | Pending |
| IT-009 | Audio track selection on the video backends (ExoPlayer track switch; HTML5 stream re-open at the chosen `AudioStreamIndex`) — *not* via libmpv, which does not implement it | IR-019, UR-021 | Pending |
| IT-010 | Playback progress sync to Jellyfin | IR-015, UR-025 | Pending |
| IT-011 | Resume playback from server position | IR-015, UR-019 | Pending |
| IT-012 | Equalizer bands via libmpv | IR-020, UR-027 | Pending |
@@ -442,6 +740,50 @@ Internal architecture, components, and application logic.
## 5. Technical Debt
### Open items carried over from the v0.6.0 codebase audit
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
> stripping JNI-loaded classes has broken release builds here before, and
> v0.8.0 added a new Kotlin path (`onFastForward`/`onRewind`) that the
> unminified debug pass did not cover.
| # | Item | Why it matters | Size |
|---|------|----------------|------|
| 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** | `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 | **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
**Issue**: The `keyring-rs` crate (v3.x) has issues with retrieving credentials from the Linux Secret Service API, despite successfully saving them.
@@ -486,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**:
@@ -512,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`
@@ -553,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.
-196
View File
@@ -1,196 +0,0 @@
# Spec: Android native video — transparent-webview spike
**Status:** Proposed (spike — timeboxed, may conclude "not viable")
**Requirements:** IR-004, UR-003, UR-004 → DR-001, DR-023, DR-024
**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.
### 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.
### 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**
- [ ] Transparent WebView confirmed working on a physical device.
- [ ] `experimentalNativeVideo` off → behaviour byte-identical to today.
- [ ] `webviewAudio.ts` no longer inspects `navigator.userAgent`; the platform's audio backend is read from Rust.
- [ ] `experimentalNativeVideo` on → video plays via ExoPlayer/MediaCodec, correctly positioned, with working seek, audio-track switch, and subtitle selection through the existing `PlayerAdapter` contract.
- [ ] No artefacts on rotation, background/foreground, or mini-player transition.
- [ ] `adb shell dumpsys media.metrics` (or logcat) confirms a hardware decoder is in use.
- [ ] 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:
- [ ] `bun run check`, `bun run test`, `bun run check:boundary` pass.
- [ ] `cargo fmt` / `cargo clippy` clean; `bun run test:rust` passes.
## 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-023, DR-024`
- 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:** —
+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.
+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)
+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
+231
View File
@@ -0,0 +1,231 @@
# Spec: Two-path media — selectable playback bitrate, independent whole-file download
**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:** 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
Two things that are today tangled become explicitly separate:
- **The playback path** streams at a bitrate the viewer can change from the
player. It is ephemeral and its rendition is volatile.
- **The download path** fetches the whole file at one canonical quality, in the
background, independently of whatever playback is doing.
Bytes fetched for playback are kept **only** when the playback rendition happens
to be the same artifact the download path would produce — i.e. direct play.
Otherwise playback bytes are discarded and the download path does its own fetch.
## Motivation
The appealing version of this — "stream and download at once, switch when enough
has arrived" — breaks the moment the viewer can change bitrate. A capture taken
while the rendition changes underneath it is a splice of two encodings: not a
playable file, and not something that can be honestly recorded as a download.
Once bitrate is selectable, one stream cannot serve both jobs.
Separating the paths also removes the thing that made the original idea
expensive: there is no mid-playback source swap to engineer, because the download
never has to take over the live session. It lands on disk and is used at the next
natural boundary — next episode, or next time the item is played.
What exists already and is *not* this: `SmartCache` predictively downloads *other*
items, `player_preload_upcoming` warms the next one, and
`refresh_queue_local_sources` swaps queue entries to local at boundaries. All of
it concerns items you are not currently playing.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Available bitrate options for an item | **Rust** | Derived from Jellyfin's media sources and playback-info negotiation; changes with the API. |
| Mapping a chosen bitrate to transcode parameters | **Rust** | Domain vocabulary. `get_video_download_url` already owns the quality→params mapping; playback must reuse it, not restate it. |
| Deciding whether playback bytes are keepable (direct play vs transcode) | **Rust** | Depends on the negotiated session. |
| Canonical download quality | **Rust** | Policy over domain data. |
| Cache eviction, storage budget, sparse-range bookkeeping | **Rust** | Storage policy. |
| Promotion to a `downloads` row, and what invalidates a cache entry | **Rust** | Domain state. |
| Rendering the quality selector; remembering the last choice | **Frontend** | Presentation and a view preference. The *list* comes from Rust. |
| WiFi-only / opt-in toggles | **Frontend collects, Rust enforces** | The control is UI; the gate must hold even if the UI never calls. |
Borderline, recorded: the **default** playback bitrate could look like a user
preference (frontend). It goes to Rust because it must be reconcilable with what
the server can actually produce for a given media source — a preference the
backend has to validate is not a preference the frontend can own alone. The
frontend stores the user's *choice*; Rust decides what that choice resolves to.
## Design
### DR-121 — Bitrate selection in the player
The player exposes the qualities Rust reports for the current item. Changing it
re-negotiates the stream URL at the new quality and resumes at the current
position. This is a deliberate, user-initiated interruption — a brief rebuffer is
expected and acceptable, unlike the involuntary swap the earlier design would
have needed.
Constraints that must not be broken:
- On Linux, video playback must keep using the HLS `master.m3u8` URL. CLAUDE.md
records that returning `stream.mp4` means transcoded playback never starts.
A quality change re-negotiates *within* HLS.
- The quality→transcode-parameter mapping already exists in
`get_video_download_url` ([online.rs:1702-1717](../../src-tauri/src/repository/online.rs#L1702-L1717)).
Playback must call into the same mapping. Two copies of that table will drift.
- Track selection (audio/subtitle) already survives a stream re-negotiation
elsewhere in the player; a quality change must preserve it too.
### DR-122 — The playback path is ephemeral
Playback bytes are not persisted unless DR-124 says they are keepable. No partial
capture is ever retained across a quality change: on change, any in-flight capture
for that session is abandoned and its partial file deleted.
### DR-123 — The download path is independent
Downloading the whole file is a separate operation through the existing download
manager, at one canonical quality (default `original`, the direct static copy),
using `/Videos/{id}/stream.mp4` — progressive and Range-capable, which is what
the resumable download worker relies on. It is unaffected by what playback is
doing, and playback is unaffected by it.
Once complete it becomes an ordinary download row, so everything already built on
top of downloads — offline browsing, `refresh_queue_local_sources`, the Downloads
page — picks it up with no further work.
**Prerequisite:** downloaded *video* is currently never played locally.
`repository_get_video_stream_url` goes straight to the online repo and
[player/[id]/+page.svelte:316](../../src/routes/player/[id]/+page.svelte#L316)
calls it with no local check — so a completed video download is still streamed.
This must be fixed or the whole feature is invisible for video.
### DR-124 — Keep playback bytes only when they *are* the download
Capture is enabled only where the played bytes and the canonical download artifact
are the same thing — a **direct-play** session. Then:
| Path | Mechanism |
|---|---|
| Android / ExoPlayer | `SimpleCache` + `CacheDataSource`, keyed by item id **and** media-source id so renditions never collide. LRU evictor sharing the existing smart-cache budget — not a second budget over the same disk. |
| Linux audio / MPV | `stream-record`, set through the existing `set_property` plumbing. |
| Linux video (HLS transcode) | **Not captured.** Segments are not a file; assembling one needs ffmpeg, which is not a dependency and which CI is forbidden from installing at job time. The download path (DR-123) covers this case instead. |
Two abandonment rules, both of which must delete the partial rather than promote
it:
- **Seek during an mpv capture.** `stream-record` is documented as intended for
linear streams; seeking breaks the recording. Straight-through listening
captures, scrubbing does not.
- **Any quality change** (DR-122).
### DR-125 — Promotion, rendition, and invalidation
A capture is promoted to a `downloads` row (`status = 'completed'`) only when it
covers the whole resource. Partial captures stay cache and remain evictable.
A new `downloads.source_rendition` column records the negotiated
quality/container/codec of whatever produced the bytes; `NULL` for rows fetched by
the existing paths, which are always `original`. This is what makes an "upgrade to
original" action possible later, and what stops a 720p capture and a 4K download
being indistinguishable rows.
**Invalidation.** A quality change never touches a file that already exists —
neither a permanent download nor a completed temporary one. Both remain valid
copies of the rendition they hold, and deleting either would throw away bytes
already paid for.
What a quality change *does* invalidate is an **in-flight** capture or background
download of cached media: it is abandoned and restarted at the newly chosen
quality, because a capture spanning a rendition change is a splice of two
encodings rather than a playable file (DR-122).
So the rule is about *ongoing* work, not stored files. Nothing in this spec
deletes user data.
### Gating
Capture and background download obey the existing WiFi-only gate and storage
budget, and are off unless opted in. Enforcement is in Rust.
## Out of scope
- **Mid-playback switch onto a completing download.** Two independent paths make
it unnecessary; the download is used from the next boundary.
- **Backfilling the unplayed remainder of a capture.** Watch 40 minutes and you
have 40 minutes; completing it needs sparse-range bookkeeping and a resumable
tail fetch. The DR-123 download path already produces a complete file, which is
the reason this can wait.
- **Bundling ffmpeg** to make transcoded video capturable. Real option, large
packaging decision, its own proposal.
- **Routing Linux video playback through `stream.mp4`.** Regresses a documented,
hard-won fix.
## Acceptance criteria
- [ ] The player offers the qualities Rust reports, and changing one resumes at
the same position with audio/subtitle selection preserved.
- [ ] A quality change abandons any in-flight capture and leaves no partial file.
- [ ] A quality change never deletes a `downloads` row.
- [ ] A completed background download of a video is *played from disk* on the next
play (the DR-123 prerequisite).
- [ ] A direct-play session played start-to-finish leaves a complete local file
with no second fetch; replaying it fetches no media bytes.
- [ ] Seeking during an mpv capture abandons it; no truncated file is promoted.
- [ ] A transcoded Linux video session is never captured, and never partially
promoted.
- [ ] Promoted rows record their rendition; existing paths still record
`NULL`/`original`.
- [ ] Gates hold with the setting off *and* with the frontend never sending it.
- [ ] Eviction cannot delete bytes backing a promoted download row.
- [ ] `bun run check`, `bun run test`, `cargo fmt`, `cargo clippy`,
`bun run test:rust`, `bun run check:boundary` pass; `bindings.ts`
regenerated if Rust types changed.
## Testing
Rust, table-driven and pure where possible: quality→params resolution shared with
the download path; keepability (direct play vs transcode vs gate off); promotion
(complete → promoted, partial → not, seek-abandoned → not, quality-changed → not);
invalidation (evicts cache, never a download row); rendition round-trip.
Android: instrumented — a played direct-play item yields cache entries, and a
replay issues no media network request.
Frontend: the quality list renders from backend data with no item-type or
codec taxonomy in `src/`; the selector's remembered choice is a view preference.
## TRACES
| Piece | Tag |
|---|---|
| Quality selector + re-negotiation | `// TRACES: UR-070 \| DR-121` |
| Ephemeral playback / capture abandonment | `// TRACES: UR-070 \| DR-122` |
| Independent whole-file download + local video playback fix | `// TRACES: UR-071 \| DR-123, IR-032` |
| ExoPlayer cache / mpv stream-record / keepability | `// TRACES: UR-071 \| DR-124` |
| Promotion, `source_rendition`, invalidation | `// TRACES: UR-071 \| DR-125` |
## Notes for the implementer
- **A parallel Claude session is active in this repo.** `git diff` before
"repairing" anything you did not write.
- Do not duplicate the quality→transcode-parameter table. Call the existing one.
- Reuse the smart-cache storage budget; two budgets over one disk is how devices
fill up.
- The `downloads` FK to `items` is relaxed (migration 005) — exercise promotion
for an item that was never cached.
- Build DR-123's local-playback fix first. Without it nothing in this spec is
observable for video.
-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
-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)
+47 -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 (50%)
- ✅ 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:** 50%
- **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,15 +54,47 @@ 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
**orphaned** and does not count toward coverage. UT/IT test identifiers are a
separate taxonomy and are excluded entirely.
The workflow **fails** and blocks merge if coverage drops below 50% — or if it
computes above 100%, which can only mean the gate is miscounting.
The workflow **fails** and blocks merge if coverage drops below the threshold —
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 (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. 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
instead. The same number lives in `MIN_COVERAGE_PERCENT` in
`scripts/extract-traces.ts` (so `bun run traces:coverage` gates locally on the
same bar); `scripts/extract-traces.test.ts` fails if the two drift apart.
### 2b. Dangling requirement IDs
```bash
bun run traces:validate
```
Every ID named by a `TRACES:` comment must be defined as a table row in
`docs/requirements.md`. The extractor used to accept any well-formed ID
silently, so a typo or a rename that missed a call site passed unnoticed —
`DR-189` and `UT-188` were referenced from three source files, defined nowhere,
for months.
This check spans **all six** ID types (UR/IR/DR/JA/UT/IT), unlike the coverage
`orphaned` list above, which considers only the four requirement types so that
UT/IT noise cannot bury a real typo in the ratio's reporting. The workflow step
**fails the build** on any dangling ID and prints each offender with the files
that reference it.
### 3. Modified File Checking
On pull requests, the workflow:
@@ -120,13 +152,13 @@ TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
### On Push to Main Branch
1. ✅ Extracts all traces from code
2. ✅ Validates coverage is >= 50%
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 >= 50%
2. ✅ Validates coverage >= 88%
3. ✅ Checks modified files for TRACES
4. ✅ Warns if new code lacks TRACES
5. ✅ Suggests proper format
@@ -134,7 +166,8 @@ TRACES: [UR-###, ...] | [IR-###, ...] | [DR-###, ...] | [JA-###, ...]
### Failure Scenarios
The workflow **fails** (blocks merge) if:
- Coverage drops below 50%
- Coverage drops below 88%
- A `TRACES:` comment names an ID `docs/requirements.md` does not define
- JSON extraction fails
- Invalid trace format
@@ -171,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 ≥50% overall
- **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
@@ -209,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 ≥ 50%)
- [ ] Workflow passes (coverage ≥ 88%)
- [ ] No coverage regressions
- [ ] Artifact traceability report was generated
## Troubleshooting
### "Coverage below minimum threshold"
**Problem:** Workflow fails with coverage < 50%
**Problem:** Workflow fails with coverage < 88%
**Solution:**
1. Run `bun run traces:json` locally
+9527 -2630
View File
File diff suppressed because it is too large Load Diff
+13 -12
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
@@ -133,18 +133,19 @@ bun run traces:json | jq '.requirements."UR-005"'
### Before Committing
1. Ensure all new code has TRACES
2. Format is correct: `// TRACES: ...`
3. Requirements exist in README.md
4. No typos in requirement IDs
3. Requirements exist in `docs/requirements.md``bun run traces:validate`
4. No typos in requirement IDs (same command catches them)
## CI/CD Validation
The workflow automatically checks:
- ✅ Coverage stays >= 50%
- ✅ 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
@@ -198,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)
---
+150 -3
View File
@@ -634,7 +634,7 @@ episode strip.
│ │ S2E4 • 48m • ★8.1 │ │
│ │ Overview… │ │
│ │ ▓▓▓▓▓░░░░░ 32m left │ │
│ │ [▶ Play] │ │
│ │ [▶ Play] [⬇] [♡] │ │
│ └───────────────────────────────────────────┘ │
│ │
│ More Episodes │ ← 2. EPISODE STRIP
@@ -688,10 +688,10 @@ A movie has no continuation set, so cast follows the hero directly.
### 5B.4 Series detail — section order
```
Hero (poster, title, metadata, Play / Download)
Hero (poster, title, metadata, Resume SxEy / Download / Favorite / Clear history)
→ Crew links
→ Genre tags
→ Seasons + episodes (per-season sections)
→ Seasons (collapsible; only the current season expanded)
→ Cast
→ More Like This
```
@@ -700,6 +700,29 @@ The same principle as §5B.2: **episodes come before cast and similar shows.**
The reason a user opens a series page is to pick an episode; discovery content
is secondary and sits underneath.
**Rules for the seasons block** *(UR-062, UR-064)*:
- **The page opens where the viewer is.** The backend resolves the current
episode — in progress, else Next Up, else first unwatched, else the premiere —
and the page scrolls it into view with an `Up next` badge and a highlight ring.
Never season 1 by default, unless season 1 *is* where the viewer is.
- **Seasons collapse; only the current one is expanded.** A ten-season show
otherwise renders hundreds of rows and buries the episode the viewer came for.
A collapsed season still names its episode count and watched count, so
progress is readable without expanding it.
- **The hero button opens, it does not play.** It reads `Resume S2E4` /
`Play S1E1` — naming its target — and navigates to that episode's Focus View,
where Play commits. Play on a *container* is navigation (§5B.5); Play on a
*leaf* is the commitment.
- **A season is never its own page.** `/library/<seasonId>` redirects to
`/library/<seriesId>#season-N`. Every affordance that names a season — the
episode breadcrumb, a season card in a grid, a Downloads drill-in — lands on
the series with that season in view, so the episodes of all seasons stay one
browsable list.
- **Watch history is erasable** per series (hero) and per season (season
header). It confirms first, cannot be undone, and needs the server. Clearing a
whole series returns it to S1E1 by the same path a never-watched show takes.
### 5B.5 Home-card interaction — tap opens, long-press plays
Cards on the Home screen carousels (Next Movie, Next Episode, Continue
@@ -734,6 +757,130 @@ opt-in. Grids and other surfaces keep tap-to-open with no long-press.
---
## 5C. Favourites
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 [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
A favourite is a property of an *item*, so the affordance follows the item
rather than living on one privileged screen. Any surface that shows a whole
item shows its heart.
| Surface | Heart position | Notes |
|---------|----------------|-------|
| Movie / Series detail hero | In the button row, after Play and Download | §5B.3, §5B.4 |
| Episode Focus View hero | Same row as Play / Download | §5B.2 |
| Album, Artist, Playlist detail | In the header button row | §5.2 |
| Media card (any grid or carousel) | Top-right overlay on the artwork | Hidden on server-only (greyed) cards |
| Mini player | Right of the track metadata | Existing behaviour, unchanged |
| Full player | Secondary controls row | §3.2 — **not yet built**, see §5C.5 |
Rules:
- **The heart never competes with the card.** On a media card it is its own
button and swallows the tap, so hearting an item never also opens or plays
it, and never triggers the §5B.5 long-press.
- **State is shown, not guessed.** A filled heart means the *server* considers
the item a favourite (or you just tapped it). An item favourited in Jellyfin
Web, on another device, or by another client renders filled here without
being touched in JellyTau.
- **Feedback is immediate.** The heart fills on tap and a toast confirms;
neither waits for the server round-trip.
### 5C.2 Three ways back to what you favourited
Favourites are not one destination — they are a lens, and the right surface
depends on whether the user is *browsing*, *deciding*, or *hunting*.
```mermaid
flowchart TB
User[User wants their favourites] --> How{Intent}
How -->|Passive: show me something| Home[Home carousels<br/>Favourite Movies / Shows / Music]
How -->|Deliberate: my whole collection| Page[Favourites page<br/>/library/favorites]
How -->|Narrowing: within this library| Filter[Favourites filter<br/>on a library page]
Home -->|See all| Page
Page --> Detail[Item detail page]
Filter --> Detail
```
**Home carousels.** Rows for favourite movies, shows and music sit below
*Recently Added*. A row with nothing in it **does not render** — a fresh install
shows no empty favourite rows. Each row ends with *See all*, landing on the
matching tab of the Favourites page.
**The Favourites page** (`/library/favorites`) is the complete collection,
scoped by tabs:
```
┌─────────────────────────────────────────────────┐
│ [←] Favourites │
│ ┌─────┬────────┬───────┬───────┐ │
│ │ All │ Movies │ Shows │ Music │ ← scope tabs │
│ └─────┴────────┴───────┴───────┘ │
│ │
│ ┌────┐┌────┐┌────┐┌────┐┌────┐ │
│ │ ♥ ││ ♥ ││ ♥ ││ ♥ ││ ♥ │ grid/list │
│ └────┘└────┘└────┘└────┘└────┘ per §5A │
└─────────────────────────────────────────────────┘
```
- Cards obey §5A in full — shape follows the media, so a mixed *All* tab reads
as posters, squares and thumbnails side by side rather than one forced shape.
- Reached from a card on the library overview (`/library`) and from *See all*
on any home favourites row.
- Sorted by name. Jellyfin does not record *when* an item was favourited, so
"recently favourited" is not offerable — see §5C.5.
- Empty state, per tab: *"Nothing favourited yet — tap the heart on anything
you like."*
**The in-library filter** is for narrowing where the user already is: a
favourites toggle in the header of the Movies, TV and Music browse pages,
filtering the current list in place. It is **session-scoped and not persisted**
a sticky filter that silently hides most of a library reads as data loss on the
next launch.
### 5C.3 Removing a favourite removes it everywhere, at once
Un-hearting an item on the Favourites page removes its card from the grid
immediately; the same item disappears from the home rows and shows an empty
heart on its detail page without a manual refresh. The reverse holds for
favouriting. There is no confirmation prompt — the action is one tap to undo.
### 5C.4 Offline
- **Marking works offline.** The heart fills, the toast confirms, and the change
is held locally.
- **It reaches the server on reconnect**, without the user returning to the
screen where they made it.
- **Browsing offline shows favourites among media on the device**, subject to
the same "Show all server media" gate as every other browse surface (§7.2) —
with the gate off, an empty Favourites tab means *nothing favourited is
downloaded*, and the page does not quietly fall back to the server catalog.
### 5C.5 Known deviations
- **The full player has no heart.** §3.2 and §3.3 list a Favorite button among
the full player's secondary controls; it was never built, and this pass does
not add it. The mini player heart above it is the only in-player affordance.
*(UR-067)*
- **No "recently favourited" sort.** Jellyfin's API does not expose a favourite
timestamp, so favourites can only be ordered by name. Recording the
timestamp locally at toggle time would order *this device's* favourites only,
which is worse than a consistent name sort.
- **Music is one tab, not three.** The Music scope mixes albums, artists and
tracks in a single grid rather than offering sub-tabs. Acceptable while
favourite counts are small; revisit if the tab becomes unscannable.
---
## 6. Search Flow
Search is **context-scoped**: what you are looking at when you start a search
-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",
},
},
);
+35 -16
View File
@@ -1,7 +1,14 @@
{
"name": "jellytau",
"version": "0.2.7",
"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",
@@ -39,37 +52,43 @@
"traces:json": "bun run scripts/extract-traces.ts --format json",
"traces:markdown": "bun run scripts/extract-traces.ts --format markdown > docs/traceability.md",
"traces:coverage": "bun run scripts/extract-traces.ts --format coverage",
"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"
+85 -4
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
@@ -69,7 +84,8 @@ Extract requirement IDs (TRACES) from source code and generate a traceability ma
bun run traces # Generate markdown report
bun run traces:json # Generate JSON report
bun run traces:markdown # Save to docs/traceability.md
bun run traces:coverage # Coverage gate — exits non-zero below 50%
bun run traces:coverage # Coverage gate — exits non-zero below the ratchet
bun run traces:validate # Dangling-ID gate — every traced ID must be defined
```
The script scans all TypeScript, Svelte, and Rust files (plus `scripts/`)
@@ -84,13 +100,20 @@ derived from `docs/requirements.md` at run time; they are never hardcoded. An ID
that appears in a `TRACES:` comment but is not defined in `requirements.md` is
reported as *orphaned* and does not count toward coverage (see DR-093).
**`bun run traces:validate` is the dangling-ID gate.** It fails if any traced ID
— including `UT`/`IT`, which coverage deliberately ignores — is not defined as a
table row in `requirements.md`, printing each offender with the files that
reference it. Without it the extractor accepted any well-formed ID silently, so
typos and renames that missed a call site went unreported for months.
> **Removed:** `check-req-coverage.sh`, `check-test-coverage.sh`, and
> `find-req-implementations.sh` were deleted in July 2026. They read an
> undocumented `@req:` tag convention parallel to `TRACES:`, grepped `src-tauri/`
> 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
@@ -104,7 +127,8 @@ See [docs/traceability.md](../docs/traceability.md) for the latest generated map
The traceability system is integrated with Gitea Actions CI/CD:
- Automatically validates TRACES on every push and pull request
- Enforces minimum 50% coverage threshold
- Enforces a minimum coverage threshold (a ratchet: raise it, never lower it)
- Fails on dangling IDs — traced but undefined in `requirements.md`
- Warns if new code lacks TRACES comments
- Generates traceability reports automatically
@@ -112,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`
@@ -124,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
+6 -4
View File
@@ -11,11 +11,13 @@ echo ""
echo ""
# Deploy APK — extract build type (default debug), ignoring flags like --clean.
BUILD_TYPE="debug"
# Deploy APK — forward the build type and the side-by-side flag (which decides
# which package to launch), ignoring build-only flags like --clean and --device.
DEPLOY_ARGS=("debug")
for arg in "$@"; do
case "$arg" in
debug|release) BUILD_TYPE="$arg" ;;
debug|release) DEPLOY_ARGS[0]="$arg" ;;
--debug|--side-by-side) DEPLOY_ARGS+=("--side-by-side") ;;
esac
done
./scripts/deploy-android.sh "$BUILD_TYPE"
./scripts/deploy-android.sh "${DEPLOY_ARGS[@]}"
+47 -4
View File
@@ -23,9 +23,18 @@ echo ""
# which is what a distributable universal APK needs — but for an on-device test
# it means three wasted Rust compiles. Pass --device (or ABI=aarch64) to build
# only the connected device's architecture; --abi <t> targets one explicitly.
#
# Side-by-side: the `debug` build type always installs as
# com.dtourolle.jellytau.debug ("JellyTau Debug"), so it never collides with a
# real install. `release --debug` puts a *release* build — R8-minified, exactly
# what ships — into that same slot, signed with the local debug keystore. That
# is how you validate minification (R8 stripping JNI-loaded classes has broken
# release APKs here before) without the real signing key and without
# uninstalling the app you actually use.
BUILD_TYPE="debug"
CLEAN="${CLEAN:-0}"
ABI="${ABI:-}"
SIDE_BY_SIDE="${SIDE_BY_SIDE:-0}"
next_is_abi=0
for arg in "$@"; do
if [ "$next_is_abi" = "1" ]; then
@@ -37,10 +46,17 @@ for arg in "$@"; do
--clean) CLEAN=1 ;;
--abi) next_is_abi=1 ;;
--device) ABI="device" ;;
--debug|--side-by-side) SIDE_BY_SIDE=1 ;;
debug|release) BUILD_TYPE="$arg" ;;
esac
done
# The debug build type is side-by-side unconditionally; the flag only means
# something for a release build.
if [ "$BUILD_TYPE" = "debug" ]; then
SIDE_BY_SIDE=1
fi
# Resolve --device to the attached device's Rust target triple.
if [ "$ABI" = "device" ]; then
device_abi="$(adb shell getprop ro.product.cpu.abi 2>/dev/null | tr -d '\r\n')"
@@ -66,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
@@ -78,17 +104,34 @@ echo "🎨 Building frontend..."
bun run build
# Step 2: Build Android APK
if [ "$BUILD_TYPE" = "release" ]; then
# `--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 "${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"
+39 -4
View File
@@ -13,25 +13,60 @@ if ! adb devices | grep -q "device$"; then
exit 1
fi
# Build type: debug or release (default: debug)
BUILD_TYPE="${1:-debug}"
# Build type: debug or release (default: debug). `--debug` alongside `release`
# means the side-by-side release build — same APK path, but it was packaged
# under the .debug applicationId, so the package to launch differs.
BUILD_TYPE="debug"
SIDE_BY_SIDE=0
for arg in "$@"; do
case "$arg" in
--debug|--side-by-side) SIDE_BY_SIDE=1 ;;
debug|release) BUILD_TYPE="$arg" ;;
esac
done
[ "$BUILD_TYPE" = "debug" ] && SIDE_BY_SIDE=1
# The .debug applicationId (see src-tauri/android/app/build.gradle.kts) is a
# separate package, so it installs alongside a real release build — no
# uninstall dance needed.
if [ "$BUILD_TYPE" = "release" ]; then
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk"
else
APK_PATH="src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk"
fi
if [ "$SIDE_BY_SIDE" = "1" ]; then
APP_PACKAGE="com.dtourolle.jellytau.debug"
else
APP_PACKAGE="com.dtourolle.jellytau"
fi
# Check if APK exists
if [ ! -f "$APK_PATH" ]; then
echo "❌ APK not found at: $APK_PATH"
if [ "$BUILD_TYPE" = "release" ] && [ "$SIDE_BY_SIDE" = "1" ]; then
echo "Run './scripts/build-android.sh release --debug' first"
else
echo "Run './scripts/build-android.sh $BUILD_TYPE' first"
fi
exit 1
fi
echo "📦 Installing APK: $APK_PATH"
adb install -r "$APK_PATH"
echo "📛 Package: $APP_PACKAGE"
if ! adb install -r "$APK_PATH"; then
echo ""
echo "❌ Install failed."
echo " If it says INSTALL_FAILED_UPDATE_INCOMPATIBLE, an older build of"
echo " '$APP_PACKAGE' signed with a different key is still installed."
echo " Uninstall just that one and retry:"
echo " adb uninstall $APP_PACKAGE"
exit 1
fi
echo ""
echo "✅ Deployment complete!"
echo "🚀 Launch the app on your device"
echo "🚀 Launching..."
adb shell monkey -p "$APP_PACKAGE" -c android.intent.category.LAUNCHER 1 > /dev/null 2>&1 \
|| echo " (auto-launch failed — start it from the launcher)"
+244 -20
View File
@@ -11,10 +11,76 @@
*
* @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";
import { countDefinedRequirements, computeCoverage } from "./extract-traces";
import * as fs from "fs";
import * as path from "path";
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", () => {
@@ -82,6 +148,81 @@ Some prose explaining that UR-005 relates to DR-001 and JA-002.
expect(defined.ids.has("DR-050")).toBe(true);
expect(defined.ids.has("UR-999")).toBe(false);
});
it("collects UT/IT rows separately, out of the coverage denominator", () => {
// §4 defines the test taxonomy. Those rows must be known (so a TRACES
// comment may name them) without ever moving the coverage ratio.
const md = `
| UR-001 | A | High | Done |
| UT-001 | Player state transitions | DR-001 | Pending |
| IT-004 | Playback end-to-end | DR-002 | Pending |
`;
const defined = countDefinedRequirements(md);
expect(defined.total).toBe(1);
expect(defined.ids.has("UT-001")).toBe(false);
expect(defined.testIds.has("UT-001")).toBe(true);
expect(defined.testIds.has("IT-004")).toBe(true);
});
});
describe("findDanglingIds", () => {
const defined = {
UR: 1,
IR: 0,
DR: 1,
JA: 0,
total: 2,
ids: new Set(["UR-001", "DR-001"]),
testIds: new Set(["UT-001"]),
};
it("flags a requirement ID that requirements.md does not define", () => {
expect(findDanglingIds(["UR-001", "DR-189"], defined)).toEqual(["DR-189"]);
});
it("flags an undefined UT/IT id, which the coverage orphan list cannot", () => {
// The gap this closes: computeCoverage deliberately ignores UT/IT, so
// UT-188 sat in three source files, defined nowhere, entirely unreported.
expect(computeCoverage(["UT-188"], defined).orphaned).toEqual([]);
expect(findDanglingIds(["UT-188"], defined)).toEqual(["UT-188"]);
});
it("accepts every ID that is defined, requirement or test", () => {
expect(findDanglingIds(["UR-001", "DR-001", "UT-001"], defined)).toEqual([]);
});
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",
]);
});
it("ignores IDs whose prefix is not a known trace type", () => {
// e.g. an unrelated "AB-123" caught by the loose ID regex.
expect(findDanglingIds(["AB-123"], defined)).toEqual([]);
});
});
describe("coverage threshold", () => {
it("matches MIN_THRESHOLD in the Gitea traceability workflow", () => {
// Two files must agree on the gate: the script (local `traces:coverage`)
// and the workflow. Drift means the local gate and CI disagree about what
// 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",
);
const match = workflow.match(/^\s*MIN_THRESHOLD=(\d+)\s*$/m);
expect(match).not.toBeNull();
expect(Number(match![1])).toBe(MIN_COVERAGE_PERCENT);
});
it("is a ratchet: never lower it to make a red build pass", () => {
// Sanity bound. If coverage genuinely climbs, raise both numbers together.
expect(MIN_COVERAGE_PERCENT).toBeGreaterThanOrEqual(82);
expect(MIN_COVERAGE_PERCENT).toBeLessThanOrEqual(100);
});
});
describe("computeCoverage", () => {
@@ -92,6 +233,7 @@ describe("computeCoverage", () => {
JA: 0,
total: 4,
ids: new Set(["UR-001", "UR-002", "DR-001", "DR-002"]),
testIds: new Set<string>(),
};
it("computes coverage as traced ∩ defined over defined", () => {
@@ -138,7 +280,15 @@ describe("computeCoverage", () => {
});
it("reports 0% rather than NaN when nothing is defined", () => {
const empty = { UR: 0, IR: 0, DR: 0, JA: 0, total: 0, ids: new Set<string>() };
const empty = {
UR: 0,
IR: 0,
DR: 0,
JA: 0,
total: 0,
ids: new Set<string>(),
testIds: new Set<string>(),
};
const cov = computeCoverage([], empty);
expect(cov.percent).toBe(0);
expect(Number.isNaN(cov.percent)).toBe(false);
@@ -157,26 +307,100 @@ describe("computeCoverage", () => {
});
});
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
/** 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 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 fs = require("fs");
const path = require("path");
// import.meta.dir is Bun-only; derive from import.meta.url under vitest.
const here = path.dirname(new URL(import.meta.url).pathname);
const md = fs.readFileSync(
path.resolve(here, "../docs/requirements.md"),
"utf-8"
);
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);
expect(defined.UR).toBe(61);
expect(defined.IR).toBe(29);
expect(defined.DR).toBe(95);
expect(defined.JA).toBe(32);
expect(defined.total).toBe(217);
// 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);
});
});
+223 -40
View File
@@ -23,7 +23,7 @@ interface RequirementMapping {
[reqId: string]: TraceEntry[];
}
interface TracesData {
export interface TracesData {
timestamp: string;
totalFiles: number;
totalTraces: number;
@@ -37,16 +37,34 @@ interface TracesData {
/** Requirements *defined* in requirements.md — the coverage denominators. */
defined?: { UR: number; IR: number; DR: number; JA: number; total: number };
coverage?: CoverageResult;
/** Traced IDs of any type that requirements.md does not define. */
dangling?: string[];
}
/**
* Minimum overall requirement coverage the traceability gate accepts.
*
* **Ratchet policy: this number only ever goes up.** It is set a few points
* below the coverage actually achieved, so a real regression trips it instead of
* being absorbed by slack. It sat at 50 while true coverage was 86%, which meant
* half the matrix could rot before CI noticed. When coverage rises durably,
* raise this to sit just under the new figure. Do **not** lower it to make a
* failing build pass add the missing TRACES comments instead.
*
* `.gitea/workflows/traceability-check.yml` carries the same number as
* `MIN_THRESHOLD`; `scripts/extract-traces.test.ts` fails if the two drift.
*
* TRACES: | DR-093
*/
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.
//
// `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;
@@ -57,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.
@@ -72,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);
}
}
@@ -104,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;
}
@@ -211,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
// ---------------------------------------------------------------------------
@@ -222,7 +315,10 @@ export interface DefinedRequirements {
DR: number;
JA: number;
total: number;
/** Requirement IDs (UR/IR/DR/JA) — the coverage denominator. */
ids: Set<string>;
/** Test IDs (UT/IT) from §4. A separate taxonomy: never part of coverage. */
testIds: Set<string>;
}
export interface CoverageResult {
@@ -247,15 +343,21 @@ export interface CoverageResult {
*/
export function countDefinedRequirements(markdown: string): DefinedRequirements {
const ids = new Set<string>();
const ROW_ID = /^\|\s*(UR|IR|DR|JA)-(\d{3})\s*\|/;
const testIds = new Set<string>();
const ROW_ID = /^\|\s*(UR|IR|DR|JA|UT|IT)-(\d{3})\s*\|/;
for (const line of markdown.split("\n")) {
const match = line.match(ROW_ID);
if (match) ids.add(`${match[1]}-${match[2]}`);
if (!match) continue;
const id = `${match[1]}-${match[2]}`;
// UT/IT rows live in §4 and are collected separately: they must not enter
// the coverage denominator, but they still need to exist for a `TRACES:`
// comment to be allowed to name them (see findDanglingIds).
if (match[1] === "UT" || match[1] === "IT") testIds.add(id);
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"),
@@ -264,9 +366,36 @@ export function countDefinedRequirements(markdown: string): DefinedRequirements
JA: countOf("JA"),
total: ids.size,
ids,
testIds,
};
}
/**
* Every traced ID that requirements.md defines nowhere a typo, a rename that
* missed a call site, or a reference to a deleted requirement.
*
* This is broader than `CoverageResult.orphaned`, which only ever considers the
* four requirement types because a UT/IT entry among the orphans would corrupt
* the coverage ratio's reporting. Dangling detection has no such constraint, so
* it checks all six ID types against both defined sets. Before it existed, the
* extractor accepted any well-formed ID silently: `DR-189` and `UT-188` were
* referenced from `controlsVisibility.ts` and `VideoPlayer.svelte` for months
* without being defined anywhere, and nothing reported it.
*
* TRACES: | DR-093
*/
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)),
);
return [...dangling].sort();
}
/**
* Coverage is the *intersection* of traced and defined IDs over defined IDs.
*
@@ -277,10 +406,7 @@ export function countDefinedRequirements(markdown: string): DefinedRequirements
*
* 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.
@@ -293,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,
};
}
@@ -307,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()}
@@ -365,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`;
@@ -402,12 +552,17 @@ 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\`.`,
);
}
// A ratio above 100% means the computation is broken (the condition that hid
// the stale-denominator bug for so long). Fail loudly rather than report it.
if (cov.percent > 100) {
@@ -427,12 +582,39 @@ function reportCoverage(data: TracesData, minThreshold: number): number {
return 0;
}
/**
* Hard gate on dangling IDs: a `TRACES:` comment may only name an ID that
* requirements.md actually defines. Prints every offender with the files that
* reference it, so the fix is mechanical.
*
* TRACES: | DR-093
*/
function reportDangling(data: TracesData): number {
const dangling = data.dangling ?? [];
if (dangling.length === 0) {
console.log("✅ All traced IDs are defined in docs/requirements.md");
return 0;
}
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();
console.log(` ${id}`);
for (const file of files) console.log(` ${file}`);
}
console.log("");
console.log("Fix each one by either:");
console.log(" • correcting the ID in the TRACES comment (typo/rename), or");
console.log(" • adding the requirement as a table row in docs/requirements.md.");
return 1;
}
// 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();
@@ -447,16 +629,17 @@ if (import.meta.main) {
total: defined.total,
};
data.coverage = computeCoverage(allTraced, defined);
data.dangling = findDanglingIds(allTraced, defined);
if (format === "json") {
console.log(generateJson(data));
} else if (format === "coverage") {
process.exit(reportCoverage(data, 50));
process.exit(reportCoverage(data, MIN_COVERAGE_PERCENT));
} else if (format === "validate") {
process.exit(reportDangling(data));
} else {
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"
+25 -4
View File
@@ -1,13 +1,34 @@
#!/bin/bash
# View Android logcat output filtered for the app
# View Android logcat output filtered for the app.
#
# Usage: ./scripts/logcat.sh [debug|release] (default: debug)
#
# The debug build has applicationIdSuffix ".debug" so it can be installed
# alongside a release build; pick the package to follow accordingly.
set -e
APP_PACKAGE="com.jellytau.app"
BUILD_TYPE="${1:-debug}"
if [ "$BUILD_TYPE" = "release" ]; then
APP_PACKAGE="com.dtourolle.jellytau"
else
APP_PACKAGE="com.dtourolle.jellytau.debug"
fi
echo "📱 Showing logcat for $APP_PACKAGE"
echo "Press Ctrl+C to stop"
echo ""
# Filter logcat for the app's package name
adb logcat | grep -i "$APP_PACKAGE\|tauri\|rust"
# Prefer PID-scoped output when the app is running — it drops the noise that a
# text grep can't. Fall back to the old keyword filter when it isn't (so you can
# start the script first and then launch the app).
PID="$(adb shell pidof "$APP_PACKAGE" 2>/dev/null | tr -d '\r\n' | awk '{print $1}')"
if [ -n "$PID" ]; then
echo " (attached to pid $PID)"
adb logcat --pid="$PID"
else
echo " (app not running — falling back to keyword filter)"
adb logcat | grep -i "$APP_PACKAGE\|jellytau\|tauri\|rust"
fi
+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);
});
});
+80 -4
View File
@@ -54,15 +54,88 @@ 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)
// 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. */
function idsFromFiles(files: string[]): Set<string> {
const ids = new Set<string>();
@@ -134,4 +207,7 @@ function main() {
console.log(out.join("\n"));
}
// 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."
+134
View File
@@ -0,0 +1,134 @@
#!/bin/bash
# Stamp the release version into every file that carries it.
#
# The git tag is the single source of truth for a release version. The versions
# committed in package.json / tauri.conf.json / Cargo.toml are a placeholder for
# dev builds; a tagged build overwrites all of them from the tag so they cannot
# disagree with each other or with the tag.
#
# Usage:
# ./scripts/set-version.sh 0.5.0 # explicit
# JELLYTAU_VERSION=0.5.0 ./scripts/set-version.sh
# ./scripts/set-version.sh # derive from git describe (dev builds)
#
# Accepts the version with or without a leading "v".
#
# Why a script and not four sed lines in CI: the version lived in four files and
# CI only ever rewrote one of them (tauri.conf.json), so a tagged release shipped
# a matching installer name and mismatched package metadata. Keeping the write in
# one place is what makes "the tag is authoritative" actually true.
set -euo pipefail
cd "$(dirname "$0")/.."
VERSION="${1:-${JELLYTAU_VERSION:-}}"
# CI passes "${GITHUB_REF#refs/tags/}" unconditionally, which on an untagged
# build is still a full ref ("refs/heads/master"). Treat anything that is not a
# bare version as "no version given" and fall through to git describe, so a
# branch build gets a sane dev version instead of failing the job.
case "$VERSION" in
refs/*) VERSION="" ;;
esac
if [ -z "$VERSION" ]; then
# No explicit version: derive from the most recent tag. Dev builds land on
# something like 0.5.0 (exact tag) or 0.5.0-3-gabc1234 (ahead of the tag).
VERSION="$(git describe --tags --always --match 'v*' 2>/dev/null || echo "0.0.0")"
fi
# Tags are written v0.5.0; the files carry a bare semver.
VERSION="${VERSION#v}"
# Validate before writing anything — a malformed version silently propagated
# into four files is far worse than a failed script.
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'; then
echo "❌ Not a valid semver: '$VERSION'" >&2
echo " Expected MAJOR.MINOR.PATCH with an optional -prerelease/+build suffix." >&2
exit 1
fi
echo "📌 Setting version to $VERSION"
# --- The three committed manifests -----------------------------------------
# Anchored to the first "version" key so a dependency's version is never hit.
# package.json — the top-level "version", which sits in the first few lines.
perl -0pi -e 's/("version"\s*:\s*)"[^"]*"/$1"'"$VERSION"'"/' package.json
# tauri.conf.json — likewise; this is the one the bundler reads for installer
# names, and the one CI used to patch alone.
perl -0pi -e 's/("version"\s*:\s*)"[^"]*"/$1"'"$VERSION"'"/' src-tauri/tauri.conf.json
# Cargo.toml — only the [package] version, never a dependency's. Restricted to
# the first occurrence of a line-anchored `version = "..."`.
perl -0pi -e 's/^(version\s*=\s*)"[^"]*"/$1"'"$VERSION"'"/m' src-tauri/Cargo.toml
# Cargo.lock — the jellytau entry. Left alone if the lock has not been generated
# yet; the next cargo invocation writes it. Cargo would otherwise rewrite the
# lock mid-build and dirty the tree.
if [ -f src-tauri/Cargo.lock ]; then
perl -0pi -e 's/(name = "jellytau"\nversion = )"[^"]*"/$1"'"$VERSION"'"/' src-tauri/Cargo.lock
fi
# PKGBUILD — the Arch package version. Easy to miss because Arch packaging is a
# separate path from the tauri bundler, and missing it is exactly the failure
# this script exists to prevent: pkgver sat at 0.0.18 while the rest of the tree
# had moved on, so `makepkg` produced a package whose version bore no relation
# to the source it was built from. `pkgrel` resets to 1 because a new upstream
# version starts its packaging revisions over.
if [ -f packaging/arch/PKGBUILD ]; then
# Arch pkgver may not contain a hyphen (it separates pkgver from pkgrel), so a
# dev version like 0.9.0-3-gabc1234 becomes 0.9.0.r3.gabc1234, per the VCS
# package guidelines.
ARCH_VERSION="$(echo "$VERSION" | sed 's/-\([0-9]*\)-g/.r\1.g/; s/-/_/g')"
perl -0pi -e 's/^pkgver=.*$/pkgver='"$ARCH_VERSION"'/m' packaging/arch/PKGBUILD
perl -0pi -e 's/^pkgrel=.*$/pkgrel=1/m' packaging/arch/PKGBUILD
fi
# --- Android versionCode ----------------------------------------------------
# Only when the generated Android project exists (i.e. after `tauri android
# init`); on Linux/Windows jobs there is nothing to stamp.
#
# `tauri android init` derives a versionCode from the semver (0.0.15 -> 15).
# That is both tiny and NOT monotonic across our history: earlier local/dev
# builds shipped versionCode 1000 (from a 0.1.0 config), so a plain 15 is a
# *downgrade* and Android refuses the update.
#
# The floor has to clear the highest code actually in the field, which is not the
# same as the highest this formula has produced. v0.5.2 shipped versionCode
# **5002** under an earlier `minor*1000` scheme; the `minor*100` formula that
# replaced it yields only 1502 for that same version, and 1503 for 0.5.3 — so
# every 0.5.x release built from it was an un-installable downgrade for anyone
# already on v0.5.2, which is exactly the failure this block exists to prevent.
# The multipliers are widened and the floor raised past 5002 accordingly.
#
# code = 10000 + major*1000000 + minor*1000 + patch
# e.g. 0.0.14 -> 10014, 0.1.0 -> 11000, 0.5.3 -> 15003, 1.0.0 -> 1010000.
PROPS="src-tauri/gen/android/app/tauri.properties"
if [ -f "$PROPS" ]; then
# Strip any -rc1/+build suffix first: it is not numeric, and feeding it to
# $(( )) would abort the script under `set -e`.
CORE="${VERSION%%-*}"
CORE="${CORE%%+*}"
MAJ=$(echo "$CORE" | cut -d. -f1)
MIN=$(echo "$CORE" | cut -d. -f2)
PAT=$(echo "$CORE" | cut -d. -f3)
: "${MAJ:=0}" "${MIN:=0}" "${PAT:=0}"
CODE=$(( 10000 + MAJ*1000000 + MIN*1000 + PAT ))
echo " versionCode=$CODE (from $CORE)"
if grep -q '^tauri.android.versionCode=' "$PROPS"; then
sed -i "s/^tauri.android.versionCode=.*/tauri.android.versionCode=$CODE/" "$PROPS"
else
echo "tauri.android.versionCode=$CODE" >> "$PROPS"
fi
fi
# --- Report -----------------------------------------------------------------
echo "✅ Version stamped:"
grep -m1 '"version"' package.json | sed 's/^/ package.json: /'
grep -m1 '"version"' src-tauri/tauri.conf.json | sed 's/^/ tauri.conf.json: /'
grep -m1 '^version' src-tauri/Cargo.toml | sed 's/^/ Cargo.toml: /'
[ -f "$PROPS" ] && grep '^tauri.android.versionCode=' "$PROPS" | sed 's/^/ tauri.properties: /'
exit 0
+228
View File
@@ -0,0 +1,228 @@
/**
* Guards for scripts/set-version.sh the release version stamper.
*
* TRACES: DR-153 | UT-150
*
* These run the real script against a throwaway copy of the manifests, because
* the failure modes are all in the shell, not in any TS logic: a regex that also
* matches a dependency's version, arithmetic that aborts on a `-rc1` suffix, or
* a CI ref reaching the validator verbatim.
*
* The versionCode formula matters most. Android refuses an update whose code is
* lower than the installed one, and builds already in the field shipped code
* 1000 so any formula that can emit a smaller number for a *newer* release
* bricks updates for those users, silently and irreversibly.
*/
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { execFileSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
const script = path.join(repoRoot, "scripts", "set-version.sh");
let tmp: string;
/** A minimal repo skeleton: just the files the script rewrites. */
function seed(dir: string) {
fs.mkdirSync(path.join(dir, "src-tauri", "gen", "android", "app"), { recursive: true });
fs.mkdirSync(path.join(dir, "scripts"), { recursive: true });
fs.copyFileSync(script, path.join(dir, "scripts", "set-version.sh"));
fs.chmodSync(path.join(dir, "scripts", "set-version.sh"), 0o755);
fs.writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({ name: "jellytau", version: "0.0.1", dependencies: { hls: "1.2.3" } }, null, 2),
);
fs.writeFileSync(
path.join(dir, "src-tauri", "tauri.conf.json"),
JSON.stringify({ productName: "jellytau", version: "0.0.1" }, null, 2),
);
// A dependency carrying its own `version =` is the trap: a greedy regex
// rewrites it too and the build then resolves the wrong crate.
fs.writeFileSync(
path.join(dir, "src-tauri", "Cargo.toml"),
[
"[package]",
'name = "jellytau"',
'version = "0.0.1"',
"",
"[dependencies]",
'serde = { version = "1.0.100" }',
"",
].join("\n"),
);
fs.writeFileSync(
path.join(dir, "src-tauri", "Cargo.lock"),
[
"[[package]]",
'name = "serde"',
'version = "1.0.100"',
"",
"[[package]]",
'name = "jellytau"',
'version = "0.0.1"',
"",
].join("\n"),
);
fs.writeFileSync(
path.join(dir, "src-tauri", "gen", "android", "app", "tauri.properties"),
"tauri.android.versionCode=1\n",
);
fs.mkdirSync(path.join(dir, "packaging", "arch"), { recursive: true });
fs.writeFileSync(
path.join(dir, "packaging", "arch", "PKGBUILD"),
["pkgname=jellytau", "pkgver=0.0.1", "pkgrel=3", 'pkgdesc="x"', ""].join("\n"),
);
}
function run(version: string, dir = tmp) {
return execFileSync("bash", [path.join(dir, "scripts", "set-version.sh"), version], {
cwd: dir,
encoding: "utf-8",
});
}
function read(rel: string): string {
return fs.readFileSync(path.join(tmp, rel), "utf-8");
}
function versionCode(): number {
const m = read("src-tauri/gen/android/app/tauri.properties").match(
/^tauri\.android\.versionCode=(\d+)$/m,
);
return m ? Number(m[1]) : NaN;
}
beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "setversion-"));
seed(tmp);
});
afterEach(() => {
fs.rmSync(tmp, { recursive: true, force: true });
});
describe("set-version.sh", () => {
it("stamps the version into all four manifests", () => {
run("0.5.0");
expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.5.0");
expect(read("src-tauri/Cargo.toml")).toContain('version = "0.5.0"');
expect(read("src-tauri/Cargo.lock")).toMatch(/name = "jellytau"\nversion = "0\.5\.0"/);
});
it("accepts a leading v, as git tags are written", () => {
run("v0.5.0");
expect(JSON.parse(read("package.json")).version).toBe("0.5.0");
});
// The regression that motivates anchoring the patterns.
it("does not rewrite dependency versions", () => {
run("0.5.0");
expect(read("src-tauri/Cargo.toml")).toContain('serde = { version = "1.0.100" }');
expect(read("src-tauri/Cargo.lock")).toMatch(/name = "serde"\nversion = "1\.0\.100"/);
expect(JSON.parse(read("package.json")).dependencies.hls).toBe("1.2.3");
});
describe("Android versionCode", () => {
// A newer release must never produce a smaller number than an older one, or
// Android refuses the update. The floor tracks the highest code actually in
// the field, which is NOT the same as the highest this formula has produced:
// v0.5.2 shipped versionCode 5002 from an earlier `minor*1000` scheme, while
// the `minor*100` formula that replaced it yields only 1502 for that same
// version — so every 0.5.x release built from it was an un-installable
// downgrade for anyone already on v0.5.2. The floor is raised to clear it.
it("clears the highest code shipped by earlier builds", () => {
run("0.0.1");
// v0.5.2 shipped 5002; anything at or below that cannot install over it.
expect(versionCode()).toBeGreaterThan(5002);
});
it("keeps 0.5.3 installable over the 5002 that shipped as v0.5.2", () => {
run("0.5.3");
expect(versionCode()).toBeGreaterThan(5002);
});
it("uses 10000 + major*1000000 + minor*1000 + patch", () => {
const cases: Array<[string, number]> = [
["0.0.14", 10014],
["0.0.15", 10015],
["0.1.0", 11000],
["0.4.8", 14008],
["0.5.0", 15000],
["0.5.3", 15003],
["1.0.0", 1010000],
];
for (const [version, code] of cases) {
seed(tmp);
run(version);
expect(versionCode(), `versionCode for ${version}`).toBe(code);
}
});
it("increases monotonically across an upgrade sequence", () => {
const ordered = ["0.0.14", "0.0.15", "0.1.0", "0.4.8", "0.5.0", "1.0.0"];
const codes = ordered.map((v) => {
seed(tmp);
run(v);
return versionCode();
});
const sorted = [...codes].sort((a, b) => a - b);
expect(codes).toEqual(sorted);
expect(new Set(codes).size).toBe(codes.length);
});
// `$(( 0-rc1 ))` aborts the script under `set -e`, so the suffix has to be
// stripped before the arithmetic.
it("derives the code from the numeric core of a prerelease", () => {
run("0.6.0-rc1");
expect(versionCode()).toBe(16000);
expect(JSON.parse(read("package.json")).version).toBe("0.6.0-rc1");
});
});
describe("input validation", () => {
it("rejects a malformed version without writing anything", () => {
expect(() => run("not-a-version")).toThrow();
// The manifests must be untouched, not half-written.
expect(JSON.parse(read("package.json")).version).toBe("0.0.1");
expect(JSON.parse(read("src-tauri/tauri.conf.json")).version).toBe("0.0.1");
});
// CI passes "${GITHUB_REF#refs/tags/}" unconditionally; on a branch build
// that is still a full ref, and must not fail the job.
it("falls back to a dev version when handed a non-tag ref", () => {
const out = run("refs/heads/master");
expect(out).not.toMatch(/refs\/heads/);
expect(JSON.parse(read("package.json")).version).not.toBe("0.0.1");
});
});
// The Arch package is built by makepkg, not the tauri bundler, so its version
// lives in a file the rest of the release path never touches. It sat at
// 0.0.18 while the tree was on 0.8.x — makepkg happily produced a package
// whose version bore no relation to the source it was built from, which is
// the exact failure this script was written to prevent.
describe("PKGBUILD", () => {
it("stamps pkgver and resets pkgrel", () => {
run("0.9.0");
const pkgbuild = read("packaging/arch/PKGBUILD");
expect(pkgbuild).toMatch(/^pkgver=0\.9\.0$/m);
// A new upstream version starts its packaging revisions over.
expect(pkgbuild).toMatch(/^pkgrel=1$/m);
});
it("converts a dev version into a pkgver Arch accepts", () => {
// pkgver may not contain a hyphen — it is the pkgver/pkgrel separator.
run("0.9.0-3-gabc1234");
const pkgbuild = read("packaging/arch/PKGBUILD");
const match = pkgbuild.match(/^pkgver=(.*)$/m);
expect(match).not.toBeNull();
expect(match![1]).not.toContain("-");
expect(match![1]).toBe("0.9.0.r3.gabc1234");
});
});
});
+45
View File
@@ -23,6 +23,19 @@ rm -rf "$TARGET_DIR/player" "$TARGET_DIR/security"
cp -r "$SOURCE_DIR/player" "$TARGET_DIR/"
cp -r "$SOURCE_DIR/security" "$TARGET_DIR/"
# JVM unit tests (src/test). Plain JUnit over the pure decision helpers — no
# Android framework classes — run with `./gradlew :app:testDebugUnitTest` from
# gen/android. Mirrored here so the canonical tree stays the only place tests
# are edited.
TEST_SOURCE_DIR="$PROJECT_ROOT/src-tauri/android/src/test/java/com/dtourolle/jellytau"
TEST_TARGET_DIR="$PROJECT_ROOT/src-tauri/gen/android/app/src/test/java/com/dtourolle/jellytau"
if [ -d "$TEST_SOURCE_DIR" ]; then
rm -rf "$TEST_TARGET_DIR"
mkdir -p "$TEST_TARGET_DIR"
cp -r "$TEST_SOURCE_DIR"/. "$TEST_TARGET_DIR/"
echo " Copied unit tests: src/test"
fi
# Copy individual Kotlin files (like VideoOverlayManager.kt)
for kt_file in "$SOURCE_DIR"/*.kt; do
if [ -f "$kt_file" ]; then
@@ -87,6 +100,16 @@ if [ -d "$RES_SRC" ]; then
cp "$RES_SRC"/values/*.xml "$RES_DST/values/"
echo " Copied res: values"
fi
# xml/ (network_security_config.xml): referenced from the manifest, so a
# missing copy fails the resource link rather than degrading quietly.
# Merged into Tauri's generated xml/ (which holds file_paths.xml) rather
# than replacing it.
if [ -d "$RES_SRC/xml" ]; then
mkdir -p "$RES_DST/xml"
cp "$RES_SRC"/xml/*.xml "$RES_DST/xml/"
echo " Copied res: xml"
fi
# We ship only the color adaptive icon (background + foreground). Drop any
# monochrome layer Tauri may generate: the themed-icon monochrome doesn't
# render well, and our adaptive-icon xml no longer references it, so a stray
@@ -108,4 +131,26 @@ if [ -d "$RES_SRC" ]; then
"$RES_DST"/drawable*/ic_launcher_background.xml
fi
# Gradle wrapper distribution. `tauri android init` regenerates the wrapper
# pointing at services.gradle.org, so each build downloads ~130MB of Gradle —
# slow, and a hard failure when the CDN drops the connection mid-transfer
# ("Unexpected end of file from server"), which is what broke the release APK
# job. The builder image ships the matching distribution under /opt/gradle/dist,
# so when it's present repoint the wrapper at that local zip and build offline.
# Outside the image (dev machines) the properties file is left untouched and the
# wrapper downloads as usual.
WRAPPER_PROPS="$PROJECT_ROOT/src-tauri/gen/android/gradle/wrapper/gradle-wrapper.properties"
if [ -f "$WRAPPER_PROPS" ]; then
WANTED_VERSION="$(sed -n 's#.*/gradle-\([0-9.]*\)-\(bin\|all\)\.zip.*#\1#p' "$WRAPPER_PROPS")"
LOCAL_DIST="/opt/gradle/dist/gradle-${WANTED_VERSION}-bin.zip"
if [ -n "$WANTED_VERSION" ] && [ -f "$LOCAL_DIST" ]; then
# distributionUrl is a java.util.Properties value: ':' must stay escaped.
sed -i "s#^distributionUrl=.*#distributionUrl=file\\\\:///opt/gradle/dist/gradle-${WANTED_VERSION}-bin.zip#" \
"$WRAPPER_PROPS"
echo " Gradle wrapper -> local distribution ($WANTED_VERSION, offline)"
elif [ -n "$WANTED_VERSION" ]; then
echo " Gradle wrapper: $WANTED_VERSION not in image, will download"
fi
fi
echo "✓ Android sources synced successfully"

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