diff --git a/lib/media/media_browser_dialect.dart b/lib/media/media_browser_dialect.dart index 021f7e6ad..3e766900b 100644 --- a/lib/media/media_browser_dialect.dart +++ b/lib/media/media_browser_dialect.dart @@ -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) { diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 47e154720..9038f43ec 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -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.from(uri.queryParameters)..['api_key'] = connection.accessToken; + final params = Map.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 get streamHeaders => const {}; diff --git a/lib/services/jellyfin_playback_urls.dart b/lib/services/jellyfin_playback_urls.dart index 6107cf283..a5cce5191 100644 --- a/lib/services/jellyfin_playback_urls.dart +++ b/lib/services/jellyfin_playback_urls.dart @@ -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 = { '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 = {'api_key': accessToken, 'DeviceId': deviceId, 'MediaSourceId': ?mediaSourceId}; + final params = {tokenQueryParam: accessToken, 'DeviceId': deviceId, 'MediaSourceId': ?mediaSourceId}; final encodedItem = Uri.encodeComponent(itemId); return '$baseUrl/Videos/$encodedItem/Trickplay/$width/$sheetIndex.jpg?${_encodeQuery(params)}'; } diff --git a/lib/services/library_events/media_browser_library_event_socket.dart b/lib/services/library_events/media_browser_library_event_socket.dart index 1a33016b7..10747cffc 100644 --- a/lib/services/library_events/media_browser_library_event_socket.dart +++ b/lib/services/library_events/media_browser_library_event_socket.dart @@ -5,7 +5,7 @@ import '../../media/media_browser_dialect.dart'; import 'library_event_socket.dart'; /// MediaBrowser (Jellyfin/Emby) session socket: -/// `ws(s)://?api_key=&deviceId=`. +/// `ws(s)://?=&deviceId=`. /// /// 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}, ); } diff --git a/test/services/jellyfin_client_emby_dialect_test.dart b/test/services/jellyfin_client_emby_dialect_test.dart index 8162da95d..96b51f859 100644 --- a/test/services/jellyfin_client_emby_dialect_test.dart +++ b/test/services/jellyfin_client_emby_dialect_test.dart @@ -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); diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index adcbfc84c..67124b176 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -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 { diff --git a/test/services/jellyfin_trickplay_service_test.dart b/test/services/jellyfin_trickplay_service_test.dart index 2bf9f3a49..77e77a98b 100644 --- a/test/services/jellyfin_trickplay_service_test.dart +++ b/test/services/jellyfin_trickplay_service_test.dart @@ -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 { diff --git a/test/services/library_events/library_event_socket_test.dart b/test/services/library_events/library_event_socket_test.dart index 2061d0a5e..13a5c528e 100644 --- a/test/services/library_events/library_event_socket_test.dart +++ b/test/services/library_events/library_event_socket_test.dart @@ -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(); diff --git a/test/services/live_tv_playback_session_test.dart b/test/services/live_tv_playback_session_test.dart index 8951abe7c..52f1a6072 100644 --- a/test/services/live_tv_playback_session_test.dart +++ b/test/services/live_tv_playback_session_test.dart @@ -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. diff --git a/test/utils/log_redaction_manager_test.dart b/test/utils/log_redaction_manager_test.dart index 5493d7649..89fe44640 100644 --- a/test/utils/log_redaction_manager_test.dart +++ b/test/utils/log_redaction_manager_test.dart @@ -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); });