fix: Jellyfin 12 sign-in, Quick Connect and downloads (Authorization header) - #129
Conversation
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.
✅ Claude PR Review —
|
| 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.
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.
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.
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-Tokenand theapi_keyquery parameter. Every Jellyfin call we make sent its MediaBrowser value inX-Emby-Authorization, so on a Jellyfin 12 server: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").401on library browsing once their server upgrades.?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
fix: send Jellyfin auth in the standard Authorization header— everyJellyfinApiendpoint sends the same MediaBrowser value inAuthorization. The progress push's duplicate header builder (it reported "Android" / 1.0.0) is gone; it usesJellyfinService's. Its custom headers are now sanitized like the service's, so a customAuthorizationheader can't replace the token.fix: authenticate media-server downloads with headers, not a URL token—DownloadFileProcessorattaches 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=.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).ExternalServiceUtils.originPinnedHeaders). OkHttp dropsAuthorizationon a cross-host redirect but keeps custom headers (often Cloudflare Access secrets); playback already pins its headers to the server's host.DownloadFileProcessorshares one baseOkHttpClient(per-server variants vianewBuilder(), 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
Authorizationheader).Verification
Older servers. The exact requests the new build sends, against throwaway containers:
* 10.8 has no
UserViewsroute (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):
Tests.
./gradlew assembleDevDebug testDevDebugUnitTest :core:testDebugUnitTest lintDevDebuggreen (735 tests: 217 app, 68 wear, 450 core). New: sign-in sends the identity inAuthorizationand notX-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 inAuthorizationand a customAuthorizationheader 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).