From b1844f673e3f3514f16f1e93948654a70deac6be Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Tue, 8 Sep 2026 20:09:11 +0200 Subject: [PATCH] docs(specs): Jellyfin server version compatibility, and what research found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/requirements.md | 22 + docs/specs/README.md | 9 +- docs/specs/jellyfin-12-api-delta.md | 732 ++++++++++++++++++ .../jellyfin-server-version-compatibility.md | 294 +++++++ 4 files changed, 1054 insertions(+), 3 deletions(-) create mode 100644 docs/specs/jellyfin-12-api-delta.md create mode 100644 docs/specs/jellyfin-server-version-compatibility.md diff --git a/docs/requirements.md b/docs/requirements.md index 2df63eeb9..2bee7565c 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -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 `