fix(jellyfin): send the token as ApiKey so Jellyfin 12 accepts trickplay, subtitle and socket URLs (#2252)

Jellyfin 12 ships with EnableLegacyAuthorization=false, which drops the
legacy `api_key=` query spelling (jellyfin/jellyfin#15559). Every
authenticated URL Plezy self-authenticates via the query string then
fails with 401: trickplay sprite sheets (visible as missing scrub
thumbnails), transcoding/subtitle/Live TV URLs built through
_withApiKey, and the library-event websocket (403 on upgrade).

`ApiKey=` is read unconditionally by Jellyfin 10.8 through 12, while
Emby only accepts `api_key=`, so the parameter name now comes from
MediaBrowserDialect.tokenQueryParam. Emby output is byte-identical.
Image URLs keep `api_key`: Jellyfin serves item images without
authentication, and the artwork cache keys strip that exact name.

Fixes #2247
This commit is contained in:
Albert
2026-09-06 01:21:51 +02:00
committed by GitHub
parent d6f0aa8936
commit d2d73f35f8
10 changed files with 68 additions and 36 deletions
+10 -2
View File
@@ -5,7 +5,7 @@ import 'media_backend.dart';
/// Jellyfin forked from Emby 3.5.2, so the two still share almost their entire
/// wire contract: identical `BaseItemDto` shapes, the same `/Items` query
/// grammar, the `MediaBrowser` Authorization scheme, the `X-Emby-Token` header
/// and `api_key=` query fallback. Plezy therefore drives both through one
/// and a token query fallback. Plezy therefore drives both through one
/// client stack ([JellyfinClient]) and keeps every delta in this one type.
///
/// Verified against Jellyfin 10.10.7/10.11 and Emby 4.9.5:
@@ -76,8 +76,16 @@ enum MediaBrowserDialect {
MediaBrowserDialect.emby => const [8920, 8096],
};
/// Jellyfin 12 rejects legacy `api_key` when `EnableLegacyAuthorization` is
/// false, while Emby requires it; `ApiKey` works across Jellyfin versions.
String get tokenQueryParam => switch (this) {
MediaBrowserDialect.jellyfin => 'ApiKey',
MediaBrowserDialect.emby => 'api_key',
};
/// Path of the realtime notification websocket. Same protocol on both
/// dialects (`?api_key=&deviceId=`, `ForceKeepAlive`/`KeepAlive`,
/// dialects (`?ApiKey=` on Jellyfin, `?api_key=` on Emby,
/// `ForceKeepAlive`/`KeepAlive`,
/// `LibraryChanged`); only the route differs. Verified against Jellyfin
/// 10.11 (`/socket`) and Emby 4.9.5 (`/embywebsocket`).
String get webSocketPath => switch (this) {
@@ -201,7 +201,8 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
@override
String _withApiKey(String urlOrPath) {
final uri = JellyfinImageAbsolutizer.joinUri(baseUrl: connection.baseUrl, urlOrPath: urlOrPath);
final params = Map<String, String>.from(uri.queryParameters)..['api_key'] = connection.accessToken;
final params = Map<String, String>.from(uri.queryParameters)
..[connection.dialect.tokenQueryParam] = connection.accessToken;
return uri.replace(queryParameters: params).toString();
}
@@ -211,7 +212,7 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
/// audio/subtitle streams server-side. Uses the returned `TranscodingUrl`
/// when the caller asked for a capped quality; otherwise — and on any
/// DirectPlay decision — builds the shared static direct stream URL
/// (`/Videos/{id}/stream?Static=true&api_key=...`) itself.
/// (`/Videos/{id}/stream?Static=true` plus the dialect's token query) itself.
///
/// The returned `MediaSourceInfo` is what the player uses for track-picker
/// labels and auto-track selection by language.
@@ -344,7 +345,7 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
if (!wantsOriginal && transcodingUrl is String && transcodingUrl.isNotEmpty) {
// TranscodingUrl is server-relative and already encodes container,
// codecs, MediaSourceId, and PlaySessionId; we just append the
// api_key for auth.
// dialect's token query parameter for auth.
final urlSessionId = Uri.tryParse(transcodingUrl)?.queryParameters['PlaySessionId'];
final negotiatedSessionId = negotiation!['PlaySessionId'];
playSessionId = urlSessionId != null && urlSessionId.isNotEmpty
@@ -569,7 +570,7 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
final path = track.key ?? _jellyfinSubtitleFallbackPath(itemId, mediaSourceId, track);
if (path == null) continue;
// Jellyfin's subtitle URL is a path relative to baseUrl; build the
// absolute URL with the api_key query param.
// absolute URL with the dialect's token query parameter.
final url = _withApiKey(path);
externalSubtitles.add(
PlaybackSubtitleSidecar(
@@ -653,7 +654,8 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
/// Direct-stream URL for [itemId]. Best for files the device can play
/// natively. Adds `?Static=true` to skip the transcoder and
/// `&api_key=...` so the request authenticates without a header.
/// the dialect's token query parameter so the request authenticates without
/// a header.
///
/// Pass [mediaSourceId] to stream a non-default alternate version. When the
/// item only has a single MediaSource, [mediaSourceId] equals [itemId] and
@@ -671,6 +673,7 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
return buildJellyfinDirectStreamUrl(
baseUrl: connection.baseUrl,
accessToken: connection.accessToken,
tokenQueryParam: connection.dialect.tokenQueryParam,
deviceId: connection.deviceId,
itemId: itemId,
container: container,
@@ -682,13 +685,14 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
}
/// Audio sibling of [buildDirectStreamUrl]: `/Audio/{id}/stream` with the
/// same `Static=true` + `api_key` + `DeviceId` self-authentication. Used
/// same `Static=true` + token query + `DeviceId` self-authentication. Used
/// for track direct-play fallback, downloads, and external players.
@override
String buildAudioDirectStreamUrl(String itemId, {String? container, String? mediaSourceId}) {
return buildJellyfinDirectStreamUrl(
baseUrl: connection.baseUrl,
accessToken: connection.accessToken,
tokenQueryParam: connection.dialect.tokenQueryParam,
deviceId: connection.deviceId,
itemId: itemId,
mediaSegment: 'Audio',
@@ -706,6 +710,7 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
return buildJellyfinTrickplayTileUrl(
baseUrl: connection.baseUrl,
accessToken: connection.accessToken,
tokenQueryParam: connection.dialect.tokenQueryParam,
deviceId: connection.deviceId,
itemId: itemId,
width: width,
@@ -926,9 +931,9 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
return const ExternalIds();
}
/// Jellyfin embeds the access token in the URL query string (`api_key=...`)
/// rather than relying on headers, so the player needs no extra headers
/// for direct streams.
/// MediaBrowser embeds the access token in the URL query string rather than
/// relying on headers, so the player needs no extra headers for direct
/// streams.
@override
Map<String, String> get streamHeaders => const {};
+4 -2
View File
@@ -4,6 +4,7 @@
String buildJellyfinDirectStreamUrl({
required String baseUrl,
required String accessToken,
required String tokenQueryParam,
required String deviceId,
required String itemId,
String mediaSegment = 'Videos',
@@ -15,7 +16,7 @@ String buildJellyfinDirectStreamUrl({
}) {
final params = <String, String>{
'Static': 'true',
'api_key': accessToken,
tokenQueryParam: accessToken,
'DeviceId': deviceId,
'Container': ?container,
'MediaSourceId': ?mediaSourceId,
@@ -30,13 +31,14 @@ String buildJellyfinDirectStreamUrl({
String buildJellyfinTrickplayTileUrl({
required String baseUrl,
required String accessToken,
required String tokenQueryParam,
required String deviceId,
required String itemId,
required int width,
required int sheetIndex,
String? mediaSourceId,
}) {
final params = <String, String>{'api_key': accessToken, 'DeviceId': deviceId, 'MediaSourceId': ?mediaSourceId};
final params = <String, String>{tokenQueryParam: accessToken, 'DeviceId': deviceId, 'MediaSourceId': ?mediaSourceId};
final encodedItem = Uri.encodeComponent(itemId);
return '$baseUrl/Videos/$encodedItem/Trickplay/$width/$sheetIndex.jpg?${_encodeQuery(params)}';
}
@@ -5,7 +5,7 @@ import '../../media/media_browser_dialect.dart';
import 'library_event_socket.dart';
/// MediaBrowser (Jellyfin/Emby) session socket:
/// `ws(s)://<server><dialect.webSocketPath>?api_key=<token>&deviceId=<id>`.
/// `ws(s)://<server><dialect.webSocketPath>?<dialect token key>=<token>&deviceId=<id>`.
///
/// Protocol (verified against Jellyfin 10.11 and Emby 4.9.5):
/// - The server opens with `ForceKeepAlive` whose `Data` is the timeout in
@@ -58,7 +58,7 @@ class MediaBrowserLibraryEventSocket extends LibraryEventSocket {
final base = webSocketBase(baseUrl());
return base.replace(
path: '${base.path}${dialect.webSocketPath}',
queryParameters: {'api_key': accessToken, 'deviceId': deviceId},
queryParameters: {dialect.tokenQueryParam: accessToken, 'deviceId': deviceId},
);
}
@@ -1131,6 +1131,16 @@ void main() {
});
group('MediaBrowser playback session identity', () {
test('Emby direct streams keep the lowercase api_key query parameter', () {
final client = testEmbyClient();
addTearDown(client.close);
final query = Uri.parse(client.buildDirectStreamUrl('item-1')).queryParameters;
expect(query['api_key'], 'token');
expect(query.containsKey('ApiKey'), isFalse);
});
test('Emby synthesizes one item-derived PlaySessionId for a replay triple', () async {
final requests = _RequestCapture((_) => http.Response('', 204));
final client = testEmbyClient(handler: requests.handle);
+20 -16
View File
@@ -216,7 +216,7 @@ void main() {
expect(detailFetches, 2);
});
test('buildDirectStreamUrl includes static flag, api_key, and device id', () {
test('buildDirectStreamUrl uses the Jellyfin ApiKey query parameter', () {
final url = client.buildDirectStreamUrl('item-99');
final uri = Uri.parse(url);
@@ -224,7 +224,8 @@ void main() {
expect(uri.host, 'jf.example.com');
expect(uri.path, '/Videos/item-99/stream');
expect(uri.queryParameters['Static'], 'true');
expect(uri.queryParameters['api_key'], 'tok-abc');
expect(uri.queryParameters['ApiKey'], 'tok-abc');
expect(uri.queryParameters.containsKey('api_key'), isFalse);
expect(uri.queryParameters['DeviceId'], 'dev-xyz');
expect(uri.queryParameters.containsKey('Container'), isFalse);
});
@@ -263,7 +264,7 @@ void main() {
expect(uri.path, '/Audio/track-7/stream');
expect(uri.queryParameters['Static'], 'true');
expect(uri.queryParameters['api_key'], 'tok-abc');
expect(uri.queryParameters['ApiKey'], 'tok-abc');
expect(uri.queryParameters['DeviceId'], 'dev-xyz');
expect(uri.queryParameters.containsKey('Container'), isFalse);
expect(uri.queryParameters.containsKey('MediaSourceId'), isFalse);
@@ -594,7 +595,7 @@ void main() {
expect(subtitle.languageCode, 'eng');
final subtitleUri = Uri.parse(subtitle.url);
expect(subtitleUri.path, '/Videos/item-1/src-2/Subtitles/3/Stream.srt');
expect(subtitleUri.queryParameters['api_key'], 'tok-abc');
expect(subtitleUri.queryParameters['ApiKey'], 'tok-abc');
requests.clear();
playbackInfoBody = null;
@@ -825,7 +826,7 @@ void main() {
expect(uri.path, '/Videos/item-1/master.m3u8');
expect(uri.queryParameters['MediaSourceId'], 'src-1');
expect(uri.queryParameters['PlaySessionId'], 'play-session-1');
expect(uri.queryParameters['api_key'], 'tok-abc');
expect(uri.queryParameters['ApiKey'], 'tok-abc');
expect(uri.queryParameters.containsKey('StartTimeTicks'), isFalse);
expect(result.mediaInfo!.subtitleTracks, hasLength(1));
expect(result.mediaInfo!.subtitleTracks.single.isExternalFile, isFalse);
@@ -1475,7 +1476,7 @@ void main() {
expect(uri.path, '/Videos/item-1/stream');
expect(uri.queryParameters['MediaSourceId'], 'src-1');
expect(uri.queryParameters.containsKey('PlaySessionId'), isFalse);
expect(uri.queryParameters['api_key'], 'tok-abc');
expect(uri.queryParameters['ApiKey'], 'tok-abc');
expect(result.mediaInfo!.subtitleTracks, hasLength(1));
expect(result.externalSubtitles, hasLength(1));
expect(result.subtitleSidecars.single.sourceStreamId, 3);
@@ -1483,7 +1484,7 @@ void main() {
expect(result.externalSubtitles.single.title, 'English');
final subtitleUri = Uri.parse(result.externalSubtitles.single.uri!);
expect(subtitleUri.path, '/Videos/item-1/src-1/Subtitles/3/Stream.srt');
expect(subtitleUri.queryParameters['api_key'], 'tok-abc');
expect(subtitleUri.queryParameters['ApiKey'], 'tok-abc');
});
test('getPlaybackInitialization maps semantic subtitle preferences to current source rows', () async {
@@ -1703,7 +1704,7 @@ void main() {
expect(uri.queryParameters['Static'], 'true');
expect(uri.queryParameters['MediaSourceId'], 'src-1');
expect(uri.queryParameters['Container'], 'mkv');
expect(uri.queryParameters['api_key'], 'tok-abc');
expect(uri.queryParameters['ApiKey'], 'tok-abc');
expect(uri.queryParameters.containsKey('PlaySessionId'), isFalse);
expect(uri.queryParameters.containsKey('StartTimeTicks'), isFalse);
});
@@ -2627,7 +2628,7 @@ void main() {
expect(requested, isFalse);
});
test('getPlaybackInitialization URL-encodes appended api_key', () async {
test('getPlaybackInitialization URL-encodes the Jellyfin ApiKey', () async {
final scoped = JellyfinClient.forTesting(
connection: _conn(accessToken: 'tok+with spaces/?&'),
httpClient: MockClient((request) async {
@@ -2666,8 +2667,10 @@ void main() {
),
);
expect(result.videoUrl, contains('api_key=tok%2Bwith+spaces%2F%3F%26'));
expect(Uri.parse(result.videoUrl!).queryParameters['api_key'], 'tok+with spaces/?&');
expect(result.videoUrl, contains('ApiKey=tok%2Bwith+spaces%2F%3F%26'));
final query = Uri.parse(result.videoUrl!).queryParameters;
expect(query['ApiKey'], 'tok+with spaces/?&');
expect(query.containsKey('api_key'), isFalse);
});
test('getPlaybackInitialization builds fallback URL for external subtitle without DeliveryUrl', () async {
@@ -2718,7 +2721,7 @@ void main() {
expect(result.playMethod, 'DirectPlay');
final uri = Uri.parse(result.externalSubtitles.single.uri!);
expect(uri.path, '/Videos/item-1/src-1/Subtitles/3/Stream.srt');
expect(uri.queryParameters['api_key'], 'tok-abc');
expect(uri.queryParameters['ApiKey'], 'tok-abc');
});
test('live TV playback start negotiates an HLS transcode', () async {
@@ -2768,7 +2771,7 @@ void main() {
final uri = Uri.parse((await session!.streamUrlAt())!);
expect(uri.path, '/Videos/channel-1/live.m3u8');
expect(uri.queryParameters['PlaySessionId'], 'live-session-1');
expect(uri.queryParameters['api_key'], 'tok-abc');
expect(uri.queryParameters['ApiKey'], 'tok-abc');
// The negotiated session identity is only observable on the heartbeat
// wire, so drive one report and assert what reaches the server.
@@ -2836,14 +2839,15 @@ void main() {
expect(await scoped.liveTv.startPlayback('channel-1'), isNull);
});
test('buildTrickplayTileUrl wires width, sheet index, api_key, and DeviceId', () {
test('buildTrickplayTileUrl uses the Jellyfin ApiKey query parameter', () {
final url = client.buildTrickplayTileUrl('item-99', 320, 4);
final uri = Uri.parse(url);
expect(uri.scheme, 'https');
expect(uri.host, 'jf.example.com');
expect(uri.path, '/Videos/item-99/Trickplay/320/4.jpg');
expect(uri.queryParameters['api_key'], 'tok-abc');
expect(uri.queryParameters['ApiKey'], 'tok-abc');
expect(uri.queryParameters.containsKey('api_key'), isFalse);
expect(uri.queryParameters['DeviceId'], 'dev-xyz');
expect(uri.queryParameters.containsKey('MediaSourceId'), isFalse);
});
@@ -2933,7 +2937,7 @@ void main() {
final uri = Uri.parse(result.videoUrl!);
expect(uri.path, '/jellyfin/Videos/item-1/stream');
expect(uri.queryParameters['PlaySessionId'], 'play-session-direct');
expect(uri.queryParameters['api_key'], 'tok-abc');
expect(uri.queryParameters['ApiKey'], 'tok-abc');
});
test('selected source never inherits another source nested trickplay', () async {
@@ -216,7 +216,7 @@ void main() {
final uri = Uri.parse(url);
expect(uri.path, '/Videos/item-99/Trickplay/320/1.jpg');
expect(uri.queryParameters['MediaSourceId'], 'src-2');
expect(uri.queryParameters['api_key'], 'tok-abc');
expect(uri.queryParameters['ApiKey'], 'tok-abc');
});
test('sheet URL omits MediaSourceId when null', () async {
@@ -338,7 +338,8 @@ void main() {
final socket = await server.nextConnection();
expect(server.requestUris.single.path, '/socket');
expect(server.requestUris.single.queryParameters, containsPair('api_key', 'access-1'));
expect(server.requestUris.single.queryParameters, containsPair('ApiKey', 'access-1'));
expect(server.requestUris.single.queryParameters.containsKey('api_key'), isFalse);
expect(server.requestUris.single.queryParameters, containsPair('deviceId', 'device-1'));
server.send(socket, {'MessageId': 'x', 'Data': 60, 'MessageType': 'ForceKeepAlive'});
@@ -422,6 +423,8 @@ void main() {
final first = await server.nextConnection();
expect(registrations, 1);
expect(server.requestUris.single.path, '/embywebsocket');
expect(server.requestUris.single.queryParameters, containsPair('api_key', 'access-1'));
expect(server.requestUris.single.queryParameters.containsKey('ApiKey'), isFalse);
// A drop re-registers on the reconnect attempt.
await first.close();
@@ -631,7 +631,7 @@ void main() {
expect(url.queryParameters['Static'], 'true');
expect(url.queryParameters['MediaSourceId'], 'source-1');
expect(url.queryParameters['LiveStreamId'], 'live-1');
expect(url.queryParameters['api_key'], 'tok-abc');
expect(url.queryParameters['ApiKey'], 'tok-abc');
// Heartbeats must report DirectPlay so the server accounts the session
// correctly and can reclaim the live stream on stop.
+2 -2
View File
@@ -39,8 +39,8 @@ void main() {
expect(result.contains('fmt=jpg'), isTrue);
});
test('api_key redaction is case-insensitive', () {
final result = LogRedactionManager.redact('API_KEY=topsecret&z=1');
test('ApiKey redaction is case-insensitive', () {
final result = LogRedactionManager.redact('https://example.com/Items?ApiKey=topsecret&z=1');
expect(result.contains('topsecret'), isFalse);
expect(result.contains('[REDACTED]'), isTrue);
});