Commit Graph
173 Commits
Author SHA1 Message Date
dtourolleandClaude Opus 5 9e2278080d feat(storage): remember which server generation wrote the cached catalog
The cache was version-blind: nothing recorded which Jellyfin generation produced
a row, so a server upgraded underneath the app kept serving rows parsed under the
previous generation's assumptions.

Migration 026 adds servers.catalog_generation and deliberately does NOT clear
synced_at the way migration 025 did. The column starts NULL, which reads as "no
generation recorded yet" rather than "changed", so the first connection after
upgrading simply records what it finds. Invalidation happens only when the
recorded generation actually changes.

That distinction is the point. Treating absent information as a change would
charge every existing user a full catalog re-fetch to defend against a server
upgrade that has not happened — and at the time of writing, 12.0 is hours old, so
essentially no installed server is on the newer generation at all.

Capabilities are also wired at repository creation: the version storage already
holds is read once, resolved, and handed to the online repository. A missing or
unparseable version is not an error — it resolves to the older generation, whose
request shapes work on both.

TRACES: UR-085 | IR-035, DR-280, DR-284

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 20:09:53 +02:00
dtourolleandClaude Opus 5 33e1403981 test(repository): run the online repository against a real HTTP server
src-tauri/ contained no HTTP mocking of any kind. Every test of the ~4,800-line
online adapter asserted on a constructed URL string; not one exercised a
response. So "works against both server generations" was not merely untested, it
was unfalsifiable.

Adds wiremock (a project dev-dependency, so no CI image change — the toolchain
rule is about system packages) and a FakeJellyfin fixture that reports a chosen
version. The repository it hands back resolves its capabilities from exactly that
string via the production path, so a test running against both generations is
running the real resolution rather than a stubbed one.

Eight cross-generation tests, each asserting on what the client actually put on
the wire or did with a response it actually received:

  - every request carries Authorization: MediaBrowser and no X-Emby-Authorization
  - a listing parses into domain items on both generations
  - a type-filtered listing puts Recursive on the wire
  - libraries resolve through the user-scoped route on both
  - flipping user_scoped_item_routes really changes the request and still parses,
    so the alternative shape is exercised rather than being untested code waiting
    to be switched on
  - favourites send Filters=IsFavorite and omit the type filter under All scope
  - player-facing URLs carry ApiKey= and never api_key=
  - capabilities come from the version the server reported

The auth test was verified to fail when the legacy header is reintroduced into
get_json_inner, so it is a guard rather than decoration.

HttpClient keeps https_only(true) in production; a #[cfg(test)] constructor
allows the plaintext loopback wiremock serves. Weakening the real one to make
testing possible would trade the thing that stops a downgrade putting a session
token in clear for the thing meant to protect it.

The rule this module states and follows: assert against a response from a mock
server, never against a mock that re-derives the thing under test. That is the
mistake the deleted online_integration_test.rs made, and it shipped a broken
download endpoint while staying green.

TRACES: UR-085 | DR-281 | IT-019, IT-020, IT-021, IT-022, IT-023, IT-024, IT-025, IT-026

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 20:09:53 +02:00
dtourolleandClaude Opus 5 9bb5b44d0f fix(auth): use the authentication spellings Jellyfin 12.0 leaves enabled
X-Emby-Authorization at the remaining request builders, and api_key= in the
player-facing URLs, become Authorization and ApiKey.

Jellyfin 12.0 disables X-Emby-Authorization, X-Emby-Token, X-MediaBrowser-Token,
the Emby scheme and the api_key query parameter by default — and a migration
(DisableLegacyAuthorization) turns them off on servers upgraded from 10.11 as
well, so this is not confined to fresh installs. A client using them stops
working against an upgraded server rather than degrading.

Verified at source level rather than inferred: AuthorizationContext.cs is
byte-identical between v10.11.5 and v12.0 apart from whitespace. The only change
is the default of the gate that guards the legacy spellings. Authorization with
the MediaBrowser scheme, and ApiKey as a query parameter, are ungated in both
trees — and the server itself emits ApiKey in both (StreamInfo.cs). So one
spelling is correct everywhere and no capability flag is involved.

Also adds ServerCompatibility to ServerInfo: an opaque verdict the frontend
renders without ever comparing a version number, with three states rather than a
boolean. A server newer than this build is usable, not refused; an unreadable
version string is not grounds for refusal either. Only a server below the floor
is refused.

TRACES: UR-085 | DR-286, DR-287

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 20:09:34 +02:00
dtourolleandClaude Opus 5 8027fd5fac feat(repository): a route table and resolved server capabilities
Endpoints were 57 inline format! literals with their query strings baked in at
the point of use. That is workable against exactly one server and hostile to
anything else: a second route shape would mean a conditional at every one of
them. They now live in repository/endpoints.rs, one function each, taking
&ServerCapabilities.

Two things fall out of the move:

  - A small Endpoint builder replaces the manual ?/& juggling, so a double or
    trailing separator is structurally impossible rather than something four
    assertions in a deleted test file used to watch for.
  - Both user-scoped route shapes (/Users/{uid}/Items and /Items?userId=) are
    built and tested, though nothing selects the second yet. The family still
    works on 12.0, so migrating is optional; having both means it is a one-line
    change if 13.0 removes them, as the newly written removal policy allows.

ServerCapabilities is resolved once per connection from the version the server
already reported at connect. The version-to-flags mapping lives in exactly one
function and nothing else in the crate compares a version number: a `version < N`
at the point of use re-derives a domain fact where it is consumed, is unreadable
by its second occurrence, and cannot express a backport.

An unrecognised version resolves forward to the newest known generation rather
than being refused, because refusing would make every release expire the moment
the server upgrades. Only a version below the floor is refused.

This commit also carries the two fixes that are NOT capability branches, because
they live in the same files:

  - Authorization replaces X-Emby-Authorization, and ApiKey replaces the api_key
    query parameter. Jellyfin 12.0 disables both legacy spellings by default and
    a migration flips them on upgraded servers too, so this is what actually
    breaks against 12.0. The header value this app already built was always the
    correct MediaBrowser scheme, and both new spellings are ungated on 10.11.x —
    so it is a rename, not a branch. The query-parameter spelling is load-bearing
    rather than cosmetic: stream URLs go to mpv, ExoPlayer and the webview's
    <video>, none of which can send a header.
  - A type-filtered listing now states Recursive explicitly. 12.0 defaults it to
    true for a library parent with IncludeItemTypes where 10.11 returned
    immediate children, so the identical request returned a different result set
    with nothing in the response to say which rule applied. The value sent is the
    one that shipped, so this is a compatibility fix and not a silent behaviour
    change.

A structural test refuses any deprecated auth spelling reaching a request
builder, verified to fail when one is reintroduced. Behaviour is otherwise
preserved: the four previous endpoint builders become test-only shims over the
new table, so the ~20 existing tests encoding DR-116/DR-212/DR-257 now exercise
the production path rather than being deleted.

TRACES: UR-085 | IR-035, DR-279, DR-280, DR-287, DR-288

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 20:09:34 +02:00
dtourolleandClaude Opus 5 f0c33a52c2 chore(repository): delete online_integration_test.rs, which never compiled
The file was not declared in repository/mod.rs and imported crate::api::jellyfin,
a module that does not exist. It had never been built, let alone run.

Dead would be reason enough, but it was worse than dead. Its mock reimplemented
the URL builders and then asserted against itself, and online.rs still carries
the comment recording where that leads: the mock used the correct stream.mp4
endpoint while the real implementation shipped /Videos/{id}/download, which 404s
on real servers and silently broke every movie and TV download. The "test" stayed
green throughout. Its own test_image_url_basic asserted api_key= appears in image
URLs while the mock two lines above it documented the opposite.

Deleted rather than revived: it asserts on constructed URL strings, which is the
pattern the mock-server harness replaces. The lesson it left is preserved in the
online.rs comment that referenced it — assert against a response from a mock
server, never against a mock that re-derives the thing under test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 20:09:11 +02:00
dtourolle 65d3d912f7 fix(player): declare and enforce a lock hierarchy for PlayerController
The controller carries seventeen mutexes, reached from the MPV event loop,
JNI callbacks, sleep and autoplay timers, the session poller and every IPC
command. Nothing prevented two threads taking the same pair in opposite
orders, which deadlocks playback outright — and this subsystem has already
produced one deadlock.

No inversion exists today: the acquisitions really are scoped, and
`previous()` explicitly drops the backend guard before touching the queue.
That is the point. It holds by convention, convention is not checked, and
the failure it guards against is a frozen app with no error anywhere.

`LOCK_ORDER` writes the convention down, following the nesting the code
already relies on — `backend` before `queue` ("what is playing" before
"what is next"), `event_emitter` last because notifying the frontend must
never reach back for player state.

The tripwire only reports acquisitions that actually *overlap*, since two
locks taken one after another, each released before the next, cannot
deadlock. Verified by injecting a real inversion into `seek()`, which the
test located by line and rank.
2026-09-07 22:24:45 +02:00
dtourolle 192a8b3c67 fix(credentials): persist the fallback key instead of deriving an unstable one
The encrypted-file fallback derived its AES key from the hostname, a
hardcoded salt and `$USER`. Two problems, and the second is the one users
actually hit.

It was never secret. Every input is readable by anyone who can read the
ciphertext beside it, so the derivation bought nothing against the threat
its name implies. Calling the result "AES-256-GCM encrypted" oversold it.

And it was unstable. Renaming the machine, or launching from a context
where `$USER` is unset — a systemd user service, some desktop launchers —
changed the key and made every stored token undecryptable.
`load_credentials_file` reports a failed decrypt as "no stored
credentials", so this surfaced as being silently signed out with nothing
to explain it.

The key is now 32 random bytes persisted beside the credentials file, mode
0600, generated on first use. That is strictly better on both counts:
higher entropy, and it does not move when the machine does. It is still
obfuscation at rest rather than a secret — the key sits next to what it
opens — and the module docs now say so plainly instead of implying
otherwise. The keyring remains the only place a token is really protected.

The old derivation is kept solely to read a file written by an earlier
build; anything it opens is immediately rewritten under the persisted key,
so no one is signed out by the upgrade.

Verified against aarch64-linux-android as well as the host.
2026-09-07 22:24:45 +02:00
dtourolle 747ec0161c fix(download): stop an empty download completing and then hanging the player
Two halves of one failure, either of which is enough to produce an
offline item that never starts.

The worker marked a transfer `completed` without checking it produced
any bytes, so a server that answered 200 with no body — an error page, a
transcode that yielded nothing — renamed a zero-byte `.part` into place
and published it as available offline. That is worse than failing: the
retry budget never applies and the UI shows the item as ready.

The media server then answered a request for that file with a span of
`{ start: 0, end: 0 }`. `end` is inclusive, so `Span::len()` reported
**one** byte: the response declared `Content-Length: 1` and streamed
nothing, which Chromium's media loader waits on forever. The user sees a
downloaded item that just never plays, with nothing explaining why.

A zero-length file has no satisfiable range, so `span_for` now returns
`None` and the server answers 416. An empty transfer is rejected as a
network error, which keeps the `.part` for a resume and lets the existing
retry budget do its job.
2026-09-07 22:24:45 +02:00
dtourolle bc92eb4dea fix(repository): stop a slow cache read surfacing as a network error offline
The cache leg of a cache-first query had a hard 100 ms deadline that
cancelled the read and reported it as a miss. That conflates "the cache
has nothing" with "the cache was slow", and the two want opposite
answers: offline the server leg fails too, so browsing surfaced a network
error over cached content that was sitting on disk.

It is not a rare race. The database is a single SQLite connection behind
a single mutex, so a concurrent write — a sync drain, a bulk
save_to_cache, a thumbnail write — blocks every read for its duration,
and 100 ms is easily exceeded on phone storage. It also compounded:
`spawn_blocking` work is not cancellable, so an abandoned query still ran
and still held the mutex, making the next one slower.

The deadline now bounds only the *fast path*. A cache query that misses it
keeps running on its own task, and when the server leg fails the race
waits that query out instead of discarding it. A cache that answers in
time still short-circuits the server exactly as before, and when both
sides genuinely fail the server's error is still what the caller sees.

`parallel_race`/`race_with_refresh` no longer need `&self`, so they are
associated functions and directly testable without constructing a
repository.

Not addressed here: one connection behind one mutex makes `PRAGMA
journal_mode = WAL` inert, since reads and writes fully serialise
regardless. A read pool is an architecture change and wants a spec.
2026-09-07 22:24:45 +02:00
dtourolle c72ca86865 fix(storage): stop a panic poisoning the connection mutex for the whole session
`RusqliteService` is the path every async database operation in the app
takes, and all seven of its lock sites used a raw `.lock()`. A single
panic while that guard is held poisons the mutex, after which every
database call for the rest of the process returns "poisoned lock" — for
a database-backed app, the entire UI stops working until restart.

`utils::lock` exists to stop exactly this cascade, and `storage::Database`
already used `lock_safe()`. The busiest lock in the app was the one that
did not.

The test poisons the connection the way a panicking row mapper would and
asserts queries still serve.

The same raw-lock pattern remains at ~121 command-layer sites on the
`DatabaseWrapper`/`CredentialsWrapper` mutexes. Those degrade to a failed
command rather than a panic, and converting them is a mechanical sweep
better reviewed on its own.
2026-09-07 22:24:45 +02:00
dtourolle f9e1a8e69a fix(android): contain panics at the JNI boundary instead of aborting the process
Ten `extern "system"` callbacks are entered by the JVM on arbitrary
threads. A panic unwinding out of one crosses the FFI boundary, which
Rust answers by aborting: the app vanishes with no Java exception, no
attributable stack trace, and no crash report the user can send. For
callbacks that fire four times a second during playback that is the worst
available failure mode.

It was reachable. `nativeOnPositionUpdate` built a fallback Tokio runtime
with `Runtime::new().unwrap()` on threads that have none, and
`Runtime::new()` fails under exactly the fd exhaustion and thread-spawn
refusal Android subjects a media app to. That now logs and drops the
report — losing one progress report is recoverable, losing the app is not.

Every callback body is wrapped in `jni_guard`, which catches the unwind
and logs it. It is a backstop, not a licence to panic: a contained panic
still leaves whatever it interrupted half-done.

The guard lives in `player::jni_guard` rather than `player::android`
because that module is `cfg(target_os = "android")` and so never compiles
on the host — which is why its 1575 lines had no tests at all. A tripwire
test asserts every entry point wraps its body, so an eleventh callback
cannot reintroduce the defect; it reads the source, since exercising the
real boundary needs a JVM.

Verified with `cargo check --target aarch64-linux-android`.
2026-09-07 22:24:45 +02:00
dtourolle d4f80a4afa fix(storage): make each migration atomic so a partial failure can't brick the app
Migrations ran as bare `execute_batch` calls with the `_migrations` row
written afterwards. SQLite autocommits every statement, so a migration
that died partway — low disk, an OOM kill, the process dying mid-boot —
left its earlier statements applied and recorded nothing.

That is unrecoverable rather than merely untidy. `execute_batch` aborts
on the first error, so the retry on the next launch failed at statement 1
with "duplicate column name" and kept failing forever, and
`Database::open` turns a migration error into a `panic!` — the app never
started again and the only fix was clearing app data, losing downloads
and logins. Several migrations have exactly the shape that triggers it:
006 is three `ADD COLUMN`s, 003/005/024 are full table rebuilds.

Each migration now runs in one transaction with its `_migrations` row
committed inside it, so a migration is all-or-nothing and a retry is
always safe. Every migration is pure DDL/DML, which SQLite runs
transactionally; a `PRAGMA` or `VACUUM` added to one would not roll back.

`migrate()` delegates to a new `migrate_with()` so a test can inject a
deliberately-failing migration.
2026-09-07 22:24:45 +02:00
dtourolle 7b738002a0 fix(ci): move MIGRATION_025 above the test module
clippy's items_after_test_module fired on schema.rs: the new migration
const was appended to the end of the file, which is after the
#[cfg(test)] block added alongside migration 024.

    error: items after a test module
      --> src/storage/schema.rs:901:1

Only visible under --all-targets, which compiles the test target; the
--lib run I checked locally cannot see it. CI runs --all-targets, so it
failed there and nowhere else. Verified this time with the exact CI
invocation rather than a narrower one.
2026-09-07 22:24:45 +02:00
dtourolle c41b8ec896 fix(library): record which library a cached item came from
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 13m40s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 43s
📱 Test APK / Build test APK (push) Successful in 49m21s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 8m39s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
"TV" and "Shows" showed identical contents, and so would any two
libraries of the same type.

save_to_cache bound library_id NULL on every row it wrote, so nothing in
the cache knew where an item came from. The only association available
was the collection_type/item_type taxonomy, and that is unable in
principle to tell two libraries of one type apart -- both are 'tvshows',
so every Series on the server satisfies either. DR-277 narrowed the
library clause, which stopped Books and Photos serving the whole server,
but no clause over that taxonomy could have fixed this.

The write path is the single choke point every cached row passes through
and it already knows the parent being browsed, so it now resolves the
owning library once per call: the parent itself when it is a library,
otherwise the library its parent item was filed under, which carries the
association down a hierarchy as it is browsed. Synthetic parents like
"favorites" match neither and stay NULL -- they are not a library and
span several.

This is what makes the taxonomy stop being load-bearing. Library types
nobody enumerated -- Books, Photos, Collections, mixed libraries with no
collection type at all -- are now scoped by the same link as everything
else rather than by whether someone remembered to add an arm for them.

Existing rows cannot be repaired locally, because the association was
never stored: migration 025 clears synced_at to force a re-fetch, the
same move MIGRATION_018 made for is_folder. Nothing is deleted --
downloads, favourites and playback positions live in other tables, and a
cleared synced_at only means "ask the server again".

The new tests seed through save_to_cache rather than inserting rows
directly, so they exercise the path that was actually broken.
2026-09-07 19:41:40 +02:00
dtourolle dea78b89b9 test(library): pin collections, both as a library and as an item
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 24m24s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 1m3s
📱 Test APK / Build test APK (push) Successful in 48m0s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 7m55s
Traceability Validation / Check Requirement Traces (push) Successful in 23s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 6m26s
"Is Collections broken too?" deserved an answer from the suite rather
than from reading the query.

Both, and they behave differently. A Collections *library* was hit by the
same defect as Books and Photos -- unmapped collection_type, no
include_item_types, so the library clause matched every cached row -- and
is fixed by the same change. The unknown-type test now covers boxsets,
photos, homevideos and the empty collection_type Jellyfin sends for a
mixed library, instead of standing on books alone.

An individual collection is a different path and keeps working: a
BoxSet's members carry parent_id, which the cache does store, so they
match the ordinary parent link rather than the library clause. That is
worth its own test because narrowing the clause could plausibly have
taken collections with it, and "Collections is empty" would look
identical to the bug being fixed.
2026-09-07 00:27:51 +02:00
dtourolle 368935e6f4 fix(library): scope a library listing to that library
Opening a library that is not Music, Movies or TV served whatever
happened to be cached — films under Books, albums under Photos — rather
than the library's own contents.

The cached-browse query matched a library parent with an EXISTS that
never referenced the item:

    OR EXISTS (SELECT 1 FROM libraries l
               WHERE l.id = ? AND l.server_id = i.server_id)

It asks only whether a library with the requested id exists, so it is
true for every cached row the moment the parent is any library. The three
typed libraries concealed it because their landing pages pass
include_item_types, which narrowed the result to albums or films or
series; the generic library page passes none, so nothing narrowed it at
all.

`library_id` now decides wherever the cache kept one. That is the
server's own answer, and the only thing that can scope a library whose
type has no mapping (Books, Photos, Collections) or none at all — a
mixed library, where Jellyfin sends CollectionType null. The
collection_type/item_type taxonomy stays as the fallback for rows written
before the link was stored, and a library with neither matches nothing
and falls through to the server, which does know what is in it.

The taxonomy is now one macro shared with the downloaded listing. That
listing had the identical defect and it was fixed there alone (DR-167) —
the comment there even says the mapping "is needed in two places that
must agree", which was true of a third place nobody looked at.

One existing assertion changed rather than being worked around:
UT-206 expected a lib-2 album back from a lib-1 listing, which only held
because of this bug. It is about parameter binding order, so it keeps
testing exactly that, now with an album that is really in lib-1.
2026-09-07 00:11:20 +02:00
dtourolle da762da55d feat(profiles): multi-user profiles with PIN switching
A shared device can hold several accounts from the same server and switch
between them in a couple of taps. A profile can be locked behind a 4-8
digit PIN; one without a PIN is one tap away. Forgetting a PIN falls
through to the account's own Jellyfin password, so there is no reset flow
and no recovery secret to store.

Opt-in by construction: a single account with no PIN starts, plays and
downloads exactly as before, and never sees a picker.

Two decisions worth keeping:

- Switching is not logging out. auth_logout invalidates the token
  server-side, which is precisely what a switch must not do, or every
  switch back would cost a password. The switch runs as a plan
  (profiles/switch.rs) so the teardown *ordering* is unit-testable with
  no player and no server -- a straggler reporting after the active user
  flips would attribute one account's viewing to another, silently.

- The PIN gates switching, not the token at rest. Wrapping each token
  with its PIN would leave a locked profile unable to resume its own
  downloads or drain its own sync queue until somebody typed the code,
  which on a device that reboots nightly costs more than it defends
  against a four-digit secret. auth_initialize does refuse to restore a
  PIN-protected session, so the gate is on the session rather than on
  which screen is shown.

"Child account" is not modelled anywhere -- a child's profile is simply
one with no PIN. The frontend renders an opaque unlockMethod and never
compares a PIN, counts an attempt or infers a role.

Migration 024 adds user_pins, user_item_visibility, user_libraries and
download_grants, and backfills the existing user so an upgrade does not
blank its library. The visibility and grant tables are the schema half of
the cache-scoping and shared-download work; the read-path enforcement is
still to come (see docs/specs/multi-user-profiles.md).
2026-08-30 19:03:59 +02:00
dtourolle d25f6be697 fix(catalog): group Recently Added tracks into albums in the client
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 15m37s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 34s
Traceability Validation / Check Requirement Traces (push) Successful in 12s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m19s
Recently Added still listed a newly-imported album one song at a time.
GroupItems=true asks Jellyfin to collapse leaves into their container, but the
server only groups a track whose parent chain actually resolves a MusicAlbum,
and older servers ignore the parameter outright — so the raw leaves kept
arriving. The home row passes no library, so the offline branch (which collapses
in SQL) contributes nothing there and the server's answer *is* the row.

Group again in the online repository, so the shape of the row is a property of
this app rather than of the server it happens to be talking to:

- A track naming an album_id collapses into one MusicAlbum card, placed where
  the first of its tracks stood so recency order survives. The card keeps the
  artwork, album name and artists; track number, duration, album link, streams
  and per-track user data stay with the leaf.
- If the server did return the album row, that row wins and its tracks are
  dropped — it carries detail a track-built stand-in cannot.
- Tracks with no album, movies, episodes and folders pass through untouched.
- Collapsing only shrinks a listing, so the request over-fetches 3x and
  truncates afterwards; otherwise one 14-track import left the row nearly empty.

Episodes are still grouped only by the server, so a freshly added season can
flood the row the same way — same fix applies if it shows up.
2026-08-26 19:37:26 +02:00
dtourolle 079153d9d5 fix(library): stop offering the episode you just finished as "up next"
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 16m0s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 35s
Traceability Validation / Check Requirement Traces (push) Successful in 26s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m27s
Finish an episode, leave the player with Back, and the season view still put
the yellow ring and the "Up next" badge on the episode that had just ended --
and scrolled to it.

Nothing records completion locally. storage_update_playback_progress writes a
position and never touches is_played, and mirror_user_data carried the
server's favourite flag and position but not its played flag, so that column
was written by nothing except an explicit local toggle. By the second visit to
a series page get_items is a cache hit, so every episode reads back unwatched;
meanwhile Jellyfin's Next Up is still one stop-report behind and names the
episode that just ended. pick_current_episode had no reason to disagree with
either of them.

is_finished -- the played flag, or a position at or past MAX_PROGRESS_FRACTION
of the runtime, the same 95% threshold that already disqualifies an episode
from counting as in-progress -- replaces the bare is_played in the
furthest-watched scan and the first-unwatched fallback, and screens the Next Up
candidate before it is accepted: the server is briefly behind, the local
position is not. The current episode becomes the next one, and the highlight,
the badge, the auto-scroll and which season starts expanded all follow it.

mirror_user_data now carries is_played alongside the rest, under the same
pending_sync = 0 conflict rule, so watched state survives a cache write instead
of being dropped -- which is also what puts the checkmarks back in the season
list.

TRACES: UR-025, UR-062 | DR-264 | UT-239, UT-240
2026-08-26 18:56:16 +02:00
dtourolle bbccc8567c fix(player): play on past the end of a season
Autoplay listed the episodes of the current season and stopped dead at the
last one, so the end of a season returned AutoplayDecision::Stop. On the
Android background-audio handoff that is felt as playback simply pausing
mid-binge with the screen locked and no UI to un-pause it -- the device log
reads "Current episode is the last in the season" and then "Decision: Stop
playback", one episode after a mid-season boundary the backend advanced
through by itself.

The lookup now walks the series' seasons and takes the first episode of the
next one that has any. Seasons are sorted client-side by index number
because the offline repository ignores sort_by, empty seasons are skipped
rather than read as the end of the series, and Specials are never rolled
into: Jellyfin numbers them 0 so they sort ahead of season 1, but a server
that leaves the index unset sorts them last, exactly where the walk would
otherwise land.

Every autoplay entry point shares this lookup, so foreground video and the
Android native path cross the boundary too. It stays below the sleep-timer
gate in on_playback_ended, so a timer set to end-of-episode or a remaining
episode count still stops at the boundary instead of being carried past it.

TRACES: UR-023, UR-040 | DR-263 | UT-238
2026-08-25 23:48:15 +02:00
dtourolle 64de22bd51 fix(player): change the audio track, and load subtitles at all
Two faults, both present since v0.0.1, both found and confirmed on a device.

Audio track (DR-258). Jellyfin builds a transcode around one AudioStreamIndex,
so the alternate tracks are not in the stream that arrives — but the native
path only ever called setAudioTrack(n), which indexes ExoPlayer's audio track
*groups*. On Android that is the common case, since any source whose default
audio codec the device cannot decode is transcoded: logcat showed ExoPlayer
holding `Audio tracks: 1` while the menu listed every track in the file, so
each selection warned `Invalid audio track index` and was dropped, leaving the
default track playing with nothing in the UI saying so.

determine_audio_track_switch_strategy now decides by whether the stream in
front of the engine carries the track at all — a direct play still selects in
place, a transcode is re-negotiated at the chosen index and resumed. Where it
resumes is the player's answer rather than the UI's: the native path has no
<video> element to read, so it sends no position, and defaulting that to zero
re-opened the film at the beginning (caught on device before it shipped).

Subtitles (DR-259). The URL was missing its `Stream.` route segment, so every
sideloaded subtitle 404ed; since media3 1.5 a sideloaded text track only
becomes a track group once its file is parsed, so 42 failed fetches left
ExoPlayer with no text tracks and selection warned `available: 0`. Verified
against a live server: the built URL answers 404, the corrected one 200. The
tests that should have caught this asserted the shape of a mock helper that
restated the format string instead of the URL the app requests — so the new
test drives the repository itself, and failed red on the old URL.
2026-08-23 19:15:05 +02:00
dtourolle 231ffae626 fix(library): podcasts list newest episode first
A Jellypod podcast listed its episodes alphabetically. The store pinned
SortBy=SortName onto every drill-down, which overrode the order the
channel plugin returns — and since Jellypod prefixes played episodes with
"[Played]", the name sort also clumped every heard episode at the top.

Which order a container's children take is domain knowledge, so it moves
to Rust: the caller names the container (GetItemsOptions.parentKind) and
default_listing_sort answers with the sort. A channel folder is
PremiereDate descending, every other container keeps SortName ascending,
and a caller naming no container still gets no SortBy, so the paths that
rely on the server's own order keep it. An explicit sort always wins.

ChannelFolderItem with is_folder now maps to MediaKind::ChannelFolder
instead of collapsing into Folder — while both were Folder there was
nothing to key the rule on. The offline leg of the cache/server race
applies the same order, so the cached list no longer flashes in name
order before the server's arrives.

TRACES: UR-007 | DR-257 | UT-229, UT-230, UT-231
2026-08-23 18:38:24 +02:00
dtourolle bb14c66e71 fix(player): three defects from review, and one duplicate removed
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 21m37s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 2m55s
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Successful in 15m34s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m26s
Build & Release / Build Linux (push) Successful in 20m58s
Build & Release / Build Windows (push) Successful in 16m10s
Build & Release / Build Android (push) Successful in 31m25s
Build & Release / Create Release (push) Successful in 1m5s
Verified each against the code before acting; four of the five findings held,
one did not.

DR-253 — a deferred seek outlived its file. `seek` holds a position while MPV
has nothing loaded and `FileLoaded` applies it (DR-241), but neither `load` nor
`stop` discarded it. Scrub near the end of a transcoded item — which re-opens
the stream — then skip to the next item before the reload completes, and the
old position lands on the new item. It starts wherever the previous one was
scrubbed to, silently. Both lifecycle points clear it now.

DR-254 — a per-playback quality ceiling outlived its playback. The override is
process-wide and describes one playback: dropping to 720p for a struggling
episode says nothing about the next. Every advance the frontend drives clears
it through player_play_item, but the background audio-only advance loads the
next episode in Rust and skipped all three clearing sites — so every later
episode stayed capped, with nothing in the UI explaining why.

DR-255 — `playable_url` was a byte-identical copy of `playback_url`, added for
the cross-platform open path. The original is `#[cfg(target_os = "android")]`,
so it does not exist in a Linux build and nothing warned. Two matches over
MediaSource meant a new variant could be handled in one and forgotten in the
other. The gate is gone and the copy with it.

The fifth finding — that the comment on `video_audio_codecs` describes a
renderer switch the code no longer has — does not hold. `get_player_status`
hard-codes Android to Native, but `experimentalNativeVideo` is still live in
VideoPlayer.svelte as a suppressor that can force HTML5 even when Rust says
native. The switch exists, so the narrow codec list is still doing its job.

Both correctness fixes are red-then-green. The tests are wiring assertions in
the style of UT-218: what matters is the call site, and reaching these at
runtime needs a live MPV handle or a repository, a server and a player. That
technique now appears three times and is worth watching — it pins call sites,
not behaviour.

The review's sharpest point is one it raised as redundancy: MpvPlayer already
handles DR-253 correctly, resetting deferred state on every open, and the old
path had to be patched separately. That is the drift two parallel engines
produce, and the argument for finishing DR-248/249 rather than leaving
LegacyPlayer in place indefinitely.

795 Rust tests, 1088 frontend, every CI check green locally.
2026-08-23 11:50:47 +02:00
dtourolle fd8273824a test(player): drive an engine that answers badly
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 42s
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 3m46s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m53s
Traceability Validation / Check Requirement Traces (push) Successful in 16s
Build & Release / Run Tests (push) Successful in 15m15s
Build & Release / Build Linux (push) Successful in 21m14s
Build & Release / Build Windows (push) Successful in 15m53s
Build & Release / Build Android (push) Successful in 31m34s
Build & Release / Create Release (push) Successful in 53s
Fair criticism: hardware time went into writing a checklist describing what the
tablet found, when it should have gone into making the suites able to find it.
A checklist decays and depends on someone following it. A test does not.

The gap was specific. Every engine the conformance suite drives reports sane
numbers, so it stayed green while a real one took the backend down. The old
PlayerBackend contract is a plain f64 — it never promised finite, never
promised positive, and nothing enforced it.

UT-223 adds the engine that was missing: a HostileBackend answering with
C.TIME_UNSET as seconds, NaN, both infinities, a negative and a zero. Reading a
snapshot must yield no duration and a zero position rather than panicking.
Against the adapter as originally written it fails with

    cannot convert float seconds to Duration: value is negative

which is the exact panic that produced a black screen on the tablet — now
reproduced in 0.00s on a laptop instead of by backgrounding an app.

UT-224 pins the other hardware-only finding: stopping clears an active
background-audio handoff, flag and base offset both. That was verified by
listening to a device, which is not a test.

Both were confirmed to fail against the pre-fix code before being kept.

The verification plan now says to prefer moving cases out of it and into tests,
and that what remains should be what genuinely needs eyes, ears or a display —
not what merely has not been automated yet.

793 Rust tests.
2026-08-23 10:54:49 +02:00
dtourolle 11d9d760d8 feat(player): native video on Linux, and one contract for every player (v0.11.0)
mpv now decodes video on Linux, drawn into a framebuffer we own and blitted
into the default vbox's draw handler. Tauri's widget tree is untouched, so an
upgrade that assumes its own layout cannot invalidate this. Direct play means
the original file, hardware decoding, and no server transcode at all — where
previously every desktop video was re-encoded to h264 for the browser engine,
whatever the file actually was. Off by default: JELLYTAU_NATIVE_VIDEO=1.

That settles finding 2 of playback-backend-unification.md — "native video
cannot be composited with a Tauri webview" — by demonstration rather than
argument, on X11 and Wayland both.

Turning it on exposed nine defects, none of them mpv's. Each was the same
mistake in a different place: a capability written down as a compile-time fact
about the platform, or a state asserted instead of confirmed.

  DR-238/246  a seek routed by the stream's container rather than by what the
              engine could do with it - correct only while one player handled
              those streams, silent the moment another did
  DR-239      a property handled but never observed, so the play/pause button
              waited for an event that could not arrive
  DR-240      fullscreen expanding the document while the window stayed put
  DR-241      a seek issued before the engine had a file, failed, and discarded
              - which is why resume began at zero
  DR-247      a Linux-only gate outliving the caller that made it Linux-only,
              breaking the Android build outright
  DR-250      a stop aimed at whichever renderer bookkeeping believed was in
              charge, missing the one actually making sound
  DR-251      a duration of zero believed, leaving the seek bar no scale
  DR-252      a junk float converted to a Duration, panicking the backend the
              instant a length-less stream appeared

So the MediaPlayer contract (DR-242 … DR-247): `open` carries a start position,
so no caller sequences load-then-seek and none can race an engine's load;
`seek` states a destination and leaves in-place-versus-re-open to the engine;
`snapshot` is one coherent read; and `Phase::Opening` names the window where
intent used to be lost. One conformance suite runs against every engine —
FakePlayer and mpv under cargo test, ExoPlayer instrumented on a device — so an
engine is either correct or visibly failing.

Two of the nine were introduced during this work and caught on hardware, not by
any suite: an over-broad capability that grouped ExoPlayer with mpv, and the
Duration panic. The suites test engines that behave. That is recorded in
docs/native-player-verification.md, which asks for the exact action sequences
that found them.

Verified: all automated gates, conformance (mpv 9/9, legacy 8/9 by design,
ExoPlayer 7/7 on device), and manual desktop and Android passes on real
hardware.

Known open and deliberately shipped: resume reads local progress and never the
server's; the background-audio handoff still declares a state swap it does not
confirm (the symptom is now impossible, the race is not); and `bun run
android:dev` builds an APK carrying the release application id, whose failure
message advises an uninstall that would destroy app data. Fix that last one
before anyone else builds for Android.

Squashed from worktree-linux-native-video, which keeps the per-defect history.
2026-08-23 10:51:45 +02:00
dtourolle edff6eedc9 fix(player): let the background-audio toggle govern backgrounding again
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 18m44s
🏗️ Build and Test JellyTau / Supply Chain (push) Failing after 49s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 10s
Build & Release / Run Tests (push) Successful in 14m48s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m19s
Build & Release / Build Linux (push) Successful in 20m20s
Build & Release / Build Windows (push) Successful in 15m36s
Build & Release / Build Android (push) Successful in 30m46s
Build & Release / Create Release (push) Successful in 38s
Locking the screen kept a video's audio playing whether or not the
background-audio button was on. Reported as "audio only mode is always
active even if not selected".

The button (UR-040) was built for the WebView <video> path, where losing
visibility kills the decode: it chose between handing off to a native
audio stream and letting playback stop. Native video then became the
default renderer (DR-188), and on that path playback runs through
ExoPlayer inside a MediaSessionService -- a foreground media service
whose entire purpose is to keep playing while the app is hidden. Nothing
stopped it, and nothing in the codebase paused on background.

So the button governed a handoff that no longer had a gap to bridge.
There was no interruption to paper over, and a user who never touched it
got background playback anyway.

The gating made it self-concealing: MainActivity.onStop only dispatched
'jellytau-background' when backgroundAudioEnabled was already true. The
one notification that the app had gone away was itself conditional on the
setting, so with the button OFF nothing could react even in principle.
onStop and onStart now fire unconditionally and carry the two facts only
the activity knows -- whether the toggle is armed, and whether Android
put the window into picture-in-picture.

What to do about it is decided in Rust (player/background_policy.rs),
because it depends on whether the item has a picture to lose:

  video + toggle off  -> Pause
  video + toggle on   -> HandOffToAudio
  music, either       -> KeepPlaying   (no picture to give up)
  picture-in-picture  -> KeepPlaying   (the window is still on screen)

It takes no renderer parameter on purpose. Two renderers with two
behaviours and one toggle reaching only one of them is what produced the
defect; a rule that cannot see the renderer cannot reproduce it.

Two failure modes are deliberate. A decision call that fails leaves
playback alone rather than risking silence mid-listen. An event with no
detail -- older Kotlin against newer JS -- reads as "armed, not PiP",
degrading to the previous behaviour instead of pausing unexpectedly.

Foregrounding resumes only what backgrounding paused: a video the user
paused themselves before locking stays paused.

Written test-first per CLAUDE.md. The stub encoded today's behaviour
(nothing ever pauses) and failed exactly as reported --
`left: KeepPlaying, right: Pause` -- before the rule was implemented.

Verified on a device, R8-minified, both directions:

  [player_background_action] video=true armed=false pip=false -> Pause
  [player_background_action] video=true armed=true  pip=false -> HandOffToAudio

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

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

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

The result, on every launch, was:

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

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

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

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

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

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

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

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

Also fixed here, both found the same way:

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

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

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

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

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

Two things the tests caught that review would not have:

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

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

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

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

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

Verified: 1079 frontend tests and the coverage gate, 759 Rust tests,
clippy -D warnings, svelte-check 0 errors, and cargo check for
aarch64-linux-android.
2026-08-21 18:58:57 +02:00
dtourolle 3211c96ecf feat(updater): in-app update on desktop, releases link on Android
Anyone who installed an AppImage or ran the Windows installer was frozen
on that version forever. Nothing in the app ever mentioned a new release
existed, and the release notes were the only announcement.

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

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

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

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

Also fixed, all of it release-integrity:

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

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

Verified: 1070 frontend tests, cargo check for both the host and
aarch64-linux-android (confirming the plugins are absent there), clippy
-D warnings, svelte-check 0 errors.
2026-08-21 18:41:50 +02:00
dtourolle 32043a2152 docs: fold shipped specs into the architecture docs and delete them
A spec was a promise; sixteen of them had become descriptions of code that
already shipped, sitting beside four that describe work still outstanding, with
nothing in the file telling the two apart. Half the statuses were also wrong —
audio-equalizer read "Accepted" with the EQ live on both platforms, the native
video spec said the flag stays off after the default was flipped on.

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

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

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

requirements.md had fourteen stale statuses — Android audio parity still read
"Linux only", DR-150 still said the native-video default was off, DR-190 was
Proposed after DR-196 implemented it, and five tooling requirements were
Proposed after landing. Three unbuilt specs suggested requirement ids that have
since been allocated to other work; each now carries a warning.
2026-08-21 18:15:58 +02:00
dtourolle 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 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 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 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 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 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 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 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