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:
@@ -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,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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user