From 70606c8ac5946a5ce8ba62df3ef01068ad2fee2c Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 3 May 2026 09:23:16 +0200 Subject: [PATCH] fix(jellyfin): align playback and browse requests --- lib/services/jellyfin_client.dart | 75 +++++++------ lib/services/jellyfin_playback_urls.dart | 24 ----- lib/services/library_query_translator.dart | 7 ++ test/services/jellyfin_client_urls_test.dart | 101 +++++++++++------- .../library_query_translator_test.dart | 2 + 5 files changed, 117 insertions(+), 92 deletions(-) diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 06f5e05c..a2b26fee 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -832,6 +832,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'userId': connection.userId, 'Limit': '1', 'Fields': _browseFields, + ...jellyfinImageQueryParameters, }); final onDeckEpisode = nextUp.isEmpty ? null : _mapItem(nextUp.first); return (item: item, onDeckEpisode: onDeckEpisode); @@ -909,7 +910,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc try { final seasons = await _http.get( '/Shows/${_segment(parentId)}/Seasons', - queryParameters: {'userId': connection.userId, 'Fields': _browseFields}, + queryParameters: {'userId': connection.userId, 'Fields': _browseFields, ...jellyfinImageQueryParameters}, ); if (seasons.statusCode == 200) { final data = seasons.data; @@ -926,7 +927,13 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc // collection → items, etc. final response = await _http.get( '/Items', - queryParameters: {'userId': connection.userId, 'ParentId': parentId, 'Fields': _browseFields, 'Limit': '500'}, + queryParameters: { + 'userId': connection.userId, + 'ParentId': parentId, + 'Fields': _browseFields, + 'Limit': '500', + ...jellyfinImageQueryParameters, + }, ); throwIfHttpError(response); final data = response.data; @@ -955,6 +962,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'Recursive': 'true', 'IncludeItemTypes': 'Movie,Episode', 'Fields': _browseFields, + ...jellyfinImageQueryParameters, }, ); throwIfHttpError(response); @@ -982,6 +990,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'Fields': _queueFields, 'StartIndex': '$startIndex', 'Limit': '$_episodeQueuePageSize', + ...jellyfinImageQueryParameters, }, ); throwIfHttpError(response); @@ -1010,6 +1019,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'Limit': limit.toString(), 'IncludeItemTypes': 'Movie,Series,Episode', 'Fields': _browseFields, + ...jellyfinImageQueryParameters, }, ); throwIfHttpError(response); @@ -1021,7 +1031,12 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc // Matches userLibraryApi.getLatestMedia in the Jellyfin SDK. final response = await _http.get( '/Users/${_segment(connection.userId)}/Items/Latest', - queryParameters: {'Limit': limit.toString(), 'Fields': _browseFields, 'IncludeItemTypes': 'Movie,Series,Episode'}, + queryParameters: { + 'Limit': limit.toString(), + 'Fields': _browseFields, + 'IncludeItemTypes': 'Movie,Series,Episode', + ...jellyfinImageQueryParameters, + }, ); throwIfHttpError(response); final data = response.data; @@ -1040,6 +1055,8 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'Limit': count.toString(), 'Fields': _browseFields, 'MediaTypes': 'Video', + 'Recursive': 'true', + ...jellyfinImageQueryParameters, }), _safeFetchItemsArray('/Shows/NextUp', { 'userId': connection.userId, @@ -1047,6 +1064,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'Fields': _browseFields, 'EnableResumable': 'false', 'EnableTotalRecordCount': 'false', + ...jellyfinImageQueryParameters, }), ]); @@ -1065,11 +1083,15 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'Limit': limit.toString(), 'Fields': _browseFields, 'IncludeItemTypes': 'Movie,Series,Episode', + ...jellyfinImageQueryParameters, }), _safeFetchItemsArray('/UserItems/Resume', { 'userId': connection.userId, 'Limit': limit.toString(), 'Fields': _browseFields, + 'MediaTypes': 'Video', + 'Recursive': 'true', + ...jellyfinImageQueryParameters, }), _safeFetchItemsArray('/Shows/NextUp', { 'userId': connection.userId, @@ -1077,6 +1099,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'Fields': _browseFields, 'EnableResumable': 'false', 'EnableTotalRecordCount': 'false', + ...jellyfinImageQueryParameters, }), ]); @@ -1125,6 +1148,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'Limit': limit.toString(), 'ParentId': libraryId, 'Fields': _browseFields, + ...jellyfinImageQueryParameters, }), _safeFetchItemsArray('/UserItems/Resume', { 'userId': connection.userId, @@ -1132,6 +1156,8 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'Limit': limit.toString(), 'Fields': _browseFields, 'MediaTypes': 'Video', + 'Recursive': 'true', + ...jellyfinImageQueryParameters, }), _safeFetchItemsArray('/Shows/NextUp', { 'userId': connection.userId, @@ -1140,6 +1166,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'Fields': _browseFields, 'EnableResumable': 'false', 'EnableTotalRecordCount': 'false', + ...jellyfinImageQueryParameters, }), ]); @@ -1196,6 +1223,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'Limit': effectiveLimit, 'Fields': _browseFields, if (parentId != null) 'ParentId': parentId else 'IncludeItemTypes': 'Movie,Series,Episode', + ...jellyfinImageQueryParameters, }); break; case 'continue': @@ -1203,7 +1231,9 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'userId': connection.userId, 'Limit': effectiveLimit, 'Fields': _browseFields, + 'Recursive': 'true', if (parentId != null) 'ParentId': parentId else 'MediaTypes': 'Video', + ...jellyfinImageQueryParameters, }); break; case 'nextup': @@ -1214,6 +1244,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'ParentId': ?parentId, 'EnableResumable': 'false', 'EnableTotalRecordCount': 'false', + ...jellyfinImageQueryParameters, }); break; default: @@ -1226,7 +1257,12 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc Future> fetchRelatedHubs(String id, {int count = 10}) async { final response = await _http.get( '/Items/${_segment(id)}/Similar', - queryParameters: {'userId': connection.userId, 'Limit': count.toString(), 'Fields': _browseFields}, + queryParameters: { + 'userId': connection.userId, + 'Limit': count.toString(), + 'Fields': _browseFields, + ...jellyfinImageQueryParameters, + }, ); throwIfHttpError(response); return [ @@ -1299,6 +1335,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'IncludeItemTypes': 'Playlist', 'Recursive': 'true', 'Fields': 'Overview,DateCreated,DateLastSaved,ChildCount,Tags', + ...jellyfinImageQueryParameters, }, ); throwIfHttpError(response); @@ -1340,6 +1377,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'StartIndex': offset.toString(), 'Limit': limit.toString(), 'Fields': _browseFields, + ...jellyfinImageQueryParameters, }, ); throwIfHttpError(response); @@ -1443,6 +1481,7 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc 'IncludeItemTypes': 'BoxSet', 'Recursive': 'true', 'Fields': _browseFields, + ...jellyfinImageQueryParameters, }, ); throwIfHttpError(response); @@ -1650,35 +1689,9 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc ); } - /// HLS master playlist URL for transcoded playback. Use when the file - /// container/codecs don't match the player's capabilities. The exact - /// negotiation (which streams, what bitrate) is server-driven via the - /// `PlaybackInfo` POST that should precede this call when fidelity - /// matters; this method assumes the caller already knows what they want. - String buildHlsStreamUrl( - String itemId, { - required String mediaSourceId, - int? videoBitrate, - int? audioStreamIndex, - int? subtitleStreamIndex, - String? playSessionId, - }) { - return buildJellyfinHlsStreamUrl( - baseUrl: connection.baseUrl, - accessToken: connection.accessToken, - deviceId: connection.deviceId, - itemId: itemId, - mediaSourceId: mediaSourceId, - videoBitrate: videoBitrate, - audioStreamIndex: audioStreamIndex, - subtitleStreamIndex: subtitleStreamIndex, - playSessionId: playSessionId, - ); - } - /// Negotiate playback: returns the parsed `MediaSources[]` array and the /// server's recommended `PlaySessionId`. Caller decides which media source - /// to use and feeds the result into [buildHlsStreamUrl] / direct play. + /// to use and feeds the returned `TranscodingUrl` into the player. /// /// [maxStreamingBitrate] is forwarded as both the top-level field and inside /// the `DeviceProfile` so the server caps direct-stream and transcode bitrate diff --git a/lib/services/jellyfin_playback_urls.dart b/lib/services/jellyfin_playback_urls.dart index 0f72f737..2bd3f3ae 100644 --- a/lib/services/jellyfin_playback_urls.dart +++ b/lib/services/jellyfin_playback_urls.dart @@ -31,29 +31,5 @@ String buildJellyfinTrickplayTileUrl({ return '$baseUrl/Videos/$encodedItem/Trickplay/$width/$sheetIndex.jpg?${_encodeQuery(params)}'; } -String buildJellyfinHlsStreamUrl({ - required String baseUrl, - required String accessToken, - required String deviceId, - required String itemId, - required String mediaSourceId, - int? videoBitrate, - int? audioStreamIndex, - int? subtitleStreamIndex, - String? playSessionId, -}) { - final params = { - 'DeviceId': deviceId, - 'MediaSourceId': mediaSourceId, - 'api_key': accessToken, - 'VideoBitrate': ?videoBitrate?.toString(), - 'AudioStreamIndex': ?audioStreamIndex?.toString(), - 'SubtitleStreamIndex': ?subtitleStreamIndex?.toString(), - 'PlaySessionId': ?playSessionId, - }; - final encodedItem = Uri.encodeComponent(itemId); - return '$baseUrl/Videos/$encodedItem/master.m3u8?${_encodeQuery(params)}'; -} - String _encodeQuery(Map params) => params.entries.map((e) => '${e.key}=${Uri.encodeQueryComponent(e.value)}').join('&'); diff --git a/lib/services/library_query_translator.dart b/lib/services/library_query_translator.dart index 0375310d..f90990a2 100644 --- a/lib/services/library_query_translator.dart +++ b/lib/services/library_query_translator.dart @@ -2,6 +2,12 @@ import '../media/library_query.dart'; import '../media/media_kind.dart'; import 'plex_constants.dart'; +/// Limit browse payload image tags to the artwork types the UI maps. +const jellyfinImageQueryParameters = { + 'EnableImageTypes': 'Primary,Backdrop,Thumb,Logo', + 'ImageTypeLimit': '1', +}; + /// Translates a backend-neutral [LibraryQuery] into the per-backend /// query-parameter map that the corresponding `/library/sections/{id}/all` /// (Plex) or `/Items` (Jellyfin) endpoint expects. @@ -212,6 +218,7 @@ class JellyfinLibraryQueryTranslator implements LibraryQueryTranslator { 'Limit': query.limit.toString(), 'IncludeItemTypes': _includeTypesFor(query.kind), 'Fields': fields, + ...jellyfinImageQueryParameters, }; if (!query.includeWatched) { params['Filters'] = 'IsUnplayed'; diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index bf5baec0..b3bbd3c6 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -77,43 +77,6 @@ void main() { expect(Uri.parse(url).path, '/Videos/folder%2Fitem%20%231%3Fx/stream'); }); - test('buildHlsStreamUrl wires required + optional params', () { - final url = client.buildHlsStreamUrl( - 'item-42', - mediaSourceId: 'src-1', - videoBitrate: 5000000, - audioStreamIndex: 1, - subtitleStreamIndex: 2, - playSessionId: 'sess-01', - ); - final uri = Uri.parse(url); - - expect(uri.path, '/Videos/item-42/master.m3u8'); - expect(uri.queryParameters['MediaSourceId'], 'src-1'); - expect(uri.queryParameters['VideoBitrate'], '5000000'); - expect(uri.queryParameters['AudioStreamIndex'], '1'); - expect(uri.queryParameters['SubtitleStreamIndex'], '2'); - expect(uri.queryParameters['PlaySessionId'], 'sess-01'); - expect(uri.queryParameters['DeviceId'], 'dev-xyz'); - expect(uri.queryParameters['api_key'], 'tok-abc'); - }); - - test('buildHlsStreamUrl omits null optional params', () { - final url = client.buildHlsStreamUrl('item-42', mediaSourceId: 'src-1'); - final uri = Uri.parse(url); - - expect(uri.queryParameters['MediaSourceId'], 'src-1'); - expect(uri.queryParameters.containsKey('VideoBitrate'), isFalse); - expect(uri.queryParameters.containsKey('AudioStreamIndex'), isFalse); - expect(uri.queryParameters.containsKey('SubtitleStreamIndex'), isFalse); - expect(uri.queryParameters.containsKey('PlaySessionId'), isFalse); - }); - - test('buildHlsStreamUrl path-encodes reserved item id characters', () { - final url = client.buildHlsStreamUrl('folder/item #1?x', mediaSourceId: 'src-1'); - expect(Uri.parse(url).path, '/Videos/folder%2Fitem%20%231%3Fx/master.m3u8'); - }); - test('reportPlaybackProgress sends media source and stream indexes', () async { Uri? capturedUri; String? capturedBody; @@ -325,6 +288,45 @@ void main() { expect(capturedUri.toString(), contains('/Items/folder%2Fitem%20%231%3Fx/PlaybackInfo')); }); + test('getPlaybackInfo keeps the known-good lean DeviceProfile', () async { + Uri? capturedUri; + String? capturedBody; + final scoped = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((request) async { + capturedUri = request.url; + capturedBody = request.body; + return http.Response(jsonEncode({'MediaSources': []}), 200, headers: {'content-type': 'application/json'}); + }), + ); + addTearDown(scoped.close); + + await scoped.getPlaybackInfo( + 'item-1', + maxStreamingBitrate: 5000000, + mediaSourceId: 'src-1', + audioStreamIndex: 1, + subtitleStreamIndex: 2, + ); + + expect(capturedUri!.queryParameters['MaxStreamingBitrate'], '5000000'); + expect(capturedUri!.queryParameters.containsKey('IsPlayback'), isFalse); + expect(capturedUri!.queryParameters.containsKey('AutoOpenLiveStream'), isFalse); + expect(capturedUri!.queryParameters['MediaSourceId'], 'src-1'); + expect(capturedUri!.queryParameters['AudioStreamIndex'], '1'); + expect(capturedUri!.queryParameters['SubtitleStreamIndex'], '2'); + + final body = jsonDecode(capturedBody!) as Map; + final profile = body['DeviceProfile'] as Map; + expect(profile['MaxStreamingBitrate'], 5000000); + expect(profile.containsKey('MaxStaticBitrate'), isFalse); + expect(profile.containsKey('MusicStreamingTranscodingBitrate'), isFalse); + expect(profile['DirectPlayProfiles'], isNotEmpty); + expect(profile['TranscodingProfiles'], isNotEmpty); + expect(profile['CodecProfiles'], isEmpty); + expect(profile.containsKey('SubtitleProfiles'), isFalse); + }); + test('path-encodes reserved ids for browse and watch-state endpoints', () async { final captured = []; final scoped = JellyfinClient.forTesting( @@ -710,6 +712,8 @@ void main() { expect(capturedNextUp, isNotNull); expect(capturedNextUp!.queryParameters['seriesId'], 'show-1'); expect(capturedNextUp!.queryParameters['Limit'], '1'); + expect(capturedNextUp!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); + expect(capturedNextUp!.queryParameters['ImageTypeLimit'], '1'); expect(capturedNextUp!.queryParameters.containsKey('EnableResumable'), isFalse); expect(capturedNextUp!.queryParameters.containsKey('NextUpDateCutoff'), isFalse); }); @@ -830,11 +834,16 @@ void main() { expect(resume.queryParameters['userId'], 'user-1'); expect(resume.queryParameters['Limit'], '3'); expect(resume.queryParameters['MediaTypes'], 'Video'); + expect(resume.queryParameters['Recursive'], 'true'); + expect(resume.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); + expect(resume.queryParameters['ImageTypeLimit'], '1'); final nextUp = requests.singleWhere((uri) => uri.path == '/Shows/NextUp'); expect(nextUp.queryParameters['userId'], 'user-1'); expect(nextUp.queryParameters['Limit'], '3'); expect(nextUp.queryParameters['EnableResumable'], 'false'); expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false'); + expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); + expect(nextUp.queryParameters['ImageTypeLimit'], '1'); expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse); }); @@ -892,6 +901,8 @@ void main() { expect(nextUp.queryParameters['Limit'], '12'); expect(nextUp.queryParameters['EnableResumable'], 'false'); expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false'); + expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); + expect(nextUp.queryParameters['ImageTypeLimit'], '1'); expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse); }); }); @@ -920,6 +931,8 @@ void main() { expect(nextUp.queryParameters['Limit'], '12'); expect(nextUp.queryParameters['EnableResumable'], 'false'); expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false'); + expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); + expect(nextUp.queryParameters['ImageTypeLimit'], '1'); expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse); }); }); @@ -944,6 +957,8 @@ void main() { expect(captured!.path, '/Users/user-1/Items/Latest'); expect(captured!.queryParameters['Limit'], '80'); expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode'); + expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); + expect(captured!.queryParameters['ImageTypeLimit'], '1'); expect(captured!.queryParameters.containsKey('ParentId'), isFalse); client.close(); }); @@ -957,6 +972,9 @@ void main() { expect(captured!.queryParameters['userId'], 'user-1'); expect(captured!.queryParameters['Limit'], '50'); expect(captured!.queryParameters['MediaTypes'], 'Video'); + expect(captured!.queryParameters['Recursive'], 'true'); + expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); + expect(captured!.queryParameters['ImageTypeLimit'], '1'); expect(captured!.queryParameters.containsKey('ParentId'), isFalse); client.close(); }); @@ -972,6 +990,8 @@ void main() { expect(captured!.queryParameters.containsKey('ParentId'), isFalse); expect(captured!.queryParameters['EnableResumable'], 'false'); expect(captured!.queryParameters['EnableTotalRecordCount'], 'false'); + expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); + expect(captured!.queryParameters['ImageTypeLimit'], '1'); expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse); client.close(); }); @@ -984,6 +1004,8 @@ void main() { expect(captured!.path, '/Users/user-1/Items/Latest'); expect(captured!.queryParameters['ParentId'], 'lib-99'); expect(captured!.queryParameters['Limit'], '30'); + expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); + expect(captured!.queryParameters['ImageTypeLimit'], '1'); // ParentId-scoped Latest should NOT also pin IncludeItemTypes (the // library already constrains the kinds returned). expect(captured!.queryParameters.containsKey('IncludeItemTypes'), isFalse); @@ -998,6 +1020,9 @@ void main() { expect(captured!.path, '/UserItems/Resume'); expect(captured!.queryParameters['ParentId'], 'lib-99'); expect(captured!.queryParameters['userId'], 'user-1'); + expect(captured!.queryParameters['Recursive'], 'true'); + expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); + expect(captured!.queryParameters['ImageTypeLimit'], '1'); client.close(); }); @@ -1011,6 +1036,8 @@ void main() { expect(captured!.queryParameters['userId'], 'user-1'); expect(captured!.queryParameters['EnableResumable'], 'false'); expect(captured!.queryParameters['EnableTotalRecordCount'], 'false'); + expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); + expect(captured!.queryParameters['ImageTypeLimit'], '1'); expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse); client.close(); }); diff --git a/test/services/library_query_translator_test.dart b/test/services/library_query_translator_test.dart index 1aa0baeb..075870a7 100644 --- a/test/services/library_query_translator_test.dart +++ b/test/services/library_query_translator_test.dart @@ -79,6 +79,8 @@ void main() { expect(params['Recursive'], 'true'); expect(params['Fields'], 'UserData'); expect(params['IncludeItemTypes'], isNotEmpty); + expect(params['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo'); + expect(params['ImageTypeLimit'], '1'); }); test('movie kind maps to IncludeItemTypes=Movie', () {