Compare commits

..
10 Commits
Author SHA1 Message Date
dtourolle 24d85f3738 chore(release): v0.12.1
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 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) Waiting to run
Build & Release / Build Windows (push) Waiting to run
Build & Release / Build Android (push) Waiting to run
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 / Android Compile Check (push) Waiting to run
🏗️ Build and Test JellyTau / Supply Chain (push) Successful in 1m7s
Traceability Validation / Check Requirement Traces (push) Successful in 31s
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
30 changed files with 3709 additions and 896 deletions
+108
View File
@@ -9,6 +9,114 @@ 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.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,
+22
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
@@ -476,7 +479,17 @@ Internal architecture, components, and application logic.
| 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 |
---
@@ -568,6 +581,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 |
---
@@ -842,6 +856,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 |
---
+6 -3
View File
@@ -27,15 +27,18 @@ 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-289**. 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`) |
+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.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jellytau",
"version": "0.11.6",
"version": "0.12.1",
"description": "A cross-platform Jellyfin client built with Tauri, SvelteKit and Rust.",
"author": "Duncan Tourolle <duncan@tourolle.paris>",
"license": "MIT",
+109 -3
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.6"
version = "0.12.1"
dependencies = [
"aes-gcm",
"argon2",
@@ -2253,6 +2319,7 @@ dependencies = [
"tokio-util",
"urlencoding",
"uuid",
"wiremock",
"zip 2.4.2",
]
@@ -2417,6 +2484,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 +2793,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 +4000,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",
@@ -6430,6 +6513,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"
+2 -1
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.6"
version = "0.12.1"
description = "A cross-platform Jellyfin client"
authors = ["Duncan Tourolle <duncan@tourolle.paris>"]
license = "MIT"
@@ -150,6 +150,7 @@ ndk-context = "0.1"
[dev-dependencies]
tempfile = "3.24.0"
wiremock = "0.6.5"
[features]
# Exposes the MediaPlayer conformance suite and the `player-conformance` binary
+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::*;
+231 -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");
@@ -1216,3 +1342,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);
}
}
+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),
+1 -1
View File
@@ -4475,7 +4475,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(),
},
+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"
);
}
+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"
);
}
}
}
+4 -4
View File
@@ -434,7 +434,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 +442,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 +451,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"
);
}
+6
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;
+172 -325
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
@@ -2713,11 +2592,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 +2602,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 +2623,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 +2631,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 +2665,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 +2673,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 +2706,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 +2714,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 +2751,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 +2766,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 +2795,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 +2827,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 +2870,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 +2911,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 +2930,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 +2971,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 +3153,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 +3608,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 +3634,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]
@@ -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"));
}
}
+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(),
}
}
+20
View File
@@ -30,6 +30,7 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
("023_downloads_expiry", MIGRATION_023),
("024_multi_user_profiles", MIGRATION_024),
("025_backfill_item_library_id", MIGRATION_025),
("026_server_catalog_generation", MIGRATION_026),
];
/// Initial schema migration
@@ -921,6 +922,25 @@ 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;
"#;
#[cfg(test)]
mod migration_024_tests {
use super::*;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "JellyTau",
"version": "0.11.6",
"version": "0.12.1",
"identifier": "com.dtourolle.jellytau",
"build": {
"beforeDevCommand": "bun run dev",
+43 -1
View File
@@ -2135,7 +2135,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)
*/
@@ -3428,6 +3439,37 @@ 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 }
/**
* 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
*/
-38
View File
@@ -6,7 +6,6 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import {
getCachedImageUrl,
getCacheStats,
setCacheLimit,
clearCache,
@@ -50,43 +49,6 @@ describe("image cache service", () => {
vi.clearAllMocks();
});
describe("getCachedImageUrl", () => {
it("should build server URL with default image type", async () => {
const url = await getCachedImageUrl("http://server.local:8096", "item-123");
expect(url).toContain("http://server.local:8096/Items/item-123/Images/Primary");
});
it("should build server URL with custom image type", async () => {
const url = await getCachedImageUrl("http://server.local:8096", "item-123", "Backdrop");
expect(url).toContain("Backdrop");
});
it("should include image options in URL", async () => {
const url = await getCachedImageUrl("http://server.local:8096", "item-123", "Primary", {
maxWidth: 300,
maxHeight: 400,
quality: 90,
tag: "abc123",
});
expect(url).toContain("maxWidth=300");
expect(url).toContain("maxHeight=400");
expect(url).toContain("quality=90");
expect(url).toContain("tag=abc123");
});
it("should trigger background caching", async () => {
const { invoke } = await import("@tauri-apps/api/core");
const invokeSpy = vi.mocked(invoke);
await getCachedImageUrl("http://server.local:8096", "item-123");
const saveCall = invokeSpy.mock.calls.find((call) => call[0] === "thumbnail_save");
expect(saveCall).toBeDefined();
expect(saveCall![1]).toHaveProperty("itemId", "item-123");
expect(saveCall![1]).toHaveProperty("imageType", "Primary");
});
});
describe("cache statistics", () => {
it("should get cache statistics", async () => {
const stats = await getCacheStats();
+18 -63
View File
@@ -1,11 +1,23 @@
// Image cache service - Handles lazy caching of thumbnails with LRU eviction
// TRACES: UR-007 | DR-016
// Image cache service — cache statistics, limits and eviction.
//
// This module used to also export `getCachedImageUrl`, which built
// `${serverUrl}/Items/${itemId}/Images/${imageType}` in the frontend. That was a
// Jellyfin route in the presentation layer — domain logic by this project's own
// litmus test (would it change if Jellyfin changed its API?) — and it was
// **dead**: nothing outside this file and its test ever called it. The live path
// is CachedImage.svelte -> commands.imageGetUrl -> Rust, which was already
// correct. It was deleted rather than migrated (DR-285).
//
// Note for whoever touches the CSP next: 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 and is a
// candidate for removal. Left in place here deliberately — 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.
//
// TRACES: UR-007, UR-085 | DR-016, DR-285
import { convertFileSrc } from "@tauri-apps/api/core";
import { commands } from "$lib/api/bindings";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("ImageCache");
/**
* Statistics about the thumbnail cache
@@ -16,63 +28,6 @@ export interface ImageCacheStats {
limitBytes: number;
}
/**
* Get an image URL, checking cache first then falling back to server.
* Triggers background caching if not cached.
*
* @param serverUrl - The Jellyfin server base URL
* @param itemId - The Jellyfin item ID
* @param imageType - The image type (Primary, Backdrop, etc.)
* @param options - Image options (maxWidth, maxHeight, quality, tag)
* @returns The image URL (local asset URL if cached, server URL otherwise)
*/
export async function getCachedImageUrl(
serverUrl: string,
itemId: string,
imageType: string = "Primary",
options: {
maxWidth?: number;
maxHeight?: number;
quality?: number;
tag?: string;
} = {},
): Promise<string> {
const tag = options.tag || "default";
// Try to get cached version
try {
const cachedPath = await commands.thumbnailGetCached(itemId, imageType, tag);
if (cachedPath) {
// Convert file path to asset URL for Tauri. This is the only remaining
// convertFileSrc caller, which is why the asset-protocol scope is narrowed
// to $APPDATA/thumbnails/** — a path outside it resolves to nothing.
// TRACES: UR-012 | DR-134, DR-198
return convertFileSrc(cachedPath);
}
} catch (e) {
log.debug("Failed to check thumbnail cache:", e);
}
// Build server URL
const params = new URLSearchParams();
if (options.maxWidth) params.set("maxWidth", options.maxWidth.toString());
if (options.maxHeight) params.set("maxHeight", options.maxHeight.toString());
if (options.quality) params.set("quality", options.quality.toString());
if (options.tag) params.set("tag", options.tag);
const serverImageUrl = `${serverUrl}/Items/${itemId}/Images/${imageType}?${params.toString()}`;
// Trigger background caching (fire and forget)
commands.thumbnailSave(itemId, imageType, tag, serverImageUrl).catch((e) => {
// Silently fail - caching is best-effort
log.debug("Background thumbnail cache failed:", e);
});
// Return server URL for immediate display
return serverImageUrl;
}
/**
* Get thumbnail cache statistics
*/
+34
View File
@@ -0,0 +1,34 @@
/**
* TRACES: UR-085 | DR-286
*/
import { describe, it, expect } from "vitest";
import { compatibilityNotice } from "./serverCompatibility";
describe("server compatibility notice", () => {
it("says nothing about a supported server", () => {
expect(compatibilityNotice({ type: "supported" }, "12.0.0")).toBeNull();
});
it("does not interrupt anyone over a version it could not parse", () => {
// Refusing, or even warning, on an unreadable version string would punish
// the user for a parsing limitation of ours.
expect(compatibilityNotice({ type: "unknownVersion" }, "weird-build")).toBeNull();
});
it("mentions a newer-than-known server without blocking it", () => {
const notice = compatibilityNotice({ type: "newerThanKnown" }, "13.0.0");
expect(notice).not.toBeNull();
expect(notice!.blocking).toBe(false);
expect(notice!.tone).toBe("warning");
expect(notice!.message).toContain("13.0.0");
});
it("blocks a server below the floor and names the floor", () => {
const notice = compatibilityNotice({ type: "tooOld", minimum: "10.10" }, "10.9.11");
expect(notice).not.toBeNull();
expect(notice!.blocking).toBe(true);
expect(notice!.tone).toBe("error");
expect(notice!.message).toContain("10.9.11");
expect(notice!.message).toContain("10.10");
});
});
+54
View File
@@ -0,0 +1,54 @@
// Presentation of the backend's server-compatibility verdict.
//
// The decision is Rust's — see `ServerCompatibility` in `auth/mod.rs`. This file
// decides only how it *reads*, which is presentation and changes only if the UI
// is redesigned. Nothing here compares a version number, and nothing here may
// start to: the backend sends an opaque state precisely so the frontend cannot.
//
// TRACES: UR-085 | DR-286
import type { ServerCompatibility } from "$lib/api/bindings";
export interface CompatibilityNotice {
/** Blocks going on to the login step. Only a server below the floor does. */
blocking: boolean;
tone: "error" | "warning";
message: string;
}
/**
* What to show the user about a server's version, or `null` when there is
* nothing worth saying which is the common case.
*/
export function compatibilityNotice(
compatibility: ServerCompatibility,
serverVersion: string,
): CompatibilityNotice | null {
switch (compatibility.type) {
case "supported":
return null;
case "unknownVersion":
// Not worth interrupting anyone over: the server almost certainly works,
// and we simply could not read what it called itself.
return null;
case "newerThanKnown":
return {
blocking: false,
tone: "warning",
message:
`This server (${serverVersion}) is newer than this version of JellyTau. ` +
`It should work normally — update the app if anything looks wrong.`,
};
case "tooOld":
return {
blocking: true,
tone: "error",
message:
`This server runs Jellyfin ${serverVersion}. JellyTau needs ` +
`${compatibility.minimum} or newer.`,
};
}
}
+21
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { auth, isAuthenticated, isLoading, authError } from "$lib/stores/auth";
import { compatibilityNotice } from "$lib/utils/serverCompatibility";
let step = $state<"server" | "login">("server");
let serverUrl = $state("");
@@ -11,6 +12,8 @@
let connecting = $state(false);
let loggingIn = $state(false);
let localError = $state<string | null>(null);
/// Non-blocking note about the server version (e.g. newer than this build).
let serverNotice = $state<string | null>(null);
// Redirect to library if already authenticated
$effect(() => {
@@ -36,6 +39,16 @@
try {
const info = await auth.connectToServer(serverUrl);
// The backend decided whether this server's version is usable; we only
// render its verdict. TRACES: UR-085 | DR-286
const notice = compatibilityNotice(info.compatibility, info.version);
if (notice?.blocking) {
localError = notice.message;
return;
}
serverNotice = notice?.message ?? null;
serverName = info.name;
serverUrl = info.normalizedUrl; // Use normalized URL with https://
step = "login";
@@ -224,6 +237,14 @@
</div>
</div>
{#if serverNotice}
<div
class="p-3 bg-amber-900/40 border border-amber-700 rounded-lg text-amber-200 text-sm"
>
{serverNotice}
</div>
{/if}
{#if localError || $authError}
<div class="p-3 bg-red-900/50 border border-red-700 rounded-lg text-red-200 text-sm">
{localError || $authError}