Compare commits

..
46 Commits
Author SHA1 Message Date
dtourolle a67452bc80 chore(release): v0.14.0
Build & Release / Create Release (push) Blocked by required conditions
🏗️ 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 7m22s
📱 Test APK / Build test APK (push) Successful in 23m39s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 3m36s
Traceability Validation / Check Requirement Traces (push) Successful in 10s
Build & Release / Run Tests (push) Successful in 10m16s
Build & Release / Build Linux (push) Successful in 14m14s
Build & Release / Build Windows (push) Successful in 11m32s
Build & Release / Build Android (push) In progress
mpv plays all video on Linux and Windows, and the built-in web video
player is gone from every platform. Windows plays audio through mpv.
mpv commands can no longer be injected through a title, and mpv now
verifies TLS; the page loses its network access. Subtitles and audio
tracks work in desktop video.
2026-09-25 04:19:06 -04:00
dtourolle 1677f5f299 refactor(player): delete the webview video path; mpv selects its own tracks
DR-235 phase 3. Every video renderer is native now: mpv on Linux and
Windows, ExoPlayer on Android, all drawing behind the transparent
webview. The HTML5 <video> path is gone, not bypassed:

- Frontend: hls.js, Html5PlayerAdapter and its compatibility shim, the
  createAdapter factory, streamTransport, hlsRecovery, timeTracking,
  videoFit, the <video>/<track> markup and every element handler in
  VideoPlayer (3277 -> 2144 lines), the experimentalNativeVideo store
  and its Settings toggle, webviewVideoFallback/supportsNativeVideo, and
  the setHtml5VideoState PiP bridge call. NativePlayerAdapter is the one
  video adapter; webview audio gets its own adapter kind.
- Rust: use_html5 dropped from player_seek_video,
  player_switch_audio_track and player_set_stream_quality with the
  Html5* strategies and ReloadStream responses; use_html5_element and
  VideoBackend dropped from PlayerStatus; player_play_item always loads
  the backend (set_current_item removed); Capabilities::webview removed;
  the WebKitGTK GStreamer/VAAPI setup (and its gst-inspect spawn) removed.
- Android: the HTML5 video state in PictureInPictureManager and
  ScreenWakeManager, and the bridge method feeding it.
- CSP: connect-src loses http:/https: and worker-src loses blob: -
  both existed for hls.js; with it gone they were only an exfiltration
  channel and a blob worker for injected script. A test now keeps them
  out.

mpv takes over what the <video> element did (mpv_tracks, UT-275):
subtitles are the WebVTT list the play request carries, queued on
sub-files and selected by position in that list, starting off; audio
tracks are selected by position in the file; sid/aid are reset before
each load. Without this, Linux video had no subtitle selection and a
direct-play audio switch failed since mpv became its renderer.

Verified: Rust 948 passing, and the same 948 cross-compiled for Windows
under wine against the shipped DLL (track tests included); frontend
1111 passing; aarch64 debug APK builds. Lint warnings 158 -> 146, CI
ratchet tightened to match. Not yet seen on Windows hardware.
2026-09-24 23:11:17 -04:00
dtourolle bb3ab1edd7 feat(windows): mpv draws Windows video into the app window
DR-237's video half. mpv now renders Windows video the way
tauri-plugin-libmpv does on Windows: MpvBackend is handed the main
window's HWND and sets it as `wid` before mpv initialises, so mpv draws
as a child of the app window beneath the transparent WebView2, with
vo=gpu-next,gpu. osc, default bindings, VO keyboard and cursor handling
are off, so the Svelte controls drawn over the picture are the only ones.

- video_output() decides per platform (UT-274): Window(hwnd) on Windows,
  RenderApi on Linux, Off without native video. Windows with no handle
  draws nothing rather than letting mpv open a top-level window.
- native_video::enabled() is true on Windows as well, so the frontend
  takes the native path there: NativePlayerAdapter, and the page clears
  its background while video is on screen.
- enableNativeVideoCompositing() no longer logs a missing Android bridge
  as an error on the desktop, where there is no bridge to have.

Verified: every option, wid included, is accepted by the real libmpv on
Linux and by the shipped Windows DLL under wine; the Windows unit suite
passes under wine (949). Not yet seen on real Windows hardware.
2026-09-24 22:42:42 -04:00
dtourolle 4daf172834 feat(windows): mpv plays audio on Windows, with libmpv shipped in the installer
libmpv on Windows (DR-237):
- The builder image carries zhongfly's LGPL libmpv-2.dll, pinned by
  asset name and sha256, plus an MSVC mpv.lib generated from the DLL's
  own mpv_* exports (the archive ships only a MinGW .dll.a). LGPL, not
  the GPL builds: no x264/x265, mpv -Dgpl=false; FFmpeg is version3, so
  LGPL-3.0. THIRD_PARTY_NOTICES.md records it.
- build-windows-cross.sh stages both files into src-tauri/windows-libs/;
  build.rs links mpv.lib from there and tauri.windows.conf.json bundles
  the DLL beside jellytau.exe from there, with the licence texts under
  licenses/. One directory, so the DLL shipped is the one linked.
- Workflows move to builder image 2026.09.1.

Windows audio:
- MpvBackend replaces WebviewAudioBackend on Windows (ao=wasapi), so
  volume, EQ, normalization and gapless work there as on Linux. Local
  files are passed to mpv as native paths, not file:// URLs.

Fixes found on the way:
- player_play_item decided "does the backend render video" with
  cfg!(not(linux)), true on Windows, while get_player_status sent
  Windows video to the <video> element. With mpv as the backend that
  would decode every film's soundtrack twice. All three callers now
  ask video_renders_natively() (UT-273).
- confine_queued_path rebuilt paths with PathBuf::push, so on Windows
  a queued `downloads/x` was stored as `downloads\x`, no longer the
  spelling the app built. It now keeps the caller's separator (UT-205,
  which only ever ran on Linux, failed under Windows).

Verified: jellytau.exe imports libmpv-2.dll; the full unit suite
cross-compiled for Windows passes under wine against the shipped DLL
(943/943, including the mpv injection and TLS tests); the NSIS
installer contains the DLL and licence texts. Not yet run on real
Windows hardware.
2026-09-24 22:25:31 -04:00
dtourolle 9d9d81bef3 fix(player): mpv draws all Linux video, and no longer runs text from a URL
Security (DR-298, DR-299):
- The pinned libmpv crate's Mpv::command joins its arguments and calls
  mpv_command_string, which parses `;` as a command separator. Stream
  URLs carry server-controlled ids and TranscodingUrl, and a download's
  file:// path carries its track title, so a crafted title could run any
  mpv command, `run` included. Every call now goes through
  mpv_command::command, an argv built for mpv_command. The same parse
  broke loadfile for every downloaded title containing a space.
- mpv's tls-verify defaults to no, and its URLs carry the ApiKey. Every
  handle is now hardened with tls-verify=yes and ytdl=no before its
  first loadfile, and fails construction if it cannot be.

Linux video (DR-235 phase 1):
- native_video::enabled() is unconditional on Linux; the
  JELLYTAU_NATIVE_VIDEO opt-in is retired. No platform reports a
  webview video fallback, so the Settings switch no longer appears.
  Windows keeps the webview element until mpv reaches it (DR-237).
- The Linux device profile is unchanged (still h264, DR-234), so this
  ships the configuration that was tested under the env var.
2026-09-24 20:38:32 -04:00
dtourolle fd1277746d chore(release): v0.13.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 3m57s
📱 Test APK / Build test APK (push) Successful in 20m27s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 3m45s
Traceability Validation / Check Requirement Traces (push) Successful in 11s
Build & Release / Run Tests (push) Successful in 10m38s
Build & Release / Build Linux (push) Successful in 14m21s
Build & Release / Build Windows (push) Successful in 10m47s
Build & Release / Build Android (push) Successful in 20m26s
Build & Release / Create Release (push) Successful in 43s
Resuming the app after time in the background no longer replaces the page
on screen with "Failed to load item".
2026-09-24 07:40:35 -04:00
dtourolle d7136aef48 fix(library): resuming the app no longer blanks the page on screen
Coming back to the app after a few minutes in the background replaced the
Frasier series page with "Failed to load item". Android cuts a backgrounded
app's network, the app declares the server offline, and on resume the
reconnect and offline-filter reloads refresh the page. Every backend call in
that refresh answered from the cache, yet something in it threw, and:

- any throw replaced the whole page with an error, although it was a
  refresh of content already on screen;
- no later successful reload of the same item cleared that error, so it
  stayed until the viewer navigated away;
- the catch logged nothing and turned every non-Error value (backend
  errors arrive as plain strings) into the generic text, so neither logcat
  nor the screen said what failed.

A failed refresh now keeps the page and logs the value actually thrown;
every successful load clears the error; failing to open an item still shows
one, with the backend's own message. The decision lives in
detailLoadError.ts so it is unit-tested.

The throw itself is not yet identified - the next occurrence names itself
in the log.

DR-297, UT-267.
2026-09-24 07:40:14 -04:00
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
dtourolle bb7d5dc01a fix(offline): one server-only rule for both library views
Two defects with one cause: "server only" was a private $derived inside
MediaCard.

The list view (what LibraryGrid renders when the stored view preference
is list) had no notion of it at all, so a library browsed as a list
offline showed every revealed item as an ordinary tappable row that plays
nothing, with no way to queue it.

And the rule asked the downloads store whether *this item id* was
downloaded — but only a playable leaf (Audio, Movie, Episode) ever has a
download row. An album's tracks carry them, the album does not, so a
fully downloaded album greyed itself out and offered to queue what was
already on the device.

The rule moves to the pure $lib/utils/serverOnly and both views call it.
The container half is answered by the backend rather than guessed at:
get_download_disk_usage().sizes already carries container subtotals
beside leaf sizes (DR-085), so deviceContentIds is membership in a
Rust-computed map, not a frontend list of which item types are
containers. That map was loaded only by the Downloads page, so the shell
primes it at startup and re-reads it whenever the offline gate settles.
Queueing is shared too, since the list view had no copy to diverge from.

TRACES: UR-052, UR-055 | DR-292 | UT-257, UT-258
2026-09-22 21:29:38 -04:00
dtourolle a90de67c54 fix(ui): stack episode title under the thumbnail on narrow rows
A fixed 160px thumbnail beside the title left phone-width rows cramped.
A container query stacks a full-width thumbnail above the info block when
the row is under 28rem, lets the title wrap to two lines there, and
requests a 640px image so the wider thumbnail stays sharp.
2026-09-22 21:27:38 -04:00
dtourolle b98cbfe28a fix(offline): keep the offline banner off the full-screen player
Every other shell rule in layoutShell.ts already treats /player/* as
immersive; the amber "You're offline" strip was the one piece of chrome
still rendered above it. On the native Android video path that is not
cosmetic: VideoPlayer makes itself transparent so the ExoPlayer
SurfaceView behind the WebView is visible (DR-185), so a shell child that
still paints shows through the picture as a stripe across the top of the
film. Offline is also precisely when a downloaded video plays, so the
banner appeared when it was most in the way — and it offers the viewer
nothing to act on, since local playback needs no server.

The rule moves into the pure module as showOfflineBanner() rather than
staying an inline {#if} in the shell, so the immersive-route contract is
stated in one tested place.

TRACES: UR-003, UR-043 | DR-291 | UT-255
2026-09-22 21:08:03 -04:00
dtourolle 0f928798d7 docs(specs): download the original and fix the audio on device
A Jellyfin transcode is generated as it is sent — no Content-Length, Range
ignored — so every interruption restarts it from byte zero, and three
concurrent downloads are three ffmpeg jobs on the server. Measured on the
tablet: a direct copy moves 2.06 GB in 142 s with no retries, a transcode
crawls at ~1 MB/s and cannot resume. The transcode is only ever requested
because of the audio track.

So: always fetch Static=true, and re-encode the audio on the device when the
source carries something the renderers cannot decode.

DR-171 keeps its diagnosis — a downloaded film played as picture in silence,
and offline there is no other source to fall back to — and loses its remedy,
which explicitly accepted the loss of byte-range resumability. Its other
finding survives and constrains this spec: a downloaded file outlives whatever
experimentalNativeVideo was set to when it arrived, which is why the fix is to
the bytes on disk rather than to one renderer.

Records the rejected alternatives with the specific reason each fails, and the
decision to accept HEVC staying HEVC (Android-first; recoverable by a setting,
unlike silent audio).
2026-09-22 21:00:32 -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 6c188a2b44 feat(login): show the backend's server-version verdict, and drop a dead route builder
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 29m23s
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 44s
📱 Test APK / Build test APK (push) Successful in 43m47s
Publish Documentation / Build & publish docs to gitea-pages (push) Successful in 6m33s
Traceability Validation / Check Requirement Traces (push) Successful in 14s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 5m21s
Two frontend halves of the version-compatibility work.

The login flow now renders ServerCompatibility. A server below the floor blocks
with a message naming the minimum; a server newer than this build gets a
non-blocking note and proceeds; an unreadable version says nothing at all,
because refusing — or even warning — on a version string we could not parse would
punish the user for a limitation of ours.

The frontend never receives a version number to reason about, only the opaque
verdict, for the same reason it never receives an item-type list. Rust decides
whether the server is usable; the frontend decides only how that reads.

Separately, imageCache.getCachedImageUrl is deleted. It built
${serverUrl}/Items/${itemId}/Images/${imageType} in Svelte — a Jellyfin route in
the presentation layer, which is domain logic by this project's own litmus test
(would it change if Jellyfin changed its API?). check:boundary does not catch it:
the tripwire flags item-type array literals, not route strings.

It was also entirely unused. Nothing outside its own file and test ever called
it; the live path is CachedImage.svelte -> commands.imageGetUrl -> Rust, which
was already correct. So the leak was in dead code and the fix is a deletion
rather than a migration.

One consequence left deliberately unacted: that function was the last
convertFileSrc caller, so the asset-protocol grant narrowed to
$APPDATA/thumbnails/** under DR-198 now has no caller at all. Dropping a
capability grant is a security change that deserves its own commit and its own
testing on Android, not a side effect of deleting dead code. Noted in the file.

TRACES: UR-012, UR-085 | DR-285, DR-286

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 20:10:06 +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
dtourolleandClaude Opus 5 b1844f673e docs(specs): Jellyfin server version compatibility, and what research found
Adds the spec for running one build against two Jellyfin generations, plus the
research report that establishes what actually differs — with a source URL per
claim, and an explicit section for what could NOT be established.

The framing the spec started from was wrong, which is the most useful thing here:

  Jellyfin 11.0 does not exist and never did. With 12.0 the project dropped the
  leading "10" from its scheme, so what would have been 10.12.0 shipped as 12.0
  and the server reports Version: "12.0.0". The two live generations are 10.11.x
  and 12.x — one release-branch step apart, not two majors. 12.0 became stable
  on 2026-09-08.

The delta turned out far smaller than assumed, and almost none of it is a
version branch:

  - X-Emby-Authorization and the api_key query parameter are disabled by default
    in 12.0, including on upgraded servers via a migration. This is the one
    genuinely breaking change, and the fix is a rename: Authorization and ApiKey
    are ungated on both generations.
  - GetItems now defaults recursive to true for a library parent with
    IncludeItemTypes, so the same request returns a different result set. Fixed
    by stating Recursive explicitly.
  - The /Users/{userId}/... family survives. Six routes were removed in total;
    none are ones this client calls.
  - BaseItemDto is purely additive. DeviceProfile, PlaybackInfo and
    PublicSystemInfo are byte-identical between the two tags.

The generalisable lesson, recorded in the spec: most of a version delta is fixed
by writing the request correctly for both generations rather than by branching
on the version. A flag is a silent branch that outlives the reason it was added.

Allocates UR-085, IR-035, JA-037, DR-279..DR-288 and IT-019..IT-026. DR-287 and
DR-288 did not exist when the spec was written — they are what the research
turned up.

Also corrects docs/specs/README.md, whose "next free requirement ids" line was
stale by five, two and forty-seven.

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
162 changed files with 22683 additions and 12973 deletions
+4 -4
View File
@@ -28,7 +28,7 @@ jobs:
if: "!startsWith(github.event.head_commit.message, 'chore(release)')"
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
steps:
- name: Checkout repository
@@ -109,7 +109,7 @@ jobs:
# at "warn" until its class is cleared and it can be promoted to "error".
# Lower this as you clear them. Never raise it to make a build pass.
- name: Lint
run: bun run lint -- --max-warnings=158
run: bun run lint -- --max-warnings=146
- name: Check TypeScript
run: |
@@ -187,7 +187,7 @@ jobs:
runs-on: linux/amd64
needs: test
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
@@ -256,7 +256,7 @@ jobs:
name: Supply Chain
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
steps:
- name: Checkout repository
+5 -5
View File
@@ -21,7 +21,7 @@ jobs:
name: Run Tests
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -94,7 +94,7 @@ jobs:
runs-on: linux/amd64
needs: test
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -235,7 +235,7 @@ jobs:
# baked into the builder image. No toolchain installs here — the image has
# cargo-xwin, clang/clang-cl, lld, llvm, nsis and the msvc target.
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -308,7 +308,7 @@ jobs:
runs-on: linux/amd64
needs: test
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
@@ -411,7 +411,7 @@ jobs:
needs: [build-linux, build-windows, build-android]
if: startsWith(github.ref, 'refs/tags/v')
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
steps:
- name: Checkout repository
uses: actions/checkout@v4
+1 -1
View File
@@ -85,7 +85,7 @@ jobs:
# The short-SHA output below avoids depending on it regardless.
shell: bash
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
env:
ANDROID_HOME: /opt/android-sdk
ANDROID_SDK_ROOT: /opt/android-sdk
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
name: Build & publish docs to gitea-pages
runs-on: linux/amd64
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
steps:
- name: Checkout code
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: linux/amd64
name: Check Requirement Traces
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08.1
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.09.1
steps:
- name: Checkout repository
+377
View File
@@ -9,6 +9,383 @@ generated trace matrix lives in [docs/traceability.md](docs/traceability.md).
For how long each fixed defect had been shipping before it was found, see
[docs/defect-windows.md](docs/defect-windows.md).
## v0.14.0
Video on the desktop is played by mpv, on Linux and now on Windows, and the
built-in web player is gone from every platform. Windows gets real audio
playback too.
### 🔒 Security
- **A track title can no longer run a command on Linux.** mpv was handed stream
URLs and downloaded-file paths as a single command string, in which `;` starts
a new command — so a file whose title tag carried one could run a program
when it played. Every mpv command now passes its arguments separately.
(DR-298)
- **mpv checks the server's certificate.** It did not by default, and the
addresses it opens carry your login token, so anyone able to intercept the
connection could read it. It also no longer hands a failed address to
youtube-dl. (DR-299)
- **The app's web page can no longer reach the network.** It needed that only
for the web video player; everything now goes through the backend, so the
permission was just a way out for anything injected into the page.
### ✨ Features
- **mpv plays all video on Linux and Windows.** On Windows it draws into the app
window under the controls; on Linux it no longer needs the experimental
switch. Video still arrives transcoded to h264 for now — asking the server for
the original file is the next step. (DR-235, DR-237)
- **Windows plays audio through mpv**, so volume, the equalizer, volume
normalization and gapless playback now work there. The installer ships
mpv's LGPL-licensed library and its licence text. (DR-237)
### 🐛 Fixes
- **Subtitles and audio-track switching work in desktop video.** mpv now loads
the subtitle list and switches subtitles and audio tracks itself; before this
they did nothing once mpv drew the picture. (DR-023, DR-024)
- **Downloaded songs with a space in their title play offline on Linux.**
(DR-298)
- **A queued download on Windows keeps the path it was saved under**, rather
than having its separators rewritten. (DR-211)
### 🧹 Removed
- **The built-in web video player and the "Native Video" setting.** There is
nothing left to switch between: every platform plays video natively.
(DR-235)
## v0.13.3
### 🐛 Fixes
- **Coming back to the app no longer replaces the page with "Failed to load
item".** After a few minutes in the background, resuming the app on a series
page could swap the whole page for that error, and it stayed until you
navigated away. The page refreshes itself on resume; a refresh that fails now
leaves what you were looking at on screen, and the error clears as soon as a
load succeeds. When a page genuinely cannot open, it now says why instead of
the generic message. (DR-297)
## v0.13.2
A series opens with its episodes in under a second. v0.13.1 fixed the season
list; the episode list below it still took about five seconds on a Fairphone.
### ⚡ Performance
- **The episode list no longer waits for the server.** It used to wait for
"Next Up" and your resume point, fetched from the server first, although every
episode was already on the device. The list now shows as soon as it is
loaded, and Next Up answers from the device first like everything else.
(DR-101, DR-295)
- **A page loads once.** Opening a series loaded it six times over, putting
about seventy requests in flight at once. It now loads once, and re-loads only
when something actually changed (reconnecting, marking watched). (DR-295)
- **The local database finds things by index instead of reading everything.**
Each item now records the one container it is listed under, so a series,
season or album page is a single index lookup — under a millisecond on a
100,000-item library, where it used to scan the whole catalogue. The update
converts an existing library in well under a second, once. (DR-012, DR-013)
### 🐛 Fixes
- **A series lists its seasons, not every episode as well.** Both the library
view and the Downloads view mixed a series' episodes in with its seasons.
(DR-013)
## v0.13.1
Pages answer from the cache again. Found on a Fairphone, where opening a
series took one to ten seconds even though everything on it was already cached.
### ⚡ Performance
- **Series and library pages load from the cache.** Frasier's season and
episode lists now appear in 34–133 ms on a Fairphone 5; they took 600–1030 ms
each, waiting on the server. Three causes stacked up. Every database read
waited behind every write, because one connection served the whole app. The
listing query scanned the entire cached catalogue whatever page it was for.
And a cache answer that took longer than 100 ms was ignored until the server
replied. Reads now have their own connections, the query uses its indexes
(about 50× faster on a 100k-item cache), and the cache and the server race:
whichever answers first with something to show wins. (DR-012, DR-013)
- **A series page walks its seasons once, in parallel.** It used to walk all of
them twice, one after another. (DR-295)
### 🐛 Fixes
- **Coming back from background audio opens the right episode.** If an episode
ended while the app was in the background, returning to it reloaded the
*previous* episode at the new one's position. (DR-296)
- **Offline changes keep their own identity.** Two favourites or progress
updates queued at the same moment could be handed each other's queue entry,
so syncing one marked the other as done. (DR-014)
- **Catalog caching no longer switches off data-integrity checks for the rest
of the app** while it saves a page. (DR-012)
## v0.13.0
Android plays and downloads the original file, and offline mode works without
a network. Found on a tablet with no Dolby decoder, where nearly every film was
being transcoded by the server — slowly, and unresumably — just for its audio.
### ✨ Features
- **Dolby and DTS audio play on every Android device.** Android ships no AC-3,
E-AC-3, DTS or TrueHD decoders — they exist only where a manufacturer paid for
them. The player now decodes them itself (FFmpeg), so these films stream and
download as the original file instead of a server transcode: direct play when
streaming, and a download that runs at full speed, shows a real percentage,
and resumes after a dropped connection. Measured: a 910 MB E-AC-3 5.1 episode
in 94 seconds, where a transcode managed about 1 MB/s. (DR-293)
### 🐛 Fixes
- **Downloaded films play offline.** Playing a download asked the server for
details it did not need, so with no network it waited seven seconds, failed,
and never opened the file on disk. It now answers from the download itself.
(DR-294)
- **"Failed to load item" offline.** Opening a downloaded show's details could
fail while the app was writing to its database (the catalog sync at every
launch, a download finishing): the cached answer was thrown away for being
slow, and the server — unreachable — was reported instead. The library list,
genres, playlists, search and favourites had the same flaw. They now wait for
the cache. (DR-294)
- **The TV page no longer blanks offline.** Its "Next Up" row was server-only,
and its failure took the whole page with it. It now falls back to the cache.
(DR-294)
### 🔧 Changes
- **Native video is no longer optional on Android.** The built-in web player
cannot decode Dolby or DTS audio, so with original files downloaded it would
play them silent. The "Native Video" switch is gone from Android settings (it
remains on Linux, beside mpv native video).
- **Licence.** The Android app now bundles a GPL-3.0 component (the FFmpeg audio
decoder), so the distributed APK carries GPL-3.0 terms; JellyTau's source stays
MIT. See `THIRD_PARTY_NOTICES.md`.
## v0.12.2
Two download fixes, found together on a tablet whose films all needed their
audio re-encoded. Downloads that took longer than five minutes were being cut
off and restarted, and the progress bar sat on "0%" for the whole of a
transcode.
### 🐛 Fixes
- **Downloads longer than five minutes no longer fail.** The download worker's
HTTP client carried a *total* request deadline of five minutes — from
connect until the last byte — so every transfer longer than that was cut off
mid-body and retried. A transcode cannot be resumed (the server ignores
`Range` and starts over), so each retry threw away what had been fetched, hit
the same deadline, and after three attempts the download failed. No feature
film at transcode speed could complete; a large direct copy limped through in
five-minute slices with a backoff between each. The deadline is now a
30-second connect timeout plus a 60-second *stall* timeout that resets on
every chunk: a dead connection is still caught, a healthy transfer can run as
long as it needs. Present since the first release. (DR-289)
### ✨ Improvements
- **The progress bar moves during a transcode download.** A transcode is
produced as it is sent, with no `Content-Length`, and the bar had nothing to
measure against — it read "0%" until the file completed. The backend now
predicts the size when it resolves the download: the source's size for
`original` (the picture is copied byte-for-byte; only the audio changes), or
bitrate × runtime for a quality preset. The bar shows the estimate as
"~42%" and caps at 99% until the last byte lands; the server's own figure is
used whenever it gives one; and when there is no prediction at all the bar
is a moving band with the bytes so far, rather than a false "0%". (DR-290)
## v0.12.1
One change: the TLS library every connection to the server goes through has a
published vulnerability, and this build carries the fixed release of it. Nothing
in JellyTau itself changed.
### 🔒 Security
- **Updated the TLS library (rustls) to 0.23.45** for
[RUSTSEC-2026-0285](https://rustsec.org/advisories/RUSTSEC-2026-0285). The
version in v0.12.0 accepted TLS 1.3 handshake messages sent at the wrong
encryption level — the same fault as Go's CVE-2025-61730. The handshake stays
authenticated, so someone on the network could not alter or complete a
connection with it; the practical effect was that a server could send in
plaintext what should have been encrypted without the app refusing. Every
JellyTau build from the first release used an affected version. Found by the
dependency-advisory gate in CI, which is what it is there for.
## v0.12.0
JellyTau works against Jellyfin 12. Jellyfin 12.0 shipped on 2026-09-08 and
turns off, by default, the two ways every earlier JellyTau build identified
itself to a server — including on servers that were upgraded rather than freshly
installed. An app that was not changed for it stops signing in the day the server
updates. This release is changed for it, and still works against 10.11, so the
app can be updated first and the server whenever it suits.
There is no Jellyfin 11. The project dropped the leading `10` from its version
scheme: what would have been 10.12.0 shipped as 12.0. Anything that compares
Jellyfin version numbers needed to learn that, and this app now has.
### ✨ Changes
- **Signing in survives a server upgrade to Jellyfin 12.** The server used to be
told who was asking through a header and a URL parameter that 12.0 disables by
default — a migration disables them on upgraded servers too, so nothing in an
admin's hands changes the outcome. The replacement spellings are accepted by
10.11 and 12 alike, so this is one way of identifying the app that works
everywhere, not a switch between two. The URL half matters more than it
sounds: video and audio are streamed by the device's media player, which cannot
send headers at all, so the URL parameter is the only way playback can
authenticate. A test now refuses any request built with the old spellings,
because the failure is silent right up until a server upgrades.
(UR-085 → DR-287)
- **Browsing a library returns the same things on both server versions.** 12.0
changed what a filtered library listing means — asked for the films in a
library, 10.11 returned the folder's immediate contents and 12.0 returns
everything beneath it, and nothing in the reply says which rule applied. The
app now says which it wants, so both servers answer the same way, and the
answer is the one it always had. (UR-085 → DR-288)
- **The app knows what server it is talking to, once, and adapts.** The server
version was already fetched at sign-in and then thrown away. It is now
resolved into a small set of named capabilities that every version-dependent
decision reads from, rather than the version number being compared wherever
somebody needed it — which is unreadable by the second occurrence and cannot
express a backport. A server newer than this build is treated as the newest
one it knows and keeps working; refusing it would make every release expire
the moment the server updated. Only a server older than 10.10 is refused, and
the sign-in screen says so and names the minimum. (UR-085 → IR-035, DR-280,
DR-286)
- **The cached library re-fetches itself after a server upgrade.** Nothing had
recorded which server version wrote the cached catalog, so a server upgraded
underneath the app kept serving rows read under the old rules. The generation
is now recorded, and a change clears the cache so it fills back under the new
one. The first launch of this version records and clears nothing — an
existing install is not charged a full re-download to defend against an
upgrade that has not happened. (UR-085 → DR-284)
### 🛠 Development
- **Every route the app speaks lives in one place.** Fifty-seven inline URL
strings across the server adapter became one module of route functions, each
taking the resolved capabilities. Both shapes of the item routes Jellyfin has
deprecated are built and tested, though nothing selects the second yet — the
family still works on 12.0, and 12.0's written policy that unlisted endpoints
may go in any major release is why having the alternative ready costs less
than needing it. (UR-085 → DR-279, DR-282)
- **The server adapter is tested against a server.** There was no HTTP mocking
in the Rust tree at all: every test of the adapter asserted on a URL string it
had built, and none exercised a reply. A fake Jellyfin now answers over real
HTTP and reports whichever version a test asks for, so the same assertions run
against both generations through the production resolution path. The one
file that had tried this before reimplemented the URL builders inside its own
mock and asserted against itself — and had never compiled, and had once
stayed green while the real code shipped a download endpoint that 404s. It is
deleted, and the rule it teaches is written at the top of its replacement.
(UR-085 → DR-281)
- **A Jellyfin URL was being built in the interface layer** — the last one,
and, it turned out, unused. Deleted rather than moved. (UR-085 → DR-285)
### ⚠️ Known limits
Every cross-version assertion runs against a fake server built from a
source-level diff of the two Jellyfin releases, not against a running 12.0. Two
things that diff could not settle: whether remote control and casting behave
identically, and whether the audio-codec check that forces a transcode on 10.11
is still needed on 12 — it is left on, which errs toward an unnecessary
transcode rather than silent playback. Both resolve with a real 12.0 server;
reports welcome.
**Upgrading:** install this version *before* upgrading the server, not after.
It works against both; an older JellyTau does not work against 12.
## v0.11.6
Found by an audit of the stack's most fragile seams rather than by hitting them,
so most of these are faults that had not yet been reported — several could only
be reached on a bad day, and the worst of them only once.
### 🐛 Fixes
- **An interrupted update can no longer stop the app from ever opening again.**
Changes to the local database were applied one statement at a time with no way
to undo a half-finished one. If an update was interrupted partway — a full
disk, the phone reclaiming memory, the app being killed mid-launch — the
earlier statements stuck while nothing recorded that the change had happened.
On the next launch it started again from the beginning, immediately hit the
part that was already done, and gave up; and since the app treats a database
it cannot prepare as fatal, it stopped opening at all, on every launch, with
the only way out being to clear its data and lose downloads and sign-ins. Each
change is now all-or-nothing, so an interrupted one leaves no trace and the
next launch simply tries again. (UR-002 → DR-012)
- **The app no longer vanishes without trace when the player hits trouble.**
The parts of the Android player that report back into the app — position,
state changes, errors, the end of a track — had no protection around them, and
a failure inside one killed the whole app instantly: no error, no message, not
even a crash report worth sending. One such failure was reachable in ordinary
use, on the position report that fires four times a second: under memory
pressure the app could fail to build the small worker it needs to send that
report, and that alone was enough to take everything down. A dropped position
report is now just a dropped position report. (UR-005 → DR-052)
- **One internal failure no longer disables the whole app until it is restarted.**
Every part of the app that reads or writes local data shares a single gate to
it. If anything failed while holding that gate, the gate stayed jammed: from
then on every library page, download, setting and sign-in returned an error for
the rest of the session, and only quitting and reopening cleared it. The gate
now recovers instead of jamming. (UR-002 → DR-012)
- **Browsing offline no longer reports a network error over content already on
the device.** A read of local data was given a tenth of a second to answer and
otherwise abandoned and treated as "nothing stored". That is easily exceeded
on phone storage whenever something else is writing — a sync catching up, a
batch of artwork being saved — and offline, where there is no server to fall
back to, the result was a network error shown over a library that was sitting
on disk. Worse, the abandoned read kept running and kept the storage busy,
making the next one slower still. A slow read is now waited for rather than
thrown away, and a fast one still answers immediately as before. (UR-002 →
DR-013)
- **A download that arrived empty is no longer presented as ready to play.** If
the server answered a download with nothing at all — an error page, a
conversion that produced no output — the empty file was moved into place and
the item was marked available offline. Opening it then hung: the app's own
media server promised one byte of it and sent none, so the player waited
forever with nothing on screen to say why. An empty download is now treated as
the failure it is, keeping the partial file so it can resume, and a request for
an empty file gets an honest refusal instead of a promise. (UR-019, UR-071 →
DR-168, DR-137)
- **Renaming your computer no longer signs you out.** On systems without a
password manager, sign-in tokens are kept in a file whose key was rebuilt from
the machine's name and the current username each time the app started. Rename
the machine, or launch it from somewhere the username is not set, and the key
came out different, the file could no longer be read, and the app treated that
as never having been signed in — with nothing shown to explain it. The key is
now made once, kept, and unaffected by what the machine is called. Existing
saved sign-ins are carried over automatically. This file has never been a
substitute for a real password manager, and the app now says so plainly rather
than implying otherwise. (UR-012 → IR-014)
- **The player's internal locking is now checked rather than merely careful.**
The playback controller coordinates seventeen separate pieces of shared state
across the audio engine, the lock screen, timers and every screen in the app.
Nothing stopped two of them being taken in opposite orders by different parts
of the code, which freezes playback outright with no error anywhere — a fault
this part of the app has produced before. The correct order is now written down
and enforced automatically, so a future change cannot quietly reintroduce it.
No such fault existed; this keeps it that way. (UR-005 → DR-052)
## v0.11.5
### 🐛 Fixes
+17 -12
View File
@@ -2,8 +2,9 @@
A cross-platform Jellyfin client. Business logic lives in a Rust backend
(`src-tauri/`); a SvelteKit + TypeScript frontend (`src/`) handles presentation
and talks to it over Tauri v2 IPC. Targets **Linux** (libmpv, WebKitGTK HTML5
`<video>` for transcoded playback) and **Android** (ExoPlayer).
and talks to it over Tauri v2 IPC. Targets **Linux** and **Windows** (libmpv for audio and video) and **Android**
(ExoPlayer). There is no webview `<video>`: every video renderer is native,
drawing behind the transparent webview.
Package manager is **bun**.
@@ -151,9 +152,13 @@ output as a reviewed draft, not a final changelog.
- **Svelte frontend** (`src/`) — presentation only. Stores in
`src/lib/stores/`, API wrappers in `src/lib/api/`, components in
`src/lib/components/`.
- **Playback layers** — Linux uses libmpv for direct playback and a WebKitGTK
HTML5 `<video>` element for HLS-transcoded (h264) streams; Android uses
ExoPlayer with a foreground media service + `MediaSessionCompat`.
- **Playback layers** — Linux and Windows use libmpv for audio and video (mpv
draws video beneath the transparent webview: the render API into a GTK surface
on Linux, `wid` into the app window on Windows); Android uses ExoPlayer with a
foreground media service + `MediaSessionCompat`. The webview `<video>`/hls.js
path was deleted (DR-235). Every mpv command goes through
`player/mpv_command.rs` (argv, never a command string) and every handle is
hardened (`tls-verify=yes`, `ytdl=no`) — see DR-298/DR-299.
- **tauri-specta** generates TypeScript bindings and typed events from the Rust
command/event definitions (registered via the Builder in `src-tauri/src/lib.rs`).
@@ -167,7 +172,7 @@ canonical, maintained source; this file only summarizes. See
| [02-svelte-frontend.md](docs/architecture/02-svelte-frontend.md) | Stores, repository architecture, MiniPlayer, autoplay, nav guard |
| [03-data-flow.md](docs/architecture/03-data-flow.md) | Cache-first query flow, playback initiation, mode transfer |
| [04-type-sync-and-threading.md](docs/architecture/04-type-sync-and-threading.md) | **Rust↔TS type sync, the IPC camelCase convention + param table, locking** |
| [05-platform-backends.md](docs/architecture/05-platform-backends.md) | MpvBackend (Linux), ExoPlayerBackend (Android), MediaSession, HTML5 adapter |
| [05-platform-backends.md](docs/architecture/05-platform-backends.md) | MpvBackend (Linux/Windows) incl. desktop native video, ExoPlayerBackend (Android), MediaSession |
| [06-downloads-and-offline.md](docs/architecture/06-downloads-and-offline.md) | Download manager/worker, smart cache, offline commands |
| [07-connectivity.md](docs/architecture/07-connectivity.md) | HTTP retry, ConnectivityMonitor, reachability model |
| [08-database-design.md](docs/architecture/08-database-design.md) | Tables, relationships, key queries |
@@ -185,10 +190,9 @@ and [docs/build/build-release.md](docs/build/build-release.md).
player reports and never determine it.
- **Unified player boundary.** UI controls playback *only* through the frontend
facade `src/lib/player/index.ts` (`playerController`) — never by calling
`commands.player*` directly. Webview HTML5 `<video>` reports its state back
into Rust via `src/lib/player/html5Adapter.ts` and the `player_report_*`
commands, so the controller stays the single source of truth in both native
and HTML5 modes.
`commands.player*` directly. Video has one adapter, `NativePlayerAdapter`,
which only forwards intents; the backend performs every seek, track switch
and quality change itself.
- **Reachability from real traffic.** Server online/offline is derived from the
outcome of actual repository requests (reported to `ConnectivityMonitor`), not
a side-channel poller. The `/System/Info/Public` probe runs *only while
@@ -313,8 +317,9 @@ tagged responses keep the Rust field names as-is (e.g. `new_url`, not `newUrl`).
player or hold a lock — it deadlocks. On Android, bind a locked
`AutoplayDecision` to a `let` *before* matching; a tokio `MutexGuard` held in
the `match` scrutinee deadlocks the `AdvanceToNext` arm.
- **VideoPlayer native mode**: no lifecycle calls after an `await` in `onMount`
(it flips to HTML5 mode and breaks Android seek).
- **VideoPlayer `onMount`**: no lifecycle calls after an `await` — they throw
`lifecycle_outside_component` (this once silently switched seeks to a renderer
that was not playing).
- **Transcoded resume/seek**: `get_video_stream_url` must return the HLS
`master.m3u8`, not `stream.mp4`, or transcoded playback never starts.
- **Downloads** cap at 3 concurrent; the backend pump auto-starts pending rows.
+2 -2
View File
@@ -136,8 +136,8 @@ WORKDIR /app
CMD ["bash", "-c", "OUTPUT_DIR=/app/dist scripts/build-desktop-linux.sh"]
# Windows cross-compile environment (MSVC target via cargo-xwin). Video works via
# WebView2 and audio via the webview <audio> backend; NSIS installer is produced
# from Linux by cargo-xwin. Default bundles NSIS; override WIN_BUNDLES=none for
# WebView2 and audio via mpv, whose DLL the builder image carries
# (/opt/libmpv-win64); NSIS installer is produced from Linux by cargo-xwin. Default bundles NSIS; override WIN_BUNDLES=none for
# exe-only. Build runs at container-run time like above.
FROM ${BUILDER_IMAGE} AS windows-cross
WORKDIR /app
+36
View File
@@ -186,6 +186,42 @@ RUN . $HOME/.cargo/env && \
cargo cyclonedx --version && \
mdbook --version
# ---------------------------------------------------------------------------
# libmpv for Windows (DR-237) — the Windows build links it and ships the DLL.
#
# zhongfly/mpv-winbuild's **LGPL** dev build: libmpv-2.dll with FFmpeg compiled
# in, built `-Dgpl=false` with no x264/x265 and no --enable-gpl (its
# compile-lgpl-libmpv.patch). FFmpeg keeps --enable-version3, so the DLL is
# LGPL-3.0: the installer ships the licence texts and keeps the DLL a separate,
# replaceable file — THIRD_PARTY_NOTICES.md. Not the default (GPL) asset from
# the same release, and not shinchiro's, which is GPL-3.0 only.
#
# Pinned by asset name *and* sha256: GitHub prunes old releases from that repo,
# so a rebuild after pruning fails loudly here rather than quietly taking a
# newer build. To bump, change all three values.
#
# The archive ships a MinGW import library (libmpv.dll.a); the MSVC target needs
# `mpv.lib`, generated from the DLL's own mpv_* exports so it cannot name a
# symbol the DLL lacks. Output: $LIBMPV_WIN_DIR/{libmpv-2.dll,mpv.lib,include}.
ENV LIBMPV_WIN_RELEASE=2026-09-24-2a4eb8067c \
LIBMPV_WIN_ASSET=mpv-dev-lgpl-x86_64-20260924-git-2a4eb8067c.7z \
LIBMPV_WIN_SHA256=f89c6195e13bfce3e8c0cc5d7f5d889ebdcf12184a41c8288360f5acd1e7306c \
LIBMPV_WIN_DIR=/opt/libmpv-win64
RUN apt-get update && apt-get install -y --no-install-recommends libarchive-tools \
&& rm -rf /var/lib/apt/lists/* \
&& wget -q "https://github.com/zhongfly/mpv-winbuild/releases/download/${LIBMPV_WIN_RELEASE}/${LIBMPV_WIN_ASSET}" \
-O /tmp/mpv-dev.7z \
&& echo "${LIBMPV_WIN_SHA256} /tmp/mpv-dev.7z" | sha256sum -c - \
&& mkdir -p "$LIBMPV_WIN_DIR" \
&& bsdtar -xf /tmp/mpv-dev.7z -C "$LIBMPV_WIN_DIR" libmpv-2.dll include \
&& rm /tmp/mpv-dev.7z \
&& { echo "LIBRARY libmpv-2.dll"; echo "EXPORTS"; \
llvm-readobj --coff-exports "$LIBMPV_WIN_DIR/libmpv-2.dll" \
| awk '/Name: mpv_/ { print " " $2 }'; } > "$LIBMPV_WIN_DIR/mpv.def" \
&& llvm-lib /def:"$LIBMPV_WIN_DIR/mpv.def" /out:"$LIBMPV_WIN_DIR/mpv.lib" /machine:x64 \
&& test "$(grep -c '^ mpv_' "$LIBMPV_WIN_DIR/mpv.def")" -ge 50 \
&& ls -la "$LIBMPV_WIN_DIR"
WORKDIR /app
ENTRYPOINT ["/bin/bash"]
+42
View File
@@ -0,0 +1,42 @@
# Third-party notices
JellyTau's own source code is licensed under the MIT License (see `LICENSE`).
Some builds bundle third-party components under other licences, listed here.
## Android: FFmpeg audio decoder (GPL-3.0)
The Android app bundles **`org.jellyfin.media3:media3-ffmpeg-decoder`**, the
Jellyfin project's build of the media3 FFmpeg extension, which contains FFmpeg.
It lets the player decode AC-3, E-AC-3, DTS and TrueHD audio, which Android does
not ship.
- Licence: **GNU General Public License v3.0**
- Source: <https://github.com/jellyfin/jellyfin-androidx-media> (build of
<https://github.com/androidx/media>), with FFmpeg from <https://ffmpeg.org>
Because this component is GPL-3.0, **the Android APK as distributed is subject
to the terms of the GPL-3.0**. The complete corresponding source for JellyTau is
available in this repository; JellyTau's own code remains available under MIT.
Desktop builds do not include this component.
## Windows: libmpv (LGPL-3.0)
The Windows installer bundles **`libmpv-2.dll`**, the mpv media player library
with FFmpeg compiled in, which plays audio on Windows. It is the LGPL build from
<https://github.com/zhongfly/mpv-winbuild> (mpv built `-Dgpl=false`, FFmpeg
without `--enable-gpl`), pinned by version and checksum in `Dockerfile.builder`.
- Licence: **GNU Lesser General Public License v3.0** (FFmpeg is configured
`--enable-version3`; mpv itself is LGPL-2.1-or-later in this configuration).
The installer carries the texts as `licenses/LGPL-3.0.txt` and
`licenses/GPL-3.0.txt`, which the LGPL-3.0 incorporates.
- Source: mpv <https://github.com/mpv-player/mpv> and FFmpeg
<https://ffmpeg.org>, built by the scripts at
<https://github.com/zhongfly/mpv-winbuild>; the exact mpv commit is in the
asset name in `Dockerfile.builder`.
JellyTau links it dynamically, and the DLL sits beside `jellytau.exe` as a
separate file, so it can be replaced with any compatible libmpv build. JellyTau's
own code remains under MIT. The Linux builds use the system's libmpv and do not
bundle it.
-3
View File
@@ -11,7 +11,6 @@
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "2.10.1",
"hls.js": "^1.6.15",
"svelte-dnd-action": "^0.9.69",
},
"devDependencies": {
@@ -485,8 +484,6 @@
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="],
"html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="],
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
+49 -86
View File
@@ -314,57 +314,22 @@ pub struct PlaybackModeManager {
**Location**: `src-tauri/src/storage/db_service.rs`
Async database interface wrapping synchronous `rusqlite` to prevent blocking the Tokio runtime:
Async database interface over `rusqlite`. `RusqliteService` owns the
database: writes run as jobs on one writer thread, reads on a pool of read-only
WAL connections, so a read never waits for a write. Callers only see the
trait (`execute`, `insert`, `execute_detached`, `query_one` / `query_optional` /
`query_many`, `transaction`, `transaction_without_foreign_keys`) and build
queries with `Query` + `QueryParam`, which keeps values out of the SQL string.
```rust
#[async_trait]
pub trait DatabaseService: Send + Sync {
async fn execute(&self, query: Query) -> Result<usize, DatabaseError>;
async fn execute_batch(&self, queries: Vec<Query>) -> Result<(), DatabaseError>;
async fn query_one<T, F>(&self, query: Query, mapper: F) -> Result<T, DatabaseError>
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> Result<Option<T>, DatabaseError>
where F: FnOnce(&Row) -> Result<T> + Send + 'static;
async fn query_many<T, F>(&self, query: Query, mapper: F) -> Result<Vec<T>, DatabaseError>
where F: Fn(&Row) -> Result<T> + Send + 'static;
async fn transaction<F, T>(&self, f: F) -> Result<T, DatabaseError>
where F: FnOnce(Transaction) -> Result<T> + Send + 'static;
}
pub struct RusqliteService {
connection: Arc<Mutex<Connection>>,
}
impl DatabaseService for RusqliteService {
async fn execute(&self, query: Query) -> Result<usize, DatabaseError> {
let conn = self.connection.clone();
tokio::task::spawn_blocking(move || {
// Execute query on blocking thread pool
}).await?
}
// ... other methods use spawn_blocking
}
```
**Key Benefits:**
- **No Freezing**: All blocking DB ops run in thread pool via `spawn_blocking`
- **Type Safety**: `QueryParam` enum prevents SQL injection
- **Future Proof**: Easy to swap to native async DB (tokio-rusqlite)
- **Testable**: Can mock DatabaseService for tests
**Usage Pattern:**
```rust
// Before (blocking - causes UI freeze)
let conn = database.connection();
let conn = conn.lock().unwrap(); // BLOCKS
conn.query_row(...) // BLOCKS
// After (async - no freezing)
let db_service = database.service();
let db_service = database.service(); // cheap clone of the shared owner
let query = Query::with_params("SELECT ...", vec![...]);
db_service.query_one(query, |row| {...}).await // spawn_blocking internally
db_service.query_one(query, |row| {...}).await // runs on a reader
```
Ownership model, invariants and the reasons for them:
[08-database-design.md → Connection ownership](08-database-design.md#connection-ownership).
## Component Hierarchy
```mermaid
@@ -701,6 +666,14 @@ render behind it. There is no measurement and no reserved padding. If you
restructure the shell, preserve the scroll containment — reintroducing padding
math reintroduces the bug.
**The route renders in exactly one element.** `+layout.svelte` switches the
wrapper's *classes* between the shell scroller and the plain clipped box that
layout-owning routes (library, settings, player) get — it must not switch
between two branches that each render `children`. The page store that decides
the mode can update a flush after the new route renders, so two branches
mounted a page under one and then remounted it under the other: every
navigation between the two kinds of route loaded the page twice (DR-295).
### AccountMenu
One component for both breakpoints, anchored to the username/avatar (a real
@@ -754,6 +727,22 @@ hero button labelled `Resume S2E4` / `Play S1E1`.
A season is not a destination: `/library/<seasonId>` redirects to its series
(DR-103). Video library routes collapse to one per library (DR-105).
**The episode list never waits for Next Up or resume.** `resolve_series_view`
(`series_progress.rs`, `with_hints`) returns as soon as the episodes are in;
Next Up and resume are used if they have answered by then and dropped if not,
and `pick_current_episode` falls back to the episodes' own watch state. They
only refine which episode is current, and waiting for them held the list for
the server's 2–3 s although every episode was cached. Next Up is cache-first
like every other query (03-data-flow).
**One load per item, however many triggers.** The detail page loads through
`createCoalescedLoader` (`utils/coalescedLoader.ts`): calls for the item
already loading share that load, and callers that know the data changed
(`fresh`: reconnect, filter change, mark watched, clear history) get exactly
one re-run after it. `onMount`, a mount-time `$effect`, the reachability
effect's first run and the double mount above used to each start a full load —
six per open, about seventy requests in flight.
`episodeStrip.ts` holds the pure logic for the "More Episodes" strip, extracted
from the component because it had three distinct bugs that markup made
untestable: the strip collapsing to just the current episode while real siblings
@@ -802,36 +791,15 @@ by exactly the inset.
Unlike `addJavascriptInterface`, the inset push only writes CSS properties, so it
can safely be re-sent on resume.
## Stream Transport
## Stream Selection in the Player
**Location**: `src/lib/player/streamTransport.ts`
**TRACES**: UR-079 | DR-225 | UT-214
`videoLoaderFor(selection, capabilities)` picks the loader for the webview
`<video>` element — `hlsjs`, `nativeHls`, or `direct` — from the backend's tagged
`selection.transport`. `elementSrcFor` is its template companion: the element's
`src` is emptied only when hls.js is driving it.
The split is the point. **The transport is the stream's property and comes from
Rust; whether a given loader exists is the browser's, and is the only thing
decided here.**
> This replaced `currentStreamUrl.includes(".m3u8")`, which appeared twice in
> `VideoPlayer.svelte` — once in the HLS `$effect` and once inline in the
> template's `src`. Rust builds that URL and knows what it is; re-deriving it
> here by substring match was a domain fact reconstructed in the presentation
> layer, and it fails silently in both directions. The two tests that pin it are
> the ones that failed against the old implementation: a `progressive` stream
> whose URL contains `.m3u8` must **not** get an HLS loader, and an `hls` stream
> whose URL contains no `.m3u8` must.
>
> Logic lives in a plain `.ts` module rather than in the component for the usual
> reason — it is testable there. Same pattern as `episodeStrip.ts`.
**TRACES**: UR-079 | DR-225, DR-227
`VideoPlayer` holds a `currentSelection`, not a URL string; `currentStreamUrl` is
derived from it. A reload replaces the selection **wholesale** (the adapter's
bridge takes a `StreamSelection`, not a URL), so transport and URL can never
drift apart. The background-audio handoff states the transport it is moving to —
derived from it. A reload replaces the selection **wholesale**, so transport and
URL can never drift apart — the transport is the stream's property and comes from
Rust, never from a substring match on the URL (which is what `.m3u8` sniffing in
the component once did). The background-audio handoff states the transport it is moving to —
progressive mp3 out, HLS back — via `selectionAt()`, rather than leaving it to be
inferred.
@@ -844,21 +812,16 @@ ceiling above the source bitrate *is* the source.
## Native Video Store
**Location**: `src/lib/stores/nativeVideo.ts`
**TRACES**: UR-003, UR-004 | DR-188
**TRACES**: UR-003, UR-004 | DR-188, DR-235
Two separate concerns live here, deliberately:
`nativeVideoActive` — whether a native surface is on screen *right now*.
Setting it toggles `data-native-video` on `<html>`, which is what the CSS in
`app.css` keys off to clear the app's opaque backgrounds so the picture behind
the webview shows. The backgrounds must come back the moment the player unmounts.
- `experimentalNativeVideo` — the user-facing opt-in flag, **defaulting to on**.
Rust already decides *which backend this platform has* (`useHtml5Element` from
`player_play_item`); this flag only *suppresses* that decision. It never turns
native on where Rust says HTML5. An explicit stored choice wins in both
directions, so someone who opted out is not re-enabled by a default flip —
hence the `null` check rather than a bare `=== "true"`.
- `nativeVideoActive` — whether a native surface is on screen *right now*.
Setting it toggles `data-native-video` on `<html>`, which is what the CSS in
`app.css` keys off to clear the app's opaque backgrounds. It is deliberately
**not** derived from the flag: the backgrounds must come back the moment the
player unmounts.
It used to hold `experimentalNativeVideo`, a stored switch that could force video
back to the webview `<video>` element. That element is gone (DR-235), and the
switch with it.
See [05-platform-backends.md](05-platform-backends.md#native-video-compositing-android)
for what is behind the WebView.
+24 -9
View File
@@ -28,12 +28,17 @@ sequenceDiagram
Server->>Conn: mark_unreachable() (debounced)
end
alt Cache returns with content
alt Cache answers first with content (inside 100ms, or later but before the server)
Cache-->>Hybrid: Result with items
Hybrid-->>Rust: Return cache result
else Cache timeout or empty
Server-->>Hybrid: Fresh result (later)
Hybrid->>Cache: save_to_cache() in background
else Server answers first, or cache is empty
Server-->>Hybrid: Fresh result
Hybrid-->>Rust: Return server result
else Server fails
Cache-->>Hybrid: Whatever the cache has (waited for)
Hybrid-->>Rust: Return cache result, else the server error
end
Rust-->>Client: SearchResult
@@ -42,11 +47,21 @@ sequenceDiagram
```
**Key Points:**
- Cache queries have 100ms timeout for responsiveness
- Server queries always run for fresh data
- Cache wins if it has meaningful content
- Automatic fallback to server if cache is empty/stale
- Background cache updates (planned)
- Both legs start together. A cache answer with content inside 100 ms
(`CACHE_FAST_PATH`) returns at once.
- **The deadline does not decide the race.** A cache read still running at
100 ms is raced against the server (`HybridRepository::race_slow_cache`), and
whichever answers first *with content* wins. It used to be that a read past
the deadline was only consulted if the server failed, so a page whose cache
read took 150 ms always paid the full server round trip — about a second on a
phone, on every visit.
- An empty or failed cache answer is not a win; the server decides. A failed
server falls back to whatever the cache said, waiting for it if necessary.
- On a cache win the server's page is still cached in the background when it
arrives, so per-user state (positions, favourites) keeps up.
- A `get_items` leg that takes 250 ms or more is logged at INFO with its row
count, so a slow page can be attributed to the cache or the server from a
device log alone.
- **Connectivity side-effect**: each server request feeds the `ConnectivityMonitor`, which is the source of truth for the offline/online banner (see [07-connectivity.md](07-connectivity.md)). A server-answered error (401/404/5xx) still counts as *reachable* — only network failures, sustained past a debounce window, flip the app to offline.
### Listing order is decided in Rust
@@ -198,8 +213,8 @@ sequenceDiagram
Online-->>Page: StreamSelection
end
Page->>VP: selection
VP->>VP: videoLoaderFor(selection, caps)
Note over VP: hls.js / native HLS / direct —<br/>from the tag, never from the URL
VP->>VP: player_play_item(selection.url, transport)
Note over VP: the native player opens it —<br/>transport from the tag, never from the URL
```
The selection travels with the stream from then on. A reload — a quality change,
+109 -55
View File
@@ -90,55 +90,75 @@ flowchart LR
**Important**: The command is `player_get_queue` (returns `QueueStatus` with `hasNext`/`hasPrevious`). There is no `player_get_queue_status` command.
## HTML5 Video Adapter (webview-rendered video)
## Video is always native (no webview `<video>`)
**Location**: `src/lib/player/html5Adapter.ts`, `src/lib/player/index.ts`, report commands in
`src-tauri/src/commands/player/timers.rs`
**TRACES**: UR-080 | DR-231, DR-235, DR-237
Video on desktop (Linux WebKitGTK) — and, per current interim behavior, Android — is rendered by an
HTML5 `<video>`/HLS element **inside the webview**. libmpv is initialized audio-only (`vo=null`,
`video=false`), so the native backend cannot render or observe this element. The `<video>` is therefore
the real player, living outside Rust's reach.
Every video renderer is a native player drawing **behind** the transparent
webview, with the Svelte controls composited over it: ExoPlayer on Android, mpv on
Linux and Windows. There is no webview `<video>` element, no hls.js, and no
HTML5 adapter; the frontend has one video adapter, `NativePlayerAdapter`, and the
backend performs every seek, track switch and quality change itself.
To keep the `PlayerController` the single source of truth (matching the audio path), the HTML5 element
is treated as **a dumb output device that reports back into Rust**, rather than an independent state
authority:
Why the webview path was deleted rather than kept as a fallback:
```mermaid
flowchart LR
subgraph Webview["Webview"]
Video["HTML5 <video> / HLS.js"]
Adapter["html5Adapter.ts<br/>(reports DOM events)"]
end
subgraph Backend["Rust"]
Cmds["player_report_state<br/>player_report_position<br/>player_report_media_loaded"]
Controller["PlayerController"]
Emitter["TauriEventEmitter"]
end
subgraph Frontend["Frontend"]
Events["playerEvents.ts"]
Store["player store"]
end
- **The transcode was a decoder constraint.** The `<video>` element decodes
little beyond h264, so the desktop profile could only claim h264 and the server
re-encoded almost everything (7% direct play on a real library, against 85% for
the same library through ExoPlayer). The machine was never the limit — mpv was
already running for audio. (The Linux/Windows profile still claims h264 until
DR-234 widens it; see [desktop-native-video.md](../specs/desktop-native-video.md).)
- **Each renderer is another place for every bug.** Three video renderers meant
every seek strategy, track switch and lifecycle fix had three places to be got
right; the webview path was also where the renderer choice itself went wrong
(silent Linux video, a Windows soundtrack decoded twice — DR-237).
- **A fallback that decodes less is not a fallback.** Android showed it first
(DR-293): an original-file download plays silent in the webview. With no
webview path there is no silent downgrade to fall into; a failed mpv init
emits `backend-init-failed` instead.
Video -->|DOM events| Adapter --> Cmds --> Controller --> Emitter --> Events --> Store
```
What survives of the webview reporting pipeline is audio-only:
`WebviewAudioBackend` plays through a hidden `<audio>` element and reports back
through the `player_report_*` commands (`rustReportHost.ts`), for a desktop with
no mpv. No shipped platform uses it.
**Key points:**
- The adapter re-emits the *same* `PlayerStatusEvent`s (`StateChanged`, `PositionUpdate`, `MediaLoaded`)
the native backends emit, so `playerEvents.ts` needs **no** HTML5-specific branch — HTML5 is just
another event source feeding the existing pipeline.
- Position reports are throttled (~250ms) to match the MPV cadence and avoid flooding IPC from the
60fps RAF loop.
- **Boundary rule**: UI components never touch the report commands or `videoElement` state directly.
Playback *control* goes through the unified facade `src/lib/player/index.ts` (`playerController`);
HTML5 *state reporting* goes through `html5Adapter.ts`. This restores the documented invariant
("frontend only displays state and invokes commands") for the video path.
## Native video on the desktop (mpv)
**TRACES**: UR-080 | DR-231 … DR-237, DR-298, DR-299
The mpv half is shared; only the surface differs per platform
(`mpv_backend::video_output`):
| Platform | Output | Where the picture goes |
|---|---|---|
| Linux | `vo=libmpv` (render API) | An FBO drawn in the main window's own `GtkBox` `draw` handler, which GTK paints *before* its children — so beneath the webview, with no widget reparenting (`video_surface.rs`; a `GtkOverlay` aborts the process on the first click, see that module) |
| Windows | `vo=gpu-next,gpu`, `wid=<HWND>` | mpv renders as a child of the app window, beneath the transparent WebView2 — the arrangement tauri-plugin-libmpv ships. `wid` only takes effect before initialisation, so `MpvBackend::new` takes the handle; with no handle it draws nothing rather than open a window of its own. mpv's controller, bindings and cursor handling are off |
In both, the page clears its opaque backgrounds while a video is on screen
(`data-native-video`, the same CSS Android uses).
**Tracks** (`mpv_tracks.rs`): subtitles are the WebVTT list the play request
carries, queued on `sub-files` before the load and selected **by position in
that list** — the same meaning ExoPlayer gives `player_set_subtitle_track`.
Selection starts off (the menu opens on "Off") and `sid`/`aid` are reset before
every load, so a choice made for one item cannot leak into the next. Audio tracks
are selected by position in the file; a transcode carries one track and is
re-opened instead.
**Two rules for every libmpv handle** (`mpv_command.rs`):
- Commands go through `mpv_command::command`, an argv built for `mpv_command`,
never the pinned crate's `Mpv::command`, which joins its arguments into a
string that mpv parses — `;` chains a second command, so a track title in a
downloaded file's path could run `run …` (DR-298).
- Every handle is hardened before its first load: `tls-verify=yes` (mpv's
default is *no*, and its URLs carry the `ApiKey`) and `ytdl=no` (DR-299).
## MpvBackend (Linux)
**Location**: `src-tauri/src/player/mpv/`
The MPV backend uses libmpv for audio playback on Linux. Since MPV handles are not `Send`, all operations occur on a dedicated thread.
The MPV backend uses libmpv for audio **and video** playback on Linux and Windows (video: see *Native video on the desktop* above). Since MPV handles are not `Send`, all operations occur on a dedicated thread.
```mermaid
flowchart TB
@@ -287,6 +307,44 @@ and the trait default is still a silent `Ok(())` rather than an error, so a back
that omits the method still reports success. Flipping that default waits on the
device verification.
### Licensed audio codecs: the FFmpeg extension
**TRACES**: UR-004, UR-071 | DR-293
Android does not ship AC-3, E-AC-3, DTS or TrueHD decoders — they are licensed
codecs, present only where a vendor paid for them. The ROD2-W09 test tablet has a
vendor DTS decoder and no AC-3/E-AC-3 at all. ExoPlayer has no decoders of its
own, so on such a device those tracks are undecodable, and before this every film
with Dolby audio was re-encoded by the server — for streaming *and* for download.
`JellyTauPlayer` builds ExoPlayer with `DefaultRenderersFactory` in
`EXTENSION_RENDERER_MODE_ON`: the platform's decoders are tried first (a vendor DTS
decoder stays in charge where there is one) and the FFmpeg audio renderer takes
what they cannot decode. `CodecDetector` reports the extension's codecs beside the
`MediaCodecList` ones, asking `FfmpegLibrary.supportsFormat` per MIME type rather
than assuming, so a build whose native library failed to load reports only what
the platform decodes. Rust's device profile and download policy read that list,
which is what keeps "what we tell the server" and "what actually decodes" in step.
The decoder is `org.jellyfin.media3:media3-ffmpeg-decoder` — Jellyfin's build of
media3's FFmpeg extension, versioned `<media3 version>+N`. **Bump it in the same
commit as media3.** It is GPL-3.0: the distributed APK carries those terms, the
source stays MIT (see `THIRD_PARTY_NOTICES.md`). Its JNI methods are covered by the
AAR's own consumer rules and by `-keep class androidx.media3.** { *; }` in
`proguard-jellytau.pro`, which also keeps the renderer ExoPlayer loads reflectively.
**Rejected:** re-encoding a download's audio on the device after it lands (a
remux). It costs minutes of CPU and twice the disk per film, needs a pipeline
state of its own, and does nothing for streaming. Decoding at playback fixes both
paths with no extra step.
### Why the webview could not stay a fallback on Android
The webview decodes none of the codecs above — so with the original file now
downloaded as-is (DR-293), the old `experimentalNativeVideo` switch would have
played every such download as a silent film. That is what first removed the
webview video path on Android; DR-235 then removed it everywhere.
### The equalizer, and where its vocabulary lives
**TRACES**: UR-027 | DR-030, IR-020
@@ -314,8 +372,8 @@ chain of unbounded length.
Keeping a video's **audio** alive when the app is backgrounded or the screen
locks, while video decode stops. Two verified facts drive the whole design:
1. An Android WebView `<video>` **does not** keep playing audio once the app is
backgrounded — the system throttles the WebView and media pauses.
1. Nothing in the WebView keeps playing once the app is backgrounded — the
system throttles it.
2. Keeping audio alive in the background requires a **native foreground media
service**, which already exists for music (`JellyTauPlaybackService` +
`JellyTauPlayer` + `MediaSessionCompat`).
@@ -345,18 +403,14 @@ sequenceDiagram
Details that were each a shipped defect:
- **Position is absolute.** Transcoded HLS tracks time as
`videoElement.currentTime + seekOffset` (the element resets to 0 after each
transcode reload). `computeHandoffPosition` sums both terms; using the element
time alone rewinds by the offset.
- **Position is absolute** — the position on the item's timeline the player
reports, never an offset within a re-opened transcode (`computeHandoffPosition`).
- **A downloaded episode takes no base URL and an ordinary seek** (DR-180); a
stream takes the base and no seek; a handoff at 0:00 takes neither.
- **The return must restart the renderer that is actually on screen** (DR-196).
The two paths resume by different means — the webview `<video>` reloads off its
stream URL, watched by an `$effect`; ExoPlayer owns no element and nothing
watches the URL for it, so it needs an explicit re-issue. Doing only the URL
assignment restarted nothing on the native path and left a black screen with a
play button that did nothing.
- **The return must re-issue the load** (DR-196). The native player owns no
element and nothing watches the stream URL for it, so reassigning the URL — how
the deleted webview path came back — restarted nothing and left a black screen
with a play button that did nothing.
- **`wasPlaying` is captured on the way out** so play/pause survives the round
trip, and the handoff does not silently rewind (DR-203).
- **Mutually exclusive with PiP.** Toggle on → `setAutoEnterEnabled(false)`;
@@ -377,10 +431,10 @@ non-Android platform.
**TRACES**: UR-003, UR-004 | DR-150 … DR-152, DR-182 … DR-196
Android can render video on the **native ExoPlayer surface behind a transparent
Tauri WebView**, with the Svelte controls drawn over it. This is on by default;
the HTML5 `<video>` path remains the fallback and is not being removed. The
default has been flipped and reverted twice and each revert has a named cause —
the per-defect record is in `requirements.md` (DR-150 … DR-196).
Tauri WebView**, with the Svelte controls drawn over it. It is the only video
path (DR-235). Before that it was an opt-in whose default was flipped and
reverted twice, each revert with a named cause — the per-defect record is in
`requirements.md` (DR-150 … DR-196).
```mermaid
flowchart TB
@@ -410,7 +464,7 @@ Load-bearing details, each of which was a shipped defect:
- **The app shell stops painting over the surface** (DR-185). `app.css` clears
its opaque backgrounds off `[data-native-video]`; before that, a CSS rule
targeted an attribute nothing ever set, so the fix looked applied and was not.
- **The poster card can lift on a path with no `<video>` element** (DR-182) — the
- **The poster card lifts without a `<video>` element** (DR-182) — the
native reveal fires on a `playing` state or a position tick carrying a position
or duration, and on nothing else.
- **Letterbox bars are painted**, not left holding whatever was last in the
@@ -186,6 +186,63 @@ Per-item disk usage comes from `repository_get_download_disk_usage`
Downloaded browse cards, detail pages, the device total and the remove
confirmation (DR-085).
## What a Video Download Fetches
**TRACES**: UR-071, UR-004 | DR-171, DR-293
An `original`-quality download is the server's untouched file (`Static=true`)
unless its audio cannot be decoded by **the renderer that will play it** —
`renderer_can_decode_audio`, DR-234's per-platform answer. Only then is the
server asked to re-encode the audio on the way down (`allowVideoStreamCopy`
keeps the picture byte-for-byte).
The distinction matters because a transcode is generated as it is sent: no
`Content-Length`, `Range` ignored. It measured ~1 MB/s and restarted from byte
zero on every network blip, against a direct copy that moved a 910 MB episode in
94 s with no retries. On Android the renderer is ExoPlayer with the FFmpeg
extension ([05-platform-backends.md](05-platform-backends.md)), which decodes
AC-3/E-AC-3/DTS/TrueHD, so Android downloads are always the direct copy. On
Linux the webview still renders video and the transcode still applies.
The policy used to judge against the *webview's* codec list on every platform
(DR-171), because a download outlives the native-video setting that was active
when it arrived. That reasoning is why Android's webview video path was removed
rather than merely defaulted off: a file downloaded as the original must never
meet a renderer that cannot decode it.
## Offline Means No Network
**TRACES**: UR-002, UR-071 | DR-294
Three defects made "offline" depend on the network; the invariants that replace
them:
- **A download plays without the server.** Playing a downloaded item asked the
server for its `PlaybackInfo` only to read the media-source id; offline that
retried for seven seconds, failed, and the file was never opened.
`OfflineRepository::local_playback_info` answers for any completed download of
the current user — local path, direct play, item id as media source (a download
names no source, so the server served its default, which carries the item's id)
— and `HybridRepository::get_playback_info` consults it **first**.
- **A slow cache read is waited for, never discarded.** Reads no longer queue
behind writes (see [Connection ownership](08-database-design.md#connection-ownership)),
but a big query, a cold page cache or a busy reader pool can still push a read
past the 100 ms fast path. Such a read is raced against the server rather
than set aside (see [03-data-flow.md](03-data-flow.md)). `get_items`, the library list, genres and playlist items used to discard
such a read, wait for the server, and — offline — return its error over data on
disk; "More info" on a downloaded show failed that way. They now start the read
with `cache_try` (which keeps it running) and `settle` on it when the server
fails. Cache-only reads (search, favourites) have no server to fall back from,
so they simply await the cache.
- **Server-only sections degrade, they do not fail a page.** Next Up went only to
the server, and the TV landing page loads it in one `Promise.all`, so offline it
blanked the whole page. It now falls back to the cache when the server cannot
answer.
What still needs the server, deliberately: streaming anything not downloaded,
live TV and channels, reporting playback, and edits (favourites, playlists,
played state).
## Download Commands
**Location**: `src-tauri/src/commands/download/` — `mod.rs` (the commands below), `pinning.rs`, `smart_cache.rs`
+124
View File
@@ -243,9 +243,14 @@ CREATE TABLE items (
last_sync DATETIME,
UNIQUE(jellyfin_id, server_id)
-- Logical container (migration 027): episode → season/series, season →
-- series, track → album, else parent. See "Listing query shape".
-- container_id TEXT GENERATED ALWAYS AS (CASE item_type … END) VIRTUAL
);
-- Performance indexes
CREATE INDEX idx_items_container ON items(container_id, sort_name, name);
CREATE INDEX idx_items_server ON items(server_id);
CREATE INDEX idx_items_library ON items(library_id);
CREATE INDEX idx_items_parent ON items(parent_id);
@@ -594,6 +599,125 @@ flowchart LR
| Episode | ~3 KB | ~100 KB | 300 MB - 2 GB |
| Full music library (5000 songs) | ~10 MB | ~250 MB | 25-75 GB |
## Connection ownership
`storage::Database` is opened once at startup and owns the database for the
life of the app; nothing else opens the file. Everything goes through
`Database::service()`, which returns a clone of one `RusqliteService`
(`storage/db_service.rs`) — callers never hold a `Connection`.
```
┌─────────────────────────────┐
execute / insert / │ writer thread ("db-writer") │ one read-write connection,
transaction / ────►│ jobs run in arrival order │ foreign_keys = ON
execute_detached └─────────────────────────────┘
┌─────────────────────────────┐
query_one / │ reader pool (3 connections) │ query_only = ON; WAL gives
query_optional / ─►│ via spawn_blocking │ each the last committed
query_many └─────────────────────────────┘ snapshot
```
Pragmas: `journal_mode = WAL`, `synchronous = NORMAL`, `busy_timeout = 5 s` on
every connection. `PRAGMA optimize` runs once at open, after migrations, so the planner has
statistics (see "Listing query shape").
**Why.** It used to be one connection behind one `std::sync::Mutex`. WAL was on,
but with a single connection its one benefit — readers running beside a writer —
never applied: every read queued behind every write. A series page's background
refresh cached hundreds of episodes one autocommit (and one fsync) at a time,
and library pages, thumbnail lookups and settings reads all waited behind the
pile; the same page measured 1.7 s or 3.95 s depending on what was queued.
**Invariants a change must keep:**
- **Writes go to the writer, reads to the pool.** A reader is `query_only`, so a
write sent through `query_*` fails loudly rather than racing the writer. A
read that must see a write *in the same unit of work* belongs inside the
`transaction` closure, which runs on the writer.
- **Connection-wide state is set inside one writer job.** `PRAGMA foreign_keys`
is per connection and ignored inside a transaction. `save_to_cache` used to
switch it off with one `execute` and back on with another, so it stayed off
for every other write that ran across the save's awaits (they did — nearly
all of 1,600 FK-violating writes got through in the regression test).
`transaction_without_foreign_keys` flips it around `BEGIN`/`COMMIT` in a single
job. Any future pragma toggle must work the same way.
- **An insert's rowid comes from the same job.** `insert()` returns it; there is
deliberately no standalone `last_insert_rowid()`, which returned whichever row
the last *anyone* inserted.
- **Batch writes into one transaction.** A commit is a queue slot on the
writer; a page of items is one `transaction`, not one `execute` per row.
- **Best-effort bookkeeping does not wait.** `execute_detached` queues a write
(the thumbnail LRU access time) in order with the rest and returns
immediately; failures are only logged.
- **The writer survives a panicking job.** The job's caller gets an error; the
loop rolls back any transaction the job left open and restores
`foreign_keys = ON`, then carries on.
**`synchronous = NORMAL`** is corruption-safe in WAL mode and survives an app
crash; only a power cut can lose the last few commits. Everything stored is
either re-fetchable from the server or (the sync queue, local positions)
recoverable at that granularity.
**In-memory databases** (tests) cannot be shared between connections, so
`RusqliteService::new` has no pool and routes reads through the writer — the
old serialized behaviour.
**Not done, and why:** grouping consecutive small `execute` jobs into one
commit on the writer. With `synchronous = NORMAL` a commit no longer fsyncs, so
the gain is small, and it would change a failed statement's error semantics
for the jobs grouped with it.
### Listing query shape
`OfflineRepository::get_items` (`items_listing_sql`) is the hot read: every
library, series, season and album page goes through it, and it must fit the
100 ms cache fast path on a phone. Every other cached read follows the same
rules.
- **Children are matched on `items.container_id`** (migration 027), a VIRTUAL
generated column holding the item's *logical* container: an episode's season
(else series, else parent), a season's series, a track's album, otherwise the
parent. Jellyfin's `ParentId` is the storage parent, not the logical one — in
a series without season folders an episode's `ParentId` is the series while
its `SeasonId` names a virtual season — so listings used to match on four
columns at once. That `OR` defeated the planner into walking the whole table,
and it was wrong: every episode carries its series id, so a series listed all
its episodes beside its seasons. Being generated, the column covers every
write path (cache, downloads, catalog crawl) without any of them knowing, and
cannot drift from the columns it is computed from.
- **`idx_items_container (container_id, sort_name, name)`** serves a listing as
one index range already in display order — no sort step. `sort_name` is
usually NULL in the cache (the cache never writes it), hence `name` in the
index too.
- **Containers exist even when never browsed.** A series lists its seasons, so
an episode whose season row was never cached (it arrived through Next Up or
Latest) would be unreachable from its series offline. `save_to_cache` — and
migration 027 for rows already on disk — inserts placeholders named from the
child's own fields (`season_name`, `series_name`, `album_name`) with
`synced_at` NULL, so they show only when a download makes them available; the
server's real row replaces them wholesale on the next browse.
- **Availability is a per-row `EXISTS`** (`downloaded_sql` / `available_sql`):
cached for browsing (only with the catalog-browse flag), downloaded, or a
container with a downloaded *descendant* — which is why that one check still
looks at all four link columns (a series is available through an episode two
levels down). It used to be a CTE that built the id of every available item
in the database before filtering, paying for the whole table on every call.
- **The library clause is added only for a library parent** (`is_library`),
never `OR`ed into an ordinary listing, where it forces a full scan.
- **`+i.server_id`** keeps the planner off the server index, which every row
shares. `PRAGMA optimize` at open (`analysis_limit = 400`) gives the planner
statistics, but even with them it chose that index for the old `OR`; the `+`
is the guarantee.
- **User data is fetched in batches** (`with_user_data`, one `IN (…)` query per
500 rows), not once per row.
Measured on a ~110k-item benchmark catalogue (desktop): a series listing went
from ~80 ms to under 1 ms; migration 027 upgrades an existing database of that
size in ~0.1 s. `listing_a_non_library_parent_uses_the_container_index` and
migration 027's tests assert the plans; `storage::tests::write_bench_database`
(ignored; set `JELLYTAU_BENCH_DB`) writes the benchmark catalogue for the
`sqlite3` CLI.
## Rust Module Structure
```
+6 -6
View File
@@ -68,8 +68,8 @@ style-src 'self' 'unsafe-inline';
font-src 'self' data:;
img-src 'self' data: blob: asset: http://asset.localhost http: https:;
media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:;
connect-src 'self' ipc: http://ipc.localhost http: https:;
worker-src 'self' blob:;
connect-src 'self' ipc: http://ipc.localhost;
worker-src 'self';
object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
```
@@ -79,13 +79,13 @@ object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-
| `script-src 'self'` | The genuinely restrictive half. Bundled JS only; Tauri's build-time nonce covers the one inline `<script>` in `index.html`. Adding `'unsafe-inline'` here would silently do nothing anyway — a nonce in a directive voids it. |
| `style-src 'self' 'unsafe-inline'` | Svelte compiles `style="…"` attributes into markup, including `app.html`'s `display: contents` wrapper, and CSP treats a style *attribute* as inline. Safe only while no `<style>` **element** survives into `index.html`: Tauri would nonce it, and the nonce would then void `'unsafe-inline'`. The production build extracts all CSS to files, so it currently has none. |
| `img-src` | Thumbnails come from two places: the asset protocol (`asset://localhost/…` on Linux/macOS, `http://asset.localhost/…` on Windows/Android — the same protocol, named differently by `convertFileSrc`) and, on a cache miss, straight from the Jellyfin server. `data:`/`blob:` cover inline and generated images. |
| `media-src` | `<video>`/`<audio>` sources: HLS transcodes and progressive streams from the server, the token-guarded loopback media server on `http://127.0.0.1:<random port>` (DR-137), and `blob:` for the MSE object URL hls.js attaches. |
| `connect-src` | `ipc:` / `http://ipc.localhost` is Tauri's `invoke` transport (custom scheme on Linux/macOS, `http` host on Windows/Android) — without it every command is blocked. `http:`/`https:` is hls.js fetching manifests and segments; ordinary API traffic goes through Rust and is not subject to CSP. |
| `worker-src 'self' blob:` | hls.js runs its demuxer in a worker built from a blob (`enableWorker: true`). Without `blob:` it falls back to main-thread demuxing — playback survives but costs more CPU. |
| `media-src` | `<audio>` sources for the webview audio backend (a desktop without mpv; no shipped platform): streams from the server and the token-guarded loopback media server on `http://127.0.0.1:<random port>` (DR-137). Video never plays in the webview (DR-235). |
| `connect-src` | `ipc:` / `http://ipc.localhost` is Tauri's `invoke` transport (custom scheme on Linux/macOS, `http` host on Windows/Android) — without it every command is blocked. Nothing else: all network traffic goes through Rust. It allowed any `http:`/`https:` host while hls.js fetched manifests and segments in the page; with hls.js gone (DR-235) that grant was only an exfiltration channel for injected script, so it went too. |
| `worker-src 'self'` | No blob workers since hls.js (whose demuxer ran in one) was removed (DR-235). |
| `object-src`, `frame-src` = `'none'` | No plugins, no iframes; both are classic injection sinks. |
| `base-uri 'self'`, `form-action 'self'`, `frame-ancestors 'none'` | Block `<base>` hijacking, form exfiltration and framing. `frame-ancestors` is only honoured when the policy is delivered as a header, which is platform-dependent; it is harmless where it is not. |
**`img-src`/`media-src`/`connect-src` are deliberately permissive.** The Jellyfin
**`img-src`/`media-src` are deliberately permissive.** The Jellyfin
origin is typed in by the user at run time and is routinely plain `http` on a
LAN, so it cannot be enumerated at build time. `http: https:` is a wide grant for
*data* — but it still bars `file:`, `filesystem:` and scripting schemes, and it
+1 -2
View File
@@ -99,7 +99,7 @@ Each major subsystem is documented in its own file in this directory:
| [02 - Svelte Frontend](02-svelte-frontend.md) | Store structure, music library navigation, playback reporting, repository architecture, playback mode system, database service abstraction, component hierarchy, MiniPlayer, sleep timer, auto-play, navigation guard, playlist management UI, library mosaic, series/episode navigation, downloaded browse, safe-area insets, native-video store, logging |
| [03 - Data Flow](03-data-flow.md) | Repository query flow (cache-first), locally-indexed search, playback initiation, playback mode transfer, queue navigation, volume control |
| [04 - Type Sync & Threading](04-type-sync-and-threading.md) | Rust/TypeScript type synchronization, Tauri v2 IPC parameter naming convention, thread safety patterns |
| [05 - Platform Backends](05-platform-backends.md) | Player events system, HTML5 video adapter, MpvBackend (Linux), ExoPlayerBackend (Android) incl. audio settings parity, **native video compositing**, MediaSession & remote volume, album art caching, backend initialization |
| [05 - Platform Backends](05-platform-backends.md) | Player events system, MpvBackend (Linux), ExoPlayerBackend (Android) incl. audio settings parity, **native video compositing**, MediaSession & remote volume, album art caching, backend initialization |
| [06 - Downloads & Offline](06-downloads-and-offline.md) | Download manager, download worker, smart caching engine, **one storage model (cache entries are downloads)**, offline catalog visibility, download/offline commands, player integration, frontend store, UI components |
| [07 - Connectivity](07-connectivity.md) | HTTP client with retry logic, connectivity monitor, network resilience architecture |
| [08 - Database Design](08-database-design.md) | Entity relationships, all table definitions (servers, users, libraries, items, user_data, downloads, media_streams, sync_queue, thumbnails, playlists), key queries, data flow diagrams, storage estimates |
@@ -176,7 +176,6 @@ src/lib/
│ └── sessions.ts # SessionsApi (remote session control)
├── player/ # Unified player boundary (frontend)
│ ├── index.ts # playerController facade — the only write-side entry point for playback
│ └── html5Adapter.ts # Reports webview <video> DOM events back into Rust (player_report_*)
├── services/
│ ├── playerEvents.ts # Tauri event listener for player events
│ └── playbackReporting.ts # Thin wrapper (~50 lines)
+35 -17
View File
@@ -7,17 +7,37 @@ job / SMTC lockscreen), but it runs and plays media.
## How playback works on Windows
- **Video** — renders through the webview HTML5 `<video>` element (hls.js) on
*every* platform; on Windows that is WebView2 (Chromium/Edge), which plays HLS +
h264 fine. No Windows-specific code.
- **Audio-only (music)** — the native audio backends are libmpv (Linux) and
ExoPlayer (Android); neither exists on Windows. Instead
`create_player_backend()` in [../src-tauri/src/lib.rs](../../src-tauri/src/lib.rs)
uses `WebviewAudioBackend` on non-Linux/non-Android targets: it hands the stream
URL to a webview `<audio>` element (see
[../src/lib/services/webviewAudio.ts](../../src/lib/services/webviewAudio.ts)),
which reports state back through the same `player_report_*` round-trip the video
path uses. Pure Rust + Tauri events.
- **Video** — **mpv**, drawing into the app's own window: `MpvBackend` is
handed the main window's HWND as `wid` before mpv initialises and renders with
`vo=gpu-next,gpu` as a child of it, beneath the transparent WebView2 whose page
clears its background while a video is on screen (`data-native-video`). mpv's
own controller, key bindings and cursor handling are off — the Svelte controls
drawn over it are the only ones. This is the arrangement tauri-plugin-libmpv
ships on Windows (same zhongfly LGPL DLL). **Not yet seen on real Windows
hardware** — if the picture ends up *over* the controls, the z-order of mpv's
child window is the first thing to check.
- **Audio** — **libmpv**, the same `MpvBackend` Linux uses, with `ao=wasapi`.
Volume, EQ, normalization and gapless all go through mpv's filter graph as on
Linux. `libmpv-2.dll` ships beside `jellytau.exe` in the installer.
### libmpv on Windows
- **Which build:** zhongfly/mpv-winbuild's *LGPL* dev asset, pinned by name and
sha256 in [Dockerfile.builder](../../Dockerfile.builder), which unpacks it to
`/opt/libmpv-win64`. Licence terms: [THIRD_PARTY_NOTICES.md](../../THIRD_PARTY_NOTICES.md).
The same release also carries a GPL asset (x264/x265) — do not switch to it
casually, it moves the installer onto GPL-3.0 terms.
- **Import library:** the archive only ships a MinGW `libmpv.dll.a`. The image
generates an MSVC `mpv.lib` from the DLL's own `mpv_*` exports
(`llvm-readobj --coff-exports` → `.def` → `llvm-lib /def:`), so it cannot name
a symbol the DLL lacks.
- **One directory:** `scripts/build-windows-cross.sh` copies both files into
`src-tauri/windows-libs/` (gitignored) before building. `build.rs` links
`mpv.lib` from there and `tauri.windows.conf.json` bundles the DLL from there,
so the DLL shipped is the one linked against. Outside the image, point
`LIBMPV_WIN_DIR` at a directory holding `libmpv-2.dll` and `mpv.lib`.
- **Bumping it:** change the three `LIBMPV_WIN_*` values together, rebuild and
push the image, bump the image tag the workflows pin.
## Cross-compiling from Linux (MSVC + cargo-xwin)
@@ -35,7 +55,7 @@ Tauri CLI bundle the **NSIS installer from a Linux host**.
The builder image ([../Dockerfile.builder](../../Dockerfile.builder)) bakes in the
whole toolchain: the `x86_64-pc-windows-msvc` rust target, `cargo-xwin`, `lld`,
`llvm`, and `nsis`.
`llvm`, `nsis`, and the pinned Windows libmpv.
```bash
bun run docker:build:windows # NSIS installer + .exe -> ./dist
@@ -71,8 +91,6 @@ Outputs:
## Outstanding for a first-class Windows release
1. Gapless/crossfade + SMTC (lockscreen) — currently no-ops in the webview audio
path.
2. Downloaded (`Local` source) file playback needs `convertFileSrc` on the
frontend; streaming works today.
3. Code signing + a Windows packaging CI job.
1. SMTC (lockscreen / media keys) — not wired on Windows.
2. Code signing — the installer is unsigned.
3. First run of mpv video on real Windows hardware (DR-237).
+10 -2
View File
@@ -17,8 +17,8 @@ row can be re-checked or disputed:
## Present since the first release
Sixteen defects date to the initial proof of concept (v0.0.1, 2026-06-23) and
shipped for between two weeks and two months before anyone hit them.
Eighteen defects date to the initial proof of concept (v0.0.1, 2026-06-23) and
shipped for between two weeks and three months before anyone hit them.
That is the dominant pattern here: not regressions, but original assumptions that
went unexercised until a later feature leaned on them.
@@ -47,6 +47,14 @@ for ~2.
| Audio-track change asked the player to select a track the transcode never carried (DR-258) | v0.0.1 | **v0.11.1** | ~2 months | pickaxe |
| Subtitle URL missing its `Stream.` route segment, so every fetch 404ed (DR-259) | v0.0.1 | **v0.11.1** | ~2 months | pickaxe |
| `timeupdate` gated on `!isPlaying`, so a paused activity froze the position (DR-265) | v0.0.1 | **v0.11.5** | ~9 weeks | pickaxe |
| Download client used a 5-minute *total* request deadline, so any transfer longer than that was cut off and retried (DR-289) | v0.0.1 | **v0.12.2** | ~13 weeks | pickaxe |
| Progress reported 0.0 for any response without `Content-Length` — every transcode download (DR-290) | v0.0.1 | **v0.12.2** | ~13 weeks | absence |
The two v0.12.2 rows are the same latent shape as the `Range` header above:
the deadline was inert while every download was a short direct copy, and
became fatal only once DR-171 (v0.5.3) started re-encoding audio for offline
playback — a transcode is both slow enough to exceed five minutes and
impossible to resume. Defective for ~13 weeks, hittable for ~5.
### Why they took so long to surface
+2 -2
View File
@@ -80,10 +80,10 @@ introduced during this work passed conformance.
## 3. Desktop (Linux)
Run with native video on, since that is what is new:
mpv draws all Linux video (DR-235) — there is no switch:
```bash
JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
bun run tauri dev
```
- [ ] **Direct play** — a file the server does not transcode. Picture and sound.
+83 -20
View File
@@ -94,6 +94,7 @@ For a narrative overview of the system design, see
| UR-082 | A shared device holds more than one account from the same server, and changing who is using it takes a couple of taps rather than a password. Switching away leaves the account it left able to come straight back, and each account sees only its own library, its own progress and its own downloads — including offline, where the server is not there to filter | Medium | Proposed |
| UR-083 | An account can be locked behind a short numeric code, so that on a family device the accounts that need protecting are protected and the ones that do not are one tap away. The code gates switching to that account, not what the account may watch. Repeated wrong guesses stop being answered | Medium | Proposed |
| UR-084 | Forgetting the code is not a lockout: the account's ordinary password gets in, and a new code can be set from there | Medium | Proposed |
| UR-085 | Upgrading the server does not break the app, and the app does not force the upgrade. A server and its clients are updated by different people on different schedules — a family server can sit a major version behind for a year while the phone updates itself weekly — but the app encodes one server generation's routes and quirks unconditionally, as fact rather than as a branch. So the first release that follows the server forward silently abandons everyone who has not moved, and the failure reaches the user as a broken app rather than as a version mismatch. The app instead asks the server what it is, adapts to the answer, keeps working against a server merely newer than the release, and says plainly when it is talking to one it cannot use | Medium | Proposed |
| UR-074 | Video streaming can be held to a **bandwidth budget the viewer sets**, rather than spent at whatever rate the server would otherwise send. A ceiling chosen once — from the source's own bitrate down to a rung that still plays on a poor connection — governs every video the app opens, live TV included, and survives a restart, so a metered connection is not quietly drained by the next thing played. A single video can be moved to a different ceiling from the player, resuming where it was, without disturbing that default | Medium | Done |
---
@@ -140,6 +141,7 @@ External system integrations and platform-specific implementations.
| IR-032 | Whole-file background download of the item being played, reusing the existing resumable download worker and the Range-capable `/Videos/{id}/stream.mp4` endpoint; plus per-platform read-through caching hooks (ExoPlayer `CacheDataSource`, mpv `stream-record`) for direct-play sessions only | Storage | UR-071 | Proposed |
| IR-033 | libmpv render-API integration for video: `vo=libmpv` driving an OpenGL FBO bound by the host toolkit, with GL entry points resolved through libepoxy. Note that libepoxy exports them as *data* symbols — there is no `glFoo` function, only an `epoxy_glFoo` variable holding a lazily-resolving pointer — so `get_proc_address` must return the pointer stored **at** that symbol; returning the symbol's own address makes mpv jump into non-executable data and take SIGSEGV on the first GL call. The `epoxy` crate resolves this correctly but is unusable, its `gl_generator` dependency pulling a yanked `xml-rs` | Playback | UR-080 | Proposed |
| IR-034 | One downloaded file serves every account that asked for it: the download row owns the bytes, a per-user grant owns the claim, and the file is unlinked only when the last grant goes. The on-disk layout is already content-derived rather than user-derived, so this formalises what the paths already imply and stops two accounts clobbering one file | Storage | UR-082 | Proposed |
| IR-035 | Server capability negotiation: one `ServerCapabilities` value is resolved per connection from the version the server already reports at `/System/Info/Public`, and every version-dependent decision — route shape, device-profile override, cache validity — reads a named flag from it. Flags rather than version comparisons, because 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. Detection itself is free: `connect_to_server` already parses the version before login and the `servers` table already has a column for it; the value is simply discarded today | System | UR-085 | Proposed |
> **Where a UR is met by a different mechanism than its IR anticipated.** Several
> integration requirements were written when libmpv was expected to be the single
@@ -203,6 +205,7 @@ API endpoints and data contracts required for Jellyfin integration.
| JA-034 | Read `UserData` (favourite, played, resume position) from item responses | UserData | UR-069 | Done |
| JA-035 | Mark item played (`POST /Users/{userId}/PlayedItems/{itemId}`) | UserData | UR-025 | Done |
| JA-036 | Query next-up episodes excluding in-progress ones (`/Shows/NextUp` with `EnableResumable=false`) | Shows | UR-059 | Done |
| JA-037 | Read the server version from `/System/Info/Public` and select route shape from it — user-scoped `/Users/{userId}/Items` against `/Items?userId=` and its siblings | System | UR-085 | Proposed |
### 2.3 Development Requirements
@@ -381,7 +384,7 @@ Internal architecture, components, and application logic.
| DR-188 | Native Android video is **ready to be the default except for the background-audio handoff**, and the flip therefore waits. The picture defects behind DR-172 are all found, fixed and device-verified — DR-185 (the app shell painted over the surface through a CSS rule targeting an attribute nothing set), DR-182 (nothing could lift the poster card on a path with no `<video>` element), DR-183 (the JS bridges raced the page load, so `setTransparent(true)` could never arrive), DR-184 (the SurfaceView was never detached), plus DR-186 and DR-187, the two UI defects only this path could reveal. On a device logcat now carries `WebView transparent = true` and `Marking media ready` with video on screen, which is the pair DR-172 went looking for and could not find, and skip, seek and rotation were exercised by hand. Turning the default on then surfaced a *different* unverified sub-path: the background-audio handoff could only *return* through the HTML5 element, so coming back from the lockscreen left playback dead, and the flip waited for that rather than shipping a verified sub-path over an unverified one as DR-161 had. **The default is now on.** The two defects holding it back are fixed and device-verified — DR-196 (the handoff return restarts the renderer that is actually on screen) and DR-194 (the letterbox bars are painted rather than retaining stale framebuffer content) — with the evidence this default has been held to since DR-161: an audio handoff at 69:54 returning to video playing at 70:18, and clean bars across playback, the control bar and a rotation round-trip. An explicit stored choice still wins in both directions, so an opt-out survives the flip (the stored value is null-checked rather than compared to "true", which would have silently re-enabled it for everyone who turned it off) | Android | UR-003, UR-004 | Done |
| DR-189 | The control bar comes down on a touchscreen. Its hide timer was armed from exactly one place — the player container's `onmousemove` — and a touchscreen never fires `mousemove`, so on Android the bar was never scheduled to hide and sat over the video for the whole film. It went unnoticed for as long as the native video surface was itself invisible (DR-172/DR-185): with nothing behind it to obscure, a permanent control bar reads as the UI rather than as a defect. Two changes, because there were two faults. `revealControls()` replaces `handleMouseMove` and is called on entry and on every touch interaction as well as on mouse movement, so touch arms the countdown. And the countdown became an `$effect` over the state rather than a one-shot timer armed by the input event: the first attempt armed a timer on entry, three seconds later playback had not started, `shouldHideControls` correctly declined, and nothing ever re-armed it — the timer has to follow the conditions that *permit* hiding, which arrive on their own schedule. The decision itself is `shouldHideControls` in `controlsVisibility.ts`, pure and separated from the clock and the DOM, because what was wrong here was the conditions and not the `setTimeout`: the bar stays up while paused (a user who paused by tapping the surface has no other way back), mid-seek (the position readout is the point of the bar then), and while any track/subtitle/quality menu is open (the menus are anchored to the bar, so hiding it would take the open menu with it) | UI | UR-003, UR-066 | Done |
| DR-191 | Forcing the WebView overlay to redraw from the Activity, because with the ExoPlayer **SurfaceView** beneath it the overlay's ordinary damage stopped reaching the screen: the page kept mutating — the clock text every second, the control bar's opacity going to 0 — while the display held whatever frame it last presented, over video that animated perfectly. Not a state defect; the live DOM showed the slider advancing 476 → 479 across three seconds behind a screen showing neither. Only **structural** changes got through, which is why the play overlay always appeared to work (an `{#if}` block, added and removed) while the progress bar never did, and why rotation lost the transport UI. A CSS animation cannot help, since opacity animates on the compositor without repainting the layer. **Superseded by DR-192**: this drove `postInvalidateOnAnimation` in a loop, which treats the symptom — the cause is the SurfaceView's separate layer, and removing that removes the need. Kept as the record of how the mechanism was identified | Android | UR-003, UR-004 | Superseded by DR-192 |
| DR-195 | Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. `html5_playing` is Rust's record of "a webview `<video>` is active and in this state", and `toggle_playback`, `play` and `pause` all route transport to that element whenever it is set. The player route mirrored element state into it **unconditionally** — from `handleReportStart` and, fatally, from `handleReportProgress`, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct `player_toggle` invocation, while seek and skip kept working because `player_seek_video` decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off `isPlaying`, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in `mirrorElementStateToRust` in VideoPlayer, gated on `useHtml5Element`, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resume | Playback | UR-005, UR-003 | Done |
| DR-195 | Play/pause works on the native path, because the frontend stops claiming a webview element is playing when there is none. `html5_playing` is Rust's record of "a webview `<video>` is active and in this state", and `toggle_playback`, `play` and `pause` all route transport to that element whenever it is set. The player route mirrored element state into it **unconditionally** — from `handleReportStart` and, fatally, from `handleReportProgress`, which VideoPlayer calls on a 10-second interval — so on the native path the frontend re-declared every ten seconds that an element was playing when none existed, and every transport intent was emitted into the void. The pause button was dead from the on-screen tap, from the control bar, and from a direct `player_toggle` invocation, while seek and skip kept working because `player_seek_video` decides elsewhere; that asymmetry is the signature. It also explains the flashing, since the control bar and the JRay overlay both key off `isPlaying`, which was being contradicted on every interval tick. DR-193 clearing the flag at load was necessary but insufficient on its own — the interval put it straight back. The mirror now lives in `mirrorElementStateToRust` in VideoPlayer, gated on `useHtml5Element`, which is the only place that knows whether an element renders at all; the route cannot tell the two paths apart, which is precisely how it came to lie. Confirmed on device by ADB: surface tap and control bar each pause (position frozen across repeated samples, transport label flipped) and resume | Playback | UR-005, UR-003 | Superseded by DR-235 |
| DR-196 | Returning from background audio brings the picture back on the **native** path, because the return now restarts the renderer that is actually on screen. The two paths resume by different means: the webview `<video>` reloads off its stream URL, watched by an `$effect` that reinitialises HLS and lets `canplay` drive the seek — while ExoPlayer owns no element and nothing watches the URL on its behalf, so its playback is only ever started by an explicit `player_play_item` + adapter load, issued once from `onMount`. `exitBackgroundAudioHandoff` did only the URL assignment, for both paths, so on the native path it restarted nothing: `player_exit_background_audio` had already stopped the handoff's audio player, leaving the backend holding no item at all. The symptom is a black screen with a play overlay pinned at 0:00, a seek bar at zero, and a play button that does nothing — the process alive and the frontend still logging, since nothing crashed; the transition was simply dropped. The branch is decided by `planHandoffReturn` (pure, in `backgroundAudioHandoff.ts`), which also folds in `shouldResumeOnForeground` so a lockscreen pause during the handoff still wins over the snapshot taken on the way out. Subtitle configurations are reused from the ones resolved at mount, since ExoPlayer sideloads them as `MediaItem.SubtitleConfiguration`s and cannot accept one after `prepare()`. Verified on device: handoff to audio at 69:54, return restored video playing at 70:18 | Playback | UR-040, UR-003 | Done |
| DR-197 | Continue Watching and Next Up stop showing the same episode. Jellyfin's `/Shows/NextUp` defaults `EnableResumable=true`, which returns a partially-watched episode as its own series' next up — precisely the episode `/Items/Resume` already returns — so the Home "Next Episode" row and the TV landing's Next Up row duplicated Continue Watching card for card. `build_next_up_endpoint` sends `EnableResumable=false`, and because servers predating that parameter ignore it, `filterInProgressNextUpItems` also drops any next-up entry whose id appears in the resume list. It is the mirror of DR-089 and lives beside it: same presentation-layer de-duplication over two lists the frontend already holds, no Jellyfin taxonomy involved. The resume filter still reads its frontier from the *unfiltered* Next Up list, so removing in-progress entries cannot resurrect a stale resume card. The division is then exact: Continue Watching offers episodes the viewer has started and not finished, Next Up offers the episode after the ones they finished | Repository | UR-059 | Done |
| DR-200 | The lockscreen notification is exempt from `POST_NOTIFICATIONS`, because of the **session token**, not because it belongs to a foreground service — and the difference is what the code now records. `POST_NOTIFICATIONS` was declared in the manifest and requested nowhere, so on Android 13+ it sat permanently denied; an audit read that as a threat to UR-006, since the media notification is what carries the lockscreen transport controls. It is not. Android's own wording is that the permission covers "non-exempt (including Foreground Services (FGS)) notifications", with denied users seeing FGS notices "in the Task Manager but [not] in the notification drawer" — so an FGS notification is explicitly *not* exempt — while separately "Notifications related to media sessions are exempt from this behavior change". The platform predicate is `Notification.isMediaNotification()`, which requires `MediaStyle` **and** a non-null `EXTRA_MEDIA_SESSION`, and it is byte-identical across API 33–36. `NotificationManagerService` uses it to decide whether to drop the post, and SystemUI's media carousel (`MediaDataProcessor.onNotificationAdded`) is gated on the *same* predicate — so a token-less notification is not merely absent from the shade, it never reaches the notification listener and the lockscreen/Quick-Settings controls do not exist at all. Confirmed on device (HONOR ROD2-W09, Android 16 / SDK 36): appops `POST_NOTIFICATION: ignore`, `granted=false`, and the service simultaneously `isForeground=true` with `foregroundNoti=Notification(category=transport actions=3 vis=PUBLIC)`. So **no runtime permission request is added** — a prompt the app does not need is a prompt that can be permanently denied for nothing — and no `checkSelfPermission` gate is placed on `startForeground`, which would trade a cosmetic problem for the "did not then call Service.startForeground()" kill. What is added is the guard that matches the real precondition: `mediaSessionCompat?.sessionToken` is a null-safe call, and the exemption hangs entirely on it, so both builders now bind the token once and log an error if it is ever null while the permission is denied — converting a failure that is invisible unless the tester happened to deny the permission (most grant it reflexively) into a logcat line. The manifest declaration is *kept*, unrequested, and documented: media3 does not need it (media3-session declares no permissions and the `MediaSessionService` guide asks only for the two `FOREGROUND_SERVICE` ones), but the exemption covers media and self-managed-call notifications only, so a download-completion notice (UR-011) would be an ordinary notification and silently dropped — keeping the declaration is what makes adding one a one-file change | Android | UR-006 | Done |
@@ -434,9 +437,9 @@ Internal architecture, components, and application logic.
| DR-232 | The mpv render context's lifetime is bound to the GL context it draws into: created on `realize`, freed on `unrealize`, on the same thread, with the update callback unregistered *before* the free so a callback cannot land on a freed context. This is DR-184 on Android restated — a surface outliving its player — and it is a requirement in its own right rather than a fix for a specific crash. The spike observed one SIGSEGV in a decoder thread that three targeted soaks failed to reproduce; what is not in doubt is that the spike never called `mpv_render_context_free` and never tore down on `unrealize`, so nothing defended against the GL context being recreated underneath. Removing the likeliest cause is worth doing whether or not it was the cause | Playback | UR-080 | Proposed |
| DR-233 | Frame pacing goes through mpv's update callback, with `mpv_render_context_report_swap` after each render. Recorded as a requirement because the failure mode misleads: driving the widget's frame clock every tick without reporting the swap leaves mpv with nothing to time against, which looks fine in a window and **judders at fullscreen** — reading as a compositing or GPU limit and being neither | Playback | UR-080 | Proposed |
| DR-234 | The device profile is derived from the **renderer that will decode the stream**, not from a compile-time platform constant. `video_codecs` was `#[cfg(target_os)]`, which is correct only while a build has one video renderer; once mpv and the webview element coexist it must be runtime state. This is the change that converts the measured 7% desktop direct-play rate toward the 85% the Android profile achieves on the same library, because the two differ by nothing except which component decodes. It looks like configuration and is not — it is the input that decides whether the server re-encodes, and getting it wrong fails silently, a claimed codec the renderer cannot decode being a black picture or silence (DR-148, and DR-227's audio override). The webview's narrower *audio* set stops applying to the video path once mpv decodes it, while the multichannel bound still does, since a 5.1 track direct-played into a two-channel sink is silence or inaudible dialogue | Repository | UR-080, UR-070 | In Progress |
| DR-235 | The webview video path is deleted, not merely bypassed. Staged, because a path cannot be removed while a shipped platform still needs it: Linux moves to mpv first, Windows follows, and only then do `hls.js`, `html5Adapter.ts`, `videoLoaderFor` and the `<video>` element go. The staging is the point — a Linux-only version would leave the fork alive permanently, taking video from three renderers to four and giving every seek strategy, track switch and lifecycle bug one more place to be got right. Android keeps ExoPlayer and keeps the webview as its documented opt-out; the background-audio `<audio>` path is untouched. With no HTML5 fallback left, a failed mpv init emits `backend-init-failed` and surfaces a real error rather than silently degrading to the transcode this work exists to stop paying for | Playback | UR-080 | Proposed |
| DR-235 | The webview video path is deleted, not merely bypassed. Staged, because a path cannot be removed while a shipped platform still needs it: Linux moves to mpv first, Windows follows, and only then do `hls.js`, `html5Adapter.ts`, `videoLoaderFor` and the `<video>` element go. The staging is the point — a Linux-only version would leave the fork alive permanently, taking video from three renderers to four and giving every seek strategy, track switch and lifecycle bug one more place to be got right. Android keeps ExoPlayer and keeps the webview as its documented opt-out; the background-audio `<audio>` path is untouched. With no HTML5 fallback left, a failed mpv init emits `backend-init-failed` and surfaces a real error rather than silently degrading to the transcode this work exists to stop paying for | Playback | UR-080 | Done |
| DR-236 | Hardware-decode policy is decided from what mpv reports it **selected** (`hwdec-current`), never from what it was asked for. The spike established that hardware decode works through the render API at all — the load-bearing result, since it means direct play is not bought with software decoding — but also that `auto` reached for the discrete GPU in copy-back mode on a hybrid Intel+NVIDIA laptop, the least efficient hardware path, and that `vaapi` fell back to software silently because the libva driver was absent. So zero-copy VA-API on the integrated GPU is preferred where the driver is present, `auto` is a fallback rather than the default, and a missing driver is detected and logged rather than mistaken for a compositing limit | Playback | UR-080 | Proposed |
| DR-237 | Windows reaches the same mpv path, reusing everything except the surface. The surface is genuinely different code — a native child window beneath a transparent WebView2, not GTK — but the render context, lifetime discipline, frame pacing, device profile and hwdec policy are shared, which is why none of them may be guarded on `cfg!(target_os = "linux")`. The cost is mostly build, not video: `libmpv` is currently a Linux-only dependency while Windows is cross-compiled from Linux via `x86_64-pc-windows-msvc` + `cargo-xwin`, so a Windows libmpv must reach that cross-build and its DLL must ship in the NSIS bundle, carrying the LGPL obligations DR-216 already records — dynamic linkage, licence text shipped alongside. Windows gains a native audio decoder as a side effect, which is what the long-blocked Windows audio work wants and cannot otherwise have | Playback | UR-080 | Proposed |
| DR-237 | Windows reaches the same mpv path, reusing everything except the surface. The surface is genuinely different code — a native child window beneath a transparent WebView2, not GTK — but the render context, lifetime discipline, frame pacing, device profile and hwdec policy are shared, which is why none of them may be guarded on `cfg!(target_os = "linux")`. The cost is mostly build, not video: `libmpv` is currently a Linux-only dependency while Windows is cross-compiled from Linux via `x86_64-pc-windows-msvc` + `cargo-xwin`, so a Windows libmpv must reach that cross-build and its DLL must ship in the NSIS bundle, carrying the LGPL obligations DR-216 already records — dynamic linkage, licence text shipped alongside. Windows gains a native audio decoder as a side effect, which is what the long-blocked Windows audio work wants and cannot otherwise have | Playback | UR-080 | In Progress |
| DR-238 | A transcoded seek re-negotiates the stream on every renderer, not just the webview. Jellyfin produces a transcode *from* `StartTimeTicks`, so where a seek lands is a property of the request rather than of the stream in hand. `determine_video_seek_strategy` treated `is_hls` as a proxy for "seekable in place", which held only because hls.js was always the HLS renderer — it seeks within the VOD playlist it is handed and lets the server catch up. mpv's HLS demuxer cannot make the server transcode from a new offset, so with native video on, every transcoded seek became a backend seek that silently did nothing and presented as "resume does not work". The rule is now written on `needs_transcoding` with hls.js as the stated exception; all four webview cells are unchanged | Player | UR-040 | Done |
| DR-239 | Properties the mpv event loop handles are registered with `observe_property`. libmpv delivers `PropertyChange` only for observed properties, so a `match` arm for an unobserved one is unreachable code that reads as implemented — the handler is right there. `pause` was handled and never observed, so `StateChanged` was never emitted on pause or resume and the play/pause control never moved. It stayed invisible while Linux video played in the webview, because the `<video>` element's own DOM events drove that control; native video made the UI depend on the event that never came | Player | UR-005 | Done |
| DR-240 | Fullscreen moves whatever actually owns the pixels. `requestFullscreen()` fullscreens the *document*, which sufficed while every renderer lived inside it — the HTML5 `<video>` element is part of the document, so WebKit scaled it and the OS window's real size never mattered. A native surface is drawn behind the webview at **window** size, so a document-only fullscreen expands the page and leaves the picture where it was; on WebKitGTK the result is a maximised window with decorations still holding a strip of the screen, which reads as "fullscreen is broken" rather than as a windowing problem. Android needed the same rule for the system bars (DR-157); this is its desktop half | Player | UR-066 | Done |
@@ -474,7 +477,30 @@ Internal architecture, components, and application logic.
| DR-274 | Startup shows the picker only when the last-used profile has a PIN, or when more than one profile exists and the setting asks for it; otherwise it resumes exactly as before. The feature is invisible to a single-account install, which is what makes it safe to ship without a migration anyone has to think about | Auth | UR-082 | Proposed |
| DR-275 | Idle re-lock separates "the UI is locked" from "who is the active profile", so audio keeps playing and keeps reporting as the account that started it while the screen is locked. Lockscreen transport controls keep working untouched, because nothing on a lockscreen browses or starts new content — the locked UI refuses only what reaches past the current queue. The timer starts when playback stops rather than when the UI goes quiet, and unlocking to a *different* profile stops playback. It lives in Rust beside the player state machine: it needs authoritative playback state, and a frontend timer dies with the WebView on Android | Player | UR-083 | Proposed |
| DR-276 | The picker and PIN pad render an opaque `unlock_method` and an `UnlockOutcome` union the backend returns; the frontend never compares a PIN, counts an attempt, or infers that an account without a PIN is a child's. "Child account" is not modelled at all — a child profile is simply one with no PIN — so no role taxonomy is invented on either side of a boundary that has leaked taxonomy before | Frontend | UR-082, UR-083 | Proposed |
| DR-277 | A library listing is scoped to that library. The cached-browse query matched a library parent with an `EXISTS` that never referenced the item — it asked only whether a library with the requested id existed — so the clause was true for every cached row on the server. Music, Movies and TV concealed it because their landing pages pass `include_item_types`, which narrowed the result; the generic library page passes none, so opening Books, Photos, Collections or a mixed library served whatever happened to be cached. The stored `library_id` now decides wherever the cache kept one, because that is the server's own answer and the only thing able to scope a library whose type has no mapping or none at all; the `collection_type` ↔ `item_type` taxonomy is the fallback for rows written before it was stored, and a library with neither matches nothing and falls through to the server. The taxonomy itself is now a single macro shared with the downloaded listing, which had the identical defect fixed in isolation (DR-167) while this path kept it | Repository | UR-007 | Done |
| DR-278 | Cached items record the library they came from. `save_to_cache` bound `library_id` NULL on every row it wrote, so the only association available was the `collection_type` ↔ `item_type` taxonomy — which cannot distinguish two libraries of the *same* type (a server with "TV" and "Shows" served both the same contents) and says nothing about a library whose type it does not map. The write path is the single choke point every cached row passes through and it already knows the parent being browsed, so it resolves the owning library once per call: the parent itself when it is a library, otherwise the library its parent item was already filed under, which propagates the association down a hierarchy as it is browsed. Synthetic parents such as `favorites` match neither and stay NULL, since they are not a library and span several. Existing rows cannot be repaired locally — the association was never stored — so migration 025 clears `synced_at` to force a re-fetch, the same move MIGRATION_018 made for `is_folder`; the taxonomy fallback stays for one release while caches refill | Repository | UR-007 | Done |
| DR-279 | Endpoints live in one route table, not 57 inline `format!` literals with their query strings baked in at the point of use. `repository/endpoints.rs` holds roughly thirty functions, each taking `&ServerCapabilities` and returning a path; `online.rs` keeps the three helpers every request already funnels through (`get_json`, `post_json`, `post_json_response`), so the interception point is 32 call sites rather than 57 literals. Behaviour-preserving on its own and a precondition for everything else: without it, a second route shape is 57 conditionals | Repository | UR-085 | Proposed |
| DR-280 | `ServerCapabilities` is resolved once at connect and hung on `OnlineRepository`, with the version → flags mapping in exactly one function and no version comparison anywhere else. `OfflineRepository` has no server and no capabilities; `HybridRepository` delegates. No `MediaRepository` method signature changes, so nothing above `repository/` learns that server generations exist | Repository | UR-085 | Proposed |
| DR-281 | The online repository is testable against a response, not a URL string. `src-tauri/` contains no HTTP mocking of any kind — every existing test of the 4,797-line adapter asserts on a constructed URL — so there is currently no mechanism by which "works against both server generations" could be demonstrated. A mock HTTP server plus one recorded fixture set per generation makes the repository suite parameterisable over them. This is the largest item in the version work and is worth doing on its own merits: an adapter that size with no response-level tests is under-covered whatever it talks to | Testing | UR-085 | Proposed |
| DR-282 | The legacy user-scoped routes become capability-selected rather than assumed. Roughly twelve sites use `/Users/{uid}/Items`, `/Users/{uid}/Items/Resume`, `/Users/{uid}/Views`, `/Users/{uid}/FavoriteItems/{id}` and `/Users/{uid}/PlayedItems/{id}` — precisely the family upstream has been moving away from in favour of `/Items?userId=`. Whichever release drops them takes the app with it, and the change is wide but mechanical once the route table exists | Repository | UR-085 | Proposed |
| DR-283 | The device-profile and `PlaybackInfo` overrides fire only on the server generation they were written for. They are unconditional today and documented as version-specific in the same breath — "the override that exists because Jellyfin 10.11.5 ignores…" — so each is correct for one server and wrong for another with nowhere to say which. Gating them is where the versions differ semantically rather than structurally, which is why it needs per-generation tests and not a compile-time switch | Playback | UR-085 | Proposed |
| DR-284 | Cached rows record the server generation that wrote them, and a change invalidates by clearing `synced_at`. The cache is version-blind today: a server upgraded underneath the app keeps serving rows parsed under the previous generation's assumptions, and existing rows cannot be repaired locally because the association was never stored. This is the move MIGRATION_018 and migration 025 already make, for the same reason | Storage | UR-085 | Proposed |
| DR-285 | Image URLs are built in Rust. `imageCache.ts` constructs `${serverUrl}/Items/${itemId}/Images/${imageType}` in the frontend — a Jellyfin route, therefore something that changes when Jellyfin's API changes, which is the project's own litmus test for domain logic. It is the last such leak, `check:boundary` does not catch it (the tripwire flags item-type array literals, not route strings), and this is the feature that turns it from misplaced into actively wrong | Frontend | UR-012, UR-085 | Proposed |
| DR-286 | An unrecognised server version resolves forward to the newest known capability set and is recorded, rather than rejected: a server merely newer than the release should keep working. Rejection is reserved for a version below the supported floor, where failure is certain rather than likely, and it crosses the IPC boundary as an opaque state — the frontend renders it and never receives a version number to compare, for the same reason it never receives an item-type list | Repository | UR-085 | Proposed |
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
| DR-287 | Authentication uses only the spellings Jellyfin 12.0 leaves enabled. 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 upgraded servers too — so a client using them stops working against an upgraded server rather than degrading. This is not a version branch: `Authorization` with the `MediaBrowser` scheme, and `ApiKey` as a query parameter, are ungated on *both* generations, and the header value this app already built was always the correct one. So the fix is a rename at 21 header sites and 28 query sites, not a capability flag. The query-parameter spelling is load-bearing rather than cosmetic: stream URLs are handed to mpv, ExoPlayer and the webview's `<video>`, none of which can set a header, so `ApiKey` is the only way a player authenticates at all. A structural test refuses any deprecated spelling reaching a request builder, because the failure is silent until a server upgrades | Security | UR-085 | Proposed |
| DR-288 | A type-filtered listing states `Recursive` explicitly. Jellyfin 12.0 defaults it to true when the parent is a library folder and `IncludeItemTypes` is set, where 10.11 returned immediate children — the identical request, a different result set, with nothing in the response to say which rule applied. Sending the value the client actually wants makes both generations agree, and the value sent is the one that shipped rather than the new server-side default, so this is a compatibility fix and not a silent behaviour change | Repository | UR-085 | Proposed |
| DR-289 | The download worker's HTTP client carries a **read** timeout and a connect timeout, never a total request timeout. reqwest's `Client::timeout` is a deadline that runs until the body has finished, and it was set to five minutes: every transfer longer than that 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 read timeout resets on every chunk, so it still catches a dead connection without capping how long a healthy transfer may run | Downloads | UR-071 | Done |
| DR-290 | A download whose response states no length still reports progress against a predicted total. A transcode is produced as it is sent — chunked, 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, which 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 exactly the source when nothing is re-encoded) and its runtime (a preset re-encodes at fixed rates, so the size is rate × runtime, from the same preset table the URL is built from 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 carries the bytes actually written so neither side persists the prediction as the real size. With no prediction the bar is indeterminate, which is honest and was the status quo. The single-video button joins the series/season buttons on the enqueue path so all three resolve — and predict — in one place | Downloads | UR-071 | Done |
| DR-291 | The offline banner stays off the full-screen player. Every other shell rule in `layoutShell.ts` already treats `/player/*` as immersive; the amber "You're offline" strip was the one piece of chrome still rendered above it. On the native Android video path that is not cosmetic: VideoPlayer makes itself transparent so the ExoPlayer SurfaceView behind the WebView is visible (DR-185), so a shell child that still paints shows *through* the picture as a stripe across the top of the film. Offline is also precisely when a downloaded video plays, so the banner appeared when it was most in the way, and it offers the viewer nothing to act on — local playback needs no server. The rule moves into the pure module as `showOfflineBanner({ pathname, isAuthenticated, isConnected })` rather than staying an inline `{#if}` in the shell, so the immersive-route contract is stated in one tested place | UI | UR-003, UR-043 | Done |
| DR-292 | The offline catalog reveal is one rule, applied by both library views. Two defects, one cause — "server only" was a private `$derived` inside `MediaCard`. (1) The list view (`LibraryListView`, what `LibraryGrid` renders when the stored view preference is `list`) had no notion of it at all, so a library browsed as a list offline showed every revealed item as an ordinary tappable row that plays nothing, with no way to queue it. (2) The rule asked the downloads store whether *this item id* was downloaded, but only a playable leaf (Audio, Movie, Episode) ever has a download row — an album's tracks carry them, the album does not — so a fully downloaded album greyed itself out and offered to queue what was already on the device, which is what "my downloaded music is greyed out" was. The rule moves to the pure `$lib/utils/serverOnly`, both views call it, and the container half is answered by the backend: `get_download_disk_usage().sizes` already carries container subtotals beside leaf sizes (DR-085), so `deviceContentIds` is membership in a Rust-computed map rather than a frontend guess at which item types are containers. That map was loaded only by the Downloads page, so the shell now primes it at startup and re-reads it whenever the offline gate settles (the DR-143 signal). Queueing is shared too (`queueOfflineDownload`), since the list view had no copy to diverge from | UI | UR-052, UR-055 | Done |
| DR-293 | Android plays the original file: ExoPlayer decodes AC-3, E-AC-3, DTS and TrueHD in software through the FFmpeg extension, so neither a download nor a stream needs the server to re-encode its audio. These are licensed codecs that Android does not ship — the ROD2-W09 tablet has a vendor DTS decoder and no AC-3/E-AC-3 at all — so the download policy (DR-171) judged audio against the webview's list and turned most films into a server transcode: generated as it is sent, no `Content-Length`, `Range` ignored, measured at ~1 MB/s and restarting from zero on every network blip, against a direct copy that moved a 910 MB episode in 94 s. The renderer is `DefaultRenderersFactory` in `EXTENSION_RENDERER_MODE_ON` (platform decoders first, FFmpeg for what they lack), and `CodecDetector` reports the extension's codecs beside `MediaCodecList`'s, so the device profile and the download policy — now `renderer_can_decode_audio`, DR-234's per-platform answer, instead of the webview's list — agree with what actually decodes. The webview video path is gone on Android: it decodes none of those codecs, so an original-file download would play there as a silent film; `webview_video_fallback` (Rust) is false on Android and the frontend neither offers the switch nor honours a stored "off". Linux keeps the webview fallback beside mpv native video, and with it the server transcode for undecodable audio. Rejected: re-encoding audio on the device after download — minutes of CPU and twice the disk per film, and it would not have helped streaming. The decoder is Jellyfin's `media3-ffmpeg-decoder` build (GPL-3.0; the distributed APK carries its terms, the source stays MIT) and must be versioned in step with media3 | Playback | UR-004, UR-071 | Done |
| DR-294 | A download plays with no network. Playing a downloaded item asked the server for its `PlaybackInfo` — only to read the media-source id that subtitle URLs are keyed by — and `HybridRepository::get_playback_info` went to the server alone, so offline the call retried for seven seconds, failed, and the file on disk was never opened. A completed download for the current user now answers playback info from its download row, first and regardless of reachability: the local path, direct play, and the item id as media-source id (a download names no source, so the server served its default, which carries the item's id). Next Up had the same shape — server-only — and the TV landing page loads it in one `Promise.all` with its other rows, so offline that single failure blanked the whole page with Continue Watching and Latest sitting in the cache; it now falls back to the cache when the server cannot answer. And a slow cache read is waited for, never discarded: 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 offline returned its error over data on disk — "More info" on a downloaded show failed exactly so. They keep the read running (`cache_try`) and wait for it when the server fails (`settle`); the cache-only reads (search, favourites) simply await the cache | Repository | UR-002, UR-071 | Done |
| DR-295 | A series page lists its episodes with one concurrent season fan-out. "More info" on Frasier took ~10 s: the page asked Rust for the episodes and for the current episode as two commands, each of which 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 slowed by whatever the database was writing (the catalog sync at launch measured it at ~4 s per walk). The seasons are now fetched together (`gather_season_episodes`, so the wait is the slowest season), and `repository_get_series_view` returns the episodes and the current episode from one walk, with Next Up and resume fetched alongside it | Repository | UR-062 | Done |
| DR-296 | Returning from background audio resumes the item the native player is actually on, not the one the video page was mounted with. An episode that ends while backgrounded advances in the backend (`advance_to_next_episode_audio_only`), but `player_exit_background_audio` returned only a position, so the webview reloaded the *previous* episode at the new episode's timestamp. The command now returns `BackgroundAudioResume { itemId, positionSeconds }` (`PlayerController::background_audio_resume`); `planHandoffReturn` yields `other-item` when the id differs from the mounted one, and the player page navigates to that episode with `resumeAt=<seconds>`, recording the outgoing episode as watched and suppressing the stale unmount stop report | Playback | UR-040, UR-023 | Done (pending device verification) |
| DR-297 | A background refresh never blanks a library detail page that is on screen, and every load that succeeds clears the page's error. Resuming the app after a few minutes in the background replaced the Frasier series page with "Failed to load item": the resume reload (reconnect / offline-filter change) is a refresh of content the cache had already answered, but any throw in it replaced the page with an error that no later successful reload of the same item cleared — and the catch logged nothing and turned backend errors (plain strings) into the generic text. A failed refresh now keeps the page and logs the thrown value; only failing to open an item shows an error, with the backend's own message | UI | UR-062 | Done |
| DR-298 | mpv receives commands as an argument vector, never as a command string. The pinned `libmpv` crate's `Mpv::command` joins its arguments with spaces and calls `mpv_command_string`, which parses input.conf syntax — whitespace splits, `;` chains a second command, `#` comments out the rest — so a stream URL carrying a server-controlled item id or `TranscodingUrl`, or a download's `file://` path built from its track title, could run any mpv command, `run` included: a crafted title tag was arbitrary code execution on the Linux desktop. The same parse broke every downloaded title containing a space, `Song.mp3` landing in `loadfile`'s flags slot. Every call goes through `mpv_command::command`, which builds a NUL-terminated `argv` for `mpv_command` so each argument reaches mpv as one opaque string, and refuses an argument containing NUL rather than loading a truncated URL | Playback | UR-003, UR-004 | Done |
| DR-299 | Every libmpv handle verifies TLS and never hands a URL to youtube-dl. mpv's `tls-verify` defaults to *no*, and the stream URLs it loads carry the account's `ApiKey`, so anyone able to present a certificate for the server's host — a hostile network, a spoofed DNS answer — read a long-lived token from the one HTTP path in the app that did not check. libmpv also loads its ytdl hook by default, which passes a URL that failed to open, token included, to an external `yt-dlp`. `mpv_command::harden` sets `tls-verify=yes` and `ytdl=no` on each handle before its first `loadfile`, and a handle that cannot be hardened fails construction instead of playing unverified | Playback | UR-012 | Done |
---
@@ -485,17 +511,17 @@ Internal architecture, components, and application logic.
| User Req | Integration Requirements | Development Requirements |
|----------|-------------------------|-------------------------|
| UR-001 | IR-001, IR-002 | - |
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014 |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203, DR-265 |
| UR-002 | IR-013 | DR-003, DR-012, DR-013, DR-014, DR-294 |
| UR-003 | IR-003, IR-004, IR-011 | DR-002, DR-004, DR-010, DR-182, DR-183, DR-184, DR-185, DR-186, DR-187, DR-188, DR-190, DR-191, DR-192, DR-193, DR-194, DR-195, DR-196, DR-291, DR-298 |
| UR-004 | IR-003, IR-004, IR-008, IR-011 | DR-002, DR-004, DR-006, DR-129, DR-171, DR-176, DR-177, DR-181, DR-182, DR-183, DR-185, DR-188, DR-203, DR-265, DR-293, DR-298 |
| UR-005 | - | DR-001, DR-005, DR-009, DR-178, DR-179, DR-186, DR-193, DR-195 |
| UR-006 | IR-005, IR-006, IR-007, IR-008 | DR-200, DR-201 |
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262 |
| UR-007 | IR-010 | DR-007, DR-008, DR-016, DR-257, DR-262, DR-277, DR-278 |
| UR-008 | IR-010 | DR-007, DR-011 |
| UR-009 | IR-009, IR-010, IR-011 | - |
| UR-010 | IR-012, IR-021 | DR-037, DR-059 |
| UR-011 | IR-013 | DR-003, DR-015, DR-018 |
| UR-012 | IR-009, IR-014 | DR-198 |
| UR-012 | IR-009, IR-014 | DR-198, DR-299 |
| UR-013 | IR-013 | DR-017 |
| UR-014 | IR-010 | DR-014, DR-019 |
| UR-015 | - | DR-005, DR-020 |
@@ -506,7 +532,7 @@ Internal architecture, components, and application logic.
| UR-020 | IR-016, IR-018 | DR-023, DR-176 | <!-- IR-018 delivered by ExoPlayer + HTML5 `<track>`, not libmpv -->
| UR-021 | IR-016, IR-019 | DR-024 | <!-- IR-019 delivered by ExoPlayer + HLS stream re-open, not libmpv -->
| UR-022 | IR-017 | DR-025 |
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049, DR-263 |
| UR-023 | IR-010 | DR-026, DR-047, DR-048, DR-049, DR-263, DR-296 |
| UR-024 | IR-010 | DR-027 |
| UR-025 | IR-015 | DR-028, DR-131, DR-132, DR-178, DR-179 |
| UR-026 | - | DR-029, DR-048, DR-050 |
@@ -523,10 +549,10 @@ Internal architecture, components, and application logic.
| UR-037 | IR-010 | DR-042 |
| UR-038 | IR-010 | DR-043 |
| UR-039 | - | DR-045, DR-046 |
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203, DR-263, DR-266 |
| UR-040 | IR-025 | DR-051, DR-052, DR-129, DR-130, DR-159, DR-178, DR-179, DR-180, DR-183, DR-190, DR-196, DR-201, DR-203, DR-263, DR-266, DR-296 |
| UR-041 | IR-026 | DR-053, DR-160, DR-161, DR-172, DR-182, DR-183, DR-184, DR-185, DR-188, DR-265, DR-266 |
| UR-042 | IR-009, IR-014 | DR-054 |
| UR-043 | IR-027 | DR-055 |
| UR-043 | IR-027 | DR-055, DR-291 |
| UR-044 | - | DR-056 |
| UR-045 | - | DR-057 |
| UR-046 | IR-028 | DR-058 |
@@ -535,16 +561,16 @@ Internal architecture, components, and application logic.
| UR-049 | IR-010 | DR-063, DR-064, DR-065, DR-147 |
| UR-050 | - | DR-066, DR-067 |
| UR-051 | - | DR-068, DR-069, DR-070 |
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143 |
| UR-052 | IR-027 | DR-078, DR-079, DR-080, DR-143, DR-292 |
| UR-053 | IR-029 | DR-074 |
| UR-054 | - | DR-075, DR-076, DR-077, DR-147 |
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173 |
| UR-055 | - | DR-081, DR-082, DR-083, DR-084, DR-167, DR-168, DR-169, DR-173, DR-292 |
| UR-056 | - | DR-085 |
| UR-057 | - | DR-086 |
| UR-058 | - | DR-087, DR-142 |
| UR-060 | - | DR-090, DR-091, DR-111 |
| UR-061 | - | DR-092 |
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107 |
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107, DR-295, DR-297 |
| UR-063 | - | DR-105 |
| UR-064 | - | DR-106 |
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
@@ -553,7 +579,7 @@ Internal architecture, components, and application logic.
| UR-068 | - | DR-119 |
| UR-069 | - | DR-113, DR-114, DR-120 |
| UR-070 | - | DR-121, DR-122 |
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171, DR-180, DR-198, DR-199 |
| UR-071 | IR-032 | DR-123, DR-124, DR-125, DR-126, DR-127, DR-128, DR-133, DR-134, DR-135, DR-136, DR-137, DR-138, DR-170, DR-171, DR-180, DR-198, DR-199, DR-289, DR-290, DR-293, DR-294 |
| UR-072 | - | DR-156 |
| UR-073 | - | DR-158 |
| UR-074 | - | DR-162, DR-177, DR-181 |
@@ -566,6 +592,7 @@ Internal architecture, components, and application logic.
| UR-082 | IR-034 | DR-267, DR-270, DR-271, DR-272, DR-273, DR-274, DR-276 |
| UR-083 | - | DR-268, DR-275, DR-276 |
| UR-084 | - | DR-269 |
| UR-085 | IR-035 | DR-279, DR-280, DR-281, DR-282, DR-283, DR-284, DR-285, DR-286, DR-287, DR-288 |
---
@@ -712,7 +739,7 @@ Internal architecture, components, and application logic.
| UT-140 | `useOfflineFilterReload` skips the value a page already loaded under and reloads on each later change | DR-143 | Done |
| UT-141 | The advertised channel cap: an unknown or zero reading falls back to stereo, a real route keeps its channels, an absurd driver reading is capped at 7.1, and mono is taken at its word | DR-141 | Done |
| UT-148 | Forcing a transcode from the client: an undecodable default track forces one, a decodable track does not, the default track decides rather than the first, the first decides when nothing is marked default, and neither an audio-less source nor an unnamed codec is second-guessed | DR-149 | Done |
| UT-149 | `createAdapter` returns the native adapter only when Rust reports native AND `experimentalNativeVideo` is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 | DR-150 | Done |
| UT-149 | `createAdapter` returns the native adapter only when Rust reports native AND `experimentalNativeVideo` is on; the flag off forces HTML5 even when Rust says native, and the flag on never promotes a platform Rust reported as HTML5 | DR-150 | Superseded by DR-235 |
| UT-150 | `set-version.sh` stamps all four manifests without touching dependency versions, and the Android versionCode is monotonic across an upgrade sequence, clears the 1000 floor, and survives a prerelease suffix | DR-153 | Done |
| UT-151 | An unreportable stop lands in the queue and is pushed by the existing drain; re-queueing the same item supersedes the earlier position rather than adding a row, distinct items keep their own positions, and an abandoned row is not revived by a later report | DR-154 | Done |
| UT-152 | Caching a server result mirrors its watch position locally — including for an item carrying a position but no favourite flag — without inventing a row for an item the server reported no user data for, and without pulling a still-unsynced local position backwards | DR-155 | Done |
@@ -746,7 +773,7 @@ Internal architecture, components, and application logic.
| UT-153 | Scroll handling per navigation kind: a forward move always lands at the top even when the previous page was scrolled and even when the target was visited before, Back restores that route's own saved offset (and the top when it has none), offsets are kept per route rather than shared, a repeated Back still restores, and the initial load leaves the container alone | DR-156 | Done |
| UT-142 | The audio codecs offered for video direct play: a Dolby device's real `MediaCodecList` output drops `ac3`/`eac3`, AMR and raw PCM are dropped too, a fully-supported list is passed through untouched, a list with nothing decodable still claims `aac`, and stray spacing or casing does not decide whether the user gets sound | DR-148 | Done |
| UT-143 | Subtitle URLs resolve to plain strings before they reach the markup (never a Promise), unresolvable tracks are dropped, a stale selection collapses to "Off", and a server-default track is never auto-selected | UR-020, DR-023 | Done |
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Done |
| UT-144 | VideoPlayer actually renders `<track kind="subtitles">` children carrying `data-stream-index`, with no `default` attribute and no async `getSubtitleUrl()` bound to `src` | UR-020, DR-023 | Superseded by DR-235 |
| UT-145 | The frontend's subtitle payload survives the IPC hop: a camelCase `PlayItemRequest` carrying `subtitles` deserializes, `create_media_item` lands them on `MediaItem.subtitles` in the order sent, and a request without the field still defaults to empty | UR-020, IR-016 | Done |
| UT-146 | The subtitle JSON serialized across the JNI boundary uses the keys `JellyTauPlayer.load()` reads — `url`, `language`, `label` and `mime_type`, never `mimeType` | UR-020, IR-016, JA-008 | Done |
| UT-147 | The native subtitle payload and the track-selection index come from the same resolved list: the wire shape keeps `mime_type` and stream order, `playerPlayItem` actually sends it, and the index is a position in the sent list (so a track whose URL failed to resolve cannot shift the others) rather than the menu's row number | UR-020, IR-016 | Done |
@@ -754,7 +781,7 @@ Internal architecture, components, and application logic.
| UT-183 | A reloaded stream is resumed by seeking the element to the absolute position with the transcode offset cleared to zero — never by carrying the position as an offset base, which since DR-181 would display the position while playing the item from its start — and a reload to 0:00 waits for no seek | DR-181 | Done |
| UT-184 | The native reveal rule fires on `state === "playing"` and on a position tick carrying a position or a duration, and on nothing else — not `buffering`, `paused`, `stopped`, `ended` or `error`, not an empty tick, and not a negative position | DR-182 | Done |
| UT-188 | The control-bar auto-hide rule permits hiding only during uninterrupted playback: it declines while paused, while a seek is in flight, and while a track/subtitle/quality menu is open — asserted against the pure `shouldHideControls` rule rather than a clock or a DOM | DR-189 | Done |
| UT-189 | On the native path the player never calls `player_report_state` — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothing | DR-195 | Done |
| UT-189 | On the native path the player never calls `player_report_state` — driven through the real 10-second progress interval under fake timers, which is the call site that mattered; asserting on a freshly mounted player passes with the guard deleted and guards nothing | DR-195 | Superseded by DR-235 |
| UT-187 | On the native path the play overlay follows the backend: it clears when the backend resumes after a pause and is raised again when the backend pauses, and the system bars are hidden on player entry rather than only by the fullscreen button | DR-186, DR-187 | Done |
| UT-186 | Every attribute the native-video compositing block in app.css targets is set somewhere in the app — `[data-app-shell]` in particular — so a selector aimed at nothing fails the suite instead of failing silently on a device | DR-185 | Done |
| UT-185 | Mounted on the native path (backend reports native, opt-in flag on, no `<video>` element rendered and the backend not stopped), VideoPlayer keeps the poster card up until the backend reports something, drops it on a playing state or a position tick with a duration, and keeps it up through `error` and `stopped` | DR-182 | Done |
@@ -782,9 +809,9 @@ Internal architecture, components, and application logic.
| UT-211 | The background decision: a video with the toggle off pauses (the reported defect, where the media service kept playing regardless), a video with it on hands off to audio, music keeps playing whatever the toggle says because it has no picture to lose, picture-in-picture keeps playing in every combination since the window is still visible, and the answer does not vary by renderer | DR-224 | Done |
| UT-212 | The stream-selection contract. `Transport` and `PlaybackKind` each serialise to exactly the tag the frontend matches (`{"type":"hls"}`, `{"type":"directPlay"}`, …) and round-trip; nested `StreamSelection` fields are camelCase on the wire including `playbackKind`, `mediaSourceId` and `maxBitrate`; only `Transcode` counts as transcoding, so a direct stream does not; a local file is a direct play over a local transport with no ladder. The ladder: every rung at or above a 1.12 Mbps source is marked redundant while the three that constrain it are not, `Original` is never marked for any bitrate including zero and unknown, an unreported source bitrate keeps all eight rungs offered, a 40 Mbps source marks none, and each option carries the ladder's own label and detail | DR-224, DR-226 | Done |
| UT-213 | The direct-play negotiation, one test per branch, against `PlaybackInfo` fixtures whose shapes were all observed on a live server: a supported source direct-plays; a remuxable one direct-streams and reports itself as *not* transcoding; an unsupported codec transcodes; undecodable audio overrides the server's direct-play offer (silent picture is worse than a transcode); a pinned audio track forces a transcode; a ceiling below the source bitrate transcodes even though the codec is fine, and the ladder agrees that rung constrains it; direct play wins over direct stream when both are offered. Plus the ceiling: a per-playback override governs the stream being opened without disturbing the durable default the Settings screen shows, and dropping it returns to that default | DR-225, DR-227 | Done |
| UT-214 | The loader comes from the transport, never the URL. hls.js is attached for `hls` when available and the element's own loader when not; progressive and local files load directly; the element's `src` is emptied only when hls.js drives it. The two cases that fail against a substring check, and the reason the field exists: a `progressive` stream whose URL contains `.m3u8` is *not* given an HLS loader, and an `hls` stream whose URL contains no `.m3u8` *is*. Both failed against the pre-DR-225 implementation before the fix landed | DR-224 | Done |
| UT-214 | The loader comes from the transport, never the URL. hls.js is attached for `hls` when available and the element's own loader when not; progressive and local files load directly; the element's `src` is emptied only when hls.js drives it. The two cases that fail against a substring check, and the reason the field exists: a `progressive` stream whose URL contains `.m3u8` is *not* given an HLS loader, and an `hls` stream whose URL contains no `.m3u8` *is*. Both failed against the pre-DR-225 implementation before the fix landed | DR-224 | Superseded by DR-235 |
| UT-215 | Waiting for the repository rather than racing it: it resolves immediately when the session is already restored, resolves when the session arrives later (the race the player page lost on mount), still rejects when there genuinely is no session, unsubscribes once settled so a later store change cannot re-settle it, and leaves no armed timer to reject an already-resolved promise | DR-013 | Done |
| UT-216 | The native-video opt-in is read from one place and only explicit truthy values enable it: absent, empty, `0`, `no`, `false` and anything unrecognised all mean off, because a half-set variable that half-enabled the renderer would configure mpv for video with nothing drawing it — audio over a black rectangle | DR-231 | Done |
| UT-216 | The native-video opt-in is read from one place and only explicit truthy values enable it: absent, empty, `0`, `no`, `false` and anything unrecognised all mean off, because a half-set variable that half-enabled the renderer would configure mpv for video with nothing drawing it — audio over a black rectangle | DR-231 | Superseded by UT-271 |
| UT-217 | A transcoded HLS stream on the native backend re-negotiates rather than seeking in place, while the same stream under hls.js still seeks in place — the cell that native video made reachable for the first time | DR-238 | Done |
| UT-218 | Every property name matched by the mpv event loop also appears in an `observe_property` call, asserted against the source because the registration cannot be observed at runtime without a live mpv | DR-239 | Done |
| UT-219 | A fullscreen toggle moves the document only when an in-document `<video>` renders, and moves the OS window as well when a native surface does | DR-240 | Done |
@@ -816,6 +843,34 @@ Internal architecture, components, and application logic.
| UT-245 | A `timeupdate` is applied while the video is playing — the case that froze the position behind a PiP window — and still yields to an in-flight seek, a seek-bar drag, and an element with no current data | DR-265 | Done |
| UT-246 | Opening a PiP window disarms background audio, and a background signal arriving with the native PiP flag false is still treated as PiP while the frontend's latch says the window is open — without resurrecting one it has already seen close | DR-266 | Done |
| UT-247 | A library whose `collection_type` has no mapping — Books, Photos, a mixed library — does not return the server's films, albums and shows from cache | DR-277 | Done |
| UT-248 | Narrowing the library clause does not starve the libraries that do have landing pages: music, movies and TV each still list their own media and none of the others | DR-277 | Done |
| UT-249 | Opening an individual collection still lists its own children: a BoxSet's members are matched by the stored `parent_id`, not by the library clause, so narrowing that clause did not empty collections | DR-277 | Done |
| UT-250 | Two libraries of the same collection type are not interchangeable: seeded through the real cache write path, a "TV" and a "Shows" library each list their own series and not the other's | DR-278 | Done |
| UT-251 | A download that runs longer than the stall timeout completes as long as bytes keep arriving, and one whose connection goes silent is given up on promptly as a network error — driven through the real `reqwest` client against a loopback socket, because the defect was the client's configuration | DR-289 | Done |
| UT-252 | A predicted total fills in only when the server sent no length, the server's length always wins, an estimated fraction is capped below 1.0, and the prediction is rate × runtime for a preset and the source's size for `original` | DR-290 | Done |
| UT-253 | Resolving a queued video row persists its predicted size, and resolving an audio row (no prediction) leaves a size the row already holds untouched | DR-290 | Done |
| UT-254 | The progress row renders an unknown total as indeterminate rather than "0%", an estimated total as "~N%", an exact one plainly; the store carries the estimate flag through progress and persists the worker's byte count, never the prediction, on completion | DR-290 | Done |
| UT-255 | The offline banner shows while offline on ordinary routes and never on `/player/*`, and stays off while connected or signed out | DR-291 | Done |
| UT-257 | The server-only rule: true only offline with the reveal on and nothing on the device; never for a library tile; and not for a container whose children are downloaded (the greyed-album regression) | DR-292 | Done |
| UT-258 | The list view greys a server-only row, makes it inert to tap, offers the queue button (and the Queued badge once pending), and leaves downloaded rows and containers with device content alone | DR-292 | Done |
| UT-259 | The user may send video to the webview only beside mpv native video on Linux: never on Android, where ExoPlayer is the only video renderer, and not where the webview is the only renderer | DR-293 | Superseded by UT-272 |
| UT-260 | A downloaded item gets playback info with the server unreachable — immediately, from its download row (local path, direct play, item id as media source) — while an unfinished download, another user's, or an item never downloaded is left to the server | DR-294 | Done |
| UT-261 | Next Up answers from the cache, rather than failing, when the server is unreachable | DR-294 | Done |
| UT-262 | The Android webview fallback is neither offered in Settings nor honoured by the player unless Rust reports it, so a stored "native video off" cannot route video to a renderer that plays the original file silent | DR-293 | Superseded by UT-272 |
| UT-263 | With the database held past the 100 ms fast path and the server unreachable, `get_items`, the library list, a cache-only search and cache-only favourites all answer from the cache instead of failing | DR-294 | Done |
| UT-264 | Ten seasons whose listings each take 100 ms are gathered in well under the 1 s a sequential walk takes, and a season that fails to load leaves the other nine seasons' episodes in the result | DR-295 | Done |
| UT-265 | `planHandoffReturn` switches to the item the backend advanced to while backgrounded, and reloads in place when the backend is still on the mounted item or reports none | DR-296 | Done |
| UT-266 | After a background-audio episode advance, the controller's resume point names the new episode and carries no base from the previous one | DR-296 | Done |
| UT-267 | A failed refresh of a detail page already on screen shows no error, a successful load clears any error, a failure to open an item shows its message, and a backend error's plain-string text is shown rather than a generic fallback | DR-297 | Done |
| UT-268 | Against a real libmpv, a URL containing `;set volume 13;#` is loaded as one URL and the volume is unchanged, and an argument containing a space stays one argument | DR-298 | Done |
| UT-269 | Neither mpv player calls the string-joining `Mpv::command`; every command goes through `mpv_command::command` | DR-298 | Done |
| UT-270 | A hardened handle reads back `tls-verify=yes` and `ytdl=no`, and both mpv players harden the handle they create | DR-299 | Done |
| UT-271 | Native video is on for Linux whatever `JELLYTAU_NATIVE_VIDEO` says, including unset and explicit "off" values, and off where mpv is not the video renderer | DR-235 | Done |
| UT-272 | No platform reports a webview video fallback, Linux reports native video, and the player status on Linux never sends video to the `<video>` element | DR-235 | Done |
| UT-273 | `player_play_item`, `get_player_status` and `player_get_capabilities` answer "does a native renderer draw video here" from one function, so the backend is loaded with video exactly where the frontend is told not to use a `<video>` element — on Windows, where mpv now plays audio, the film's soundtrack is not decoded twice | DR-237 | Done |
| UT-274 | mpv's video output is decided per platform: Windows renders into the app window's HWND (`wid`, set before initialisation) with `vo=gpu-next,gpu` and mpv's own controller, bindings and cursor handling off; Windows with no handle draws nothing rather than opening a window of its own; Linux uses the render API; no native video means `video=no`. Every option is accepted by the real libmpv, on Linux and by the shipped Windows DLL | DR-237 | Done |
| UT-275 | mpv selects sideloaded subtitles and audio tracks by position, as ExoPlayer does: against a real file, a sideloaded WebVTT arrives, starts hidden, and is shown and hidden by its position in the sent list; position 1 plays the file's second audio track; and preparing the next load forgets the last item's subtitle files, subtitle choice and audio track. Positions resolve to mpv ids per kind, embedded before external | DR-023, DR-024, DR-235 | Done |
### Integration Tests
| Test ID | Test Description | Traces To | Status |
@@ -836,6 +891,14 @@ Internal architecture, components, and application logic.
| IT-016 | Offline library listing end-to-end: with the server unreachable, a library page lists only downloaded media with the toggle off, and additionally reveals greyed-out cached catalog entries with the toggle on | UR-052, DR-078, DR-079, DR-080 | Done |
| IT-017 | A download queued from a greyed-out offline catalog entry persists and is resolved and started on reconnect | UR-052, UR-011 | Done |
| IT-018 | The conformance cases run against ExoPlayer on a device: opening from the beginning and at a position, a seek issued while still preparing, a seek after open, pause and play observable, stop silent and idempotent, and a load cancelled by stop never playing. The fixture is a silent WAV synthesised at setup, so the repo carries no media and the duration is exact | DR-247 | Done |
| IT-019 | Every request carries `Authorization: MediaBrowser …` and no `X-Emby-Authorization`, asserted against the header a real HTTP server received, on both the 10.11.x and 12.x generations | UR-085, DR-287 | Done |
| IT-020 | A listing parses into domain items on both generations, against a real HTTP response rather than a constructed URL | UR-085, DR-281 | Done |
| IT-021 | A type-filtered listing puts `Recursive` on the wire, so 10.11 and 12.0 cannot disagree about the result set | UR-085, DR-288 | Done |
| IT-022 | The library listing resolves and parses on both generations, confirming the user-scoped route family still serves 12.0 | UR-085, DR-282 | Done |
| IT-023 | Flipping `user_scoped_item_routes` actually changes the wire request to `/Items?userId=` and the response still parses — so the alternative shape is exercised rather than being untested code awaiting a switch | UR-085, DR-282 | Done |
| IT-024 | A favourites query sends `Filters=IsFavorite` and omits the type filter under `All` scope, on both generations | UR-067, UR-085, DR-281 | Done |
| IT-025 | A player-facing stream URL carries `ApiKey=` and never `api_key=`, on both generations — the only way mpv/ExoPlayer/`<video>` can authenticate, since none can set a header | UR-004, UR-085, DR-287 | Done |
| IT-026 | Capabilities are resolved from the version the fake server actually reported, not from a value poked in by the test — which is what makes the other cross-generation assertions meaningful | UR-085, DR-280 | Done |
---
+10 -3
View File
@@ -27,15 +27,21 @@ know how something *works*, read
| Design authority | No code of its own — it records a decision later specs act on. |
**Next free requirement ids** (always re-check
[requirements.md](../requirements.md) before allocating): **UR-079**,
**IR-033**, **DR-232**. Three specs below suggested ids that have since been
taken by other work; each carries a ⚠️ note at the top.
[requirements.md](../requirements.md) before allocating): **UR-086**,
**IR-036**, **JA-038**, **DR-295**, **UT-264** (DR-289/290 went to the v0.12.2
download fixes; DR-291/292 and UT-255/257/258 to the offline-banner and
server-only-reveal work; DR-293/294 and UT-259-263 to Android's FFmpeg decoding
and offline-without-network). Three specs below suggested ids that have
since been taken by other work; each carries a ⚠️ note at the top — this line
was itself stale by five, two and forty-seven until 2026-09-08, which is why the
re-check is not optional.
## Partially implemented
| Spec | What landed | What is left |
|---|---|---|
| [frontend-domain-model.md](frontend-domain-model.md) | Catalog surface: `MediaKind`, `from_jellyfin` isolated, ticks → ms | `primaryImageTag` → `imageId` (~30 sites); player/session/reporting tick math; `stream.type` |
| [jellyfin-server-version-compatibility.md](jellyfin-server-version-compatibility.md) | Route table, `ServerCapabilities`, the auth-spelling fix (the one thing 12.0 actually breaks), explicit `Recursive`, cache generation stamping, the frontend route leak, the unsupported-server state, and an HTTP-level harness that runs the repository against both generations | DR-283: two resolved flags are not consumed yet, and `honours_directplay_audio_codec` is unestablished for 12.x — both need a running 12.x server. Nothing has been tested against a real server of either generation |
| [libmpv2-migration.md](libmpv2-migration.md) | `LICENSE` | The `libmpv` → `libmpv2` crate swap |
| [read-through-media-cache.md](read-through-media-cache.md) | DR-126…128, DR-133…138 — cache entries *are* download rows; local playback of downloads | DR-122/124/125 — the read-through capture. DR-121 shipped as backend-owned stream selection and left this spec |
| [scoped-search-boundary-implementation.md](scoped-search-boundary-implementation.md) | Stage 1: `SearchScope` owned by Rust (DR-063…067) | Stage 2: result-side grouping (`GROUP_ITEM_TYPES` still in `searchScope.ts`) |
@@ -79,4 +85,5 @@ Where to look for each:
| Video background audio | [05-platform-backends.md](../architecture/05-platform-backends.md) — Background Audio Handoff |
| Traceability gate repair | [traceability-ci.md](../traceability-ci.md) |
| Boundary tripwire hardening | `scripts/check-frontend-boundary.sh` (its header is the spec) |
| Original-file downloads & Android FFmpeg decoding (was on-device-audio-remux) | [05-platform-backends.md](../architecture/05-platform-backends.md) — Licensed audio codecs; [06-downloads-and-offline.md](../architecture/06-downloads-and-offline.md) — What a Video Download Fetches, Offline Means No Network |
| Playback docs corrections · req-coverage script removal | Nothing to document — both were corrections that have been applied |
+12 -1
View File
@@ -1,6 +1,17 @@
# Spec: Desktop native video — mpv renders the picture, everywhere
**Status:** Proposed
**Status:** Partially implemented — all three phases' *code* has shipped: mpv
is the only video renderer on Linux (render API into the GTK vbox) and Windows
(`wid` into the app window, `vo=gpu-next,gpu`), and the webview video path is
deleted — hls.js, the HTML5 adapter, the `<video>` element, the frontend switch,
the `use_html5` command parameters and the Android HTML5 PiP/screen-wake state
(DR-235). mpv selects subtitles and audio tracks itself (`mpv_tracks`).
**Left:** Linux/Windows still claim only `h264` (DR-234), so video is still a
server transcode — mpv plays it, but the direct-play gain is not taken; `hwdec`
is unset, so mpv decodes in software (DR-236); a failed surface attach only
logs; and every hardware criterion is unrecorded — the X11/Wayland check and
soak on Linux, and **any** run of Windows video, which has only been verified by
unit tests under wine (options accepted by the shipped DLL), never on screen.
**Requirements:** UR-080 (new) → DR-231 … DR-237 (new); IR-033 (new)
**UX spec:** n/a — nothing about the player's appearance changes. What changes is
what is behind the controls.
+732
View File
@@ -0,0 +1,732 @@
<!--
Companion to jellyfin-server-version-compatibility.md — the evidence base for
every decision in it. Kept in the repo because the *reasoning* is what a future
change needs: which differences were verified, which were looked for and could
NOT be established, and which URL each claim came from.
Delete this alongside the spec when the last of it ships and the design is
folded into docs/architecture/.
-->
# Jellyfin server API delta: 10.11.x → next major
Research date: **2026-09-08**. All claims verified against live sources; no claim below is
from model memory. Method: GitHub Releases/Tags API, the official release blog, the published
OpenAPI spec, and a **byte-level diff of the actual C# source trees** at tags `v10.11.5` and
`v12.0` (downloaded from `codeload.github.com`, extracted locally).
---
## Section 1 — Version reality check
### 🔴 Jellyfin 11.0 does not exist and never did.
The complete tag list of `jellyfin/jellyfin` contains **zero** `v11.*` tags. The project went
directly from the `10.11.x` branch to `12.0`.
Source: `https://api.github.com/repos/jellyfin/jellyfin/tags` (all 8 pages; 116 tags total).
Major-version histogram: `v10` × 107, `v12` × 8, `v3` × 1. `11.x tags: []`.
### What actually exists today (2026-09-08)
| Version | Status | Published | Source |
|---|---|---|---|
| **12.0** | **Current stable / `releases/latest`** | **2026-09-08T01:38:39Z** (today) | `https://api.github.com/repos/jellyfin/jellyfin/releases/latest` |
| 12.0-rc1 … rc7 | prereleases | 2026-06 → 2026-08-31 | `https://api.github.com/repos/jellyfin/jellyfin/releases` |
| 10.11.11 | last release on the 10.11 branch | 2026-06-06T16:18:54Z | `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v10.11.11` |
| 10.11.5 | **what JellyTau targets** | 2025-12-15 (file mtime in tag tarball) | `https://codeload.github.com/jellyfin/jellyfin/tar.gz/refs/tags/v10.11.5` |
| 10.11.0 | 10.11 branch opened | 2025-10-20 | `https://jellyfin.org/posts/jellyfin-release-10.11.0` |
**v12.0 was released roughly 18 hours before this research was performed.** Treat "12.0 in the
wild" as approximately zero installs today, rising over the coming months.
### Why the number jumped 10.11 → 12.0
Official rationale, quoted from the release blog:
> "The most visible change in this release is the one in its name: we are dropping the major
> version '10' from our naming scheme. What would have been 10.12.0 is simply 12.0, and the
> server reports its version as `12.0.0`. 10.11.x was the last release branch to use the old
> scheme. […] Jumping to 11.0 would still look like a minor increment […]"
> "**If you maintain anything that parses Jellyfin version strings** — a client, a monitoring
> check, a deployment script, a container tag pin — **this is the item to look at before
> upgrading.**"
Source: `https://jellyfin.org/posts/jellyfin-release-12.0` (dated September 7, 2026)
So: `12.0` *is* `10.12` under the old scheme. It is one release-branch step from 10.11, not two.
**"Two server generations from one build" means 10.11.x and 12.x.** There is no third thing.
⚠️ Direct consequence for JellyTau: `/System/Info/Public` returns `Version: "12.0.0"` on the new
generation and `"10.11.5"` on the old. Any version comparison must not assume a leading `10.`.
---
## Section 2 — Confirmed changes
### 2.1 Routes: the legacy user-scoped family SURVIVES intact
**The `/Users/{userId}/…` route family that JellyTau depends on in ~17 call sites is NOT removed
in 12.0.** Every route the task listed still exists and still functions.
Verified by diffing every `[HttpGet|Post|Delete|Put|Patch|Head]` attribute across
`Jellyfin.Api/Controllers/` in both tags (369 routes in 10.11.5, 364 in 12.0).
**Complete list of routes removed in 12.0 — all six:**
| Route | Handler |
|---|---|
| `POST /Users/{userId}/EasyPassword` | `UpdateUserEasyPassword` |
| `GET /Items/{itemId}/CriticReviews` | `GetCriticReviews` |
| `GET /Environment/NetworkShares` | `GetNetworkShares` |
| `POST /System/MediaEncoder/Path` | `UpdateMediaEncoderPath` |
| `GET /LiveTv/Recordings/Groups/{groupId}` | `GetRecordingGroup` |
| `GET /QuickConnect/Initiate` | `InitiateQuickConnectLegacy` |
**Complete list of routes added in 12.0 — one:** `GET /Items/{itemId}/Collections`
(`GetItemCollections`).
Sources:
- Route diff computed from `https://codeload.github.com/jellyfin/jellyfin/tar.gz/refs/tags/v10.11.5`
and `.../v12.0`, directory `Jellyfin.Api/Controllers/`.
- Corroborated verbatim by the release notes: "Removed obsolete API routes: `POST
/Users/{userId}/EasyPassword` (the EasyPassword feature is gone), `GET
/Items/{itemId}/CriticReviews`, `GET /Environment/NetworkShares`, `POST
/System/MediaEncoder/Path`, `GET /LiveTv/Recordings/Groups/{groupId}`, and `GET
/QuickConnect/Initiate`" — `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`
**Confirmed present and functional in v12.0** (`Jellyfin.Api/Controllers/`, tag `v12.0`):
| Route | File:line in v12.0 |
|---|---|
| `GET /Users/{userId}/Items` | `ItemsController.cs:721` |
| `GET /Users/{userId}/Items/Resume` | `ItemsController.cs:1027` |
| `GET /Users/{userId}/Items/Latest` | `UserLibraryController.cs:619` |
| `GET /Users/{userId}/Views` | `UserViewsController.cs:107` |
| `GET /Users/{userId}/Items/{itemId}` | `UserLibraryController.cs:117` |
| `POST /Users/{userId}/FavoriteItems/{itemId}` | `UserLibraryController.cs:252` |
| `DELETE /Users/{userId}/FavoriteItems/{itemId}` | `UserLibraryController.cs:300` |
| `POST /Users/{userId}/PlayedItems/{itemId}` | `PlaystateController.cs:120` |
| `DELETE /Users/{userId}/PlayedItems/{itemId}` | `PlaystateController.cs:185` |
### 2.2 …but the whole family was ALREADY deprecated in 10.11.5, and 12.0 hardens the policy
This is **not a new deprecation**. Every one of those methods already carried
`[Obsolete("Kept for backwards compatibility")]` **and** `[ApiExplorerSettings(IgnoreApi = true)]`
in 10.11.5, at the same positions. Nothing changed about their status between the two versions.
Confirmed: the 12.0 OpenAPI spec contains only these `/Users` paths — `/Users`,
`/Users/AuthenticateByName`, `/Users/AuthenticateWithQuickConnect`, `/Users/Configuration`,
`/Users/ForgotPassword`, `/Users/ForgotPassword/Pin`, `/Users/Me`, `/Users/New`,
`/Users/Password`, `/Users/Public`, `/Users/{userId}`, `/Users/{userId}/Policy`.
**None of the item/view/favorite/played routes appear.**
Source: `https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json`
(`info.version` = `"12.0.0"`, `x-jellyfin-version` = `"12.0.0"`, 294 paths).
What *is* new in 12.0 is the written removal policy:
> "If an endpoint isn't listed in the OpenAPI specification it should not be used by clients.
> There are certain endpoints that are still exposed for legacy reasons despite being excluded
> from the OpenAPI spec. **These can be removed in any major release without warning.**"
> "As a general rule, any deprecations will be marked as such for an entire (major) release cycle
> before the deprecated endpoint or parameter is liable for removal."
Source: `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`
**Assessment:** the user-scoped family needs no migration to run on 12.0, but it is now formally
removable without notice in 13.0. The replacements (`/Items?userId=`, `/UserViews?userId=`,
`/UserFavoriteItems/{itemId}`, `/UserPlayedItems/{itemId}`) **already exist in 10.11.5**, so
migrating is a one-generation-compatible change, not a branch.
Verified: `GET /Items` accepts `[FromQuery] Guid? userId` in v12.0
(`ItemsController.cs:171-174`), and non-user-scoped twins exist in *both* trees
(`UserLibraryController.cs`: `UserFavoriteItems/{itemId}`, `UserItems/{itemId}/Rating`).
### 2.3 🔴 AUTHENTICATION — the one genuinely breaking change for JellyTau
**`X-Emby-Authorization` is disabled by default in 12.0, including on upgraded servers.
`api_key` as a query parameter is disabled by default in 12.0.**
The authoritative accepted/deprecated table, from the Jellyfin core team's canonical
client-developer gist (last updated 2026-09-08):
| Type | Name | Method | Deprecated |
|---|---|---|---|
| Header | `Authorization` | Schema | **No** |
| Query | `ApiKey` | Token only | **No**, but discouraged |
| Query | `api_key` | Token only | **yes** |
| Header | `X-Emby-Token` | Token only | **yes** |
| Header | `X-MediaBrowser-Token` | Token only | **yes** |
| Header | `X-Emby-Authorization` | Schema | **yes** |
Source: `https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f`
(referenced from PR #13306 and from the 12.0 release notes)
**Verified in source.** `Jellyfin.Server.Implementations/Security/AuthorizationContext.cs` is
**byte-identical between v10.11.5 and v12.0** except one whitespace change
(`authorizationHeader[start.. i]` → `[start..i]`). The gating logic in **both** versions:
```csharp
// always read, no gate:
var auth = httpReq.Headers[HeaderNames.Authorization];
if (_configurationManager.Configuration.EnableLegacyAuthorization && string.IsNullOrEmpty(auth))
{
auth = httpReq.Headers["X-Emby-Authorization"];
}
...
var validName = name.Equals("MediaBrowser", StringComparison.OrdinalIgnoreCase); // always OK
validName = validName || (…EnableLegacyAuthorization && name.Equals("Emby", …)); // gated
...
if (…EnableLegacyAuthorization && string.IsNullOrEmpty(token)) { token = headers["X-Emby-Token"]; }
if (…EnableLegacyAuthorization && string.IsNullOrEmpty(token)) { token = headers["X-MediaBrowser-Token"]; }
if (string.IsNullOrEmpty(token)) { token = queryString["ApiKey"]; } // NOT gated
if (…EnableLegacyAuthorization && string.IsNullOrEmpty(token)) { token = queryString["api_key"]; } // gated
```
Source: `https://raw.githubusercontent.com/jellyfin/jellyfin/v12.0/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs`
(and the `v10.11.5` path of the same file)
**The only difference between the two versions is the default of the gate:**
- `v10.11.5` — `MediaBrowser.Model/Configuration/ServerConfiguration.cs:290`:
`public bool EnableLegacyAuthorization { get; set; } = true;`
- `v12.0` — same file, same line: `public bool EnableLegacyAuthorization { get; set; }`
(no initializer → C# default `false`)
Source: `https://raw.githubusercontent.com/jellyfin/jellyfin/v10.11.5/MediaBrowser.Model/Configuration/ServerConfiguration.cs`
and `.../v12.0/...`
**Existing installs are flipped too**, by a migration that runs on first boot:
```csharp
[JellyfinMigration("2026-05-31T16:00:00", nameof(DisableLegacyAuthorization), …)]
public class DisableLegacyAuthorization : IAsyncMigrationRoutine
{
public Task PerformAsync(CancellationToken cancellationToken)
{
_serverConfigurationManager.Configuration.EnableLegacyAuthorization = false;
_serverConfigurationManager.SaveConfiguration();
```
Source: `tree/jellyfin-12.0/Jellyfin.Server/Migrations/Routines/20260531160000_DisableLegacyAuthorization.cs`
(from `https://codeload.github.com/jellyfin/jellyfin/tar.gz/refs/tags/v12.0`)
Release-note wording: "Legacy authorization is now disabled by default, and a migration disables
it on existing installs as well."
Source: `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`
Blog wording: "the deprecated way of signing in is now disabled, including on existing servers."
Source: `https://jellyfin.org/posts/jellyfin-release-12.0`
Change history (all merged):
- PR #13306 "Add option to disable deprecated legacy authorization options", merged
2025-01-11, shipped in 10.11 with default `true`. Body: *"The only method we'll allow is the
`Authorization` header with `MediaBrowser` scheme and the `ApiKey` query parameter. The other
headers (`X-Emby-Authorization`, `X-Emby-Token`, `X-MediaBrowser-Token`), query parameter
(`api_key`) and authorization scheme (`Emby`) are all deprecated."*
`https://api.github.com/repos/jellyfin/jellyfin/pulls/13306`
- PR #15559 "Disable legacy authorization methods by default", merged 2025-11-27. Body:
*"We'll remove this configuration option (and the authorization methods) in a future release,
likely 10.13."* `https://api.github.com/repos/jellyfin/jellyfin/pulls/15559`
- PR #16754 "Keep legacy authorization enabled" (temporary revert), merged 2026-05-05.
`https://api.github.com/repos/jellyfin/jellyfin/pulls/16754`
- PR #16992 "Re-disable legacy authorization methods by default", merged 2026-06-01 — the
state that shipped. `https://api.github.com/repos/jellyfin/jellyfin/pulls/16992`
#### 🟢 The critical good news: query-param auth for media players is SAFE
`ApiKey` (capital A, capital K, no underscore) as a **query parameter** is **not** deprecated and
**not** gated in either version. The server itself generates it — identically in both trees:
- `v10.11.5` `MediaBrowser.Model/Dlna/StreamInfo.cs:1042` → `sb.Append("&ApiKey=");`
- `v12.0` `MediaBrowser.Model/Dlna/StreamInfo.cs:1034` → `sb.Append("&ApiKey=");`
- `v12.0` `StreamInfo.cs:1279-1280` → `// Use "?ApiKey=" as seen in HEAD and other parts of the code`
So the load-bearing requirement — handing stream URLs to mpv / ExoPlayer / HTML5 `<video>`, which
cannot set headers — **remains satisfied on both generations by one code path**, provided the
parameter is spelled `ApiKey` rather than `api_key`.
#### 🔴 JellyTau uses the disabled spellings today
Grep of `/home/dtourolle/Development/JellyTau/src-tauri/src`:
- **21 occurrences of `.header("X-Emby-Authorization", …)`** across
`auth/mod.rs` (3), `jellyfin/client.rs` (5), `repository/online.rs` (13).
- **28 non-test occurrences of `api_key`**, including every stream URL:
`repository/online.rs:1046, 2314` (`/Videos/{}/stream?…&api_key={}`),
`online.rs:2338` (`/Audio/{}/stream?…&api_key={}`),
`online.rs:2464` (`/Videos/{}/master.m3u8?api_key={}&…`),
`online.rs:633, 727, 2626`, plus `player/stream_end.rs`, `player/mod.rs`,
`jellyfin/http_client.rs`, `repository/device_profile.rs`, `utils/diagnostics.rs`.
The header **value** JellyTau already builds is correct — `jellyfin/client.rs:60` emits
`MediaBrowser Client="…", Version="…", Device="…", DeviceId="…", Token="…"`, which is exactly the
`MediaBrowser` scheme the non-deprecated `Authorization` header expects.
**Therefore the fix is a rename, not a branch:**
- `X-Emby-Authorization` → `Authorization` (value unchanged)
- `api_key=` → `ApiKey=`
Both work on 10.11.5 **and** 12.0. **No capability flag is needed for authentication.**
### 2.4 `POST /Users/AuthenticateByName` — unchanged
Request DTO `Jellyfin.Api/Models/UserDtos/AuthenticateUserByName.cs` and response
`MediaBrowser.Controller/Authentication/AuthenticationResult.cs` are **byte-identical** between
v10.11.5 and v12.0 (`diff` exit 0, no output). The controller method differs only by an added
`[Tags("Authentication")]` OpenAPI annotation.
Note the endpoint still reads the auth context from the request, so the client-identifying
`Authorization: MediaBrowser Client=…, DeviceId=…` header must be present on the login call too.
`UserDto` (returned inside `AuthenticationResult`) has three fields whose **type widened to
nullable**, all annotated obsolete:
`HasPassword` `bool` → `bool? = true` `[Obsolete("This information is no longer provided")]`;
`HasConfiguredPassword` `bool` → `bool? = true` `[Obsolete("This is always true")]`;
`HasConfiguredEasyPassword` `bool` → `bool? = false`.
Source: `MediaBrowser.Model/Dto/UserDto.cs` diff between the two tags; corroborated by release
notes "`UserDto.HasPassword` is marked obsolete and no longer provides useful information".
### 2.5 `/System/Info/Public` — unchanged endpoint, changed version string
`MediaBrowser.Model/System/PublicSystemInfo.cs` is **byte-identical** between v10.11.5 and v12.0
(`diff` produced no output). Fields in v12.0: `LocalAddress`, `ServerName`, `Version`,
`ProductName`, `OperatingSystem`, `Id`, `StartupWizardCompleted`.
The route `[HttpGet("Info/Public")]` sits at `SystemController.cs:92` in **both** versions, with
no `[Authorize]` attribute (anonymous), and is present in the 12.0 OpenAPI spec as
`/System/Info/Public`.
Sources: source diff of both tags; `https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json`
**The only delta is the value of `Version`:** `"12.0.0"` instead of `"10.11.x"`. Confirmed by the
blog: "the server reports its version as `12.0.0`" —
`https://jellyfin.org/posts/jellyfin-release-12.0`
Both uses JellyTau makes of this endpoint (version detection, offline-recovery probe) remain valid.
Version *parsing* is the thing to fix.
### 2.6 `/emby/*` and `/mediabrowser/*` route prefixes removed
`Jellyfin.Api/Middleware/LegacyEmbyRouteRewriteMiddleware.cs` **exists in v10.11.5 and is deleted
in v12.0**. Verified by `grep -rln '/emby' --include='*.cs'` over both trees: the file is listed
for 10.11.5 and absent for 12.0.
Release note: "Legacy route prefixes removed (`/emby/*` and `/mediabrowser/*`). Old third-party
clients that rely on them will stop working."
Source: `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`
**Not applicable to JellyTau** — grep found no `/emby/` or `/mediabrowser/` prefix usage.
### 2.7 BaseItemDto — purely additive, nothing removed or renamed
`MediaBrowser.Model/Dto/BaseItemDto.cs` diff between v10.11.5 and v12.0 is **two added fields and
nothing else**:
```diff
+ public float? AlbumNormalizationGain { get; set; }
+ public string OriginalLanguage { get; set; }
```
Every field the task called out is **declared identically in both versions** (verified by
extracting the property declarations from both files):
| Field | Type (identical in 10.11.5 and 12.0) |
|---|---|
| `ImageTags` | `Dictionary<ImageType, string>` |
| `BackdropImageTags` | `string[]` |
| `ParentBackdropImageTags` | `string[]` |
| `ParentBackdropItemId` | `Guid?` |
| `ParentThumbImageTag` / `ParentPrimaryImageTag` | `string` |
| `UserData` | `UserItemDataDto` |
| `MediaStreams` | `MediaStream[]` |
| `MediaSources` | `MediaSourceInfo[]` |
| `RunTimeTicks` | `long?` |
| `IndexNumber` | `int?` |
| `ParentIndexNumber` | `int?` |
| `SeriesId` | `Guid?` |
| `SeasonId` | `Guid?` |
`MediaBrowser.Model/Dto/UserItemDataDto.cs` and `MediaBrowser.Model/Dto/MediaSourceInfo.cs` are
**byte-identical** between the two tags.
`MediaBrowser.Model/Entities/MediaStream.cs` adds two fields — `LocalizedLanguage`,
`LocalizedOriginal` — and rewrites the computed `DisplayTitle` to use pre-resolved localized names
(this is the `Accept-Language` header support). **No field removed, no type changed.**
Source: source diff of `v10.11.5` vs `v12.0`.
### 2.8 PlaybackInfo — request and response shape unchanged; behaviour changed
`Jellyfin.Api/Models/MediaInfoDtos/PlaybackInfoDto.cs` (the POST body, carrying `DeviceProfile`)
is **byte-identical** between v10.11.5 and v12.0. `/Items/{itemId}/PlaybackInfo` is present in the
12.0 OpenAPI spec.
`MediaInfoController.cs` diff is 28 lines, all plumbing:
`GetPlaybackInfo(item, user)` → `GetPlaybackInfo(item, user, Request)` (to read `Accept-Language`),
and `SortMediaSources(info, maxStreamingBitrate)` → `SortMediaSources(info, maxStreamingBitrate, item.Id)`.
Source: source diff of `Jellyfin.Api/Controllers/MediaInfoController.cs` and
`Jellyfin.Api/Helpers/MediaInfoHelper.cs`.
### 2.9 DeviceProfile schema — near-identical, two changes
`MediaBrowser.Model/Dlna/` diff between v10.11.5 and v12.0:
| File | Result |
|---|---|
| `DeviceProfile.cs` | **byte-identical** |
| `DirectPlayProfile.cs` | **byte-identical** |
| `CodecProfile.cs` | **byte-identical** |
| `SubtitleProfile.cs` | **byte-identical** |
| `ProfileCondition.cs` | **byte-identical** |
| `TranscodingProfile.cs` | one change (below) |
| `ProfileConditionValue.cs` | one added enum member (below) |
**Change 1 — `TranscodingProfile.BreakOnNonKeyFrames` retired:**
```diff
[DefaultValue(false)]
+ [XmlIgnore]
[XmlAttribute("breakOnNonKeyFrames")]
- public bool BreakOnNonKeyFrames { get; set; }
+ [Obsolete("This is always false")]
+ public bool? BreakOnNonKeyFrames { get; set; }
```
**Type widened `bool` → `bool?`.** Also dropped from the copy constructor, dropped from
`StreamInfo`, and the `breakOnNonKeyFrames` **query parameter is removed from every streaming
endpoint** (`DynamicHlsController`, `VideosController`, `AudioController`,
`UniversalAudioController` — 8 method signatures total). Unknown query params are ignored by
ASP.NET Core, so a client still sending it is harmless.
**Change 2 — `ProfileConditionValue` gains `VideoRotation = 26`**, with a matching
`TranscodeReason.VideoRotationNotSupported = 1 << 27`. Additive; existing enum values are
unchanged (`NumStreams` is still `25`). Release note: "Add VideoRotation profile condition for
Android TVs that do not support rotation metadata."
Source: source diff of both tags; `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`
### 2.10 🟠 New: HLS/DASH-container sources are no longer eligible for direct play
New in `MediaBrowser.Model/Dlna/StreamBuilder.cs` (v12.0):
```csharp
private const string ManifestContainers = "hls,applehttp,dash";
…
// A manifest is not a byte stream, so it cannot be handed to the client as one. The variant
// and segment URIs inside it are relative to the origin and do not resolve against the
// Jellyfin url the client would fetch it from.
if (ContainerHelper.ContainsContainer(ManifestContainers, item.Container))
{
isEligibleForDirectPlay = false;
}
```
A source whose container is `hls`/`applehttp`/`dash` that direct-played on 10.11.5 will now be
transcoded/remuxed. Source: `StreamBuilder.cs` diff, hunk `@@ -714,6 +720,14 @@`.
### 2.11 🟠 TranscodeReasons now reports codec mismatches that 10.11.5 silently omitted
New in v12.0 `StreamBuilder.cs`:
```csharp
playlistItem.VideoCodecs = videoCodecs;
if (videoStream is not null && !ContainerHelper.ContainsContainer(videoCodecs, false, videoStream.Codec))
{
playlistItem.TranscodeReasons |= TranscodeReason.VideoCodecNotSupported;
}
…
if (audioStream is not null && audioStreamWithSupportedCodec is null)
{
playlistItem.TranscodeReasons |= TranscodeReason.AudioCodecNotSupported;
}
```
Source: `StreamBuilder.cs` diff, hunks `@@ -944,6 +958,10 @@` and `@@ -992,6 +1010,10 @@`.
This is a **reporting** improvement: PlaybackInfo responses now carry `VideoCodecNotSupported` /
`AudioCodecNotSupported` in cases where 10.11.5 returned an empty or partial reason set. If any
JellyTau workaround keys off "TranscodeReasons was empty so the profile must have been honoured",
that inference changes. See §3 for what this does **not** establish.
### 2.12 🔴 `GetItems` now defaults `recursive` to true for library folders with `includeItemTypes`
New in v12.0 `ItemsController.cs`:
```csharp
else if (folder is ICollectionFolder && includeItemTypes.Length == 0)
{
includeItemTypes = collectionType switch { CollectionType.boxsets => [BaseItemKind.BoxSet], _ => [] };
}
// includeItemTypes on a library lists its contents recursively rather than just its
// immediate children, so default to a recursive query when the client didn't choose.
if (folder is ICollectionFolder && includeItemTypes.Length > 0)
{
recursive ??= true;
}
```
and, at the user root, filtered requests now take the query path:
```diff
-if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder)
+if ((recursive.HasValue && recursive.Value) || ids.Length != 0 || item is not UserRootFolder || query.HasFilters)
```
Source: `Jellyfin.Api/Controllers/ItemsController.cs` diff (703 lines), hunks `@@ -294,7 +321,22 @@`
and `@@ -307,220 +349,273 @@`.
Release-note wording: "`GetItems` is now asynchronous and applies `recursive` when filters are
requested, limited to requests that include `includeItemTypes`. **The same query can return a
different result set than it did on 10.11.**"
Source: `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`; also
`https://jellyfin.org/posts/jellyfin-release-12.0`
This applies equally to the deprecated `/Users/{userId}/Items` alias, which routes to the same
handler.
**JellyTau audit item:** any request that sends `ParentId=<library>` **plus** `IncludeItemTypes`
**without** an explicit `Recursive` will change behaviour. The hard-coded query strings in
`repository/online.rs` all pair `IncludeItemTypes` with `Recursive=true`, but the dynamically
appended ones do not obviously do so — check `repository/endpoints.rs:172, 263, 315, 353, 367` and
`repository/online.rs:1259, 1492, 2246, 2947`. **Sending `Recursive` explicitly makes the
behaviour identical on both generations** — again a rename-class fix, not a capability branch.
### 2.13 🟠 HLS controllers removed from the OpenAPI spec (routes still live)
`Jellyfin.Api/Controllers/DynamicHlsController.cs` gains a class-level
`[ApiExplorerSettings(IgnoreApi = true)]` in v12.0 (it had none in 10.11.5), as does
`HlsSegmentController.cs`. Release note: "The HLS controllers are hidden from the specification."
**The routes still exist and still work in v12.0**, confirmed in source:
| Route | v12.0 location |
|---|---|
| `GET/HEAD /Videos/{itemId}/master.m3u8` | `DynamicHlsController.cs:404-405` |
| `GET/HEAD /Audio/{itemId}/master.m3u8` | `DynamicHlsController.cs:577-578` |
| `GET /Videos/{itemId}/main.m3u8` | `DynamicHlsController.cs:745` |
| `GET /Videos/{itemId}/live.m3u8` | `DynamicHlsController.cs:164` |
| `GET /Videos/{itemId}/hls1/{playlistId}/{segmentId}.{container}` | `DynamicHlsController.cs:1086` |
But they are **absent from the 12.0 OpenAPI spec**. Grepping
`https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json` for m3u8/stream/universal paths
returns only: `/Audio/{itemId}/stream`, `/Audio/{itemId}/stream.{container}`,
`/Audio/{itemId}/universal`, `/Videos/{itemId}/stream`, `/Videos/{itemId}/stream.{container}`,
`/Videos/{itemId}/Trickplay/{width}/tiles.m3u8`,
`/Videos/{itemId}/{mediaSourceId}/Subtitles/{index}/subtitles.m3u8`, plus LiveTv paths.
**`/Videos/{itemId}/master.m3u8` is not among them.**
Combined with the stated policy ("can be removed in any major release without warning"), JellyTau's
transcoded-playback path — which depends on `master.m3u8` — is now on **unspecified-but-functional**
footing. It works on 12.0; it carries removal risk for 13.0. This is a risk to track, not a
behavioural difference to branch on.
`GET/HEAD /Audio/{itemId}/universal` (`UniversalAudioController.cs:92-93`) and
`/Videos/{itemId}/stream` remain **in** the spec.
### 2.14 `StartTimeTicks` — unchanged
`long? startTimeTicks` appears in the same **11 method signatures** across
`VideosController.cs`, `AudioController.cs` and `DynamicHlsController.cs` in **both** v10.11.5 and
v12.0. Source: grep count over both trees.
One related fix in `StreamInfo.cs`: the master.m3u8 URL builder no longer emits a stray `?`
(10.11.5 appended `"/master.m3u8?"` then later `'?'`/`'&'`; 12.0 appends `"/master.m3u8"` and
rewrites the first `&` to `?`). This only affects server-generated URLs.
### 2.15 🟠 Image endpoints no longer upscale
New in v12.0 `MediaBrowser.Model/Drawing/DrawingUtils.cs`:
```csharp
/// Scales a size down uniformly until it fits inside a bounding box.
/// Returns the original size if it already fits, so this never upscales.
public static ImageDimensions ScaleDownToFit(ImageDimensions size, ImageDimensions boundingBox)
```
Blog: "Artwork is no longer stretched past its real size. Low resolution posters now appear at
their actual size instead of being blown up to fit."
Sources: `DrawingUtils.cs` diff; `https://jellyfin.org/posts/jellyfin-release-12.0`
A request for `?fillWidth=400` against a 200px-wide source now returns a ~200px image on 12.0 and a
400px image on 10.11.5. Layouts that assume the returned image matches the requested dimensions
will see different intrinsic sizes.
### 2.16 Other confirmed API-surface changes (obsolete-but-functional)
From `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0`, each verified as an
`[Obsolete]` attribute present in the v12.0 controller source (file:line from the extracted tree):
| Endpoint | Replacement | v12.0 source |
|---|---|---|
| `GetTrailers` | `GetItems` with `includeItemTypes=Trailer` | `TrailersController.cs:125` |
| `GetArtists`, `GetAlbumArtists` | `GetPersons` | `ArtistsController.cs:90, 244` |
| `GetArtistByName` | `GetPerson` | `ArtistsController.cs:368` |
| `GetMusicGenre` | `GetGenre` | `MusicGenresController.cs:154` |
| `GetInstantMixFromMusicGenreBy{Id,Name}` | `GetInstantMixFromItem` | `InstantMixController.cs:199, 363` |
| `GetStartupConfiguration`, `UpdateInitialConfiguration`, `SetRemoteAccess` | configuration endpoints | `StartupController.cs:56, 76, 95` |
Also confirmed from the release notes: "`ItemByName` responses are restricted and people are
deduplicated"; sorting by name now uses `SortName`/`CleanName` so library ordering may differ;
`.ogg` is audio-only; global subtitle configuration removed in favour of per-library settings.
---
## Section 3 — Unverified / could not establish
Everything below was actively looked for and **could not be confirmed**. Treat each as unknown.
1. **Whether 12.0 honours a submitted `DirectPlayProfile`'s declared container and video codec any
differently from 10.11.5.** This was the central question behind JellyTau's workarounds and I
**cannot answer it.** What I established is narrower: 12.0 *reports* `VideoCodecNotSupported` /
`AudioCodecNotSupported` in `TranscodeReasons` where 10.11.5 did not (§2.11), and 12.0 refuses
direct play for HLS/DASH-container sources (§2.10). Neither tells you whether the *decision*
about a declared container/codec changed. `DirectPlayProfile.cs` is byte-identical and the rest
of `StreamBuilder.cs`'s direct-play evaluation shows no relevant change across its 18 diff
hunks, which is weak evidence for "no change" — but I did not trace the full decision path, and
I did not run either server. **Do not remove any existing workaround on the strength of this
report.** Verify empirically against a real 12.0 instance.
2. **Which specific 10.11.5 profile-ignoring defect each JellyTau workaround exists for.** I did
not read the workarounds or their originating issues, so I cannot say whether any is now
unnecessary, still necessary, or actively harmful on 12.0.
3. **Whether `EnableLegacyAuthorization` will be removed entirely in 13.0.** PR #15559 said
removal was expected "likely 10.13" (i.e. 13.0 under the new scheme), but that is a 2025-11
statement about a plan, not a commitment, and the flag still exists in 12.0. The 12.0 release
notes do not restate a removal target.
4. **Whether real-world 12.0 servers will have `EnableLegacyAuthorization` re-enabled by users.**
The setting is user-editable in `system.xml` and some users will flip it back to keep older
clients working. A client cannot read this setting (it is not in `/System/Info/Public`), so
**there is no way to detect it other than attempting a request and observing 401.** Do not
assume "server is 12.0" implies "legacy auth is off".
5. **The exact HTTP status/body returned when a legacy auth method is rejected.** I did not run a
12.0 server. I assume 401 based on the authorization pipeline but **did not verify it**, and I
did not establish whether a rejected `X-Emby-Authorization` produces a distinguishable error
from an expired token — which matters if you want to auto-detect and re-auth.
6. **Whether `/Users/{userId}/…` routes emit a deprecation warning header** (e.g. `Deprecation`,
`Sunset`, `Warning`) on 12.0. I looked at the controllers and found only `[Obsolete]` /
`[ApiExplorerSettings]` compile-time and spec-time attributes. I found no evidence of a runtime
response header, but did not exhaustively search the middleware pipeline.
7. **A 10.11.x OpenAPI document for a true spec-to-spec diff.** `api.jellyfin.org` serves only one
spec and it is now `12.0.0`; both the "stable" and "unstable" URLs return the identical
1,894,898-byte 12.0 document. The `jellyfin-sdk-typescript` repo's historic `openapi.json` files
are **Git LFS pointers**, which I did not resolve. All route/DTO comparisons in this report are
therefore from **C# source**, not from two specs. Source-level results should be equivalent or
better, but the difference is worth stating.
8. **Changes to `POST /Sessions/Playing`, `/Sessions/Playing/Progress`, `/Sessions/Playing/Stopped`
payload semantics**, and to remote-control / session-polling behaviour. `PlaystateController.cs`
shows the routes intact with unchanged obsolete markers, but I did not diff the session
manager, `SessionInfo`, or the WebSocket message set. JellyTau's remote mode depends on these
and they were **not examined**.
9. **Whether the `Accept-Language` header support changes any response JellyTau parses.**
`MediaStream.DisplayTitle` is now built from server-resolved `LocalizedLanguage` rather than
client-side culture lookup, which means `DisplayTitle` **strings will differ** — but I did not
determine the default when no `Accept-Language` is sent, nor whether JellyTau parses
`DisplayTitle` anywhere.
10. **Any change to `/Items/{itemId}/Images/{type}` URL parameters** (`tag`, `maxWidth`,
`fillHeight`, `quality`). I confirmed the *upscaling* behaviour change (§2.15) but did not diff
`ImageController`'s parameter list.
11. **Download / sync / offline endpoints** (`/Items/{id}/Download`, `/Sync/*`). Not examined.
12. **`/Videos/{id}/stream` `static=true` semantics** — whether the container/`mediaSourceId`
handling changed. `VideosController.cs` has a 207-line diff dominated by the
`PrimaryVersionId` `string` → `Guid` refactor and alternate-version relinking; I did not
isolate whether any of it alters `static=true` responses.
13. **Whether 12.0 changes the `DeviceId` single-session constraint** mentioned in the auth gist.
Not investigated.
14. **Actual 12.0 runtime behaviour of anything.** Nothing in this report was tested against a
running server of either version. Everything is source, spec, and release-note analysis.
---
## Section 4 — Proposed capability flags
The strongest finding here is that **most of this needs no flag.** Four of the five headline
changes are fixed by writing the request in a way that is correct on *both* generations. Flags
should be reserved for genuine either/or behaviour, because each one is a silent branch that will
outlive the reason it was added.
### Needs no flag — fix once, works on both generations
| Change | Fix | Why no flag |
|---|---|---|
| §2.3 auth header | `X-Emby-Authorization` → `Authorization`, same value | `Authorization` + `MediaBrowser` scheme is ungated in 10.11.5 and 12.0 |
| §2.3 query auth | `api_key=` → `ApiKey=` | `ApiKey` is ungated in both; the server itself emits it in both |
| §2.12 recursive default | send `Recursive` explicitly on every `IncludeItemTypes` query | an explicit value makes both generations agree |
| §2.9 breakOnNonKeyFrames | stop sending it | ignored as an unknown query param on both |
| §2.2 user-scoped routes | optional: migrate to `/Items?userId=` etc. | replacements exist in 10.11.5 too |
Do these first. They eliminate the entire breaking surface without introducing a single branch.
### Genuinely version-dependent — flag candidates
A single detected generation, derived once from `/System/Info/Public` `Version`, should drive these:
```
ServerGeneration::V10_11 // Version major == 10
ServerGeneration::V12Plus // Version major >= 12
```
| Flag | Guards | Default 10.11.x | Default 12.x | Source |
|---|---|---|---|---|
| `supports_manifest_container_direct_play` | Whether an `hls`/`applehttp`/`dash` source may be direct-played | `true` | `false` | §2.10 |
| `reports_codec_transcode_reasons` | Whether an empty/partial `TranscodeReasons` can be read as "profile honoured" | `false` | `true` | §2.11 |
| `image_endpoint_upscales` | Whether a requested `fillWidth`/`maxWidth` is the size you get back | `true` | `false` | §2.15 |
| `hls_master_playlist_in_spec` | Whether `/Videos/{id}/master.m3u8` is a specified endpoint (removal-risk telemetry, not a behaviour switch) | `true` | `false` | §2.13 |
### Runtime-probed, not version-derived
| Flag | Why it cannot be version-derived |
|---|---|
| `legacy_auth_accepted` | A 12.0 admin can set `EnableLegacyAuthorization=true`, and a 10.11 admin can set it to `false`. Not exposed to clients (§3.4). If JellyTau keeps any legacy-auth fallback, it must be probe-and-observe-401, never version-inferred. **Better: send only non-deprecated auth and delete the concept.** |
### Version parsing
Whatever detects the generation must **not** assume a leading `10.`. `/System/Info/Public` returns
`"10.11.5"` on one generation and `"12.0.0"` on the other; under the old scheme 12.0 would have been
10.12.0, so `major >= 12` and `major == 10` are the two live cases and `major == 11` will never
occur. The Jellyfin blog explicitly flags version-string parsers as the thing to check before
upgrading (`https://jellyfin.org/posts/jellyfin-release-12.0`).
---
## Source index
| # | URL |
|---|---|
| 1 | `https://api.github.com/repos/jellyfin/jellyfin/tags` (pages 1-8) |
| 2 | `https://api.github.com/repos/jellyfin/jellyfin/releases/latest` |
| 3 | `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v12.0` |
| 4 | `https://api.github.com/repos/jellyfin/jellyfin/releases/tags/v10.11.11` |
| 5 | `https://jellyfin.org/posts/jellyfin-release-12.0` |
| 6 | `https://jellyfin.org/posts/` |
| 7 | `https://api.jellyfin.org/openapi/jellyfin-openapi-stable.json` (`info.version` = 12.0.0) |
| 8 | `https://gist.github.com/nielsvanvelzen/ea047d9028f676185832e51ffaf12a6f` (auth methods table) |
| 9 | `https://api.github.com/repos/jellyfin/jellyfin/pulls/13306` |
| 10 | `https://api.github.com/repos/jellyfin/jellyfin/pulls/15559` |
| 11 | `https://api.github.com/repos/jellyfin/jellyfin/pulls/16754` |
| 12 | `https://api.github.com/repos/jellyfin/jellyfin/pulls/16992` |
| 13 | `https://codeload.github.com/jellyfin/jellyfin/tar.gz/refs/tags/v10.11.5` (full source tree) |
| 14 | `https://codeload.github.com/jellyfin/jellyfin/tar.gz/refs/tags/v12.0` (full source tree) |
| 15 | `https://raw.githubusercontent.com/jellyfin/jellyfin/v12.0/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs` |
| 16 | `https://raw.githubusercontent.com/jellyfin/jellyfin/v10.11.5/MediaBrowser.Model/Configuration/ServerConfiguration.cs` |
| 17 | `https://raw.githubusercontent.com/jellyfin/jellyfin/v12.0/MediaBrowser.Model/Configuration/ServerConfiguration.cs` |
Working files (source trees, diffs, route diff JSON) are retained in the scratchpad alongside this
report: `tree/jellyfin-10.11.5/`, `tree/jellyfin-12.0/`, `routediff.json`, `sb.diff`, `items.diff`,
`v12-body.md`, `oas-stable.json`.
@@ -0,0 +1,294 @@
# Spec: Jellyfin server version compatibility
**Status:** Partially implemented
**Requirements:** UR-085 → IR-035, JA-037, DR-279 … DR-288 (DR-287 and DR-288
were added once research established what actually breaks).
## What is left
Everything below shipped on 2026-09-08 **except**:
- **DR-283 is partially done.** `supports_manifest_container_direct_play` and
`image_endpoint_upscales` are resolved and tested, but **nothing consumes them
yet** — and that may be correct rather than an omission: on 12.0 the *server*
enforces both (it refuses direct play for manifest containers itself, and
simply returns the smaller image), so the client learns the answer from the
`PlaybackInfo` response without needing to predict it. Decide whether to
consume them or delete them once a running 12.x server can be observed. Do not
leave them unread indefinitely: an unconsumed flag is a branch waiting to be
wired wrongly.
- **`honours_directplay_audio_codec` is unresolved for 12.x.** A source-level
diff could not establish whether the behaviour changed. The override stays on
for both generations. Flip it only against a running 12.x server — keeping it
costs an unnecessary transcode, removing it wrongly costs silent playback.
- **The user-scoped route migration (DR-282) was not performed.** It is not
needed: the whole family still works on 12.0. Both route shapes are built and
tested, so switching is a one-line change whenever it is wanted.
- **Nothing was tested against a real server of either generation.** Every
cross-generation assertion runs against a mock built from a source-level diff.
## What research established
The framing this spec was written under was wrong in a way worth recording.
**Jellyfin 11.0 does not exist and never did.** With 12.0 the project dropped the
leading `10` from its version scheme: what would have been 10.12.0 shipped as
`12.0`, and the server reports `Version: "12.0.0"`. So "two generations" means
**10.11.x and 12.x**, one release-branch step apart, not two majors. 12.0 became
stable on 2026-09-08 — the same day this work was done — so real-world 12.x
installs are currently near zero and rising.
The delta is far smaller than this spec assumed, and almost none of it is a
branch:
| Finding | Consequence |
|---|---|
| `X-Emby-Authorization` and the `api_key` query parameter are **disabled by default in 12.0**, including on upgraded servers via a migration | The one genuinely breaking change. Fixed by a **rename** — `Authorization` + `ApiKey` are ungated on both — not a flag (DR-287) |
| `GetItems` now defaults `recursive` to true for a library parent with `IncludeItemTypes` | The same request returns a different result set. Fixed by stating `Recursive` explicitly (DR-288) |
| The `/Users/{userId}/…` family **survives** in 12.0 | No migration needed. Six routes were removed in total; none are ones this client calls |
| `BaseItemDto` is **purely additive**; `DeviceProfile`, `PlaybackInfo`, `PublicSystemInfo` byte-identical | No DTO work at all |
| Manifest-container sources are no longer direct-play eligible; image endpoints no longer upscale | The only two genuine either/or differences — and both are server-enforced |
The lesson for the layer rule: **most of a version delta is fixed by writing the
request correctly for both generations, not by branching on the version.** Flags
are for genuine either/or behaviour, because each one is a silent branch that
outlives the reason it was added.
The full report, with a source URL per claim, is
[jellyfin-12-api-delta.md](jellyfin-12-api-delta.md).
**UX spec:** n/a for the bulk of it. One new user-visible state — "this server
is a version JellyTau does not know" — needs a home in the connect flow; see
DR-286.
**Supersedes / revises:** nothing. Touches
[backend-owned-stream-selection.md](backend-owned-stream-selection.md) at the
`StreamSelection` boundary and should land after it where they overlap, but
neither blocks the other.
**Destination on completion:**
[01-rust-backend.md](../architecture/01-rust-backend.md) — a new "Server
capability negotiation" section beside "Domain Vocabulary Owned by Rust", which
is where the litmus test this feature exists to satisfy already lives; and a
paragraph in [07-connectivity.md](../architecture/07-connectivity.md) noting
that the `/System/Info/Public` probe now has a second consumer. The durable half
is the capability model and *why* it is flags rather than version comparisons;
phases, ticket boundaries and acceptance criteria are disposable.
## Summary
Let one build of JellyTau talk to more than one generation of Jellyfin server.
The app already asks the server what version it is, at connect, before login —
and then throws the answer away. Instead it resolves that version into a
`ServerCapabilities` value once per connection, and every decision that depends
on the server generation reads a named flag from it.
Nothing about the app changes for a user whose server matches what the code
targets today. What changes is that the release which follows the server forward
stops silently abandoning everyone who has not upgraded, and that a server the
app does not recognise produces a sentence rather than a cascade of parse
failures.
## Motivation
The server and its clients are upgraded by different people on different
schedules. A family server can sit a major version behind for a year while the
phone updates itself weekly. Today the code has no way to express that.
**1. One server generation is hard-coded, unconditionally.** The current target
is 10.11.5 and it is written into the code as fact, not as a branch —
[device_profile.rs:336](../../src-tauri/src/repository/device_profile.rs#L336),
[online.rs:966](../../src-tauri/src/repository/online.rs#L966),
[online.rs:2271](../../src-tauri/src/repository/online.rs#L2271), and most
pointedly [online.rs:4667](../../src-tauri/src/repository/online.rs#L4667),
which is documented as "the override that exists because Jellyfin 10.11.5
ignores…". Every one of those is correct for one server and wrong for another,
and there is nowhere to say which.
**2. Endpoints are 57 inline string literals, not a route table.** They are
built with `format!` at the point of use, query string and all —
[online.rs:1957](../../src-tauri/src/repository/online.rs#L1957) is
representative. Supporting a second route shape without a table means 57
conditionals rather than one.
**3. The legacy user-scoped routes are load-bearing.** Roughly twelve sites use
`/Users/{uid}/Items`, `/Users/{uid}/Items/Resume`, `/Users/{uid}/Views`,
`/Users/{uid}/FavoriteItems/{id}` and `/Users/{uid}/PlayedItems/{id}`. These are
precisely the routes upstream has been moving away from in favour of
`/Items?userId=`. Whichever release drops them takes the app with it.
**4. There is no way to test any of this.** `src-tauri/` contains no HTTP mocking
at all — no `wiremock`, no `mockito`, no `httpmock`. Every test of the online
repository asserts on a *constructed URL string*; not one exercises a response.
So there is currently no mechanism by which "works against both generations"
could be demonstrated, and this is the single largest item in the work. It is
also worth doing on its own merits: a 4,797-line adapter with no response-level
tests is under-covered regardless of how many server versions it supports.
**5. A Jellyfin route is being built in the frontend.**
[imageCache.ts:64](../../src/lib/services/imageCache.ts#L64) constructs
`${serverUrl}/Items/${itemId}/Images/${imageType}` in Svelte. By the litmus test
in this project's own spec template — *would this have to change if Jellyfin
changed its API?* — that is domain logic in the presentation layer. It is the
only one left, and this is the feature that makes it actively wrong rather than
merely misplaced.
**What this is not.** It is not multi-server support. Profiles are users on one
server ([profiles/store.rs](../../src-tauri/src/profiles/store.rs)), and that
does not change here. "Both versions at the same time" means one binary that
adapts to whichever server it is pointed at, not two servers connected at once.
## Layer assignment
| Logic / responsibility | Layer | Why it belongs there |
|------------------------|-------|----------------------|
| Server version string → capability flags | Rust | Domain vocabulary in the strictest sense: it changes when and only when Jellyfin's API changes. The template's litmus test answers this in one word. |
| Which route shape to use for a given call | Rust | Wire format. The frontend must not know that a route exists, let alone that there are two. |
| Image URL construction (**moving** out of `imageCache.ts`) | Rust | A Jellyfin route, therefore it changes with Jellyfin's API. Currently in the frontend; this feature is what turns that from untidy into broken. |
| Device-profile / `PlaybackInfo` override selection | Rust | Already Rust and staying there. Only the *gating* is new — the overrides stop being unconditional. |
| Whether a cache written against one server generation is still valid | Rust | A storage invariant. The frontend cannot see the server version and must not learn to. |
| Deciding a server is too old / too new to use | Rust | A domain judgement about an API, expressed as an opaque state on the wire. |
| How the "unsupported server" state is worded and where it appears in the connect flow | Frontend | Pure presentation. It changes if the UI is redesigned and not otherwise. The frontend renders an opaque state; it never compares a version. |
Borderline: none. The one row that could be argued is the last, and it splits
cleanly — Rust decides *that* the server is unsupported, the frontend decides
what that looks like. The frontend never receives a version number to reason
about, for the same reason it never receives an item-type list.
## Design
### `ServerCapabilities`
Resolved once, at connect, from a version the app already has.
`AuthManager::connect_to_server` ([auth/mod.rs:147](../../src-tauri/src/auth/mod.rs#L147))
already parses `PublicSystemInfo.version` and returns it in `ServerInfo`, and the
`servers` table already has a `version TEXT` column
([schema.rs:42](../../src-tauri/src/storage/schema.rs#L42)) that is written on
insert. Detection therefore costs nothing new; the value is simply discarded
today.
The resolved value hangs on `OnlineRepository` and is passed to the route table.
`OfflineRepository` has no server and no capabilities; `HybridRepository`
delegates. No `MediaRepository` method signature changes, so no caller above
`repository/` is touched.
**Flags, not comparisons.** Every capability is named for the behaviour it
governs — `user_scoped_item_routes`, `honours_directplay_container`,
`playback_info_respects_container` — and the version → flags mapping lives in
exactly one function. A `version < 11` scattered through call sites is the same
mistake as a taxonomy in the frontend: it re-derives a domain fact at the point
of use, and it is unreadable at the second occurrence. Flags also survive the
case the comparison cannot express, which is a backport.
### Route table
The ~30 distinct endpoints move into `repository/endpoints.rs`, each a function
taking `&ServerCapabilities` and returning the path. Everything in `online.rs`
already funnels through three helpers that take `endpoint: &str` —
`get_json`, `post_json`, `post_json_response`
([online.rs:315-459](../../src-tauri/src/repository/online.rs#L315-L459)) — so
the interception point exists and there are 32 call sites, not 57 literals.
This step is behaviour-preserving on its own and lands before anything depends
on it.
### Unknown versions
An unrecognised version resolves to the newest known capability set and is
recorded, not rejected — the app should keep working against a server that is
merely newer than the release. Rejection is reserved for a version below the
floor, where the failure is certain rather than likely. Either way the outcome
crosses the IPC boundary as an opaque state, never a version number.
### Cache validity
Cached rows carry no record of which server generation wrote them. The server's
version goes on the cache alongside the existing `synced_at`, and a change
invalidates by clearing `synced_at` — the same move
[MIGRATION_018 and migration 025](../../src-tauri/src/storage/schema.rs) already
make, and for the same reason: the association was never stored, so existing rows
cannot be repaired locally and must be re-fetched.
## Out of scope
- **Multi-server support.** One server per install, as today.
- **Emby, or any non-Jellyfin server.** The capability model would carry it; the
DTO layer would not, and nothing here should be read as a step toward it.
- **The Windows/Linux/Android split.** Capabilities describe the *server*, never
the client platform. Platform differences stay in `device_profile.rs`.
- **Raising coverage of the whole online adapter.** The mock-server harness makes
that possible and the version-sensitive paths get tests; a general backfill is
separate work.
## Acceptance criteria
- [ ] The app connects, browses, plays and reports against both target server
generations, from one build, with no user-visible configuration.
- [ ] The repository suite runs against both generations' fixtures and passes.
- [ ] No `format!` endpoint literal remains in `online.rs`.
- [ ] No version comparison exists outside the single version → capabilities
function.
- [ ] A server below the supported floor produces one legible message; a server
newer than the release still works.
- [ ] `bun run check` and `bun run test` pass.
- [ ] `cargo fmt` clean, `cargo clippy --all-targets -D warnings` clean,
`bun run test:rust` passes.
- [ ] `bun run check:boundary` passes — and note it will *not* catch the
`imageCache.ts` route, which is why DR-285 is a ticket rather than a
tripwire.
- [ ] New requirement-implementing code carries `// TRACES:` comments;
`bun run traces:validate` passes and coverage does not fall.
- [ ] `bindings.ts` regenerated if Rust types changed.
## Testing
The harness is the feature's precondition, not its afterthought.
**Rust.** Add a mock HTTP server (`wiremock` — a project dependency, so no CI
image change; see the toolchain rule in CLAUDE.md) plus one recorded fixture set
per server generation. The repository suite becomes parameterised over
generations. What must be covered: route selection per capability; the
device-profile overrides firing on the generation they were written for and *not*
on the other; cache invalidation across a version change; an unknown version
resolving forward rather than failing.
**Frontend.** `imageCache.ts` loses its URL construction, so its tests assert it
calls the command rather than that it builds a string.
`repository/online_integration_test.rs` **has been deleted** (2026-09-08). It was
never declared in `repository/mod.rs` and referenced a `crate::api::jellyfin`
module that does not exist, so it had never compiled. It is worth knowing why it
was not merely dead but harmful: its mock *reimplemented* the URL builders and
then asserted against itself, and `online.rs` carries a comment recording that
this exact arrangement once shipped a `/Videos/{id}/download` endpoint that 404s
on real servers while the mock happily tested the correct one — silently breaking
every movie and TV download. Its own `test_image_url_basic` asserted `api_key=`
appears in image URLs while the mock beside it documented the opposite.
That is the anti-pattern DR-281 exists to replace: assert against a *response*
from a mock **server**, never against a mock that re-derives the thing under
test.
## TRACES
| Piece | Suggested tag |
|---|---|
| `ServerCapabilities` + version resolution | `UR-085 \| IR-035, DR-280 \| UT-xxx` |
| `repository/endpoints.rs` | `UR-085 \| DR-279` |
| Route selection for user-scoped endpoints | `UR-085 \| JA-037, DR-282` |
| Capability-gated profile overrides | `UR-085 \| DR-283` |
| Cache generation stamp + invalidation | `UR-085 \| DR-284` |
| Image URL command | `UR-012, UR-085 \| DR-285` |
| Unsupported-server state | `UR-085 \| DR-286` |
## Notes for the implementer
- **The concrete API delta is not in this spec, deliberately.** No route, field
or behaviour difference between the two generations is asserted here, because
none has been verified against an upstream changelog. The first ticket exists
to establish it. Do not let a plausible-sounding difference enter the code
without a citation — a wrong capability flag is worse than none, since it fires
silently on the generation it was not tested against.
- The route table and the capability struct are independently useful and
independently reviewable. If the feature is cut, cut from the end, not the
start.
- A parallel Claude session may be active in this repo — `git diff` before
"repairing" unexpected changes.
+10 -3
View File
@@ -1,8 +1,15 @@
# Spec: Windows native audio backend
**Status:** Proposed — not started. Windows still runs on
`WebviewAudioBackend`. Blocked on [libmpv2-migration.md](libmpv2-migration.md),
whose crate swap has not landed either.
**Status:** Partially implemented — code and packaging done, **unverified on
Windows hardware**. `MpvBackend` is the Windows audio backend (`ao=wasapi`);
the builder image carries zhongfly's pinned LGPL `libmpv-2.dll` plus a generated
MSVC `mpv.lib`, and the NSIS installer ships the DLL beside `jellytau.exe` with
its licence texts ([build-windows.md](../build/build-windows.md)). Done *before*
the libmpv2 migration after all: the current `libmpv` pin cross-links fine, and
the migration now has one call site to keep safe (`mpv_command`, DR-298) rather
than a crate API to port twice. **Left:** every acceptance criterion that needs a
Windows machine — audible volume/EQ/normalization/gapless, seek/queue/sleep
timer, and the clean-VM install test.
**Requirements:** UR-003, UR-027, UR-032, UR-033 → DR-030, DR-035, DR-036;
⚠️ the suggested id **IR-030 has since been allocated** to the scheduled catalog
crawl — allocate a fresh id (IR-033 or later) on implementation
+8893 -5905
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.11.5",
"version": "0.14.0",
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
"author": "Duncan Tourolle <duncan@tourolle.paris>",
"license": "MIT",
@@ -64,7 +64,6 @@
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "2.10.1",
"hls.js": "^1.6.15",
"svelte-dnd-action": "^0.9.69"
},
"devDependencies": {
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+165
View File
@@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.
+21 -3
View File
@@ -7,14 +7,16 @@
# officially supports for Windows (the mingw/GNU target is not), and unlike GNU
# it can bundle the NSIS installer from a Linux host.
#
# Playback on Windows: video renders via WebView2 and audio via the webview
# <audio> backend (WebviewAudioBackend) — see docs/build/build-windows.md.
# Playback on Windows: video renders via WebView2; audio via mpv, whose
# libmpv-2.dll ships beside jellytau.exe — see docs/build/build-windows.md.
#
# Requirements (present in the Docker windows-cross target / unified builder):
# - rustup target x86_64-pc-windows-msvc
# - cargo-xwin (cargo install --locked cargo-xwin)
# - lld, llvm (linker + llvm-lib used by cargo-xwin)
# - nsis (makensis) (installer generator)
# - libmpv for Windows ($LIBMPV_WIN_DIR: libmpv-2.dll + mpv.lib, pinned and
# sha256-checked in Dockerfile.builder)
#
# Usage:
# scripts/build-windows-cross.sh # exe + NSIS installer
@@ -29,10 +31,26 @@ WIN_BUNDLES="${WIN_BUNDLES:-nsis}"
echo "🪟 Cross-compiling JellyTau for Windows ($TARGET, via cargo-xwin)"
echo "================================================================"
echo "Video plays via WebView2; audio via the webview <audio> backend."
echo "Video plays via WebView2; audio via mpv (libmpv-2.dll)."
echo "Bundles: $WIN_BUNDLES"
echo ""
# Stage libmpv where build.rs links it from and tauri.windows.conf.json bundles
# it from — one directory, so the DLL shipped is the one linked against.
# Copied fresh every build: a stale DLL left from an older image would ship
# silently. TRACES: UR-004 | DR-237
LIBMPV_WIN_DIR="${LIBMPV_WIN_DIR:-/opt/libmpv-win64}"
for f in libmpv-2.dll mpv.lib; do
if [[ ! -f "$LIBMPV_WIN_DIR/$f" ]]; then
echo "❌ $LIBMPV_WIN_DIR/$f not found. Build inside the jellytau-builder image,"
echo " or set LIBMPV_WIN_DIR to a directory holding libmpv-2.dll and mpv.lib."
exit 1
fi
done
rm -rf src-tauri/windows-libs
mkdir -p src-tauri/windows-libs
cp -v "$LIBMPV_WIN_DIR/libmpv-2.dll" "$LIBMPV_WIN_DIR/mpv.lib" src-tauri/windows-libs/
bun install --frozen-lockfile 2>/dev/null || bun install
bun run build
+12 -4
View File
@@ -54,21 +54,29 @@ describe("tauri.conf.json CSP", () => {
expect(csp["img-src"]).toContain("asset:");
expect(csp["img-src"]).toContain("http://asset.localhost");
expect(csp["media-src"]).toContain("asset:");
// hls.js: MSE object URLs, and its demuxer worker built from a blob.
expect(csp["media-src"]).toContain("blob:");
expect(csp["worker-src"]).toContain("blob:");
// The token-guarded loopback media server (DR-137).
expect(csp["media-src"]).toContain("http://127.0.0.1:*");
// Tauri's invoke transport.
expect(csp["connect-src"]).toContain("ipc:");
expect(csp["connect-src"]).toContain("http://ipc.localhost");
// The user's Jellyfin server: an arbitrary run-time origin, http on a LAN.
for (const directive of ["img-src", "media-src", "connect-src"]) {
for (const directive of ["img-src", "media-src"]) {
expect(csp[directive]).toContain("http:");
expect(csp[directive]).toContain("https:");
}
});
// The page makes no network requests of its own — all traffic goes through
// Rust — so `connect-src` is IPC only. It allowed any http(s) host while
// hls.js fetched segments in the page; with hls.js deleted (DR-235) that
// grant was only an exfiltration channel for injected script. Likewise the
// blob worker was hls.js' demuxer.
it("gives injected script no network egress and no blob workers", () => {
expect(csp["connect-src"]).not.toContain("http:");
expect(csp["connect-src"]).not.toContain("https:");
expect(csp["worker-src"]).not.toContain("blob:");
});
it("never widens a data directive into script execution", () => {
for (const [name, sources] of Object.entries(csp)) {
if (name === "script-src" || name === "worker-src") {
+4
View File
@@ -22,3 +22,7 @@ local.properties
# macOS
.DS_Store
# Windows libmpv (import library + DLL), staged from the builder image by
# scripts/build-windows-cross.sh. Never committed: 100 MB of LGPL binary.
/windows-libs/
+109 -15
View File
@@ -194,6 +194,16 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -878,6 +888,24 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "deadpool"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
dependencies = [
"deadpool-runtime",
"lazy_static",
"num_cpus",
"tokio",
]
[[package]]
name = "deadpool-runtime"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
[[package]]
name = "deranged"
version = "0.5.8"
@@ -1345,6 +1373,21 @@ dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
@@ -1352,6 +1395,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
@@ -1419,6 +1463,7 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
@@ -1754,6 +1799,25 @@ dependencies = [
"syn 2.0.112",
]
[[package]]
name = "h2"
version = "0.4.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http",
"indexmap 2.12.1",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1902,9 +1966,11 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"pin-utils",
@@ -2209,7 +2275,7 @@ dependencies = [
[[package]]
name = "jellytau"
version = "0.11.5"
version = "0.14.0"
dependencies = [
"aes-gcm",
"argon2",
@@ -2249,10 +2315,10 @@ dependencies = [
"tempfile",
"tiny_http",
"tokio",
"tokio-rusqlite",
"tokio-util",
"urlencoding",
"uuid",
"wiremock",
"zip 2.4.2",
]
@@ -2417,6 +2483,12 @@ dependencies = [
"selectors 0.24.0",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libappindicator"
version = "0.9.0"
@@ -2720,6 +2792,16 @@ dependencies = [
"autocfg",
]
[[package]]
name = "num_cpus"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "num_enum"
version = "0.7.5"
@@ -3917,9 +3999,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.35"
version = "0.23.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
dependencies = [
"once_cell",
"ring",
@@ -5257,17 +5339,6 @@ dependencies = [
"syn 2.0.112",
]
[[package]]
name = "tokio-rusqlite"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b65501378eb676f400c57991f42cbd0986827ab5c5200c53f206d710fb32a945"
dependencies = [
"crossbeam-channel",
"rusqlite",
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
@@ -6430,6 +6501,29 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "wiremock"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
dependencies = [
"assert-json-diff",
"base64 0.22.1",
"deadpool",
"futures",
"http",
"http-body-util",
"hyper",
"hyper-util",
"log",
"once_cell",
"regex",
"serde",
"serde_json",
"tokio",
"url",
]
[[package]]
name = "wit-bindgen"
version = "0.46.0"
+12 -5
View File
@@ -4,7 +4,7 @@ name = "jellytau"
# `player-conformance`, and a second binary makes a bare `cargo run` —
# which `tauri dev` issues — ambiguous.
default-run = "jellytau"
version = "0.11.5"
version = "0.14.0"
description = "A cross-platform Jellyfin client"
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
license = "MIT"
@@ -53,7 +53,6 @@ futures-util = "0.3"
async-trait = "0.1"
# SQLite for offline storage
tokio-rusqlite = "0.6"
rusqlite = { version = "0.32", features = ["bundled"] }
chrono = { version = "0.4", features = ["serde"] }
directories = "5"
@@ -109,9 +108,10 @@ tiny_http = { version = "0.12.0", default-features = false }
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
# Linux-specific dependencies
[target.'cfg(target_os = "linux")'.dependencies]
hostname = "0.4"
# mpv platforms: Linux (system libmpv) and Windows (libmpv-2.dll shipped in the
# installer, import library generated in the builder image — see build.rs and
# scripts/build-windows-cross.sh). TRACES: UR-004 | DR-237
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
libc = "0.2"
# The crates.io release of libmpv predates the MPV versions we support, so this
# tracks the upstream git repo.
@@ -138,6 +138,9 @@ libmpv = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", rev = "3e6c38
# TRACES: UR-080 | DR-230, IR-033
libmpv-sys = { git = "https://github.com/ParadoxSpiral/libmpv-rs.git", rev = "3e6c389b716f52a595cc5e8e3fa1f96cb76b3de7" }
# Linux-specific dependencies
[target.'cfg(target_os = "linux")'.dependencies]
hostname = "0.4"
# Same major as the one Tauri/wry already resolve, so `gtk_window()` and
# `default_vbox()` hand back types this crate can name rather than a second,
# incompatible GTK.
@@ -150,6 +153,10 @@ ndk-context = "0.1"
[dev-dependencies]
tempfile = "3.24.0"
# `net` for the loopback server in `download::worker::timeout_tests`; reqwest
# enables it transitively, but a test must not depend on that.
tokio = { version = "1", features = ["net"] }
wiremock = "0.6.5"
[features]
# Exposes the MediaPlayer conformance suite and the `player-conformance` binary
+10
View File
@@ -172,6 +172,16 @@ dependencies {
// itself: without a view to hand them to, a selected subtitle track renders
// nowhere. See JellyTauPlayer.onCues. (DR-260)
implementation("androidx.media3:media3-ui:1.5.0")
// Software audio decoders for what Android does not ship: AC-3, E-AC-3,
// DTS and TrueHD are licensed codecs, present only where a vendor paid for
// them (the ROD2-W09 tablet has DTS but no AC-3/E-AC-3 at all). With this,
// ExoPlayer plays the source file as-is, so neither a download nor a stream
// needs the server to re-encode its audio (DR-293). Jellyfin's own build of
// the media3 FFmpeg extension, versioned to match media3 above — keep the
// two in step. Licence: GPL-3.0 — the distributed APK carries its terms,
// the source stays MIT; see THIRD_PARTY_NOTICES.md and
// docs/architecture/05-platform-backends.md.
implementation("org.jellyfin.media3:media3-ffmpeg-decoder:1.5.0+1")
implementation("com.google.guava:guava:33.0.0-android")
// Media library for VolumeProviderCompat (remote volume control)
@@ -85,10 +85,9 @@ class MainActivity : TauriActivity() {
super.onWebViewCreate(webView)
android.util.Log.d("MainActivity", "onWebViewCreate - installing bridges before first page load")
mediaWebView = webView
// A new WebView means a new page, which reports no video yet. Anything the
// previous one left held would otherwise pin the screen on for the life of
// the process, since a page that goes away never sends its final
// setHtml5VideoState(false, …). (DR-202)
// A new WebView means a new page, which plays no video yet. Anything held
// for the previous one would otherwise pin the screen on for the life of
// the process. (DR-202)
ScreenWakeManager.releaseAll()
installJavascriptBridges(webView)
configureWebViewSettings(webView)
@@ -327,22 +326,6 @@ class MainActivity : TauriActivity() {
autoEnterPipEnabled = enabled
}
/**
* Report the WebView `<video>` state.
*
* Without this PiP only ever knew about the native ExoPlayer surface,
* which is behind an experimental flag that defaults to off — so in the
* shipping configuration nothing ever satisfied canEnterPip and the
* button did nothing. (DR-160)
*/
@JavascriptInterface
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
PictureInPictureManager.setHtml5VideoState(active, width, height, playing)
// The same report is what keeps the display awake on the webview
// rendering path — the WebView takes no display wake lock of its own
// for `<video>`. (DR-202)
ScreenWakeManager.onHtml5VideoState(active, playing)
}
}, "AndroidPictureInPicture")
android.util.Log.d("MainActivity", "JavaScript interface 'AndroidPictureInPicture' added")
@@ -46,52 +46,7 @@ object PictureInPictureManager {
private var receiver: BroadcastReceiver? = null
private var hiddenWebView: WebView? = null
/**
* State of an HTML5 `<video>` playing inside the WebView, reported by the
* frontend.
*
* PiP was written for the native ExoPlayer surface only — [canEnterPip]
* required a SurfaceView to be attached and rendering. But native video is
* behind `experimentalNativeVideo`, which defaults to **off**, so in the
* shipping configuration video plays in the WebView's `<video>` element and
* every one of those conditions is false. `enterPip` therefore always bailed
* with "no local video playing": PiP could not work at all, however the
* button was pressed.
*
* On this path the WebView *is* the video, which inverts two things: the
* WebView must stay visible in PiP rather than be hidden, and play/pause has
* to reach the element rather than ExoPlayer. Both are handled below.
*
* TRACES: UR-041 | DR-160
*/
@Volatile
private var html5VideoActive = false
@Volatile
private var html5VideoPlaying = false
@Volatile
private var html5AspectRatio: Rational? = null
/**
* Report the WebView `<video>` state from the frontend.
*
* @param active whether a video element is currently the playback surface
* @param width intrinsic video width, for the PiP window's aspect ratio
* @param height intrinsic video height
* @param playing whether it is playing right now, for the PiP play/pause action
*/
fun setHtml5VideoState(active: Boolean, width: Int, height: Int, playing: Boolean) {
html5VideoActive = active
html5VideoPlaying = playing
html5AspectRatio = if (active && width > 0 && height > 0) {
clampedRatio(width.toDouble() / height.toDouble())
} else {
null
}
}
/** True when PiP would be showing the native surface rather than the WebView. */
/** True when a native video surface is attached and playing. */
private fun isNativeVideoPath(): Boolean = try {
val player = JellyTauPlayer.getInstance()
player.isPlayingVideo() &&
@@ -120,10 +75,9 @@ object PictureInPictureManager {
*/
fun canEnterPip(activity: Activity): Boolean {
if (!isPipSupported(activity)) return false
// Either surface will do: the native one, or the WebView's `<video>`,
// which is what actually plays while experimentalNativeVideo is off.
// (DR-160)
return isNativeVideoPath() || html5VideoActive
// Video only ever renders on the native surface: the WebView `<video>`
// path is gone (DR-235). (DR-160)
return isNativeVideoPath()
}
/**
@@ -186,9 +140,7 @@ object PictureInPictureManager {
return clampedRatio(surface.width.toDouble() / surface.height.toDouble())
}
// No native surface: the WebView is the video, so use the intrinsic size
// the frontend reported. (DR-160)
return html5AspectRatio
return null
}
/**
@@ -207,16 +159,10 @@ object PictureInPictureManager {
@RequiresApi(Build.VERSION_CODES.O)
private fun buildPlayPauseAction(activity: Activity): RemoteAction {
// On the HTML5 path ExoPlayer is idle, so its `isPlaying` is always false
// and the button would be stuck showing "Play" mid-playback. (DR-160)
val isPlaying = if (isNativeVideoPath()) {
try {
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
} catch (e: Exception) {
false
}
} else {
html5VideoPlaying
val isPlaying = try {
JellyTauPlayer.getInstance().getExoPlayer().isPlaying
} catch (e: Exception) {
false
}
val (iconRes, title, controlType, requestCode) = if (isPlaying) {
@@ -288,11 +234,8 @@ object PictureInPictureManager {
*/
fun onPipModeChanged(activity: Activity, isInPipMode: Boolean) {
if (isInPipMode) {
// Hiding the WebView is correct only when the video is *behind* it on
// the native surface. On the HTML5 path the WebView is the video, so
// hiding it would leave an empty black PiP window — the frontend
// instead strips its own chrome when it hears the event below.
// (DR-160)
// The video is *behind* the WebView on the native surface, so hiding
// the WebView leaves only the picture. (DR-160)
if (isNativeVideoPath()) {
hideWebView(activity)
}
@@ -315,9 +258,8 @@ object PictureInPictureManager {
/**
* Fire a DOM event into the WebView.
*
* The HTML5 PiP path is a conversation with the frontend rather than
* something native can do alone: it has to be told to strip its chrome when
* the window shrinks, and to play/pause the element. (DR-160)
* Tells the frontend the window shrank or grew, so it can strip or restore
* its chrome. (DR-160)
*/
private fun dispatchWebEvent(activity: Activity, name: String) {
val webView = findWebView(activity.window.decorView) ?: return
@@ -358,28 +300,14 @@ object PictureInPictureManager {
if (intent?.action != ACTION_MEDIA_CONTROL) return
val control = intent.getIntExtra(EXTRA_CONTROL_TYPE, 0)
if (isNativeVideoPath()) {
val player = try {
JellyTauPlayer.getInstance()
} catch (e: Exception) {
return
}
when (control) {
CONTROL_PLAY -> player.play()
CONTROL_PAUSE -> player.pause()
}
} else {
// The WebView owns playback here, so the command has to reach
// the `<video>` element. Driving ExoPlayer instead would do
// nothing at all, which is what a PiP button on the HTML5 path
// used to do. (DR-160)
val name = when (control) {
CONTROL_PLAY -> "jellytau-pip-play"
CONTROL_PAUSE -> "jellytau-pip-pause"
else -> return
}
dispatchWebEvent(activity, name)
html5VideoPlaying = control == CONTROL_PLAY
val player = try {
JellyTauPlayer.getInstance()
} catch (e: Exception) {
return
}
when (control) {
CONTROL_PLAY -> player.play()
CONTROL_PAUSE -> player.pause()
}
// Swap the button to reflect the new state.
updatePipActions(activity)
@@ -7,14 +7,12 @@ import android.view.WindowManager
import java.lang.ref.WeakReference
/**
* Which playback paths currently want the screen kept awake.
* Whether playback currently wants the screen kept awake.
*
* Pure state, deliberately free of any Android type so it can be unit-tested —
* see ScreenWakeStateTest. Two independent holders, because video can be
* rendered by either renderer and only one of them is active at a time:
*
* - **native** — ExoPlayer drawing into the TextureView (DR-192)
* - **html5** — a `<video>` inside the WebView, reported by the frontend
* see ScreenWakeStateTest. The one holder is ExoPlayer drawing video into the
* TextureView (DR-192); the WebView `<video>` that was a second holder is gone
* (DR-235).
*
* Audio is deliberately *not* a holder. Playing music with the screen off is the
* point of the audio path; only video needs the display alive.
@@ -23,11 +21,10 @@ import java.lang.ref.WeakReference
*/
class ScreenWakeState {
private var nativeVideoPlaying = false
private var html5VideoPlaying = false
/** True while any video renderer is actively playing. */
/** True while video is actively playing. */
val keepScreenOn: Boolean
get() = nativeVideoPlaying || html5VideoPlaying
get() = nativeVideoPlaying
/**
* @param playing whether ExoPlayer is playing right now
@@ -37,18 +34,9 @@ class ScreenWakeState {
nativeVideoPlaying = playing && isVideo
}
/**
* @param active whether a webview `<video>` is the current playback surface
* @param playing whether that element is playing right now
*/
fun updateHtml5(active: Boolean, playing: Boolean) {
html5VideoPlaying = active && playing
}
/** Drop every hold (teardown, or a page that can no longer be trusted). */
fun reset() {
nativeVideoPlaying = false
html5VideoPlaying = false
}
}
@@ -66,9 +54,7 @@ class ScreenWakeState {
* appeared nowhere, and neither renderer supplies one for free — ExoPlayer's
* `setWakeMode` is a *CPU/wifi* wake lock and says nothing about the display,
* and it draws into a `TextureView` we own rather than a `PlayerView`, which is
* the media3 widget that would otherwise set `keepScreenOn` itself. The WebView
* `<video>` path does not either: the display wake lock Chrome takes for video
* lives in the browser layer, not in an embedded WebView.
* the media3 widget that would otherwise set `keepScreenOn` itself.
*
* ## Approach
*
@@ -78,15 +64,9 @@ class ScreenWakeState {
* missed release the way an explicitly acquired wake lock can. It needs no
* permission. (The manifest's `WAKE_LOCK` is the media service's, unrelated.)
*
* The two renderers report independently and are OR-ed together in
* [ScreenWakeState]:
*
* - `JellyTauPlayer.onIsPlayingChanged` and its surface teardown drive the
* native path — ExoPlayer is the authoritative source of playback state, so
* the hold follows what it reports rather than what the UI intends.
* - `MainActivity`'s `AndroidPictureInPicture.setHtml5VideoState` bridge drives
* the webview path. The frontend already reports that state on every
* play/pause and on player teardown for PiP, so no new bridge is needed.
* `JellyTauPlayer.onIsPlayingChanged` and its surface teardown drive
* [ScreenWakeState] — ExoPlayer is the authoritative source of playback state,
* so the hold follows what it reports rather than what the UI intends.
*
* The Activity reference is weak and re-set on every `onCreate`, so a
* recreation (rotation) re-applies the current hold to the new window.
@@ -126,20 +106,9 @@ object ScreenWakeManager {
}
/**
* The frontend reported the webview `<video>` state. Arrives on a WebView
* binder thread, hence the synchronization and the post to the main thread.
*/
@Synchronized
fun onHtml5VideoState(active: Boolean, playing: Boolean) {
state.updateHtml5(active, playing)
apply()
}
/**
* Drop every hold. Used when a new WebView/page load invalidates whatever the
* previous page last reported — a page that goes away without a final
* `setHtml5VideoState(false, …)` would otherwise leave the screen pinned on
* for the life of the process.
* Drop every hold. Used when a new WebView/page load starts from scratch, so
* nothing held for the previous page can pin the screen on for the life of
* the process.
*/
@Synchronized
fun releaseAll() {
@@ -3,8 +3,11 @@ package com.dtourolle.jellytau.player
import android.content.Context
import android.media.MediaCodecList
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.decoder.ffmpeg.FfmpegLibrary
import androidx.media3.exoplayer.audio.AudioCapabilities
/**
@@ -13,9 +16,24 @@ import androidx.media3.exoplayer.audio.AudioCapabilities
* This class queries the device's media codec capabilities and reports
* them to the Rust backend via JNI for accurate DeviceProfile generation.
*/
@OptIn(UnstableApi::class) // FfmpegLibrary and the licensed-codec MimeTypes
object CodecDetector {
private const val TAG = "CodecDetector"
/**
* Formats the FFmpeg extension can decode, as Jellyfin codec names. The
* platform decodes the rest itself; these are the licensed codecs a device
* often lacks.
*/
private val FFMPEG_AUDIO_FORMATS = listOf(
MimeTypes.AUDIO_AC3 to "ac3",
MimeTypes.AUDIO_E_AC3 to "eac3",
MimeTypes.AUDIO_E_AC3_JOC to "eac3",
MimeTypes.AUDIO_DTS to "dts",
MimeTypes.AUDIO_DTS_HD to "dts",
MimeTypes.AUDIO_TRUEHD to "truehd",
)
/**
* Data class to hold detected codec capabilities.
*/
@@ -67,6 +85,25 @@ object CodecDetector {
}
}
// The FFmpeg extension decodes in software what the platform lacks.
// ExoPlayer uses it for playback (JellyTauPlayer's renderers factory),
// so it belongs in the same list: Rust judges both the streaming
// profile and the download policy against this set, and a codec
// missing here is re-encoded by the server for nothing. Asked per
// format rather than assumed, so a build whose native library failed
// to load reports only what the platform itself decodes.
// TRACES: UR-004, UR-071 | DR-293
if (FfmpegLibrary.isAvailable()) {
for ((mime, codec) in FFMPEG_AUDIO_FORMATS) {
if (FfmpegLibrary.supportsFormat(mime)) {
audioCodecs.add(codec)
Log.d(TAG, "Audio codec: $codec (MIME: $mime, FFmpeg extension)")
}
}
} else {
Log.w(TAG, "FFmpeg extension unavailable; reporting platform decoders only")
}
Log.i(TAG, "Detected ${videoCodecs.size} video codecs: ${videoCodecs.sorted()}")
Log.i(TAG, "Detected ${audioCodecs.size} audio codecs: ${audioCodecs.sorted()}")
} catch (e: Exception) {
@@ -148,7 +185,12 @@ object CodecDetector {
"audio/eac3" -> "eac3"
"audio/eac3-joc" -> "eac3"
"audio/dts" -> "dts"
// The platform's own spelling — what MediaCodecList reports on the
// ROD2-W09. Only the `.hd` variant was listed, so plain DTS was
// detected by luck, through the HD decoder advertising both.
"audio/vnd.dts" -> "dts"
"audio/vnd.dts.hd" -> "dts"
"audio/true-hd" -> "truehd"
"audio/x-ms-wma" -> "wma"
"audio/amr-nb" -> "amrnb"
"audio/amr-wb" -> "amrwb"
@@ -19,6 +19,7 @@ import androidx.media3.common.MediaMetadata
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
@@ -332,6 +333,18 @@ class JellyTauPlayer(private val appContext: Context) {
//
// TRACES: UR-004, UR-006 | IR-008
exoPlayer = ExoPlayer.Builder(appContext)
// Extension renderers ON: the device's own decoders are tried first
// (a vendor DTS decoder stays in charge where there is one), and the
// FFmpeg audio renderer takes any format they cannot decode — AC-3,
// E-AC-3, TrueHD on a device without Dolby licensing. This is what
// lets the untouched source file play, instead of a server transcode.
// CodecDetector reports the same codecs to Rust, so the device
// profile and the download policy agree with what actually decodes.
// TRACES: UR-004, UR-071 | DR-293
.setRenderersFactory(
DefaultRenderersFactory(appContext)
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON)
)
// Decline the player's own load-error retry for a stream it could
// only restart (DR-203). Every other source keeps the default
// behaviour, which resumes the failed load where it stopped.
@@ -40,46 +40,9 @@ class ScreenWakeStateTest {
}
@Test
fun `webview video playing holds the screen on`() {
val state = ScreenWakeState()
state.updateHtml5(active = true, playing = true)
assertTrue(state.keepScreenOn)
}
@Test
fun `webview video paused releases the screen`() {
val state = ScreenWakeState()
state.updateHtml5(active = true, playing = true)
state.updateHtml5(active = true, playing = false)
assertFalse(state.keepScreenOn)
}
/** The element going away must release even if it never reported a pause. */
@Test
fun `webview video going inactive while playing releases the screen`() {
val state = ScreenWakeState()
state.updateHtml5(active = true, playing = true)
state.updateHtml5(active = false, playing = true)
assertFalse(state.keepScreenOn)
}
/** The two rendering paths are independent holders; either one is enough. */
@Test
fun `one path releasing does not release while the other still plays`() {
fun `teardown releases the hold`() {
val state = ScreenWakeState()
state.updateNative(playing = true, isVideo = true)
state.updateHtml5(active = true, playing = true)
state.updateHtml5(active = false, playing = false)
assertTrue(state.keepScreenOn)
state.updateNative(playing = false, isVideo = true)
assertFalse(state.keepScreenOn)
}
@Test
fun `teardown releases both paths`() {
val state = ScreenWakeState()
state.updateNative(playing = true, isVideo = true)
state.updateHtml5(active = true, playing = true)
state.reset()
assertFalse(state.keepScreenOn)
}
+29
View File
@@ -1,3 +1,32 @@
use std::path::PathBuf;
fn main() {
link_windows_libmpv();
tauri_build::build()
}
/// Point the MSVC linker at `mpv.lib` for a Windows target.
///
/// `libmpv-sys` emits `rustc-link-lib=mpv` and nothing else; on Linux the
/// system library satisfies it. For Windows the import library and the DLL it
/// names are staged into `src-tauri/windows-libs/` by
/// `scripts/build-windows-cross.sh` from the builder image's pinned libmpv
/// build — the same directory `tauri.windows.conf.json` bundles the DLL from,
/// so what is linked against and what is shipped cannot come from two places.
///
/// TRACES: UR-004 | DR-237
fn link_windows_libmpv() {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
return;
}
let dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("windows-libs");
println!("cargo:rerun-if-changed={}", dir.join("mpv.lib").display());
if !dir.join("mpv.lib").is_file() {
panic!(
"{} is missing. Windows builds link libmpv from the builder image; \
build through scripts/build-windows-cross.sh, which stages it.",
dir.join("mpv.lib").display()
);
}
println!("cargo:rustc-link-search=native={}", dir.display());
}
+137 -3
View File
@@ -19,6 +19,40 @@ pub struct ServerInfo {
pub id: String,
/// Normalized server URL with protocol and no trailing slash
pub normalized_url: String,
/// Whether this build can talk to this server, as an **opaque state**.
///
/// The version string above is informational — for display and for the log.
/// This is the judgement, made in Rust, because deciding whether an API
/// version is usable is domain reasoning: the frontend must never compare a
/// version number, for the same reason it never receives an item-type list.
///
/// TRACES: UR-085 | DR-286
pub compatibility: ServerCompatibility,
}
/// The verdict on a server's version.
///
/// Deliberately three states rather than a boolean. "Unrecognised" is not a
/// failure: a server newer than this build resolves forward and works, and
/// refusing it would make every JellyTau release expire the moment the server
/// upgrades. Only a server below the supported floor is refused, where failure
/// is certain rather than merely likely.
///
/// TRACES: UR-085 | DR-286
#[derive(specta::Type, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum ServerCompatibility {
/// A generation this build knows and was tested against.
Supported,
/// Parsed, but newer than anything this build knows. Treated as the newest
/// known generation; everything works, and this exists so the UI *may*
/// mention it rather than so it must.
NewerThanKnown,
/// The version string could not be parsed. Treated as supported — we do not
/// refuse a server on the strength of not understanding its version string.
UnknownVersion,
/// Below the supported floor. This one is a refusal.
TooOld { minimum: String },
}
/// User information
@@ -166,11 +200,35 @@ impl AuthManager {
monitor.mark_reachable().await;
}
let capabilities =
crate::repository::capabilities::ServerCapabilities::from_reported(
&info.version,
);
let compatibility = if capabilities.is_below_supported_floor() {
let (major, minor) =
crate::repository::capabilities::MINIMUM_SUPPORTED_MAJOR_MINOR;
ServerCompatibility::TooOld {
minimum: format!("{major}.{minor}"),
}
} else {
use crate::repository::capabilities::ServerGeneration;
match capabilities.generation {
ServerGeneration::Unknown => ServerCompatibility::UnknownVersion,
ServerGeneration::V12Plus
if capabilities.version.as_ref().is_some_and(|v| v.major > 12) =>
{
ServerCompatibility::NewerThanKnown
}
_ => ServerCompatibility::Supported,
}
};
Ok(ServerInfo {
name: info.server_name,
version: info.version,
id: info.id,
normalized_url,
compatibility,
})
}
Err(e) => {
@@ -210,7 +268,7 @@ impl AuthManager {
.client
.post(&endpoint)
.header("Content-Type", "application/json")
.header("X-Emby-Authorization", auth_header)
.header("Authorization", auth_header)
.json(&serde_json::json!({
"Username": username,
"Pw": password,
@@ -286,7 +344,7 @@ impl AuthManager {
.http_client
.client
.get(&endpoint)
.header("X-Emby-Authorization", auth_header)
.header("Authorization", auth_header)
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
@@ -365,7 +423,7 @@ impl AuthManager {
.http_client
.client
.post(&endpoint)
.header("X-Emby-Authorization", auth_header)
.header("Authorization", auth_header)
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
@@ -397,6 +455,82 @@ impl AuthManager {
}
}
#[cfg(test)]
mod compatibility_tests {
use super::*;
use crate::repository::capabilities::ServerCapabilities;
/// Mirror of the mapping in `connect_to_server`, so the verdict can be
/// asserted without standing up an HTTP server.
fn verdict(reported: &str) -> ServerCompatibility {
let capabilities = ServerCapabilities::from_reported(reported);
if capabilities.is_below_supported_floor() {
let (major, minor) = crate::repository::capabilities::MINIMUM_SUPPORTED_MAJOR_MINOR;
return ServerCompatibility::TooOld {
minimum: format!("{major}.{minor}"),
};
}
use crate::repository::capabilities::ServerGeneration;
match capabilities.generation {
ServerGeneration::Unknown => ServerCompatibility::UnknownVersion,
ServerGeneration::V12Plus
if capabilities.version.as_ref().is_some_and(|v| v.major > 12) =>
{
ServerCompatibility::NewerThanKnown
}
_ => ServerCompatibility::Supported,
}
}
/// Both live generations are supported outright. 12.0 is the current stable
/// and 10.11.x is what this client was built against.
///
/// TRACES: UR-085 | DR-286
#[test]
fn both_live_generations_are_supported() {
assert_eq!(verdict("10.11.5"), ServerCompatibility::Supported);
assert_eq!(verdict("10.11.11"), ServerCompatibility::Supported);
assert_eq!(verdict("12.0.0"), ServerCompatibility::Supported);
}
/// A server newer than this build is usable, not refused — otherwise every
/// release would expire the moment the server upgraded.
///
/// TRACES: UR-085 | DR-286
#[test]
fn a_newer_server_is_usable_not_refused() {
assert_eq!(verdict("13.0.0"), ServerCompatibility::NewerThanKnown);
assert_eq!(verdict("99.1.2"), ServerCompatibility::NewerThanKnown);
}
/// An unreadable version is not grounds for refusal.
///
/// TRACES: UR-085 | DR-286
#[test]
fn an_unreadable_version_is_not_a_refusal() {
assert_eq!(
verdict("not-a-version"),
ServerCompatibility::UnknownVersion
);
assert_eq!(verdict(""), ServerCompatibility::UnknownVersion);
}
/// Only a server below the floor is refused, and it says what the floor is
/// so the message can name it.
///
/// TRACES: UR-085 | DR-286
#[test]
fn only_a_server_below_the_floor_is_refused() {
assert_eq!(
verdict("10.9.11"),
ServerCompatibility::TooOld {
minimum: "10.10".to_string()
}
);
assert_eq!(verdict("10.10.0"), ServerCompatibility::Supported);
}
}
#[cfg(test)]
mod tests {
use super::*;
+99 -16
View File
@@ -517,6 +517,34 @@ pub(crate) async fn requeue_mistyped_video_downloads(
Ok(n)
}
/// What a resolver hands back for one queued row: the URL to fetch and, for a
/// video, the size predicted for it (see `download::estimate`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ResolvedDownloadUrl {
pub url: String,
pub expected_bytes: Option<u64>,
}
impl From<String> for ResolvedDownloadUrl {
/// An audio stream URL: served static, so the response states its own
/// length and nothing needs predicting.
fn from(url: String) -> Self {
Self {
url,
expected_bytes: None,
}
}
}
impl From<crate::repository::ResolvedVideoDownload> for ResolvedDownloadUrl {
fn from(r: crate::repository::ResolvedVideoDownload) -> Self {
Self {
url: r.url,
expected_bytes: r.expected_bytes,
}
}
}
/// Core of [`resume_queued_downloads`], factored out for testing: select every
/// `pending`/`stream_url IS NULL` row, resolve each via `resolve` (returning
/// `None` leaves the row pending), and heal the row so the pump can start it.
@@ -534,7 +562,7 @@ pub(crate) async fn resolve_pending_download_urls<F, Fut>(
) -> Result<ResumeQueuedResult, String>
where
F: Fn(String, String, String) -> Fut,
Fut: std::future::Future<Output = Option<String>>,
Fut: std::future::Future<Output = Option<ResolvedDownloadUrl>>,
{
if only_ids.is_some_and(|ids| ids.is_empty()) {
return Ok(ResumeQueuedResult {
@@ -600,8 +628,8 @@ where
let mut failed = 0usize;
for (download_id, item_id, media_type, quality) in rows {
let stream_url = match resolve(item_id.clone(), media_type, quality).await {
Some(url) => url,
let target = match resolve(item_id.clone(), media_type, quality).await {
Some(target) => target,
None => {
failed += 1;
continue;
@@ -609,13 +637,21 @@ where
};
// Heal the row so the pump can start it. Guard on stream_url IS NULL so a
// concurrent resolver doesn't clobber an already-started row.
// concurrent resolver doesn't clobber an already-started row. The
// predicted size, when there is one, gives the worker a progress total
// for a response that carries none (DR-290).
let expected = target
.expected_bytes
.and_then(|n| i64::try_from(n).ok())
.map_or(QueryParam::Null, QueryParam::Int64);
let update = Query::with_params(
"UPDATE downloads SET stream_url = ?, target_dir = ?
"UPDATE downloads SET stream_url = ?, target_dir = ?,
file_size = COALESCE(?, file_size)
WHERE id = ? AND status = 'pending' AND stream_url IS NULL",
vec![
QueryParam::String(stream_url),
QueryParam::String(target.url),
QueryParam::String(target_dir.to_string()),
expected,
QueryParam::Int64(download_id),
],
);
@@ -705,17 +741,18 @@ pub async fn resume_queued_downloads(
async move {
if media_type == "video" {
Some(
crate::repository::resolve_video_download_url(
crate::repository::resolve_video_download(
repo.as_ref(),
&item_id,
&quality,
None,
)
.await,
.await
.into(),
)
} else {
match repo.get_audio_stream_url(&item_id).await {
Ok(url) => Some(url),
Ok(url) => Some(url.into()),
Err(e) => {
warn!(
"[Catalog] Failed to resolve audio URL for {}: {:?}",
@@ -807,6 +844,7 @@ mod tests {
target_dir TEXT,
media_type TEXT,
quality_preset TEXT,
file_size INTEGER,
progress REAL DEFAULT 0,
bytes_downloaded INTEGER DEFAULT 0,
started_at TEXT,
@@ -890,7 +928,7 @@ mod tests {
&db,
"/data/downloads",
None,
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}").into()) },
)
.await
.unwrap();
@@ -932,7 +970,7 @@ mod tests {
&db,
"/data",
Some(&[mine]),
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}")) },
|item_id, _mt, _q| async move { Some(format!("http://resolved/{item_id}").into()) },
)
.await
.unwrap();
@@ -962,7 +1000,7 @@ mod tests {
let out =
resolve_pending_download_urls(&db, "/data", Some(&[]), |item_id, _mt, _q| async move {
Some(format!("http://resolved/{item_id}"))
Some(format!("http://resolved/{item_id}").into())
})
.await
.unwrap();
@@ -1015,7 +1053,7 @@ mod tests {
let seen = Arc::clone(&seen_c);
async move {
seen.lock_safe().push((item_id.clone(), media_type));
Some(format!("http://resolved/{item_id}"))
Some(format!("http://resolved/{item_id}").into())
}
})
.await
@@ -1048,7 +1086,7 @@ mod tests {
let seen = Arc::clone(&seen_c);
async move {
*seen.lock_safe() = media_type;
Some("http://x".to_string())
Some("http://x".to_string().into())
}
})
.await
@@ -1072,7 +1110,7 @@ mod tests {
let seen = Arc::clone(&seen_c);
async move {
*seen.lock_safe() = media_type;
Some("http://x".to_string())
Some("http://x".to_string().into())
}
})
.await
@@ -1123,6 +1161,51 @@ mod tests {
assert_eq!(status, "completed", "a correct video download is untouched");
}
/// A transcode answers with no `Content-Length`, so the worker's only
/// chance at a progress total is the size predicted at resolve time. That
/// prediction has to reach the row, and only where there is one — an
/// audio row's `None` must not null out a size the row already holds.
///
/// TRACES: UR-071 | DR-290 | UT-253
#[tokio::test]
async fn resolving_persists_the_predicted_size_without_erasing_a_known_one() {
let db = test_db();
insert_download(&db, "film", "pending", None, Some("video")).await;
insert_download(&db, "track", "pending", None, Some("audio")).await;
db.execute(Query::with_params(
"UPDATE downloads SET file_size = 777 WHERE item_id = ?",
vec![QueryParam::String("track".to_string())],
))
.await
.unwrap();
resolve_pending_download_urls(&db, "/data", None, |item_id, media_type, _q| async move {
Some(ResolvedDownloadUrl {
url: format!("http://resolved/{item_id}"),
expected_bytes: (media_type == "video").then_some(1_500_000_000),
})
})
.await
.unwrap();
let size = |item: &'static str| {
let db = Arc::clone(&db);
async move {
db.query_one(
Query::with_params(
"SELECT file_size FROM downloads WHERE item_id = ?",
vec![QueryParam::String(item.to_string())],
),
|row| row.get::<_, Option<i64>>(0),
)
.await
.unwrap()
}
};
assert_eq!(size("film").await, Some(1_500_000_000));
assert_eq!(size("track").await, Some(777));
}
#[tokio::test]
async fn video_rows_use_media_type_in_resolver() {
let db = test_db();
@@ -1134,7 +1217,7 @@ mod tests {
None,
|item_id, media_type, _q| async move {
assert_eq!(media_type, "video");
Some(format!("http://transcode/{item_id}"))
Some(format!("http://transcode/{item_id}").into())
},
)
.await
+67 -19
View File
@@ -185,18 +185,45 @@ fn confine_to_root(root: &Path, candidate: &Path) -> Result<PathBuf, String> {
///
/// TRACES: DR-211 | UT-205
fn confine_queued_path(root: &Path, file_path: &str) -> Result<String, String> {
// The returned spelling keeps the caller's own separator. Rebuilding it with
// `PathBuf::push` rewrote `downloads/x` as `downloads\x` on Windows, so the
// stored row no longer spelled the path the app built — the contract the
// test below pins, which only ever ran on Linux.
// TRACES: DR-211 | UT-205
let sep = file_path
.chars()
.find(|c| std::path::is_separator(*c))
.unwrap_or('/');
let mut sanitized = PathBuf::new();
let mut spelled = String::new();
let mut need_sep = false;
for component in Path::new(file_path).components() {
match component {
Component::Normal(part) => sanitized.push(sanitize_filename(&part.to_string_lossy())),
let piece = match component {
Component::Normal(part) => sanitize_filename(&part.to_string_lossy()),
// Kept as they are, so `confine_to_root` is the single thing
// deciding whether what they add up to is still inside the root.
other => sanitized.push(other),
other => other.as_os_str().to_string_lossy().into_owned(),
};
sanitized.push(&piece);
match component {
Component::Prefix(_) => spelled.push_str(&piece),
Component::RootDir => {
spelled.push(sep);
need_sep = false;
continue;
}
_ => {
if need_sep {
spelled.push(sep);
}
spelled.push_str(&piece);
}
}
need_sep = !matches!(component, Component::Prefix(_));
}
confine_to_root(root, &root.join(&sanitized))?;
Ok(sanitized.to_string_lossy().to_string())
Ok(spelled)
}
/// Request payload for download_item_and_start (bundled to stay within specta's
@@ -759,7 +786,7 @@ pub async fn download_album(
async move {
use crate::repository::MediaRepository;
match repo.get_audio_stream_url(&item_id).await {
Ok(url) => Some(url),
Ok(url) => Some(url.into()),
Err(e) => {
warn!(
"[download_album] Failed to resolve stream URL for {}: {:?}",
@@ -1604,6 +1631,9 @@ pub async fn start_download(
item_id,
stream_url,
target_path,
file_size_from_server
.or(file_size)
.and_then(|n| u64::try_from(n).ok()),
active_downloads,
);
@@ -1703,15 +1733,20 @@ pub async fn enqueue_video_downloads(
// Build the download URL, resolving the source's audio codec first so a
// track this device cannot decode is re-encoded on the way down rather
// than saved as a silent file (DR-167).
let stream_url =
crate::repository::resolve_video_download_url(repo.as_ref(), &item_id, &quality, None)
let resolved =
crate::repository::resolve_video_download(repo.as_ref(), &item_id, &quality, None)
.await;
// The predicted size becomes the row's `file_size` so the worker has a
// total to report against when the response has none (DR-290). A
// size the server states later replaces it on completion.
let update_query = Query::with_params(
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ? WHERE id = ?",
"UPDATE downloads SET status = 'pending', stream_url = ?, target_dir = ?, \
file_size = COALESCE(?, file_size) WHERE id = ?",
vec![
QueryParam::String(stream_url),
QueryParam::String(resolved.url),
QueryParam::String(target_dir.clone()),
expected_bytes_param(resolved.expected_bytes),
QueryParam::Int64(download_id),
],
);
@@ -1819,7 +1854,7 @@ pub(crate) async fn pump_download_queue(
// Find the next pending, startable download (has a stream URL). Exclude
// anything already registered as active to avoid double-starting.
let next_query = Query::with_params(
"SELECT id, item_id, file_path, stream_url, target_dir
"SELECT id, item_id, file_path, stream_url, target_dir, file_size
FROM downloads
WHERE status = 'pending'
AND stream_url IS NOT NULL
@@ -1828,7 +1863,7 @@ pub(crate) async fn pump_download_queue(
vec![],
);
let candidates: Vec<(i64, String, String, String, String)> = match db_service
let candidates: Vec<(i64, String, String, String, String, Option<i64>)> = match db_service
.query_many(next_query, |row| {
Ok((
row.get(0)?,
@@ -1836,6 +1871,7 @@ pub(crate) async fn pump_download_queue(
row.get(2)?,
row.get(3)?,
row.get(4)?,
row.get(5)?,
))
})
.await
@@ -1848,14 +1884,14 @@ pub(crate) async fn pump_download_queue(
};
// Pick the first candidate not already active.
let next = candidates.into_iter().find(|(id, _, _, _, _)| {
let next = candidates.into_iter().find(|(id, _, _, _, _, _)| {
active_downloads
.lock()
.map(|active| !active.contains(id))
.unwrap_or(false)
});
let (download_id, item_id, file_path, stream_url, target_dir) = match next {
let (download_id, item_id, file_path, stream_url, target_dir, file_size) = match next {
Some(n) => n,
None => return, // Nothing pending to start
};
@@ -1943,11 +1979,20 @@ pub(crate) async fn pump_download_queue(
item_id,
stream_url,
target_path,
file_size.and_then(|n| u64::try_from(n).ok()),
active_downloads.clone(),
);
}
}
/// A predicted size as a bind parameter: `NULL` keeps whatever the row holds.
fn expected_bytes_param(expected: Option<u64>) -> QueryParam {
match expected.and_then(|n| i64::try_from(n).ok()) {
Some(n) => QueryParam::Int64(n),
None => QueryParam::Null,
}
}
/// Spawn the background worker for one download. On completion or failure it
/// unregisters the slot, emits the terminal event, and pumps the queue so the
/// next pending download starts automatically.
@@ -1957,6 +2002,7 @@ fn spawn_download_worker(
item_id: String,
stream_url: String,
target_path: std::path::PathBuf,
expected_bytes: Option<u64>,
active_downloads: Arc<Mutex<std::collections::HashSet<i64>>>,
) {
use crate::download::events::DownloadEvent;
@@ -1975,18 +2021,19 @@ fn spawn_download_worker(
// Progress callback that emits events to the frontend
let progress_app = app.clone();
let progress_item_id = item_id.clone();
let on_progress = move |bytes_downloaded: u64, total_bytes: Option<u64>| {
let progress = total_bytes
.filter(|&t| t > 0)
.map(|t| bytes_downloaded as f64 / t as f64)
.unwrap_or(0.0);
let on_progress = move |bytes_downloaded: u64, content_length: Option<u64>| {
// The server's length when it gave one; the prediction made at
// resolve time when it did not (a transcode). DR-290
let total = crate::download::estimate::progress_total(content_length, expected_bytes);
let progress = crate::download::estimate::progress_fraction(bytes_downloaded, total);
let event = DownloadEvent::Progress {
download_id,
item_id: progress_item_id.clone(),
bytes_downloaded: bytes_downloaded as i64,
total_bytes: total_bytes.map(|t| t as i64),
total_bytes: total.map(|t| t.bytes as i64),
progress,
estimated: total.is_some_and(|t| t.estimated),
};
let _ = progress_app.emit("download-event", event);
};
@@ -2060,6 +2107,7 @@ fn spawn_download_worker(
download_id,
item_id,
file_path,
bytes_downloaded: res.bytes_downloaded as i64,
};
match app.emit("download-event", completed_event) {
Ok(_) => debug!(" Completed event emitted successfully"),
+106 -195
View File
@@ -69,10 +69,6 @@ pub struct PlayerStatus {
pub muted: bool,
pub shuffle: bool,
pub repeat: RepeatMode,
/// Backend being used (native = ExoPlayer/libmpv, html5 = fallback)
pub backend: VideoBackend,
/// Whether frontend should render HTML5 video element
pub use_html5_element: bool,
// Merged fields (prefer remote session when available)
/// Media item from either local queue or remote session
@@ -156,16 +152,6 @@ pub struct QueueStatus {
pub has_previous: bool,
}
/// Backend type for video playback
#[derive(specta::Type, Debug, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum VideoBackend {
/// Native backend (ExoPlayer on Android, libmpv on Linux)
Native,
/// HTML5 video element fallback
Html5,
}
/// Request to play a single video item
///
/// Simplified to video playback only. Audio playback uses player_play_tracks
@@ -217,9 +203,8 @@ pub struct PlayItemRequest {
pub series_id: Option<String>,
/// Subtitle tracks to sideload, with URLs the frontend has already resolved.
///
/// Only the native backends use these: on Android they become the
/// `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
/// builds its own `<track>` children instead and ignores this list.
/// On Android they become the `MediaItem.SubtitleConfiguration`s ExoPlayer
/// renders; mpv loads them as external subtitle files (`mpv_tracks`).
///
/// **Order is the contract.** `player_set_subtitle_track(n)` reaches
/// `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
@@ -328,19 +313,6 @@ pub enum VideoSeekResponse {
/// Confirmed position after seek
position: f64,
},
/// Reload stream from new position (transcoded non-HLS)
ReloadStream {
/// What to open, and how — transport included, so the frontend picks
/// its loader from a tagged enum rather than by searching the URL for
/// `.m3u8`. TRACES: UR-079 | DR-225
selection: StreamSelection,
/// `seek_offset` carries the position to RESUME AT, not a base to add to
/// the element's clock. The reloaded stream starts at the item's zero —
/// a position on an HLS playlist makes the server 400 every segment
/// behind it (DR-181) — so the adapter reaches the position by seeking
/// the element and leaves the transcode offset at zero.
seek_offset: f64,
},
}
/// Response for audio track switching operations
@@ -352,13 +324,6 @@ pub enum AudioTrackSwitchResponse {
/// Confirmation message
success: bool,
},
/// HTML5 needs to reload stream with new audio track
ReloadStream {
/// What to open, and how. TRACES: UR-079 | DR-225
selection: StreamSelection,
/// Current position to resume from
position: f64,
},
}
/// Response for a mid-playback streaming-quality change.
@@ -388,16 +353,6 @@ pub enum StreamQualityResponse {
/// Position playback resumed at.
position: f64,
},
/// HTML5 must reload its element with this selection.
ReloadStream {
/// What to open, and how — already negotiated against the requested
/// ceiling. Carries `available` too, so a picker opened after a quality
/// change still describes the source correctly.
/// TRACES: UR-070, UR-079 | DR-225, DR-227
selection: StreamSelection,
/// Position to resume from.
position: f64,
},
}
/// Helper function to create MediaItem from video request
@@ -726,34 +681,18 @@ pub async fn player_play_item(
}
let controller = player.0.lock().await;
// Who gets the stream depends on who is going to *render* it, which is a
// runtime question, not a platform constant.
// The backend always gets the stream: every video renderer is native (mpv,
// ExoPlayer) since the webview path was deleted (DR-235). This used to ask
// who would render — the webview's `<video>` played it itself, so the
// backend was only told about it (`set_current_item`) — and got the answer
// wrong twice: the Linux guard silenced mpv video entirely once mpv drew the
// picture, and on Windows it loaded video into the backend while the status
// sent it to the element too.
//
// Historically Linux video was always the webview's (`use_html5_element`),
// so handing the file to MPV as well would only have started a redundant
// decode with no window to show it in — hence a `#[cfg(not(linux))]` guard
// and a queue-only path here. With mpv drawing the picture that inverts:
// the webview is no longer loading anything, so if this does not load the
// file, *nothing does*. The symptom is total silence — no picture and no
// audio — which reads like a broken stream rather than a stream nobody was
// given.
//
// This is the fifth place in this cycle where a renderer's capability was
// written as a compile-time platform fact. Same fix as the others: ask.
//
// TRACES: UR-080 | DR-231, DR-235
let renders_natively = cfg!(not(target_os = "linux")) || crate::player::native_video::enabled();
if renders_natively {
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
} else {
// The webview will play it; keep the queue in sync for the UI and for a
// remote transfer without starting a second decode.
controller
.set_current_item(media_item)
.map_err(|e| e.to_string())?;
}
// TRACES: UR-080 | DR-231, DR-235, DR-237
controller
.play_item(media_item)
.map_err(|e| e.to_string())?;
// Emit queue changed event
controller.emit_queue_changed();
@@ -776,8 +715,8 @@ pub async fn player_play_item(
/// `stream_url` MUST be an audio-only URL (see
/// `get_audio_only_stream_url_for_video`). The item is created as
/// `MediaType::Audio` so it starts an audio session and loads into the native
/// backend with `mediaType="audio"` — the WebView `<video>` is torn down on the
/// frontend side, so exactly one audio source is ever active.
/// backend with `mediaType="audio"`, replacing the video, so exactly one audio
/// source is ever active.
///
/// This deliberately goes through the queue-based `play_item` path (NOT a
/// side-channel) so end-of-track lands in `on_playback_ended`, which already
@@ -893,7 +832,7 @@ pub async fn player_enter_background_audio(
}
/// Exit background-audio mode: stop the native audio player and return its final
/// position so the frontend can reload the WebView `<video>` there (UR-040).
/// position so the frontend can reload the video there (UR-040).
///
/// Returns the position in seconds. The sleep timer is intentionally left
/// untouched — if it fired while backgrounded, playback is already stopped and
@@ -939,12 +878,17 @@ pub async fn player_background_action(
Ok(action)
}
/// TRACES: UR-040 | DR-052 | UT-061, IT-013
/// Returns the item the native player is on and its absolute position. The
/// item matters: an episode that ended while backgrounded has already advanced
/// in the backend, so reloading the video the webview was mounted with would
/// bring back the previous episode. (DR-296)
///
/// TRACES: UR-040, UR-023 | DR-052, DR-296 | UT-061, IT-013
#[tauri::command]
#[specta::specta]
pub async fn player_exit_background_audio(
player: State<'_, PlayerStateWrapper>,
) -> Result<f64, String> {
) -> Result<crate::player::BackgroundAudioResume, String> {
let controller = player.0.lock().await;
// Read the position BEFORE clearing either base. The position tick applies the
@@ -954,23 +898,23 @@ pub async fn player_exit_background_audio(
// lock discipline from CLAUDE.md: never hold work across a re-entrant call.
// (DR-159)
//
// `absolute_position` rather than `position`, because a tick that has not
// `background_audio_resume` reads `absolute_position` rather than `position`, because a tick that has not
// landed *yet* is the same hazard from the other side: returning to the
// foreground while the audio-only transcode is still opening read 0.0, and
// the video reloaded at StartTimeTicks=0 — the episode restarting from the
// beginning. Flooring at the handoff base cannot overshoot: the stream is
// physically incapable of being behind its own starting point. (DR-178)
let absolute = controller.absolute_position();
let resume = controller.background_audio_resume();
// Now safe to tear the handoff down, native side first.
let _ = crate::player::set_lockscreen_position_offset(0.0);
controller.exit_background_audio();
controller.stop().map_err(|e| e.to_string())?;
info!(
"player_exit_background_audio: resuming the video at {:.1}s",
absolute
"player_exit_background_audio: resuming {:?} at {:.1}s",
resume.item_id, resume.position_seconds
);
Ok(absolute)
Ok(resume)
}
/// Play a queue of media items
@@ -1183,8 +1127,9 @@ pub async fn player_stop(
let mode = playback_mode.0.get_mode();
// Stopping is a state transition worth seeing in a log. Native video is
// what made its absence matter: the webview <video> stopped implicitly when
// the component unmounted, so nothing ever had to call this — and "never
// what made its absence matter: the (since deleted) webview <video> stopped
// implicitly when the component unmounted, so nothing ever had to call
// this — and "never
// called" and "called but the backend kept playing" look identical from
// outside without it.
info!("[player_stop] called (mode: {:?})", mode);
@@ -1425,8 +1370,8 @@ pub async fn player_seek(
/// - Direct play streams: Use native seeking
/// - Transcoded non-HLS: Request new stream URL from server starting at seek position
///
/// For native (non-HTML5) backends, this command handles the entire stream reload
/// internally. For HTML5 backends, it returns the new URL for the frontend to handle.
/// The backend always handles the seek itself, including re-opening a stream,
/// since every video renderer is native (DR-235).
#[tauri::command]
#[specta::specta]
pub async fn player_seek_video(
@@ -1436,12 +1381,8 @@ pub async fn player_seek_video(
position: f64,
media_source_id: Option<String>,
audio_stream_index: Option<i32>,
use_html5: bool,
) -> Result<VideoSeekResponse, String> {
info!(
"[player_seek_video] Seeking to {} seconds (use_html5: {})",
position, use_html5
);
info!("[player_seek_video] Seeking to {} seconds", position);
// Get repository
let repository = repository_manager
@@ -1483,17 +1424,13 @@ pub async fn player_seek_video(
let controller = player.0.lock().await;
controller.capabilities().seeks_transcoded_in_place
};
let strategy = determine_video_seek_strategy(
is_local,
seeks_transcoded_in_place,
needs_transcoding,
use_html5,
);
let strategy =
determine_video_seek_strategy(is_local, seeks_transcoded_in_place, needs_transcoding);
info!(
"[player_seek_video] Stream analysis: is_local={}, seeks_transcoded_in_place={}, \
needs_transcoding={}, use_html5={}, strategy={:?}",
is_local, seeks_transcoded_in_place, needs_transcoding, use_html5, strategy
needs_transcoding={}, strategy={:?}",
is_local, seeks_transcoded_in_place, needs_transcoding, strategy
);
match strategy {
@@ -1504,35 +1441,6 @@ pub async fn player_seek_video(
controller.seek(position).map_err(|e| e.to_string())?;
Ok(VideoSeekResponse::Native { position })
}
VideoSeekStrategy::Html5NativeSeek => {
// HTML5 backend with HLS or direct play - frontend handles seeking
// We don't call backend.seek() because video is in HTML5 element, not in MPV
info!("[player_seek_video] HTML5 native seek - returning position for frontend");
Ok(VideoSeekResponse::Native { position })
}
VideoSeekStrategy::Html5ReloadStream => {
// Transcoded non-HLS with HTML5 - frontend handles stream reload
info!("[player_seek_video] HTML5 reload stream - requesting new stream URL");
let selection = repository
.get_stream_selection(
&jellyfin_item_id,
media_source_id.as_deref(),
audio_stream_index,
)
.await
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
info!(
"[player_seek_video] Selected {:?} over {:?} for position {}",
selection.playback_kind, selection.transport, position
);
Ok(VideoSeekResponse::ReloadStream {
selection,
seek_offset: position,
})
}
VideoSeekStrategy::BackendReloadStream => {
// Transcoded non-HLS with native backend - backend handles stream reload
info!("[player_seek_video] Backend reload stream - requesting new stream URL");
@@ -1603,9 +1511,6 @@ pub async fn player_seek_video(
/// carries the requested track at all** — see
/// [`determine_audio_track_switch_strategy`]:
///
/// - An HTML5 `<video>` element has no track-selection API, so the stream is
/// always re-opened at the chosen `AudioStreamIndex` and the frontend seeks
/// the reloaded element back to `position`.
/// - A native backend playing a **direct play** holds the source file with
/// every track in it, so ExoPlayer selects in place by track-group index.
/// - A native backend playing a **transcode** does not. Jellyfin builds a
@@ -1621,10 +1526,8 @@ pub async fn player_seek_video(
/// audio track index` and dropped the request — the default track just kept
/// playing, with nothing in the UI saying so.
///
/// libmpv implements neither selection nor reload here — it is the audio-only
/// backend and leaves `PlayerBackend::set_audio_track` at its
/// `not_implemented()` default, which is why IR-019 is met by these paths
/// rather than by MPV.
/// mpv selects in place the same way (`mpv_tracks::select_audio`, by position in
/// the file's audio tracks), and re-opens a transcode through the same path.
///
/// TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258
#[tauri::command]
@@ -1639,12 +1542,13 @@ pub async fn player_switch_audio_track(
repository_handle: String,
stream_index: i32,
array_index: i32,
use_html5: bool,
current_position: Option<f64>,
media_source_id: Option<String>,
) -> Result<AudioTrackSwitchResponse, String> {
info!("[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}, use_html5: {}",
stream_index, array_index, use_html5);
info!(
"[player_switch_audio_track] Switching to audio track - stream_index: {}, array_index: {}",
stream_index, array_index
);
// Read what the engine is playing before deciding anything — including
// where it is, which has to be captured before the stop below wipes it.
@@ -1668,11 +1572,11 @@ pub async fn player_switch_audio_track(
)
};
let strategy = determine_audio_track_switch_strategy(needs_transcoding, use_html5);
let strategy = determine_audio_track_switch_strategy(needs_transcoding);
info!(
"[player_switch_audio_track] needs_transcoding={}, use_html5={}, strategy={:?}",
needs_transcoding, use_html5, strategy
"[player_switch_audio_track] needs_transcoding={}, strategy={:?}",
needs_transcoding, strategy
);
if strategy == AudioTrackSwitchStrategy::BackendSelectInPlace {
@@ -1693,7 +1597,7 @@ pub async fn player_switch_audio_track(
// Select a stream carrying the chosen audio track. It starts at zero —
// an HLS playlist cannot carry a position (DR-181) — so the position is
// restored by seeking afterwards, here or in the frontend.
// restored by seeking afterwards.
//
// Pinning a track is itself a reason the source cannot be direct-played:
// the file has one default track and the viewer asked for another, so
@@ -1714,10 +1618,6 @@ pub async fn player_switch_audio_track(
let position = crate::player::track_switch::resume_position(current_position, engine_position);
match strategy {
AudioTrackSwitchStrategy::Html5ReloadStream => Ok(AudioTrackSwitchResponse::ReloadStream {
selection,
position,
}),
AudioTrackSwitchStrategy::BackendReloadStream => {
// The native backend re-opens its own stream, the same sequence the
// transcoded seek and quality change use: stop, repoint the queue
@@ -1776,9 +1676,7 @@ pub async fn player_switch_audio_track(
/// A cap is a property of the stream the server is producing, so unlike a volume
/// change it cannot be applied to a stream already in flight — the stream has to
/// be re-opened at the new quality and resumed at the current position. That is
/// the same reload the transcoded-seek and audio-track paths use, and the same
/// two-sided split: HTML5 gets the URL back and reloads its own element, while a
/// native backend is reloaded here.
/// the same reload the transcoded-seek and audio-track paths use, done here.
///
/// The change applies to **this playback only**. The in-player picker is a
/// "this film, this connection" control and its doc has always said so, but it
@@ -1802,15 +1700,13 @@ pub async fn player_set_stream_quality(
repository_manager: State<'_, super::repository::RepositoryManagerWrapper>,
repository_handle: String,
quality: crate::settings::StreamingQuality,
use_html5: bool,
current_position: Option<f64>,
media_source_id: Option<String>,
audio_stream_index: Option<i32>,
) -> Result<StreamQualityResponse, String> {
info!(
"[player_set_stream_quality] Switching to {} (use_html5: {}, position: {:?})",
"[player_set_stream_quality] Switching to {} (position: {:?})",
quality.label(),
use_html5,
current_position
);
@@ -1875,14 +1771,7 @@ pub async fn player_set_stream_quality(
.map_err(|e| format!("Failed to select a stream: {:?}", e))?;
let new_url = selection.url.clone();
if use_html5 {
return Ok(StreamQualityResponse::ReloadStream {
selection,
position,
});
}
// Native backend (Android/ExoPlayer): stop, repoint the queue entry at the
// The native backend (mpv, ExoPlayer): stop, repoint the queue entry at the
// new URL, and reload — mirroring `VideoSeekStrategy::BackendReloadStream`.
// The re-opened stream begins at zero (an HLS playlist cannot carry a start
// position without 400ing every segment — DR-181), so it is seeked back to
@@ -1935,8 +1824,8 @@ pub async fn player_set_audio_track(
///
/// On Android this indexes ExoPlayer's *text track groups* — i.e. the position
/// of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
/// index. The HTML5 path never reaches here; it toggles its own `<track>`
/// children. libmpv implements neither, leaving the trait default in place.
/// index. mpv gives it the same meaning: the position in the sideloaded WebVTT
/// list, loaded as external subtitle files (`mpv_tracks`).
///
/// TRACES: UR-020 | IR-018, DR-023
#[tauri::command]
@@ -2179,14 +2068,13 @@ pub async fn player_get_queue(
#[serde(rename_all = "camelCase")]
pub struct PlaybackCapabilities {
/// True when audio is rendered by a webview `<audio>` element rather than a
/// native backend. Native audio exists on Linux (mpv) and Android
/// (ExoPlayer); everything else (Windows, future desktops) uses the webview.
/// native backend. Native audio exists on Linux and Windows (mpv) and
/// Android (ExoPlayer); only an unported desktop uses the webview.
///
/// Video has no counterpart: it is always drawn by the native backend, behind
/// the transparent webview (DR-235) — there is no webview video renderer
/// left to report.
pub uses_webview_audio: bool,
/// True when video can be rendered by a native surface composited *behind*
/// a transparent webview. Android only: ExoPlayer draws into a SurfaceView
/// beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
/// compositing), so it stays on the HTML5 element.
pub supports_native_video: bool,
}
/// Report this platform's playback capabilities to the frontend.
@@ -2195,37 +2083,24 @@ pub struct PlaybackCapabilities {
#[tauri::command]
#[specta::specta]
pub async fn player_get_capabilities() -> Result<PlaybackCapabilities, String> {
// Mirrors the cfg gates the backends themselves are built under.
let native_audio = cfg!(any(target_os = "android", target_os = "linux"));
// Mirrors the cfg gates the backends themselves are built under: mpv on
// Linux and Windows (DR-237), ExoPlayer on Android.
let native_audio = cfg!(any(
target_os = "android",
target_os = "linux",
target_os = "windows"
));
Ok(PlaybackCapabilities {
uses_webview_audio: !native_audio,
// TRACES: UR-080 | DR-235
supports_native_video: cfg!(target_os = "android")
|| crate::player::native_video::enabled(),
})
}
pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
// Determine backend at compile time based on platform
let (backend, use_html5_element) = if cfg!(target_os = "android") {
// Android uses ExoPlayer native backend
(VideoBackend::Native, false)
} else if crate::player::native_video::enabled() {
// mpv draws the picture on this desktop; the frontend must not also
// load it into a <video> element or the stream decodes twice and the
// two fight over the audio. TRACES: UR-080 | DR-235
(VideoBackend::Native, false)
} else {
// Linux and other platforms use HTML5 video element in frontend
(VideoBackend::Html5, true)
};
PlayerStatus {
state: controller.state(),
// The position on the item's timeline, whichever of the three paths is
// rendering it — the native backend answers for only one of them, and
// reads 0 for webview video and for a handoff that has not ticked yet.
// The position on the item's timeline, whichever path is rendering it —
// the native backend reads 0 for a handoff that has not ticked yet.
// TRACES: UR-005 | DR-178
position: controller.absolute_position(),
duration: controller.duration(),
@@ -2233,8 +2108,6 @@ pub(super) fn get_player_status(controller: &PlayerController) -> PlayerStatus {
muted: controller.muted(),
shuffle: controller.is_shuffle(),
repeat: controller.repeat_mode(),
backend,
use_html5_element,
// Merged fields initialized to defaults (will be set by player_get_status)
merged_media: None,
@@ -3058,6 +2931,44 @@ pub async fn player_disable_jellyfin(player: State<'_, PlayerStateWrapper>) -> R
mod tests {
use crate::utils::lock::MutexSafe;
/// Video always goes to the backend. `player_play_item` once decided per
/// platform whether the backend or the webview's `<video>` would render,
/// and each wrong answer was silence (Linux, once mpv drew the picture) or a
/// soundtrack decoded twice (Windows). With the webview video path deleted
/// there is no second renderer to route to, and the queue-only branch is
/// gone with it.
///
/// TRACES: UR-003, UR-080 | DR-235, DR-237 | UT-273
#[test]
fn test_video_always_goes_to_the_backend() {
let src = include_str!("mod.rs");
let play_item = src
.split("pub async fn player_play_item(")
.nth(1)
.and_then(|rest| rest.split("\n}\n").next())
.expect("player_play_item exists");
assert!(play_item.contains(".play_item(media_item)"));
assert!(
!play_item.contains(".set_current_item("),
"player_play_item must not keep video from the backend"
);
}
/// Only audio can still be the webview's, and only on a desktop with no mpv.
///
/// TRACES: UR-003, UR-080 | DR-235, DR-237 | UT-272
#[tokio::test]
async fn test_every_shipped_platform_plays_audio_natively() {
let caps = super::player_get_capabilities().await.unwrap();
if cfg!(any(
target_os = "linux",
target_os = "windows",
target_os = "android"
)) {
assert!(!caps.uses_webview_audio);
}
}
/// UT-206 — the volume the command hands on is always a real number in
/// 0.0..=1.0.
///
+10 -9
View File
@@ -138,7 +138,7 @@ pub async fn player_play_next_episode(
/// Handle playback ended event - triggers autoplay decision logic
/// This is called from:
/// - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
/// - Frontend when a video ends - passes itemId + repositoryHandle for the video
/// - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
/// - Android JNI callback also triggers this logic directly
///
@@ -158,7 +158,7 @@ pub async fn player_on_playback_ended(
let controller_arc = player.0.clone();
// Run autoplay decision logic
// If item_id is provided (HTML5 video case), use the video-specific path
// If item_id is provided (a video), use the video-specific path
// that bypasses the backend queue and stale end_reason
let decision = {
let controller = controller_arc.lock().await;
@@ -326,16 +326,17 @@ pub async fn player_recover_stream(player: State<'_, PlayerStateWrapper>) -> Res
}
}
// ===== HTML5 video state-report commands =====
// ===== Webview media state-report commands =====
//
// On platforms where video renders in the webview (Linux WebKitGTK HTML5
// <video>), the real player lives outside the native backend, so the frontend
// HTML5 adapter reports DOM events back through these commands. The controller
// Where media renders in the webview — the `<audio>` element of the webview
// audio backend, on a desktop with no mpv; video never does since DR-235 — the
// real player lives outside the native backend, so the frontend adapter reports
// DOM events back through these commands. The controller
// re-emits them through the same PlayerStatusEvent pipeline the native backends
// use, keeping the Rust controller the single source of truth and the frontend
// player store fed from one place (playerEvents.ts) in both modes.
/// Report an HTML5 <video> state change (playing/paused/loading/stopped/idle).
/// Report a webview media element's state change (playing/paused/loading/stopped/idle).
#[tauri::command]
#[specta::specta]
pub async fn player_report_state(
@@ -348,7 +349,7 @@ pub async fn player_report_state(
Ok(())
}
/// Report an HTML5 <video> position tick (seconds). The adapter should throttle
/// Report a webview media element's position tick (seconds). The adapter should throttle
/// these to roughly match the native backends' ~250ms cadence.
#[tauri::command]
#[specta::specta]
@@ -362,7 +363,7 @@ pub async fn player_report_position(
Ok(())
}
/// Report that the HTML5 <video> finished loading and knows its duration.
/// Report that a webview media element finished loading and knows its duration.
#[tauri::command]
#[specta::specta]
pub async fn player_report_media_loaded(
+249 -11
View File
@@ -14,6 +14,7 @@ use uuid::Uuid;
use crate::domain::rank_search_results;
use crate::jellyfin::HttpClient;
use crate::repository::capabilities::ServerCapabilities;
use crate::repository::{
series_progress, types::*, HybridRepository, MediaRepository, OfflineRepository,
OnlineRepository, StreamSelection,
@@ -63,6 +64,111 @@ impl RepositoryManager {
/// Wrapper for Tauri state
pub struct RepositoryManagerWrapper(pub RepositoryManager);
/// Read the server's reported version and resolve it into capabilities.
///
/// Never fails: a server row that is missing, or carries a version this build
/// cannot parse, yields the conservative generation rather than an error. A
/// client that refused to start because it did not recognise a version string
/// would be the exact failure UR-085 exists to remove.
///
/// TRACES: UR-085 | IR-035, DR-280
async fn server_capabilities(
db: &Arc<crate::storage::db_service::RusqliteService>,
server_id: &str,
) -> ServerCapabilities {
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
let reported: Option<String> = db
.query_one(
Query::with_params(
"SELECT version FROM servers WHERE id = ?1",
vec![QueryParam::String(server_id.to_string())],
),
|row| row.get::<_, Option<String>>(0),
)
.await
.ok()
.flatten();
match reported {
Some(version) => ServerCapabilities::from_reported(version.as_str()),
None => {
debug!("[REPO] No server version recorded for {server_id}; assuming current target");
ServerCapabilities::assumed()
}
}
}
/// Drop the cached catalog if the server changed generation since we last looked.
///
/// Returns whether anything was invalidated, which is what the tests assert on.
///
/// The first run after this feature ships records the generation and invalidates
/// nothing: a NULL column means "never recorded", not "changed". Making the
/// absence of information trigger a full re-fetch would charge every existing
/// user bandwidth for a server upgrade that has not happened.
///
/// TRACES: UR-085 | DR-284
async fn invalidate_cache_on_generation_change(
db: &Arc<crate::storage::db_service::RusqliteService>,
server_id: &str,
generation: crate::repository::capabilities::ServerGeneration,
) -> bool {
use crate::storage::db_service::{DatabaseService, Query, QueryParam};
let current = format!("{generation:?}");
let previous: Option<String> = db
.query_one(
Query::with_params(
"SELECT catalog_generation FROM servers WHERE id = ?1",
vec![QueryParam::String(server_id.to_string())],
),
|row| row.get::<_, Option<String>>(0),
)
.await
.ok()
.flatten();
let changed = matches!(previous.as_deref(), Some(prev) if prev != current);
if changed {
warn!(
"[REPO] Server generation changed ({:?} -> {}); dropping the cached catalog so it \
is re-fetched under the new generation's shapes",
previous, current
);
if let Err(e) = db
.execute(Query::with_params(
"UPDATE items SET synced_at = NULL WHERE server_id = ?1",
vec![QueryParam::String(server_id.to_string())],
))
.await
{
// Not fatal: stale-but-parseable rows are better than refusing to
// start, and the next successful sync overwrites them anyway.
error!("[REPO] Failed to invalidate cached catalog: {e}");
}
}
if previous.as_deref() != Some(current.as_str()) {
if let Err(e) = db
.execute(Query::with_params(
"UPDATE servers SET catalog_generation = ?1 WHERE id = ?2",
vec![
QueryParam::String(current),
QueryParam::String(server_id.to_string()),
],
))
.await
{
error!("[REPO] Failed to record server generation: {e}");
}
}
changed
}
/// Create a new repository instance
/// Returns a handle (UUID) for accessing the repository
#[tauri::command]
@@ -100,17 +206,6 @@ pub async fn repository_create(
monitor.reporter()
};
// Create online repository wired to connectivity reporting
debug!("[REPO] Creating online repository...");
let online = OnlineRepository::new(
Arc::new(http_client),
server_url,
user_id.clone(),
access_token,
)
.with_connectivity(connectivity_reporter);
debug!("[REPO] Online repository created");
// Create offline repository with async-safe database service
debug!("[REPO] Creating database service...");
let db_service = {
@@ -123,6 +218,37 @@ pub async fn repository_create(
}; // Lock is released here
debug!("[REPO] Database service created");
// Resolve what this server can do, from the version it reported at connect.
// `AuthManager::connect_to_server` already parsed it and `storage` already
// persisted it, so this costs one indexed read and no extra round trip.
//
// A missing or unreadable version is not an error: `from_reported` treats it
// as the older generation, whose request shapes also work on the newer one.
//
// TRACES: UR-085 | IR-035, DR-280
let capabilities = server_capabilities(&db_service, &server_id).await;
info!(
"[REPO] Server generation: {:?} (reported {:?})",
capabilities.generation,
capabilities.version.as_ref().map(|v| v.raw.as_str())
);
// A server upgraded underneath us means the cached catalog was parsed under
// a different generation's assumptions. TRACES: UR-085 | DR-284
invalidate_cache_on_generation_change(&db_service, &server_id, capabilities.generation).await;
// Create online repository wired to connectivity reporting
debug!("[REPO] Creating online repository...");
let online = OnlineRepository::new(
Arc::new(http_client),
server_url,
user_id.clone(),
access_token,
)
.with_connectivity(connectivity_reporter)
.with_capabilities(capabilities);
debug!("[REPO] Online repository created");
debug!("[REPO] Creating offline repository...");
let offline = OfflineRepository::new(db_service, server_id, user_id);
debug!("[REPO] Offline repository created");
@@ -388,6 +514,24 @@ pub async fn repository_get_series_current_episode(
.map_err(|e| format!("{:?}", e))
}
/// A series' episodes and the viewer's current episode, from one season
/// fan-out. The series page used to ask for these as two commands, each of
/// which walked every season.
///
/// TRACES: UR-062 | DR-101, DR-295
#[tauri::command]
#[specta::specta]
pub async fn repository_get_series_view(
manager: State<'_, RepositoryManagerWrapper>,
handle: String,
series_id: String,
) -> Result<series_progress::SeriesView, String> {
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
series_progress::resolve_series_view(repo.as_ref(), &series_id)
.await
.map_err(|e| format!("{:?}", e))
}
/// Erase the viewer's watch history for an item.
///
/// Clears the played flag and the resume position; on a series or season the
@@ -1216,3 +1360,97 @@ mod tests {
}
}
}
#[cfg(test)]
mod generation_change_tests {
use super::*;
use crate::repository::capabilities::ServerGeneration;
use crate::storage::db_service::{DatabaseService, Query, QueryParam, RusqliteService};
async fn db_with_server() -> Arc<RusqliteService> {
let conn = rusqlite::Connection::open_in_memory().expect("in-memory db");
for (_, sql) in crate::storage::schema::MIGRATIONS {
conn.execute_batch(sql).expect("migration");
}
let db = Arc::new(RusqliteService::new(Arc::new(std::sync::Mutex::new(conn))));
db.execute(Query::with_params(
"INSERT INTO servers (id, name, url, version) VALUES (?1, ?2, ?3, ?4)",
vec![
QueryParam::String("srv-1".into()),
QueryParam::String("Home".into()),
QueryParam::String("https://example.test".into()),
QueryParam::String("10.11.5".into()),
],
))
.await
.expect("seed server");
db
}
async fn recorded(db: &Arc<RusqliteService>) -> Option<String> {
db.query_one(
Query::new("SELECT catalog_generation FROM servers WHERE id = 'srv-1'"),
|row| row.get::<_, Option<String>>(0),
)
.await
.ok()
.flatten()
}
/// The first look records the generation and invalidates nothing. A NULL
/// column means "never recorded", not "changed" — treating it as a change
/// would charge every existing user a full re-fetch on upgrade.
///
/// TRACES: UR-085 | DR-284
#[tokio::test]
async fn the_first_look_records_without_invalidating() {
let db = db_with_server().await;
let invalidated =
invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
assert!(!invalidated, "a first sighting is not a change");
assert_eq!(recorded(&db).await.as_deref(), Some("V10_11"));
}
/// Seeing the same generation again is not a change either.
///
/// TRACES: UR-085 | DR-284
#[tokio::test]
async fn an_unchanged_generation_does_not_invalidate() {
let db = db_with_server().await;
invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
let invalidated =
invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
assert!(!invalidated);
assert_eq!(recorded(&db).await.as_deref(), Some("V10_11"));
}
/// An actual upgrade drops the cached catalog and records the new
/// generation, so the next browse re-fetches under the new shapes.
///
/// TRACES: UR-085 | DR-284
#[tokio::test]
async fn a_real_upgrade_invalidates_and_records() {
let db = db_with_server().await;
invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V10_11).await;
let invalidated =
invalidate_cache_on_generation_change(&db, "srv-1", ServerGeneration::V12Plus).await;
assert!(invalidated, "10.11 -> 12.x is a generation change");
assert_eq!(recorded(&db).await.as_deref(), Some("V12Plus"));
}
/// A server row that is missing entirely must not panic or invalidate.
///
/// TRACES: UR-085 | DR-284
#[tokio::test]
async fn an_unknown_server_is_harmless() {
let db = db_with_server().await;
let invalidated =
invalidate_cache_on_generation_change(&db, "no-such-server", ServerGeneration::V12Plus)
.await;
assert!(!invalidated);
}
}
+70 -7
View File
@@ -49,6 +49,19 @@ pub async fn sync_queue_mutation(
Arc::new(database.service())
};
enqueue_mutation(&*db_service, user_id, operation, item_id, payload).await
}
/// Insert one pending mutation and return the id of *that* row.
///
/// TRACES: UR-002, UR-017 | DR-014
pub(crate) async fn enqueue_mutation<S: DatabaseService>(
db_service: &S,
user_id: String,
operation: String,
item_id: Option<String>,
payload: Option<String>,
) -> Result<i64, String> {
let query = Query::with_params(
"INSERT INTO sync_queue (user_id, operation, item_id, payload, status, created_at)
VALUES (?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)",
@@ -60,13 +73,9 @@ pub async fn sync_queue_mutation(
],
);
db_service.execute(query).await.map_err(|e| e.to_string())?;
let id = db_service
.last_insert_rowid()
.await
.map_err(|e| e.to_string())?;
Ok(id)
// `insert`, not `execute` + `last_insert_rowid`: the id must be read in
// the same job as the insert, or a concurrent write hands us its row.
db_service.insert(query).await
}
/// Get all pending sync operations for a user
@@ -387,4 +396,58 @@ mod tests {
assert!(item.retry_count == i);
}
}
/// Each queued mutation must get back the id of its *own* row.
///
/// The id used to come from a separate `last_insert_rowid()` call — a
/// second trip to the shared connection — so another insert landing in
/// between handed this mutation someone else's id, and marking it synced
/// later completed the wrong row.
///
/// TRACES: UR-002, UR-017 | DR-014 | UT-014
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
async fn concurrent_enqueues_each_get_their_own_row_id() {
let database = crate::storage::Database::open_in_memory().unwrap();
let service = Arc::new(database.service());
service
.execute(Query::new(
"INSERT INTO servers (id, name, url) VALUES ('s', 'S', 'http://s')",
))
.await
.unwrap();
service
.execute(Query::new(
"INSERT INTO users (id, server_id, username) VALUES ('u', 's', 'u')",
))
.await
.unwrap();
let tasks: Vec<_> = (0..200)
.map(|i| {
let service = Arc::clone(&service);
tokio::spawn(async move {
let op = format!("op-{i}");
let id = enqueue_mutation(&*service, "u".into(), op.clone(), None, None)
.await
.unwrap();
(op, id)
})
})
.collect();
for task in tasks {
let (op, id) = task.await.unwrap();
let stored: String = service
.query_one(
Query::with_params(
"SELECT operation FROM sync_queue WHERE id = ?",
vec![QueryParam::Int64(id)],
),
|row| row.get(0),
)
.await
.unwrap();
assert_eq!(stored, op, "mutation {op} was handed row {id}");
}
}
}
+1
View File
@@ -150,6 +150,7 @@ pub fn run_engine(url: &str, engine: Engine) -> u32 {
None,
std::sync::Arc::new(tokio::sync::Mutex::new(None)),
std::sync::Arc::new(crate::playback_reporting::throttle::EventThrottler::new()),
None,
)
.expect("could not create the legacy backend"),
crate::player::media_player::Capabilities::mpv(),
+296 -50
View File
@@ -4,8 +4,19 @@
//! - Primary: System keyring (Secret Service on Linux, Keychain on macOS)
//! - Fallback: AES-256-GCM encrypted file when keyring unavailable
//!
//! The fallback is less secure as the encryption key is derived from machine
//! identifiers, but provides functionality on headless systems.
//! The fallback is **obfuscation at rest, not a secret**: its key sits in a file
//! beside the ciphertext, so anyone who can read one can read the other. It
//! exists so headless systems keep working, and the keyring remains the only
//! place a token is actually protected.
//!
//! The key used to be *derived* from the hostname, `$USER` and a hardcoded salt.
//! That was no more secret — those are readable by anyone who can read the file
//! — and it was unstable: renaming the machine, or launching from a context
//! where `$USER` is unset, changed the key and made every stored token
//! undecryptable. `load_credentials_file` treats a failed decrypt as "no stored
//! credentials", so that surfaced as being silently signed out rather than as an
//! error. The key is now random and persisted, and the old derivation is kept
//! only to migrate a file written before this change.
//!
//! TRACES: UR-012 | IR-014
@@ -25,6 +36,119 @@ const SERVICE_NAME: &str = "com.dtourolle.jellytau";
const CREDENTIALS_FILENAME: &str = "credentials.enc";
/// Key file for the encrypted-file fallback, beside the credentials it opens.
const KEY_FILENAME: &str = "credentials.key";
/// Load the fallback encryption key, creating it on first use.
///
/// Random rather than derived. A derived key was no more secret — its inputs
/// (hostname, `$USER`, a hardcoded salt) are readable by anyone who can read
/// the ciphertext — and it silently changed when the machine was renamed or
/// `$USER` was unset, which read to the user as being signed out for no reason.
///
/// If the key cannot be persisted the process still gets a usable key for this
/// run; credentials written under it simply will not be readable next launch,
/// which is the same outcome as today and better than refusing to store a token.
///
/// TRACES: UR-012 | IR-014 | UT-014
fn load_or_create_key(path: &std::path::Path) -> [u8; 32] {
if let Ok(existing) = fs::read(path) {
if existing.len() == 32 {
let mut key = [0u8; 32];
key.copy_from_slice(&existing);
return key;
}
warn!(
"Fallback key at {:?} is {} bytes, not 32; replacing it. Credentials \
written under the old key will need signing in again.",
path,
existing.len()
);
}
let mut key = [0u8; 32];
if getrandom::getrandom(&mut key).is_err() {
warn!("No system randomness for the fallback key; deriving one for this run");
return CredentialStore::derive_legacy_encryption_key();
}
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
match fs::write(path, key) {
Ok(()) => restrict_to_owner(path),
Err(e) => warn!(
"Could not persist the fallback key at {:?} ({}); credentials stored \
this run will not be readable next launch",
path, e
),
}
key
}
/// Make a key file owner-readable only. Best effort — a filesystem without
/// Unix permissions is not a reason to fail.
fn restrict_to_owner(path: &std::path::Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) {
warn!("Could not restrict permissions on {:?}: {}", path, e);
}
}
#[cfg(not(unix))]
let _ = path;
}
/// Decrypt `encrypted` with `key`.
///
/// TRACES: UR-012 | IR-014 | UT-014
fn decrypt_with(key: &[u8; 32], encrypted: &str) -> Result<String, CredentialError> {
let combined = BASE64
.decode(encrypted)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
if combined.len() < 12 {
return Err(CredentialError::Encryption(
"Invalid encrypted data".to_string(),
));
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
let cipher =
Aes256Gcm::new_from_slice(key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
String::from_utf8(plaintext).map_err(|e| CredentialError::Encryption(e.to_string()))
}
/// Encrypt `plaintext` with `key`, prepending a fresh random nonce.
///
/// TRACES: UR-012 | IR-014 | UT-014
fn encrypt_with(key: &[u8; 32], plaintext: &str) -> Result<String, CredentialError> {
let cipher =
Aes256Gcm::new_from_slice(key).map_err(|e| CredentialError::Encryption(e.to_string()))?;
let mut nonce_bytes = [0u8; 12];
getrandom::getrandom(&mut nonce_bytes)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext.as_bytes())
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
let mut combined = nonce_bytes.to_vec();
combined.extend(ciphertext);
Ok(BASE64.encode(&combined))
}
/// Result of a credential storage operation
#[derive(Debug)]
pub enum CredentialResult {
@@ -66,15 +190,21 @@ pub struct CredentialStore {
using_keyring: bool,
/// Path to the encrypted credentials file (fallback)
credentials_path: PathBuf,
/// Encryption key for file fallback (derived from machine ID)
/// Encryption key for the file fallback. Random and persisted, so it does
/// not change when the machine is renamed.
encryption_key: [u8; 32],
/// The pre-existing derivation, retained only to read a file written before
/// the key was persisted. Anything decrypted with it is rewritten under
/// `encryption_key`.
legacy_key: [u8; 32],
}
impl CredentialStore {
/// Create a new credential store, detecting the best available backend
pub fn new() -> Self {
let credentials_path = Self::get_credentials_path();
let encryption_key = Self::derive_encryption_key();
let encryption_key = load_or_create_key(&Self::get_key_path());
let legacy_key = Self::derive_legacy_encryption_key();
// Test if keyring is available by trying a dummy operation
let using_keyring = Self::test_keyring_available();
@@ -93,6 +223,7 @@ impl CredentialStore {
using_keyring,
credentials_path,
encryption_key,
legacy_key,
}
}
@@ -406,6 +537,16 @@ impl CredentialStore {
// --- Encrypted file backend ---
/// Where the fallback key lives: beside the credentials file, so the two
/// travel together and a restore that brings one brings the other.
fn get_key_path() -> PathBuf {
if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
proj_dirs.data_dir().join(KEY_FILENAME)
} else {
PathBuf::from(KEY_FILENAME)
}
}
fn get_credentials_path() -> PathBuf {
if let Some(proj_dirs) = ProjectDirs::from("com", "dtourolle", "jellytau") {
proj_dirs.data_dir().join(CREDENTIALS_FILENAME)
@@ -414,7 +555,14 @@ impl CredentialStore {
}
}
fn derive_encryption_key() -> [u8; 32] {
/// The key derivation used before keys were persisted.
///
/// Kept **only** so a credentials file written by an older build can still
/// be read once and rewritten under the persisted key. Never used to
/// encrypt. See the module docs for why it was replaced.
///
/// TRACES: UR-012 | IR-014
fn derive_legacy_encryption_key() -> [u8; 32] {
// Derive a key from machine-specific identifiers
// This is less secure than a true keyring but provides some protection
let mut hasher = Sha256::new();
@@ -490,7 +638,7 @@ impl CredentialStore {
return Ok(serde_json::json!({}));
}
let decrypted = match self.decrypt(&encrypted_data) {
let (decrypted, from_legacy_key) = match self.decrypt_migrating(&encrypted_data) {
Ok(decrypted) => decrypted,
Err(e) => {
warn!(
@@ -505,7 +653,19 @@ impl CredentialStore {
};
match serde_json::from_str(&decrypted) {
Ok(value) => Ok(value),
Ok(value) => {
// Rewrite under the persisted key so the legacy derivation is
// never needed again.
if from_legacy_key {
if let Err(e) = self.save_credentials_file(&value) {
warn!(
"Could not rewrite credentials under the persisted key: {}",
e
);
}
}
Ok(value)
}
Err(e) => {
warn!(
"Credentials file at {:?} decrypted to invalid JSON ({}); \
@@ -531,48 +691,27 @@ impl CredentialStore {
}
fn encrypt(&self, plaintext: &str) -> Result<String, CredentialError> {
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
// Generate a random nonce
let mut nonce_bytes = [0u8; 12];
getrandom::getrandom(&mut nonce_bytes)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext.as_bytes())
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
// Prepend nonce to ciphertext and encode as base64
let mut combined = nonce_bytes.to_vec();
combined.extend(ciphertext);
Ok(BASE64.encode(&combined))
encrypt_with(&self.encryption_key, plaintext)
}
fn decrypt(&self, encrypted: &str) -> Result<String, CredentialError> {
let combined = BASE64
.decode(encrypted)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
if combined.len() < 12 {
return Err(CredentialError::Encryption(
"Invalid encrypted data".to_string(),
));
/// Decrypt with the current key, falling back to the legacy derivation.
///
/// Returns the plaintext and whether the legacy key was what opened it, so
/// the caller can rewrite the file under the current key and stop depending
/// on a derivation that changes when the machine is renamed.
///
/// TRACES: UR-012 | IR-014 | UT-014
fn decrypt_migrating(&self, encrypted: &str) -> Result<(String, bool), CredentialError> {
match decrypt_with(&self.encryption_key, encrypted) {
Ok(plaintext) => Ok((plaintext, false)),
Err(current_err) => match decrypt_with(&self.legacy_key, encrypted) {
Ok(plaintext) => {
info!("Credentials were written under the legacy derived key; rewriting them");
Ok((plaintext, true))
}
Err(_) => Err(current_err),
},
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
let cipher = Aes256Gcm::new_from_slice(&self.encryption_key)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
let plaintext = cipher
.decrypt(nonce, ciphertext)
.map_err(|e| CredentialError::Encryption(e.to_string()))?;
String::from_utf8(plaintext).map_err(|e| CredentialError::Encryption(e.to_string()))
}
fn save_to_file(&self, user_id: &str, token: &str) -> Result<(), CredentialError> {
@@ -884,6 +1023,103 @@ pub use android_keystore::{
#[cfg(test)]
mod tests {
/// The fallback key must be the same on every launch.
///
/// It used to be derived from the hostname, `$USER` and a static salt.
/// Renaming the machine — or launching from a context where `$USER` is
/// unset, such as a systemd user service — changed the key, and
/// `load_credentials_file` reports a failed decrypt as "no stored
/// credentials". The user was silently signed out with nothing to explain it.
///
/// TRACES: UR-012 | IR-014 | UT-014
#[test]
fn the_fallback_key_is_stable_across_processes() {
let dir = std::env::temp_dir().join(format!("jellytau-key-{}", std::process::id()));
let path = dir.join("credentials.key");
let _ = fs::remove_file(&path);
let first = load_or_create_key(&path);
let second = load_or_create_key(&path);
assert_eq!(first, second, "the key must not change between launches");
assert_ne!(first, [0u8; 32], "the key must be real randomness");
let _ = fs::remove_dir_all(&dir);
}
/// Two installs must not share a key.
///
/// TRACES: UR-012 | IR-014 | UT-014
#[test]
fn separate_installs_get_separate_keys() {
let base = std::env::temp_dir().join(format!("jellytau-keys-{}", std::process::id()));
let a = load_or_create_key(&base.join("a").join("credentials.key"));
let b = load_or_create_key(&base.join("b").join("credentials.key"));
assert_ne!(a, b);
let _ = fs::remove_dir_all(&base);
}
/// A credentials file written under the old derived key must still open.
///
/// TRACES: UR-012 | IR-014 | UT-014
#[test]
fn credentials_written_under_the_legacy_key_still_decrypt() {
let legacy = CredentialStore::derive_legacy_encryption_key();
let mut persisted = [0u8; 32];
getrandom::getrandom(&mut persisted).unwrap();
assert_ne!(legacy, persisted);
let blob = encrypt_with(&legacy, r#"{"user-1":"token-abc"}"#).unwrap();
let store = CredentialStore {
using_keyring: false,
credentials_path: PathBuf::from("/nonexistent/credentials.enc"),
encryption_key: persisted,
legacy_key: legacy,
};
let (plaintext, migrated) = store
.decrypt_migrating(&blob)
.expect("a file written under the legacy key must still be readable");
assert_eq!(plaintext, r#"{"user-1":"token-abc"}"#);
assert!(migrated, "the caller must know to rewrite it");
}
/// The current key is tried first and needs no migration.
///
/// TRACES: UR-012 | IR-014 | UT-014
#[test]
fn credentials_under_the_current_key_are_not_flagged_for_migration() {
let mut persisted = [0u8; 32];
getrandom::getrandom(&mut persisted).unwrap();
let blob = encrypt_with(&persisted, "hello").unwrap();
let store = CredentialStore {
using_keyring: false,
credentials_path: PathBuf::from("/nonexistent/credentials.enc"),
encryption_key: persisted,
legacy_key: [7u8; 32],
};
let (plaintext, migrated) = store.decrypt_migrating(&blob).unwrap();
assert_eq!(plaintext, "hello");
assert!(!migrated);
}
/// A blob under neither key fails rather than returning something wrong.
///
/// TRACES: UR-012 | IR-014 | UT-014
#[test]
fn an_unreadable_blob_is_an_error() {
let blob = encrypt_with(&[1u8; 32], "secret").unwrap();
let store = CredentialStore {
using_keyring: false,
credentials_path: PathBuf::from("/nonexistent/credentials.enc"),
encryption_key: [2u8; 32],
legacy_key: [3u8; 32],
};
assert!(store.decrypt_migrating(&blob).is_err());
}
use super::*;
/// Build a store pinned to the encrypted-file backend with an explicit key,
@@ -894,6 +1130,9 @@ mod tests {
using_keyring: false,
credentials_path,
encryption_key,
// A distinct legacy key, so "same file, different machine key" stays
// undecryptable rather than being opened by the migration path.
legacy_key: [0xABu8; 32],
}
}
@@ -959,15 +1198,22 @@ mod tests {
let plaintext = "test-access-token-12345";
let encrypted = store.encrypt(plaintext).unwrap();
let decrypted = store.decrypt(&encrypted).unwrap();
let (decrypted, _) = store.decrypt_migrating(&encrypted).unwrap();
assert_eq!(plaintext, decrypted);
}
/// The legacy derivation must stay deterministic *within a machine*, or the
/// one-time migration of an old credentials file cannot read it.
///
/// Its instability *across* machine states is exactly why it no longer
/// encrypts anything — see `the_fallback_key_is_stable_across_processes`.
///
/// TRACES: UR-012 | IR-014 | UT-014
#[test]
fn test_derive_encryption_key_is_deterministic() {
let key1 = CredentialStore::derive_encryption_key();
let key2 = CredentialStore::derive_encryption_key();
fn test_legacy_derivation_is_deterministic_for_migration() {
let key1 = CredentialStore::derive_legacy_encryption_key();
let key2 = CredentialStore::derive_legacy_encryption_key();
assert_eq!(key1, key2);
}
}
+181
View File
@@ -0,0 +1,181 @@
//! How big a download is going to be when the server will not say.
//!
//! A direct copy answers with `Content-Length`, and the worker reports exact
//! progress from it. A transcode is produced as it is sent — chunked, with no
//! length — and the worker used to report `progress: 0.0` for its whole
//! duration: an empty bar and "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 does know enough to estimate. It fetches the item to decide the
//! audio policy anyway, and that item carries the source's size and runtime;
//! the preset it chose fixes the bitrate. So the estimate is made where the
//! URL is, persisted on the row as its `file_size`, and used only as a
//! fallback: a real `Content-Length` always wins, and an estimated bar never
//! claims completion.
//!
//! TRACES: UR-071 | DR-290
use super::presets::download_preset;
/// Ticks per second in Jellyfin's runtime unit.
const TICKS_PER_SECOND: u64 = 10_000_000;
/// The progress bar never reports more than this from an estimate, so a source
/// that encodes a little larger than predicted shows 99% until the last byte
/// rather than 104% — completion is the worker's to announce.
pub const ESTIMATED_PROGRESS_CEILING: f64 = 0.99;
/// The size a download for `quality` is expected to produce, in bytes.
///
/// - A preset re-encodes both streams at fixed rates, so the size is rate ×
/// runtime. Jellyfin encodes to a target bitrate (`-b:v` with `-maxrate`), so
/// the average lands near the cap rather than well under it.
/// - `original` copies the picture and at most re-encodes the audio, so the
/// output is the source's size give or take the audio track — and when no
/// transcode is needed at all it is exactly the source's size.
///
/// `None` when the inputs needed are missing; the caller then has no total and
/// the bar is indeterminate, which is honest and was the status quo.
///
/// TRACES: UR-071 | DR-290 | UT-252
pub fn expected_download_bytes(
quality: &str,
runtime_ticks: Option<i64>,
source_size: Option<i64>,
) -> Option<u64> {
match download_preset(quality) {
Some(preset) => {
let seconds = u64::try_from(runtime_ticks?).ok()? / TICKS_PER_SECOND;
(seconds > 0).then(|| preset.total_bit_rate() / 8 * seconds)
}
None => source_size
.and_then(|s| u64::try_from(s).ok())
.filter(|&s| s > 0),
}
}
/// What the progress bar measures against.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProgressTotal {
pub bytes: u64,
/// The total is a prediction, not the server's word.
pub estimated: bool,
}
/// The total to report progress against, given what the response said and what
/// was predicted before it was made. The server's `Content-Length` always
/// wins; the estimate fills in only when the server sent none.
///
/// TRACES: UR-071 | DR-290 | UT-252
pub fn progress_total(content_length: Option<u64>, expected: Option<u64>) -> Option<ProgressTotal> {
match (
content_length.filter(|&n| n > 0),
expected.filter(|&n| n > 0),
) {
(Some(bytes), _) => Some(ProgressTotal {
bytes,
estimated: false,
}),
(None, Some(bytes)) => Some(ProgressTotal {
bytes,
estimated: true,
}),
(None, None) => None,
}
}
/// The fraction complete, in `0.0..=1.0`. An estimated total is capped at
/// [`ESTIMATED_PROGRESS_CEILING`] so a prediction that ran low never shows a
/// finished bar on a download still running.
///
/// TRACES: UR-071 | DR-290 | UT-252
pub fn progress_fraction(downloaded: u64, total: Option<ProgressTotal>) -> f64 {
let Some(total) = total else { return 0.0 };
let fraction = downloaded as f64 / total.bytes as f64;
let ceiling = if total.estimated {
ESTIMATED_PROGRESS_CEILING
} else {
1.0
};
fraction.clamp(0.0, ceiling)
}
#[cfg(test)]
mod tests {
use super::*;
const HOUR_TICKS: i64 = 3600 * TICKS_PER_SECOND as i64;
/// A transcode has no `Content-Length`, and this is the case that showed
/// "0%" for its whole duration: with a prediction in hand the bar must move.
///
/// TRACES: UR-071 | DR-290 | UT-252
#[test]
fn test_estimate_fills_in_when_the_server_sent_no_length() {
let total = progress_total(None, Some(4_000));
assert_eq!(
total,
Some(ProgressTotal {
bytes: 4_000,
estimated: true
})
);
let fraction = progress_fraction(1_000, total);
assert!((fraction - 0.25).abs() < 1e-9, "got {fraction}");
}
/// The server's own figure is never second-guessed by a prediction.
#[test]
fn test_content_length_wins_over_the_estimate() {
let total = progress_total(Some(10_000), Some(4_000)).unwrap();
assert_eq!(total.bytes, 10_000);
assert!(!total.estimated);
assert_eq!(progress_fraction(10_000, Some(total)), 1.0);
}
/// A prediction that ran low must not announce completion: that is the
/// worker's to do when the last byte lands.
#[test]
fn test_estimated_progress_never_reaches_one() {
let total = progress_total(None, Some(1_000));
assert_eq!(progress_fraction(1_200, total), ESTIMATED_PROGRESS_CEILING);
assert_eq!(progress_fraction(0, total), 0.0);
}
/// Nothing known → nothing claimed, and a zero length is "nothing known".
#[test]
fn test_no_total_means_no_progress_claim() {
assert_eq!(progress_total(None, None), None);
assert_eq!(progress_total(Some(0), Some(0)), None);
assert_eq!(progress_fraction(500, None), 0.0);
}
/// A preset's size is its combined rate over the runtime — one hour of the
/// medium preset (4 Mb/s + 256 kb/s) is about 1.9 GB.
#[test]
fn test_preset_estimate_is_rate_times_runtime() {
let bytes = expected_download_bytes("medium", Some(HOUR_TICKS), Some(9_999)).unwrap();
assert_eq!(bytes, (4_000_000 + 256_000) / 8 * 3600);
// Without a runtime there is nothing to multiply.
assert_eq!(expected_download_bytes("medium", None, Some(9_999)), None);
assert_eq!(expected_download_bytes("medium", Some(0), None), None);
}
/// `original` copies the picture, so the source's size is the prediction —
/// with or without the audio being re-encoded on the way.
#[test]
fn test_original_estimate_is_the_source_size() {
assert_eq!(
expected_download_bytes("original", Some(HOUR_TICKS), Some(3_000_000_000)),
Some(3_000_000_000)
);
assert_eq!(
expected_download_bytes("original", Some(HOUR_TICKS), None),
None
);
assert_eq!(expected_download_bytes("original", None, Some(0)), None);
// An unknown quality name is treated as original by the URL builder,
// so it is here too.
assert_eq!(expected_download_bytes("wat", None, Some(10)), Some(10));
}
}
+10
View File
@@ -20,6 +20,10 @@ pub enum DownloadEvent {
bytes_downloaded: i64,
total_bytes: Option<i64>,
progress: f64, // 0.0 to 1.0
/// `total_bytes` is a prediction rather than the server's
/// `Content-Length`, so `progress` stops short of 1.0 until the
/// download completes. TRACES: UR-071 | DR-290
estimated: bool,
},
/// Download completed successfully
#[serde(rename_all = "camelCase")]
@@ -27,6 +31,10 @@ pub enum DownloadEvent {
download_id: i64,
item_id: String,
file_path: String,
/// Bytes actually written. The frontend persists completion too, and
/// without this it fell back to the row's `file_size` — which is a
/// prediction for a transcode (DR-290), not the real size.
bytes_downloaded: i64,
},
/// Download failed with error
#[serde(rename_all = "camelCase")]
@@ -60,6 +68,7 @@ mod tests {
bytes_downloaded: 1024,
total_bytes: Some(2048),
progress: 0.5,
estimated: false,
};
let json = serde_json::to_string(&event).unwrap();
@@ -86,6 +95,7 @@ mod tests {
download_id: 42,
item_id: "song456".to_string(),
file_path: "/path/to/file.mp3".to_string(),
bytes_downloaded: 4096,
};
let json = serde_json::to_string(&event).unwrap();
+2
View File
@@ -7,8 +7,10 @@
//! - Resume support via HTTP Range requests
pub mod cache;
pub mod estimate;
pub mod events;
pub mod network;
pub mod presets;
pub mod stop;
pub mod worker;
+66
View File
@@ -0,0 +1,66 @@
//! The quality presets a video download can be asked for.
//!
//! One table, read by both the URL builder (which turns a preset into transcode
//! parameters) and the size estimate (which turns the same numbers into an
//! expected byte count). They were the same literals in two places before,
//! which is how a bar can claim 40% of a file that is nearly done.
/// Transcode caps for one named preset.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DownloadPreset {
/// Video target, bits per second.
pub video_bit_rate: u64,
/// Longest edge the picture is scaled down to.
pub max_height: u32,
/// Audio target, bits per second.
pub audio_bit_rate: u64,
}
impl DownloadPreset {
/// Combined stream rate, bits per second.
pub fn total_bit_rate(&self) -> u64 {
self.video_bit_rate + self.audio_bit_rate
}
}
/// The preset a quality name denotes; `None` for `original` and anything
/// unrecognised, both of which mean "do not cap the picture".
///
/// TRACES: UR-071 | DR-123, DR-290
pub fn download_preset(quality: &str) -> Option<DownloadPreset> {
match quality {
"high" => Some(DownloadPreset {
video_bit_rate: 8_000_000,
max_height: 1080,
audio_bit_rate: 384_000,
}),
"medium" => Some(DownloadPreset {
video_bit_rate: 4_000_000,
max_height: 720,
audio_bit_rate: 256_000,
}),
"low" => Some(DownloadPreset {
video_bit_rate: 1_500_000,
max_height: 480,
audio_bit_rate: 128_000,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_presets_are_ordered_and_original_has_none() {
let high = download_preset("high").unwrap();
let medium = download_preset("medium").unwrap();
let low = download_preset("low").unwrap();
assert!(high.total_bit_rate() > medium.total_bit_rate());
assert!(medium.total_bit_rate() > low.total_bit_rate());
assert!(high.max_height > medium.max_height && medium.max_height > low.max_height);
assert_eq!(download_preset("original"), None);
assert_eq!(download_preset("nonsense"), None);
}
}
+189 -2
View File
@@ -18,11 +18,40 @@ pub struct DownloadWorker {
max_retries: u32,
}
/// How long a transfer may go without receiving a single byte before it is
/// treated as dead and retried. Generous because a transcode download waits on
/// ffmpeg, which pauses when Jellyfin throttles it.
const STALL_TIMEOUT: Duration = Duration::from_secs(60);
/// How long to wait for the TCP/TLS handshake before giving up.
const CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
impl DownloadWorker {
pub fn new() -> Self {
Self::with_stall_timeout(STALL_TIMEOUT, true)
}
/// Build a worker whose HTTP client gives up on a transfer that receives
/// nothing for `stall`. `https_only` is relaxed only by tests, which serve
/// from a loopback socket.
///
/// The timeouts are a *connect* timeout and a *read* timeout — never
/// `Client::timeout`. That one is a total deadline that runs until the body
/// has finished, and it was set to five minutes: every download longer than
/// that was cut off mid-body with "error decoding response body", then
/// retried. A transcode ignores `Range`, so each retry restarted from byte
/// zero, ran into the same five minutes, and after three attempts the
/// download failed — which is why no feature film at transcode speed ever
/// completed on a device that needs the audio re-encoded. A read timeout
/// resets on every chunk, so it catches a dead connection without putting a
/// ceiling on how long a healthy transfer may run.
///
/// TRACES: UR-071 | DR-289 | UT-251
fn with_stall_timeout(stall: Duration, https_only: bool) -> Self {
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(300)) // 5 minute timeout
.https_only(true)
.connect_timeout(CONNECT_TIMEOUT)
.read_timeout(stall)
.https_only(https_only)
.build()
.expect("Failed to create HTTP client");
@@ -183,6 +212,16 @@ impl DownloadWorker {
.await
.map_err(|e| DownloadError::FileSystem(e.to_string()))?;
// A media file is never legitimately empty, and completing one is worse
// than failing: the row goes `completed`, the item shows as available
// offline, and playback then stalls on a file with nothing in it. A
// server that answered 200 with no body — an error page, a transcode
// that produced nothing — used to land here. Treat it as the network
// failure it is so the retry budget applies and the `.part` is kept.
if let Some(reason) = rejects_as_empty(downloaded) {
return Err(DownloadError::Network(reason.to_string()));
}
// Move from .part to final location
fs::rename(&temp_path, &task.target_path)
.await
@@ -229,6 +268,23 @@ pub fn resume_offset(existing_bytes: u64, status: u16) -> u64 {
}
}
/// Why a finished transfer must not be accepted, if it must not be.
///
/// A media file is never legitimately empty, and *completing* an empty one is
/// worse than failing: the row goes `completed`, the item shows as available
/// offline, and playback later stalls on a file with nothing in it. A server
/// that answered 200 with no body — an error page, a transcode that produced
/// nothing — used to land exactly there.
///
/// Reported as a network error so the existing retry budget applies and the
/// `.part` file is kept for a resume.
///
/// TRACES: UR-019 | DR-168 | UT-168
pub fn rejects_as_empty(downloaded: u64) -> Option<&'static str> {
(downloaded == 0)
.then_some("server sent an empty body; refusing to complete a zero-byte download")
}
/// The partial-download sidecar for `target`.
///
/// **Appends** `.part` rather than replacing the extension. The worker used
@@ -305,6 +361,23 @@ impl std::error::Error for DownloadError {}
mod tests {
use super::*;
/// A transfer that produced no bytes must never be marked complete.
///
/// Completing it publishes an empty file as playable offline; the media
/// server then answers a request for it with a 416 and the item simply
/// never starts, with nothing in the UI explaining why.
///
/// TRACES: UR-019 | DR-168 | UT-168
#[test]
fn test_a_zero_byte_transfer_is_rejected_rather_than_completed() {
assert!(
rejects_as_empty(0).is_some(),
"a zero-byte download must not be completed"
);
assert!(rejects_as_empty(1).is_none());
assert!(rejects_as_empty(4 * 1024 * 1024).is_none());
}
/// The bitrate-download corruption: a transcode ignores `Range` and answers
/// `200` with the whole stream. Appending that to the bytes already on disk
/// duplicated them, so every retry grew the file past its real size and left
@@ -392,3 +465,117 @@ mod tests {
assert!(!DownloadError::Network("timeout".to_string()).is_stopped());
}
}
/// Transfers that outlive the timeout. These drive the real `reqwest` client
/// against a loopback socket because the defect lived in how that client was
/// configured, not in any code of ours a mock could stand in for.
#[cfg(test)]
mod timeout_tests {
use super::*;
use tokio::net::TcpListener;
/// Serve one HTTP/1.1 response of `chunks` bodies of `chunk_len` bytes,
/// pausing `gap` between them. `hang_after` chunks, the server stops sending
/// and never closes — a stalled connection.
async fn dribbling_server(
chunks: usize,
chunk_len: usize,
gap: Duration,
hang_after: Option<usize>,
) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut sock, _) = listener.accept().await.unwrap();
// Drain the request head; we answer the same thing regardless.
let mut buf = [0u8; 4096];
let _ = tokio::io::AsyncReadExt::read(&mut sock, &mut buf).await;
let head = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
chunks * chunk_len
);
if sock.write_all(head.as_bytes()).await.is_err() {
return;
}
let body = vec![b'x'; chunk_len];
for i in 0..chunks {
if hang_after == Some(i) {
// Hold the socket open forever without writing.
tokio::time::sleep(Duration::from_secs(3600)).await;
}
// The client hanging up (as it does once it times out) is not
// the server's failure to report.
if sock.write_all(&body).await.is_err() || sock.flush().await.is_err() {
return;
}
tokio::time::sleep(gap).await;
}
});
format!("http://{}/file.bin", addr)
}
/// A download that takes longer than the timeout but never stalls must
/// finish. The worker set `Client::timeout`, which in reqwest is a *total*
/// deadline covering the body, so every transfer longer than five minutes —
/// any film at transcode speed — was cut off with "error decoding response
/// body", retried from byte zero (a transcode ignores `Range`), and cut off
/// again until the retry budget ran out.
///
/// TRACES: UR-071 | DR-289 | UT-251
#[tokio::test]
async fn test_download_longer_than_the_stall_timeout_completes_when_bytes_keep_flowing() {
let stall = Duration::from_millis(400);
// 12 chunks × 100 ms ≈ 1.2 s of transfer, three times the stall timeout,
// with every gap comfortably inside it.
let url = dribbling_server(12, 1024, Duration::from_millis(100), None).await;
let dir = tempfile::tempdir().unwrap();
let task = DownloadTask {
url,
target_path: dir.path().join("file.bin"),
};
let worker = DownloadWorker::with_stall_timeout(stall, false);
let result = worker
.download(&task, &AtomicBool::new(false), |_, _| {})
.await;
let result = result
.unwrap_or_else(|e| panic!("a transfer that never stalls must not time out: {e:?}"));
assert_eq!(result.bytes_downloaded, 12 * 1024);
assert!(task.target_path.exists());
}
/// The converse: a connection that goes silent is still given up on, so
/// dropping the total deadline did not turn a dead wifi link into a download
/// that hangs forever with no retry.
///
/// TRACES: UR-071 | DR-289 | UT-251
#[tokio::test]
async fn test_download_that_stalls_is_given_up_on() {
let stall = Duration::from_millis(300);
let url = dribbling_server(4, 1024, Duration::from_millis(10), Some(2)).await;
let dir = tempfile::tempdir().unwrap();
let task = DownloadTask {
url,
target_path: dir.path().join("file.bin"),
};
let worker = DownloadWorker::with_stall_timeout(stall, false);
// `download()` retries with 5 s/15 s/45 s backoff; a single attempt is
// what proves the stall is detected.
let started = std::time::Instant::now();
let result = worker
.try_download(&task, &AtomicBool::new(false), &|_, _| {})
.await;
assert!(
matches!(result, Err(DownloadError::Network(_))),
"a stalled transfer must fail as a network error: {result:?}"
);
assert!(
started.elapsed() < Duration::from_secs(5),
"the stall must be detected promptly, took {:?}",
started.elapsed()
);
}
}
+9 -6
View File
@@ -54,7 +54,10 @@ impl JellyfinClient {
return "Unknown";
}
/// Build the X-Emby-Authorization header value
/// Build the value for the `Authorization` header (the `MediaBrowser`
/// scheme — see `HttpClient::build_auth_header`).
///
/// TRACES: UR-085 | DR-287
fn get_auth_header(&self) -> String {
format!(
"MediaBrowser Client=\"{}\", Version=\"{}\", Device=\"{}\", DeviceId=\"{}\", Token=\"{}\"",
@@ -75,7 +78,7 @@ impl JellyfinClient {
let response = self
.http_client
.get(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.header("Authorization", self.get_auth_header())
.send()
.await
.map_err(|e| {
@@ -155,7 +158,7 @@ impl JellyfinClient {
.http_client
.post(&url)
.header("Content-Type", "application/json")
.header("X-Emby-Authorization", self.get_auth_header())
.header("Authorization", self.get_auth_header())
.json(body)
.send()
.await
@@ -293,7 +296,7 @@ impl JellyfinClient {
let response = self
.http_client
.post(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.header("Authorization", self.get_auth_header())
.send()
.await
.map_err(|e| {
@@ -360,7 +363,7 @@ impl JellyfinClient {
let response = self
.http_client
.post(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.header("Authorization", self.get_auth_header())
.send()
.await
.map_err(|e| format!("Network request failed: {}", e))?;
@@ -503,7 +506,7 @@ impl JellyfinClient {
let response = self
.http_client
.delete(&url)
.header("X-Emby-Authorization", self.get_auth_header())
.header("Authorization", self.get_auth_header())
.send()
.await
.map_err(|e| format!("Network request failed: {}", e))?;
+31 -1
View File
@@ -56,6 +56,27 @@ impl HttpClient {
Ok(Self { client, config })
}
/// A client that will also talk plain HTTP, for tests only.
///
/// `new` sets `https_only(true)` and that must stay: it is what stops a
/// downgrade putting a session token on the wire in clear. `wiremock` serves
/// plain HTTP on loopback, so the alternative to this constructor is either
/// weakening the real one or not testing the repository against a server at
/// all — and the latter is what DR-281 exists to end.
///
/// `#[cfg(test)]` so it cannot reach a shipped binary.
///
/// TRACES: UR-085 | DR-281
#[cfg(test)]
pub fn new_allowing_plaintext_for_tests(config: HttpConfig) -> Result<Self, String> {
let client = Client::builder()
.timeout(config.timeout)
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
Ok(Self { client, config })
}
/// Get device name based on platform
fn get_device_name() -> &'static str {
#[cfg(target_os = "android")]
@@ -78,7 +99,16 @@ impl HttpClient {
return "Unknown";
}
/// Build the X-Emby-Authorization header value
/// Build the value for the `Authorization` header.
///
/// The `MediaBrowser` scheme, which is the non-deprecated one: Jellyfin 12.0
/// disables `X-Emby-Authorization` (and the `Emby` scheme, `X-Emby-Token`
/// and `X-MediaBrowser-Token`) by default, and a migration turns it off on
/// upgraded servers too. `Authorization: MediaBrowser …` is ungated on both
/// 10.11.x and 12.x, so this is one value for both generations rather than a
/// capability branch.
///
/// TRACES: UR-085 | DR-287
pub fn build_auth_header(access_token: Option<&str>, device_id: &str) -> String {
let mut parts = vec![
format!("MediaBrowser Client=\"{}\"", APP_NAME),
+50 -126
View File
@@ -238,6 +238,7 @@ use commands::{
repository_get_resume_movies,
repository_get_series_current_episode,
repository_get_series_episodes,
repository_get_series_view,
repository_get_similar_items,
repository_get_stream_selection,
repository_get_subtitle_url,
@@ -351,7 +352,7 @@ use player::{MediaSessionManager, PlayerBackend, PlayerController, TauriEventEmi
// still launch (browse library, manage downloads, see an error) instead of crashing.
use player::NullBackend;
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "windows"))]
use player::MpvBackend;
use settings::VideoSettings;
use storage::Database;
@@ -693,15 +694,44 @@ fn create_player_backend(
}
}
// For Linux, use MPV backend for audio playback
#[cfg(target_os = "linux")]
// Linux and Windows: mpv. On Windows libmpv-2.dll ships beside the exe in
// the installer (DR-237); on Linux it is the system library.
#[cfg(any(target_os = "linux", target_os = "windows"))]
{
info!("Linux platform detected - initializing MPV backend for audio");
match MpvBackend::new(Some(_event_emitter), playback_reporter, position_throttler) {
info!("Initializing MPV backend");
// Windows: mpv draws video into the main window itself (DR-237), so it
// needs the HWND before it initialises. Linux draws through the render
// API into a GTK surface attached later, and needs no handle.
#[cfg(target_os = "windows")]
let video_window = {
use tauri::Manager;
app_handle
.get_webview_window("main")
.and_then(|w| w.hwnd().ok())
.map(|hwnd| hwnd.0 as i64)
};
#[cfg(not(target_os = "windows"))]
let video_window = None;
match MpvBackend::new(
Some(_event_emitter),
playback_reporter,
position_throttler,
video_window,
) {
Ok(backend) => {
info!("Successfully initialized MPV backend for Linux");
info!("Successfully initialized MPV backend");
Box::new(backend)
}
#[cfg(target_os = "windows")]
Err(e) => {
// The DLL ships in the installer, so there is no package to
// tell the user to install; a failure here is a broken install.
error!("FATAL ERROR: Failed to initialize MPV backend: {}", e);
error!("libmpv-2.dll should sit beside jellytau.exe; reinstall JellyTau.");
emit_backend_init_failed(&app_handle, "mpv", e.to_string());
Box::new(NullBackend::new())
}
#[cfg(target_os = "linux")]
Err(e) => {
error!("\n========================================");
error!("FATAL ERROR: Failed to initialize MPV backend");
@@ -730,10 +760,10 @@ fn create_player_backend(
}
}
// Platforms with no native audio backend (e.g. Windows): render audio-only
// playback through a webview <audio> element (all video already renders in
// the webview). Falls back to NullBackend only if the backend can't init.
#[cfg(not(any(target_os = "linux", target_os = "android")))]
// Platforms with no native audio backend (none that ships since Windows
// moved to mpv): render audio-only playback through a webview <audio>
// element. Falls back to NullBackend only if the backend can't init.
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "windows")))]
{
info!("No native audio backend for this platform - using webview <audio> backend");
match player::WebviewAudioBackend::new(_event_emitter) {
@@ -1014,6 +1044,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
repository_get_next_up_episodes,
repository_get_series_episodes,
repository_get_series_current_episode,
repository_get_series_view,
repository_clear_watch_history,
repository_get_recently_played_audio,
repository_get_resume_movies,
@@ -1071,86 +1102,6 @@ fn specta_builder() -> Builder<tauri::Wry> {
])
}
/// Configure GStreamer (the media backend behind WebKitGTK's HTML5 `<video>`
/// element on Linux) to prefer hardware-accelerated VAAPI decoding when the
/// host provides it, falling back to software decoding otherwise.
///
/// All variables are only set if the user has not already exported them, so an
/// explicit override (e.g. forcing software decode for debugging) is respected.
/// They must be applied before WebKitGTK builds its GStreamer pipeline, hence the
/// call at the very top of `run()`.
#[cfg(target_os = "linux")]
fn enable_linux_hardware_video_decoding() {
// Boost the rank of the modern stateless VAAPI decoders (gst-plugins-bad
// `va` plugin) so GStreamer selects them ahead of the software decoders. The
// `MAX` rank wins decoder autoplugging when the hardware/driver supports the
// codec; unsupported codecs simply fall through to software.
let rank_overrides = "vah264dec:MAX,vah265dec:MAX,vavp9dec:MAX,vaav1dec:MAX,\
vampeg2dec:MAX,vavp8dec:MAX";
set_env_if_unset("GST_PLUGIN_FEATURE_RANK", rank_overrides);
// Ensure WebKit keeps GStreamer's hardware/DMABUF video path enabled. Setting
// this to "0" would force software decoding, so only default it to "1".
set_env_if_unset("WEBKIT_GST_ENABLE_HW_VIDEO_DECODER", "1");
info!("[INIT] Linux hardware video decoding (VAAPI) enabled where supported");
log_available_vaapi_decoders();
}
/// Probe (via `gst-inspect-1.0`, which ships with GStreamer) which VAAPI hardware
/// video decoders GStreamer can actually load on this host, and log the result so
/// it is clear at startup whether hardware decoding is genuinely available or
/// whether playback will fall back to software.
#[cfg(target_os = "linux")]
fn log_available_vaapi_decoders() {
const HW_DECODERS: &[&str] = &[
"vah264dec",
"vah265dec",
"vavp9dec",
"vaav1dec",
"vampeg2dec",
"vavp8dec",
];
let available: Vec<&str> = HW_DECODERS
.iter()
.copied()
.filter(|name| {
std::process::Command::new("gst-inspect-1.0")
.arg(name)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
})
.collect();
if available.is_empty() {
log::warn!(
"[INIT] No VAAPI hardware video decoders found via gst-inspect-1.0; \
video will use software decoding. Install the GStreamer 'va' plugin \
(gst-plugins-bad) and a VAAPI driver to enable hardware decoding."
);
} else {
info!(
"[INIT] VAAPI hardware video decoders available to GStreamer: {}",
available.join(", ")
);
}
}
#[cfg(target_os = "linux")]
fn set_env_if_unset(key: &str, value: &str) {
if std::env::var_os(key).is_none() {
// SAFETY: called once at startup before any threads that read the
// environment (WebKitGTK/GStreamer) are spawned.
std::env::set_var(key, value);
}
}
/// Cached thumbnails are handed to the webview as asset-protocol URLs by
/// `convertFileSrc` (`asset://localhost/…` on Linux/macOS,
/// `http://asset.localhost/…` on Windows/Android). Tauri only answers that
@@ -1230,13 +1181,6 @@ pub fn run() {
// TRACES: UR-078 | DR-218
crate::utils::diagnostics::install_panic_hook();
// On Linux, video plays through WebKitGTK's HTML5 <video> element, which uses
// GStreamer as its media backend. Enable hardware-accelerated (VAAPI) decoding
// when available so video transcoding/decoding does not fall back to the CPU.
// These must be set before WebKitGTK initializes its GStreamer pipeline.
#[cfg(target_os = "linux")]
enable_linux_hardware_video_decoding();
// NOTE: TypeScript bindings are generated by the `export_typescript_bindings`
// test (`cargo test export_typescript_bindings`), NOT at runtime. Calling
// `.export()` here would try to write `../src/lib/api/bindings.ts` at app
@@ -1398,40 +1342,20 @@ pub fn run() {
// during its construction, and doing this in the order the code
// used to read produced "no mpv handle" every time — the surface was
// built before there was anything to draw from.
// Native video surface: put a GL area under Tauri's webview so mpv
// can draw beneath the controls (UR-080 / DR-231).
// Native video surface: mpv draws into the main window's own vbox,
// underneath Tauri's webview, so the Svelte controls composite over
// the picture (UR-080 / DR-231). The widget tree is left exactly as
// Tauri built it — wrapping the webview in a GtkOverlay aborts the
// process on the first click; `video_surface` explains why.
//
// 🔴 OFF BY DEFAULT — the naive reparent crashes the app on the
// first click. `tauri-runtime-wry`'s undecorated-resizing handler
// walks a hard-coded two-hop path on every button press in the
// webview:
// Unconditional on Linux since DR-235: mpv is the only Linux video
// renderer, so there is no webview path to fall back to if this
// fails — the warnings below are the whole diagnosis.
//
// webview.parent() // "This one should be GtkBox"
// .parent() // ...and this one the GtkWindow
// .downcast::<gtk::Window>().unwrap()
//
// Wrapping the webview in a GtkOverlay makes that chain
// webview → GtkOverlay → GtkBox, the downcast fails, and because the
// panic is non-unwinding it aborts the process. The decoration check
// that would otherwise make this handler inert runs *after* the
// unwrap, so no window configuration avoids it.
//
// This is the "only place Tauri-specific behaviour could still bite"
// that the spike named as the untested half of G1. It bites. The
// surface attaches perfectly and then dies on interaction, so
// "attached successfully" in the log is not the gate — a click is.
//
// Kept behind an env var rather than deleted so the next attempt has
// something to iterate on: JELLYTAU_NATIVE_VIDEO=1 bun run tauri dev
//
// TRACES: UR-080 | DR-231
// TRACES: UR-080 | DR-231, DR-235
#[cfg(target_os = "linux")]
if crate::player::native_video::enabled() {
use tauri::Manager;
log::warn!(
"[INIT] JELLYTAU_NATIVE_VIDEO=1 — attaching the experimental \
video surface (mpv drawn behind the webview, no reparenting)"
);
if let Some(window) = app.get_webview_window("main") {
match window.default_vbox() {
Ok(vbox) => {
+41 -1
View File
@@ -326,8 +326,12 @@ impl Span {
///
/// TRACES: UR-071 | DR-137 | UT-127
pub fn span_for(range: Option<&str>, len: u64) -> Option<Span> {
// A zero-length file has no byte to serve. `end` is inclusive, so the
// shortest span this type can express is one byte — returning one for an
// empty file declared `Content-Length: 1` and then streamed nothing, which
// Chromium's media loader waits on forever. 416 says so honestly instead.
if len == 0 {
return Some(Span { start: 0, end: 0 });
return None;
}
let last = len - 1;
let first_chunk = Span {
@@ -442,6 +446,42 @@ fn content_type(path: &Path, head: &[u8]) -> &'static str {
mod tests {
use super::*;
/// An empty file must not be answered with a span that promises a byte.
///
/// `Span::len()` is `end + 1 - start`, so the `Span { start: 0, end: 0 }`
/// that a zero-length file used to produce reported a length of **one**.
/// The response then declared `Content-Length: 1` and streamed nothing,
/// which Chromium's media loader waits on forever — reaching the user as a
/// downloaded item that never starts. A zero-byte file has no satisfiable
/// range, so 416 is the honest answer.
///
/// TRACES: UR-071 | DR-137 | UT-127
#[test]
fn test_span_for_an_empty_file_is_unsatisfiable() {
assert!(
span_for(None, 0).is_none(),
"a zero-length file has no byte to serve"
);
assert!(span_for(Some("bytes=0-"), 0).is_none());
assert!(span_for(Some("bytes=0-100"), 0).is_none());
}
/// Whatever a span says, its length must match the bytes that follow it.
///
/// TRACES: UR-071 | DR-137 | UT-127
#[test]
fn test_span_len_never_exceeds_the_file() {
for len in [0u64, 1, 2, 4095, CHUNK_LEN, CHUNK_LEN + 1] {
if let Some(span) = span_for(None, len) {
assert!(
span.len() <= len,
"span for a {len}-byte file claims {} bytes",
span.len()
);
}
}
}
/// The whole point: a request with no `Range` must still come back bounded.
/// That is the case Tauri's asset protocol answers with the entire file —
/// the read Chromium abandoned after 31s.
+379 -313
View File
@@ -3,6 +3,7 @@
//! This module provides a `PlayerBackend` implementation using Android's ExoPlayer
//! through JNI calls to Kotlin code.
use super::jni_guard::jni_guard;
use crate::utils::lock::MutexSafe;
use log::debug;
use std::sync::{Arc, Mutex, OnceLock};
@@ -677,42 +678,47 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
position: jdouble,
duration: jdouble,
) {
// Debug: Log every 10th update to avoid spam
static mut UPDATE_COUNTER: u32 = 0;
unsafe {
UPDATE_COUNTER += 1;
if UPDATE_COUNTER % 10 == 0 {
log::debug!("[Android] Position update {} / {}", position, duration);
}
}
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate",
|| {
// Debug: Log every 10th update to avoid spam
static mut UPDATE_COUNTER: u32 = 0;
unsafe {
UPDATE_COUNTER += 1;
if UPDATE_COUNTER % 10 == 0 {
log::debug!("[Android] Position update {} / {}", position, duration);
}
}
// Update state and get the preserved duration to emit
let duration_to_emit = if let Some(state) = SHARED_STATE.get() {
let mut state = state.lock_safe();
state.position = position;
if duration > 0.0 {
state.duration = Some(duration);
}
// Use preserved duration from state, or fall back to the received value
state.duration.unwrap_or(duration)
} else {
duration
};
// Update state and get the preserved duration to emit
let duration_to_emit = if let Some(state) = SHARED_STATE.get() {
let mut state = state.lock_safe();
state.position = position;
if duration > 0.0 {
state.duration = Some(duration);
}
// Use preserved duration from state, or fall back to the received value
state.duration.unwrap_or(duration)
} else {
duration
};
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PositionUpdate {
position,
duration: duration_to_emit,
});
} else {
log::error!("[Android] WARNING: No event emitter for position update!");
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PositionUpdate {
position,
duration: duration_to_emit,
});
} else {
log::error!("[Android] WARNING: No event emitter for position update!");
}
// Throttled progress reporting to Jellyfin so playback position syncs and can
// be resumed on another device. ExoPlayer only fires position updates while
// playing, but guard on the stored state anyway. Mirrors the MPV backend's
// progress loop; both share the same EventThrottler (every 30s per item).
report_android_progress(position);
// Throttled progress reporting to Jellyfin so playback position syncs and can
// be resumed on another device. ExoPlayer only fires position updates while
// playing, but guard on the stored state anyway. Mirrors the MPV backend's
// progress loop; both share the same EventThrottler (every 30s per item).
report_android_progress(position);
},
);
}
/// Report throttled playback progress to Jellyfin from the Android position
@@ -774,8 +780,17 @@ fn report_android_progress(position: f64) {
handle.spawn(spawn_report());
} else {
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(spawn_report());
// Not `unwrap()`: this runs on a JNI thread, and `Runtime::new()`
// fails under the fd exhaustion and thread-spawn refusal Android
// subjects a media app to. The panic used to unwind out of the
// `extern "system"` caller and abort the process — losing one
// progress report is recoverable, losing the app is not.
match tokio::runtime::Runtime::new() {
Ok(rt) => rt.block_on(spawn_report()),
Err(e) => log::error!(
"[Android] No runtime available to report progress; dropping it: {e}"
),
}
});
}
@@ -790,51 +805,56 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
state: JString,
media_id: JString,
) {
let state_str: String = env.get_string(&state).map(|s| s.into()).unwrap_or_default();
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnStateChanged",
|| {
let state_str: String = env.get_string(&state).map(|s| s.into()).unwrap_or_default();
let media_id_opt: Option<String> = if media_id.is_null() {
None
} else {
env.get_string(&media_id).map(|s| s.into()).ok()
};
let media_id_opt: Option<String> = if media_id.is_null() {
None
} else {
env.get_string(&media_id).map(|s| s.into()).ok()
};
// Update shared state
if let Some(shared) = SHARED_STATE.get() {
let mut shared = shared.lock_safe();
if let Some(media) = shared.current_media.clone() {
let duration = shared.duration.unwrap_or(0.0);
let position = shared.position;
match state_str.as_str() {
"playing" => {
shared.state = PlayerState::Playing {
media,
position,
duration,
};
shared.is_loaded = true;
// Update shared state
if let Some(shared) = SHARED_STATE.get() {
let mut shared = shared.lock_safe();
if let Some(media) = shared.current_media.clone() {
let duration = shared.duration.unwrap_or(0.0);
let position = shared.position;
match state_str.as_str() {
"playing" => {
shared.state = PlayerState::Playing {
media,
position,
duration,
};
shared.is_loaded = true;
}
"paused" => {
shared.state = PlayerState::Paused {
media,
position,
duration,
};
}
"idle" => {
shared.state = PlayerState::Idle;
shared.is_loaded = false;
}
_ => {}
}
}
"paused" => {
shared.state = PlayerState::Paused {
media,
position,
duration,
};
}
"idle" => {
shared.state = PlayerState::Idle;
shared.is_loaded = false;
}
_ => {}
}
}
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::StateChanged {
state: state_str,
media_id: media_id_opt,
});
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::StateChanged {
state: state_str,
media_id: media_id_opt,
});
}
},
);
}
/// Called when media has finished loading.
@@ -844,15 +864,20 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
_class: JClass,
duration: jdouble,
) {
if let Some(state) = SHARED_STATE.get() {
let mut state = state.lock_safe();
state.duration = Some(duration);
state.is_loaded = true;
}
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnMediaLoaded",
|| {
if let Some(state) = SHARED_STATE.get() {
let mut state = state.lock_safe();
state.duration = Some(duration);
state.is_loaded = true;
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
}
},
);
}
/// Called when playback reaches the end.
@@ -861,49 +886,38 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
_env: JNIEnv,
_class: JClass,
) {
log::info!("[ExoPlayer] Playback ended - processing autoplay decision");
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPlaybackEnded",
|| {
log::info!("[ExoPlayer] Playback ended - processing autoplay decision");
// Get player controller and handle autoplay decision
if let Some(controller) = PLAYER_CONTROLLER.get() {
let controller = controller.clone();
// Get player controller and handle autoplay decision
if let Some(controller) = PLAYER_CONTROLLER.get() {
let controller = controller.clone();
// Spawn async task to handle autoplay decision
// Use tauri::async_runtime::spawn instead of tokio::spawn
// JNI callbacks happen on arbitrary threads without a Tokio runtime
tauri::async_runtime::spawn(async move {
// Compute the autoplay decision and release the lock before matching.
// Holding the guard across the match would deadlock the AdvanceToNext
// arm, which re-locks the controller to call next() — leaving playback
// stopped (paused at position 0) instead of advancing.
let decision = controller.lock().await.on_playback_ended().await;
match decision {
Ok(AutoplayDecision::Stop) => {
log::debug!("[Autoplay] Decision: Stop playback");
// Emit PlaybackEnded event to frontend
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
Ok(AutoplayDecision::AdvanceToNext) => {
log::debug!("[Autoplay] Decision: Advance to next track");
// Advance to next track in queue
let ctrl = controller.lock().await;
// Spawn async task to handle autoplay decision
// Use tauri::async_runtime::spawn instead of tokio::spawn
// JNI callbacks happen on arbitrary threads without a Tokio runtime
tauri::async_runtime::spawn(async move {
// Compute the autoplay decision and release the lock before matching.
// Holding the guard across the match would deadlock the AdvanceToNext
// arm, which re-locks the controller to call next() — leaving playback
// stopped (paused at position 0) instead of advancing.
let decision = controller.lock().await.on_playback_ended().await;
match decision {
Ok(AutoplayDecision::Stop) => {
log::debug!("[Autoplay] Decision: Stop playback");
// Emit PlaybackEnded event to frontend
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
Ok(AutoplayDecision::AdvanceToNext) => {
log::debug!("[Autoplay] Decision: Advance to next track");
// Advance to next track in queue
let ctrl = controller.lock().await;
// Log queue state before advancing
let queue_info = {
let queue = ctrl.queue.lock_safe();
format!(
"current_index={:?}, len={}",
queue.current_index(),
queue.items().len()
)
};
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
match ctrl.next() {
Ok(_) => {
log::info!("[Autoplay] Successfully advanced to next track");
// Log queue state after advancing
// Log queue state before advancing
let queue_info = {
let queue = ctrl.queue.lock_safe();
format!(
@@ -912,93 +926,115 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
queue.items().len()
)
};
log::debug!("[Autoplay] Queue state after next(): {}", queue_info);
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
// Emit queue changed event so frontend updates UI with new current track
ctrl.emit_queue_changed();
match ctrl.next() {
Ok(_) => {
log::info!("[Autoplay] Successfully advanced to next track");
// Log queue state after advancing
let queue_info = {
let queue = ctrl.queue.lock_safe();
format!(
"current_index={:?}, len={}",
queue.current_index(),
queue.items().len()
)
};
log::debug!(
"[Autoplay] Queue state after next(): {}",
queue_info
);
// Emit queue changed event so frontend updates UI with new current track
ctrl.emit_queue_changed();
}
Err(e) => {
log::error!(
"[Autoplay] Failed to advance to next track: {}",
e
);
// Emit PlaybackEnded event on error
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
}
}
Ok(AutoplayDecision::ShowNextEpisodePopup {
current_episode,
next_episode,
countdown_seconds,
auto_advance,
}) => {
log::info!(
"[Autoplay] Decision: Show next episode popup (countdown: {}s, auto: {})",
countdown_seconds,
auto_advance
);
// Emit popup event to frontend
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::ShowNextEpisodePopup {
current_episode: current_episode.clone(),
next_episode: next_episode.clone(),
countdown_seconds,
auto_advance,
});
}
if auto_advance {
// Shared with the frontend-invoked command path
// (player_on_playback_ended) so the two dispatchers cannot
// disagree about how a background audio-only episode
// advances — they did, and the command's copy was missing
// the case entirely. That copy is the one that actually
// decides here: the end reason set at load makes this
// callback's own decision Stop, and the frontend echoes the
// resulting PlaybackEnded back into the command.
controller
.lock()
.await
.auto_advance_to_next_episode(next_episode, countdown_seconds)
.await;
}
}
Ok(AutoplayDecision::ResumeStream { position }) => {
// ExoPlayer reported ENDED because the progressive transcode's
// connection dropped, not because the episode finished. This
// is the arm that matters while backgrounded: it needs no
// frontend echo, so the stream re-opens even with the webview
// suspended — and playback never parks in STATE_ENDED, where
// the next lockscreen/Bluetooth play restarts the item at 0:00.
log::info!(
"[Autoplay] Decision: Resume truncated stream at {:.1}s",
position
);
let ctrl = controller.lock().await;
if let Err(e) = ctrl.resume_stream_at(position).await {
log::error!("[Autoplay] Failed to resume truncated stream: {}", e);
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
}
Err(e) => {
log::error!("[Autoplay] Failed to advance to next track: {}", e);
log::error!("[Autoplay] Decision failed: {}", e);
// Emit PlaybackEnded event on error
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
}
}
Ok(AutoplayDecision::ShowNextEpisodePopup {
current_episode,
next_episode,
countdown_seconds,
auto_advance,
}) => {
log::info!(
"[Autoplay] Decision: Show next episode popup (countdown: {}s, auto: {})",
countdown_seconds,
auto_advance
);
// Emit popup event to frontend
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::ShowNextEpisodePopup {
current_episode: current_episode.clone(),
next_episode: next_episode.clone(),
countdown_seconds,
auto_advance,
});
}
if auto_advance {
// Shared with the frontend-invoked command path
// (player_on_playback_ended) so the two dispatchers cannot
// disagree about how a background audio-only episode
// advances — they did, and the command's copy was missing
// the case entirely. That copy is the one that actually
// decides here: the end reason set at load makes this
// callback's own decision Stop, and the frontend echoes the
// resulting PlaybackEnded back into the command.
controller
.lock()
.await
.auto_advance_to_next_episode(next_episode, countdown_seconds)
.await;
}
}
Ok(AutoplayDecision::ResumeStream { position }) => {
// ExoPlayer reported ENDED because the progressive transcode's
// connection dropped, not because the episode finished. This
// is the arm that matters while backgrounded: it needs no
// frontend echo, so the stream re-opens even with the webview
// suspended — and playback never parks in STATE_ENDED, where
// the next lockscreen/Bluetooth play restarts the item at 0:00.
log::info!(
"[Autoplay] Decision: Resume truncated stream at {:.1}s",
position
);
let ctrl = controller.lock().await;
if let Err(e) = ctrl.resume_stream_at(position).await {
log::error!("[Autoplay] Failed to resume truncated stream: {}", e);
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
}
Err(e) => {
log::error!("[Autoplay] Decision failed: {}", e);
// Emit PlaybackEnded event on error
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
});
} else {
log::warn!("[Autoplay] PlayerController not initialized - emitting PlaybackEnded");
// Fallback: just emit PlaybackEnded event
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
});
} else {
log::warn!("[Autoplay] PlayerController not initialized - emitting PlaybackEnded");
// Fallback: just emit PlaybackEnded event
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded);
}
}
},
);
}
/// Called when buffering state changes.
@@ -1008,11 +1044,16 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
_class: JClass,
percent: jint,
) {
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Buffering {
percent: percent as u8,
});
}
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnBuffering",
|| {
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Buffering {
percent: percent as u8,
});
}
},
);
}
/// Called when a playback error occurs.
@@ -1023,67 +1064,72 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
message: JString,
recoverable: jboolean,
) {
let message_str: String = env
.get_string(&message)
.map(|s| s.into())
.unwrap_or_else(|_| "Unknown error".to_string());
let recoverable = recoverable != 0;
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnError",
|| {
let message_str: String = env
.get_string(&message)
.map(|s| s.into())
.unwrap_or_else(|_| "Unknown error".to_string());
let recoverable = recoverable != 0;
// A background audio-only handoff is an mp3 the device was already decoding,
// so a recoverable failure part-way through is the network. Surfacing it as a
// player error stops playback for good (the frontend's handler calls
// player_stop); re-opening the stream where it died is the "buffer and
// resume" this actually is. Everything else keeps reporting the error.
if recoverable {
if let Some(controller) = PLAYER_CONTROLLER.get() {
let controller = controller.clone();
let message_str = message_str.clone();
tauri::async_runtime::spawn(async move {
let resume = controller.lock().await.recoverable_error_resume();
let Some((position, delay_secs)) = resume else {
// Declined here, so report it as NOT recoverable: the frontend
// would otherwise echo it into player_recover_stream and ask
// the same question a second time.
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Error {
message: message_str,
recoverable: false,
});
}
// A background audio-only handoff is an mp3 the device was already decoding,
// so a recoverable failure part-way through is the network. Surfacing it as a
// player error stops playback for good (the frontend's handler calls
// player_stop); re-opening the stream where it died is the "buffer and
// resume" this actually is. Everything else keeps reporting the error.
if recoverable {
if let Some(controller) = PLAYER_CONTROLLER.get() {
let controller = controller.clone();
let message_str = message_str.clone();
tauri::async_runtime::spawn(async move {
let resume = controller.lock().await.recoverable_error_resume();
let Some((position, delay_secs)) = resume else {
// Declined here, so report it as NOT recoverable: the frontend
// would otherwise echo it into player_recover_stream and ask
// the same question a second time.
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Error {
message: message_str,
recoverable: false,
});
}
return;
};
log::warn!(
"[ExoPlayer] Recoverable stream error ({}) — re-opening at {:.1}s in {}s",
message_str,
position,
delay_secs
);
// Give a brief outage time to clear before asking the server for
// the stream again; retrying instantly just burns the budget.
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
let ctrl = controller.lock().await;
if let Err(e) = ctrl.resume_stream_at(position).await {
log::error!("[ExoPlayer] Failed to resume after error: {}", e);
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Error {
message: message_str,
recoverable: false,
});
}
}
});
return;
};
log::warn!(
"[ExoPlayer] Recoverable stream error ({}) — re-opening at {:.1}s in {}s",
message_str,
position,
delay_secs
);
// Give a brief outage time to clear before asking the server for
// the stream again; retrying instantly just burns the budget.
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
let ctrl = controller.lock().await;
if let Err(e) = ctrl.resume_stream_at(position).await {
log::error!("[ExoPlayer] Failed to resume after error: {}", e);
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Error {
message: message_str,
recoverable: false,
});
}
}
});
return;
}
}
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Error {
message: message_str,
recoverable,
});
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Error {
message: message_str,
recoverable,
});
}
},
);
}
/// Called when volume changes.
@@ -1094,16 +1140,21 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
volume: jfloat,
muted: jboolean,
) {
if let Some(state) = SHARED_STATE.get() {
state.lock_safe().volume = volume;
}
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnVolumeChanged",
|| {
if let Some(state) = SHARED_STATE.get() {
state.lock_safe().volume = volume;
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::VolumeChanged {
volume,
muted: muted != 0,
});
}
if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::VolumeChanged {
volume,
muted: muted != 0,
});
}
},
);
}
// JNI callback for MediaSession commands from JellyTauPlaybackService
@@ -1127,14 +1178,19 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackServic
_class: JClass,
command: JString,
) {
let command_str: String = env
.get_string(&command)
.map(|s| s.into())
.unwrap_or_default();
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnMediaCommand",
|| {
let command_str: String = env
.get_string(&command)
.map(|s| s.into())
.unwrap_or_default();
if let Some(handler) = MEDIA_COMMAND_HANDLER.get() {
handler.on_command(&command_str);
}
if let Some(handler) = MEDIA_COMMAND_HANDLER.get() {
handler.on_command(&command_str);
}
},
);
}
/// JNI callback from JellyTauPlaybackService when volume buttons are pressed in remote mode.
@@ -1148,14 +1204,19 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackServic
command: JString,
volume: jint,
) {
let command_str: String = env
.get_string(&command)
.map(|s| s.into())
.unwrap_or_default();
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnRemoteVolumeChange",
|| {
let command_str: String = env
.get_string(&command)
.map(|s| s.into())
.unwrap_or_default();
if let Some(handler) = REMOTE_VOLUME_HANDLER.get() {
handler.on_remote_volume_change(&command_str, volume as i32);
}
if let Some(handler) = REMOTE_VOLUME_HANDLER.get() {
handler.on_remote_volume_change(&command_str, volume as i32);
}
},
);
}
/// JNI callback from Kotlin when codec detection completes.
@@ -1170,40 +1231,45 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
audio_codecs: JString,
max_audio_channels: jint,
) {
let video_str: String = env
.get_string(&video_codecs)
.map(|s| s.into())
.unwrap_or_default();
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Companion_nativeOnCodecsDetected",
|| {
let video_str: String = env
.get_string(&video_codecs)
.map(|s| s.into())
.unwrap_or_default();
let audio_str: String = env
.get_string(&audio_codecs)
.map(|s| s.into())
.unwrap_or_default();
let audio_str: String = env
.get_string(&audio_codecs)
.map(|s| s.into())
.unwrap_or_default();
// Kotlin sends 0 when AudioCapabilities had no answer for the current route.
let channels = u32::try_from(max_audio_channels).ok().filter(|c| *c > 0);
// Kotlin sends 0 when AudioCapabilities had no answer for the current route.
let channels = u32::try_from(max_audio_channels).ok().filter(|c| *c > 0);
let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str, channels);
let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str, channels);
log::info!(
"[CodecDetection] Detected {} video codecs: {}",
codecs.video_codecs.len(),
codecs.video_codecs_string()
log::info!(
"[CodecDetection] Detected {} video codecs: {}",
codecs.video_codecs.len(),
codecs.video_codecs_string()
);
log::info!(
"[CodecDetection] Detected {} audio codecs: {}",
codecs.audio_codecs.len(),
codecs.audio_codecs_string()
);
log::info!(
"[CodecDetection] Audio route max channels: {:?}",
codecs.max_audio_channels
);
// Store in global state
if DETECTED_CODECS.set(codecs).is_err() {
log::error!("[CodecDetection] Failed to store codecs - already initialized");
}
},
);
log::info!(
"[CodecDetection] Detected {} audio codecs: {}",
codecs.audio_codecs.len(),
codecs.audio_codecs_string()
);
log::info!(
"[CodecDetection] Audio route max channels: {:?}",
codecs.max_audio_channels
);
// Store in global state
if DETECTED_CODECS.set(codecs).is_err() {
log::error!("[CodecDetection] Failed to store codecs - already initialized");
}
}
/// Start the JellyTauPlaybackService if not already running.
+6 -8
View File
@@ -98,10 +98,9 @@ pub trait PlayerBackend: Send + Sync {
/// Set the active audio track by stream index
///
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
/// does **not** override it — MPV is the audio-only backend here, so it keeps
/// this `not_implemented()` default and the Linux video path switches track by
/// re-opening the stream instead (`player_switch_audio_track`).
/// Overridden by both video backends, ExoPlayer and `MpvBackend`; the
/// argument is a position among the file's audio tracks. A transcode is
/// re-opened instead (`player_switch_audio_track`).
///
/// TRACES: UR-021 | IR-019, DR-024
fn set_audio_track(&mut self, _stream_index: i32) -> Result<(), PlayerError> {
@@ -111,10 +110,9 @@ pub trait PlayerBackend: Send + Sync {
/// Set the active subtitle track by stream index (None to disable subtitles)
///
/// Overridden by the Android (ExoPlayer) backend. `MpvBackend` deliberately
/// does **not** override it, so it keeps this `not_implemented()` default;
/// the Linux video path renders subtitles as `<track>` children of the
/// WebKitGTK HTML5 `<video>` element and never calls this.
/// Overridden by both video backends, ExoPlayer and `MpvBackend`; the
/// argument is a position in the sideloaded subtitle list the play request
/// carried.
///
/// TRACES: UR-020 | IR-018, DR-023
fn set_subtitle_track(&mut self, _stream_index: Option<i32>) -> Result<(), PlayerError> {
+9 -10
View File
@@ -85,9 +85,8 @@ pub enum PlayerStatusEvent {
remaining_seconds: u32,
},
/// Time-based sleep timer expired: playback must stop. The backend stops
/// its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the
/// webview outside the backend's control — the frontend pauses it on this
/// event.
/// its own (MPV/ExoPlayer) playback; the frontend pauses the active adapter
/// on this event, which reaches a webview `<audio>` element where one plays.
SleepTimerExpired,
/// Show next episode popup with countdown
ShowNextEpisodePopup {
@@ -152,9 +151,9 @@ pub enum PlayerStatusEvent {
/// media item locally), so the native side only signals intent here.
RemoteDisconnectRequested,
/// Backend-originated control command targeting the active frontend player
/// adapter (the HTML5 <video> that lives in the webview, which Rust cannot
/// drive directly). Emitted by control paths like the sleep timer, lockscreen,
/// or remote so they can pause/play/seek/stop the webview element.
/// adapter — the webview `<audio>` element, which Rust cannot drive
/// directly. Emitted by control paths like the sleep timer, lockscreen, or
/// remote so they can pause/play/seek/stop it.
/// `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
ControlCommand {
/// One of: "play", "pause", "stop", "seek".
@@ -164,10 +163,10 @@ pub enum PlayerStatusEvent {
},
/// Ask the frontend webview `<audio>` element to load and play a stream.
///
/// Emitted by `WebviewAudioBackend` on platforms with no native audio
/// backend (e.g. Windows): audio-only playback is rendered by an `<audio>`
/// element in the webview, mirroring how all video already renders through
/// the webview `<video>`. The element then reports its state/position back
/// Emitted by `WebviewAudioBackend` on a desktop with no native audio
/// backend (none that ships: Linux and Windows have mpv): audio-only
/// playback is rendered by an `<audio>` element in the webview. The element
/// then reports its state/position back
/// through the `player_report_*` commands, so the Rust controller stays the
/// single source of truth. Subsequent play/pause/seek/stop reach the element
/// via `ControlCommand`.
+4
View File
@@ -0,0 +1,4 @@
WEBVTT
00:00.000 --> 00:02.000
fixture subtitle
Binary file not shown.
+122
View File
@@ -0,0 +1,122 @@
//! Panic containment for the Android JNI boundary.
//!
//! Compiled on every platform, unlike `player::android` itself, so the guard and
//! the tripwire that enforces its use are unit-tested on the host — the same
//! reason `RESUME_BACKOFF_STEP_SECS` lives outside the `cfg(android)` block.
/// Run the body of a JNI callback with any panic contained.
///
/// Every `extern "system"` function in this file is called by the JVM on an
/// arbitrary thread. A panic that unwinds out of one crosses the FFI boundary,
/// which Rust answers by **aborting the process** — the app vanishes with no
/// Java exception, no stack trace attributable to it, and no crash report the
/// user can send. That is the worst possible failure mode for the callbacks
/// that fire four times a second during playback.
///
/// The panics are real, not theoretical: this file builds a fallback Tokio
/// runtime on threads that have none, and `Runtime::new()` fails under the fd
/// exhaustion and thread-spawn refusal an Android device puts a media app
/// through. Losing one position report is recoverable; losing the process is
/// not.
///
/// A contained panic still leaves whatever it interrupted half-done, so this is
/// a backstop, not a licence to panic. `utils::lock` already keeps a poisoned
/// mutex from cascading; this keeps the FFI boundary from turning any remaining
/// panic into a process kill.
///
/// TRACES: UR-005 | DR-052
///
/// Only *called* from `player::android`, which is `cfg(target_os = "android")`,
/// so it is dead code on every other target — the same reason
/// `RESUME_BACKOFF_STEP_SECS` carries this attribute. It is still compiled and
/// tested here on purpose.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub(crate) fn jni_guard<F: FnOnce()>(name: &str, body: F) {
// AssertUnwindSafe: the shared state behind these callbacks is already
// reached through poison-tolerant locks, so a panic cannot hand out a
// guard observing a torn value.
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)).is_err() {
// The panic hook has already logged the payload and location.
log::error!("[JNI] Panic in {name} was contained; the callback was dropped");
}
}
// TRACES: UR-005 | DR-052 | UT-052
#[cfg(test)]
mod jni_guard_tests {
use super::*;
/// The guard must swallow a panic rather than let it reach the JVM.
///
/// TRACES: UR-005 | DR-052 | UT-052
#[test]
fn a_panicking_callback_body_does_not_escape_the_guard() {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
jni_guard("test_callback", || panic!("ExoPlayer callback blew up"));
std::panic::set_hook(hook);
// Reaching here at all is the assertion: without the guard the panic
// would unwind out of the `extern "system"` fn and abort the process.
}
/// The guard must not disturb a callback that behaves.
///
/// TRACES: UR-005 | DR-052 | UT-052
#[test]
fn a_normal_callback_body_still_runs() {
let mut ran = false;
jni_guard("test_callback", || ran = true);
assert!(ran);
}
/// **Tripwire.** Every JNI entry point must wrap its body in `jni_guard`.
///
/// A panic crossing the `extern "system"` boundary aborts the process, so a
/// twelfth callback added without the guard reintroduces the whole defect.
/// Checked against the source because the real boundary needs a JVM to
/// exercise — the same tripwire idiom as `check:boundary`.
///
/// TRACES: UR-005 | DR-052 | UT-052
#[test]
fn every_jni_entry_point_wraps_its_body_in_the_guard() {
let src = include_str!("android/mod.rs");
let mut unguarded = Vec::new();
let mut lines = src.lines().enumerate().peekable();
while let Some((_, line)) = lines.next() {
if !line.starts_with("pub extern \"system\" fn ") {
continue;
}
let name = line
.trim_start_matches("pub extern \"system\" fn ")
.trim_end_matches('(')
.to_string();
// Walk to the end of the parameter list, then look at the first
// statement of the body.
let mut body_start = None;
for (n, l) in lines.by_ref() {
if l.trim_end().ends_with(") {") || l.trim() == ") {" {
body_start = Some(n);
break;
}
}
assert!(body_start.is_some(), "could not find the body of {name}");
match lines.peek() {
Some((_, first)) if first.trim_start().starts_with("jni_guard(") => {}
other => unguarded.push(format!(
"{name} (body starts with {:?})",
other.map(|(_, l)| l.trim()).unwrap_or("<eof>")
)),
}
}
assert!(
unguarded.is_empty(),
"JNI entry points whose body is not wrapped in jni_guard — a panic in \
one of these aborts the process:\n {}",
unguarded.join("\n ")
);
}
}
+268
View File
@@ -0,0 +1,268 @@
//! A declared lock hierarchy for [`PlayerController`], and a tripwire that
//! enforces it.
//!
//! The controller carries seventeen separate mutexes, reached from the MPV event
//! loop, JNI callbacks, sleep/autoplay timers, the session poller and every IPC
//! command. Nothing about that arrangement prevents two threads taking the same
//! two locks in opposite orders, which deadlocks the player outright — and this
//! subsystem has already produced one deadlock (a tokio `MutexGuard` held in a
//! `match` scrutinee, which stalled the `AdvanceToNext` arm).
//!
//! Today the code is disciplined: acquisitions are scoped, and `previous()` for
//! instance explicitly drops the backend guard before touching the queue. But
//! that holds by convention, and convention is not checked. [`LOCK_ORDER`]
//! writes the convention down and `every_overlapping_acquisition_respects_the_order`
//! fails the build when a change breaks it.
//!
//! Ordering only matters where one guard is **still held** while another lock is
//! taken. Acquiring two locks one after another, each released before the next,
//! cannot deadlock — so the analysis looks for overlap, not for mere sequence.
//!
//! TRACES: UR-005 | DR-052
// This module is a static analysis of `player/mod.rs` plus the hierarchy it
// checks against. Its only caller is its own test module, but `LOCK_ORDER` is
// the documentation of record for how these locks nest, so it stays compiled
// (and rustdoc'd) rather than hidden behind `cfg(test)`.
#![allow(dead_code)]
/// The order in which `PlayerController`'s locks may be nested.
///
/// A thread already holding one of these may only acquire a lock that appears
/// **later** in this list. The order is not arbitrary — it follows the nesting
/// the code already relies on:
///
/// - `repository` and `sleep_timer` are taken by long-running decisions that go
/// on to consult playback state, so they sit outermost.
/// - `backend` outranks `queue`: "what is playing" is read before "what is
/// next", never the reverse.
/// - `event_emitter` is last. Emitting is a leaf — notifying the frontend must
/// never reach back for more player state.
///
/// TRACES: UR-005 | DR-052
pub const LOCK_ORDER: &[&str] = &[
"repository",
"sleep_timer",
"countdown_cancel",
"jellyfin_client",
"backend",
"queue",
"stream_resume",
"end_reason",
"reported_time",
"background_audio_active",
"background_audio_base",
"html5_playing",
"autoplay_settings",
"autoplay_episode_count",
"reports",
"event_emitter",
];
/// Rank of `field` in [`LOCK_ORDER`], or `None` if it is not a declared lock.
pub fn rank(field: &str) -> Option<usize> {
LOCK_ORDER.iter().position(|f| *f == field)
}
/// One lock acquired while another is still held.
#[derive(Debug, PartialEq, Eq)]
pub struct Overlap {
/// The lock already held.
pub outer: String,
/// The lock acquired underneath it.
pub inner: String,
/// 1-indexed line of the inner acquisition, for a useful failure message.
pub line: usize,
}
/// Find every place `src` takes a lock while holding another.
///
/// Deliberately simple and line-based: it tracks `let … = self.FIELD.lock_safe()`
/// bindings and looks for a different `self.OTHER.lock_safe()` before the
/// binding goes out of scope or is explicitly dropped. A guard that is not bound
/// to a name (`*self.flag.lock_safe() = false;`) is released at the end of its
/// statement and cannot overlap anything, so it is only ever an *inner*
/// acquisition here.
///
/// TRACES: UR-005 | DR-052 | UT-052
pub fn overlapping_acquisitions(src: &str) -> Vec<Overlap> {
let lines: Vec<&str> = src.lines().collect();
let mut found = Vec::new();
for (i, line) in lines.iter().enumerate() {
let Some((var, field)) = parse_binding(line) else {
continue;
};
let indent = line.len() - line.trim_start().len();
for (j, later) in lines.iter().enumerate().skip(i + 1) {
if later.contains(&format!("drop({var})")) {
break;
}
let trimmed = later.trim();
if trimmed.is_empty() {
continue;
}
// Left the block the guard lives in.
let later_indent = later.len() - later.trim_start().len();
if later_indent < indent && !trimmed.starts_with(['.', ')', '}']) {
break;
}
if trimmed == "}" && later_indent < indent {
break;
}
if let Some(inner) = parse_acquisition(later, field) {
found.push(Overlap {
outer: field.to_string(),
inner,
line: j + 1,
});
break;
}
}
}
found
}
/// `let [mut] name = self.field.lock_safe()` → `(name, field)`.
fn parse_binding(line: &str) -> Option<(&str, &str)> {
let rest = line.trim_start().strip_prefix("let ")?;
let rest = rest.strip_prefix("mut ").unwrap_or(rest);
let (name, rest) = rest.split_once(" = self.")?;
let (field, _) = rest.split_once(".lock_safe()")?;
if name.contains(' ') || field.contains('.') {
return None;
}
Some((name, field))
}
/// The first `self.other.lock_safe()` on `line` that is not `held`.
fn parse_acquisition(line: &str, held: &str) -> Option<String> {
let mut search = line;
while let Some(at) = search.find("self.") {
let after = &search[at + 5..];
if let Some((field, _)) = after.split_once(".lock_safe()") {
if !field.contains(['.', '(', ' ']) && field != held {
return Some(field.to_string());
}
}
search = after;
}
None
}
// TRACES: UR-005 | DR-052 | UT-052
#[cfg(test)]
mod tests {
use super::*;
/// **The tripwire.** Every nested acquisition in the controller must follow
/// [`LOCK_ORDER`].
///
/// A violation is a lock-order inversion: two threads taking the same pair
/// in opposite orders deadlock the player, and the symptom is a frozen app
/// with no error anywhere.
///
/// TRACES: UR-005 | DR-052 | UT-052
#[test]
fn every_overlapping_acquisition_respects_the_order() {
let src = include_str!("mod.rs");
let mut violations = Vec::new();
for overlap in overlapping_acquisitions(src) {
let (Some(outer), Some(inner)) = (rank(&overlap.outer), rank(&overlap.inner)) else {
violations.push(format!(
"player/mod.rs:{} takes '{}' while holding '{}', and one of them \
is not declared in LOCK_ORDER",
overlap.line, overlap.inner, overlap.outer
));
continue;
};
if outer >= inner {
violations.push(format!(
"player/mod.rs:{} takes '{}' (rank {inner}) while holding '{}' \
(rank {outer}) — an inversion against LOCK_ORDER",
overlap.line, overlap.inner, overlap.outer
));
}
}
assert!(
violations.is_empty(),
"lock-order inversions in PlayerController:\n {}\n\nEither reorder the \
acquisitions or, if the new order is the correct one, change LOCK_ORDER \
and re-check every other site.",
violations.join("\n ")
);
}
/// The analysis must actually see the nesting the controller does today,
/// or the tripwire above passes by finding nothing.
///
/// TRACES: UR-005 | DR-052 | UT-052
#[test]
fn the_analysis_finds_the_nesting_that_exists() {
let found = overlapping_acquisitions(include_str!("mod.rs"));
assert!(
found.len() >= 5,
"expected the controller's known nested acquisitions, found {found:?}"
);
assert!(
found
.iter()
.any(|o| o.outer == "backend" && o.inner == "queue"),
"the backend->queue nesting in state() should be detected: {found:?}"
);
}
/// A guard held across a lock taken in the wrong order must be caught.
///
/// TRACES: UR-005 | DR-052 | UT-052
#[test]
fn an_inversion_is_detected() {
let src = " fn bad(&self) {\n\
\x20 let queue = self.queue.lock_safe();\n\
\x20 let b = self.backend.lock_safe();\n\
\x20 }\n";
let found = overlapping_acquisitions(src);
assert_eq!(found.len(), 1, "{found:?}");
assert_eq!(found[0].outer, "queue");
assert_eq!(found[0].inner, "backend");
assert!(rank("queue").unwrap() > rank("backend").unwrap());
}
/// Sequential, non-overlapping acquisitions cannot deadlock and must not be
/// reported — `previous()` drops the backend guard before taking the queue.
///
/// TRACES: UR-005 | DR-052 | UT-052
#[test]
fn a_dropped_guard_is_not_an_overlap() {
let src = " fn fine(&self) {\n\
\x20 let backend = self.backend.lock_safe();\n\
\x20 drop(backend);\n\
\x20 let queue = self.queue.lock_safe();\n\
\x20 }\n";
assert!(overlapping_acquisitions(src).is_empty());
}
/// Every declared lock name must be a real field, or the order documents
/// something that no longer exists.
///
/// TRACES: UR-005 | DR-052 | UT-052
#[test]
fn every_declared_lock_is_a_real_field() {
let src = include_str!("mod.rs");
let decl = src
.split_once("pub struct PlayerController {")
.expect("PlayerController struct")
.1;
let decl = decl.split_once("\n}").expect("end of struct").0;
for name in LOCK_ORDER {
assert!(
decl.contains(&format!("{name}:")),
"LOCK_ORDER names '{name}', which is not a PlayerController field"
);
}
}
}
+5 -18
View File
@@ -140,7 +140,7 @@ pub struct Capabilities {
pub audio_track_switching: bool,
/// A *server-side transcode* can be seeked without re-opening the stream.
///
/// True for hls.js, which seeks within the VOD playlist it is handed and
/// True for ExoPlayer, which seeks within the VOD playlist it is handed and
/// lets the server catch up. False for mpv, whose HLS demuxer cannot make
/// the server transcode from a new offset.
///
@@ -173,9 +173,8 @@ impl Capabilities {
/// ExoPlayer.
///
/// **Can** seek a transcode in place. It is a full HLS client, so like
/// hls.js it seeks within the VOD playlist it was handed and lets the
/// server catch up. Grouping it with mpv as "a native engine" gets this
/// **Can** seek a transcode in place. It is a full HLS client, so it seeks
/// within the VOD playlist it was handed and lets the server catch up. Grouping it with mpv as "a native engine" gets this
/// exactly backwards — being native is not the property that matters here,
/// speaking HLS is, and that is the whole reason this is declared per
/// engine rather than inferred from a category.
@@ -188,18 +187,6 @@ impl Capabilities {
seeks_transcoded_in_place: true,
}
}
/// An engine that renders through the webview element, where hls.js seeks
/// within the playlist it was handed.
pub fn webview() -> Self {
Self {
video: true,
audio_settings: false,
subtitle_switching: true,
audio_track_switching: false,
seeks_transcoded_in_place: true,
}
}
}
/// A request to present an item.
@@ -242,7 +229,7 @@ impl OpenRequest {
/// Anything that can present media.
///
/// Implementations: `MpvPlayer` (Linux/Windows), `ExoPlayerPlayer` (Android),
/// `WebviewPlayer` (HTML5 element), and `FakePlayer` for tests. Every one of
/// and `FakePlayer` for tests. Every one of
/// them must pass [`super::conformance`].
pub trait MediaPlayer: Send {
/// Present `req.selection`, beginning at `req.start`.
@@ -267,7 +254,7 @@ pub trait MediaPlayer: Send {
/// Seek to an absolute position on the item's own timeline.
///
/// Whether that is an in-place seek or a re-open of the stream is the
/// engine's business: hls.js seeks within a VOD playlist, mpv's HLS demuxer
/// engine's business: ExoPlayer seeks within a VOD playlist, mpv's HLS demuxer
/// cannot make a server transcode from a new offset. Callers state the
/// destination and nothing else.
fn seek(&mut self, to: Duration) -> Result<(), PlayerError>;
+107 -46
View File
@@ -15,8 +15,12 @@ mod fake_player_conformance;
pub mod legacy_player;
pub mod media;
pub mod media_player;
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub mod mpv_command;
#[cfg(target_os = "linux")]
pub mod mpv_player;
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub mod mpv_tracks;
pub mod queue;
pub mod seek;
pub mod session;
@@ -28,11 +32,19 @@ pub mod track_switch;
#[cfg(test)]
mod mpv_backend_test;
// The declared lock hierarchy for `PlayerController` below, and the tripwire
// that enforces it. See the module docs for why seventeen mutexes need one.
pub mod lock_order;
// Panic containment for the JNI boundary. Not gated on the target: the guard
// and its tripwire test are exercised on the host, where `android` never builds.
pub mod jni_guard;
// Platform-specific backends
#[cfg(target_os = "android")]
pub mod android;
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub mod mpv_backend;
/// Whether this process renders video natively — one answer, three consumers
@@ -54,9 +66,10 @@ pub mod mpv_render;
#[cfg(target_os = "linux")]
pub mod video_surface;
// Platforms with no native audio backend (e.g. Windows) render audio-only
// playback through a webview <audio> element, mirroring how all video renders.
#[cfg(not(any(target_os = "linux", target_os = "android")))]
// Platforms with no native audio backend render audio-only playback through a
// webview <audio> element. None that ships: Windows moved to mpv (DR-237); this
// remains for an unported desktop (macOS).
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "windows")))]
pub mod webview_audio_backend;
// Re-export commonly used types
@@ -78,10 +91,10 @@ pub use track_switch::{determine_audio_track_switch_strategy, AudioTrackSwitchSt
#[cfg(target_os = "android")]
pub use android::ExoPlayerBackend;
#[cfg(target_os = "linux")]
#[cfg(any(target_os = "linux", target_os = "windows"))]
pub use mpv_backend::MpvBackend;
#[cfg(not(any(target_os = "linux", target_os = "android")))]
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "windows")))]
pub use webview_audio_backend::WebviewAudioBackend;
#[cfg(target_os = "android")]
@@ -169,6 +182,20 @@ fn completion_report_position(runtime: Option<f64>, last_position: f64) -> f64 {
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
const RESUME_BACKOFF_STEP_SECS: u64 = 2;
/// Where playback stands when a background-audio handoff returns to the
/// foreground. See [`PlayerController::background_audio_resume`].
///
/// TRACES: UR-040, UR-023 | DR-296
#[derive(specta::Type, Debug, Clone, PartialEq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BackgroundAudioResume {
/// Item the native audio player is on — `None` if the queue emptied (e.g.
/// the sleep timer stopped playback while backgrounded).
pub item_id: Option<String>,
/// Absolute position in that item, in seconds.
pub position_seconds: f64,
}
/// Metadata for the lockscreen / media notification.
///
/// Used to drive the Android MediaSession from Rust in remote (cast) mode, where
@@ -297,9 +324,10 @@ pub struct PlayerController {
background_audio_base: Arc<Mutex<f64>>,
// True while a background-audio handoff owns playback: the native audio
// player is the real player and the webview <video> has been torn down.
// player is the real player and the video has been replaced.
//
// The teardown is what makes this necessary. It fires a DOM `pause` that the
// The teardown is what made this necessary (when a webview <video> was
// torn down). It fires a DOM `pause` that the
// frontend reports like any other, which would otherwise leave the controller
// believing webview media is still active — aiming lockscreen transport at an
// element that no longer exists (see `is_html5_active`).
@@ -316,7 +344,8 @@ pub struct PlayerController {
// TRACES: UR-040 | DR-129
stream_resume: Arc<Mutex<stream_end::ResumeTracker>>,
// Last state reported by a webview-rendered HTML5 <video>/<audio> element.
// Last state reported by a webview-rendered `<audio>` element (the webview
// audio backend; video never renders in the webview since DR-235).
//
// Webview-rendered media is played by an element the native backend cannot
// reach, so the backend's own state() says nothing about it. Tracking the
@@ -396,7 +425,7 @@ impl PlayerController {
/// Configure the media repository used for next-episode lookups.
///
/// The Android ExoPlayer ended-callback calls `on_playback_ended` with no
/// repository handle (unlike the Linux HTML5 path, which passes one per
/// repository handle (unlike a frontend-reported end, which passes one per
/// call), so the controller needs a repository of its own or episode
/// autoplay silently decides Stop.
pub fn set_repository(&self, repo: Arc<dyn MediaRepository>) {
@@ -516,37 +545,6 @@ impl PlayerController {
Ok(())
}
/// Set the current queue item without loading it into the playback backend.
///
/// Used on platforms where video is rendered outside the native backend
/// (Linux WebKitGTK HTML5 <video>): the queue/UI state must reflect the
/// item, but MPV must not start a redundant decode for it.
///
/// Not gated to Linux. Its caller stopped being a `#[cfg]` branch and became
/// a runtime question — "does this renderer draw the picture?" — so the
/// `else` arm is compiled on every platform even where it never runs. The
/// gate outliving its caller broke the Android build outright, which went
/// unnoticed because nothing built for Android afterwards.
pub fn set_current_item(&self, item: MediaItem) -> Result<(), PlayerError> {
debug!(
"[PlayerController] set_current_item (no backend load): {}",
item.title
);
self.reset_autoplay_count();
// A different item is current; the last one's reported position must not
// be reported against it. This path is how webview-rendered video is
// queued (no backend load at all), so it is exactly where a stale
// reading would otherwise survive.
// TRACES: UR-005 | DR-178
self.clear_reported_time();
let mut queue = self.queue.lock_safe();
queue.set_queue(vec![item], 0);
Ok(())
}
/// Load and play an item without modifying the queue
/// Use this when the queue is already set up and you just want to play a specific item from it
pub fn load_and_play(&self, item: &MediaItem) -> Result<(), PlayerError> {
@@ -571,7 +569,7 @@ impl PlayerController {
// intermittent.
//
// The webview re-establishes its own authority the moment an element
// reports again, so nothing is lost on the HTML5 path: this is the same
// reports again, so nothing is lost on the webview path: this is the same
// "element is gone" semantics as the "stopped"/"idle" report, applied at
// the point where we can know it directly.
//
@@ -695,7 +693,7 @@ impl PlayerController {
Ok(())
}
/// True while webview-rendered media (HTML5 `<video>`/`<audio>`) is the real
/// True while webview-rendered media (a webview `<audio>`) is the real
/// player, so transport must be routed to it rather than the native backend.
///
/// TRACES: UR-005 | DR-097
@@ -747,7 +745,7 @@ impl PlayerController {
/// Toggle play/pause.
///
/// The decision is made HERE, from authoritative state — the reported webview
/// state for HTML5-rendered media, or the native backend's state otherwise.
/// state for webview-rendered audio, or the native backend's state otherwise.
/// The frontend must never decide this from the DOM (see DR-097).
///
/// TRACES: UR-005 | DR-097
@@ -1024,7 +1022,7 @@ impl PlayerController {
/// truncation comparison. `position()` alone answers for exactly one of the
/// three ways this app plays media, and reads 0 for the other two:
///
/// - **Webview `<video>`/`<audio>`**: nothing is loaded into the native
/// - **Webview `<audio>`**: nothing is loaded into the native
/// backend, so its position is a permanent 0. The element's own reports are
/// the only reading there is.
/// - **Background-audio handoff**: the audio-only stream's zero is the
@@ -1720,6 +1718,23 @@ impl PlayerController {
/// reports are honoured from here on.
///
/// TRACES: UR-040, UR-005 | DR-052, DR-097
/// Where the foreground should pick up from a background-audio handoff: the
/// item the native player is on now, and its absolute position.
///
/// The item is not necessarily the one the handoff started from — an episode
/// that ends while backgrounded advances in the backend
/// (`advance_to_next_episode_audio_only`) — so the webview must not assume
/// it can reload the video it was mounted with. Read-only: call it before
/// `exit_background_audio` clears the base the position depends on.
///
/// TRACES: UR-040, UR-023 | DR-296 | UT-266
pub fn background_audio_resume(&self) -> BackgroundAudioResume {
BackgroundAudioResume {
item_id: self.queue.lock_safe().current().map(|item| item.id.clone()),
position_seconds: self.absolute_position(),
}
}
pub fn exit_background_audio(&self) -> f64 {
*self.background_audio_active.lock_safe() = false;
self.take_background_audio_base()
@@ -4403,6 +4418,52 @@ mod tests {
);
}
/// Returning to the foreground after the backend advanced to the next episode
/// must bring back THAT episode, not the one the handoff started from.
///
/// The return used to carry only a position; the webview reloaded the video it
/// was mounted with, so the user came back to the previous episode — at the new
/// episode's timestamp. The resume point therefore names the item the native
/// player is actually on.
///
/// TRACES: UR-040 | DR-296 | UT-266
#[tokio::test]
async fn test_background_audio_resume_names_the_advanced_episode() {
let controller = PlayerController::default();
controller.set_repository(Arc::new(MockEpisodeRepo::season(3)));
let episode = MediaItem {
transport: None,
id: "ep1".to_string(),
item_type: Some("Episode".to_string()),
media_type: MediaType::Audio,
series_id: Some("series1".to_string()),
..create_test_items(1).remove(0)
};
controller.play_queue(vec![episode], 0).unwrap();
controller.enter_background_audio(1200.0);
let before = controller.background_audio_resume();
assert_eq!(before.item_id.as_deref(), Some("ep1"));
assert_eq!(before.position_seconds, 1200.0);
controller
.advance_to_next_episode_audio_only("ep2")
.await
.expect("advance should succeed");
let after = controller.background_audio_resume();
assert_eq!(
after.item_id.as_deref(),
Some("ep2"),
"the foreground must resume the episode the backend advanced to"
);
assert!(
after.position_seconds < 1200.0,
"the previous episode's handoff base must not leak into the new one"
);
}
/// A background audio-only episode must advance IN THE BACKEND when the
/// autoplay decision comes back as ShowNextEpisodePopup — never by starting a
/// countdown the frontend is supposed to act on.
@@ -4467,7 +4528,7 @@ mod tests {
duration: Some(runtime_seconds),
source: MediaSource::Remote {
stream_url:
"http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=0"
"http://s/Audio/ep2/universal?ApiKey=k&AudioStreamIndex=2&StartTimeTicks=0"
.to_string(),
jellyfin_item_id: "ep2".to_string(),
},
+259 -28
View File
@@ -9,7 +9,6 @@ use crate::utils::conversions::{seconds_to_ticks, volume_to_percent};
use crate::utils::lock::MutexSafe;
use libmpv::Mpv;
use log::{debug, error, info, warn};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
@@ -54,8 +53,21 @@ struct InternalState {
volume: f32,
}
/// Detect which audio system is available on the system
/// The audio output mpv should use on Windows: WASAPI, the only one it ships
/// there. Nothing to probe — and spawning `pactl` from a GUI app on Windows
/// would at best fail and at worst flash a console window.
///
/// TRACES: UR-004 | DR-237
#[cfg(target_os = "windows")]
fn detect_audio_system() -> String {
"wasapi".to_string()
}
/// Detect which audio system is available on the system
#[cfg(not(target_os = "windows"))]
fn detect_audio_system() -> String {
use std::process::Command;
info!("[MpvBackend] Detecting audio system...");
// Try PulseAudio/PipeWire first (most common on modern Linux)
@@ -95,6 +107,13 @@ fn detect_audio_system() -> String {
fn get_stream_url(media: &MediaItem) -> String {
match &media.source {
MediaSource::Remote { stream_url, .. } => stream_url.clone(),
// A Windows path is not a URL (`file://C:\...` is malformed); mpv
// takes the native path as it is. Safe to pass verbatim because it goes
// to mpv as one argv element (DR-298), not through a command string.
// TRACES: UR-004, UR-071 | DR-237
MediaSource::Local { file_path, .. } if cfg!(target_os = "windows") => {
file_path.to_string_lossy().into_owned()
}
MediaSource::Local { file_path, .. } => {
format!("file://{}", file_path.to_string_lossy())
}
@@ -121,6 +140,8 @@ static MPV_HANDLE: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
/// can fail, and the app falls back to a no-op backend rather than dying).
///
/// TRACES: UR-080 | DR-231
// Only the Linux video surface reads it until Windows gets one (DR-237).
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub fn registered_handle() -> *mut libmpv_sys::mpv_handle {
MPV_HANDLE
.get()
@@ -128,12 +149,73 @@ pub fn registered_handle() -> *mut libmpv_sys::mpv_handle {
.unwrap_or(std::ptr::null_mut())
}
/// How mpv shows video on this platform.
///
/// TRACES: UR-080 | DR-231, DR-237
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum VideoOutput {
/// No picture: audio-only playback, or nowhere to draw.
Off,
/// Linux: frames through the render API into the GTK surface beneath the
/// webview (`video_surface`).
RenderApi,
/// Windows: mpv renders as a child of the app's own window (`wid`, set
/// before initialisation), beneath the transparent WebView2 — the
/// arrangement tauri-plugin-libmpv ships on Windows.
Window(i64),
}
impl VideoOutput {
/// Runtime options for this output. `wid` is not among them: it only takes
/// effect before initialisation, so the constructor sets it separately.
pub(crate) fn options(&self) -> Vec<(&'static str, String)> {
match self {
VideoOutput::Off => vec![("video", "no".to_string())],
VideoOutput::RenderApi => vec![("vo", "libmpv".to_string())],
VideoOutput::Window(_) => [
// libplacebo's renderer, with the classic one as fallback for a
// build or GPU that lacks it.
("vo", "gpu-next,gpu"),
// mpv is a surface here, not a player: the app's controls are
// drawn over it, so its own controller and bindings must not
// answer clicks, keys or the cursor.
("osc", "no"),
("input-default-bindings", "no"),
("input-vo-keyboard", "no"),
("input-cursor", "no"),
("cursor-autohide", "no"),
]
.into_iter()
.map(|(k, v)| (k, v.to_string()))
.collect(),
}
}
}
/// Decide the video output from whether native video is on, the platform, and
/// the app window's handle (Windows only).
///
/// TRACES: UR-080 | DR-231, DR-237 | UT-274
pub(crate) fn video_output(native: bool, is_windows: bool, window: Option<i64>) -> VideoOutput {
match (native, is_windows, window) {
(false, _, _) => VideoOutput::Off,
(true, true, Some(wid)) => VideoOutput::Window(wid),
// No handle: mpv would open a top-level window of its own.
(true, true, None) => VideoOutput::Off,
(true, false, _) => VideoOutput::RenderApi,
}
}
impl MpvBackend {
/// Create a new MPV backend
///
/// `video_window` is the app window's native handle (an HWND), which mpv
/// draws video into on Windows; `None` elsewhere.
pub fn new(
event_emitter: Option<Arc<dyn PlayerEventEmitter>>,
playback_reporter: Arc<TokioMutex<Option<PlaybackReporter>>>,
position_throttler: Arc<EventThrottler>,
video_window: Option<i64>,
) -> Result<Self, PlayerError> {
info!("[MpvBackend] Initializing MPV backend...");
@@ -145,9 +227,26 @@ impl MpvBackend {
libc::setlocale(libc::LC_NUMERIC, c_locale.as_ptr());
}
let mpv = Mpv::new().map_err(|e| PlayerError {
let output = video_output(
super::native_video::enabled(),
cfg!(target_os = "windows"),
video_window,
);
if super::native_video::enabled() && output == VideoOutput::Off {
error!("[MpvBackend] no window handle to draw video into; video will have no picture");
}
// `wid` only takes effect before initialisation. TRACES: UR-080 | DR-237
let mpv = Mpv::with_initializer(|init| {
if let VideoOutput::Window(wid) = output {
init.set_property("wid", wid)?;
}
Ok(())
})
.map_err(|e| PlayerError {
message: format!("Failed to initialize MPV: {:?}", e),
})?;
// TRACES: UR-012 | DR-299
super::mpv_command::harden(&mpv).map_err(|message| PlayerError { message })?;
// Detect and configure audio output
let audio_driver = detect_audio_system();
@@ -178,26 +277,23 @@ impl MpvBackend {
// Video is disabled unless this process is drawing it.
//
// `video: no` is why mpv has never decoded a frame here: Linux video has
// always gone through the webview, and decoding it twice would burn a
// core for a picture nobody sees. With native video on, mpv needs both
// the decoder *and* `vo=libmpv` — the render API only works through that
// output, and the default would try to open a window of its own.
// Linux video went through the webview until DR-235, and decoding it
// here too would have burned a core for a picture nobody saw — hence
// `video: no`. With native video, mpv needs the decoder *and* an output
// that draws where the app wants it: the render API on Linux (the default
// would open a window of its own), the app's window on Windows.
//
// Set at construction because mpv resolves the video output when it
// initialises; flipping it later does not re-open one.
//
// TRACES: UR-080 | DR-231, DR-235
if super::native_video::enabled() {
mpv.set_property("vo", "libmpv").map_err(|e| PlayerError {
message: format!("Failed to select the libmpv video output: {:?}", e),
})?;
info!("[MpvBackend] native video enabled (vo=libmpv)");
} else {
mpv.set_property("video", "no").map_err(|e| PlayerError {
message: format!("Failed to configure MPV video: {:?}", e),
})?;
// TRACES: UR-080 | DR-231, DR-235, DR-237
for (name, value) in output.options() {
mpv.set_property(name, value.as_str())
.map_err(|e| PlayerError {
message: format!("Failed to set {name}={value}: {:?}", e),
})?;
}
info!("[MpvBackend] video output: {:?}", output);
// Set volume to 100% (we'll control via MPV's volume property)
mpv.set_property("volume", 100i64)
@@ -288,8 +384,8 @@ impl MpvBackend {
// StateChanged rather than tracking playback itself, per the
// one-directional state rule. Unobserved, the event never came and
// the button never moved. Invisible until native video shipped,
// because the webview <video> element's own DOM events drove that
// control on Linux.
// because the (since deleted) webview <video> element's own DOM
// events drove that control on Linux.
//
// TRACES: UR-005 | DR-239
ev_ctx
@@ -600,12 +696,23 @@ impl PlayerBackend for MpvBackend {
// TRACES: UR-040, UR-005 | DR-253
*self.pending_seek.lock_safe() = None;
// Load the media file
self.mpv
.command("loadfile", &[&stream_url])
.map_err(|e| PlayerError {
message: format!("Failed to load file: {:?}", e),
})?;
// The item's own sideloaded subtitles, none shown, and its default audio
// track — whatever the previous item had chosen. Only video carries
// subtitles; for audio this just clears the last item's.
// TRACES: UR-020, UR-021 | DR-023, DR-024, DR-235
let subtitle_urls: Vec<&str> = media.subtitles.iter().map(|t| t.url.as_str()).collect();
super::mpv_tracks::prepare_load(&self.mpv, &subtitle_urls).map_err(|e| PlayerError {
message: format!("Failed to prepare tracks: {e}"),
})?;
// Load the media file. Through `mpv_command::command`, never
// `Mpv::command`: the URL carries server-controlled text.
// TRACES: UR-003, UR-004 | DR-298
super::mpv_command::command(&self.mpv, &["loadfile", &stream_url]).map_err(|e| {
PlayerError {
message: format!("Failed to load file: {e}"),
}
})?;
debug!("[MpvBackend] Load command sent successfully");
Ok(())
@@ -638,8 +745,8 @@ impl PlayerBackend for MpvBackend {
fn stop(&mut self) -> Result<(), PlayerError> {
debug!("[MpvBackend] Stop command");
self.mpv.command("stop", &[]).map_err(|e| PlayerError {
message: format!("Failed to stop: {:?}", e),
super::mpv_command::command(&self.mpv, &["stop"]).map_err(|e| PlayerError {
message: format!("Failed to stop: {e}"),
})?;
// Stopping ends the seek's subject along with the playback.
@@ -760,6 +867,36 @@ impl PlayerBackend for MpvBackend {
state.volume
}
/// `stream_index` is a *position*: the n-th audio track of the file, the
/// same meaning ExoPlayer gives it (`player_switch_audio_track` passes the
/// array index). Only reached for a direct play/stream — a transcode carries
/// one track and is re-opened instead.
///
/// TRACES: UR-021 | IR-019, DR-024, DR-235
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
let position = usize::try_from(stream_index).map_err(|_| PlayerError {
message: format!("Invalid audio track position {stream_index}"),
})?;
super::mpv_tracks::select_audio(&self.mpv, position)
.map_err(|message| PlayerError { message })
}
/// `stream_index` is the position in the sideloaded subtitle list the play
/// request carried (`nativeSubtitleArrayIndex`), `None` to hide subtitles.
///
/// TRACES: UR-020 | IR-018, DR-023, DR-235
fn set_subtitle_track(&mut self, stream_index: Option<i32>) -> Result<(), PlayerError> {
let position = stream_index
.map(|i| {
usize::try_from(i).map_err(|_| PlayerError {
message: format!("Invalid subtitle position {i}"),
})
})
.transpose()?;
super::mpv_tracks::select_subtitle(&self.mpv, position)
.map_err(|message| PlayerError { message })
}
fn set_audio_settings(&mut self, settings: &AudioSettings) -> Result<(), PlayerError> {
info!("[MpvBackend] Applying audio settings");
self.audio_settings = settings.clone();
@@ -998,3 +1135,97 @@ mod af_filter_tests {
assert!(eq_pos < norm_pos, "normalizer runs after EQ: {af}");
}
}
#[cfg(test)]
mod video_output_tests {
use super::{video_output, VideoOutput};
/// Windows draws into the app's own window: mpv is handed its HWND before
/// initialising and renders as a child of it, beneath the transparent
/// WebView2.
///
/// TRACES: UR-080 | DR-237 | UT-274
#[test]
fn windows_native_video_renders_into_the_app_window() {
assert_eq!(
video_output(true, true, Some(0x1234)),
VideoOutput::Window(0x1234)
);
}
/// Without a handle mpv would open a top-level window of its own, a second
/// window floating beside the app. No picture is the honest failure.
///
/// TRACES: UR-080 | DR-237 | UT-274
#[test]
fn windows_without_a_window_handle_draws_nothing() {
assert_eq!(video_output(true, true, None), VideoOutput::Off);
}
/// Linux keeps the render API the GTK surface draws from.
///
/// TRACES: UR-080 | DR-231 | UT-274
#[test]
fn linux_native_video_uses_the_render_api() {
assert_eq!(video_output(true, false, None), VideoOutput::RenderApi);
}
/// TRACES: UR-080 | DR-231 | UT-274
#[test]
fn no_native_video_decodes_no_picture() {
assert_eq!(video_output(false, true, Some(1)), VideoOutput::Off);
assert_eq!(video_output(false, false, None), VideoOutput::Off);
}
/// In the app's window mpv must not act as a player of its own: its
/// on-screen controller and key/mouse bindings would compete with the
/// Svelte controls drawn over it.
///
/// TRACES: UR-080 | DR-237 | UT-274
#[test]
fn a_window_output_hands_all_input_to_the_app() {
let opts = VideoOutput::Window(7).options();
for (k, v) in [
("vo", "gpu-next,gpu"),
("osc", "no"),
("input-default-bindings", "no"),
("input-vo-keyboard", "no"),
("input-cursor", "no"),
("cursor-autohide", "no"),
] {
assert!(
opts.iter().any(|(ok, ov)| *ok == k && ov == v),
"missing {k}={v} in {opts:?}"
);
}
assert_eq!(
VideoOutput::Off.options(),
vec![("video", "no".to_string())]
);
}
/// Every option the outputs set is one this libmpv accepts, `wid` included —
/// against the real library, so a misspelt or removed option fails here
/// rather than as a player that will not start on a user's machine. Runs on
/// the Windows DLL too (under wine in the cross-build).
///
/// TRACES: UR-080 | DR-237 | UT-274
#[test]
fn libmpv_accepts_every_video_output_option() {
let mpv = libmpv::Mpv::with_initializer(|init| {
init.set_property("wid", 0i64)?;
Ok(())
})
.expect("libmpv must accept wid before initialisation");
for output in [
VideoOutput::Off,
VideoOutput::RenderApi,
VideoOutput::Window(0),
] {
for (name, value) in output.options() {
mpv.set_property(name, value.as_str())
.unwrap_or_else(|e| panic!("libmpv rejected {name}={value}: {e:?}"));
}
}
}
}
+161
View File
@@ -0,0 +1,161 @@
//! The two things every libmpv handle in this process must get right before it
//! is handed a URL.
//!
//! **Commands are an argument vector.** The pinned `libmpv` crate's
//! `Mpv::command` joins its arguments with spaces and hands the result to
//! `mpv_command_string`, which parses it as input.conf syntax: whitespace splits
//! arguments, `;` separates commands, `#` starts a comment. Every URL this app
//! loads carries server-controlled text — item and media-source ids, the
//! server's own `TranscodingUrl`, and for a download the file name, which is the
//! track title — so a title like `x;run sh -c …;#` ran a shell command the
//! moment it played. [`command`] goes through `mpv_command` instead, where each
//! argument reaches mpv as one opaque string and nothing is parsed.
//!
//! **TLS is verified.** mpv's `tls-verify` defaults to *no*, and the stream URLs
//! it loads carry the account's `ApiKey`. Every reqwest client in the app
//! verifies certificates; without [`harden`] mpv was the one path where anyone
//! able to present a certificate for the server's host could read the token.
//! `ytdl` goes with it: libmpv loads its youtube-dl hook by default and hands a
//! URL that failed to open — token included — to an external `yt-dlp`.
//!
//! TRACES: UR-003, UR-004, UR-012 | DR-298, DR-299
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use libmpv::Mpv;
/// Run an mpv command with each argument passed through verbatim.
///
/// TRACES: UR-003, UR-004 | DR-298 | UT-268
pub fn command(mpv: &Mpv, args: &[&str]) -> Result<(), String> {
if args.is_empty() {
return Err("empty mpv command".to_string());
}
// A NUL cannot be represented in a C string; refusing is the only honest
// answer, since truncating would load a different URL than the one asked.
let owned = args
.iter()
.map(|a| CString::new(*a).map_err(|_| format!("mpv argument contains NUL: {a:?}")))
.collect::<Result<Vec<_>, _>>()?;
let mut argv: Vec<*const c_char> = owned.iter().map(|a| a.as_ptr()).collect();
argv.push(std::ptr::null());
// SAFETY: `argv` is a NULL-terminated array of pointers into `owned`, which
// outlives the call; mpv copies what it keeps. `ctx` is the live handle.
let rc = unsafe { libmpv_sys::mpv_command(mpv.ctx.as_ptr(), argv.as_mut_ptr()) };
if rc < 0 {
// SAFETY: mpv_error_string returns a static string for any code.
let msg = unsafe { CStr::from_ptr(libmpv_sys::mpv_error_string(rc)) };
return Err(format!("{} ({rc})", msg.to_string_lossy()));
}
Ok(())
}
/// Configure a freshly created handle so it will not trust an unverified server.
///
/// Must run before the first `loadfile`. Failure is an error, not a warning: a
/// handle that could not be told to verify TLS is exactly the one that leaks.
///
/// TRACES: UR-012 | DR-299 | UT-270
pub fn harden(mpv: &Mpv) -> Result<(), String> {
for (name, value) in [("tls-verify", "yes"), ("ytdl", "no")] {
mpv.set_property(name, value)
.map_err(|e| format!("could not set {name}={value}: {e:?}"))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// A handle that decodes nothing and opens no device, so the tests need no
/// audio system and no display.
fn null_mpv() -> Mpv {
let mpv = Mpv::new().expect("libmpv must be available to run the player tests");
mpv.set_property("ao", "null").unwrap();
mpv.set_property("vo", "null").unwrap();
mpv
}
/// The injection itself, against a real libmpv: a URL carrying `;` and a
/// second command must be loaded as one (unreachable) URL, not split and
/// executed. `set volume 13` stands in for `run …` — the same parse, but
/// observable without spawning a process.
///
/// TRACES: UR-003, UR-004 | DR-298 | UT-268
#[test]
fn a_url_cannot_smuggle_a_second_mpv_command() {
let mpv = null_mpv();
mpv.set_property("volume", 100i64).unwrap();
// Port 9 (discard) on loopback: nothing is fetched either way.
let url = "http://127.0.0.1:9/Audio/x;set volume 13;#/stream?ApiKey=k";
let _ = command(&mpv, &["loadfile", url, "replace"]);
let volume: i64 = mpv.get_property("volume").unwrap();
assert_eq!(
volume, 100,
"text inside a URL was executed as an mpv command"
);
}
/// An argument with a space in it — every downloaded title with one —
/// arrives as one argument rather than being split into the next slot.
///
/// TRACES: UR-003, UR-004 | DR-298 | UT-268
#[test]
fn an_argument_with_spaces_stays_one_argument() {
let mpv = null_mpv();
// Split on the space this would be `loadfile file:///tmp/My Song.mp3`
// — `Song.mp3` taken as the flags argument, which mpv rejects.
assert!(command(&mpv, &["loadfile", "file:///nonexistent/My Song.mp3"]).is_ok());
}
/// No playback path may call the string-joining `Mpv::command` directly;
/// they all go through [`command`]. Asserted against the source because the
/// dangerous call and the safe one have the same shape at the call site.
///
/// TRACES: UR-003, UR-004 | DR-298 | UT-269
#[test]
fn players_never_use_the_string_command_api() {
for (file, src) in [
("mpv_backend.rs", include_str!("mpv_backend.rs")),
("mpv_player.rs", include_str!("mpv_player.rs")),
] {
assert!(
!src.contains(".command(\""),
"{file} calls Mpv::command, which parses its arguments as a command string"
);
}
}
/// TRACES: UR-012 | DR-299 | UT-270
#[test]
fn a_hardened_handle_verifies_tls_and_never_hands_urls_to_ytdl() {
let mpv = null_mpv();
harden(&mpv).unwrap();
let tls: String = mpv.get_property("tls-verify").unwrap();
assert_eq!(tls, "yes");
let ytdl: String = mpv.get_property("ytdl").unwrap();
assert_eq!(ytdl, "no");
}
/// Both constructors apply [`harden`]; a handle built without it is the bug.
///
/// TRACES: UR-012 | DR-299 | UT-270
#[test]
fn every_player_hardens_its_handle() {
for (file, src) in [
("mpv_backend.rs", include_str!("mpv_backend.rs")),
("mpv_player.rs", include_str!("mpv_player.rs")),
] {
assert!(
src.contains("mpv_command::harden(&mpv)"),
"{file} creates an mpv handle without hardening it"
);
}
}
}
+7 -5
View File
@@ -85,6 +85,8 @@ impl MpvPlayer {
let mpv = Mpv::new().map_err(|e| PlayerError {
message: format!("mpv_create failed: {e:?}"),
})?;
// TRACES: UR-012 | DR-299
super::mpv_command::harden(&mpv).map_err(|message| PlayerError { message })?;
let set = |k: &str, v: &str| {
if let Err(e) = mpv.set_property(k, v) {
@@ -229,10 +231,10 @@ impl MediaPlayer for MpvPlayer {
})?;
info!("[MpvPlayer] open {} at {:?}", req.selection.url, req.start);
self.mpv
.command("loadfile", &[&req.selection.url, "replace"])
// TRACES: UR-003, UR-004 | DR-298
super::mpv_command::command(&self.mpv, &["loadfile", &req.selection.url, "replace"])
.map_err(|e| PlayerError {
message: format!("loadfile failed: {e:?}"),
message: format!("loadfile failed: {e}"),
})?;
Ok(())
}
@@ -275,8 +277,8 @@ impl MediaPlayer for MpvPlayer {
}
// Idempotent: stopping an already-stopped mpv is not an error worth
// propagating, and callers legitimately close twice on teardown.
if let Err(e) = self.mpv.command("stop", &[]) {
debug!("[MpvPlayer] stop on an idle player: {e:?}");
if let Err(e) = super::mpv_command::command(&self.mpv, &["stop"]) {
debug!("[MpvPlayer] stop on an idle player: {e}");
}
Ok(())
}
+257
View File
@@ -0,0 +1,257 @@
//! Subtitle and audio-track selection on mpv, by *position*, the way the
//! frontend asks for it.
//!
//! Until DR-235 mpv never drew video, so it never had to: subtitles were
//! `<track>` children of the webview `<video>` and an audio track change
//! re-opened the stream. With mpv the only desktop video renderer, it has to
//! answer the same two calls ExoPlayer does, with the same meaning:
//!
//! - **Subtitles** are the sideloaded WebVTT list the play request carries
//! (`MediaItem::subtitles`), and `set_subtitle_track(n)` selects the *n-th of
//! those* — the position the frontend computes with `nativeSubtitleArrayIndex`.
//! They reach mpv as external files queued on `sub-files` before the load, and
//! selection starts off, because the menu opens on "Off".
//! - **Audio** `set_audio_track(n)` selects the n-th audio track of the file —
//! the position in the item's audio streams, which is file order. Only a direct
//! play/stream carries every track; a transcode is re-opened instead
//! (`AudioTrackSwitchStrategy`).
//!
//! mpv's own track ids are not positions: they count every track of a type,
//! embedded before external, from 1. So a position is always resolved against
//! the live `track-list`.
//!
//! TRACES: UR-020, UR-021 | DR-023, DR-024, DR-235
use libmpv::Mpv;
use super::mpv_command;
/// One entry of mpv's `track-list`, reduced to what selection needs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrackInfo {
pub id: i64,
/// "video", "audio" or "sub".
pub kind: String,
pub external: bool,
}
/// The mpv id of the `position`-th track of `kind` (optionally only external or
/// only embedded ones), in `track-list` order.
///
/// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
pub fn nth_track_id(
tracks: &[TrackInfo],
kind: &str,
external: Option<bool>,
position: usize,
) -> Option<i64> {
tracks
.iter()
.filter(|t| t.kind == kind && external.is_none_or(|e| t.external == e))
.nth(position)
.map(|t| t.id)
}
/// Read the current `track-list`.
pub fn track_list(mpv: &Mpv) -> Vec<TrackInfo> {
let count: i64 = mpv.get_property("track-list/count").unwrap_or(0);
(0..count)
.filter_map(|i| {
let id: i64 = mpv.get_property(&format!("track-list/{i}/id")).ok()?;
let kind: String = mpv.get_property(&format!("track-list/{i}/type")).ok()?;
let external: bool = mpv
.get_property(&format!("track-list/{i}/external"))
.unwrap_or(false);
Some(TrackInfo { id, kind, external })
})
.collect()
}
/// Prepare the next `loadfile`: these subtitle files will be loaded with it, no
/// subtitle is shown, and the file's default audio track plays.
///
/// `sid`/`aid` set while idle become the options the next file opens with, so
/// a track chosen for the previous item cannot leak into this one.
///
/// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
pub fn prepare_load(mpv: &Mpv, subtitle_urls: &[&str]) -> Result<(), String> {
mpv_command::command(mpv, &["change-list", "sub-files", "clr", ""])?;
for url in subtitle_urls {
// `append` adds one item without splitting on the list separator, which
// a URL's `:` would otherwise trip. Through the argv form (DR-298), so the
// URL is never parsed as command text.
mpv_command::command(mpv, &["change-list", "sub-files", "append", url])?;
}
mpv.set_property("sid", "no")
.map_err(|e| format!("could not set sid=no: {e:?}"))?;
mpv.set_property("aid", "auto")
.map_err(|e| format!("could not set aid=auto: {e:?}"))?;
Ok(())
}
/// Show the `position`-th sideloaded subtitle, or none.
///
/// TRACES: UR-020 | DR-023 | UT-275
pub fn select_subtitle(mpv: &Mpv, position: Option<usize>) -> Result<(), String> {
let Some(position) = position else {
return mpv
.set_property("sid", "no")
.map_err(|e| format!("could not set sid=no: {e:?}"));
};
let id = nth_track_id(&track_list(mpv), "sub", Some(true), position)
.ok_or_else(|| format!("no sideloaded subtitle at position {position}"))?;
mpv.set_property("sid", id)
.map_err(|e| format!("could not set sid={id}: {e:?}"))
}
/// Play the `position`-th audio track of the file.
///
/// TRACES: UR-021 | DR-024 | UT-275
pub fn select_audio(mpv: &Mpv, position: usize) -> Result<(), String> {
let id = nth_track_id(&track_list(mpv), "audio", Some(false), position)
.ok_or_else(|| format!("no audio track at position {position}"))?;
mpv.set_property("aid", id)
.map_err(|e| format!("could not set aid={id}: {e:?}"))
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{Duration, Instant};
fn fixture(name: &str) -> String {
format!("{}/src/player/fixtures/{name}", env!("CARGO_MANIFEST_DIR"))
}
fn track(id: i64, kind: &str, external: bool) -> TrackInfo {
TrackInfo {
id,
kind: kind.to_string(),
external,
}
}
/// mpv numbers each type from 1, embedded before external, so a position in
/// the sideloaded list is not an id. The file here has two embedded
/// subtitles; the first sideloaded one is mpv's sub 3.
///
/// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
#[test]
fn positions_resolve_to_mpv_ids_per_kind() {
let tracks = [
track(1, "video", false),
track(1, "audio", false),
track(2, "audio", false),
track(1, "sub", false),
track(2, "sub", false),
track(3, "sub", true),
track(4, "sub", true),
];
assert_eq!(nth_track_id(&tracks, "sub", Some(true), 0), Some(3));
assert_eq!(nth_track_id(&tracks, "sub", Some(true), 1), Some(4));
assert_eq!(nth_track_id(&tracks, "sub", Some(true), 2), None);
assert_eq!(nth_track_id(&tracks, "audio", Some(false), 1), Some(2));
assert_eq!(nth_track_id(&tracks, "audio", None, 0), Some(1));
}
fn loaded_mpv(subs: &[&str]) -> Mpv {
let mpv = Mpv::new().expect("libmpv must be available to run the player tests");
mpv.set_property("ao", "null").unwrap();
mpv.set_property("vo", "null").unwrap();
mpv.set_property("pause", true).unwrap();
prepare_load(&mpv, subs).unwrap();
mpv_command::command(&mpv, &["loadfile", &fixture("two-audio-tracks.mkv")]).unwrap();
// Video, two audio tracks, and one track per sideloaded subtitle.
let expected = 3 + subs.len() as i64;
let deadline = Instant::now() + Duration::from_secs(10);
while mpv.get_property::<i64>("track-list/count").unwrap_or(0) < expected {
assert!(
Instant::now() < deadline,
"the fixture never finished loading"
);
std::thread::sleep(Duration::from_millis(20));
}
mpv
}
/// Against a real file: the sideloaded subtitle arrives, starts hidden, and
/// is shown and hidden by position.
///
/// TRACES: UR-020 | DR-023 | UT-275
#[test]
fn a_sideloaded_subtitle_starts_off_and_is_selected_by_position() {
let vtt = fixture("sub.vtt");
let mpv = loaded_mpv(&[&vtt]);
assert_eq!(mpv.get_property::<String>("sid").unwrap(), "no");
select_subtitle(&mpv, Some(0)).unwrap();
let expected = nth_track_id(&track_list(&mpv), "sub", Some(true), 0).unwrap();
assert_eq!(mpv.get_property::<i64>("sid").unwrap(), expected);
select_subtitle(&mpv, None).unwrap();
assert_eq!(mpv.get_property::<String>("sid").unwrap(), "no");
assert!(select_subtitle(&mpv, Some(5)).is_err());
}
/// Against a real file with two audio tracks: position 1 is the second one.
///
/// TRACES: UR-021 | DR-024 | UT-275
#[test]
fn an_audio_track_is_selected_by_position_in_the_file() {
let mpv = loaded_mpv(&[]);
select_audio(&mpv, 1).unwrap();
assert_eq!(mpv.get_property::<i64>("aid").unwrap(), 2);
select_audio(&mpv, 0).unwrap();
assert_eq!(mpv.get_property::<i64>("aid").unwrap(), 1);
assert!(select_audio(&mpv, 2).is_err());
}
/// The next item opens with no subtitle and its own default audio, whatever
/// the last one had chosen, and with only its own subtitle files.
///
/// TRACES: UR-020, UR-021 | DR-023, DR-024 | UT-275
#[test]
fn preparing_a_load_forgets_the_previous_items_choices() {
let vtt = fixture("sub.vtt");
let mpv = loaded_mpv(&[&vtt]);
select_subtitle(&mpv, Some(0)).unwrap();
select_audio(&mpv, 1).unwrap();
// The next item: no subtitles of its own this time.
prepare_load(&mpv, &[]).unwrap();
mpv_command::command(
&mpv,
&["loadfile", &fixture("two-audio-tracks.mkv"), "replace"],
)
.unwrap();
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let tracks = track_list(&mpv);
let settled = tracks.len() == 3 && mpv.get_property::<i64>("aid").is_ok();
if settled {
break;
}
assert!(
Instant::now() < deadline,
"the second load never settled: {tracks:?}"
);
std::thread::sleep(Duration::from_millis(20));
}
assert_eq!(mpv.get_property::<String>("sid").unwrap(), "no");
assert_eq!(
mpv.get_property::<i64>("aid").unwrap(),
1,
"default audio again"
);
assert_eq!(
nth_track_id(&track_list(&mpv), "sub", None, 0),
None,
"the previous item's subtitle file came along"
);
}
}
+24 -41
View File
@@ -2,8 +2,7 @@
//!
//! Three things need this and must agree: the mpv backend (which has to be
//! configured for video *at construction*, before anything plays), the video
//! surface (which has nothing to draw otherwise), and `get_player_status`
//! (which tells the frontend whether to use a webview `<video>` element).
//! surface (which has nothing to draw otherwise), and the device profile.
//!
//! It is a function rather than three `env::var` checks for the reason this
//! codebase keeps rediscovering: a capability answered in several places is a
@@ -14,65 +13,49 @@
//!
//! TRACES: UR-080 | DR-231, DR-235
/// The opt-in for native desktop video.
///
/// Off by default while the render path is unproven — the webview path still
/// works and is what ships. This becomes the *default* (and then the only path)
/// when DR-235 lands; the variable is how it is exercised until then.
const ENV_FLAG: &str = "JELLYTAU_NATIVE_VIDEO";
/// Whether mpv should decode and draw video in this process.
///
/// Read fresh rather than cached: it is consulted a handful of times at startup,
/// and a `OnceLock` here would only make it harder to test.
/// True wherever mpv is the desktop video renderer: Linux since DR-235 phase 1,
/// Windows since DR-237. There is no opt-out and no webview fallback — the
/// webview `<video>` path is gone, so "off" would leave nothing drawing the
/// picture. It was the `JELLYTAU_NATIVE_VIDEO` opt-in while the render path was
/// being proven; the variable is ignored.
///
/// TRACES: UR-080 | DR-231, DR-235
pub fn enabled() -> bool {
// Only where a native renderer exists. On Android ExoPlayer already does
// this and `use_html5_element` is false for entirely separate reasons.
if !cfg!(all(target_os = "linux", not(target_os = "android"))) {
return false;
}
matches!(
std::env::var(ENV_FLAG).as_deref(),
Ok("1") | Ok("true") | Ok("yes")
)
// On Android ExoPlayer draws video and `use_html5_element` is false for
// entirely separate reasons.
cfg!(any(target_os = "linux", target_os = "windows"))
}
#[cfg(test)]
mod tests {
use super::*;
/// Absent, empty, or anything unrecognised means off. A half-set variable
/// must not half-enable a renderer — the failure mode would be mpv
/// configured for video with nothing drawing it, i.e. audio playing over a
/// black rectangle.
/// mpv draws video on Linux and Windows with nothing to opt into, and the retired
/// variable cannot opt back out: with the webview path gone, "off"
/// would configure mpv for audio only with nothing else to draw the picture.
///
/// TRACES: UR-080 | DR-231 | UT-216
/// TRACES: UR-080 | DR-235, DR-237 | UT-271
#[test]
fn test_only_explicit_truthy_values_enable_it() {
let restore = std::env::var(ENV_FLAG).ok();
fn desktop_always_renders_video_natively() {
let restore = std::env::var("JELLYTAU_NATIVE_VIDEO").ok();
for value in ["", "0", "no", "false", "maybe", "2"] {
std::env::set_var(ENV_FLAG, value);
assert!(!enabled(), "{value:?} must not enable native video");
}
for value in ["1", "true", "yes"] {
std::env::set_var(ENV_FLAG, value);
for value in [None, Some("0"), Some("false"), Some("1")] {
match value {
Some(v) => std::env::set_var("JELLYTAU_NATIVE_VIDEO", v),
None => std::env::remove_var("JELLYTAU_NATIVE_VIDEO"),
}
assert_eq!(
enabled(),
cfg!(all(target_os = "linux", not(target_os = "android"))),
"{value:?} enables it exactly where a native renderer exists"
cfg!(any(target_os = "linux", target_os = "windows")),
"JELLYTAU_NATIVE_VIDEO={value:?} must not decide the renderer"
);
}
std::env::remove_var(ENV_FLAG);
assert!(!enabled(), "absent means off");
match restore {
Some(v) => std::env::set_var(ENV_FLAG, v),
None => std::env::remove_var(ENV_FLAG),
Some(v) => std::env::set_var("JELLYTAU_NATIVE_VIDEO", v),
None => std::env::remove_var("JELLYTAU_NATIVE_VIDEO"),
}
}
}
+40 -129
View File
@@ -5,17 +5,18 @@
//! [`VideoSeekStrategy`] into a concrete backend/frontend action.
/// Seek strategy for video playback, derived from a stream's characteristics.
///
/// Every video renderer is a native backend (mpv, ExoPlayer) since the webview
/// `<video>` path was deleted (DR-235), so the backend performs every seek; what
/// remains to decide is whether it can move the stream in place.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoSeekStrategy {
/// Local file - always use native seek on backend
LocalNativeSeek,
/// HLS or direct stream with HTML5 - frontend handles seek, skip backend
Html5NativeSeek,
/// HLS or direct stream with native backend - backend handles seek
/// Seekable where it sits (direct play/stream, or a transcode the engine
/// can move) - backend seeks
BackendNativeSeek,
/// Transcoded non-HLS with HTML5 - reload stream, frontend handles
Html5ReloadStream,
/// Transcoded non-HLS with native backend - reload stream, backend handles
/// A transcode the engine cannot move - re-open the stream at the target
BackendReloadStream,
}
@@ -29,12 +30,10 @@ pub enum VideoSeekStrategy {
/// can seek a server-side transcode without re-opening it. Declared by the
/// engine via `Capabilities`, never inferred from the URL or the renderer.
/// * `needs_transcoding` - Whether the content needs transcoding
/// * `use_html5` - Whether frontend is using HTML5 video element
pub fn determine_video_seek_strategy(
is_local: bool,
seeks_transcoded_in_place: bool,
needs_transcoding: bool,
use_html5: bool,
) -> VideoSeekStrategy {
// Local files always support native seeking via backend
if is_local {
@@ -42,39 +41,20 @@ pub fn determine_video_seek_strategy(
}
// A server-side transcode is produced *from* `StartTimeTicks`, so where the
// seek lands is a property of the request, not of the stream in hand.
//
// hls.js is the exception: handed a VOD playlist it seeks within it and lets
// the server catch up segment by segment. mpv's HLS demuxer cannot make
// Jellyfin transcode from a new offset, so for the native backend a
// seek lands is a property of the request, not of the stream in hand. mpv's
// HLS demuxer cannot make Jellyfin transcode from a new offset, so for it a
// transcoded seek must re-negotiate the stream regardless of container.
//
// Before native video shipped, `use_html5` was always true for HLS and the
// native+HLS+transcode cell was unreachable, which is why `is_hls` alone
// used to be a safe proxy for "seekable in place". It no longer is: turning
// native video on routed every transcoded seek into a backend seek that
// silently does nothing, and presents as "resume does not work".
if needs_transcoding {
// Whether a transcode can be seeked in place is a property of the
// engine, and the engine states it. This used to be inferred from
// `is_hls`, which held only while hls.js was the sole HLS renderer —
// and stopped holding the moment mpv became one (DR-238).
return match (seeks_transcoded_in_place, use_html5) {
(true, true) => VideoSeekStrategy::Html5NativeSeek,
(true, false) => VideoSeekStrategy::BackendNativeSeek,
(false, true) => VideoSeekStrategy::Html5ReloadStream,
(false, false) => VideoSeekStrategy::BackendReloadStream,
};
// Whether a transcode can be seeked in place is a property of the engine,
// and the engine states it. This used to be inferred from `is_hls`, which
// held only while hls.js was the sole HLS renderer — and stopped holding the
// moment mpv became one (DR-238).
if needs_transcoding && !seeks_transcoded_in_place {
return VideoSeekStrategy::BackendReloadStream;
}
// Direct play and direct stream are seekable where they sit.
if use_html5 {
// The frontend seeks via videoElement.currentTime; calling backend.seek()
// would move a player that is not the one rendering.
VideoSeekStrategy::Html5NativeSeek
} else {
VideoSeekStrategy::BackendNativeSeek
}
// Direct play, direct stream, or a transcode the engine can move.
VideoSeekStrategy::BackendNativeSeek
}
// The four items below are consumed by the Android MediaSessionHandler; on other
@@ -224,114 +204,45 @@ mod tests {
#[test]
fn test_seek_strategy_local_file() {
// Local files always use native backend seek regardless of other flags
assert_eq!(
determine_video_seek_strategy(true, false, false, false),
VideoSeekStrategy::LocalNativeSeek
);
assert_eq!(
determine_video_seek_strategy(true, false, false, true),
VideoSeekStrategy::LocalNativeSeek
);
assert_eq!(
determine_video_seek_strategy(true, true, true, true),
VideoSeekStrategy::LocalNativeSeek
);
for (in_place, transcode) in [(false, false), (true, true), (false, true)] {
assert_eq!(
determine_video_seek_strategy(true, in_place, transcode),
VideoSeekStrategy::LocalNativeSeek
);
}
}
/// Non-transcoded streams seek in place regardless of the engine's
/// transcode ability, which only applies to transcodes.
/// Direct play and direct stream seek in place, whatever the engine's
/// transcode ability — that only applies to transcodes.
#[test]
fn test_seek_strategy_direct_stream() {
// HTML5 renders, so the frontend seeks the element
assert_eq!(
determine_video_seek_strategy(false, true, false, true),
VideoSeekStrategy::Html5NativeSeek
);
// The native engine renders, so it seeks
assert_eq!(
determine_video_seek_strategy(false, true, false, false),
VideoSeekStrategy::BackendNativeSeek
);
// A transcode an engine says it can move: seek in place
assert_eq!(
determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek
);
for in_place in [false, true] {
assert_eq!(
determine_video_seek_strategy(false, in_place, false),
VideoSeekStrategy::BackendNativeSeek
);
}
}
/// A server-side transcode cannot be seeked by the native backend.
/// A server-side transcode is re-opened by an engine that cannot move it,
/// and seeked in place by one that says it can.
///
/// Jellyfin produces a transcode from `StartTimeTicks`; hls.js can seek
/// within the VOD playlist it is handed, but mpv's HLS demuxer cannot make
/// the server transcode from a new offset, so the stream has to be
/// re-negotiated. Before native video existed, `use_html5` was always true
/// for HLS and this case was unreachable — turning native video on routed
/// every transcoded seek into a native seek that silently does nothing,
/// which presents as "resume does not work".
/// Jellyfin produces a transcode from `StartTimeTicks`; mpv's HLS demuxer
/// cannot make the server transcode from a new offset, so the stream has to
/// be re-negotiated. Inferring this from the container once routed every
/// transcoded seek into a native seek that silently does nothing, which
/// presents as "resume does not work".
///
/// TRACES: UR-040 | DR-238, DR-246 | UT-217
#[test]
fn test_transcoded_seek_follows_the_engines_declared_ability() {
// An engine that cannot move a server-side transcode re-opens it,
// whichever side is rendering.
assert_eq!(
determine_video_seek_strategy(false, false, true, false),
determine_video_seek_strategy(false, false, true),
VideoSeekStrategy::BackendReloadStream
);
assert_eq!(
determine_video_seek_strategy(false, false, true, true),
VideoSeekStrategy::Html5ReloadStream
);
// hls.js can, and says so, so it seeks in place.
assert_eq!(
determine_video_seek_strategy(false, true, true, true),
VideoSeekStrategy::Html5NativeSeek
);
// The container the stream arrives in no longer decides anything: the
// same declared ability gives the same answer on the native side.
assert_eq!(
determine_video_seek_strategy(false, true, true, false),
determine_video_seek_strategy(false, true, true),
VideoSeekStrategy::BackendNativeSeek
);
}
/// Test video seek strategy for direct play (non-transcoded) streams
#[test]
fn test_seek_strategy_direct_play() {
// Direct play with HTML5 - frontend handles seek
assert_eq!(
determine_video_seek_strategy(false, false, false, true),
VideoSeekStrategy::Html5NativeSeek
);
// Direct play with native backend - backend handles seek
assert_eq!(
determine_video_seek_strategy(false, false, false, false),
VideoSeekStrategy::BackendNativeSeek
);
}
/// Test video seek strategy for transcoded non-HLS streams
#[test]
fn test_seek_strategy_transcoded_non_hls() {
// Transcoded non-HLS with HTML5 - need to reload stream, frontend handles
assert_eq!(
determine_video_seek_strategy(false, false, true, true),
VideoSeekStrategy::Html5ReloadStream
);
// Transcoded non-HLS with native backend - need to reload stream, backend handles
assert_eq!(
determine_video_seek_strategy(false, false, true, false),
VideoSeekStrategy::BackendReloadStream
);
}
/// Test the specific bug fix: HLS + HTML5 should NOT call backend seek
/// This was the bug causing "Raw(-10)" errors
#[test]
fn test_hls_html5_does_not_use_backend_seek() {
let strategy = determine_video_seek_strategy(false, true, false, true);
// Should be Html5NativeSeek, NOT BackendNativeSeek
assert_eq!(strategy, VideoSeekStrategy::Html5NativeSeek);
assert_ne!(strategy, VideoSeekStrategy::BackendNativeSeek);
}
}
+5 -5
View File
@@ -118,7 +118,7 @@ pub fn is_truncated_end(position: f64, duration: Option<f64>, tolerance: f64) ->
/// Resuming re-opens *the stream we were already playing*, so the URL is edited
/// in place rather than rebuilt from the repository: every other parameter —
/// `AudioStreamIndex` (the track the user picked in the video player),
/// `MediaSourceId`, `api_key` — is carried over untouched, and no network call
/// `MediaSourceId`, `ApiKey` — is carried over untouched, and no network call
/// is needed to recover from a network failure.
pub fn with_start_time(url: &str, position_seconds: f64) -> String {
let ticks = (position_seconds.max(0.0) * 10_000_000.0) as i64;
@@ -387,22 +387,22 @@ mod tests {
#[test]
fn test_with_start_time_replaces_existing_ticks() {
let url = "http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=1200000000&Container=mp3";
let url = "http://s/Audio/ep2/universal?ApiKey=k&AudioStreamIndex=2&StartTimeTicks=1200000000&Container=mp3";
let out = with_start_time(url, 600.0);
assert_eq!(
out,
"http://s/Audio/ep2/universal?api_key=k&AudioStreamIndex=2&StartTimeTicks=6000000000&Container=mp3"
"http://s/Audio/ep2/universal?ApiKey=k&AudioStreamIndex=2&StartTimeTicks=6000000000&Container=mp3"
);
}
#[test]
fn test_with_start_time_appends_when_absent() {
// The next-episode stream is built without StartTimeTicks.
let url = "http://s/Audio/ep3/universal?api_key=k&AudioStreamIndex=0";
let url = "http://s/Audio/ep3/universal?ApiKey=k&AudioStreamIndex=0";
let out = with_start_time(url, 90.0);
assert_eq!(
out,
"http://s/Audio/ep3/universal?api_key=k&AudioStreamIndex=0&StartTimeTicks=900000000"
"http://s/Audio/ep3/universal?ApiKey=k&AudioStreamIndex=0&StartTimeTicks=900000000"
);
}
+4 -32
View File
@@ -13,10 +13,6 @@
/// How a request to change audio track has to be carried out.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AudioTrackSwitchStrategy {
/// Re-open the stream pinned to the chosen track; the frontend reloads its
/// `<video>` element. An HTML5 element cannot select an audio track at all,
/// so this holds whether or not the current stream is a transcode.
Html5ReloadStream,
/// Re-open the stream pinned to the chosen track; the backend reloads
/// itself and restores the position.
BackendReloadStream,
@@ -29,17 +25,9 @@ pub enum AudioTrackSwitchStrategy {
/// # Arguments
/// * `needs_transcoding` - Whether the stream now playing is a server-side
/// transcode, which carries exactly the one audio track it was built around.
/// * `use_html5` - Whether the frontend `<video>` element is rendering.
///
/// TRACES: UR-021 | IR-019, DR-024, DR-258 | UT-232
pub fn determine_audio_track_switch_strategy(
needs_transcoding: bool,
use_html5: bool,
) -> AudioTrackSwitchStrategy {
if use_html5 {
return AudioTrackSwitchStrategy::Html5ReloadStream;
}
pub fn determine_audio_track_switch_strategy(needs_transcoding: bool) -> AudioTrackSwitchStrategy {
if needs_transcoding {
AudioTrackSwitchStrategy::BackendReloadStream
} else {
@@ -86,8 +74,7 @@ mod tests {
assert_eq!(resume_position(None, 1337.5), 1337.5);
}
/// The HTML5 path does have an element and its clock is the honest answer
/// there, so what the caller supplies wins.
/// A caller that does know the position is believed.
#[test]
fn a_caller_that_knows_its_position_is_believed() {
assert_eq!(resume_position(Some(42.0), 1337.5), 42.0);
@@ -123,7 +110,7 @@ mod tests {
#[test]
fn a_transcode_is_re_opened_because_it_carries_only_one_track() {
assert_eq!(
determine_audio_track_switch_strategy(true, false),
determine_audio_track_switch_strategy(true),
AudioTrackSwitchStrategy::BackendReloadStream
);
}
@@ -133,23 +120,8 @@ mod tests {
#[test]
fn a_direct_play_switches_in_place() {
assert_eq!(
determine_audio_track_switch_strategy(false, false),
determine_audio_track_switch_strategy(false),
AudioTrackSwitchStrategy::BackendSelectInPlace
);
}
/// An HTML5 `<video>` element has no track-selection API, so it reloads
/// either way. This is the path that already worked, and it must keep
/// working: the fix is about the native side only.
#[test]
fn html5_always_reloads_because_the_element_cannot_select() {
assert_eq!(
determine_audio_track_switch_strategy(true, true),
AudioTrackSwitchStrategy::Html5ReloadStream
);
assert_eq!(
determine_audio_track_switch_strategy(false, true),
AudioTrackSwitchStrategy::Html5ReloadStream
);
}
}
+11 -18
View File
@@ -1,25 +1,18 @@
//! Webview audio backend — audio-only playback for platforms without a native
//! audio backend (currently Windows).
//! Webview audio backend — audio-only playback for a desktop without a native
//! audio backend. No shipped platform uses it: Linux and Windows play through
//! mpv, Android through ExoPlayer.
//!
//! ## Why this exists
//! All *video* already renders through the webview HTML5 `<video>` element on
//! every platform (see `VideoPlayer.svelte`); libmpv/ExoPlayer only ever drive
//! *audio-only* (music) playback. On Windows there is no native audio backend,
//! so `create_player_backend()` used to fall back to `NullBackend` and music was
//! silent.
//!
//! This backend fills that gap without any C dependency (so it still
//! cross-compiles from Linux): instead of decoding audio itself, it hands the
//! stream URL to a frontend `<audio>` element via a `WebviewAudioLoad` event and
//! then drives play/pause/seek/stop through `ControlCommand` events — exactly the
//! round-trip the HTML5 video path already uses. The `<audio>` element reports
//! its real state/position back through the `player_report_*` commands, so the
//! Rust `PlayerController` remains the single source of truth (the controller's
//! `report_html5_*` methods fold those reports into the normal event pipeline).
//! Instead of decoding audio itself, it hands the stream URL to a frontend
//! `<audio>` element via a `WebviewAudioLoad` event and then drives
//! play/pause/seek/stop through `ControlCommand` events. The `<audio>` element
//! reports its real state/position back through the `player_report_*` commands,
//! so the Rust `PlayerController` remains the single source of truth (the
//! controller's `report_html5_*` methods fold those reports into the normal
//! event pipeline).
//!
//! Because the reported state flows through the event pipeline (not through this
//! backend's `position()`/`state()` pollers — the timer loop does not poll the
//! backend for HTML5-rendered media), this backend only needs to keep a
//! backend for webview-rendered media), this backend only needs to keep a
//! best-effort local mirror for direct `player_get_state` queries.
//!
//! TRACES: UR-003, UR-004, UR-005 | DR-004
+416
View File
@@ -0,0 +1,416 @@
//! What the server on the other end of the wire can actually do.
//!
//! One `ServerCapabilities` value is resolved per connection, from the version
//! the server already reports at `/System/Info/Public`, and every decision that
//! depends on the server generation reads a **named flag** from it.
//!
//! # Why flags and not version comparisons
//!
//! A `version < N` written at the point of use re-derives a domain fact where it
//! is consumed — the same error as a Jellyfin taxonomy in the frontend, and the
//! reason `check:boundary` exists. It is also unreadable by its second
//! occurrence (`< 11` says nothing about *what* changed), and it cannot express
//! a backport, where a behaviour appears in a patch release of an older line.
//!
//! So the version → flags mapping lives in exactly one function
//! ([`ServerCapabilities::for_version`]) and nothing else in the crate compares
//! a version number.
//!
//! # Why an unknown version resolves forward
//!
//! A server newer than this build resolves to the newest capability set we know
//! rather than being refused. Refusing would make every JellyTau release expire
//! the moment the server upgrades, which is the failure UR-085 exists to remove.
//! Refusal is reserved for a version *below* [`MINIMUM_SUPPORTED_MAJOR_MINOR`],
//! where failure is certain rather than merely likely.
//!
//! TRACES: UR-085 | IR-035, DR-280
use std::fmt;
/// The oldest server this build will talk to, as `(major, minor)`.
///
/// This is the current target and not a researched floor: no older server has
/// been tested against, so claiming support for one would be a guess. Lower it
/// when a real server has been exercised, not before.
pub const MINIMUM_SUPPORTED_MAJOR_MINOR: (u32, u32) = (10, 10);
/// A parsed server version.
///
/// Jellyfin reports things like `10.11.5`, `10.11.5.0` and occasionally a
/// build suffix (`10.11.5-rc1`). Only the leading numeric components are
/// meaningful here; anything after them is preserved in `raw` for logging and
/// otherwise ignored.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerVersion {
pub major: u32,
pub minor: u32,
pub patch: u32,
pub raw: String,
}
impl ServerVersion {
/// Parse what `/System/Info/Public` reported.
///
/// Returns `None` for anything without at least a numeric major, which is
/// treated as "unknown" rather than as an error — an unparseable version is
/// not a reason to refuse a server that may work perfectly well.
pub fn parse(raw: &str) -> Option<Self> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
// Stop at the first character that cannot begin a numeric component, so
// `10.11.5-rc1` and `10.11.5+build7` both yield 10.11.5.
let numeric_prefix: String = trimmed
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
let mut parts = numeric_prefix.split('.').filter(|p| !p.is_empty());
let major = parts.next()?.parse().ok()?;
let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
Some(Self {
major,
minor,
patch,
raw: trimmed.to_string(),
})
}
fn is_below_floor(&self) -> bool {
(self.major, self.minor) < MINIMUM_SUPPORTED_MAJOR_MINOR
}
}
impl fmt::Display for ServerVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
/// How this build classified the server it is talking to.
///
/// There are exactly two live cases, and the gap between them is not a typo:
/// **Jellyfin 11.0 does not exist and never did.** With 12.0 the project dropped
/// the leading `10` from its scheme, so what would have been 10.12.0 shipped as
/// `12.0` and the server reports `Version: "12.0.0"`. 12.0 is therefore *one*
/// release-branch step from 10.11, not two, and `major == 11` will never occur.
///
/// Source: <https://jellyfin.org/posts/jellyfin-release-12.0>, which explicitly
/// flags version-string parsers as the thing to check before upgrading.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServerGeneration {
/// The 10.x line — `major == 10`. What this client was built against.
V10_11,
/// The post-rename line — `major >= 12`.
V12Plus,
/// The server did not report a parseable version. Treated as the older
/// generation, which is the conservative choice: its flags are the ones that
/// also work on 12.x.
Unknown,
}
/// The resolved answer, carried by `OnlineRepository` for the life of a
/// connection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerCapabilities {
pub version: Option<ServerVersion>,
pub generation: ServerGeneration,
/// Whether item queries go to `/Users/{userId}/Items` (`true`) or to
/// `/Items?userId=` (`false`).
///
/// **`true` for every generation, and deliberately so.** The whole
/// `/Users/{userId}/…` family still exists and still works in 12.0 — only
/// six routes were removed anywhere, and the only user-scoped one is
/// `POST /Users/{userId}/EasyPassword`, which this client never called.
///
/// What *did* change is policy: the family has carried `[Obsolete]` and been
/// hidden from the OpenAPI spec since 10.11.5, and 12.0 states in writing
/// that unspecified endpoints "can be removed in any major release without
/// warning". The replacements (`/Items?userId=` and friends) already exist
/// on 10.11.5, so migrating is a one-generation-compatible change whenever
/// it is wanted — which is why the route table carries both shapes even
/// though nothing selects the second one yet. See DR-282.
pub user_scoped_item_routes: bool,
/// Whether the server honours the **audio codec** in a submitted
/// `DirectPlayProfile`.
///
/// `false` on 10.11.5: it enforces the profile's container and video codec
/// but ignores its audio codec, so it offers direct play for an E-AC-3 track
/// the renderer cannot decode and the picture plays in silence. The client
/// therefore has to overrule the server's own direct-play offer. See
/// `device_profile::audio_forces_transcode` and DR-283.
///
/// **Still `false` on 12.x, and that is an admission rather than a finding.**
/// A source-level diff of 12.0 could not establish whether the underlying
/// behaviour changed; it established only that 12.0 *reports* codec
/// mismatches in `TranscodeReasons` which 10.11.5 omitted, which is not the
/// same claim. Keeping the override on costs a transcode that might not be
/// needed; turning it off on a guess costs silent playback. Flip it only
/// against a running 12.x server.
pub honours_directplay_audio_codec: bool,
/// Whether a source whose container is a *manifest* (`hls`, `applehttp`,
/// `dash`) may be direct-played. 12.0 makes such sources ineligible; on
/// 10.11.x they were eligible, which is what this client has assumed.
pub supports_manifest_container_direct_play: bool,
/// Whether asking the image endpoint for a size larger than the stored image
/// returns that size. 10.11.x upscaled; 12.0 returns the original instead.
/// Governs layout expectation only — a smaller image is never an error.
pub image_endpoint_upscales: bool,
}
impl ServerCapabilities {
/// The single place a version becomes behaviour. Nothing else in the crate
/// compares a version number.
pub fn for_version(version: Option<ServerVersion>) -> Self {
let generation = match &version {
None => ServerGeneration::Unknown,
// `major >= 12` and `major == 10` are the two live cases; 11 will
// never occur. A hypothetical 11 sorts with the older line, which is
// the conservative side.
Some(v) if v.major >= 12 => ServerGeneration::V12Plus,
Some(_) => ServerGeneration::V10_11,
};
let v12 = generation == ServerGeneration::V12Plus;
Self {
version,
generation,
// Unchanged across both generations — see each flag's docs. Note the
// two genuinely breaking changes 12.0 introduced (the auth spelling
// and the `Recursive` default) are fixed by writing the request
// correctly for *both*, so neither appears here. A flag is a silent
// branch that outlives the reason it was added; keep them for
// genuine either/or behaviour only.
user_scoped_item_routes: true,
honours_directplay_audio_codec: false,
supports_manifest_container_direct_play: !v12,
image_endpoint_upscales: !v12,
}
}
/// Resolve straight from what the server reported.
pub fn from_reported(raw_version: &str) -> Self {
Self::for_version(ServerVersion::parse(raw_version))
}
/// What this build assumes with no server to ask — the current target.
/// Used by offline paths and by tests that do not care.
pub fn assumed() -> Self {
Self::for_version(None)
}
/// Whether the server is old enough that failure is certain rather than
/// likely. An unparseable version is never below the floor: we do not refuse
/// a server on the strength of not understanding its version string.
pub fn is_below_supported_floor(&self) -> bool {
self.version.as_ref().is_some_and(|v| v.is_below_floor())
}
}
impl Default for ServerCapabilities {
fn default() -> Self {
Self::assumed()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// TRACES: UR-085 | DR-280
#[test]
fn parses_the_shapes_a_real_server_reports() {
assert_eq!(
ServerVersion::parse("10.11.5").unwrap().to_string(),
"10.11.5"
);
// Four components: Jellyfin reports these, the fourth is ignored.
assert_eq!(
ServerVersion::parse("10.11.5.0").unwrap().to_string(),
"10.11.5"
);
// A pre-release suffix must not defeat parsing.
assert_eq!(
ServerVersion::parse("10.11.5-rc1").unwrap().to_string(),
"10.11.5"
);
assert_eq!(
ServerVersion::parse("10.11.5+build7").unwrap().to_string(),
"10.11.5"
);
// Missing components default rather than failing.
assert_eq!(ServerVersion::parse("11").unwrap().to_string(), "11.0.0");
assert_eq!(
ServerVersion::parse(" 10.10 ").unwrap().to_string(),
"10.10.0"
);
}
/// Nonsense is "unknown", never a panic and never a refusal.
///
/// TRACES: UR-085 | DR-280, DR-286
#[test]
fn unparseable_versions_are_unknown_not_fatal() {
for raw in ["", " ", "not-a-version", "v", "-", "..."] {
assert!(
ServerVersion::parse(raw).is_none(),
"{raw:?} should not parse"
);
}
let caps = ServerCapabilities::from_reported("not-a-version");
assert_eq!(caps.generation, ServerGeneration::Unknown);
assert!(
!caps.is_below_supported_floor(),
"an unreadable version must not refuse a server that may work"
);
}
/// A server newer than this build keeps working. Refusing it would make
/// every release expire the moment the server upgrades.
///
/// TRACES: UR-085 | DR-286
#[test]
fn a_newer_than_known_server_resolves_forward() {
let newer = ServerCapabilities::from_reported("99.0.0");
assert_eq!(newer.generation, ServerGeneration::V12Plus);
assert!(!newer.is_below_supported_floor());
// It resolves to the newest known generation's flags; only the recorded
// version differs.
let known = ServerCapabilities::from_reported("12.0.0");
assert_eq!(
newer,
ServerCapabilities {
version: newer.version.clone(),
..known
}
);
}
/// The version scheme changed: 12.0 *is* 10.12 renamed, so 11 never occurs
/// and a parser must not assume a leading `10.`.
///
/// TRACES: UR-085 | DR-280
#[test]
fn the_two_live_generations_are_10_and_12_with_no_11() {
assert_eq!(
ServerCapabilities::from_reported("10.11.5").generation,
ServerGeneration::V10_11
);
assert_eq!(
ServerCapabilities::from_reported("12.0.0").generation,
ServerGeneration::V12Plus
);
// 11 cannot be reported by any real server; if one somehow does, it
// sorts with the older line rather than being treated as newer.
assert_eq!(
ServerCapabilities::from_reported("11.0.0").generation,
ServerGeneration::V10_11
);
}
/// The flags that genuinely differ, and only those.
///
/// TRACES: UR-085 | DR-283
#[test]
fn manifest_direct_play_and_upscaling_are_the_flags_that_differ() {
let old = ServerCapabilities::from_reported("10.11.5");
let new = ServerCapabilities::from_reported("12.0.0");
assert!(old.supports_manifest_container_direct_play);
assert!(!new.supports_manifest_container_direct_play);
assert!(old.image_endpoint_upscales);
assert!(!new.image_endpoint_upscales);
// The two breaking changes 12.0 introduced are NOT flags: they are fixed
// by writing the request correctly for both generations.
assert_eq!(old.user_scoped_item_routes, new.user_scoped_item_routes);
assert_eq!(
old.honours_directplay_audio_codec, new.honours_directplay_audio_codec,
"unestablished against a running 12.x server; must not be flipped on a guess"
);
}
/// Nothing may reintroduce an authentication spelling that 12.0 disables by
/// default. The header *value* is correct on both generations; only the
/// names were deprecated, so this is a structural guard.
///
/// TRACES: UR-085 | DR-287
#[test]
fn no_deprecated_auth_spelling_reaches_a_request_builder() {
let sources: &[(&str, &str)] = &[
("repository/online.rs", include_str!("online.rs")),
("jellyfin/client.rs", include_str!("../jellyfin/client.rs")),
(
"jellyfin/http_client.rs",
include_str!("../jellyfin/http_client.rs"),
),
("auth/mod.rs", include_str!("../auth/mod.rs")),
];
for (name, src) in sources {
assert!(
!src.contains(r#".header("X-Emby-Authorization""#),
"{name}: X-Emby-Authorization is disabled by default on Jellyfin 12.0 \
(a migration flips it on upgraded servers too). Use `Authorization` \
with the same MediaBrowser value — ungated on both generations."
);
assert!(
!src.contains(r#".header("X-Emby-Token""#)
&& !src.contains(r#".header("X-MediaBrowser-Token""#),
"{name}: token headers are gated behind EnableLegacyAuthorization on 12.0"
);
assert!(
!src.contains("api_key="),
"{name}: `api_key` as a query parameter is gated on 12.0. Use `ApiKey`, \
ungated on both and what the server itself emits."
);
}
}
/// TRACES: UR-085 | DR-286
#[test]
fn a_server_below_the_floor_is_refused() {
assert!(ServerCapabilities::from_reported("10.9.11").is_below_supported_floor());
assert!(ServerCapabilities::from_reported("9.0.0").is_below_supported_floor());
assert!(!ServerCapabilities::from_reported("10.10.0").is_below_supported_floor());
assert!(!ServerCapabilities::from_reported("10.11.5").is_below_supported_floor());
}
/// The documented 10.11.5 behaviour, pinned so that flipping it later is a
/// deliberate act with a citation rather than a drive-by edit.
///
/// TRACES: UR-085 | DR-283
#[test]
fn the_current_target_does_not_honour_directplay_audio_codec() {
let caps = ServerCapabilities::from_reported("10.11.5");
assert_eq!(caps.generation, ServerGeneration::V10_11);
assert!(
!caps.honours_directplay_audio_codec,
"10.11.5 ignores a DirectPlayProfile's audio codec; the client must overrule it"
);
}
/// No generation may quietly acquire an unverified route change.
///
/// TRACES: UR-085 | DR-282
#[test]
fn no_generation_yet_disables_user_scoped_routes() {
for raw in ["10.10.0", "10.11.5", "11.0.0", "12.0.0", "99.9.9"] {
assert!(
ServerCapabilities::from_reported(raw).user_scoped_item_routes,
"{raw}: flipping this needs a cited upstream source (DR-282), not a guess"
);
}
}
}
+16 -15
View File
@@ -195,17 +195,15 @@ pub fn subtitle_supports_external_delivery(codec: Option<&str>) -> bool {
/// the raw list makes Jellyfin direct-play a track the webview cannot decode, and
/// the user gets picture with no sound.
///
/// Which renderer gets it is not fixed: Linux is always the element, and Android
/// follows `experimentalNativeVideo`, which took ExoPlayer as its default in
/// DR-161 but is a user setting either way. So the *narrow* list is the only one
/// that holds on both sides of that switch. The cost is a Dolby-licensed Android
/// device transcoding an E-AC-3 track its ExoPlayer could have direct-played;
/// the alternative is silence for everyone the switch lands the other way, which
/// is the bug this exists to prevent.
///
/// The gap is widest on devices whose vendor licenses Dolby: a phone with
/// `c2.dolby.eac3.decoder` reports `eac3`, so it — and only it — gets a silent
/// direct play where a leaner device is transcoded to AAC and plays fine.
/// Which renderer gets it depends on the platform. Linux draws video in the
/// element (unless mpv native video is switched on), so it gets the narrow list.
/// Android draws video only in ExoPlayer: it used to follow the
/// `experimentalNativeVideo` setting, which could send video to the webview, and
/// while that switch existed the narrow list was the only one true on both sides
/// of it. DR-293 removed the webview video path on Android, so there the
/// platform list is the whole answer — it includes the FFmpeg extension's
/// AC-3/E-AC-3/DTS/TrueHD, which `CodecDetector` reports alongside the
/// `MediaCodecList` decoders.
///
/// This applies to the *video* direct-play profile only. Audio-only playback
/// really is ExoPlayer's, so its profile keeps the full platform list.
@@ -323,6 +321,9 @@ pub fn renderer_can_decode_audio(codec: &str) -> bool {
/// Whether the webview `<video>` element can decode this audio codec.
///
/// TRACES: UR-004 | DR-149 | UT-148
// Unreachable on Android since DR-293: video renders only in ExoPlayer there,
// so every caller goes through `renderer_can_decode_audio`'s device-list arm.
#[cfg_attr(target_os = "android", allow(dead_code))]
pub fn webview_can_decode_audio(codec: &str) -> bool {
WEBVIEW_AUDIO_CODECS
.iter()
@@ -434,7 +435,7 @@ mod tests {
#[test]
fn an_unconditional_burn_in_flag_is_stripped_whatever_its_casing() {
let url = without_server_chosen_subtitle(
"/videos/abc/master.m3u8?api_key=k&alwaysBurnInSubtitleWhenTranscoding=true\
"/videos/abc/master.m3u8?ApiKey=k&alwaysBurnInSubtitleWhenTranscoding=true\
&subtitlestreamindex=3&SubtitleCodec=ass",
);
@@ -442,7 +443,7 @@ mod tests {
assert!(!url.to_lowercase().contains("subtitlecodec"), "{url}");
assert!(!url.contains("subtitlestreamindex=3"), "{url}");
assert!(url.contains("SubtitleStreamIndex=-1"), "{url}");
assert!(url.contains("api_key=k"), "{url}");
assert!(url.contains("ApiKey=k"), "{url}");
}
/// A URL the server built without any subtitle in it still has to *say* so:
@@ -451,10 +452,10 @@ mod tests {
/// TRACES: UR-020, UR-004 | DR-176 | UT-168
#[test]
fn a_url_with_no_subtitle_params_is_still_made_to_ask_for_none() {
let url = without_server_chosen_subtitle("/videos/abc/master.m3u8?api_key=k");
let url = without_server_chosen_subtitle("/videos/abc/master.m3u8?ApiKey=k");
assert_eq!(
url,
"/videos/abc/master.m3u8?api_key=k&SubtitleStreamIndex=-1"
"/videos/abc/master.m3u8?ApiKey=k&SubtitleStreamIndex=-1"
);
// A bare URL is rare but must not come out malformed.
+858
View File
@@ -0,0 +1,858 @@
//! Every Jellyfin route the online repository speaks, in one place.
//!
//! Before this module the endpoints were 57 inline `format!` literals scattered
//! through `online.rs`, 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 means a conditional at every one of them.
//!
//! Each function here takes `&ServerCapabilities` and returns a **path**
//! (`/Users/…`), except the handful documented as returning an absolute URL
//! because they are handed to a media player rather than to the JSON helpers.
//!
//! # Percent-encoding
//!
//! Values are encoded, syntax is not. A genre named `Drama & Romance` or a
//! search for `a?b` must not split into another parameter. [`Endpoint::param`]
//! encodes; [`Endpoint::raw_param`] does not and is for values this module
//! itself composed (numbers, and lists whose separator is meaningful to
//! Jellyfin — `IncludeItemTypes` splits on `,`, `Genres` on `|`, so the
//! separator survives while each element is encoded).
//!
//! TRACES: UR-085 | DR-279
use super::capabilities::ServerCapabilities;
use super::types::{GetItemsOptions, SearchScope};
/// A path plus query string, which knows whether it needs `?` or `&` next.
///
/// The manual separator juggling this replaces produced the double-ampersand and
/// trailing-ampersand cases an earlier test file spent four assertions on.
/// Making it structural is cheaper than testing for it.
pub struct Endpoint {
buf: String,
has_query: bool,
}
impl Endpoint {
pub fn new(path: &str) -> Self {
// A caller may hand in a path that already carries a query.
let has_query = path.contains('?');
Self {
buf: path.to_string(),
has_query,
}
}
fn separator(&mut self) -> char {
if self.has_query {
'&'
} else {
self.has_query = true;
'?'
}
}
/// Append `key=value`, percent-encoding the value.
pub fn param(mut self, key: &str, value: &str) -> Self {
let sep = self.separator();
self.buf
.push_str(&format!("{}{}={}", sep, key, urlencoding::encode(value)));
self
}
/// Append `key=value` verbatim. Only for values this module composed.
pub fn raw_param(mut self, key: &str, value: &str) -> Self {
let sep = self.separator();
self.buf.push_str(&format!("{}{}={}", sep, key, value));
self
}
pub fn build(self) -> String {
self.buf
}
}
/// Encode each element of a list while keeping the separator Jellyfin splits on.
fn encode_list(values: impl IntoIterator<Item = impl AsRef<str>>, separator: &str) -> String {
values
.into_iter()
.map(|v| urlencoding::encode(v.as_ref()).into_owned())
.collect::<Vec<_>>()
.join(separator)
}
/// The base for a user-scoped item query.
///
/// This is the one place the two route shapes differ, and the reason the route
/// table exists at all. `user_scoped_item_routes` is `true` for every generation
/// today — see the flag's own documentation for why flipping it needs a cited
/// source rather than a guess (DR-282).
fn user_items_root(caps: &ServerCapabilities, user_id: &str) -> Endpoint {
if caps.user_scoped_item_routes {
Endpoint::new(&format!("/Users/{}/Items", user_id))
} else {
Endpoint::new("/Items").param("userId", user_id)
}
}
/// The standard field set for a list view. `People` is deliberately absent — it
/// is only wanted in the detail view, and it is not small.
const LIST_FIELDS: &str = "BackdropImageTags,ParentBackdropImageTags,UserData";
/// As [`LIST_FIELDS`], plus what the offline store needs to derive genre lists
/// and per-genre counts from cached rows.
const LIST_FIELDS_WITH_GENRES: &str =
"BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData";
// ===== Libraries and items =====
/// The user's library views.
///
/// TRACES: UR-007, UR-085 | JA-003, DR-279
pub fn user_views(_caps: &ServerCapabilities, user_id: &str) -> String {
format!("/Users/{}/Views", user_id)
}
/// One item, in detail. `People`, `MediaStreams` and `MediaSources` are named
/// here and nowhere else — the detail view is the only place they are wanted.
///
/// TRACES: UR-007, UR-085 | JA-005, DR-279
pub fn item_detail(caps: &ServerCapabilities, user_id: &str, item_id: &str) -> String {
let base = if caps.user_scoped_item_routes {
Endpoint::new(&format!(
"/Users/{}/Items/{}",
user_id,
urlencoding::encode(item_id)
))
} else {
Endpoint::new(&format!("/Items/{}", urlencoding::encode(item_id))).param("userId", user_id)
};
base.raw_param(
"Fields",
"BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData",
)
.build()
}
/// A folder listing.
///
/// Every value is percent-encoded before it goes into the query string: these
/// are values, not URL syntax, so a space or an `&` in one must not split it
/// into another parameter.
///
/// TRACES: UR-007, UR-067, UR-085 | DR-116, DR-212, DR-279 | UT-104, UT-206
pub fn get_items(
caps: &ServerCapabilities,
user_id: &str,
parent_id: &str,
options: Option<&GetItemsOptions>,
) -> String {
let mut ep = user_items_root(caps, user_id).param("ParentId", parent_id);
if let Some(opts) = options {
if let Some(limit) = opts.limit {
ep = ep.raw_param("Limit", &limit.to_string());
}
if let Some(start_index) = opts.start_index {
ep = ep.raw_param("StartIndex", &start_index.to_string());
}
if let Some(types) = &opts.include_item_types {
// The comma is the list separator Jellyfin splits on, so encode
// each type rather than the joined string.
ep = ep.raw_param("IncludeItemTypes", &encode_list(types, ","));
}
// An explicit sort always wins; the container's default only fills the
// gap when the caller named none. A caller that names neither gets no
// SortBy at all, leaving the server's own order intact.
//
// TRACES: UR-007 | DR-257 | UT-229
let default_sort = super::types::default_listing_sort(opts.parent_kind);
let sort_by = opts
.sort_by
.as_deref()
.or(default_sort.map(|(field, _)| field));
let sort_order = opts
.sort_order
.as_deref()
.or(default_sort.map(|(_, order)| order));
if let Some(sort_by) = sort_by {
// SortBy is likewise comma-delimited ("ParentIndexNumber,IndexNumber,
// SortName"), so encode per field.
ep = ep.raw_param("SortBy", &encode_list(sort_by.split(','), ","));
}
if let Some(sort_order) = sort_order {
ep = ep.param("SortOrder", sort_order);
}
// Jellyfin 12.0 defaults `recursive` to true when the parent is a
// library folder and `IncludeItemTypes` is set, where 10.11 listed only
// immediate children — the same request, a different result set. State
// it explicitly whenever a type filter is present so both generations
// agree, and state the behaviour that shipped rather than adopting the
// new server-side default silently.
//
// TRACES: UR-085 | DR-288
let type_filtered = opts
.include_item_types
.as_ref()
.is_some_and(|types| !types.is_empty());
match (opts.recursive, type_filtered) {
(Some(recursive), _) => ep = ep.raw_param("Recursive", &recursive.to_string()),
(None, true) => ep = ep.raw_param("Recursive", "false"),
(None, false) => {}
}
if let Some(genres) = &opts.genres {
if !genres.is_empty() {
// Genre names may contain spaces or ampersands; `|` is the
// separator Jellyfin splits this one on.
ep = ep.raw_param("Genres", &encode_list(genres, "|"));
}
}
// TRACES: UR-067 | DR-116 | UT-104
if opts.favorites_only == Some(true) {
ep = ep.raw_param("Filters", "IsFavorite");
}
}
ep.raw_param("Fields", LIST_FIELDS_WITH_GENRES).build()
}
/// A "recently added" listing.
///
/// `GroupItems=true` is the load-bearing parameter: Jellyfin defaults it to
/// `false`, which returns each newly-added *leaf* separately, so importing one
/// 14-track album pushed 14 rows into "recently added" and buried everything
/// else. With grouping on, the server collapses children into the container
/// that was added — an album appears once, while movies (which have no such
/// container) are unaffected.
///
/// TRACES: UR-024, UR-034, UR-085 | IR-024, JA-016, DR-279
pub fn latest_items(
caps: &ServerCapabilities,
user_id: &str,
parent_id: &str,
limit: Option<usize>,
) -> String {
let base = if caps.user_scoped_item_routes {
Endpoint::new(&format!("/Users/{}/Items/Latest", user_id))
} else {
Endpoint::new("/Items/Latest").param("userId", user_id)
};
base.param("ParentId", parent_id)
.raw_param("Limit", &limit.unwrap_or(16).to_string())
.raw_param("GroupItems", "true")
.raw_param("Fields", LIST_FIELDS)
.build()
}
/// The resume ("Continue Watching") listing.
///
/// TRACES: UR-019, UR-085 | JA-013, DR-279
pub fn resume_items(
caps: &ServerCapabilities,
user_id: &str,
limit: usize,
include_item_types: Option<&str>,
parent_id: Option<&str>,
) -> String {
let base = if caps.user_scoped_item_routes {
Endpoint::new(&format!("/Users/{}/Items/Resume", user_id))
} else {
Endpoint::new("/Items/Resume").param("userId", user_id)
};
let ep = base
.raw_param("Limit", &limit.to_string())
.raw_param("MediaTypes", "Video");
let ep = match include_item_types {
Some(types) => ep.raw_param("IncludeItemTypes", types),
None => ep,
};
let ep = ep.raw_param("Fields", LIST_FIELDS);
match parent_id {
Some(pid) => ep.param("ParentId", pid).build(),
None => ep.build(),
}
}
/// A Next Up listing.
///
/// `EnableResumable=false` is the point of this query: the server default is
/// `true`, which makes a partially-watched episode its own series' "next up" —
/// the very episode `/Items/Resume` returns — so Continue Watching and Next Up
/// end up showing the same cards. Servers predating the parameter ignore it,
/// which is why the frontend also drops in-progress entries (DR-197).
///
/// TRACES: UR-023, UR-059, UR-085 | DR-197, DR-279, JA-014, JA-036 | UT-190, UT-191
pub fn next_up(
_caps: &ServerCapabilities,
user_id: &str,
series_id: Option<&str>,
limit: Option<usize>,
) -> String {
let ep = Endpoint::new("/Shows/NextUp")
.param("UserId", user_id)
.raw_param("Limit", &limit.unwrap_or(16).to_string())
.raw_param("EnableResumable", "false")
.raw_param("Fields", LIST_FIELDS);
match series_id {
Some(sid) => ep.param("SeriesId", sid).build(),
None => ep.build(),
}
}
/// A favourites listing.
///
/// `scope` is expanded here — `SearchScope::All` yields `None`, and the
/// `IncludeItemTypes` filter is then **omitted entirely** rather than sent as a
/// union, which would silently drop every type nobody enumerated (see
/// `SearchScope::item_types`).
///
/// TRACES: UR-067, UR-085 | DR-115, DR-279, JA-033 | UT-100
pub fn favorites(
caps: &ServerCapabilities,
user_id: &str,
scope: SearchScope,
options: Option<&GetItemsOptions>,
) -> String {
let mut ep = user_items_root(caps, user_id)
.raw_param("Filters", "IsFavorite")
.raw_param("Recursive", "true");
if let Some(types) = scope.item_types() {
ep = ep.raw_param("IncludeItemTypes", &types.join(","));
}
// Jellyfin has no "date favourited", so name order is the only stable sort
// available; callers may still override it.
let sort_by = options
.and_then(|o| o.sort_by.as_deref())
.unwrap_or("SortName");
let sort_order = options
.and_then(|o| o.sort_order.as_deref())
.unwrap_or("Ascending");
ep = ep
.raw_param("SortBy", sort_by)
.raw_param("SortOrder", sort_order);
if let Some(limit) = options.and_then(|o| o.limit) {
ep = ep.raw_param("Limit", &limit.to_string());
}
if let Some(start_index) = options.and_then(|o| o.start_index) {
ep = ep.raw_param("StartIndex", &start_index.to_string());
}
ep.raw_param("Fields", LIST_FIELDS_WITH_GENRES).build()
}
/// Items sorted by when they were last played, filtered to played ones.
///
/// TRACES: UR-034, UR-085 | DR-279
pub fn played_items_by_date(
caps: &ServerCapabilities,
user_id: &str,
include_item_types: &str,
limit: usize,
sort_order: &str,
parent_id: Option<&str>,
) -> String {
let ep = user_items_root(caps, user_id)
.raw_param("SortBy", "DatePlayed")
.raw_param("SortOrder", sort_order)
.raw_param("IncludeItemTypes", include_item_types)
.raw_param("Limit", &limit.to_string())
.raw_param("Recursive", "true")
.raw_param("Filters", "IsPlayed")
.raw_param("Fields", LIST_FIELDS);
match parent_id {
Some(pid) => ep.param("ParentId", pid).build(),
None => ep.build(),
}
}
/// Genres, with the item counts the frontend uses to pick a diverse subset.
///
/// TRACES: UR-085 | DR-279
pub fn genres(
_caps: &ServerCapabilities,
user_id: &str,
include_item_types: &str,
parent_id: Option<&str>,
) -> String {
let ep = Endpoint::new("/Genres")
.param("UserId", user_id)
.raw_param("IncludeItemTypes", include_item_types)
.raw_param("Recursive", "true")
.raw_param("Fields", "ItemCounts");
match parent_id {
Some(pid) => ep.param("ParentId", pid).build(),
None => ep.build(),
}
}
/// A search.
///
/// TRACES: UR-085 | DR-279
pub fn search(
caps: &ServerCapabilities,
user_id: &str,
term: &str,
limit: usize,
include_item_types: Option<&[String]>,
) -> String {
let ep = user_items_root(caps, user_id)
.param("SearchTerm", term)
.raw_param("Limit", &limit.to_string())
.raw_param("Recursive", "true");
match include_item_types {
Some(types) if !types.is_empty() => ep
.raw_param("IncludeItemTypes", &encode_list(types, ","))
.build(),
_ => ep.build(),
}
}
/// A person's filmography.
///
/// TRACES: UR-036, UR-085 | JA-031, DR-279
pub fn items_by_person(
caps: &ServerCapabilities,
user_id: &str,
person_id: &str,
limit: usize,
include_item_types: Option<&[String]>,
) -> String {
let ep = user_items_root(caps, user_id)
.param("PersonIds", person_id)
.raw_param("Limit", &limit.to_string())
.raw_param("Recursive", "true")
.raw_param("Fields", LIST_FIELDS);
match include_item_types {
Some(types) if !types.is_empty() => ep
.raw_param("IncludeItemTypes", &encode_list(types, ","))
.build(),
_ => ep.build(),
}
}
/// A person as an item.
///
/// Jellyfin serves people through the ordinary user-item endpoint rather than
/// anything under `/Persons`; the cast entries on an item's `People` field carry
/// the ids this is called with.
///
/// TRACES: UR-035, UR-036, UR-085 | IR-022, JA-030, DR-279
pub fn person(caps: &ServerCapabilities, user_id: &str, person_id: &str) -> String {
if caps.user_scoped_item_routes {
format!(
"/Users/{}/Items/{}",
user_id,
urlencoding::encode(person_id)
)
} else {
Endpoint::new(&format!("/Items/{}", urlencoding::encode(person_id)))
.param("userId", user_id)
.build()
}
}
/// Items similar to one item.
///
/// TRACES: UR-085 | DR-279
pub fn similar_items(
_caps: &ServerCapabilities,
item_id: &str,
user_id: &str,
limit: usize,
) -> String {
Endpoint::new(&format!("/Items/{}/Similar", urlencoding::encode(item_id)))
.param("UserId", user_id)
.raw_param("Limit", &limit.to_string())
.raw_param("Fields", LIST_FIELDS)
.build()
}
// ===== User data mutations =====
/// Favourite / un-favourite an item (POST to set, DELETE to clear).
///
/// TRACES: UR-067, UR-085 | JA-033, DR-279
pub fn favorite_item(_caps: &ServerCapabilities, user_id: &str, item_id: &str) -> String {
format!(
"/Users/{}/FavoriteItems/{}",
user_id,
urlencoding::encode(item_id)
)
}
/// Mark played / clear watch history (POST to set, DELETE to clear).
///
/// TRACES: UR-025, UR-085 | JA-035, DR-279
pub fn played_item(_caps: &ServerCapabilities, user_id: &str, item_id: &str) -> String {
format!(
"/Users/{}/PlayedItems/{}",
user_id,
urlencoding::encode(item_id)
)
}
// ===== Playback =====
/// Playback negotiation for one item.
///
/// TRACES: UR-004, UR-085 | JA-021, DR-279
pub fn playback_info(_caps: &ServerCapabilities, item_id: &str) -> String {
format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id))
}
/// Playback reporting.
///
/// TRACES: UR-020, UR-085 | JA-010, JA-011, JA-012, DR-279
pub fn sessions_playing(_caps: &ServerCapabilities) -> &'static str {
"/Sessions/Playing"
}
pub fn sessions_playing_progress(_caps: &ServerCapabilities) -> &'static str {
"/Sessions/Playing/Progress"
}
pub fn sessions_playing_stopped(_caps: &ServerCapabilities) -> &'static str {
"/Sessions/Playing/Stopped"
}
/// Live TV channels.
///
/// TRACES: UR-085 | DR-279
pub fn live_tv_channels(_caps: &ServerCapabilities, user_id: &str) -> String {
Endpoint::new("/LiveTv/Channels")
.param("UserId", user_id)
.raw_param("Fields", "PrimaryImageAspectRatio,Overview")
.raw_param("EnableImageTypes", "Primary")
.build()
}
/// Generic channels.
///
/// TRACES: UR-085 | DR-279
pub fn channels(_caps: &ServerCapabilities, user_id: &str) -> String {
Endpoint::new("/Channels").param("UserId", user_id).build()
}
// ===== Playlists =====
/// TRACES: UR-062, UR-085 | DR-279
pub fn playlists(_caps: &ServerCapabilities) -> &'static str {
"/Playlists"
}
/// A playlist as an item — used for rename and delete, which are `/Items`
/// operations rather than `/Playlists` ones.
///
/// TRACES: UR-062, UR-085 | DR-279
pub fn playlist_as_item(_caps: &ServerCapabilities, playlist_id: &str) -> String {
format!("/Items/{}", urlencoding::encode(playlist_id))
}
/// TRACES: UR-062, UR-085 | DR-279
pub fn playlist_items(_caps: &ServerCapabilities, playlist_id: &str, user_id: &str) -> String {
Endpoint::new(&format!(
"/Playlists/{}/Items",
urlencoding::encode(playlist_id)
))
.param("UserId", user_id)
.raw_param(
"Fields",
"PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems",
)
.raw_param("StartIndex", "0")
.raw_param("Limit", "10000")
.build()
}
/// TRACES: UR-062, UR-085 | DR-279
pub fn playlist_items_add(_caps: &ServerCapabilities, playlist_id: &str, ids: &str) -> String {
Endpoint::new(&format!(
"/Playlists/{}/Items",
urlencoding::encode(playlist_id)
))
.param("Ids", ids)
.build()
}
/// TRACES: UR-062, UR-085 | DR-279
pub fn playlist_items_remove(
_caps: &ServerCapabilities,
playlist_id: &str,
entry_ids: &str,
) -> String {
Endpoint::new(&format!(
"/Playlists/{}/Items",
urlencoding::encode(playlist_id)
))
.param("EntryIds", entry_ids)
.build()
}
/// TRACES: UR-062, UR-085 | DR-279
pub fn playlist_item_move(
_caps: &ServerCapabilities,
playlist_id: &str,
item_id: &str,
new_index: u32,
) -> String {
format!(
"/Playlists/{}/Items/{}/Move/{}",
urlencoding::encode(playlist_id),
urlencoding::encode(item_id),
new_index
)
}
// ===== Plugin =====
/// The JRay plugin's per-item context. Not core Jellyfin; absent servers 404 and
/// the caller treats that as "no context", so it needs no capability flag.
///
/// TRACES: UR-085 | DR-279
pub fn jray_context(_caps: &ServerCapabilities, item_id: &str, position_seconds: f64) -> String {
format!(
"/Plugins/JRay/Items/{}/jray?t={}",
urlencoding::encode(item_id),
position_seconds
)
}
#[cfg(test)]
mod tests {
use super::*;
fn caps() -> ServerCapabilities {
ServerCapabilities::assumed()
}
/// The builder must never emit a double or trailing separator, and must use
/// `?` exactly once. This is structural now rather than asserted at every
/// call site.
///
/// TRACES: UR-085 | DR-279
#[test]
fn query_separators_are_structural() {
let url = Endpoint::new("/Items")
.param("a", "1")
.param("b", "2")
.raw_param("c", "3")
.build();
assert_eq!(url, "/Items?a=1&b=2&c=3");
assert_eq!(url.matches('?').count(), 1);
assert!(!url.contains("&&"));
assert!(!url.ends_with('&'));
// A path that already carries a query continues it rather than
// starting a second one.
let continued = Endpoint::new("/Items?x=0").param("y", "1").build();
assert_eq!(continued, "/Items?x=0&y=1");
assert_eq!(continued.matches('?').count(), 1);
// No parameters at all means no `?`.
assert_eq!(Endpoint::new("/Items").build(), "/Items");
}
/// Values are encoded, list separators are not.
///
/// TRACES: UR-007, UR-085 | DR-212, DR-279 | UT-206
#[test]
fn values_are_encoded_but_list_separators_survive() {
let url = Endpoint::new("/x").param("SearchTerm", "a?b&c d").build();
assert!(url.contains("SearchTerm=a%3Fb%26c%20d"), "{url}");
assert_eq!(
encode_list(["Drama & Romance", "Sci-Fi"], "|"),
"Drama%20%26%20Romance|Sci-Fi"
);
assert_eq!(encode_list(["Movie", "Series"], ","), "Movie,Series");
}
/// The user-scoped split is the reason this module exists. Both shapes must
/// be well-formed, and the default must be byte-identical to what shipped.
///
/// TRACES: UR-085 | DR-279, DR-282
#[test]
fn both_user_scoped_route_shapes_are_well_formed() {
let legacy = caps();
assert!(legacy.user_scoped_item_routes, "the shipped default");
let url = get_items(&legacy, "u1", "lib-1", None);
assert!(url.starts_with("/Users/u1/Items?ParentId=lib-1"), "{url}");
let mut modern = caps();
modern.user_scoped_item_routes = false;
let url = get_items(&modern, "u1", "lib-1", None);
assert!(url.starts_with("/Items?userId=u1&ParentId=lib-1"), "{url}");
assert_eq!(url.matches('?').count(), 1, "{url}");
assert!(!url.contains("/Users/"), "{url}");
}
/// Every route must be well-formed under *both* shapes — a flipped flag
/// must not produce a malformed URL anywhere.
///
/// TRACES: UR-085 | DR-279, DR-282
#[test]
fn no_route_is_malformed_under_either_shape() {
for user_scoped in [true, false] {
let mut c = caps();
c.user_scoped_item_routes = user_scoped;
let routes = vec![
user_views(&c, "u1"),
item_detail(&c, "u1", "i1"),
get_items(&c, "u1", "p1", None),
latest_items(&c, "u1", "p1", Some(8)),
resume_items(&c, "u1", 10, None, None),
resume_items(&c, "u1", 10, Some("Movie"), Some("lib-9")),
next_up(&c, "u1", Some("s1"), Some(5)),
favorites(&c, "u1", SearchScope::All, None),
played_items_by_date(&c, "u1", "Audio", 20, "Descending", None),
genres(&c, "u1", "MusicAlbum", Some("lib-1")),
search(&c, "u1", "query", 25, Some(&["Movie".to_string()])),
items_by_person(&c, "u1", "p9", 50, None),
person(&c, "u1", "p9"),
similar_items(&c, "i1", "u1", 12),
favorite_item(&c, "u1", "i1"),
played_item(&c, "u1", "i1"),
playback_info(&c, "i1"),
live_tv_channels(&c, "u1"),
channels(&c, "u1"),
playlist_as_item(&c, "pl1"),
playlist_items(&c, "pl1", "u1"),
playlist_items_add(&c, "pl1", "a,b"),
playlist_items_remove(&c, "pl1", "e1"),
playlist_item_move(&c, "pl1", "i1", 3u32),
jray_context(&c, "i1", 42.5),
];
for route in routes {
assert!(route.starts_with('/'), "{route}");
assert!(!route.contains("&&"), "{route}");
assert!(!route.contains("?&"), "{route}");
assert!(!route.ends_with('&'), "{route}");
assert!(!route.ends_with('?'), "{route}");
assert!(
route.matches('?').count() <= 1,
"more than one query separator: {route}"
);
}
}
}
/// TRACES: UR-024, UR-034 | IR-024, JA-016
#[test]
fn latest_items_groups_children_into_containers() {
let url = latest_items(&caps(), "u1", "lib-1", Some(16));
assert!(url.contains("GroupItems=true"), "{url}");
assert!(url.contains("ParentId=lib-1"), "{url}");
assert!(url.contains("Limit=16"), "{url}");
}
/// TRACES: UR-059 | DR-197, JA-036 | UT-190, UT-191
#[test]
fn next_up_excludes_resumable_and_scopes_to_series() {
let url = next_up(&caps(), "u1", None, Some(12));
assert!(url.contains("EnableResumable=false"), "{url}");
assert!(url.contains("UserId=u1"), "{url}");
assert!(url.contains("Limit=12"), "{url}");
assert!(!url.contains("SeriesId"), "{url}");
let scoped = next_up(&caps(), "u1", Some("series-a"), None);
assert!(scoped.contains("SeriesId=series-a"), "{scoped}");
assert!(scoped.contains("Limit=16"), "default limit: {scoped}");
}
/// `All` must omit the type filter entirely rather than send a union, which
/// would silently drop every type nobody enumerated.
///
/// TRACES: UR-067 | DR-115 | UT-100
#[test]
fn favorites_all_scope_omits_the_type_filter() {
let url = favorites(&caps(), "u1", SearchScope::All, None);
assert!(!url.contains("IncludeItemTypes"), "{url}");
assert!(url.contains("Filters=IsFavorite"), "{url}");
}
/// TRACES: UR-067 | DR-115 | UT-100
#[test]
fn favorites_honours_paging_and_sort() {
let url = favorites(
&caps(),
"u1",
SearchScope::All,
Some(&GetItemsOptions {
limit: Some(20),
start_index: Some(40),
sort_by: Some("Random".to_string()),
sort_order: Some("Descending".to_string()),
..Default::default()
}),
);
assert!(url.contains("&Limit=20"), "{url}");
assert!(url.contains("&StartIndex=40"), "{url}");
assert!(url.contains("&SortBy=Random&SortOrder=Descending"), "{url}");
}
/// The detail view is the only caller that wants People/MediaStreams; a list
/// query must not drag them along.
///
/// Jellyfin 12.0 changed `GetItems` to default `recursive` to **true** when
/// the parent is a library folder and `IncludeItemTypes` is set — so the
/// identical request returns a different result set on the two generations.
/// Sending an explicit value makes them agree, and `false` is what shipped.
///
/// Source: `ItemsController.cs` in v12.0 — `if (folder is ICollectionFolder
/// && includeItemTypes.Length > 0) { recursive ??= true; }`
///
/// TRACES: UR-085 | DR-288
#[test]
fn a_type_filtered_listing_always_states_recursive() {
let filtered = get_items(
&caps(),
"u1",
"lib-1",
Some(&GetItemsOptions {
include_item_types: Some(vec!["Movie".to_string()]),
..Default::default()
}),
);
assert!(
filtered.contains("Recursive="),
"a type-filtered listing must state Recursive or 12.0 will infer a \
different one than 10.11: {filtered}"
);
assert!(
filtered.contains("Recursive=false"),
"and it must state the behaviour that shipped: {filtered}"
);
// An explicit choice by the caller still wins.
let explicit = get_items(
&caps(),
"u1",
"lib-1",
Some(&GetItemsOptions {
include_item_types: Some(vec!["Movie".to_string()]),
recursive: Some(true),
..Default::default()
}),
);
assert!(explicit.contains("Recursive=true"), "{explicit}");
assert_eq!(explicit.matches("Recursive=").count(), 1, "{explicit}");
// No type filter, no inference to defend against, no parameter.
let plain = get_items(&caps(), "u1", "lib-1", None);
assert!(!plain.contains("Recursive="), "{plain}");
}
/// TRACES: UR-007 | DR-279
#[test]
fn only_the_detail_route_requests_the_heavy_fields() {
assert!(item_detail(&caps(), "u1", "i1").contains("People"));
assert!(!get_items(&caps(), "u1", "p1", None).contains("People"));
assert!(!latest_items(&caps(), "u1", "p1", None).contains("MediaStreams"));
}
}
@@ -0,0 +1,227 @@
//! The online repository, exercised against a real HTTP server on both Jellyfin
//! generations.
//!
//! These are the tests DR-281 exists for: every assertion here is about what the
//! client actually put on the wire, or about what it did with a response it
//! actually received. Nothing here reimplements a URL builder.
//!
//! TRACES: UR-085 | DR-281
use super::server_fixture::{target, FakeJellyfin, BOTH_GENERATIONS, V10_11, V12};
use super::types::{GetItemsOptions, SearchScope};
use super::MediaRepository;
/// Jellyfin 12.0 disables `X-Emby-Authorization` by default — including on
/// upgraded servers, via a migration that flips `EnableLegacyAuthorization` to
/// false. `Authorization` with the same `MediaBrowser` scheme is ungated on both
/// generations, so there is one correct spelling rather than a branch.
///
/// This is the assertion that would have caught the breakage: it looks at the
/// header the server received, not at a string the client built.
///
/// TRACES: UR-085 | DR-287 | IT-019
#[tokio::test]
async fn every_request_authenticates_with_the_non_deprecated_header() {
for version in BOTH_GENERATIONS {
let fake = FakeJellyfin::start(version).await;
let repo = fake.repository();
repo.get_libraries().await.expect("libraries");
let request = fake.only_request().await;
let auth = request
.headers
.get("authorization")
.unwrap_or_else(|| panic!("{version}: no Authorization header was sent"))
.to_str()
.expect("header is ascii");
assert!(
auth.starts_with("MediaBrowser "),
"{version}: Authorization must use the MediaBrowser scheme, got {auth:?}"
);
assert!(
auth.contains(r#"Token="token-abc""#),
"{version}: the token must reach the server, got {auth:?}"
);
assert!(
request.headers.get("x-emby-authorization").is_none(),
"{version}: X-Emby-Authorization is disabled by default on 12.0"
);
}
}
/// A listing must parse into domain items on both generations. `BaseItemDto` was
/// verified to be purely additive between 10.11.5 and 12.0, so one parse path is
/// correct for both — this is the test that would notice if that stopped holding.
///
/// TRACES: UR-007, UR-085 | DR-281 | IT-020
#[tokio::test]
async fn a_listing_parses_on_both_generations() {
for version in BOTH_GENERATIONS {
let fake = FakeJellyfin::start(version).await;
let result = fake
.repository()
.get_items("lib-1", None)
.await
.unwrap_or_else(|e| panic!("{version}: listing failed: {e:?}"));
assert_eq!(result.items.len(), 1, "{version}");
assert_eq!(result.items[0].id, "item-1", "{version}");
assert_eq!(result.items[0].name, "A Film", "{version}");
}
}
/// Jellyfin 12.0 defaults `recursive` to true when the parent is a library
/// folder and `IncludeItemTypes` is set, where 10.11 listed immediate children —
/// the identical request, a different result set. The client must state it, so
/// that the two generations agree.
///
/// TRACES: UR-085 | DR-288 | IT-021
#[tokio::test]
async fn a_type_filtered_listing_states_recursive_on_the_wire() {
for version in BOTH_GENERATIONS {
let fake = FakeJellyfin::start(version).await;
fake.repository()
.get_items(
"lib-1",
Some(GetItemsOptions {
include_item_types: Some(vec!["Movie".to_string()]),
..Default::default()
}),
)
.await
.expect("listing");
let sent = target(&fake.only_request().await);
assert!(
sent.contains("Recursive="),
"{version}: without an explicit Recursive the two generations disagree: {sent}"
);
}
}
/// The library listing goes to the route the capabilities selected, and comes
/// back parsed. Both generations still serve the user-scoped family — only six
/// routes were removed in 12.0 and none of them are these.
///
/// TRACES: UR-007, UR-085 | DR-282 | IT-022
#[tokio::test]
async fn libraries_resolve_on_both_generations() {
for version in BOTH_GENERATIONS {
let fake = FakeJellyfin::start(version).await;
let libraries = fake
.repository()
.get_libraries()
.await
.unwrap_or_else(|e| panic!("{version}: {e:?}"));
assert_eq!(libraries.len(), 1, "{version}");
assert_eq!(libraries[0].id, "lib-1", "{version}");
let sent = target(&fake.only_request().await);
assert!(sent.starts_with("/Users/user-1/Views"), "{version}: {sent}");
}
}
/// Flipping the user-scoped flag must actually change the wire request, and the
/// response must still parse. Nothing selects `false` today, so without this the
/// alternative route shape would be untested code waiting to be switched on.
///
/// TRACES: UR-085 | DR-282 | IT-023
#[tokio::test]
async fn the_alternative_route_shape_works_end_to_end() {
let fake = FakeJellyfin::start(V12).await;
let mut capabilities = super::capabilities::ServerCapabilities::from_reported(V12);
capabilities.user_scoped_item_routes = false;
let repo = fake.repository().with_capabilities(capabilities);
let result = repo.get_items("lib-1", None).await.expect("listing");
assert_eq!(result.items.len(), 1);
let sent = target(&fake.only_request().await);
assert!(sent.starts_with("/Items?"), "{sent}");
assert!(sent.contains("userId=user-1"), "{sent}");
assert!(!sent.contains("/Users/"), "{sent}");
}
/// Favourites carry the filter that makes them favourites, on both generations.
///
/// TRACES: UR-067, UR-085 | DR-281 | IT-024
#[tokio::test]
async fn favourites_filter_reaches_the_server() {
for version in BOTH_GENERATIONS {
let fake = FakeJellyfin::start(version).await;
fake.repository()
.get_favorites(SearchScope::All, None)
.await
.expect("favourites");
let sent = target(&fake.only_request().await);
assert!(sent.contains("Filters=IsFavorite"), "{version}: {sent}");
assert!(
!sent.contains("IncludeItemTypes"),
"{version}: All scope must omit the type filter rather than send a \
union, which would drop every type nobody enumerated: {sent}"
);
}
}
/// A stream URL is handed to mpv / ExoPlayer / an HTML5 `<video>`, none of which
/// can set a header — so its token must ride in the query string. `ApiKey` is
/// ungated on both generations and is what the server itself emits; `api_key` is
/// gated off by default on 12.0.
///
/// TRACES: UR-004, UR-085 | DR-287 | IT-025
#[tokio::test]
async fn player_facing_urls_carry_the_ungated_query_token() {
for version in BOTH_GENERATIONS {
let fake = FakeJellyfin::start(version).await;
let url = fake
.repository()
.get_audio_stream_url("track-1")
.await
.unwrap_or_else(|e| panic!("{version}: {e:?}"));
assert!(
url.contains("ApiKey=token-abc"),
"{version}: a player cannot send a header, so the token must be in \
the query — and spelled ApiKey: {url}"
);
assert!(
!url.contains("api_key="),
"{version}: api_key is disabled by default on 12.0: {url}"
);
}
}
/// The capability resolution is driven by what the server reported, not by a
/// value a test poked in — this is what makes the other tests here meaningful.
///
/// TRACES: UR-085 | DR-280 | IT-026
#[tokio::test]
async fn capabilities_come_from_the_version_the_server_reported() {
use super::capabilities::ServerGeneration;
let old = FakeJellyfin::start(V10_11).await;
assert_eq!(
old.repository().capabilities().generation,
ServerGeneration::V10_11
);
let new = FakeJellyfin::start(V12).await;
assert_eq!(
new.repository().capabilities().generation,
ServerGeneration::V12Plus
);
assert!(
!new.repository()
.capabilities()
.supports_manifest_container_direct_play,
"12.0 makes manifest-container sources ineligible for direct play"
);
}
File diff suppressed because it is too large Load Diff
+63 -21
View File
@@ -1,10 +1,16 @@
pub mod capabilities;
pub mod device_profile;
pub mod endpoints;
/// User-chosen browsing exclusions (UR-076 / DR-209).
pub mod exclusions;
#[cfg(test)]
mod generation_tests;
pub mod hybrid;
pub mod offline;
pub mod online;
pub mod series_progress;
#[cfg(test)]
pub mod server_fixture;
/// Backend-owned stream selection (UR-079 / DR-225).
pub mod stream_selection;
pub mod types;
@@ -207,7 +213,7 @@ pub trait MediaRepository: Send + Sync {
/// [`resolve_video_download_url`] rather than calling it directly.
///
/// `source_audio_codec` is the codec of the audio track the server would
/// serve (see [`served_audio_codec`]); `None` when it is not known. At
/// serve (see [`resolve_video_download`]); `None` when it is not known. At
/// `original` quality it decides whether the file can be copied byte-for-byte
/// or has to have its audio re-encoded on the way down — a downloaded file is
/// played back with no server in reach, so it has to be decodable *here*.
@@ -339,35 +345,70 @@ pub trait MediaRepository: Send + Sync {
) -> Result<(), RepoError>;
}
/// The audio codec the server would serve for `item_id` — the default track, or
/// the first when none is marked, matching the track Jellyfin picks.
/// A video download, resolved: the URL to fetch and, where the item told us
/// enough, how many bytes to expect from it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedVideoDownload {
pub url: String,
/// Predicted size (see `download::estimate`), used as the progress total
/// when the response carries no `Content-Length` — a transcode never does.
pub expected_bytes: Option<u64>,
}
/// Resolve the download URL for a video, applying the audio-codec policy that
/// keeps the saved file playable offline (DR-171), and predict its size from
/// the same item lookup (DR-290).
///
/// `None` when the item has no audio, names no codec, or cannot be fetched. A
/// caller must read that as "unknown", never as "fine": it is the input to a
/// policy that only *adds* a transcode, so an unknown codec leaves behaviour
/// exactly as it was.
/// Every video download goes through here rather than calling the builder
/// directly: the builder is pure and cannot look the codec up, and a caller that
/// forgets to is exactly how the silent downloads shipped.
///
/// TRACES: UR-071 | DR-171 | UT-166
pub async fn served_audio_codec(repo: &dyn MediaRepository, item_id: &str) -> Option<String> {
let item = repo.get_item(item_id).await.ok()?;
/// TRACES: UR-071 | DR-171, DR-290
pub async fn resolve_video_download(
repo: &dyn MediaRepository,
item_id: &str,
quality: &str,
media_source_id: Option<&str>,
) -> ResolvedVideoDownload {
let item = repo.get_item(item_id).await.ok();
let audio: Vec<(Option<&str>, bool)> = item
.media_streams
.as_deref()
.as_ref()
.and_then(|i| i.media_streams.as_deref())
.unwrap_or_default()
.iter()
.filter(|s| s.stream_type == "Audio")
.map(|s| (s.codec.as_deref(), s.is_default))
.collect();
// The default track, or the first when none is marked, matching the track
// Jellyfin picks. `None` reads as "unknown", never as "fine": it feeds a
// policy that only *adds* a transcode, so an unknown codec leaves behaviour
// exactly as it was. TRACES: UR-071 | DR-171 | UT-166
let codec = device_profile::served_audio_codec(&audio);
device_profile::served_audio_codec(&audio).map(str::to_string)
// The source that will be served: the one asked for, else the first —
// the same choice Jellyfin makes when no `mediaSourceId` is given.
let source_size = item.as_ref().and_then(|i| {
let sources = i.media_sources.as_deref()?;
let source = match media_source_id {
Some(id) => sources.iter().find(|s| s.id == id),
None => sources.first(),
};
source?.size
});
let expected_bytes = crate::download::estimate::expected_download_bytes(
quality,
item.as_ref().and_then(|i| i.runtime_ticks),
source_size,
);
ResolvedVideoDownload {
url: repo.get_video_download_url(item_id, quality, media_source_id, codec),
expected_bytes,
}
}
/// Resolve the download URL for a video, applying the audio-codec policy that
/// keeps the saved file playable offline (DR-171).
///
/// Every video download goes through here rather than calling the builder
/// directly: the builder is pure and cannot look the codec up, and a caller that
/// forgets to is exactly how the silent downloads shipped.
/// [`resolve_video_download`] for callers that only need the URL.
///
/// TRACES: UR-071 | DR-171
pub async fn resolve_video_download_url(
@@ -376,6 +417,7 @@ pub async fn resolve_video_download_url(
quality: &str,
media_source_id: Option<&str>,
) -> String {
let codec = served_audio_codec(repo, item_id).await;
repo.get_video_download_url(item_id, quality, media_source_id, codec.as_deref())
resolve_video_download(repo, item_id, quality, media_source_id)
.await
.url
}
File diff suppressed because it is too large Load Diff
+210 -371
View File
@@ -5,6 +5,8 @@ use log::{debug, error, info, warn};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, RwLock};
use super::capabilities::ServerCapabilities;
use super::endpoints;
use super::stream_selection::{
quality_options_for_source, PlaybackKind, Rendition, StreamSelection, Transport,
};
@@ -188,6 +190,12 @@ pub struct OnlineRepository {
/// This is the source of truth for the offline/online banner. `None` in
/// tests / contexts where connectivity tracking isn't wired up.
connectivity: Option<ConnectivityReporter>,
/// What this server can do, resolved once from the version it reported at
/// connect. Every route and every version-dependent decision reads a named
/// flag from here; nothing compares a version number.
///
/// TRACES: UR-085 | IR-035, DR-280
capabilities: ServerCapabilities,
}
impl OnlineRepository {
@@ -209,9 +217,34 @@ impl OnlineRepository {
user_id,
access_token,
connectivity: None,
// Assumed until the caller supplies what the server reported. The
// assumption is the current target, which is what it will be in
// nearly every case.
capabilities: ServerCapabilities::assumed(),
}
}
/// Adopt the capabilities resolved from the version the server reported at
/// connect. Without this the repository assumes the current target.
///
/// TRACES: UR-085 | IR-035, DR-280
pub fn with_capabilities(mut self, capabilities: ServerCapabilities) -> Self {
self.capabilities = capabilities;
self
}
/// What the server on the other end can do.
///
/// Test-only: production reads the flags through the route table and the
/// playback paths rather than asking the repository for them, so exposing
/// this outside tests would be an accessor nobody calls.
///
/// TRACES: UR-085 | DR-280
#[cfg(test)]
pub fn capabilities(&self) -> &ServerCapabilities {
&self.capabilities
}
/// Attach a connectivity reporter so server outcomes drive the reachability
/// state observed by the UI. See `report_outcome`.
pub fn with_connectivity(mut self, reporter: ConnectivityReporter) -> Self {
@@ -261,7 +294,7 @@ impl OnlineRepository {
.http_client
.client
.get(url)
.header("X-Emby-Authorization", self.auth_header())
.header("Authorization", self.auth_header())
.build()
.map_err(|e| format!("Failed to build request: {}", e))?;
@@ -298,11 +331,7 @@ impl OnlineRepository {
item_id: &str,
t: f64,
) -> Result<Vec<JRayActor>, RepoError> {
let endpoint = format!(
"/Plugins/JRay/Items/{}/jray?t={}",
urlencoding::encode(item_id),
t
);
let endpoint = endpoints::jray_context(&self.capabilities, item_id, t);
match self.get_json::<JRayContext>(&endpoint).await {
Ok(context) => Ok(context.actors),
// No plugin / no truth data for this item — not an error to the user.
@@ -339,7 +368,7 @@ impl OnlineRepository {
.http_client
.client
.get(&url)
.header("X-Emby-Authorization", self.auth_header())
.header("Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
@@ -414,7 +443,7 @@ impl OnlineRepository {
.client
.post(&url)
.header("Content-Type", "application/json")
.header("X-Emby-Authorization", self.auth_header())
.header("Authorization", self.auth_header())
.json(body)
.build()
.map_err(|e| RepoError::Network {
@@ -474,7 +503,7 @@ impl OnlineRepository {
.client
.post(&url)
.header("Content-Type", "application/json")
.header("X-Emby-Authorization", self.auth_header())
.header("Authorization", self.auth_header())
.json(body)
.build()
.map_err(|e| RepoError::Network {
@@ -538,7 +567,7 @@ impl OnlineRepository {
.http_client
.client
.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.header("Authorization", self.auth_header())
.send();
match request.await {
@@ -630,7 +659,7 @@ impl OnlineRepository {
// TRACES: UR-004, UR-080 | DR-234
let (renderer_video_codecs, _) = super::device_profile::renderer_codecs();
let mut params = vec![
("api_key", self.access_token.clone()),
("ApiKey", self.access_token.clone()),
("DeviceId", DEVICE_ID.to_string()),
("PlaySessionId", play_session_id),
("VideoCodec", renderer_video_codecs),
@@ -724,7 +753,7 @@ impl OnlineRepository {
) -> Result<String, RepoError> {
let mut params = vec![
("UserId", self.user_id.clone()),
("api_key", self.access_token.clone()),
("ApiKey", self.access_token.clone()),
("DeviceId", DEVICE_ID.to_string()),
// Progressive mp3 over HTTP — ExoPlayer-friendly; no HLS/ts.
("Container", "mp3".to_string()),
@@ -783,7 +812,7 @@ impl OnlineRepository {
&self,
item_id: &str,
) -> Result<(NegotiatedSource, String), RepoError> {
let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
let endpoint = endpoints::playback_info(&self.capabilities, item_id);
// What the renderer that will decode this can play. One source, shared
// with the transcode URL builder and the client-side audio override, so
@@ -1043,7 +1072,7 @@ impl OnlineRepository {
// (which is the *video* stream — the index is global across all
// streams) only misleads servers that do honour it.
let url = format!(
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId={}&api_key={}&userId={}",
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId={}&ApiKey={}&userId={}",
self.server_url,
item_id,
effective_source_id,
@@ -1191,119 +1220,26 @@ impl From<JellyfinUserData> for UserData {
}
}
/// Build the Jellyfin endpoint for a folder listing.
/// Test-only shim over [`endpoints::get_items`].
///
/// Extracted from `get_items` so the query it produces — in particular the
/// favourites filter — can be asserted without standing up an HTTP server.
///
/// TRACES: UR-007, UR-067 | DR-116 | UT-104
/// The endpoint builders moved to `endpoints.rs` under DR-279. These wrappers
/// keep the existing requirement coverage (DR-116, DR-212, DR-257 and friends)
/// pointed at the production path rather than deleting it, and pin the *default*
/// capability shape — the URLs that shipped before the route table existed.
#[cfg(test)]
fn build_get_items_endpoint(
user_id: &str,
parent_id: &str,
options: Option<&GetItemsOptions>,
) -> String {
// Every value below is percent-encoded before it goes into the query
// string, the same way `Genres` and `SearchTerm` already are: these are
// values, not URL syntax, so a space or an `&` in one must not split it
// into another parameter.
//
// TRACES: UR-007 | DR-212 | UT-206
let mut endpoint = format!(
"/Users/{}/Items?ParentId={}",
user_id,
urlencoding::encode(parent_id)
);
if let Some(opts) = options {
if let Some(limit) = opts.limit {
endpoint.push_str(&format!("&Limit={}", limit));
}
if let Some(start_index) = opts.start_index {
endpoint.push_str(&format!("&StartIndex={}", start_index));
}
if let Some(types) = &opts.include_item_types {
// Encode each type, not the joined string: the comma is the
// list separator Jellyfin splits on.
let encoded: Vec<String> = types
.iter()
.map(|t| urlencoding::encode(t).into_owned())
.collect();
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded.join(",")));
}
// An explicit sort always wins; the container's default only fills the
// gap when the caller named none. A caller that names neither gets no
// SortBy at all, leaving the server's own order intact.
//
// TRACES: UR-007 | DR-257 | UT-229
let default_sort = default_listing_sort(opts.parent_kind);
let sort_by = opts
.sort_by
.as_deref()
.or(default_sort.map(|(field, _)| field));
let sort_order = opts
.sort_order
.as_deref()
.or(default_sort.map(|(_, order)| order));
if let Some(sort_by) = sort_by {
// SortBy is likewise a comma-delimited list (`hybrid.rs` sends
// "ParentIndexNumber,IndexNumber,SortName"), so encode per field.
let encoded: Vec<String> = sort_by
.split(',')
.map(|field| urlencoding::encode(field).into_owned())
.collect();
endpoint.push_str(&format!("&SortBy={}", encoded.join(",")));
}
if let Some(sort_order) = sort_order {
endpoint.push_str(&format!("&SortOrder={}", urlencoding::encode(sort_order)));
}
if let Some(recursive) = opts.recursive {
endpoint.push_str(&format!("&Recursive={}", recursive));
}
if let Some(genres) = &opts.genres {
if !genres.is_empty() {
// Genre names may contain spaces/ampersands, so percent-encode each.
let encoded: Vec<String> = genres
.iter()
.map(|g| urlencoding::encode(g).into_owned())
.collect();
endpoint.push_str(&format!("&Genres={}", encoded.join("|")));
}
}
// TRACES: UR-067 | DR-116 | UT-104
if opts.favorites_only == Some(true) {
endpoint.push_str("&Filters=IsFavorite");
}
}
// Request image fields for list views (People only needed in get_item
// detail view). Genres is needed so cached items carry their genres,
// which lets the offline store derive genre lists + per-genre counts.
endpoint
.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
endpoint
endpoints::get_items(&ServerCapabilities::assumed(), user_id, parent_id, options)
}
/// Build the Jellyfin endpoint for a "recently added" listing.
///
/// `GroupItems=true` is the load-bearing parameter: Jellyfin defaults it to
/// `false`, which returns each newly-added *leaf* separately, so importing one
/// 14-track album pushed 14 rows into "recently added" and buried everything
/// else. With grouping on, the server collapses children into the container
/// that was added — an album appears once, while movies (which have no such
/// container) are unaffected.
///
/// Pulled out of `get_latest_items` so the query can be asserted without an
/// HTTP server, matching `build_favorites_endpoint`.
///
/// TRACES: UR-024, UR-034 | IR-024, JA-016
/// Test-only shim over [`endpoints::latest_items`]. See
/// [`build_get_items_endpoint`].
#[cfg(test)]
fn build_latest_items_endpoint(user_id: &str, parent_id: &str, limit: Option<usize>) -> String {
format!(
"/Users/{}/Items/Latest?ParentId={}&Limit={}&GroupItems=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
user_id,
parent_id,
limit.unwrap_or(16)
)
endpoints::latest_items(&ServerCapabilities::assumed(), user_id, parent_id, limit)
}
/// How many rows to ask the server for, given how many the row will show.
@@ -1417,73 +1353,21 @@ fn album_from_track(track: &MediaItem, album_id: String) -> MediaItem {
}
}
/// Build the Jellyfin endpoint for a Next Up listing.
///
/// `EnableResumable=false` is the point of this query: the server default is
/// `true`, which makes a partially-watched episode its own series' "next up" —
/// the very episode `/Items/Resume` returns — so Continue Watching and Next Up
/// end up showing the same cards. Next Up should only ever offer episodes the
/// viewer has not started. Servers predating the parameter ignore it, which is
/// why the frontend also drops in-progress entries (DR-197).
///
/// Pulled out of `get_next_up_episodes` so the query can be asserted without an
/// HTTP server, matching `build_favorites_endpoint`.
///
/// TRACES: UR-023, UR-059 | DR-197, JA-014, JA-036 | UT-190, UT-191
/// Test-only shim over [`endpoints::next_up`]. See [`build_get_items_endpoint`].
#[cfg(test)]
fn build_next_up_endpoint(user_id: &str, series_id: Option<&str>, limit: Option<usize>) -> String {
let mut endpoint = format!(
"/Shows/NextUp?UserId={}&Limit={}&EnableResumable=false&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
user_id,
limit.unwrap_or(16)
);
if let Some(sid) = series_id {
endpoint.push_str(&format!("&SeriesId={}", sid));
}
endpoint
endpoints::next_up(&ServerCapabilities::assumed(), user_id, series_id, limit)
}
/// Build the Jellyfin endpoint for a favourites listing.
///
/// Pulled out of `get_favorites` so the query can be asserted without an HTTP
/// server. `scope` is expanded here — `SearchScope::All` yields `None`, and the
/// `IncludeItemTypes` filter is then **omitted entirely** rather than sent as a
/// union, which would silently drop every type nobody enumerated (see
/// `SearchScope::item_types`).
///
/// TRACES: UR-067 | DR-115, JA-033 | UT-100
/// Test-only shim over [`endpoints::favorites`]. See
/// [`build_get_items_endpoint`].
#[cfg(test)]
fn build_favorites_endpoint(
user_id: &str,
scope: SearchScope,
options: Option<&GetItemsOptions>,
) -> String {
let mut endpoint = format!("/Users/{}/Items?Filters=IsFavorite&Recursive=true", user_id);
if let Some(types) = scope.item_types() {
endpoint.push_str(&format!("&IncludeItemTypes={}", types.join(",")));
}
// Jellyfin has no "date favourited", so name order is the only stable sort
// available; callers may still override it.
let sort_by = options
.and_then(|o| o.sort_by.as_deref())
.unwrap_or("SortName");
let sort_order = options
.and_then(|o| o.sort_order.as_deref())
.unwrap_or("Ascending");
endpoint.push_str(&format!("&SortBy={}&SortOrder={}", sort_by, sort_order));
if let Some(limit) = options.and_then(|o| o.limit) {
endpoint.push_str(&format!("&Limit={}", limit));
}
if let Some(start_index) = options.and_then(|o| o.start_index) {
endpoint.push_str(&format!("&StartIndex={}", start_index));
}
endpoint
.push_str("&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData");
endpoint
endpoints::favorites(&ServerCapabilities::assumed(), user_id, scope, options)
}
// ImageTags from Jellyfin API - can be a HashMap with various image type keys
@@ -1810,7 +1694,7 @@ impl MediaRepository for OnlineRepository {
image_tags: Option<ImageTags>,
}
let endpoint = format!("/Users/{}/Views", self.user_id);
let endpoint = endpoints::user_views(&self.capabilities, &self.user_id);
let response: LibrariesResponse = self.get_json(&endpoint).await?;
Ok(response
@@ -1832,7 +1716,12 @@ impl MediaRepository for OnlineRepository {
parent_id: &str,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError> {
let endpoint = build_get_items_endpoint(&self.user_id, parent_id, options.as_ref());
let endpoint = endpoints::get_items(
&self.capabilities,
&self.user_id,
parent_id,
options.as_ref(),
);
let response: ItemsResponse = self.get_json(&endpoint).await?;
@@ -1858,7 +1747,7 @@ impl MediaRepository for OnlineRepository {
///
/// TRACES: UR-021, UR-035 | IR-016, IR-022, JA-005, JA-009
async fn get_item(&self, item_id: &str) -> Result<MediaItem, RepoError> {
let endpoint = format!("/Users/{}/Items/{}?Fields=BackdropImageTags,ParentBackdropImageTags,People,MediaStreams,MediaSources,PremiereDate,UserData", self.user_id, urlencoding::encode(item_id));
let endpoint = endpoints::item_detail(&self.capabilities, &self.user_id, item_id);
let item: JellyfinItem = self.get_json(&endpoint).await?;
let media_item = item.into_media_item(self.user_id.clone());
@@ -1879,7 +1768,8 @@ impl MediaRepository for OnlineRepository {
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_val = limit.unwrap_or(16);
let endpoint = build_latest_items_endpoint(
let endpoint = endpoints::latest_items(
&self.capabilities,
&self.user_id,
parent_id,
Some(latest_items_fetch_limit(limit_val)),
@@ -1909,16 +1799,14 @@ impl MediaRepository for OnlineRepository {
parent_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let mut endpoint = format!(
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
self.user_id, limit_str
let endpoint = endpoints::resume_items(
&self.capabilities,
&self.user_id,
limit.unwrap_or(16),
None,
parent_id,
);
if let Some(pid) = parent_id {
endpoint.push_str(&format!("&ParentId={}", pid));
}
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
@@ -1936,7 +1824,7 @@ impl MediaRepository for OnlineRepository {
series_id: Option<&str>,
limit: Option<usize>,
) -> Result<Vec<MediaItem>, RepoError> {
let endpoint = build_next_up_endpoint(&self.user_id, series_id, limit);
let endpoint = endpoints::next_up(&self.capabilities, &self.user_id, series_id, limit);
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
@@ -1953,9 +1841,13 @@ impl MediaRepository for OnlineRepository {
let limit_val = limit.unwrap_or(12);
// Fetch more items to account for grouping reducing the count
let fetch_limit = limit_val * 3;
let endpoint = format!(
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Descending&IncludeItemTypes=Audio&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
self.user_id, fetch_limit
let endpoint = endpoints::played_items_by_date(
&self.capabilities,
&self.user_id,
"Audio",
fetch_limit,
"Descending",
None,
);
let response: ItemsResponse = self.get_json(&endpoint).await?;
@@ -2087,15 +1979,15 @@ impl MediaRepository for OnlineRepository {
// Ask Jellyfin for played albums sorted by least-recently played first.
// Filters=IsPlayed keeps only albums the user has actually listened to,
// and SortBy=DatePlayed ascending surfaces the ones they've neglected.
let mut endpoint = format!(
"/Users/{}/Items?SortBy=DatePlayed&SortOrder=Ascending&IncludeItemTypes=MusicAlbum&Limit={}&Recursive=true&Filters=IsPlayed&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
self.user_id, limit_val
let endpoint = endpoints::played_items_by_date(
&self.capabilities,
&self.user_id,
"MusicAlbum",
limit_val,
"Ascending",
parent_id,
);
if let Some(pid) = parent_id {
endpoint.push_str(&format!("&ParentId={}", pid));
}
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
@@ -2110,10 +2002,12 @@ impl MediaRepository for OnlineRepository {
///
/// TRACES: UR-019, UR-034 | IR-024, JA-013, JA-015
async fn get_resume_movies(&self, limit: Option<usize>) -> Result<Vec<MediaItem>, RepoError> {
let limit_str = limit.unwrap_or(16);
let endpoint = format!(
"/Users/{}/Items/Resume?Limit={}&MediaTypes=Video&IncludeItemTypes=Movie&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
self.user_id, limit_str
let endpoint = endpoints::resume_items(
&self.capabilities,
&self.user_id,
limit.unwrap_or(16),
Some("Movie"),
None,
);
let response: ItemsResponse = self.get_json(&endpoint).await?;
@@ -2127,14 +2021,8 @@ impl MediaRepository for OnlineRepository {
async fn get_genres(&self, parent_id: Option<&str>) -> Result<Vec<Genre>, RepoError> {
// Ask Jellyfin to scope counts to albums and include them, so the
// frontend can rank genres by popularity without probing each one.
let mut endpoint = format!(
"/Genres?UserId={}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ItemCounts",
self.user_id
);
if let Some(pid) = parent_id {
endpoint.push_str(&format!("&ParentId={}", pid));
}
let endpoint =
endpoints::genres(&self.capabilities, &self.user_id, "MusicAlbum", parent_id);
#[derive(Debug, Deserialize)]
#[serde(rename_all = "PascalCase")]
@@ -2201,28 +2089,12 @@ impl MediaRepository for OnlineRepository {
// SearchTerm is arbitrary user input and must be percent-encoded so that
// spaces, ampersands, etc. don't corrupt the query string (a multi-word
// search like "Star Wars" would otherwise produce a malformed URL).
let mut endpoint = format!(
"/Users/{}/Items?SearchTerm={}&Limit={}&Recursive=true",
self.user_id,
urlencoding::encode(query),
limit
);
if let Some(opts) = options {
if let Some(types) = opts.include_item_types {
let encoded_types = types
.iter()
.map(|t| urlencoding::encode(t).into_owned())
.collect::<Vec<_>>()
.join(",");
endpoint.push_str(&format!("&IncludeItemTypes={}", encoded_types));
}
}
// Request image fields for list views (plus Genres so cached items
// carry genres for offline genre lists/counts).
endpoint.push_str(
"&Fields=BackdropImageTags,ParentBackdropImageTags,Genres,PremiereDate,UserData",
let endpoint = endpoints::search(
&self.capabilities,
&self.user_id,
query,
limit,
options.and_then(|o| o.include_item_types).as_deref(),
);
let response: ItemsResponse = self.get_json(&endpoint).await?;
@@ -2311,7 +2183,7 @@ impl MediaRepository for OnlineRepository {
// serves the original file untouched, and pinning index 0 (the video
// stream) only misleads servers that do honour it.
format!(
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&api_key={}&userId={}",
"{}/Videos/{}/stream?static=true&container=mp4&mediaSourceId={}&deviceId=jellytau&ApiKey={}&userId={}",
self.server_url,
item_id,
source.id,
@@ -2335,7 +2207,7 @@ impl MediaRepository for OnlineRepository {
async fn get_audio_stream_url(&self, item_id: &str) -> Result<String, RepoError> {
// Construct direct audio stream URL
let url = format!(
"{}/Audio/{}/stream?UserId={}&api_key={}&Static=true",
"{}/Audio/{}/stream?UserId={}&ApiKey={}&Static=true",
self.server_url, item_id, self.user_id, self.access_token
);
Ok(url)
@@ -2360,10 +2232,7 @@ impl MediaRepository for OnlineRepository {
async fn get_live_tv_channels(&self) -> Result<Vec<MediaItem>, RepoError> {
// Live TV channels (broadcast tuners / IPTV M3U). Returned as items with
// type "TvChannel" — playable via open_live_stream.
let endpoint = format!(
"/LiveTv/Channels?UserId={}&Fields=PrimaryImageAspectRatio,Overview&EnableImageTypes=Primary",
self.user_id
);
let endpoint = endpoints::live_tv_channels(&self.capabilities, &self.user_id);
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(response
.items
@@ -2375,7 +2244,7 @@ impl MediaRepository for OnlineRepository {
async fn get_channels(&self) -> Result<SearchResult, RepoError> {
// Root list of plugin "Channels". Drill-down into a channel folder reuses
// get_items(channel_id, ...).
let endpoint = format!("/Channels?UserId={}", self.user_id);
let endpoint = endpoints::channels(&self.capabilities, &self.user_id);
let response: ItemsResponse = self.get_json(&endpoint).await?;
let total = response.total_record_count;
let items = response
@@ -2426,7 +2295,7 @@ impl MediaRepository for OnlineRepository {
live_stream_id: Option<String>,
}
let endpoint = format!("/Items/{}/PlaybackInfo", urlencoding::encode(item_id));
let endpoint = endpoints::playback_info(&self.capabilities, item_id);
let request = OpenLiveStreamRequest {
user_id: self.user_id.clone(),
auto_open_live_stream: true,
@@ -2461,7 +2330,7 @@ impl MediaRepository for OnlineRepository {
super::device_profile::without_server_chosen_subtitle(&url)
),
None => format!(
"{}/Videos/{}/master.m3u8?api_key={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts&SubtitleStreamIndex={}",
"{}/Videos/{}/master.m3u8?ApiKey={}&MediaSourceId={}&LiveStreamId={}&VideoCodec=h264&AudioCodec=aac&TranscodingProtocol=hls&TranscodingContainer=ts&SubtitleStreamIndex={}",
self.server_url,
item_id,
self.access_token,
@@ -2503,7 +2372,8 @@ impl MediaRepository for OnlineRepository {
is_paused: false,
};
self.post_json("/Sessions/Playing", &request).await
self.post_json(endpoints::sessions_playing(&self.capabilities), &request)
.await
}
async fn report_playback_progress(
@@ -2525,7 +2395,11 @@ impl MediaRepository for OnlineRepository {
is_paused: false,
};
self.post_json("/Sessions/Playing/Progress", &request).await
self.post_json(
endpoints::sessions_playing_progress(&self.capabilities),
&request,
)
.await
}
async fn report_playback_stopped(
@@ -2545,7 +2419,11 @@ impl MediaRepository for OnlineRepository {
position_ticks,
};
self.post_json("/Sessions/Playing/Stopped", &request).await
self.post_json(
endpoints::sessions_playing_stopped(&self.capabilities),
&request,
)
.await
}
fn get_image_url(
@@ -2561,9 +2439,10 @@ impl MediaRepository for OnlineRepository {
image_type.as_str()
);
// Authentication is handled by X-Emby-Authorization header in download_bytes()
// Do NOT include api_key here — some Jellyfin servers reject requests when
// api_key is present but the token doesn't match the expected format.
// Authentication is handled by the `Authorization` header in
// download_bytes(). Do NOT add a query-parameter token here — some
// Jellyfin servers reject requests carrying one whose format they do not
// expect, and this request can already authenticate by header.
let mut params: Vec<String> = Vec::new();
if let Some(opts) = options {
@@ -2623,7 +2502,7 @@ impl MediaRepository for OnlineRepository {
// instead — it is always present and supports HTTP Range, which the
// download worker relies on for resume.
let mut url = format!("{}/Videos/{}/stream.mp4", self.server_url, item_id);
let mut params = vec![format!("api_key={}", self.access_token)];
let mut params = vec![format!("ApiKey={}", self.access_token)];
// Map the frontend quality preset to concrete transcode params. For
// "original" we request a direct static copy (no transcode) which is
@@ -2641,54 +2520,42 @@ impl MediaRepository for OnlineRepository {
// fine in itself, but it also means a mis-typed cap degrades silently.
// Note `enableAutoStreamCopy=false` alone does NOT stop a *video* copy;
// video copy is gated by `allowVideoStreamCopy`.
match quality {
"high" => {
params.push("videoBitRate=8000000".to_string());
params.push("maxHeight=1080".to_string());
params.push("audioBitRate=384000".to_string());
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
params.push("allowVideoStreamCopy=false".to_string());
}
"medium" => {
params.push("videoBitRate=4000000".to_string());
params.push("maxHeight=720".to_string());
params.push("audioBitRate=256000".to_string());
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
params.push("allowVideoStreamCopy=false".to_string());
}
"low" => {
params.push("videoBitRate=1500000".to_string());
params.push("maxHeight=480".to_string());
params.push("audioBitRate=128000".to_string());
match crate::download::presets::download_preset(quality) {
Some(preset) => {
params.push(format!("videoBitRate={}", preset.video_bit_rate));
params.push(format!("maxHeight={}", preset.max_height));
params.push(format!("audioBitRate={}", preset.audio_bit_rate));
params.push("videoCodec=h264".to_string());
params.push("audioCodec=aac".to_string());
params.push("allowVideoStreamCopy=false".to_string());
}
// "original" (and any unknown value) → direct, resumable copy —
// unless the audio in that copy is undecodable where the file will
// be played back. A download is watched with no server in reach, so
// it has to satisfy the same constraint DR-149 applies to streams:
// the webview `<video>` element renders video on both platforms and
// decodes none of AC-3/E-AC-3/DTS/TrueHD. Copying those bytes to
// disk is what made a downloaded film play offline as picture with
// no sound while the same film had sound when streamed.
// unless the audio in that copy is undecodable by the renderer that
// will play the file. A download is watched with no server in reach,
// so there is nothing to fall back to: copying a track the renderer
// cannot decode is what made a downloaded film play offline as
// picture with no sound while the same film had sound when streamed
// (DR-171).
//
// Only the *audio* is re-encoded. `allowVideoStreamCopy` keeps an
// h264 source's picture byte-for-byte, so "original" still means
// original quality, and no bitrate or resolution cap is added. A
// source the webview could not have rendered anyway (HEVC) is
// re-encoded to h264 as a side effect, which is the only form of it
// that would have played.
// "The renderer" is DR-234's per-platform answer, not the webview's
// list. On Android that is ExoPlayer — the only video renderer there
// since DR-293 removed the webview path — which decodes the device's
// own codecs plus AC-3/E-AC-3/DTS/TrueHD through the FFmpeg
// extension. So on Android every `original` download is a
// `Static=true` copy: fast, resumable (HTTP 206), and the real file.
// Judging against the webview's list instead turned most films into a
// server transcode — generated as it is sent, no `Content-Length`,
// `Range` ignored — measured at ~1 MB/s against 14.5 MB/s for the
// copy, and restarting from zero on every network blip.
//
// The cost of the transcode is that the response is no longer
// range-resumable, which is exactly why this is decided per item
// rather than applied to every `original` download.
// On Linux the webview still draws video, so the renderer's list *is*
// the webview's and the transcode below still applies there. Only the
// *audio* is re-encoded: `allowVideoStreamCopy` keeps an h264 source's
// picture byte-for-byte, so "original" still means original quality.
//
// TRACES: UR-071, UR-004 | DR-171 | UT-166
_ => match source_audio_codec {
Some(codec) if !super::device_profile::webview_can_decode_audio(codec) => {
// TRACES: UR-071, UR-004 | DR-171, DR-293 | UT-166
None => match source_audio_codec {
Some(codec) if !super::device_profile::renderer_can_decode_audio(codec) => {
params.push("videoCodec=h264".to_string());
params.push("allowVideoStreamCopy=true".to_string());
params.push("audioCodec=aac".to_string());
@@ -2713,11 +2580,7 @@ impl MediaRepository for OnlineRepository {
}
async fn mark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
let endpoint = format!(
"/Users/{}/FavoriteItems/{}",
self.user_id,
urlencoding::encode(item_id)
);
let endpoint = endpoints::favorite_item(&self.capabilities, &self.user_id, item_id);
self.post_json(&endpoint, &serde_json::json!({})).await
}
@@ -2727,7 +2590,8 @@ impl MediaRepository for OnlineRepository {
scope: SearchScope,
options: Option<GetItemsOptions>,
) -> Result<SearchResult, RepoError> {
let endpoint = build_favorites_endpoint(&self.user_id, scope, options.as_ref());
let endpoint =
endpoints::favorites(&self.capabilities, &self.user_id, scope, options.as_ref());
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(SearchResult {
@@ -2747,11 +2611,7 @@ impl MediaRepository for OnlineRepository {
///
/// TRACES: UR-017 | JA-018, DR-021
async fn unmark_favorite(&self, item_id: &str) -> Result<(), RepoError> {
let endpoint = format!(
"/Users/{}/FavoriteItems/{}",
self.user_id,
urlencoding::encode(item_id)
);
let endpoint = endpoints::favorite_item(&self.capabilities, &self.user_id, item_id);
let url = format!("{}{}", self.server_url, endpoint);
let result = async {
@@ -2759,7 +2619,7 @@ impl MediaRepository for OnlineRepository {
.http_client
.client
.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.header("Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
@@ -2793,11 +2653,7 @@ impl MediaRepository for OnlineRepository {
///
/// TRACES: UR-064 | DR-106, JA-033
async fn clear_watch_history(&self, item_id: &str) -> Result<(), RepoError> {
let endpoint = format!(
"/Users/{}/PlayedItems/{}",
self.user_id,
urlencoding::encode(item_id)
);
let endpoint = endpoints::played_item(&self.capabilities, &self.user_id, item_id);
let url = format!("{}{}", self.server_url, endpoint);
let result = async {
@@ -2805,7 +2661,7 @@ impl MediaRepository for OnlineRepository {
.http_client
.client
.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.header("Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
@@ -2838,11 +2694,7 @@ impl MediaRepository for OnlineRepository {
///
/// TRACES: UR-025 | DR-131 | JA-035
async fn mark_played(&self, item_id: &str) -> Result<(), RepoError> {
let endpoint = format!(
"/Users/{}/PlayedItems/{}",
self.user_id,
urlencoding::encode(item_id)
);
let endpoint = endpoints::played_item(&self.capabilities, &self.user_id, item_id);
let url = format!("{}{}", self.server_url, endpoint);
let result = async {
@@ -2850,7 +2702,7 @@ impl MediaRepository for OnlineRepository {
.http_client
.client
.post(&url)
.header("X-Emby-Authorization", self.auth_header())
.header("Authorization", self.auth_header())
.header("Content-Length", "0")
.build()
.map_err(|e| RepoError::Network {
@@ -2887,11 +2739,7 @@ impl MediaRepository for OnlineRepository {
///
/// TRACES: UR-035, UR-036 | IR-022, JA-030
async fn get_person(&self, person_id: &str) -> Result<MediaItem, RepoError> {
let endpoint = format!(
"/Users/{}/Items/{}",
self.user_id,
urlencoding::encode(person_id)
);
let endpoint = endpoints::person(&self.capabilities, &self.user_id, person_id);
let item: JellyfinItem = self.get_json(&endpoint).await?;
Ok(item.into_media_item(self.user_id.clone()))
}
@@ -2906,21 +2754,16 @@ impl MediaRepository for OnlineRepository {
) -> Result<SearchResult, RepoError> {
let limit = options.as_ref().and_then(|o| o.limit).unwrap_or(100);
let mut endpoint = format!(
"/Users/{}/Items?PersonIds={}&Limit={}&Recursive=true&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
self.user_id, person_id, limit
let endpoint = endpoints::items_by_person(
&self.capabilities,
&self.user_id,
person_id,
limit,
options
.as_ref()
.and_then(|o| o.include_item_types.as_deref()),
);
// Add item type filtering if specified in options
if let Some(ref opts) = options {
if let Some(ref include_types) = opts.include_item_types {
if !include_types.is_empty() {
let types_param = include_types.join(",");
endpoint.push_str(&format!("&IncludeItemTypes={}", types_param));
}
}
}
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(SearchResult {
items: response
@@ -2940,10 +2783,8 @@ impl MediaRepository for OnlineRepository {
let limit_str = limit.unwrap_or(20);
// Try the /Similar endpoint which works for most items
let endpoint = format!(
"/Items/{}/Similar?UserId={}&Limit={}&Fields=BackdropImageTags,ParentBackdropImageTags,UserData",
item_id, self.user_id, limit_str
);
let endpoint =
endpoints::similar_items(&self.capabilities, item_id, &self.user_id, limit_str);
let response: ItemsResponse = self.get_json(&endpoint).await?;
Ok(SearchResult {
@@ -2974,20 +2815,22 @@ impl MediaRepository for OnlineRepository {
"MediaType": "Audio",
"UserId": self.user_id,
});
let response: CreatePlaylistResponse = self.post_json_response("/Playlists", &body).await?;
let response: CreatePlaylistResponse = self
.post_json_response(endpoints::playlists(&self.capabilities), &body)
.await?;
Ok(PlaylistCreatedResult { id: response.id })
}
async fn delete_playlist(&self, playlist_id: &str) -> Result<(), RepoError> {
info!("[OnlineRepo] Deleting playlist {}", playlist_id);
let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
let endpoint = endpoints::playlist_as_item(&self.capabilities, playlist_id);
let url = format!("{}{}", self.server_url, endpoint);
let request = self
.http_client
.client
.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.header("Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
@@ -3015,16 +2858,13 @@ impl MediaRepository for OnlineRepository {
"[OnlineRepo] Renaming playlist {} to '{}'",
playlist_id, name
);
let endpoint = format!("/Items/{}", urlencoding::encode(playlist_id));
let endpoint = endpoints::playlist_as_item(&self.capabilities, playlist_id);
self.post_json(&endpoint, &serde_json::json!({ "Name": name }))
.await
}
async fn get_playlist_items(&self, playlist_id: &str) -> Result<Vec<PlaylistEntry>, RepoError> {
let endpoint = format!(
"/Playlists/{}/Items?UserId={}&Fields=PrimaryImageTag,Artists,AlbumId,Album,AlbumArtist,RunTimeTicks,ArtistItems&StartIndex=0&Limit=10000",
playlist_id, self.user_id
);
let endpoint = endpoints::playlist_items(&self.capabilities, playlist_id, &self.user_id);
let response: PlaylistItemsResponse = self.get_json(&endpoint).await?;
debug!(
@@ -3059,11 +2899,7 @@ impl MediaRepository for OnlineRepository {
.map(|id| urlencoding::encode(id).into_owned())
.collect::<Vec<_>>()
.join(",");
let endpoint = format!(
"/Playlists/{}/Items?Ids={}",
urlencoding::encode(playlist_id),
ids_param
);
let endpoint = endpoints::playlist_items_add(&self.capabilities, playlist_id, &ids_param);
self.post_json(&endpoint, &serde_json::json!({})).await
}
@@ -3082,18 +2918,15 @@ impl MediaRepository for OnlineRepository {
.map(|id| urlencoding::encode(id).into_owned())
.collect::<Vec<_>>()
.join(",");
let endpoint = format!(
"/Playlists/{}/Items?EntryIds={}",
urlencoding::encode(playlist_id),
ids_param
);
let endpoint =
endpoints::playlist_items_remove(&self.capabilities, playlist_id, &ids_param);
let url = format!("{}{}", self.server_url, endpoint);
let request = self
.http_client
.client
.delete(&url)
.header("X-Emby-Authorization", self.auth_header())
.header("Authorization", self.auth_header())
.build()
.map_err(|e| RepoError::Network {
message: format!("Failed to build request: {}", e),
@@ -3126,10 +2959,8 @@ impl MediaRepository for OnlineRepository {
"[OnlineRepo] Moving item {} in playlist {} to index {}",
item_id, playlist_id, new_index
);
let endpoint = format!(
"/Playlists/{}/Items/{}/Move/{}",
playlist_id, item_id, new_index
);
let endpoint =
endpoints::playlist_item_move(&self.capabilities, playlist_id, item_id, new_index);
self.post_json(&endpoint, &serde_json::json!({})).await
}
}
@@ -3310,7 +3141,7 @@ mod tests {
let url = result.unwrap();
assert_eq!(
url,
"https://test.server.com/Audio/test-track-123/stream?UserId=test-user-id&api_key=test-access-token&Static=true"
"https://test.server.com/Audio/test-track-123/stream?UserId=test-user-id&ApiKey=test-access-token&Static=true"
);
}
@@ -3765,10 +3596,14 @@ mod tests {
// ===== Video download URL (real impl) =====
//
// These exercise the PRODUCTION `OnlineRepository::get_video_download_url`,
// not a mock. A prior mock in online_integration_test.rs used the correct
// `stream.mp4` endpoint while the real impl shipped `/Videos/{id}/download`,
// which returns 404 on real servers and silently broke every movie/TV
// download. Assert the real builder targets the resumable stream endpoint.
// not a mock. A prior mock used the correct `stream.mp4` endpoint while the
// real impl shipped `/Videos/{id}/download`, which returns 404 on real
// servers and silently broke every movie/TV download. That mock lived in
// `online_integration_test.rs`, which was never declared as a module and so
// never compiled — it was deleted for that reason, and this is the lesson it
// left: a mock that reimplements the builder asserts on itself, and passes
// just as happily when production is wrong. Assert the real builder targets
// the resumable stream endpoint.
//
// @req-test: DR-013 - Repository pattern for online/offline data access
@@ -3787,7 +3622,7 @@ mod tests {
url.contains("/Videos/item123/stream.mp4"),
"download URL must target /Videos/{{id}}/stream.mp4: {url}"
);
assert!(url.contains("api_key=test-access-token"), "url: {url}");
assert!(url.contains("ApiKey=test-access-token"), "url: {url}");
}
#[test]
@@ -3900,13 +3735,17 @@ mod tests {
/// holds audio this device cannot decode.
///
/// `Static=true` hands back the source bytes untouched, E-AC-3/AC-3/DTS
/// track included, and video plays through the webview `<video>` element on
/// both platforms — which decodes none of them. Streaming already knows this
/// (DR-149 forces a transcode over the server's own direct-play offer); the
/// download path did not, so a downloaded film played offline as picture with
/// no sound while the very same film had sound when streamed.
/// track included. Where the webview `<video>` element renders video —
/// Linux, which is where this test runs — none of them decode. Streaming
/// already knew this (DR-149); the download path did not, so a downloaded
/// film played offline as picture with no sound.
///
/// TRACES: UR-071, UR-004 | DR-171 | UT-166
/// On Android the renderer is ExoPlayer with the FFmpeg extension, which
/// decodes all of these, so the same call there yields a `Static=true` copy
/// (DR-293). The policy is `renderer_can_decode_audio`; this test pins its
/// webview half.
///
/// TRACES: UR-071, UR-004 | DR-171, DR-293 | UT-166
#[test]
fn test_video_download_url_original_transcodes_undecodable_audio() {
let repo = create_test_repository();
@@ -1,429 +0,0 @@
#[cfg(test)]
mod tests {
use crate::api::jellyfin::{
GetItemsOptions, ImageType, ImageOptions, SortOrder,
};
/// Mock for testing URL construction without a real server
struct MockOnlineRepository {
server_url: String,
access_token: String,
}
impl MockOnlineRepository {
fn new(server_url: &str, access_token: &str) -> Self {
Self {
server_url: server_url.to_string(),
access_token: access_token.to_string(),
}
}
/// Test helper: construct image URL similar to backend
fn get_image_url(
&self,
item_id: &str,
image_type: &str,
options: Option<&ImageOptions>,
) -> String {
let mut url = format!(
"{}/Items/{}/Images/{}",
self.server_url, item_id, image_type
);
// No api_key — image downloads use X-Emby-Authorization header
let mut params: Vec<(&str, String)> = Vec::new();
if let Some(opts) = options {
if let Some(max_width) = opts.max_width {
params.push(("maxWidth", max_width.to_string()));
}
if let Some(max_height) = opts.max_height {
params.push(("maxHeight", max_height.to_string()));
}
if let Some(quality) = opts.quality {
params.push(("quality", quality.to_string()));
}
if let Some(tag) = &opts.tag {
params.push(("tag", tag.clone()));
}
}
let query_string = params
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
if !query_string.is_empty() {
url.push('?');
url.push_str(&query_string);
}
url
}
/// Test helper: construct subtitle URL
fn get_subtitle_url(
&self,
item_id: &str,
media_source_id: &str,
stream_index: usize,
format: &str,
) -> String {
format!(
"{}/Videos/{}/Subtitles/{}/{}/subtitles.{}?api_key={}",
self.server_url,
item_id,
media_source_id,
stream_index,
format,
self.access_token
)
}
/// Test helper: construct video download URL
fn get_video_download_url(
&self,
item_id: &str,
quality: &str,
) -> String {
let (max_width, bitrate) = match quality {
"1080p" => ("1920", "15000k"),
"720p" => ("1280", "8000k"),
"480p" => ("854", "3000k"),
_ => ("0", ""), // original
};
if quality == "original" {
format!("{}/Videos/{}/stream.mp4?api_key={}", self.server_url, item_id, self.access_token)
} else {
format!(
"{}/Videos/{}/stream.mp4?maxWidth={}&videoBitrate={}&api_key={}",
self.server_url, item_id, max_width, bitrate, self.access_token
)
}
}
}
// ===== Image URL Tests =====
#[test]
fn test_image_url_basic() {
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
let url = repo.get_image_url("item123", "Primary", None);
assert!(url.contains("https://jellyfin.example.com"));
assert!(url.contains("/Items/item123/Images/Primary"));
assert!(url.contains("api_key=token123"));
}
#[test]
fn test_image_url_with_max_width() {
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
let options = ImageOptions {
max_width: Some(300),
max_height: None,
quality: None,
tag: None,
};
let url = repo.get_image_url("item123", "Primary", Some(&options));
assert!(url.contains("maxWidth=300"));
assert!(url.contains("api_key=token123"));
}
#[test]
fn test_image_url_with_all_options() {
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
let options = ImageOptions {
max_width: Some(1920),
max_height: Some(1080),
quality: Some(90),
tag: Some("abc123".to_string()),
};
let url = repo.get_image_url("item456", "Backdrop", Some(&options));
assert!(url.contains("/Items/item456/Images/Backdrop"));
assert!(url.contains("maxWidth=1920"));
assert!(url.contains("maxHeight=1080"));
assert!(url.contains("quality=90"));
assert!(url.contains("tag=abc123"));
assert!(url.contains("api_key=token123"));
}
#[test]
fn test_image_url_different_image_types() {
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
let image_types = vec!["Primary", "Backdrop", "Logo", "Thumb"];
for image_type in image_types {
let url = repo.get_image_url("item123", image_type, None);
assert!(url.contains(&format!("/Images/{}", image_type)));
}
}
#[test]
fn test_image_url_credentials_included_in_backend() {
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "secret_token");
let url = repo.get_image_url("item123", "Primary", None);
// Credentials should be included in backend-generated URL
assert!(url.contains("api_key=secret_token"));
}
#[test]
fn test_image_url_proper_encoding() {
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
let options = ImageOptions {
max_width: Some(300),
max_height: None,
quality: None,
tag: Some("tag-with-special-chars".to_string()),
};
let url = repo.get_image_url("item123", "Primary", Some(&options));
// URL should be properly formatted
assert!(url.contains("?"));
assert!(url.contains("&") || !url.contains("&&")); // No double ampersands
assert!(!url.ends_with("&")); // No trailing ampersand
}
// ===== Subtitle URL Tests =====
#[test]
fn test_subtitle_url_vtt_format() {
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
let url = repo.get_subtitle_url("item123", "source456", 0, "vtt");
assert!(url.contains("Videos/item123"));
assert!(url.contains("Subtitles/source456/0"));
assert!(url.contains("subtitles.vtt"));
assert!(url.contains("api_key=token123"));
}
#[test]
fn test_subtitle_url_srt_format() {
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
let url = repo.get_subtitle_url("item123", "source456", 1, "srt");
assert!(url.contains("Subtitles/source456/1"));
assert!(url.contains("subtitles.srt"));
}
#[test]
fn test_subtitle_url_multiple_streams() {
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
for stream_index in 0..5 {
let url = repo.get_subtitle_url("item123", "source456", stream_index, "vtt");
assert!(url.contains(&format!("/{}/subtitles", stream_index)));
}
}
#[test]
fn test_subtitle_url_different_media_sources() {
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
let media_sources = vec!["src1", "src2", "src3"];
for media_source_id in media_sources {
let url = repo.get_subtitle_url("item123", media_source_id, 0, "vtt");
assert!(url.contains(&format!("Subtitles/{}/", media_source_id)));
}
}
// ===== Video Download URL Tests =====
#[test]
fn test_video_download_url_original_quality() {
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
let url = repo.get_video_download_url("item123", "original");
assert!(url.contains("Videos/item123/stream.mp4"));
assert!(url.contains("api_key=token123"));
assert!(!url.contains("maxWidth")); // Original should have no transcoding params
}
#[test]
fn test_video_download_url_1080p() {
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
let url = repo.get_video_download_url("item123", "1080p");
assert!(url.contains("maxWidth=1920"));
assert!(url.contains("videoBitrate=15000k"));
}
#[test]
fn test_video_download_url_720p() {
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
let url = repo.get_video_download_url("item123", "720p");
assert!(url.contains("maxWidth=1280"));
assert!(url.contains("videoBitrate=8000k"));
}
#[test]
fn test_video_download_url_480p() {
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
let url = repo.get_video_download_url("item123", "480p");
assert!(url.contains("maxWidth=854"));
assert!(url.contains("videoBitrate=3000k"));
}
#[test]
fn test_video_download_url_quality_presets() {
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
let qualities = vec!["original", "1080p", "720p", "480p"];
for quality in qualities {
let url = repo.get_video_download_url("item123", quality);
assert!(url.contains("Videos/item123/stream.mp4"));
}
}
// ===== Security Tests =====
#[test]
fn test_credentials_never_exposed_in_frontend() {
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "super_secret_token");
let image_url = repo.get_image_url("item123", "Primary", None);
let subtitle_url = repo.get_subtitle_url("item123", "src123", 0, "vtt");
let download_url = repo.get_video_download_url("item123", "720p");
// Image URLs no longer contain api_key — auth is via X-Emby-Authorization header
assert!(!image_url.contains("api_key="));
// Subtitle and download URLs still use api_key (used directly, not via download_bytes)
assert!(subtitle_url.contains("api_key=super_secret_token"));
assert!(download_url.contains("api_key=super_secret_token"));
}
#[test]
fn test_url_parameter_injection_prevention() {
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "token123");
// Try to inject parameters through item_id
let malicious_id = "item123&extraParam=malicious";
let url = repo.get_image_url(malicious_id, "Primary", None);
// URL should contain the full item_id, backend should handle escaping
assert!(url.contains(malicious_id));
// Backend should be responsible for proper URL encoding
}
// ===== URL Format Tests =====
#[test]
fn test_image_url_format_correctness() {
let repo = MockOnlineRepository::new("https://server.com", "token");
let url = repo.get_image_url("id123", "Primary", None);
// Should be valid format (no api_key — auth via header)
assert!(url.starts_with("https://server.com"));
assert!(url.contains("/Items/id123/Images/Primary"));
assert!(!url.contains("api_key="));
}
#[test]
fn test_query_string_properly_separated() {
let repo = MockOnlineRepository::new("https://server.com", "token");
let options = ImageOptions {
max_width: Some(300),
max_height: Some(200),
quality: None,
tag: None,
};
let url = repo.get_image_url("id123", "Primary", Some(&options));
// Should have single ? separator with params
let question_marks = url.matches('?').count();
assert_eq!(question_marks, 1);
// Should have params for maxWidth and maxHeight
assert!(url.contains("maxWidth=300"));
assert!(url.contains("maxHeight=200"));
}
#[test]
fn test_special_characters_in_urls() {
let repo = MockOnlineRepository::new("https://server.com", "token_with_special-chars");
let url = repo.get_image_url("item-with-special_chars", "Primary", None);
// Should handle special characters in id (no token in URL anymore)
assert!(url.contains("item-with-special_chars"));
}
// ===== Backend vs Frontend Responsibility Tests =====
#[test]
fn test_backend_owns_url_construction() {
// This test documents that URL construction is ONLY in backend
let repo = MockOnlineRepository::new("https://jellyfin.example.com", "secret_token");
// Backend generates full URL with credentials
let url = repo.get_image_url("item123", "Primary", None);
// URL is complete and ready to use (auth via header, not api_key)
assert!(url.starts_with("https://"));
assert!(url.contains("/Items/item123/Images/Primary"));
// Frontend never constructs URLs directly
// Frontend only receives pre-constructed URLs from backend
}
#[test]
fn test_url_includes_all_necessary_parameters() {
let repo = MockOnlineRepository::new("https://server.com", "token");
let options = ImageOptions {
max_width: Some(300),
max_height: Some(200),
quality: Some(90),
tag: Some("abc".to_string()),
};
let url = repo.get_image_url("item123", "Primary", Some(&options));
// All provided options should be in URL
assert!(url.contains("maxWidth=300"));
assert!(url.contains("maxHeight=200"));
assert!(url.contains("quality=90"));
assert!(url.contains("tag=abc"));
}
#[test]
fn test_optional_parameters_omitted_when_not_provided() {
let repo = MockOnlineRepository::new("https://server.com", "token");
let options = ImageOptions {
max_width: None,
max_height: None,
quality: None,
tag: None,
};
let url = repo.get_image_url("item123", "Primary", Some(&options));
// Should have no query params (no api_key, no options)
assert!(!url.contains("?"));
assert!(!url.contains("maxWidth"));
assert!(!url.contains("maxHeight"));
assert!(!url.contains("quality"));
assert!(!url.contains("tag"));
}
}
+207 -15
View File
@@ -209,21 +209,16 @@ pub async fn fetch_series_episodes(
) -> Result<Vec<MediaItem>, RepoError> {
let children = repo.get_items(series_id, list_options()).await?;
let mut episodes: Vec<MediaItem> = Vec::new();
for season in children.items.iter().filter(|i| is_season(i)) {
// One failing season must not blank the whole show.
match repo.get_items(&season.id, list_options()).await {
Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
Err(e) => {
log::warn!(
"[series] season {} of {} failed to load: {:?}",
season.id,
series_id,
e
);
}
}
}
let seasons: Vec<MediaItem> = children
.items
.iter()
.filter(|i| is_season(i))
.cloned()
.collect();
let mut episodes = gather_season_episodes(&seasons, |season_id| async move {
repo.get_items(&season_id, list_options()).await
})
.await;
// Flat series: the children *are* the episodes.
if episodes.is_empty() {
@@ -234,6 +229,114 @@ pub async fn fetch_series_episodes(
Ok(episodes)
}
/// Every episode in `seasons`, fetched with `fetch_season` (a season id → its
/// children), **all at once**.
///
/// They used to be fetched one after another, so the wait was the sum of every
/// season's listing: ~4 s for Frasier's eleven, on a phone whose cache reads
/// were slowed by a catalog sync writing in the background. Concurrently it is
/// the slowest single season. Order is restored afterwards by
/// `sort_series_order`, so completion order does not matter.
///
/// One failing season must not blank the whole show: its episodes are left out
/// and the rest returned.
///
/// TRACES: UR-062 | DR-295 | UT-264
pub async fn gather_season_episodes<F, Fut>(
seasons: &[MediaItem],
fetch_season: F,
) -> Vec<MediaItem>
where
F: Fn(String) -> Fut,
Fut: std::future::Future<Output = Result<super::SearchResult, RepoError>>,
{
let results = futures_util::future::join_all(
seasons.iter().map(|season| fetch_season(season.id.clone())),
)
.await;
let mut episodes = Vec::new();
for (season, result) in seasons.iter().zip(results) {
match result {
Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
Err(e) => log::warn!("[series] season {} failed to load: {:?}", season.id, e),
}
}
episodes
}
/// A series' episodes and the one the viewer is up to, from **one** season
/// fan-out.
///
/// The series page needs both, and asked for them as two commands; each walked
/// every season, so every visit listed the show twice. One call, one walk.
///
/// TRACES: UR-062 | DR-101, DR-295
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct SeriesView {
pub episodes: Vec<MediaItem>,
pub current: Option<MediaItem>,
}
/// Build a [`SeriesView`]: one fan-out, with Next Up and resume fetched
/// alongside it rather than after.
///
/// TRACES: UR-062 | DR-101, DR-295
pub async fn resolve_series_view(
repo: &dyn MediaRepository,
series_id: &str,
) -> Result<SeriesView, RepoError> {
let (episodes, (next_up, resume)) = with_hints(fetch_series_episodes(repo, series_id), async {
futures_util::join!(
async {
repo.get_next_up_episodes(Some(series_id), Some(1))
.await
.unwrap_or_default()
},
async {
repo.get_resume_items(Some(series_id), Some(10))
.await
.unwrap_or_default()
},
)
})
.await;
let episodes = episodes?;
let current = pick_current_episode(series_id, &episodes, &next_up, &resume);
Ok(SeriesView { episodes, current })
}
/// Run `primary` and `hints` together, but never hold `primary` back for
/// `hints`: once `primary` is ready, the hints are taken if they have already
/// answered and dropped (`H::default()`) if not.
///
/// For the series view the primary is the episode list and the hints are Next
/// Up and resume, which only refine which episode is "current" — and the
/// picker falls back to the episodes' own watch state without them. Waiting
/// for them made the episode list wait for the server (2-3 s on a phone)
/// although every episode was in the cache in 50 ms. The cache legs of the
/// hints usually answer before the episodes do, so they are normally kept.
///
/// TRACES: UR-062 | DR-101, DR-295
async fn with_hints<P, H>(
primary: impl std::future::Future<Output = P>,
hints: impl std::future::Future<Output = H>,
) -> (P, H)
where
H: Default,
{
use futures_util::future::{select, Either};
use futures_util::FutureExt;
let primary = std::pin::pin!(primary);
let hints = std::pin::pin!(hints);
match select(primary, hints).await {
Either::Left((primary, hints)) => (primary, hints.now_or_never().unwrap_or_default()),
Either::Right((hints, primary)) => (primary.await, hints),
}
}
/// Resolve the current episode, fetching everything the policy needs.
///
/// Next Up and resume are best-effort: offline they fail or come back empty, and
@@ -293,6 +396,95 @@ mod tests {
}
}
/// "More info" on Frasier took ~4 s to list its episodes, twice over: the
/// eleven seasons were fetched one after another, so the wait was the *sum*
/// of eleven listings. Fetched together it is the slowest one.
///
/// TRACES: UR-062 | DR-295 | UT-264
#[tokio::test]
async fn seasons_are_fetched_concurrently_not_one_after_another() {
let seasons: Vec<MediaItem> = (1..=10)
.map(|n| MediaItem {
id: format!("season-{n}"),
item_type: "Season".to_string(),
..Default::default()
})
.collect();
let started = std::time::Instant::now();
let episodes = gather_season_episodes(&seasons, |season_id| async move {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
if season_id == "season-3" {
// One failing season must not blank the show.
return Err(RepoError::Network {
message: "gone".to_string(),
});
}
let n: i32 = season_id.trim_start_matches("season-").parse().unwrap();
Ok(crate::repository::SearchResult {
items: vec![episode(&format!("e{n}"), n, 1)],
total_record_count: 1,
})
})
.await;
let elapsed = started.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(400),
"ten 100 ms seasons took {elapsed:?} — fetched in sequence, not together"
);
assert_eq!(episodes.len(), 9, "every season but the failing one");
}
/// The episode list must not wait for Next Up or resume.
///
/// The series page rendered its episodes only once Next Up had come back
/// from the server — 2-3 s on a phone while the page's other requests were
/// in flight — although every episode was in the cache after 50 ms. Those
/// two only refine which episode is "current", and the picker falls back
/// to the episodes' own watch state without them.
///
/// TRACES: UR-062 | DR-101, DR-295
#[tokio::test]
async fn the_episode_list_does_not_wait_for_slow_hints() {
let started = std::time::Instant::now();
let (episodes, hints) = with_hints(
async {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
vec![episode("e1", 1, 1)]
},
async {
tokio::time::sleep(std::time::Duration::from_millis(2000)).await;
vec![episode("from-server", 1, 2)]
},
)
.await;
let elapsed = started.elapsed();
assert_eq!(episodes.len(), 1);
assert!(hints.is_empty(), "late hints are dropped, not waited for");
assert!(
elapsed < std::time::Duration::from_millis(500),
"the episode list waited {elapsed:?} for Next Up / resume"
);
}
/// Hints that are already in (a cache answer) are used.
///
/// TRACES: UR-062 | DR-101, DR-295
#[tokio::test]
async fn hints_that_answer_first_are_kept() {
let (_, hints) = with_hints(
async {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
vec![episode("e1", 1, 1)]
},
async { vec![episode("cached", 1, 2)] },
)
.await;
assert_eq!(hints.len(), 1);
}
fn watched(mut item: MediaItem) -> MediaItem {
item.user_data = Some(UserData {
is_played: Some(true),
+147
View File
@@ -0,0 +1,147 @@
//! A fake Jellyfin server the online repository can actually talk to.
//!
//! # Why this exists
//!
//! Before it, `src-tauri/` contained no HTTP mocking of any kind. Every test of
//! the ~4,800-line online adapter asserted on a *constructed URL string*, and
//! not one exercised a response. That has a specific, recorded cost: a deleted
//! test file re-implemented the URL builders inside its own mock and then
//! asserted against itself, and `online.rs` still carries the comment recording
//! that the production builder meanwhile shipped a `/Videos/{id}/download`
//! endpoint which 404s on real servers — silently breaking every download while
//! the "test" stayed green.
//!
//! So the rule here is: **assert against a response from a mock *server*, never
//! against a mock that re-derives the thing under test.** Nothing in this module
//! may reimplement anything from `endpoints.rs` or `online.rs`.
//!
//! # Two generations
//!
//! [`FakeJellyfin::start`] takes the version string the fake server reports, and
//! the repository it hands back resolves its capabilities from exactly that — the
//! same path production takes. A test that runs against both generations is
//! therefore running the real resolution, not a stubbed one.
//!
//! TRACES: UR-085 | DR-281
use std::sync::Arc;
use serde_json::json;
use wiremock::matchers::{method, path_regex};
use wiremock::{Mock, MockServer, Request, ResponseTemplate};
use super::capabilities::ServerCapabilities;
use super::online::OnlineRepository;
use crate::jellyfin::{HttpClient, HttpConfig};
/// Jellyfin's current stable line, and the line this client was built against.
pub const V12: &str = "12.0.0";
pub const V10_11: &str = "10.11.5";
/// Both live generations. `#[test]`s that care about compatibility iterate this.
///
/// There is no 11 in the middle: Jellyfin dropped the leading `10` from its
/// scheme with 12.0, so what would have been 10.12.0 shipped as `12.0`.
pub const BOTH_GENERATIONS: [&str; 2] = [V10_11, V12];
pub struct FakeJellyfin {
server: MockServer,
version: String,
}
impl FakeJellyfin {
/// Stand up a server reporting `version`, answering any `/Items`-shaped
/// query with one item and any `/Users/.../Views` with one library.
///
/// The response bodies are deliberately minimal: this module's job is to let
/// tests observe what the *client* sent, not to re-specify Jellyfin.
pub async fn start(version: &str) -> Self {
let server = MockServer::start().await;
let item = json!({
"Id": "item-1",
"Name": "A Film",
"Type": "Movie",
"IsFolder": false,
"ServerId": "srv-1",
});
Mock::given(method("GET"))
.and(path_regex(r".*/Views$"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"Items": [{
"Id": "lib-1",
"Name": "Movies",
"Type": "CollectionFolder",
"CollectionType": "movies",
"IsFolder": true,
"ServerId": "srv-1",
}],
"TotalRecordCount": 1,
})))
.mount(&server)
.await;
// Everything else that returns a listing.
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"Items": [item],
"TotalRecordCount": 1,
})))
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(204))
.mount(&server)
.await;
Self {
server,
version: version.to_string(),
}
}
/// A repository pointed at this server, with capabilities resolved from the
/// version it reports — the same resolution production performs.
pub fn repository(&self) -> OnlineRepository {
let http = HttpClient::new_allowing_plaintext_for_tests(HttpConfig::default())
.expect("test http client");
OnlineRepository::new(
Arc::new(http),
self.server.uri(),
"user-1".to_string(),
"token-abc".to_string(),
)
.with_capabilities(ServerCapabilities::from_reported(&self.version))
}
/// Every request the server received, in order.
pub async fn requests(&self) -> Vec<Request> {
self.server
.received_requests()
.await
.expect("request recording is enabled")
}
/// The single request received, failing loudly if there was not exactly one.
pub async fn only_request(&self) -> Request {
let mut received = self.requests().await;
assert_eq!(
received.len(),
1,
"expected exactly one request, got {}",
received.len()
);
received.remove(0)
}
}
/// The request target as the server saw it — path plus query.
pub fn target(request: &Request) -> String {
match request.url.query() {
Some(q) => format!("{}?{}", request.url.path(), q),
None => request.url.path().to_string(),
}
}
+313 -99
View File
@@ -1,15 +1,31 @@
//! Database service abstraction layer
//! Database service: the single owner of the SQLite database.
//!
//! This module provides an async database interface that abstracts away
//! the underlying database implementation. This makes it easy to:
//! - Switch between sync (rusqlite) and async (tokio-rusqlite) implementations
//! - Prevent blocking the async runtime with synchronous database calls
//! - Test with different database backends
//! - Migrate to other database systems in the future
//! Every query in the app goes through [`RusqliteService`], which owns the
//! connections and hands work to them — callers never touch a `Connection`.
//!
//! - **Writes** (`execute`, `insert`, `transaction`, …) are sent as jobs to one
//! dedicated writer thread that owns the read-write connection. SQLite allows
//! one writer at a time anyway; owning it on one thread makes that explicit,
//! keeps connection-wide state (pragmas) out of reach of concurrent callers,
//! and parks no tokio blocking threads on a mutex while writes queue up.
//! - **Reads** (`query_*`) run on a small pool of read-only connections. The
//! database is in WAL mode, so readers see the last committed state and never
//! wait for the writer — a large catalog-cache transaction no longer stalls
//! library pages, thumbnail lookups or settings reads.
//!
//! A service built with [`RusqliteService::new`] has no reader pool (in-memory
//! databases cannot be shared between connections) and routes reads through
//! the writer, which is the old single-connection behaviour tests rely on.
//!
//! See `docs/architecture/08-database-design.md` → "Connection ownership".
use crate::utils::lock::MutexSafe;
use async_trait::async_trait;
use log::{debug, error};
use rusqlite::{params_from_iter, Connection, Result as SqliteResult, Row};
use std::sync::{Arc, Mutex};
use std::panic::AssertUnwindSafe;
use std::sync::mpsc;
use std::sync::{Arc, Condvar, Mutex};
/// Database query result type
pub type DbResult<T> = Result<T, String>;
@@ -83,8 +99,28 @@ pub trait DatabaseService: Send + Sync {
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
T: Send + 'static;
/// Get the row ID of the most recent successful INSERT
async fn last_insert_rowid(&self) -> DbResult<i64>;
/// Run a transaction with foreign-key enforcement switched off for its
/// duration only.
///
/// `PRAGMA foreign_keys` is per connection and is a no-op inside a
/// transaction, so it has to be flipped around the `BEGIN`/`COMMIT` — and
/// all of that must happen as one job on the writer, or any other write
/// that got in between would run unchecked too.
async fn transaction_without_foreign_keys<F, T>(&self, f: F) -> DbResult<T>
where
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
T: Send + 'static;
/// Execute an INSERT and return the rowid of the row it inserted.
///
/// The rowid is read in the same job as the insert. Reading it with a
/// second call would race every other write, returning someone else's id.
async fn insert(&self, query: Query) -> DbResult<i64>;
/// Queue a write without waiting for it — for best-effort bookkeeping (an
/// LRU access time) that must not hold up the caller. It still runs in
/// order with every other write; failures are only logged.
fn execute_detached(&self, query: Query);
}
/// Transaction handle for batching multiple operations
@@ -109,46 +145,183 @@ impl<'a> Transaction<'a> {
}
}
/// Rusqlite-based database service implementation
///
/// This implementation wraps synchronous rusqlite operations in tokio::task::spawn_blocking
/// to prevent blocking the async runtime.
type Job = Box<dyn FnOnce(&Connection) + Send>;
/// The thread that owns the read-write connection. Jobs run one at a time, in
/// the order they were sent; the thread exits when the last service handle
/// (and so the last sender) is dropped.
struct Writer {
jobs: mpsc::Sender<Job>,
}
impl Writer {
fn spawn(conn: Arc<Mutex<Connection>>) -> Self {
let (jobs, queue) = mpsc::channel::<Job>();
std::thread::Builder::new()
.name("db-writer".into())
.spawn(move || {
for job in queue {
// The connection stays behind a mutex only so migrations and
// tests can reach it; in the app this thread is its sole user.
// `lock_safe` so a poisoned lock is recovered, not fatal.
let conn = conn.lock_safe();
// A panicking row mapper must not take the owner down with
// it: the job's reply channel drops, its caller gets an
// error, and the next job runs normally. The guard lives
// outside the unwind, so the mutex is not poisoned either.
if std::panic::catch_unwind(AssertUnwindSafe(|| job(&conn))).is_err() {
error!("[db] a database job panicked; the writer carries on");
// Undo whatever connection state the job was midway
// through: an open transaction would make the next
// job's BEGIN fail, and a job that switched foreign
// keys off would leave them off for everyone.
if !conn.is_autocommit() {
let _ = conn.execute_batch("ROLLBACK");
}
let _ = conn.execute_batch("PRAGMA foreign_keys = ON");
}
}
})
.expect("failed to spawn the database writer thread");
Self { jobs }
}
async fn run<T, F>(&self, f: F) -> DbResult<T>
where
T: Send + 'static,
F: FnOnce(&Connection) -> DbResult<T> + Send + 'static,
{
let (reply, result) = tokio::sync::oneshot::channel();
self.jobs
.send(Box::new(move |conn| {
let _ = reply.send(f(conn));
}))
.map_err(|_| "database writer has stopped".to_string())?;
result
.await
.map_err(|_| "database job panicked".to_string())?
}
fn run_detached(&self, f: impl FnOnce(&Connection) + Send + 'static) {
if self.jobs.send(Box::new(f)).is_err() {
debug!("[db] writer stopped; dropped a detached write");
}
}
}
/// Read-only connections, checked out one per query. WAL gives each a
/// snapshot of the last commit, so they never wait for the writer.
struct ReaderPool {
idle: Mutex<Vec<Connection>>,
returned: Condvar,
}
impl ReaderPool {
/// Blocking: waits for a free connection. Call from `spawn_blocking`.
fn run<T>(&self, f: impl FnOnce(&Connection) -> T) -> T {
let conn = {
let mut idle = self.idle.lock_safe();
loop {
if let Some(conn) = idle.pop() {
break conn;
}
idle = self
.returned
.wait(idle)
.unwrap_or_else(|poisoned| poisoned.into_inner());
}
};
// Returned on drop, so a panicking mapper does not leak the connection.
let checkout = Checkout {
pool: self,
conn: Some(conn),
};
f(checkout.conn.as_ref().expect("checked-out connection"))
}
}
struct Checkout<'a> {
pool: &'a ReaderPool,
conn: Option<Connection>,
}
impl Drop for Checkout<'_> {
fn drop(&mut self) {
if let Some(conn) = self.conn.take() {
self.pool.idle.lock_safe().push(conn);
self.pool.returned.notify_one();
}
}
}
/// Rusqlite-based database service: a cheap, cloneable handle to the writer
/// thread and reader pool. See the module docs.
#[derive(Clone)]
pub struct RusqliteService {
conn: Arc<Mutex<Connection>>,
writer: Arc<Writer>,
readers: Option<Arc<ReaderPool>>,
}
impl RusqliteService {
/// A service over a single connection: writes *and* reads go through the
/// writer thread. Used for in-memory databases, which cannot be shared
/// between connections.
#[cfg_attr(not(test), allow(dead_code))]
pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
Self { conn }
Self {
writer: Arc::new(Writer::spawn(conn)),
readers: None,
}
}
/// A service whose reads run on `readers` — read-only connections to the
/// same (file-backed, WAL-mode) database — alongside the writer.
pub fn with_readers(conn: Arc<Mutex<Connection>>, readers: Vec<Connection>) -> Self {
let readers = (!readers.is_empty()).then(|| {
Arc::new(ReaderPool {
idle: Mutex::new(readers),
returned: Condvar::new(),
})
});
Self {
writer: Arc::new(Writer::spawn(conn)),
readers,
}
}
async fn read<T, F>(&self, f: F) -> DbResult<T>
where
T: Send + 'static,
F: FnOnce(&Connection) -> DbResult<T> + Send + 'static,
{
match &self.readers {
Some(pool) => {
let pool = Arc::clone(pool);
tokio::task::spawn_blocking(move || pool.run(f))
.await
.map_err(|e| format!("Task join error: {}", e))?
}
None => self.writer.run(f).await,
}
}
}
#[async_trait]
impl DatabaseService for RusqliteService {
async fn execute(&self, query: Query) -> DbResult<usize> {
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || {
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
execute_query(&conn, query)
})
.await
.map_err(|e| format!("Task join error: {}", e))?
self.writer
.run(move |conn| execute_query(conn, query))
.await
}
async fn execute_batch(&self, sql: &str) -> DbResult<()> {
let conn = Arc::clone(&self.conn);
let sql = sql.to_string();
tokio::task::spawn_blocking(move || {
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
conn.execute_batch(&sql)
.map_err(|e| format!("Execute batch failed: {}", e))
})
.await
.map_err(|e| format!("Task join error: {}", e))?
self.writer
.run(move |conn| {
conn.execute_batch(&sql)
.map_err(|e| format!("Execute batch failed: {}", e))
})
.await
}
async fn query_one<T, F>(&self, query: Query, mapper: F) -> DbResult<T>
@@ -156,15 +329,7 @@ impl DatabaseService for RusqliteService {
T: Send + 'static,
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
{
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || {
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
query_one(&conn, query, mapper)
})
.await
.map_err(|e| format!("Task join error: {}", e))?
self.read(move |conn| query_one(conn, query, mapper)).await
}
async fn query_optional<T, F>(&self, query: Query, mapper: F) -> DbResult<Option<T>>
@@ -172,15 +337,8 @@ impl DatabaseService for RusqliteService {
T: Send + 'static,
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
{
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || {
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
query_optional(&conn, query, mapper)
})
.await
.map_err(|e| format!("Task join error: {}", e))?
self.read(move |conn| query_optional(conn, query, mapper))
.await
}
async fn query_many<T, F>(&self, query: Query, mapper: F) -> DbResult<Vec<T>>
@@ -188,15 +346,7 @@ impl DatabaseService for RusqliteService {
T: Send + 'static,
F: Fn(&Row) -> SqliteResult<T> + Send + 'static,
{
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || {
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
query_many(&conn, query, mapper)
})
.await
.map_err(|e| format!("Task join error: {}", e))?
self.read(move |conn| query_many(conn, query, mapper)).await
}
async fn transaction<F, T>(&self, f: F) -> DbResult<T>
@@ -204,45 +354,65 @@ impl DatabaseService for RusqliteService {
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
T: Send + 'static,
{
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || {
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
conn.execute("BEGIN TRANSACTION", [])
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
let mut transaction = Transaction::new(&conn);
let result = f(&mut transaction);
match result {
Ok(value) => {
conn.execute("COMMIT", [])
.map_err(|e| format!("Failed to commit transaction: {}", e))?;
Ok(value)
}
Err(e) => {
conn.execute("ROLLBACK", [])
.map_err(|e| format!("Failed to rollback transaction: {}", e))?;
Err(e)
}
}
})
.await
.map_err(|e| format!("Task join error: {}", e))?
self.writer.run(move |conn| run_transaction(conn, f)).await
}
async fn last_insert_rowid(&self) -> DbResult<i64> {
let conn = Arc::clone(&self.conn);
tokio::task::spawn_blocking(move || {
let conn = conn
.lock()
.map_err(|e| format!("Failed to lock connection: {}", e))?;
Ok(conn.last_insert_rowid())
})
.await
.map_err(|e| format!("Task join error: {}", e))?
async fn transaction_without_foreign_keys<F, T>(&self, f: F) -> DbResult<T>
where
F: FnOnce(&mut Transaction) -> DbResult<T> + Send + 'static,
T: Send + 'static,
{
self.writer
.run(move |conn| {
conn.execute_batch("PRAGMA foreign_keys = OFF")
.map_err(|e| format!("Failed to disable foreign keys: {}", e))?;
let result = run_transaction(conn, f);
// Always restored, whatever the transaction did.
if let Err(e) = conn.execute_batch("PRAGMA foreign_keys = ON") {
error!("[db] failed to re-enable foreign keys: {}", e);
}
result
})
.await
}
async fn insert(&self, query: Query) -> DbResult<i64> {
self.writer
.run(move |conn| {
execute_query(conn, query)?;
Ok(conn.last_insert_rowid())
})
.await
}
fn execute_detached(&self, query: Query) {
self.writer.run_detached(move |conn| {
if let Err(e) = execute_query(conn, query) {
debug!("[db] detached write failed: {}", e);
}
});
}
}
fn run_transaction<F, T>(conn: &Connection, f: F) -> DbResult<T>
where
F: FnOnce(&mut Transaction) -> DbResult<T>,
{
conn.execute("BEGIN TRANSACTION", [])
.map_err(|e| format!("Failed to begin transaction: {}", e))?;
let mut transaction = Transaction::new(conn);
match f(&mut transaction) {
Ok(value) => {
conn.execute("COMMIT", [])
.map_err(|e| format!("Failed to commit transaction: {}", e))?;
Ok(value)
}
Err(e) => {
conn.execute("ROLLBACK", [])
.map_err(|e| format!("Failed to rollback transaction: {}", e))?;
Err(e)
}
}
}
@@ -410,4 +580,48 @@ mod tests {
let count: i32 = service.query_one(query, |row| row.get(0)).await.unwrap();
assert_eq!(count, 2);
}
/// A panic while the connection guard is held must not brick every later
/// query.
///
/// This is the single busiest lock in the app — every async DB operation
/// goes through it. With a raw `.lock()`, one panic under the guard poisons
/// the mutex and every subsequent call returns "poisoned lock" until the
/// process restarts, which for a database-backed app means the whole UI
/// stops working. `utils::lock` exists precisely to stop that cascade, and
/// `storage::Database` already used it; this path did not.
///
/// TRACES: UR-002 | DR-012 | UT-014
#[tokio::test]
async fn a_poisoned_connection_still_serves_queries() {
let conn = Arc::new(Mutex::new(Connection::open_in_memory().unwrap()));
{
let c = conn.lock_safe();
c.execute_batch("CREATE TABLE test (id INTEGER PRIMARY KEY);")
.unwrap();
}
// Poison the mutex the way a panicking row mapper would.
let poisoner = Arc::clone(&conn);
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let _ = std::thread::spawn(move || {
let _guard = poisoner.lock().unwrap();
panic!("a row mapper blew up while holding the connection");
})
.join();
std::panic::set_hook(hook);
assert!(conn.lock().is_err(), "the mutex should now be poisoned");
// Every operation must still work.
let service = RusqliteService::new(Arc::clone(&conn));
service
.execute(Query::new("INSERT INTO test (id) VALUES (1)"))
.await
.expect("execute must survive a poisoned connection");
let count: i32 = service
.query_one(Query::new("SELECT COUNT(*) FROM test"), |row| row.get(0))
.await
.expect("query_one must survive a poisoned connection");
assert_eq!(count, 1);
}
}
+463 -44
View File
@@ -17,9 +17,27 @@ use rusqlite::{Connection, Result as SqliteResult};
pub use db_service::{DatabaseService, RusqliteService};
use schema::MIGRATIONS;
/// Database connection wrapper with thread-safe access
/// How long a connection retries a locked database before erroring — covers a
/// reader meeting a WAL checkpoint, or the writer meeting a reader's snapshot.
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
/// How many read-only connections serve queries alongside the writer. Reads
/// are short; a few cover a library page's parallel fetches plus background
/// work without holding many file handles.
const READER_CONNECTIONS: usize = 3;
/// The database: opened once at startup and owned for the life of the app.
///
/// All access goes through [`Database::service`], which hands out clones of one
/// [`RusqliteService`] — a writer thread plus a pool of read-only connections
/// (see `db_service`). Nothing else opens the database file.
pub struct Database {
/// The read-write connection. Owned by the service's writer thread; kept
/// here only for migrations (which run before that thread starts taking
/// work) and for tests.
#[cfg_attr(not(test), allow(dead_code))]
conn: Arc<Mutex<Connection>>,
service: RusqliteService,
path: PathBuf,
}
@@ -36,18 +54,48 @@ impl Database {
// Enable foreign keys
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
// Enable WAL mode for better concurrent access
// WAL lets the reader connections run alongside the writer.
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
// In WAL mode NORMAL is corruption-safe and skips the fsync FULL pays on
// every commit (a power cut can lose the last commits, an app crash
// cannot). On Android flash that fsync dominated every small write.
conn.execute_batch("PRAGMA synchronous = NORMAL;")?;
conn.busy_timeout(BUSY_TIMEOUT)?;
let db = Self {
conn: Arc::new(Mutex::new(conn)),
let conn = Arc::new(Mutex::new(conn));
Self::migrate_connection(&conn, MIGRATIONS)?;
// Planner statistics. Without them SQLite guesses between indexes, and
// guessed badly for the listing query (see 08-database-design.md →
// "Listing query shape"). `optimize` only analyses what is missing or
// stale; `analysis_limit` bounds each table's scan so this stays in the
// milliseconds on a large catalogue. Failure is not fatal.
if let Err(e) = conn
.lock_safe()
.execute_batch("PRAGMA analysis_limit = 400; PRAGMA optimize = 0x10002;")
{
error!("PRAGMA optimize failed: {}", e);
}
// Readers open after migrations, so they only ever see the final schema.
let readers = (0..READER_CONNECTIONS)
.map(|_| Self::open_reader(path))
.collect::<SqliteResult<Vec<_>>>()?;
Ok(Self {
service: RusqliteService::with_readers(Arc::clone(&conn), readers),
conn,
path: path.clone(),
};
})
}
// Run migrations
db.migrate()?;
Ok(db)
/// A connection that can only read. `query_only` makes an accidental write
/// routed to the pool fail loudly instead of racing the writer.
fn open_reader(path: &PathBuf) -> SqliteResult<Connection> {
let conn = Connection::open(path)?;
conn.busy_timeout(BUSY_TIMEOUT)?;
conn.execute_batch("PRAGMA query_only = ON;")?;
Ok(conn)
}
/// Open an in-memory database (for testing)
@@ -58,15 +106,16 @@ impl Database {
// Enable foreign keys
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
let db = Self {
conn: Arc::new(Mutex::new(conn)),
let conn = Arc::new(Mutex::new(conn));
Self::migrate_connection(&conn, MIGRATIONS)?;
// An in-memory database cannot be shared between connections, so this
// one has no reader pool: reads go through the writer.
Ok(Self {
service: RusqliteService::new(Arc::clone(&conn)),
conn,
path: PathBuf::from(":memory:"),
};
// Run migrations
db.migrate()?;
Ok(db)
})
}
/// Get connection (for testing)
@@ -75,10 +124,45 @@ impl Database {
Arc::clone(&self.conn)
}
/// Run all pending migrations
/// Re-run all migrations against an open database (tests only; `open` runs
/// them before the service starts).
#[cfg(test)]
pub fn migrate(&self) -> SqliteResult<()> {
self.migrate_with(MIGRATIONS)
}
/// Test seam: inject a failing migration. See [`Self::migrate_connection`].
#[cfg(test)]
fn migrate_with(&self, migrations: &[(&str, &str)]) -> SqliteResult<()> {
Self::migrate_connection(&self.conn, migrations)
}
/// Apply `migrations` in order, skipping ones `_migrations` already records.
///
/// **Each migration is one transaction, and the `_migrations` row is written
/// inside it.** SQLite autocommits every statement otherwise, so a migration
/// that failed partway — low disk, an OOM kill, the process dying mid-boot —
/// used to leave its earlier statements applied while recording nothing.
/// `execute_batch` aborts on the first error, so the retry on the next launch
/// then failed at statement 1 ("duplicate column name") and kept failing
/// forever; `Database::open` turns that into a panic, so the app never
/// started again and the only fix was clearing app data. Committing the
/// schema change and the bookkeeping together makes a migration all-or-nothing
/// and a retry always safe.
///
/// Every migration is pure DDL/DML, which SQLite runs transactionally — a
/// `PRAGMA` or `VACUUM` added to one would not roll back and must not be.
///
/// Runs on the bare connection, before the writer thread takes it, so
/// tests can also inject a failing migration through `migrate_with`.
///
/// TRACES: UR-002 | DR-012 | UT-014
fn migrate_connection(
conn: &Mutex<Connection>,
migrations: &[(&str, &str)],
) -> SqliteResult<()> {
info!("Starting database migrations...");
let conn = self.conn.lock_safe();
let conn = conn.lock_safe();
// Create migrations table if it doesn't exist
debug!("Creating _migrations table if it doesn't exist...");
@@ -107,40 +191,40 @@ impl Database {
debug!("Found {} applied migrations", applied.len());
// Apply pending migrations
for (name, sql) in MIGRATIONS {
if !applied.contains(&name.to_string()) {
info!("Applying migration: {}", name);
match conn.execute_batch(sql) {
Ok(_) => {
info!("Successfully applied migration: {}", name);
match conn.execute("INSERT INTO _migrations (name) VALUES (?1)", [name]) {
Ok(_) => debug!("Recorded migration: {}", name),
Err(e) => {
error!("Failed to record migration {}: {}", name, e);
return Err(e);
}
}
}
Err(e) => {
error!("Failed to apply migration {}: {}", name, e);
return Err(e);
}
}
} else {
for (name, sql) in migrations {
if applied.contains(&name.to_string()) {
debug!("Skipping already applied migration: {}", name);
continue;
}
info!("Applying migration: {}", name);
// `unchecked_transaction` because the connection is reached through a
// shared guard rather than `&mut`. Dropping the transaction without
// committing rolls it back, which is exactly what the `?`s below do.
let tx = conn.unchecked_transaction()?;
if let Err(e) = tx.execute_batch(sql) {
error!("Failed to apply migration {} (rolled back): {}", name, e);
return Err(e);
}
if let Err(e) = tx.execute("INSERT INTO _migrations (name) VALUES (?1)", [name]) {
error!("Failed to record migration {} (rolled back): {}", name, e);
return Err(e);
}
tx.commit()?;
info!("Successfully applied migration: {}", name);
}
info!("All migrations completed successfully");
Ok(())
}
/// Get a database service for async-safe operations
///
/// This wraps all blocking database operations in spawn_blocking to prevent
/// freezing the async runtime.
/// A handle to the database service. Cheap: every call returns a clone of
/// the same writer thread and reader pool.
pub fn service(&self) -> RusqliteService {
RusqliteService::new(Arc::clone(&self.conn))
self.service.clone()
}
/// Get the database file path
@@ -166,6 +250,99 @@ mod tests {
assert_eq!(db.path().to_str(), Some(":memory:"));
}
/// A migration that dies partway must leave *nothing* behind.
///
/// SQLite autocommits each statement, so before migrations were wrapped in a
/// transaction the first `ADD COLUMN` of a failing batch stuck while the
/// `_migrations` row was never written. `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`
/// panics on that, so the app never started again.
///
/// TRACES: UR-002 | DR-012 | UT-014
#[test]
fn test_failed_migration_rolls_back_and_stays_retryable() {
let db = Database::open_in_memory().unwrap();
// Statement 1 succeeds, statement 2 fails, statement 3 never runs.
let poisoned = &[(
"900_partially_failing",
"ALTER TABLE downloads ADD COLUMN audit_a TEXT;
ALTER TABLE downloads ADD COLUMN audit_b TEXT FROM NOWHERE;
ALTER TABLE downloads ADD COLUMN audit_c TEXT;",
)][..];
let first = db.migrate_with(poisoned).unwrap_err();
// Nothing from the batch may survive, or the retry cannot re-run it.
assert!(
!has_column(&db, "downloads", "audit_a"),
"statement 1 of a failed migration was left applied: the batch did not roll back"
);
assert!(!has_column(&db, "downloads", "audit_c"));
// And it must not be recorded as applied.
let recorded: i64 = db
.connection()
.lock_safe()
.query_row(
"SELECT COUNT(*) FROM _migrations WHERE name = ?1",
["900_partially_failing"],
|r| r.get(0),
)
.unwrap();
assert_eq!(recorded, 0, "a failed migration must not be recorded");
// The retry must fail the same way it did the first time — reaching the
// real error — rather than tripping over its own leftovers.
let second = db.migrate_with(poisoned).unwrap_err();
assert!(
!second.to_string().contains("duplicate column"),
"the retry hit leftovers from the failed run instead of the real error: {second}"
);
assert_eq!(first.to_string(), second.to_string());
// A corrected migration under the same name then applies cleanly.
let fixed = &[(
"900_partially_failing",
"ALTER TABLE downloads ADD COLUMN audit_a TEXT;
ALTER TABLE downloads ADD COLUMN audit_c TEXT;",
)][..];
db.migrate_with(fixed).unwrap();
assert!(has_column(&db, "downloads", "audit_a"));
assert!(has_column(&db, "downloads", "audit_c"));
}
/// A committed migration is recorded, so it is never applied twice.
///
/// TRACES: UR-002 | DR-012 | UT-014
#[test]
fn test_successful_migration_is_recorded_in_the_same_transaction() {
let db = Database::open_in_memory().unwrap();
let m = &[(
"901_adds_a_column",
"ALTER TABLE downloads ADD COLUMN audit_d TEXT;",
)][..];
db.migrate_with(m).unwrap();
// Re-running must be a no-op, not a "duplicate column" failure.
db.migrate_with(m).unwrap();
assert!(has_column(&db, "downloads", "audit_d"));
}
fn has_column(db: &Database, table: &str, column: &str) -> bool {
let conn = db.connection();
let conn = conn.lock_safe();
let mut stmt = conn
.prepare(&format!("PRAGMA table_info({table})"))
.unwrap();
let mut names = stmt
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.filter_map(|r| r.ok());
names.any(|n| n == column)
}
#[test]
fn test_migrations_run() {
let db = Database::open_in_memory().unwrap();
@@ -762,4 +939,246 @@ mod tests {
assert_eq!(user_id, "user2");
assert_eq!(username, "recent_user");
}
/// A read must not queue behind a long write.
///
/// The database is in WAL mode precisely so readers can run alongside a
/// writer, but every query used to go through one connection behind one
/// mutex — so a big catalog-cache transaction stalled every library page,
/// thumbnail lookup and settings read in the app until it committed.
///
/// TRACES: UR-002 | DR-012 | UT-014
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn reads_do_not_wait_for_an_in_flight_write() {
use crate::storage::db_service::{DatabaseService, Query};
use std::time::{Duration, Instant};
let dir = tempfile::tempdir().unwrap();
let db = Database::open(&dir.path().join("jellytau.db")).unwrap();
let service = db.service();
let writer = service.clone();
let write = tokio::spawn(async move {
writer
.transaction(|_tx| {
std::thread::sleep(Duration::from_millis(600));
Ok(())
})
.await
});
// Let the write take the connection first.
tokio::time::sleep(Duration::from_millis(100)).await;
let started = Instant::now();
let servers: i64 = service
.query_one(Query::new("SELECT COUNT(*) FROM servers"), |r| r.get(0))
.await
.unwrap();
let waited = started.elapsed();
assert_eq!(servers, 0);
assert!(
waited < Duration::from_millis(250),
"a read waited {waited:?} for an unrelated write to commit"
);
write.await.unwrap().unwrap();
}
/// Commit cost: in WAL mode `synchronous = NORMAL` is corruption-safe and
/// skips the per-commit fsync that FULL (the default) pays — on Android
/// flash that is the dominant cost of every small write. A busy timeout
/// lets the reader connections ride out a checkpoint instead of failing.
///
/// TRACES: UR-002 | DR-012 | UT-014
#[test]
fn open_configures_wal_for_interactive_use() {
let dir = tempfile::tempdir().unwrap();
let db = Database::open(&dir.path().join("jellytau.db")).unwrap();
let conn = db.connection();
let conn = conn.lock_safe();
let mode: String = conn
.query_row("PRAGMA journal_mode", [], |r| r.get(0))
.unwrap();
let synchronous: i64 = conn
.query_row("PRAGMA synchronous", [], |r| r.get(0))
.unwrap();
let busy_timeout: i64 = conn
.query_row("PRAGMA busy_timeout", [], |r| r.get(0))
.unwrap();
assert_eq!(mode, "wal");
assert_eq!(synchronous, 1, "expected synchronous = NORMAL");
assert!(busy_timeout > 0, "expected a busy timeout");
}
/// The planner gets statistics: the app used to never run `ANALYZE`, so
/// SQLite guessed between indexes — and for the listing query guessed the
/// `server_id` index, which every row shares, turning an index lookup into
/// a walk of the whole catalogue. `PRAGMA optimize` at open refreshes
/// whatever statistics are missing or stale, bounded by `analysis_limit`.
///
/// TRACES: UR-002 | DR-012 | UT-014
#[test]
fn open_gives_the_planner_statistics() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("jellytau.db");
{
let db = Database::open(&path).unwrap();
let conn = db.connection();
let conn = conn.lock_safe();
conn.execute_batch(
"INSERT INTO servers (id, name, url) VALUES ('s', 'S', 'http://s');",
)
.unwrap();
for i in 0..2000 {
conn.execute(
"INSERT INTO items (id, server_id, name, item_type) VALUES (?1, 's', 'n', 'Audio')",
[format!("i{i}")],
)
.unwrap();
}
}
let db = Database::open(&path).unwrap();
let conn = db.connection();
let conn = conn.lock_safe();
let analysed: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE name = 'sqlite_stat1'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(analysed, 1, "the planner has no statistics");
let items_stats: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_stat1 WHERE tbl = 'items'",
[],
|r| r.get(0),
)
.unwrap();
assert!(items_stats > 0, "no statistics for items");
}
/// Writes a phone-sized catalogue to `$JELLYTAU_BENCH_DB` for timing
/// queries with the `sqlite3` CLI. Not a test; run explicitly with
/// `--ignored`.
#[test]
#[ignore]
fn write_bench_database() {
let Ok(path) = std::env::var("JELLYTAU_BENCH_DB") else {
return;
};
let _ = std::fs::remove_file(&path);
let db = Database::open(&PathBuf::from(&path)).unwrap();
let conn = db.connection();
let conn = conn.lock_safe();
conn.execute_batch(
"BEGIN;
INSERT INTO servers (id, name, url) VALUES ('srv', 'S', 'http://s');
INSERT INTO users (id, server_id, username) VALUES ('u', 'srv', 'u');
INSERT INTO libraries (id, server_id, name, collection_type) VALUES ('tv', 'srv', 'TV', 'tvshows');
INSERT INTO libraries (id, server_id, name, collection_type) VALUES ('music', 'srv', 'Music', 'music');",
)
.unwrap();
let now = "2026-09-23T00:00:00Z";
let mut item = conn
.prepare(
"INSERT INTO items (id, server_id, library_id, parent_id, name, sort_name, item_type,
series_id, season_id, album_id, synced_at)
VALUES (?1, 'srv', ?2, ?3, ?4, ?4, ?5, ?6, ?7, ?8, ?9)",
)
.unwrap();
let none: Option<String> = None;
for s in 0..300 {
let series = format!("series-{s}");
item.execute(rusqlite::params![
series,
"tv",
none,
format!("Show {s}"),
"Series",
none,
none,
none,
now
])
.unwrap();
for n in 0..11 {
let season = format!("{series}-s{n}");
item.execute(rusqlite::params![
season,
"tv",
series,
format!("Season {n}"),
"Season",
series,
none,
none,
now
])
.unwrap();
for e in 0..24 {
let ep = format!("{season}-e{e}");
item.execute(rusqlite::params![
ep,
"tv",
season,
format!("A Title {e}"),
"Episode",
series,
season,
none,
now
])
.unwrap();
conn.execute(
"INSERT INTO user_data (user_id, item_id, playback_position_ticks) VALUES ('u', ?1, 5)",
[&ep],
)
.unwrap();
}
}
}
for a in 0..2000 {
let album = format!("album-{a}");
item.execute(rusqlite::params![
album,
"music",
none,
format!("Album {a}"),
"MusicAlbum",
none,
none,
none,
now
])
.unwrap();
for t in 0..12 {
item.execute(rusqlite::params![
format!("{album}-t{t}"),
"music",
album,
format!("Track {t}"),
"Audio",
none,
none,
album,
now
])
.unwrap();
}
}
for d in 0..400 {
conn.execute(
"INSERT INTO downloads (item_id, user_id, file_path, status) VALUES (?1, 'u', '/x', 'completed')",
[format!("series-{}-s1-e{}", d % 300, d % 24)],
)
.unwrap();
}
drop(item);
// No ANALYZE: the app never runs it, so the planner works without stats.
conn.execute_batch("COMMIT;").unwrap();
}
}
+209
View File
@@ -29,6 +29,9 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
("022_people_fts", MIGRATION_022),
("023_downloads_expiry", MIGRATION_023),
("024_multi_user_profiles", MIGRATION_024),
("025_backfill_item_library_id", MIGRATION_025),
("026_server_catalog_generation", MIGRATION_026),
("027_items_container_id", MIGRATION_027),
];
/// Initial schema migration
@@ -896,6 +899,212 @@ INSERT OR IGNORE INTO download_grants (user_id, download_id)
SELECT d.user_id, d.id FROM downloads d;
"#;
/// Force cached items to be re-fetched so `library_id` is populated.
///
/// `save_to_cache` bound `library_id` NULL for every row it wrote, so nothing in
/// the cache knew which library it came from. The only available association was
/// the `collection_type` ↔ `item_type` taxonomy, which cannot tell two libraries
/// of the same type apart — a server with "TV" and "Shows" served both the same
/// contents — and says nothing at all about a library whose type it does not map
/// (Books, Photos, Collections, or a mixed library where Jellyfin sends no
/// collection type).
///
/// The write path now records the library. Existing rows cannot be repaired
/// locally — the association was never stored — so they are marked stale and
/// re-fetched on next browse, exactly as MIGRATION_018 did for `is_folder`.
///
/// Deliberately does not delete anything: downloads, favourites and playback
/// positions live in other tables and are untouched, and a cleared `synced_at`
/// only means "ask the server again", so an offline user keeps browsing what
/// they already had until the next successful fetch.
///
/// TRACES: UR-007 | DR-278
const MIGRATION_025: &str = r#"
UPDATE items SET synced_at = NULL;
"#;
/// 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.
///
/// This deliberately does **not** clear `synced_at` the way MIGRATION_025 did.
/// The column starts NULL, which reads as "no generation recorded yet", and the
/// first connection after upgrading simply records what it finds. Invalidation
/// happens only when the recorded generation actually *changes* — punishing
/// every existing user with a full re-fetch for a server upgrade that has not
/// happened would cost real bandwidth to defend against nothing. At the time of
/// writing no installed server is on the newer generation at all.
///
/// TRACES: UR-085 | DR-284
const MIGRATION_026: &str = r#"
ALTER TABLE servers ADD COLUMN catalog_generation TEXT;
"#;
/// One canonical "which container lists this item" link, plus an index that
/// serves a listing in display order.
///
/// Jellyfin's `ParentId` is the *storage* parent, not the logical one: in a
/// series without season folders an episode's `ParentId` is the series while
/// its `SeasonId` names a virtual season, and a cached episode may arrive
/// without its season row at all. So listings matched children on four
/// columns at once (`parent_id`, `album_id`, `season_id`, `series_id`). That
/// was slow — the `OR` defeated the planner into walking the whole table — and
/// wrong: every episode carries its series id, so a series answered with its
/// seasons *and* all their episodes.
///
/// `container_id` resolves the logical container once, by rule: an episode
/// belongs to its season (else its series, else its parent), a season to its
/// series, a track to its album, anything else to its parent. It is a VIRTUAL
/// generated column, so every write path — cache, downloads, catalog crawl —
/// is covered without touching any of them, and it cannot drift from the
/// columns it is computed from. The index covers the listing's
/// `ORDER BY sort_name, name` (`sort_name` is usually NULL in the cache).
///
/// The placeholders keep offline navigation intact: an episode whose season
/// or series row was never cached used to surface directly under the series
/// through the `series_id` match. Now it lists under its season, so the season
/// (and series, and a track's album) must exist. They are built from the
/// names the child rows already carry, with `synced_at` NULL — they only show
/// when a download makes them available, and a real row from the server
/// replaces them wholesale (`save_to_cache` upserts every field).
///
/// TRACES: UR-002, UR-007 | DR-013
const MIGRATION_027: &str = r#"
ALTER TABLE items ADD COLUMN container_id TEXT GENERATED ALWAYS AS (
CASE item_type
WHEN 'Episode' THEN COALESCE(season_id, series_id, parent_id)
WHEN 'Season' THEN COALESCE(series_id, parent_id)
WHEN 'Audio' THEN COALESCE(album_id, parent_id)
ELSE parent_id
END
) VIRTUAL;
CREATE INDEX IF NOT EXISTS idx_items_container ON items(container_id, sort_name, name);
INSERT OR IGNORE INTO items (id, server_id, library_id, name, item_type, is_folder, series_id, series_name)
SELECT season_id, server_id, MAX(library_id), COALESCE(MAX(season_name), 'Season'), 'Season', 1,
MAX(series_id), MAX(series_name)
FROM items
WHERE item_type = 'Episode' AND season_id IS NOT NULL
GROUP BY season_id;
INSERT OR IGNORE INTO items (id, server_id, library_id, name, item_type, is_folder)
SELECT series_id, server_id, MAX(library_id), COALESCE(MAX(series_name), 'Series'), 'Series', 1
FROM items
WHERE item_type IN ('Episode', 'Season') AND series_id IS NOT NULL
GROUP BY series_id;
INSERT OR IGNORE INTO items (id, server_id, library_id, name, item_type, is_folder, album_artist)
SELECT album_id, server_id, MAX(library_id), COALESCE(MAX(album_name), 'Album'), 'MusicAlbum', 1,
MAX(album_artist)
FROM items
WHERE item_type = 'Audio' AND album_id IS NOT NULL
GROUP BY album_id;
"#;
#[cfg(test)]
mod migration_027_tests {
use super::*;
use rusqlite::Connection;
fn pre_027_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
let upto = MIGRATIONS
.iter()
.position(|(name, _)| *name == "027_items_container_id")
.expect("migration 027 must be registered");
for (_, sql) in &MIGRATIONS[..upto] {
conn.execute_batch(sql).unwrap();
}
conn.execute_batch(
"INSERT INTO servers (id, name, url) VALUES ('s', 'S', 'http://s');
-- An episode cached without its season or series rows.
INSERT INTO items (id, server_id, name, item_type, parent_id, season_id, season_name,
series_id, series_name, library_id)
VALUES ('ep', 's', 'Pilot', 'Episode', NULL, 'season', 'Season 1',
'show', 'Show', NULL);
-- A track cached without its album.
INSERT INTO items (id, server_id, name, item_type, album_id, album_name, album_artist)
VALUES ('trk', 's', 'Song', 'Audio', 'alb', 'Record', 'Band');
-- A folder child: its container is just its parent.
INSERT INTO items (id, server_id, name, item_type) VALUES ('box', 's', 'Box', 'BoxSet');
INSERT INTO items (id, server_id, name, item_type, parent_id)
VALUES ('film', 's', 'Film', 'Movie', 'box');",
)
.unwrap();
conn
}
fn container(conn: &Connection, id: &str) -> Option<String> {
conn.query_row("SELECT container_id FROM items WHERE id = ?1", [id], |r| {
r.get(0)
})
.unwrap()
}
/// TRACES: UR-002, UR-007 | DR-013
#[test]
fn every_item_resolves_to_its_logical_container() {
let conn = pre_027_db();
conn.execute_batch(MIGRATION_027).unwrap();
assert_eq!(container(&conn, "ep").as_deref(), Some("season"));
assert_eq!(container(&conn, "season").as_deref(), Some("show"));
assert_eq!(container(&conn, "trk").as_deref(), Some("alb"));
assert_eq!(container(&conn, "film").as_deref(), Some("box"));
assert_eq!(container(&conn, "show"), None);
}
/// Containers that were never cached get placeholders named from their
/// children, so an offline episode is still reachable series → season.
///
/// TRACES: UR-002, UR-007 | DR-013
#[test]
fn missing_containers_get_named_placeholders() {
let conn = pre_027_db();
conn.execute_batch(MIGRATION_027).unwrap();
let row = |id: &str| -> (String, String, Option<String>) {
conn.query_row(
"SELECT name, item_type, synced_at FROM items WHERE id = ?1",
[id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap()
};
assert_eq!(row("season"), ("Season 1".into(), "Season".into(), None));
assert_eq!(row("show"), ("Show".into(), "Series".into(), None));
assert_eq!(row("alb"), ("Record".into(), "MusicAlbum".into(), None));
}
/// Listing a container is one index range, already in display order.
///
/// TRACES: UR-002, UR-007 | DR-013
#[test]
fn a_container_listing_is_an_ordered_index_range() {
let conn = pre_027_db();
conn.execute_batch(MIGRATION_027).unwrap();
let plan: Vec<String> = conn
.prepare(
"EXPLAIN QUERY PLAN SELECT id FROM items
WHERE container_id = ?1 ORDER BY sort_name, name",
)
.unwrap()
.query_map(["season"], |r| r.get::<_, String>(3))
.unwrap()
.map(Result::unwrap)
.collect();
let plan = plan.join("\n");
assert!(plan.contains("idx_items_container"), "{plan}");
assert!(
!plan.contains("TEMP B-TREE"),
"listing needs a sort step:\n{plan}"
);
}
}
#[cfg(test)]
mod migration_024_tests {
use super::*;
+7 -10
View File
@@ -122,7 +122,7 @@ impl ThumbnailCache {
{
let path = PathBuf::from(&path_str);
if path.exists() {
self.touch(&db, item_id, image_type, Some(tag)).await;
self.touch(&db, item_id, image_type, Some(tag));
return Some(path);
}
// File gone — drop the stale row and fall through to the tag-agnostic
@@ -157,7 +157,7 @@ impl ThumbnailCache {
let path_str: String = db.query_optional(any_tag, |row| row.get(0)).await.ok()??;
let path = PathBuf::from(&path_str);
if path.exists() {
self.touch(&db, item_id, image_type, None).await;
self.touch(&db, item_id, image_type, None);
Some(path)
} else {
None
@@ -166,13 +166,7 @@ impl ThumbnailCache {
/// Update `last_accessed` for LRU tracking. When `tag` is `Some`, scope to
/// that exact row; when `None`, touch every row for the item + type.
async fn touch(
&self,
db: &Arc<RusqliteService>,
item_id: &str,
image_type: &str,
tag: Option<&str>,
) {
fn touch(&self, db: &Arc<RusqliteService>, item_id: &str, image_type: &str, tag: Option<&str>) {
let query = match tag {
Some(tag) => Query::with_params(
"UPDATE thumbnails SET last_accessed = CURRENT_TIMESTAMP
@@ -192,7 +186,10 @@ impl ThumbnailCache {
],
),
};
let _ = db.execute(query).await;
// Detached: an LRU timestamp is bookkeeping, and a grid scroll does
// one of these per visible poster — awaiting each write held every
// thumbnail lookup behind the writer queue.
db.execute_detached(query);
}
/// Save thumbnail to cache
+3 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "JellyTau",
"version": "0.11.5",
"version": "0.14.0",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
@@ -22,8 +22,8 @@
}
],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: asset: http://asset.localhost http: https:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:; connect-src 'self' ipc: http://ipc.localhost http: https:; worker-src 'self' blob:; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'",
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: asset: http://asset.localhost http: https:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:; connect-src 'self' ipc: http://ipc.localhost http: https: ws: wss:; worker-src 'self' blob:; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'",
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: asset: http://asset.localhost http: https:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:; connect-src 'self' ipc: http://ipc.localhost; worker-src 'self'; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'",
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: asset: http://asset.localhost http: https:; media-src 'self' blob: asset: http://asset.localhost http://127.0.0.1:* http: https:; connect-src 'self' ipc: http://ipc.localhost ws: wss:; worker-src 'self'; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'self'",
"assetProtocol": {
"enable": true,
"scope": [
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://schema.tauri.app/config/2",
"bundle": {
"resources": {
"windows-libs/libmpv-2.dll": "libmpv-2.dll",
"../THIRD_PARTY_NOTICES.md": "licenses/THIRD_PARTY_NOTICES.md",
"../packaging/windows/LGPL-3.0.txt": "licenses/LGPL-3.0.txt",
"../packaging/windows/GPL-3.0.txt": "licenses/GPL-3.0.txt",
"../LICENSE": "licenses/LICENSE-JellyTau-MIT.txt"
}
}
}
+127 -88
View File
@@ -21,7 +21,7 @@ async playerPlayItem(item: PlayItemRequest) : Promise<PlayerStatus> {
},
/**
* Exit background-audio mode: stop the native audio player and return its final
* position so the frontend can reload the WebView `<video>` there (UR-040).
* position so the frontend can reload the video there (UR-040).
*
* Returns the position in seconds. The sleep timer is intentionally left
* untouched — if it fired while backgrounded, playback is already stopped and
@@ -52,8 +52,8 @@ async playerBackgroundAction(backgroundAudioArmed: boolean, inPictureInPicture:
* `stream_url` MUST be an audio-only URL (see
* `get_audio_only_stream_url_for_video`). The item is created as
* `MediaType::Audio` so it starts an audio session and loads into the native
* backend with `mediaType="audio"` — the WebView `<video>` is torn down on the
* frontend side, so exactly one audio source is ever active.
* backend with `mediaType="audio"`, replacing the video, so exactly one audio
* source is ever active.
*
* This deliberately goes through the queue-based `play_item` path (NOT a
* side-channel) so end-of-track lands in `on_playback_ended`, which already
@@ -66,9 +66,14 @@ async playerEnterBackgroundAudio(item: PlayItemRequest, positionSeconds: number)
return await TAURI_INVOKE("player_enter_background_audio", { item, positionSeconds });
},
/**
* TRACES: UR-040 | DR-052 | UT-061, IT-013
* Returns the item the native player is on and its absolute position. The
* item matters: an episode that ended while backgrounded has already advanced
* in the backend, so reloading the video the webview was mounted with would
* bring back the previous episode. (DR-296)
*
* TRACES: UR-040, UR-023 | DR-052, DR-296 | UT-061, IT-013
*/
async playerExitBackgroundAudio() : Promise<number> {
async playerExitBackgroundAudio() : Promise<BackgroundAudioResume> {
return await TAURI_INVOKE("player_exit_background_audio");
},
/**
@@ -124,11 +129,11 @@ async playerSeek(position: number) : Promise<PlayerStatus> {
* - Direct play streams: Use native seeking
* - Transcoded non-HLS: Request new stream URL from server starting at seek position
*
* For native (non-HTML5) backends, this command handles the entire stream reload
* internally. For HTML5 backends, it returns the new URL for the frontend to handle.
* The backend always handles the seek itself, including re-opening a stream,
* since every video renderer is native (DR-235).
*/
async playerSeekVideo(repositoryHandle: string, position: number, mediaSourceId: string | null, audioStreamIndex: number | null, useHtml5: boolean) : Promise<VideoSeekResponse> {
return await TAURI_INVOKE("player_seek_video", { repositoryHandle, position, mediaSourceId, audioStreamIndex, useHtml5 });
async playerSeekVideo(repositoryHandle: string, position: number, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<VideoSeekResponse> {
return await TAURI_INVOKE("player_seek_video", { repositoryHandle, position, mediaSourceId, audioStreamIndex });
},
async playerSetVolume(volume: number) : Promise<PlayerStatus> {
return await TAURI_INVOKE("player_set_volume", { volume });
@@ -152,9 +157,6 @@ async playerSetAudioTrack(streamIndex: number) : Promise<PlayerStatus> {
* carries the requested track at all** — see
* [`determine_audio_track_switch_strategy`]:
*
* - An HTML5 `<video>` element has no track-selection API, so the stream is
* always re-opened at the chosen `AudioStreamIndex` and the frontend seeks
* the reloaded element back to `position`.
* - A native backend playing a **direct play** holds the source file with
* every track in it, so ExoPlayer selects in place by track-group index.
* - A native backend playing a **transcode** does not. Jellyfin builds a
@@ -170,23 +172,21 @@ async playerSetAudioTrack(streamIndex: number) : Promise<PlayerStatus> {
* audio track index` and dropped the request — the default track just kept
* playing, with nothing in the UI saying so.
*
* libmpv implements neither selection nor reload here — it is the audio-only
* backend and leaves `PlayerBackend::set_audio_track` at its
* `not_implemented()` default, which is why IR-019 is met by these paths
* rather than by MPV.
* mpv selects in place the same way (`mpv_tracks::select_audio`, by position in
* the file's audio tracks), and re-opens a transcode through the same path.
*
* TRACES: UR-021, UR-005 | IR-019, DR-024, DR-258
*/
async playerSwitchAudioTrack(repositoryHandle: string, streamIndex: number, arrayIndex: number, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null) : Promise<AudioTrackSwitchResponse> {
return await TAURI_INVOKE("player_switch_audio_track", { repositoryHandle, streamIndex, arrayIndex, useHtml5, currentPosition, mediaSourceId });
async playerSwitchAudioTrack(repositoryHandle: string, streamIndex: number, arrayIndex: number, currentPosition: number | null, mediaSourceId: string | null) : Promise<AudioTrackSwitchResponse> {
return await TAURI_INVOKE("player_switch_audio_track", { repositoryHandle, streamIndex, arrayIndex, currentPosition, mediaSourceId });
},
/**
* Set (or clear, with `None`) the active subtitle track on a native backend.
*
* On Android this indexes ExoPlayer's *text track groups* — i.e. the position
* of the sideloaded `MediaItem.SubtitleConfiguration`, not the Jellyfin stream
* index. The HTML5 path never reaches here; it toggles its own `<track>`
* children. libmpv implements neither, leaving the trait default in place.
* index. mpv gives it the same meaning: the position in the sideloaded WebVTT
* list, loaded as external subtitle files (`mpv_tracks`).
*
* TRACES: UR-020 | IR-018, DR-023
*/
@@ -278,9 +278,7 @@ async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string
* A cap is a property of the stream the server is producing, so unlike a volume
* change it cannot be applied to a stream already in flight — the stream has to
* be re-opened at the new quality and resumed at the current position. That is
* the same reload the transcoded-seek and audio-track paths use, and the same
* two-sided split: HTML5 gets the URL back and reloads its own element, while a
* native backend is reloaded here.
* the same reload the transcoded-seek and audio-track paths use, done here.
*
* The change applies to **this playback only**. The in-player picker is a
* "this film, this connection" control and its doc has always said so, but it
@@ -294,8 +292,8 @@ async playerGetStreamingQualities() : Promise<([StreamingQuality, string, string
*
* TRACES: UR-074, UR-079 | DR-162, DR-226
*/
async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, useHtml5: boolean, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> {
return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, useHtml5, currentPosition, mediaSourceId, audioStreamIndex });
async playerSetStreamQuality(repositoryHandle: string, quality: StreamingQuality, currentPosition: number | null, mediaSourceId: string | null, audioStreamIndex: number | null) : Promise<StreamQualityResponse> {
return await TAURI_INVOKE("player_set_stream_quality", { repositoryHandle, quality, currentPosition, mediaSourceId, audioStreamIndex });
},
/**
* Set sleep timer mode
@@ -342,7 +340,7 @@ async playerPlayNextEpisode(item: PlayItemRequest) : Promise<PlayerStatus> {
/**
* Handle playback ended event - triggers autoplay decision logic
* This is called from:
* - Frontend when HTML5 video ends (Linux/desktop) - passes itemId + repositoryHandle for the video
* - Frontend when a video ends - passes itemId + repositoryHandle for the video
* - Frontend when audio track ends via backend event - no itemId/repositoryHandle needed
* - Android JNI callback also triggers this logic directly
*
@@ -375,20 +373,20 @@ async playerRecoverStream() : Promise<boolean> {
return await TAURI_INVOKE("player_recover_stream");
},
/**
* Report an HTML5 <video> state change (playing/paused/loading/stopped/idle).
* Report a webview media element's state change (playing/paused/loading/stopped/idle).
*/
async playerReportState(state: string, mediaId: string | null) : Promise<null> {
return await TAURI_INVOKE("player_report_state", { state, mediaId });
},
/**
* Report an HTML5 <video> position tick (seconds). The adapter should throttle
* Report a webview media element's position tick (seconds). The adapter should throttle
* these to roughly match the native backends' ~250ms cadence.
*/
async playerReportPosition(position: number, duration: number) : Promise<null> {
return await TAURI_INVOKE("player_report_position", { position, duration });
},
/**
* Report that the HTML5 <video> finished loading and knows its duration.
* Report that a webview media element finished loading and knows its duration.
*/
async playerReportMediaLoaded(duration: number) : Promise<null> {
return await TAURI_INVOKE("player_report_media_loaded", { duration });
@@ -1590,6 +1588,16 @@ async repositoryGetSeriesEpisodes(handle: string, seriesId: string) : Promise<Me
async repositoryGetSeriesCurrentEpisode(handle: string, seriesId: string) : Promise<MediaItem | null> {
return await TAURI_INVOKE("repository_get_series_current_episode", { handle, seriesId });
},
/**
* A series' episodes and the viewer's current episode, from one season
* fan-out. The series page used to ask for these as two commands, each of
* which walked every season.
*
* TRACES: UR-062 | DR-101, DR-295
*/
async repositoryGetSeriesView(handle: string, seriesId: string) : Promise<SeriesView> {
return await TAURI_INVOKE("repository_get_series_view", { handle, seriesId });
},
/**
* Erase the viewer's watch history for an item.
*
@@ -2119,11 +2127,7 @@ export type AudioTrackSwitchResponse =
/**
* Native backend handled it (Android ExoPlayer)
*/
{ strategy: "native"; success: boolean } |
/**
* HTML5 needs to reload stream with new audio track
*/
{ strategy: "reloadStream"; selection: StreamSelection; position: number }
{ strategy: "native"; success: boolean }
/**
* Authentication result
*/
@@ -2135,7 +2139,18 @@ export type AuthServerInfo = { name: string; version: string; id: string;
/**
* Normalized server URL with protocol and no trailing slash
*/
normalizedUrl: string }
normalizedUrl: string;
/**
* Whether this build can talk to this server, as an **opaque state**.
*
* The version string above is informational — for display and for the log.
* This is the judgement, made in Rust, because deciding whether an API
* version is usable is domain reasoning: the frontend must never compare a
* version number, for the same reason it never receives an item-type list.
*
* TRACES: UR-085 | DR-286
*/
compatibility: ServerCompatibility }
/**
* Autoplay settings (controls next episode behavior)
*/
@@ -2169,6 +2184,22 @@ export type BackgroundAction =
* Stop making sound. The user did not ask for background playback.
*/
"pause"
/**
* Where playback stands when a background-audio handoff returns to the
* foreground. See [`PlayerController::background_audio_resume`].
*
* TRACES: UR-040, UR-023 | DR-296
*/
export type BackgroundAudioResume = {
/**
* Item the native audio player is on — `None` if the queue emptied (e.g.
* the sleep timer stopped playback while backgrounded).
*/
itemId: string | null;
/**
* Absolute position in that item, in seconds.
*/
positionSeconds: number }
/**
* Smart caching configuration
*/
@@ -2794,9 +2825,8 @@ seriesId?: string | null;
/**
* Subtitle tracks to sideload, with URLs the frontend has already resolved.
*
* Only the native backends use these: on Android they become the
* `MediaItem.SubtitleConfiguration`s ExoPlayer renders. The HTML5 path
* builds its own `<track>` children instead and ignores this list.
* On Android they become the `MediaItem.SubtitleConfiguration`s ExoPlayer
* renders; mpv loads them as external subtitle files (`mpv_tracks`).
*
* **Order is the contract.** `player_set_subtitle_track(n)` reaches
* `JellyTauPlayer.setSubtitleTrack(n)`, which indexes into ExoPlayer's
@@ -2865,17 +2895,14 @@ startPosition?: number | null }
export type PlaybackCapabilities = {
/**
* True when audio is rendered by a webview `<audio>` element rather than a
* native backend. Native audio exists on Linux (mpv) and Android
* (ExoPlayer); everything else (Windows, future desktops) uses the webview.
* native backend. Native audio exists on Linux and Windows (mpv) and
* Android (ExoPlayer); only an unported desktop uses the webview.
*
* Video has no counterpart: it is always drawn by the native backend, behind
* the transparent webview (DR-235) — there is no webview video renderer
* left to report.
*/
usesWebviewAudio: boolean;
/**
* True when video can be rendered by a native surface composited *behind*
* a transparent webview. Android only: ExoPlayer draws into a SurfaceView
* beneath the WebView. Linux cannot do this (WebKitGTK/Wayland
* compositing), so it stays on the HTML5 element.
*/
supportsNativeVideo: boolean }
usesWebviewAudio: boolean }
/**
* Playback information
*/
@@ -3087,14 +3114,6 @@ export type PlayerState =
* Response for player state queries
*/
export type PlayerStatus = { state: PlayerState; position: number; duration: number | null; volume: number; muted: boolean; shuffle: boolean; repeat: RepeatMode;
/**
* Backend being used (native = ExoPlayer/libmpv, html5 = fallback)
*/
backend: VideoBackend;
/**
* Whether frontend should render HTML5 video element
*/
useHtml5Element: boolean;
/**
* Media item from either local queue or remote session
*/
@@ -3150,9 +3169,8 @@ export type PlayerStatusEvent =
{ type: "sleep_timer_changed"; mode: SleepTimerMode; remaining_seconds: number } |
/**
* Time-based sleep timer expired: playback must stop. The backend stops
* its own (MPV/ExoPlayer) playback, but HTML5 video on Linux plays in the
* webview outside the backend's control — the frontend pauses it on this
* event.
* its own (MPV/ExoPlayer) playback; the frontend pauses the active adapter
* on this event, which reaches a webview `<audio>` element where one plays.
*/
{ type: "sleep_timer_expired" } |
/**
@@ -3197,19 +3215,19 @@ export type PlayerStatusEvent =
{ type: "remote_disconnect_requested" } |
/**
* Backend-originated control command targeting the active frontend player
* adapter (the HTML5 <video> that lives in the webview, which Rust cannot
* drive directly). Emitted by control paths like the sleep timer, lockscreen,
* or remote so they can pause/play/seek/stop the webview element.
* adapter — the webview `<audio>` element, which Rust cannot drive
* directly. Emitted by control paths like the sleep timer, lockscreen, or
* remote so they can pause/play/seek/stop it.
* `playerEvents.ts` routes this to the active PlayerAdapter via the facade.
*/
{ type: "control_command"; action: string; position: number | null } |
/**
* Ask the frontend webview `<audio>` element to load and play a stream.
*
* Emitted by `WebviewAudioBackend` on platforms with no native audio
* backend (e.g. Windows): audio-only playback is rendered by an `<audio>`
* element in the webview, mirroring how all video already renders through
* the webview `<video>`. The element then reports its state/position back
* Emitted by `WebviewAudioBackend` on a desktop with no native audio
* backend (none that ships: Linux and Windows have mpv): audio-only
* playback is rendered by an `<audio>` element in the webview. The element
* then reports its state/position back
* through the `player_report_*` commands, so the Rust controller stays the
* single source of truth. Subsequent play/pause/seek/stop reach the element
* via `ControlCommand`.
@@ -3428,6 +3446,47 @@ export type SecurityStatus = { usingKeyring: boolean; storageType: string }
* Audio track preference for a series
*/
export type SeriesAudioPreference = { seriesId: string; audioTrackDisplayTitle: string | null; audioTrackLanguage: string | null; audioTrackIndex: number | null }
/**
* A series' episodes and the one the viewer is up to, from **one** season
* fan-out.
*
* The series page needs both, and asked for them as two commands; each walked
* every season, so every visit listed the show twice. One call, one walk.
*
* TRACES: UR-062 | DR-101, DR-295
*/
export type SeriesView = { episodes: MediaItem[]; current: MediaItem | null }
/**
* The verdict on a server's version.
*
* Deliberately three states rather than a boolean. "Unrecognised" is not a
* failure: a server newer than this build resolves forward and works, and
* refusing it would make every JellyTau release expire the moment the server
* upgrades. Only a server below the supported floor is refused, where failure
* is certain rather than merely likely.
*
* TRACES: UR-085 | DR-286
*/
export type ServerCompatibility =
/**
* A generation this build knows and was tested against.
*/
{ type: "supported" } |
/**
* Parsed, but newer than anything this build knows. Treated as the newest
* known generation; everything works, and this exists so the UI *may*
* mention it rather than so it must.
*/
{ type: "newerThanKnown" } |
/**
* The version string could not be parsed. Treated as supported — we do not
* refuse a server on the strength of not understanding its version string.
*/
{ type: "unknownVersion" } |
/**
* Below the supported floor. This one is a refusal.
*/
{ type: "tooOld"; minimum: string }
/**
* Server info returned to frontend
*/
@@ -3523,11 +3582,7 @@ export type StreamQualityResponse =
*
* TRACES: UR-074, UR-079 | DR-226, DR-227
*/
{ strategy: "native"; selection: StreamSelection; position: number } |
/**
* HTML5 must reload its element with this selection.
*/
{ strategy: "reloadStream"; selection: StreamSelection; position: number }
{ strategy: "native"; selection: StreamSelection; position: number }
/**
* Everything a player backend needs to open a stream, and everything the UI
* needs to describe it.
@@ -3756,18 +3811,6 @@ playbackPositionMs?: number | null; isPlayed?: boolean | null; isFavorite?: bool
* User info returned to frontend
*/
export type UserInfo = { id: string; serverId: string; username: string; isActive: boolean }
/**
* Backend type for video playback
*/
export type VideoBackend =
/**
* Native backend (ExoPlayer on Android, libmpv on Linux)
*/
"native" |
/**
* HTML5 video element fallback
*/
"html5"
/**
* Response for video seek operations
*/
@@ -3775,11 +3818,7 @@ export type VideoSeekResponse =
/**
* Use native seeking (HLS or direct stream)
*/
{ strategy: "native"; position: number } |
/**
* Reload stream from new position (transcoded non-HLS)
*/
{ strategy: "reloadStream"; selection: StreamSelection; seek_offset: number }
{ strategy: "native"; position: number }
/**
* Video playback settings
*/
+18 -1
View File
@@ -3,7 +3,13 @@
// NO direct HTTP calls - everything routes through Rust backend
import { commands } from "./bindings";
import type { DownloadDiskUsage, JRayActor, SearchScope, StreamSelection } from "./bindings";
import type {
DownloadDiskUsage,
JRayActor,
SearchScope,
SeriesView,
StreamSelection,
} from "./bindings";
import type { QualityPreset } from "./quality-presets";
import type {
Library,
@@ -164,6 +170,17 @@ export class RepositoryClient {
return commands.repositoryGetSeriesCurrentEpisode(this.ensureHandle(), seriesId);
}
/**
* A series' episodes and the episode the viewer is up to, from one season
* fan-out in Rust. Prefer this over calling `getSeriesEpisodes` and
* `getSeriesCurrentEpisode` together — each of those walks every season.
*
* TRACES: UR-062 | DR-101, DR-295
*/
async getSeriesView(seriesId: string): Promise<SeriesView> {
return commands.repositoryGetSeriesView(this.ensureHandle(), seriesId);
}
/**
* Erase watch history for an item. On a series or season the server applies
* it to everything inside, so the container returns to "never watched".
@@ -1,5 +1,6 @@
<script lang="ts">
import { downloads, type DownloadInfo } from "$lib/stores/downloads";
import { describeProgress, formatBytes } from "./downloadProgress";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("DownloadItem");
@@ -10,20 +11,8 @@
let { download }: Props = $props();
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
function formatProgress(): string {
if (!download.fileSize) {
return formatBytes(download.bytesDownloaded);
}
return `${formatBytes(download.bytesDownloaded)} / ${formatBytes(download.fileSize)}`;
}
// Exact, estimated, or unknown total — see downloadProgress.ts (DR-290).
const view = $derived(describeProgress(download));
function getStatusColor(): string {
switch (download.status) {
@@ -201,15 +190,24 @@
<!-- Progress Bar (for active/paused downloads) -->
{#if download.status === "downloading" || download.status === "paused"}
<div class="w-full bg-gray-700 rounded-full h-2 mb-2">
<div
class="h-2 rounded-full transition-all duration-300 {getStatusColor()}"
style="width: {download.progress * 100}%"
></div>
<div class="w-full bg-gray-700 rounded-full h-2 mb-2 overflow-hidden">
{#if view.kind === "indeterminate"}
<!-- No total to measure against: a moving band, not a bar stuck at 0% -->
<div
class="h-2 w-1/3 rounded-full {getStatusColor()} {download.status === 'downloading'
? 'animate-indeterminate'
: ''}"
></div>
{:else}
<div
class="h-2 rounded-full transition-all duration-300 {getStatusColor()}"
style="width: {view.percent}%"
></div>
{/if}
</div>
<div class="flex items-center justify-between text-xs text-gray-400">
<span>{Math.round(download.progress * 100)}%</span>
<span>{formatProgress()}</span>
<span>{view.percentLabel}</span>
<span>{view.label}</span>
</div>
{:else if download.status === "completed"}
<p class="text-xs text-gray-400">{formatBytes(download.bytesDownloaded)}</p>
@@ -365,3 +363,18 @@
</div>
</div>
</div>
<style>
/* A band sweeping the track: "still moving, size unknown". */
@keyframes indeterminate {
from {
transform: translateX(-100%);
}
to {
transform: translateX(300%);
}
}
.animate-indeterminate {
animation: indeterminate 1.4s ease-in-out infinite;
}
</style>

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