258 Commits
Author SHA1 Message Date
dtourolle 73fd8a1dfe chore(release): v0.13.2
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 4m15s
📱 Test APK / Build test APK (push) Successful in 20m30s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 3m46s
Traceability Validation / Check Requirement Traces (push) Successful in 14s
Build & Release / Run Tests (push) Successful in 10m32s
Build & Release / Build Linux (push) Successful in 14m6s
Build & Release / Build Windows (push) Successful in 10m50s
Build & Release / Build Android (push) Successful in 20m20s
Build & Release / Create Release (push) Successful in 37s
A series opens with its episodes in under a second: the episode list no
longer waits on the server, a page loads once instead of six times, and
the local database finds a container's children by index.
2026-09-24 04:47:07 +02:00
dtourolle a676f4aba8 perf(series): the episode list no longer waits on the server
Opening Frasier on a Fairphone took ~5 s to render the episode list
although every episode was cached. Three causes:

- resolve_series_view waited for Next Up and resume before returning the
  episodes, and Next Up was server-first. The episode list now returns as
  soon as the episodes are in (with_hints); hints that have answered are
  used, late ones dropped, and the picker falls back to local watch state.
  Next Up is cache-first like every other query.
- The page loaded itself six times per open: onMount plus a mount-time
  $effect, the reachability effect's first run posing as a reconnect, and
  a double mount. All triggers now share one coalesced load per item
  (createCoalescedLoader); refresh triggers get one re-run after it.
- The root layout rendered the route in two branches that each rendered
  children; the page store deciding between them updates a flush late, so
  navigating Search -> library page mounted the page twice. One element
  now renders the route and only its classes change.

On the device: one load per open, seasons from cache in 14 ms, episodes
and the Resume button up in under a second (was ~5 s).
2026-09-24 04:45:44 +02:00
dtourolle c0545a245f perf(db): one logical container per item, indexed; no whole-table reads
Listings matched children on four columns at once (parent_id, album_id,
season_id, series_id) because Jellyfin's ParentId is the storage parent,
not the logical one. The OR defeated the planner into a full scan, and it
was wrong: every episode carries its series id, so a series listed all its
episodes beside its seasons (on both the browse and Downloads surfaces).

- Migration 027 adds items.container_id, a VIRTUAL generated column
  (episode -> season/series/parent, season -> series, track -> album,
  else parent) indexed with (sort_name, name), so a listing is one
  ordered index range and every write path is covered untouched.
- Containers never cached (an episode that arrived via Next Up) get
  placeholders named from the child's own fields, in the migration and
  on every cache write, so offline navigation stays series -> season.
- The six queries that built the set of every downloaded item in a CTE
  (get_item, latest, recently played, search, favourites, by-person,
  Downloads) now check availability per row with one shared predicate.
- PRAGMA optimize at open gives the planner statistics.

Benchmark (~110k items, desktop): series listing ~80 ms -> <1 ms;
migration 027 upgrades that database in ~0.1 s (0.2 s on the Fairphone).
Tests first: a series listing its episodes, and the Downloads series
drill, both failed before the change.
2026-09-24 04:45:44 +02:00
dtourolle e21ddae737 chore(release): v0.13.1
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 3m56s
📱 Test APK / Build test APK (push) Canceled after 9m51s
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
Traceability Validation / Check Requirement Traces (push) Successful in 13s
Build & Release / Run Tests (push) Successful in 10m30s
Build & Release / Build Linux (push) Successful in 14m30s
Build & Release / Build Windows (push) Successful in 10m56s
Build & Release / Build Android (push) Successful in 20m10s
Build & Release / Create Release (push) Successful in 33s
Pages answer from the cache again: database reads no longer wait behind
writes, the listing query uses its indexes, and a cache answer races the
server instead of waiting it out. Plus the series page's single season
walk and the background-audio return fix.
2026-09-24 03:59:10 +02:00
dtourolle 21f24dd998 perf(db): reads no longer wait behind writes; pages answer from cache
A series page took about a second to show its seasons on a phone, every
visit, although they were cached. Three things stacked up:

- One SQLite connection behind one mutex served the whole app, so every
  read queued behind every write. The database now has one owner: a
  writer thread for writes and a pool of read-only WAL connections for
  reads. synchronous = NORMAL and a busy timeout on every connection.
- The listing query built the set of every available item in the
  database before filtering to the parent (~80 ms on a desktop for a
  100k-item cache), then fetched user data one row at a time. It now
  checks availability per row, uses the hierarchy indexes (1.5 ms on
  the same benchmark) and batches the user-data lookup.
- A cache read that missed the 100 ms fast path was set aside until the
  server answered. It is now raced against the server; whichever answers
  first with content wins.

On the Fairphone, Frasier's season and episode lists now come from
cache in 34-133 ms (was 600-1030 ms waiting on the server).

Fixes found on the way, each with a test that failed first:
- sync_queue_mutation could return another mutation's row id: the id
  came from a second trip to the shared connection. insert() reads it in
  the same job.
- save_to_cache switched foreign keys off on the shared connection
  across its awaits, so concurrent writes ran unchecked. The toggle now
  lives inside one writer job, and a page is one transaction instead of
  one commit per row.

Also: thumbnail LRU touches no longer block the lookup; unused
tokio-rusqlite dropped. Design and invariants in
docs/architecture/08-database-design.md (Connection ownership, Listing
query shape) and 03-data-flow.md.
2026-09-24 03:58:04 +02:00
dtourolle 1fb5f070c8 fix(player): return from background audio onto the episode it advanced to
An episode that ends while backgrounded in audio-only mode advances in the
backend, but player_exit_background_audio returned only a position, so the
video page reloaded the episode it was mounted with -- the previous one, at
the new episode's timestamp.

The command now returns BackgroundAudioResume { itemId, positionSeconds }.
planHandoffReturn yields "other-item" when the id differs from the mounted
one, and the player page navigates to that episode with resumeAt=<seconds>,
marking the outgoing episode watched and suppressing its stale stop report.

TRACES: UR-040, UR-023 | DR-296 | UT-265, UT-266
2026-09-24 03:45:59 +02:00
dtourolle f6efa7208a perf(series): list a series' episodes with one concurrent season walk
"More info" on Frasier took ~10 s. The series page asked Rust for the
episodes and for the current episode as two commands; each walked every
season, and each walk fetched the eleven seasons one after another. So the
wait was the sum of twenty-two listings, each a cache read queued behind
whatever the database was writing — measured at ~4 s per walk on a Fairphone
5 while the launch-time catalog sync ran.

Seasons are now fetched together (gather_season_episodes), so a walk waits
for its slowest season, not the sum. And repository_get_series_view returns
the episodes and the current episode from one walk, with Next Up and resume
fetched alongside it; the series page makes that one call.

Under today's single database connection the cache reads themselves still
queue on its mutex; the concurrency pays off fully once reads get their own
connections. Halving the walks helps regardless.

Test first: ten 100 ms seasons took 1.01 s sequentially; now well under the
400 ms bound, with a failing season still leaving the rest.

DR-295, UT-264.
2026-09-24 03:22:31 +02:00
dtourolle 9dd44eeada chore(release): v0.13.0
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 1m23s
📱 Test APK / Build test APK (push) Successful in 58m53s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 10m51s
Traceability Validation / Check Requirement Traces (push) Successful in 29s
Build & Release / Run Tests (push) Successful in 10m38s
Build & Release / Build Linux (push) Successful in 14m27s
Build & Release / Build Windows (push) Successful in 16m56s
Build & Release / Build Android (push) Successful in 20m4s
Build & Release / Create Release (push) Successful in 31s
Android plays and downloads the original file — Dolby and DTS audio decode
on the device — and offline mode no longer needs the network.
2026-09-22 22:23:00 -04:00
dtourolle 5259b47cf3 fix(offline): offline mode no longer needs the network
Three defects made "offline" depend on a server it could not reach.

A downloaded film would not play offline. The player found the file on disk,
then asked the server for the item's PlaybackInfo only to read its
media-source id; with no network that retried for seven seconds and failed,
and the file was never opened. A completed download now answers playback
info from its download row — local path, direct play, item id as media
source — and the hybrid repository consults it before the network.

"More info" on a downloaded show failed with "Failed to load item". The
cache is one SQLite connection behind one mutex, so any write in progress
(the catalog sync at every launch, a download finishing) pushes a read past
the 100 ms fast path — and get_items, the library list, genres and playlist
items discarded such a read, waited on the server, and returned its error
over data sitting on disk. They now keep the read running and wait for it
when the server fails; the cache-only reads (search, favourites) simply
await the cache, having no server to fall back from.

Next Up went only to the server, and the TV landing page loads it in one
Promise.all with its other rows, so offline it blanked the whole page. It now
falls back to the cache.

Each fix has a test that failed first against an unreachable server (and, for
the cache, a database held past the fast path).

DR-294, UT-260, UT-261, UT-263.
2026-09-22 22:22:14 -04:00
dtourolle bed1030443 feat(android): play the original file — decode Dolby/DTS audio with FFmpeg
Android ships no AC-3, E-AC-3, DTS or TrueHD decoders; they are licensed
codecs, present only where a vendor paid for them. The ROD2-W09 tablet has a
vendor DTS decoder and no AC-3/E-AC-3 at all. So every film with Dolby audio
was re-encoded by the server, for streaming and for download alike, and a
transcoded download has no Content-Length and ignores Range: ~1 MB/s,
restarting from byte zero on every network blip.

ExoPlayer now carries Jellyfin's media3 FFmpeg audio decoder in extension
mode ON (platform decoders first, FFmpeg for what they lack), and
CodecDetector reports its codecs so the device profile and the download
policy agree with what actually decodes. The download policy judges audio
against the renderer that will play the file (renderer_can_decode_audio)
instead of the webview's list, so Android downloads are always the direct
copy — a 910 MB E-AC-3 5.1 episode downloaded in 94 s and played offline.

The webview video path is removed on Android: it decodes none of these
codecs, so a stored "native video off" would play every original-file
download silent. Rust reports webview_video_fallback (false on Android, true
only beside mpv native video on Linux); Settings offers the switch and the
player honours it only then. Linux keeps the fallback and, with it, the
server transcode for undecodable audio.

The decoder is GPL-3.0; the distributed APK carries its terms and the source
stays MIT (THIRD_PARTY_NOTICES.md). The on-device remux spec this replaces is
folded into 05-platform-backends.md and deleted.

DR-293, UT-259, UT-262.
2026-09-22 22:22:05 -04:00
dtourolleandClaude Opus 5 ff21c4cd44 chore(release): v0.12.2
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 27s
📱 Test APK / Build test APK (push) Successful in 32m30s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m56s
Traceability Validation / Check Requirement Traces (push) Successful in 13s
Build & Release / Run Tests (push) Successful in 16m28s
Build & Release / Build Linux (push) Successful in 21m48s
Build & Release / Build Windows (push) Successful in 19m42s
Build & Release / Build Android (push) Successful in 32m19s
Build & Release / Create Release (push) Successful in 33s
Two download fixes since v0.12.1: transfers longer than five minutes were
being cut off by a total request deadline and restarted from zero, and the
progress bar read "0%" for the whole of a transcode download.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:19:32 +02:00
dtourolleandClaude Opus 5 b3228cb4f4 feat(downloads): estimate a transcode's size so the progress bar moves
A transcode is produced as it is sent — chunked, with no Content-Length —
and the worker reported progress 0.0 for its whole duration: an empty bar
reading "0%" while the byte count climbed for an hour. That is the case
every film whose audio must be re-encoded lands in.

The backend already fetches the item to decide the audio policy, and that
item carries what a prediction needs: the source's size (an `original`
download copies the picture, so the output is the source give or take the
audio track) and its runtime (a preset re-encodes at fixed rates, so the
size is rate × runtime — from a preset table the URL builder now shares, so
the two cannot drift). The prediction is made where the URL is resolved and
persisted as the row's file_size. The worker uses it only when the response
has no length; the server's figure always wins; an estimated bar is capped
at 99% so a low prediction never shows a finished download still running;
and the Completed event now carries the bytes actually written so the
frontend stops persisting the row's file_size as the final size.

The row renders three honest states: exact "42%", estimated "~42%" with
"X / ~Y", or — with no total at all — an indeterminate band and the bytes
so far, never "0%". The single-video button joins the series/season buttons
on the enqueue path so all three resolve, and predict, in one place.

DR-290, UT-252, UT-253, UT-254.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:18:29 +02:00
dtourolleandClaude Opus 5 e271874b1d fix(downloads): replace the 5-minute total request deadline with a stall timeout
The download worker built its HTTP client with `Client::timeout(300s)`, which
in reqwest is a total deadline that runs until the response body has finished.
Every transfer longer than five minutes was cut off mid-body as "error
decoding response body" and retried. A transcode ignores `Range`, so each
retry restarted from byte zero, met the same deadline, and after three
attempts the download failed — no feature film at transcode speed ever
completed on a device whose audio must be re-encoded, and a large direct copy
limped through in five-minute slices with a backoff between each.

A connect timeout plus a read timeout that resets on every chunk catches a
dead connection without capping how long a healthy transfer may run.

Red first: a loopback server dribbling a body three times longer than the
timeout failed with the old client (and burned the whole retry budget) and
passes now; a second test hangs the socket and shows the stall is still
detected.

DR-289, UT-251.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 11:18:20 +02:00
dtourolle 24d85f3738 chore(release): v0.12.1
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 53s
📱 Test APK / Build test APK (push) Successful in 33m27s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m15s
Traceability Validation / Check Requirement Traces (push) Successful in 15s
Build & Release / Run Tests (push) Successful in 16m12s
Build & Release / Build Linux (push) Failing after 0s
Build & Release / Build Windows (push) Failing after 0s
Build & Release / Build Android (push) Failing after 0s
Build & Release / Create Release (push) Skipped
2026-09-20 20:56:05 +02:00
dtourolle 1093c5bad8 fix(deps): update rustls to 0.23.45 for RUSTSEC-2026-0285
📱 Test APK / Build test APK (push) Canceled after 0s
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 20m56s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 1m7s
Traceability Validation / Check Requirement Traces (push) Successful in 31s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 10m47s
cargo-deny in the Supply Chain job started failing on a new advisory
against the locked rustls 0.23.35 (TLS 1.3 handshake messages accepted
across encryption level boundaries). Upgrade to the patched release.
2026-09-20 20:53:45 +02:00
dtourolleandClaude Opus 5 ed26eb881a chore(release): v0.12.0
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Failing after 53s
📱 Test APK / Build test APK (push) Successful in 54m6s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 8m54s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
Build & Release / Run Tests (push) Successful in 23m38s
Build & Release / Build Linux (push) Successful in 31m12s
Build & Release / Build Windows (push) Successful in 32m21s
Build & Release / Build Android (push) Successful in 49m32s
Build & Release / Create Release (push) Successful in 1m24s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 12:23:41 +02:00
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 4bce81a800 chore(release): v0.11.6
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 4m4s
📱 Test APK / Build test APK (push) Successful in 48m6s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 9m17s
Traceability Validation / Check Requirement Traces (push) Successful in 20s
Build & Release / Run Tests (push) Successful in 22m27s
Build & Release / Build Linux (push) Successful in 31m18s
Build & Release / Build Windows (push) Successful in 30m1s
Build & Release / Build Android (push) Successful in 45m53s
Build & Release / Create Release (push) Successful in 1m15s
Seven fixes from an audit of the stack's most fragile seams, each with a
test that fails without it. Two could take the app out entirely: an
interrupted database migration left it unable to launch at all, and an
unguarded panic at the Android JNI boundary aborted the process outright.

Frontend gates (bun run test/check/lint/format:check) were not run for
this release — node on the release machine is missing libada.so.3 and
exits 127. All changes are under src-tauri/; CI runs those gates.
2026-09-07 22:29:40 +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 5fb9c1ff3b ci: publish a rolling latest APK on every push to master
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 25m52s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 46s
📱 Test APK / Build test APK (push) Failing after 1m14s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 7m54s
Traceability Validation / Check Requirement Traces (push) Successful in 18s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 6m26s
The test-APK workflow was dispatch-only, so merging to master produced no
APK at all -- there was nothing to hand a tester without pressing a button
first, which is not what a "latest build" means.

Pushes to master now refresh a `latest` pre-release in place. Both the tag
and the asset name are stable, so the download URL never changes and a
link given to a tester once keeps serving the current build. Release
assets are public; Actions artifacts need an account, which is what made
them useless for this.

It stays the side-by-side variant: R8-minified like a real release, so it
still exercises the minification that has broken Android builds here
before, but signed with the debug keystore under the `.debug`
applicationId. A bad master commit therefore cannot replace anyone's
working install, and the production signing key stays in the tag-driven
release workflow.

Event handling is resolved in one step rather than read raw at each use.
A push carries no dispatch inputs -- every `github.event.inputs.*` is
empty on that event -- so the variant and ABI need real defaults, and the
publish decision differs by event. Doing it once means the build, collect
and publish steps cannot disagree about what the run is.

Known gap, documented rather than hidden: this builds in parallel with
build-and-test.yml, so `latest` can carry a commit whose tests later fail.
Cross-workflow dependencies are not reliably available here and
duplicating the test job would double an already hour-long queue on a
single-slot runner.
2026-09-05 13:42:33 +02:00
dtourolle 10d77f1380 ci: publish a test APK as a pre-release for outside testers
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 29m49s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 57s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 9m25s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
🏗️ Build and Test JellyTau / Android Compile Check (push) Failing after 51s
Gitea artifacts need an account with read access to download, which makes
them useless for handing a build to someone outside the project -- the
actual reason a test APK gets built in the first place.

An optional publish input attaches the APK to a pre-release instead,
whose assets are a plain public URL on a public repo. No merge to master,
no MR, no version tag, and the tester needs no account.

Safe from a feature branch on two counts. The tag is test-<branch> rather
than v*, and only v* triggers build-release.yml, so nothing else reacts
to it. And it cannot reach existing users: the desktop updater reads a
static latest.json from the updater branch, not the release list.

Re-dispatching the same branch replaces the APK on the existing
pre-release rather than accumulating one release per attempt.
2026-08-30 20:06:40 +02:00
dtourolle 03c0b5cd17 ci: build a test APK from any branch on demand
build-release.yml is tag-driven, builds three platforms and then creates
a release -- none of which is what you want from a feature branch, and
there was otherwise no way to get an installable build out of CI without
cutting one.

workflow_dispatch only, deliberately. The runner has a single slot shared
with two other projects, so an APK on every feature-branch commit would
starve them; dispatch it when you actually want to install something.

Defaults to the R8-minified release build in the debug applicationId slot
rather than a plain debug APK. Minification is where Android releases
have actually broken here (R8 stripping JNI-loaded player and security
classes), and a debug build cannot catch it. Neither variant needs the
real signing key, and both install side by side with a real install.

Builds through scripts/build-android.sh rather than a hand-rolled tauri
invocation, so CI and a developer's machine produce the same thing and
the script's applicationId assertion still runs. Shares the existing
cargo registry cache key -- no fourth copy of the registry on a disk that
has filled before.
2026-08-30 19:48:59 +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 8a04a6fad0 chore(release): v0.11.5
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 3m41s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m20s
Traceability Validation / Check Requirement Traces (push) Successful in 11s
Build & Release / Run Tests (push) Successful in 14m44s
Build & Release / Build Linux (push) Successful in 20m30s
Build & Release / Build Windows (push) Successful in 15m38s
Build & Release / Build Android (push) Successful in 30m30s
Build & Release / Create Release (push) Successful in 37s
One fix since v0.11.4: a video in a picture-in-picture window no longer drops
to audio-only partway through, replaying from wherever the picture had been
when the window opened.

Two independent faults had to line up for it. The player's position variable is
written only by a requestAnimationFrame loop while playing, and RAF stops for a
document that is not being rendered -- which an Android activity behind a PiP
window is not; the `timeupdate` handler that would have covered the gap had
gated itself on `!isPlaying` since the first commit. And PiP and the
background-audio handoff, nominally alternatives, could both be armed at once,
with a single `isInPictureInPictureMode` sample taken inside onStop() standing
between them.

The frozen position was not confined to the handoff: the seek bar, resume
points and the progress reported to Jellyfin all read the same variable, so all
three stood still for as long as a PiP window was open.

Version stamped with scripts/set-version.sh. defect-windows.md records DR-265
under the present-since/reachable-since gap: defective since v0.0.1, but only
hittable once PiP started working on the HTML5 path in v0.5.3.
2026-08-27 17:58:06 +02:00
dtourolle 66e7889030 chore(release): v0.11.4
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 3m39s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m21s
Traceability Validation / Check Requirement Traces (push) Successful in 10s
Build & Release / Run Tests (push) Successful in 14m51s
Build & Release / Build Linux (push) Successful in 20m25s
Build & Release / Build Windows (push) Successful in 15m29s
Build & Release / Build Android (push) Successful in 30m41s
Build & Release / Create Release (push) Successful in 45s
Three fixes since v0.11.3: Recently Added groups a new album's tracks into one
album card in the client rather than trusting the server's GroupItems; autoplay
crosses a season boundary instead of stopping dead at the last episode; and a
finished episode is no longer offered as its own "up next". Plus the Android
debug applicationId suffix, which the Tauri CLI's Gradle rewrite had been
dropping, so a debug build again installs beside a release one.

Version stamped with scripts/set-version.sh. defect-windows.md records the
Recently Added grouping as the second fix for the same symptom: v0.5.1 sent
GroupItems=true and the server does not always honour it.
2026-08-26 19:42:54 +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 1ba836928f fix(android): keep the debug applicationId out of Tauri's reach
`bun run android:dev` produced an APK whose applicationId was plain
com.dtourolle.jellytau, so installing it over a real release build failed
with INSTALL_FAILED_UPDATE_INCOMPATIBLE -- the only obvious way out being to
uninstall the release app and lose its data.

`tauri android build` rewrites the getByName("debug") block in the generated
copy of build.gradle.kts to inject its jniLibs.keepDebugSymbols entries. The
damage is visible in the generated file, where `packaging {` ends up with the
first injected line welded onto it. That rewrite drops applicationIdSuffix
and nothing else -- versionNameSuffix and the manifest placeholders beside it
survive -- and it happens after sync-android-sources.sh has copied the
canonical file into place and before Gradle configures, so no amount of
syncing beats it. The sideBySideRelease suffix in the release build type is
untouched by the same rewrite, which is why `build-and-deploy.sh release
--debug` kept working while the plain debug path did not.

The suffix moves to a top-level statement after the android {} block, which
is not inside what the rewriter looks for and survives. build-android.sh then
asserts the applicationId the APK actually carries, read from AGP's
output-metadata.json, so a future CLI that reaches further fails the build
instead of shipping a colliding APK.

Verified: a debug build now reports com.dtourolle.jellytau.debug, and the
statement is still there in gen/ after the CLI has run.
2026-08-25 23:48:24 +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 ad05dcd484 chore(release): bump version to v0.11.3
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 3m42s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m24s
Traceability Validation / Check Requirement Traces (push) Successful in 12s
Build & Release / Run Tests (push) Successful in 15m5s
Build & Release / Build Linux (push) Successful in 27m43s
Build & Release / Build Windows (push) Successful in 25m31s
Build & Release / Build Android (push) Successful in 50m49s
Build & Release / Create Release (push) Successful in 4m2s
Stamped with scripts/set-version.sh, which also picks up packaging/arch/PKGBUILD
— stale at 0.10.1, since the last two bumps edited the version files by hand.
2026-08-25 22:47:40 +02:00
dtourolle 1d6487774c fix(android): subtitles on the picture, not on a black bar
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 15m35s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 40s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m34s
Traceability Validation / Check Requirement Traces (push) Successful in 12s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m30s
Every subtitle line arrived in an opaque black box wide enough to sit across
the picture. That box is what Android hands back when the viewer has set no
captioning preferences: SubtitleView.setUserDefaultStyle() reads the system
style and falls back to media3's DEFAULT, which is white on opaque black.

Dropping the box is not the same as replacing the style. Someone who has
configured captions in accessibility settings has said something specific about
colour, typeface and edges, and overriding all of it to remove a background
would answer a question they did not ask. Their style is kept and only the two
colours that paint a box — background and window — are cleared.

A style specifying no edge gets a black outline, since without a box the text
must supply its own contrast or it is unreadable over a bright scene. One that
already names an edge keeps it: that viewer has said how they want their
captions separated from the picture.

Compiles and packages, but NOT yet seen on a device — the tablet was
disconnected before it could be deployed, so the requirement is recorded as
"Done (pending device verification)" and this must not be tagged into a release
until someone has looked at it.

TRACES: UR-020 | DR-261
2026-08-25 20:12:50 +02:00
dtourolle 1aec38b760 chore(release): bump version to v0.11.2
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 3m41s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m28s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
Build & Release / Run Tests (push) Successful in 15m6s
Build & Release / Build Linux (push) Successful in 21m8s
Build & Release / Build Windows (push) Successful in 15m55s
Build & Release / Build Android (push) Successful in 31m16s
Build & Release / Create Release (push) Successful in 39s
2026-08-23 22:00:44 +02:00
dtourolle 0187ee179e fix(android): draw the subtitles the player already decodes
Publish Documentation / Build & publish docs to gitea-pages (push) Canceled after 0s
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 15m44s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 34s
Traceability Validation / Check Requirement Traces (push) Successful in 24s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 4m33s
Turning a subtitle on did nothing even after DR-259 made them load. ExoPlayer
decodes subtitles and delivers them to a listener; it draws none itself. A
PlayerView would supply the view that does, but native video here is a bare
TextureView the WebView composites over — so nothing held the cues and every
one was decoded, delivered and dropped. There was no onCues, no TextOutput and
no SubtitleView anywhere in the app, and media3-ui was not even a dependency.

The gap was invisible for as long as every subtitle URL 404ed: with no text
track to select there was never a cue to lose, so fixing the URL is what
exposed it.

media3-ui's SubtitleView now takes each CueGroup and is attached at index 1 of
the content view — above the video, still below the WebView, so cues sit over
the picture and under the app's own controls. It is fitted to the letterboxed
video rect rather than the screen, so cues stay inside the picture and follow
it on rotation, and is removed by the same teardown that detaches the surface
(the defect DR-184 exists to prevent).

Verified on a device: track selected with no "Invalid subtitle track index",
SubtitleView attached at the fitted rect per the live view hierarchy, and cues
legible on screen during playback.

TRACES: UR-020, UR-003 | DR-260
2026-08-23 20:20:01 +02:00
dtourolle a94632461b chore(release): bump version to v0.11.1
🏗️ Build and Test JellyTau / Run Tests (push) Skipped
🏗️ Build and Test JellyTau / Android Compile Check (push) Skipped
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 3m47s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 5m27s
Traceability Validation / Check Requirement Traces (push) Successful in 17s
Build & Release / Run Tests (push) Successful in 15m4s
Build & Release / Build Linux (push) Successful in 20m48s
Build & Release / Build Windows (push) Successful in 16m14s
Build & Release / Build Android (push) Successful in 31m22s
Build & Release / Create Release (push) Successful in 33s
2026-08-23 19:20: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