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
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.
.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.
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
`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.
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
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.
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.
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.
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.
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.)
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.
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.
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
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.
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
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.
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.
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.
- 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.
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.
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.
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.
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.
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.
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
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.
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
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.
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.
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.
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%.
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.
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).
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.
`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.
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.
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.
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.
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).
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.
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
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.
`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.
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
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.
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.
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