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>
This commit is contained in:
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`) |
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user