Skip to content

fix: Jellyfin 12 sign-in, Quick Connect and downloads (Authorization header) - #129

Merged
GianniCarlo merged 5 commits into
developfrom
fix/jellyfin-12-auth
Sep 29, 2026
Merged

GianniCarlo merged 5 commits into
developfrom
fix/jellyfin-12-auth

Conversation

@GianniCarlo

@GianniCarlo GianniCarlo commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #119.

What was wrong

Jellyfin 12.0 (released 2026-09-07) turns legacy authorization off by default (jellyfin#15559): the server ignores X-Emby-Authorization, X-Emby-Token and the api_key query parameter. Every Jellyfin call we make sent its MediaBrowser value in X-Emby-Authorization, so on a Jellyfin 12 server:

  • Sign-in and Quick Connect reached the server with no client/device info. Jellyfin's session code throws on the missing fields and answers 400 Bad Request — the "Authentication failed: Bad Request" in Bug: ...unable to connect to jellyfin #119 (1.1.3 wording; 1.2.0 shows "The server responded with 400").
  • Installs that were already connected get 401 on library browsing once their server upgrades.
  • Offline download of a streamed book sent only ?api_key=, so it would 401 too.

It is not HTTP vs HTTPS: cleartext is allowed since 1.1.3, and a 400 means the request reached the server.

Change

  1. fix: send Jellyfin auth in the standard Authorization header — every JellyfinApi endpoint sends the same MediaBrowser value in Authorization. The progress push's duplicate header builder (it reported "Android" / 1.0.0) is gone; it uses JellyfinService's. Its custom headers are now sanitized like the service's, so a custom Authorization header can't replace the token.
  2. fix: authenticate media-server downloads with headers, not a URL token — DownloadFileProcessor attaches the provider's auth header plus the user's custom headers, resolved per run from the saved server (ExternalServiceUtils.downloadHeadersFor), only for URLs on that server; a BookPlayer-cloud presigned URL goes out bare, since S3 rejects a second auth mechanism. With every consumer on header auth, the Jellyfin download URL drops ?api_key=. AudiobookShelf keeps its ?token=.
  3. Review round 1 — the download reads its response inside use (error statuses and the no-room refusal leaked the connection on every retry), and the stream-to-cloud pipe sanitizes custom headers like the download does (a persisted illegal header failed every attempt).
  4. Review round 2 — downloads and the pipe's source GET add media-server headers through a network interceptor, only on hops that stay on the server's origin (ExternalServiceUtils.originPinnedHeaders). OkHttp drops Authorization on a cross-host redirect but keeps custom headers (often Cloudflare Access secrets); playback already pins its headers to the server's host.
  5. Review round 3 — DownloadFileProcessor shares one base OkHttpClient (per-server variants via newBuilder(), like the progress push and the pipe) instead of building a new client per download and retry.

iOS is fixed separately (TortugaPower/BookPlayer#1603 for downloads; iOS sign-in already used the SDK's Authorization header).

Verification

Older servers. The exact requests the new build sends, against throwaway containers:

Jellyfin Sign-in Token calls Download (header only, full + range) Quick Connect start Old build's requests
10.8.13 200 200* 200 / 206 405* work
10.9.11 200 200 200 / 206 200 work
10.10.7 200 200 200 / 206 200 work
10.11.11 200 200 200 / 206 200 work
12.1.0 200 200 200 / 206 200 400 / 401

* 10.8 has no UserViews route (404) and starts Quick Connect with GET (405 on our POST) — both unchanged by this PR.

Emulator (bp-api36, Android 16 like the reporter; devDebug; app data cleared first):

Route 12.1.0 10.10.7
Username & password sign-in ✅ (was the 400) ✅
Quick Connect ✅ (was "couldn't complete") ✅ updates the saved server
Details-screen Download ✅ full 4,863,624 bytes —
Stream playback (header auth only) ✅ ✅
Offline download of a stream item ✅ first attempt, full file ✅

Tests. ./gradlew assembleDevDebug testDevDebugUnitTest :core:testDebugUnitTest lintDevDebug green (735 tests: 217 app, 68 wear, 450 core). New: sign-in sends the identity in Authorization and not X-Emby-Authorization (JellyfinProbeTest); a media-server download carries the auth + custom headers while a cloud URL for the same item goes out bare, and a rejected one is retryable and writes nothing (DownloadFileProcessorTest); an illegal custom header is dropped instead of failing the pipe's source GET (StreamFileUploadProcessorTest, fails without the fix); a redirect off the media server gets none of its headers, for downloads and the pipe (both fail when the origin check is disabled); the progress push authenticates in Authorization and a custom Authorization header can't override it (ExternalUpdateProcessorTest).

Not in this PR

The progress push posts to Users/me/Items/{id}/UserData, which Jellyfin rejects with 400 on every version ("me" isn't a user id), so Android progress has never reached Jellyfin. That is independent of Jellyfin 12 and goes in a follow-up (UserItems/{id}/UserData, the route iOS uses).

Jellyfin 12 turned legacy authorization off by default: it ignores the X-Emby-Authorization header
every Jellyfin call sent. Sign-in and Quick Connect reached the server with no client/device info
and failed with 400 ("Authentication failed: Bad Request" in 1.1.3, "The server responded with
400" since the connection-flow redesign), and token calls made by already-connected installs came
back 401. Every endpoint now sends the same MediaBrowser value in `Authorization`, which Jellyfin
has always accepted (checked against 10.8.13, 10.9.11, 10.10.7, 10.11.11 and 12.1.0).

The progress push had its own copy of the header builder that reported "Android" / 1.0.0; it now
uses JellyfinService's, so the server sees one identity per install. Its custom headers are
sanitized like the service's: with the token in `Authorization`, a custom header of that name
would otherwise replace it.
Downloading a streamed Jellyfin book for offline use sent only the `?api_key=` URL, which Jellyfin
12 rejects (401), and never sent the user's custom headers (Cloudflare Access etc.). The download
processor now attaches the provider's auth header plus the custom headers, resolved per run from
the saved server, so tokens stay out of the task table and a re-auth's fresh token applies to a
queued download. Only URLs on that server get them: a BookPlayer-cloud presigned URL goes out bare,
since S3 rejects a request that carries a second auth mechanism.

With every consumer on header auth (playback through PlaybackManager's host registry, the
stream-to-cloud pipe, and now downloads), the Jellyfin download URL drops its `api_key` query
token, which only leaked the token into logs and persisted task payloads. AudiobookShelf keeps its
`token` query param.
Comment thread core/src/main/java/com/tortugapower/audiobookplayer/logic/CoreProcessors.kt Outdated
@github-actions

github-actions Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

✅ Claude PR Review — PASS

Moves every Jellyfin API call from X-Emby-Authorization to the standard Authorization header, which Jellyfin 12 requires. The progress push now uses the shared JellyfinService.getAuthHeader, and its custom headers are cleaned up the same way. The Jellyfin download URL drops api_key. Downloads and the stream-to-cloud source GET now send the server's auth and custom headers only while the request stays on that server's origin. Every caller of the tokenless URL (playback, background chapter extraction, downloads, the pipe) sends header auth, and the URL-to-server match is origin-safe because sanitizeUrl adds the trailing /. The earlier note about a new OkHttpClient per download is fixed: DownloadFileProcessor now shares one baseHttpClient. Small follow-up outside this diff: StreamArtworkBackfill still passes the saved custom headers unfiltered to addHeader, so a saved invalid header would break cover backfill.

Findings: no findings

Previously raised

Finding Status
core/src/main/java/com/tortugapower/audiobookplayer/logic/CoreProcessors.kt:601 (info) ⚠︎ moved ✅ verified fixed in 0b62d8a

Converged: nothing new this round, and every earlier finding is settled.

Model claude-opus-5-5 · run log · 0 new · 0 carried over · 1 verified closed · 0 resolved · advisory (a human should still review). Findings are de-duplicated across pushes; an earlier finding closes only when the verification pass judges it against the current code — fixed, no longer applicable, accepted by a maintainer, or a duplicate of a finding reported on this push.

DownloadFileProcessor reads the response inside `use`, so an error status or a refused
(no room) download no longer leaks the connection on each retry; with header auth an
expired token is an ordinary 401. The stream-to-cloud pipe sanitizes custom headers like
the download does: a persisted illegal header threw on addHeader and failed every attempt.
Comment thread core/src/main/java/com/tortugapower/audiobookplayer/logic/CoreProcessors.kt Outdated
Downloads and the pipe's source GET attach media-server headers through a network interceptor
that adds them only to hops on the server's origin (scheme, host, port). OkHttp drops
Authorization on a cross-host redirect but keeps custom headers, which are often Cloudflare
Access secrets; playback already pins its headers to the server's host the same way.
Comment thread core/src/main/java/com/tortugapower/audiobookplayer/logic/CoreProcessors.kt Outdated
DownloadFileProcessor keeps one base OkHttpClient and derives the per-server variant from it
with newBuilder(), like the progress push and the pipe, so queued downloads and their retries
share one connection pool and dispatcher instead of building a new client each time.
@GianniCarlo
GianniCarlo merged commit 2bf71fa into develop Sep 29, 2026
3 checks passed

This branch was successfully deployed

1 active deployment
reviewer — 0b62d8a4 Deployed Sep 29, 2026 by GianniCarlo via review #445
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: ...unable to connect to jellyfin

1 participant