Commit Graph
100 Commits
Author SHA1 Message Date
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