Files
jellytau/docs/specs/jellyfin-server-version-compatibility.md
T
dtourolleandClaude Opus 5 b1844f673e docs(specs): Jellyfin server version compatibility, and what research found
Adds the spec for running one build against two Jellyfin generations, plus the
research report that establishes what actually differs — with a source URL per
claim, and an explicit section for what could NOT be established.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 20:09:11 +02:00

295 lines
17 KiB
Markdown

# 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.