import 'dart:async'; import 'dart:convert'; import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/exceptions/media_server_exceptions.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/media/library_query.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_playlist.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/models/transcode_quality_preset.dart'; import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/playback_initialization_types.dart'; import 'package:plezy/services/subtitle_preference.dart'; import 'package:plezy/utils/device_identity.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/http_fixtures.dart'; import '../test_helpers/paged_fakes.dart'; import '../test_helpers/media_items.dart'; JellyfinConnection _conn({String accessToken = 'tok-abc', String baseUrl = 'https://jf.example.com'}) => testJellyfinConnection( baseUrl: baseUrl, userName: 'edde', accessToken: accessToken, deviceId: 'dev-xyz', createdAt: DateTime.fromMillisecondsSinceEpoch(0), ); JellyfinClient _clientWithPlaybackInfo( Future Function(http.Request request) playbackInfo, { List>? itemSources, }) { final sources = itemSources ?? [ { 'Id': 'src-1', 'Container': 'mkv', 'MediaStreams': [ {'Index': 0, 'Type': 'Video'}, ], }, ]; return JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) { if (request.url.path == '/Users/user-1/Items/item-1') { return Future.value(jsonResponse({'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': sources})); } if (request.url.path == '/Items/item-1/PlaybackInfo') { return playbackInfo(request); } return Future.value(http.Response('{}', 404)); }), ); } /// Serves [routes] as JSON keyed by request path and records the last URL seen /// for each path; every other path answers 404. ({JellyfinClient client, Map requests}) _routedClient(Map routes) { final requests = {}; final client = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { final body = routes[req.url.path]; if (body == null) return http.Response('not found', 404); requests[req.url.path] = req.url; return jsonResponse(body); }), ); addTearDown(client.close); return (client: client, requests: requests); } /// URL-builder smoke tests. We can't unit-test a network round-trip without /// spinning up a Jellyfin server, but the URL shape is a clear unit-of-work: /// query parameters must include the right keys and the auth token. These /// tests pin the contract so the next iteration of the player (Task 8 wiring) /// has something to point at. void main() { // Pin device identity so JellyfinClient.create's MediaBrowser header falls // back to Device="Plezy" instead of resolving the host machine's name. setUpAll(() => DeviceIdentityService.debugOverride(const DeviceIdentity(platform: 'Test'))); tearDownAll(() => DeviceIdentityService.debugOverride(null)); group('JellyfinClient URL builders', () { late JellyfinClient client; setUp(() async { client = await JellyfinClient.create(_conn()); }); tearDown(() { client.close(); }); test('concurrent fetchLibraries calls share one /Views request', () async { // At cold start LibrariesProvider.loadLibraries() and // DataAggregationService.getHubsFromAllServers ask for libraries at the // same time, and the hub fan-out waits serially behind its copy. Two // identical round trips was a full RTT of dead cold-start latency (#1784). var views = 0; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { if (req.url.path == '/Users/user-1/Views') views++; return http.Response( jsonEncode({ 'Items': [ {'Id': 'lib-1', 'Name': 'Movies', 'CollectionType': 'movies'}, ], }), 200, headers: {'content-type': 'application/json'}, ); }), ); addTearDown(scoped.close); final results = await Future.wait([scoped.fetchLibraries(), scoped.fetchLibraries()]); expect(views, 1); expect(results.map((libs) => libs.single.id), ['lib-1', 'lib-1']); // Single-flight, not a cache: a later pass still sees server-side changes. await scoped.fetchLibraries(); expect(views, 2); }); test('buildDirectStreamUrl includes static flag, api_key, and device id', () { final url = client.buildDirectStreamUrl('item-99'); final uri = Uri.parse(url); expect(uri.scheme, 'https'); 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['DeviceId'], 'dev-xyz'); expect(uri.queryParameters.containsKey('Container'), isFalse); }); test('buildDirectStreamUrl appends Container when provided', () { final url = client.buildDirectStreamUrl('item-99', container: 'mp4'); expect(Uri.parse(url).queryParameters['Container'], 'mp4'); }); test('buildDirectStreamUrl appends MediaSourceId when provided', () { // Items with multiple `MediaSources` need this param to disambiguate; // without it Jellyfin defaults to the primary source even if the URL's // {itemId} matches a non-primary. final url = client.buildDirectStreamUrl('item-99', mediaSourceId: 'src-2'); expect(Uri.parse(url).queryParameters['MediaSourceId'], 'src-2'); }); test('buildDirectStreamUrl appends AudioStreamIndex when provided', () { final url = client.buildDirectStreamUrl('item-99', audioStreamIndex: 4); expect(Uri.parse(url).queryParameters['AudioStreamIndex'], '4'); }); test('buildDirectStreamUrl omits MediaSourceId by default', () { final url = client.buildDirectStreamUrl('item-99'); expect(Uri.parse(url).queryParameters.containsKey('MediaSourceId'), isFalse); }); test('buildDirectStreamUrl path-encodes reserved item id characters', () { final url = client.buildDirectStreamUrl('folder/item #1?x'); expect(Uri.parse(url).path, '/Videos/folder%2Fitem%20%231%3Fx/stream'); }); test('buildAudioDirectStreamUrl targets /Audio with the same static-stream contract', () { final url = client.buildAudioDirectStreamUrl('track-7'); final uri = Uri.parse(url); expect(uri.path, '/Audio/track-7/stream'); expect(uri.queryParameters['Static'], 'true'); expect(uri.queryParameters['api_key'], 'tok-abc'); expect(uri.queryParameters['DeviceId'], 'dev-xyz'); expect(uri.queryParameters.containsKey('Container'), isFalse); expect(uri.queryParameters.containsKey('MediaSourceId'), isFalse); }); test('buildAudioDirectStreamUrl appends Container and MediaSourceId when provided', () { final url = client.buildAudioDirectStreamUrl('track-7', container: 'flac', mediaSourceId: 'src-9'); final uri = Uri.parse(url); expect(uri.queryParameters['Container'], 'flac'); expect(uri.queryParameters['MediaSourceId'], 'src-9'); }); test('buildDirectStreamUrl canonicalizes a mixed-case scheme from stored config', () async { // This URL bypasses Dart's Uri normalization on its way to the player, // and FFmpeg's protocol lookup is case-sensitive — a stored // "Https://..." base URL fails with "Protocol not found" (#1465). final mixedCase = await JellyfinClient.create(_conn(baseUrl: 'Https://jf.example.com/')); addTearDown(mixedCase.close); final url = mixedCase.buildDirectStreamUrl('item-99'); expect(url, startsWith('https://jf.example.com/Videos/')); }); test('fetchSortOptions exposes the broad Jellyfin sort set', () async { final sorts = await client.fetchSortOptions('lib-1'); expect(sorts.map((sort) => sort.key).toList(), [ 'title', 'rating', 'criticRating', 'addedAt', 'lastViewedAt', 'viewCount', 'productionYear', 'runtime', 'officialRating', 'originallyAvailableAt', 'startDate', 'airTime', 'studio', 'random', ]); }); test('fetchSortOptions adds episode added sort only for shows', () async { final showSorts = await client.fetchSortOptions('lib-1', libraryType: 'show'); expect(showSorts.map((sort) => sort.key).toList(), [ 'title', 'rating', 'criticRating', 'addedAt', 'episode.addedAt', 'lastViewedAt', 'viewCount', 'productionYear', 'runtime', 'officialRating', 'originallyAvailableAt', 'startDate', 'airTime', 'studio', 'random', ]); final movieSorts = await client.fetchSortOptions('lib-1', libraryType: 'movie'); expect(movieSorts.map((sort) => sort.key), isNot(contains('episode.addedAt'))); }); test('fetchExtras combines local trailers and special features as playable videos', () async { const itemId = 'movie/id #1?x'; final encodedItemId = Uri.encodeComponent(itemId); final requests = []; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Items/$encodedItemId/LocalTrailers') { return jsonResponse([ { 'Id': 'trailer-1', 'Name': 'Trailer', 'Type': 'Trailer', 'ExtraType': 'Trailer', 'RunTimeTicks': 900000000, 'ImageTags': {'Primary': 'trailer-tag'}, }, {'Id': 'theme-song', 'Name': 'Theme Song', 'Type': 'Audio', 'ExtraType': 'ThemeSong'}, ]); } if (request.url.path == '/Items/$encodedItemId/SpecialFeatures') { return jsonResponse([ {'Id': 'trailer-1', 'Name': 'Trailer Duplicate', 'Type': 'Trailer', 'ExtraType': 'Trailer'}, { 'Id': 'featurette-1', 'Name': 'Making Of', 'Type': 'Video', 'ExtraType': 'Featurette', 'RunTimeTicks': 1800000000, 'BackdropImageTags': ['featurette-backdrop'], }, ]); } return http.Response('unexpected ${request.url}', 500); }), ); addTearDown(scoped.close); final extras = await scoped.fetchExtras(itemId); expect(requests.map((uri) => uri.path).toSet(), { '/Items/$encodedItemId/LocalTrailers', '/Items/$encodedItemId/SpecialFeatures', }); expect(requests.every((uri) => uri.queryParameters['userId'] == 'user-1'), isTrue); expect(requests.every((uri) => uri.queryParameters['EnableImageTypes'] == 'Primary,Backdrop,Logo'), isTrue); expect(requests.every((uri) => uri.queryParameters['ImageTypeLimit'] == '3'), isTrue); expect(extras.map((item) => item.id).toList(), ['trailer-1', 'featurette-1']); expect(extras.every((item) => item.kind.isVideo), isTrue); expect(extras.every((item) => item.serverId == 'srv-1'), isTrue); expect(extras.every((item) => item.serverName == 'Home'), isTrue); expect(extras[0].kind, MediaKind.clip); expect(extras[0].raw?['ExtraType'], 'Trailer'); expect(extras[1].kind, MediaKind.clip); expect(extras[1].raw?['ExtraType'], 'Featurette'); expect(extras[1].thumbPath, isNull); expect(extras[1].artPath, isNotNull); expect(extras[1].posterThumb(), extras[1].artPath); }); test('fetchChildren requests media sources for episode-row quality labels', () async { final requests = []; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Shows/season-1/Seasons') { return http.Response('not found', 404); } if (request.url.path == '/Items') { return http.Response(jsonEncode({'Items': [], 'TotalRecordCount': 0}), 200); } return http.Response('unexpected ${request.url}', 500); }), ); addTearDown(scoped.close); await scoped.fetchChildren('season-1'); final directChildrenRequest = requests.firstWhere((uri) => uri.path == '/Items'); expect(directChildrenRequest.queryParameters['Fields']!.split(','), contains('MediaSources')); expect(directChildrenRequest.queryParameters['SortBy'], 'ParentIndexNumber,IndexNumber,SortName'); expect(directChildrenRequest.queryParameters['SortOrder'], 'Ascending,Ascending,Ascending'); }); test('fetchPlayableDescendantsPage requests media sources for episode-row quality labels', () async { Uri? capturedUri; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { capturedUri = request.url; return http.Response(jsonEncode({'Items': [], 'TotalRecordCount': 0}), 200); }), ); addTearDown(scoped.close); await scoped.fetchPlayableDescendantsPage('show-1'); expect(capturedUri!.path, '/Items'); expect(capturedUri!.queryParameters['Fields']!.split(','), contains('MediaSources')); }); test('reportPlaybackProgress sends media source and stream indexes', () async { Uri? capturedUri; String? capturedBody; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { capturedUri = request.url; capturedBody = request.body; return http.Response('', 204); }), ); addTearDown(scoped.close); await scoped.reportPlaybackProgress( itemId: 'item-1', position: const Duration(seconds: 12), duration: const Duration(seconds: 100), isPaused: true, playSessionId: 'play-1', playMethod: 'Transcode', mediaSourceId: 'source-1', audioStreamIndex: 2, subtitleStreamIndex: -1, ); expect(capturedUri!.path, '/Sessions/Playing/Progress'); final body = jsonDecode(capturedBody!) as Map; expect(body['ItemId'], 'item-1'); expect(body['MediaSourceId'], 'source-1'); expect(body['AudioStreamIndex'], 2); expect(body['SubtitleStreamIndex'], -1); expect(body['PlaySessionId'], 'play-1'); expect(body['PlayMethod'], 'Transcode'); expect(body['IsPaused'], isTrue); }); test('live playback reports preserve the same server session identity', () async { final requests = <({String path, Map body})>[]; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { requests.add((path: request.url.path, body: jsonDecode(request.body) as Map)); return http.Response('', 204); }), ); addTearDown(scoped.close); await scoped.reportPlaybackStarted( itemId: 'channel-1', position: Duration.zero, playSessionId: 'play-1', liveStreamId: 'live-1', mediaSourceId: 'source-1', ); await scoped.reportPlaybackProgress( itemId: 'channel-1', position: const Duration(seconds: 10), duration: Duration.zero, playSessionId: 'play-1', liveStreamId: 'live-1', mediaSourceId: 'source-1', ); await scoped.reportPlaybackStopped( itemId: 'channel-1', position: const Duration(seconds: 20), playSessionId: 'play-1', liveStreamId: 'live-1', mediaSourceId: 'source-1', ); expect(requests.map((request) => request.path), [ '/Sessions/Playing', '/Sessions/Playing/Progress', '/Sessions/Playing/Stopped', ]); for (final request in requests) { expect(request.body['ItemId'], 'channel-1'); expect(request.body['PlaySessionId'], 'play-1'); expect(request.body['LiveStreamId'], 'live-1'); expect(request.body['MediaSourceId'], 'source-1'); } }); test('resolveDownload pins direct stream URL and subtitles to selected media source', () async { final requests = []; String? playbackInfoBody; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Users/user-1/Items/item-1') { return jsonResponse({ 'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': [ {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, {'Id': 'src-2', 'Container': 'mkv', 'MediaStreams': []}, ], }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoBody = request.body; return jsonResponse({ 'MediaSources': [ {'Id': 'src-1', 'MediaStreams': []}, { 'Id': 'src-2', 'MediaStreams': [ { 'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'DisplayLanguage': 'English', 'DisplayTitle': 'English - SRT', 'IsExternal': true, 'DeliveryMethod': 'External', 'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/3/Stream.srt', }, { 'Index': 4, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'fra', 'DisplayLanguage': 'French', 'DisplayTitle': 'French - SRT', 'DeliveryMethod': 'External', 'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/4/Stream.srt', }, ], }, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); final resolution = await scoped.resolveDownload( testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), mediaIndex: 1, ); final uri = Uri.parse(resolution.videoUrl!); expect(uri.queryParameters['MediaSourceId'], 'src-2'); expect(uri.queryParameters['Container'], 'mkv'); expect(requests.map((u) => u.path), contains('/Items/item-1/PlaybackInfo')); final playbackInfoRequest = requests.firstWhere((u) => u.path == '/Items/item-1/PlaybackInfo'); expect(playbackInfoRequest.queryParameters['MediaSourceId'], 'src-2'); final body = jsonDecode(playbackInfoBody!) as Map; expect(body['MediaSourceId'], 'src-2'); expect(resolution.externalSubtitles, hasLength(1)); final subtitle = resolution.externalSubtitles.single; expect(subtitle.id, 3); expect(subtitle.language, 'English'); 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'); requests.clear(); playbackInfoBody = null; final pinnedResolution = await scoped.resolveDownload( testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), mediaIndex: 0, mediaSourceId: 'src-2', ); expect(Uri.parse(pinnedResolution.videoUrl!).queryParameters['MediaSourceId'], 'src-2'); expect( requests.firstWhere((u) => u.path == '/Items/item-1/PlaybackInfo').queryParameters['MediaSourceId'], 'src-2', ); expect((jsonDecode(playbackInfoBody!) as Map)['MediaSourceId'], 'src-2'); }); test('resolveDownload keeps the static stream after non-authentication enrichment failures', () async { final cases = <(String, Future Function(http.Request))>[ ('server error', (_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'})), ('client error', (_) async => http.Response('{}', 400, headers: {'content-type': 'application/json'})), ('malformed success', (_) async => jsonResponse({'MediaSources': 'invalid'})), ]; for (final (name, handler) in cases) { final scoped = _clientWithPlaybackInfo(handler); addTearDown(scoped.close); final resolution = await scoped.resolveDownload( testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), ); final uri = Uri.parse(resolution.videoUrl!); expect(uri.path, '/Videos/item-1/stream', reason: name); expect(uri.queryParameters['Static'], 'true', reason: name); expect(resolution.externalSubtitles, isEmpty, reason: name); expect(resolution.externalSubtitlesResolved, isFalse, reason: name); } }); test('resolveDownload keeps the static stream when subtitle metadata is malformed', () async { final scoped = _clientWithPlaybackInfo( (_) async => jsonResponse({ 'MediaSources': [ {'Id': 'src-1'}, ], }), ); addTearDown(scoped.close); final resolution = await scoped.resolveDownload( testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), ); expect(Uri.parse(resolution.videoUrl!).path, '/Videos/item-1/stream'); expect(resolution.externalSubtitles, isEmpty); expect(resolution.externalSubtitlesResolved, isFalse); }); test('resolveDownload does not hide PlaybackInfo authentication failures', () async { final scoped = _clientWithPlaybackInfo( (_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'}), ); addTearDown(scoped.close); await expectLater( scoped.resolveDownload( testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), ), throwsA(isA().having((error) => error.statusCode, 'statusCode', 401)), ); }); test('getPlaybackInitialization uses static playback after non-authentication negotiation failures', () async { final cases = <(String, Future Function(http.Request))>[ ('server error', (_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'})), ('client error', (_) async => http.Response('{}', 400, headers: {'content-type': 'application/json'})), ('malformed success', (_) async => jsonResponse({'MediaSources': 'invalid'})), ]; for (final (name, handler) in cases) { final scoped = _clientWithPlaybackInfo(handler); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ), selectedMediaIndex: 0, qualityPreset: TranscodeQualityPreset.original, ), ); expect(Uri.parse(result.videoUrl!).path, '/Videos/item-1/stream', reason: name); expect(result.playMethod, 'DirectPlay', reason: name); expect(result.fallbackReason, TranscodeFallbackReason.decisionFailed, reason: name); } }); test('getPlaybackInitialization maps negotiation authentication failures', () async { final scoped = _clientWithPlaybackInfo( (_) async => http.Response('{}', 403, headers: {'content-type': 'application/json'}), ); addTearDown(scoped.close); await expectLater( scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ), selectedMediaIndex: 0, qualityPreset: TranscodeQualityPreset.original, ), ), throwsA( isA().having( (error) => error.reason, 'reason', PlaybackFailureReason.authenticationRequired, ), ), ); }); test('resolveExternalPlaybackUrl pins primary source id when alternates exist', () async { final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { return jsonResponse({ 'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': [ {'Id': 'item-1', 'Container': 'mp4', 'MediaStreams': []}, {'Id': 'src-alt', 'Container': 'mkv', 'MediaStreams': []}, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); final url = await scoped.resolveExternalPlaybackUrl( testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), mediaIndex: 0, mediaSourceId: 'item-1', ); final uri = Uri.parse(url!); expect(uri.queryParameters['MediaSourceId'], 'item-1'); expect(uri.queryParameters['Container'], 'mp4'); }); test('getPlaybackInitialization sends resume ticks without rewriting TranscodingUrl', () async { final playbackInfoUris = []; final playbackInfoBodies = []; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { return jsonResponse({ 'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': [ {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, ], }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUris.add(request.url); playbackInfoBodies.add(request.body); return jsonResponse({ 'MediaSources': [ { 'Id': 'src-1', 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-1', 'MediaStreams': [ {'Index': 0, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'}, { 'Index': 2, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'DisplayTitle': 'English - SRT', 'DeliveryMethod': 'External', 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/2/Stream.srt', }, ], }, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', viewOffsetMs: 143894, ), selectedMediaIndex: 0, qualityPreset: TranscodeQualityPreset.p720_2mbps, ), ); expect(result.isTranscoding, isTrue); expect(result.playMethod, 'Transcode'); expect(result.playSessionId, 'play-session-1'); expect(playbackInfoUris, hasLength(1)); expect(playbackInfoUris.single.queryParameters['StartTimeTicks'], '1438940000'); final body = jsonDecode(playbackInfoBodies.single) as Map; expect(body['StartTimeTicks'], 1438940000); final uri = Uri.parse(result.videoUrl!); 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.containsKey('StartTimeTicks'), isFalse); expect(result.mediaInfo!.subtitleTracks, hasLength(1)); expect(result.mediaInfo!.subtitleTracks.single.isExternalFile, isFalse); expect(result.mediaInfo!.subtitleTracks.single.usesExternalDelivery, isTrue); expect(result.externalSubtitles, hasLength(1)); expect(result.subtitleSidecars.single.sourceStreamId, 2); expect(result.externalSubtitles.single.title, 'English'); expect(result.externalSubtitles.single.language, 'eng'); final subtitleUri = Uri.parse(result.externalSubtitles.single.uri!); expect(subtitleUri.path, '/Videos/item-1/src-1/Subtitles/2/Stream.srt'); expect(subtitleUri.queryParameters['api_key'], 'tok-abc'); }); test('getPlaybackInitialization strips sidecar identity from direct-played embedded subtitles', () async { // Jellyfin answers `Method: External` subtitle profiles with an // `External` delivery method plus a DeliveryUrl even for streams that // stay inside a direct-played container. Direct play never fetches those // URLs, so the rows must not keep an identity that makes track matching // wait for a sidecar (issue #1696). final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { return jsonResponse({ 'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': [ {'Id': 'src-1', 'Container': 'mkv', 'MediaStreams': []}, ], }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { return jsonResponse({ 'MediaSources': [ { 'Id': 'src-1', 'Container': 'mkv', 'SupportsDirectPlay': true, 'DefaultSubtitleStreamIndex': 3, 'MediaStreams': [ {'Index': 1, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn', 'IsDefault': true}, { 'Index': 3, 'Type': 'Subtitle', 'Codec': 'ass', 'Language': 'eng', 'DisplayTitle': 'English Forced - ASS', 'IsDefault': true, 'IsForced': true, 'IsExternal': false, 'DeliveryMethod': 'External', 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/0/Stream.ass', }, { 'Index': 4, 'Type': 'Subtitle', 'Codec': 'ass', 'Language': 'eng', 'DisplayTitle': 'English - ASS', 'IsExternal': false, 'DeliveryMethod': 'External', 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/4/0/Stream.ass', }, { 'Index': 5, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'swe', 'DisplayTitle': 'Swedish - SRT', 'IsExternal': true, 'DeliveryMethod': 'External', 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/5/0/Stream.srt', }, ], }, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ), selectedMediaIndex: 0, ), ); expect(result.isTranscoding, isFalse); expect(result.playMethod, 'DirectPlay'); final tracks = result.mediaInfo!.subtitleTracks; expect(tracks.map((track) => track.id), [3, 4, 5]); // Embedded rows lose the delivery hint they cannot honour. for (final track in tracks.where((track) => track.id != 5)) { expect(track.key, isNull, reason: 'embedded row ${track.id} kept a delivery URL'); expect(track.usesExternalDelivery, isFalse); expect(track.isExternal, isFalse); } // A genuine separate file is absent from the container either way, and // direct play does load it, so it keeps its sidecar identity. final sidecarRow = tracks.singleWhere((track) => track.id == 5); expect(sidecarRow.key, '/Videos/item-1/src-1/Subtitles/5/0/Stream.srt'); expect(sidecarRow.isExternalFile, isTrue); expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [5]); // The server default survives normalization so selection can honour it. expect(result.mediaInfo!.defaultSubtitleStreamIndex, 3); expect(tracks.singleWhere((track) => track.id == 3).selected, isTrue); }); test('getPlaybackInitialization keeps original playback on the static stream with no bitrate cap', () async { final requests = []; String? playbackInfoBody; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Users/user-1/Items/item-1') { return jsonResponse({ 'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': [ { 'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': [ {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, ], }, ], }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoBody = request.body; return jsonResponse({ 'PlaySessionId': 'play-session-direct', 'MediaSources': [ { 'Id': 'src-1', 'Container': 'mp4', 'DefaultAudioStreamIndex': 1, // Jellyfin never returns a direct-play URL, only this one. 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode', 'MediaStreams': [ {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'}, { 'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'DisplayTitle': 'English - SRT', 'IsExternal': true, 'DeliveryMethod': 'External', 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt', }, ], }, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ), selectedMediaIndex: 0, ), ); final playbackInfoRequest = requests.firstWhere((uri) => uri.path == '/Items/item-1/PlaybackInfo'); expect(playbackInfoRequest.queryParameters.containsKey('MaxStreamingBitrate'), isFalse); expect(playbackInfoRequest.queryParameters.containsKey('StartTimeTicks'), isFalse); expect(playbackInfoRequest.queryParameters['MediaSourceId'], 'src-1'); final body = jsonDecode(playbackInfoBody!) as Map; expect(body.containsKey('MaxStreamingBitrate'), isFalse); expect(body.containsKey('StartTimeTicks'), isFalse); final profile = body['DeviceProfile'] as Map; expect(profile.containsKey('MaxStreamingBitrate'), isFalse); expect(result.isTranscoding, isFalse); expect(result.playMethod, 'DirectPlay'); expect(result.playSessionId, isNull); expect(result.activeAudioStreamId, isNull); expect(result.mediaInfo!.audioTracks.single.selected, isTrue); final uri = Uri.parse(result.videoUrl!); 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(result.mediaInfo!.subtitleTracks, hasLength(1)); expect(result.externalSubtitles, hasLength(1)); expect(result.subtitleSidecars.single.sourceStreamId, 3); 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'); }); test('getPlaybackInitialization maps semantic subtitle preferences to current source rows', () async { Uri? playbackInfoUri; String? playbackInfoBody; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { return jsonResponse({ 'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': [ { 'Id': 'src-1', 'Container': 'mkv', 'MediaStreams': [ {'Index': 0, 'Type': 'Video'}, {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng'}, {'Index': 4, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'fra'}, {'Index': 5, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsForced': true}, ], }, ], }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; return jsonResponse({ 'PlaySessionId': 'play-session-direct', 'MediaSources': [ { 'Id': 'src-1', 'Container': 'mkv', 'DefaultSubtitleStreamIndex': 4, 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-direct', 'MediaStreams': [ {'Index': 0, 'Type': 'Video'}, { 'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'DisplayTitle': 'English - SRT', 'DeliveryMethod': 'External', 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt', }, { 'Index': 4, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'fra', 'DisplayTitle': 'French - SRT', 'DeliveryMethod': 'External', 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/4/Stream.srt', }, { 'Index': 5, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'DisplayTitle': 'English Forced - SRT', 'IsForced': true, 'DeliveryMethod': 'External', 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/5/Stream.srt', }, ], }, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); Future initialize(SubtitlePreference preference) { return scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ), selectedMediaIndex: 0, preferredSubtitleTrack: preference, // Transcoded playback is the case where the server delivers these // streams out-of-band; direct play keeps the embedded tracks and is // covered by the sidecar-identity test above. qualityPreset: TranscodeQualityPreset.p720_2mbps, ), ); } void expectRequestedSubtitleIndex(int? expected) { expect(playbackInfoUri!.queryParameters['SubtitleStreamIndex'], expected?.toString()); final playbackInfoJson = jsonDecode(playbackInfoBody!) as Map; expect(playbackInfoJson['SubtitleStreamIndex'], expected); } final result = await initialize( const SubtitlePreference.track( SubtitleTrack(id: 'source:4', title: 'French - SRT', language: 'fra', codec: 'srt'), ), ); expectRequestedSubtitleIndex(4); await initialize( const SubtitlePreference.intent( SubtitleIntent(language: 'eng', forced: false, title: 'English - SRT', codec: 'srt'), ), ); expectRequestedSubtitleIndex(3); await initialize( const SubtitlePreference.track( SubtitleTrack(id: 'source:3', title: 'French - SRT', language: 'fra', codec: 'srt'), ), ); expectRequestedSubtitleIndex(4); await initialize( const SubtitlePreference.track( SubtitleTrack(id: 'source:3', title: 'English Forced - SRT', language: 'eng', codec: 'srt', isForced: true), ), ); expectRequestedSubtitleIndex(5); await initialize(const SubtitlePreference.off()); expectRequestedSubtitleIndex(-1); await initialize( const SubtitlePreference.intent( SubtitleIntent(language: 'jpn', forced: false, title: 'Japanese - SRT', codec: 'srt'), ), ); expectRequestedSubtitleIndex(null); expect(result.playMethod, 'Transcode'); expect(result.mediaInfo!.subtitleTracks, hasLength(3)); expect(result.mediaInfo!.subtitleTracks.every((track) => track.usesExternalDelivery), isTrue); expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [3, 4, 5]); expect(result.externalSubtitles.map((subtitle) => Uri.parse(subtitle.uri!).path), [ '/Videos/item-1/src-1/Subtitles/3/Stream.srt', '/Videos/item-1/src-1/Subtitles/4/Stream.srt', '/Videos/item-1/src-1/Subtitles/5/Stream.srt', ]); }); test('getPlaybackInitialization ignores TranscodingUrl for original playback static fallback', () async { final requests = []; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Users/user-1/Items/item-1') { return jsonResponse({ 'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': [ { 'Id': 'src-1', 'Container': 'mkv', 'MediaStreams': [ {'Index': 0, 'Type': 'Video'}, ], }, ], }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { return jsonResponse({ 'MediaSources': [ { 'Id': 'src-1', 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode', 'MediaStreams': [ {'Index': 0, 'Type': 'Video'}, ], }, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ), selectedMediaIndex: 0, ), ); final playbackInfoRequest = requests.firstWhere((uri) => uri.path == '/Items/item-1/PlaybackInfo'); expect(playbackInfoRequest.queryParameters.containsKey('MaxStreamingBitrate'), isFalse); expect(playbackInfoRequest.queryParameters.containsKey('StartTimeTicks'), isFalse); expect(result.isTranscoding, isFalse); expect(result.playMethod, 'DirectPlay'); expect(result.playSessionId, isNull); final uri = Uri.parse(result.videoUrl!); expect(uri.path, '/Videos/item-1/stream'); 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.containsKey('PlaySessionId'), isFalse); expect(uri.queryParameters.containsKey('StartTimeTicks'), isFalse); }); test('single-source direct play still pins MediaSourceId when the source id equals the item id', () async { // The real-world shape for an ordinary Jellyfin episode: exactly one // MediaSource whose Id is the item's own GUID. Plezy used to drop // MediaSourceId here, leaving Jellyfin to resolve its own first sorted // source — a different file as soon as the item gains an alternate // version. Every official client sends it unconditionally. final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { return jsonResponse({ 'Id': 'item-1', 'Type': 'Episode', 'Name': 'Episode', 'MediaSources': [ { 'Id': 'item-1', 'Container': 'mkv', 'MediaStreams': [ {'Index': 0, 'Type': 'Video'}, ], }, ], }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { return jsonResponse({ 'MediaSources': [ { 'Id': 'item-1', 'Container': 'mkv', 'MediaStreams': [ {'Index': 0, 'Type': 'Video'}, ], }, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.episode, serverId: 'srv-1', ), selectedMediaIndex: 0, ), ); expect(result.playMethod, 'DirectPlay'); final uri = Uri.parse(result.videoUrl!); expect(uri.path, '/Videos/item-1/stream'); expect(uri.queryParameters['Static'], 'true'); expect(uri.queryParameters['MediaSourceId'], 'item-1'); expect(uri.queryParameters['Container'], 'mkv'); }); test('selected external audio is sent to PlaybackInfo but omitted from static fallback URL', () async { Uri? playbackInfoUri; String? playbackInfoBody; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { return jsonResponse({ 'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': [ { 'Id': 'src-1', 'Container': 'mkv', 'MediaStreams': [ {'Index': 0, 'Type': 'Video'}, {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'IsDefault': true}, {'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn', 'DeliveryMethod': 'External'}, ], }, ], }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; return jsonResponse({ 'MediaSources': [ {'Id': 'src-1'}, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ), selectedMediaIndex: 0, selectedAudioStreamId: 4, ), ); expect(playbackInfoUri!.queryParameters['AudioStreamIndex'], '4'); final body = jsonDecode(playbackInfoBody!) as Map; expect(body['AudioStreamIndex'], 4); expect(result.playMethod, 'DirectPlay'); expect(result.activeAudioStreamId, 4); final selected = result.mediaInfo!.audioTracks.singleWhere((track) => track.id == 4); expect(selected.isExternal, isTrue); expect(selected.selected, isTrue); final uri = Uri.parse(result.videoUrl!); expect(uri.queryParameters.containsKey('AudioStreamIndex'), isFalse); expect(uri.queryParameters['MediaSourceId'], 'src-1'); expect(uri.queryParameters['Container'], 'mkv'); }); test('stale selected audio stream is not sent for a source without that stream', () async { Uri? playbackInfoUri; String? playbackInfoBody; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { return jsonResponse({ 'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': [ { 'Id': 'src-1', 'Container': 'mkv', 'MediaStreams': [ {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, {'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn'}, ], }, { 'Id': 'src-2', 'Container': 'mp4', 'DefaultAudioStreamIndex': 8, 'MediaStreams': [ {'Index': 8, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, ], }, ], }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; return jsonResponse({ 'MediaSources': [ {'Id': 'src-2'}, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ), selectedMediaIndex: 1, selectedAudioStreamId: 4, ), ); expect(playbackInfoUri!.queryParameters.containsKey('AudioStreamIndex'), isFalse); final body = jsonDecode(playbackInfoBody!) as Map; expect(body.containsKey('AudioStreamIndex'), isFalse); expect(result.activeAudioStreamId, isNull); expect(result.mediaInfo!.audioTracks.single.selected, isTrue); final uri = Uri.parse(result.videoUrl!); expect(uri.queryParameters.containsKey('AudioStreamIndex'), isFalse); expect(uri.queryParameters['MediaSourceId'], 'src-2'); }); test('playback initialization pins selected media source id over index', () async { Uri? playbackInfoUri; String? playbackInfoBody; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { return jsonResponse({ 'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': [ { 'Id': 'src-4k', 'Container': 'mkv', 'MediaStreams': [ {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 1608, 'Width': 3840}, ], }, { 'Id': 'src-1080', 'Container': 'mp4', 'MediaStreams': [ {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 804, 'Width': 1920}, ], }, ], }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; return jsonResponse({ 'MediaSources': [ {'Id': 'src-1080'}, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ), selectedMediaIndex: 0, selectedMediaSourceId: 'src-1080', ), ); expect(playbackInfoUri!.queryParameters['MediaSourceId'], 'src-1080'); final body = jsonDecode(playbackInfoBody!) as Map; expect(body['MediaSourceId'], 'src-1080'); expect(result.availableVersions.map((version) => version.id), ['src-4k', 'src-1080']); final uri = Uri.parse(result.videoUrl!); expect(uri.queryParameters['MediaSourceId'], 'src-1080'); expect(uri.queryParameters['Container'], 'mp4'); }); test('playback initialization pins primary source id for multi-source direct fallback', () async { Uri? playbackInfoUri; String? playbackInfoBody; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { return jsonResponse({ 'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': [ { 'Id': 'item-1', 'Container': 'mp4', 'MediaStreams': [ {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, ], }, { 'Id': 'src-4k', 'Container': 'mkv', 'MediaStreams': [ {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, ], }, ], }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; return jsonResponse({ 'MediaSources': [ {'Id': 'item-1'}, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ), selectedMediaIndex: 0, selectedMediaSourceId: 'item-1', ), ); expect(playbackInfoUri!.queryParameters['MediaSourceId'], 'item-1'); final body = jsonDecode(playbackInfoBody!) as Map; expect(body['MediaSourceId'], 'item-1'); final uri = Uri.parse(result.videoUrl!); expect(uri.queryParameters['MediaSourceId'], 'item-1'); expect(uri.queryParameters['Container'], 'mp4'); }); test( 'playback initialization ignores a mismatched negotiated source and keeps the selected static stream', () async { final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { return jsonResponse({ 'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': [ { 'Id': 'src-1080', 'Container': 'mp4', 'MediaStreams': [ {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, ], }, { 'Id': 'src-4k', 'Container': 'mkv', 'MediaStreams': [ {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, ], }, ], }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { return jsonResponse({ 'PlaySessionId': 'wrong-session', 'MediaSources': [ { 'Id': 'src-4k', 'Container': 'mkv', 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-4k&PlaySessionId=wrong-session', }, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ), selectedMediaIndex: 0, selectedMediaSourceId: 'src-1080', ), ); expect(result.fallbackReason, TranscodeFallbackReason.decisionFailed); final uri = Uri.parse(result.videoUrl!); expect(uri.queryParameters['MediaSourceId'], 'src-1080'); expect(uri.queryParameters['PlaySessionId'], isNull); }, ); test('empty successful negotiation falls back to the static VOD stream', () async { final scoped = _clientWithPlaybackInfo((_) async => jsonResponse({'MediaSources': []})); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ), selectedMediaIndex: 0, ), ); expect(result.fallbackReason, TranscodeFallbackReason.decisionFailed); expect(Uri.parse(result.videoUrl!).queryParameters['MediaSourceId'], 'src-1'); }); test('applicable source without negotiated URL falls back to static direct play', () async { final scoped = _clientWithPlaybackInfo( (_) async => jsonResponse({ 'MediaSources': [ {'Id': 'src-1'}, ], }), ); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ), selectedMediaIndex: 0, qualityPreset: TranscodeQualityPreset.p720_2mbps, ), ); expect(result.isTranscoding, isFalse); expect(result.fallbackReason, TranscodeFallbackReason.directPlayOnly); expect(Uri.parse(result.videoUrl!).queryParameters['MediaSourceId'], 'src-1'); }); test('video download rejects authentication and cancellation', () async { final cases = <(String, Future Function(http.Request))>[ ('401', (_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'})), ('cancellation', (request) async => throw http.RequestAbortedException(request.url)), ]; final item = testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ); for (final (name, handler) in cases) { final scoped = _clientWithPlaybackInfo(handler); addTearDown(scoped.close); await expectLater(scoped.resolveDownload(item), throwsA(isA()), reason: name); } }); test('video download keeps the static stream for unusable negotiation sources', () async { final cases = <({Map response, List>? itemSources, String? expectedSourceId})>[ ( response: {'MediaSources': []}, itemSources: [ {'Container': 'mkv', 'MediaStreams': []}, ], expectedSourceId: null, ), ( response: { 'MediaSources': ['invalid'], }, itemSources: [ {'Container': 'mkv', 'MediaStreams': []}, ], expectedSourceId: null, ), ( response: { 'MediaSources': [ {'Id': 'other', 'MediaStreams': []}, ], }, itemSources: null, expectedSourceId: 'src-1', ), ]; final item = testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ); for (final testCase in cases) { final scoped = _clientWithPlaybackInfo( (_) async => jsonResponse(testCase.response), itemSources: testCase.itemSources, ); addTearDown(scoped.close); final resolution = await scoped.resolveDownload(item); expect(Uri.parse(resolution.videoUrl!).queryParameters['MediaSourceId'], testCase.expectedSourceId); expect(resolution.externalSubtitles, isEmpty); expect(resolution.externalSubtitlesResolved, isFalse); } }); test('matching download source with empty streams is a complete empty-sidecar plan', () async { final scoped = _clientWithPlaybackInfo( (_) async => jsonResponse({ 'MediaSources': [ {'Id': 'src-1', 'MediaStreams': []}, ], }), ); addTearDown(scoped.close); final resolution = await scoped.resolveDownload( testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1'), ); expect(resolution.videoUrl, isNotNull); expect(resolution.externalSubtitles, isEmpty); }); test('track downloads bypass PlaybackInfo', () async { var playbackInfoRequests = 0; final scoped = _clientWithPlaybackInfo((_) async { playbackInfoRequests++; return http.Response('{}', 500); }); addTearDown(scoped.close); final resolution = await scoped.resolveDownload( testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.track, serverId: 'srv-1'), ); expect(resolution.videoUrl, isNotNull); expect(resolution.externalSubtitles, isEmpty); expect(playbackInfoRequests, 0); }); test('getPlaybackInfo path-encodes reserved item id characters', () async { Uri? capturedUri; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { capturedUri = request.url; return jsonResponse({'MediaSources': []}); }), ); addTearDown(scoped.close); await scoped.getPlaybackInfo('folder/item #1?x'); expect(capturedUri.toString(), contains('/Items/folder%2Fitem%20%231%3Fx/PlaybackInfo')); }); test('getPlaybackInfo advertises embedded and external subtitle delivery', () async { Uri? capturedUri; String? capturedBody; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { capturedUri = request.url; capturedBody = request.body; return jsonResponse({'MediaSources': []}); }), ); 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); final directPlayProfile = (profile['DirectPlayProfiles'] as List).first as Map; expect(directPlayProfile['VideoCodec'], contains('mpeg2video')); expect(directPlayProfile['AudioCodec'], contains('mp2')); expect(profile['TranscodingProfiles'], isNotEmpty); expect(profile['CodecProfiles'], isEmpty); const subtitleFormats = ['srt', 'ass', 'ssa', 'vtt', 'pgssub', 'dvdsub', 'dvbsub']; final subtitleProfiles = [ for (final entry in profile['SubtitleProfiles'] as List) entry as Map, ]; // Every format is offered both ways, Embed first: the server picks per // play method, so direct play reports its container streams as embedded // while a remux or transcode still hands back sidecar URLs. expect( subtitleProfiles.where((entry) => entry['Method'] == 'Embed').map((entry) => entry['Format']), subtitleFormats, ); expect( subtitleProfiles.where((entry) => entry['Method'] == 'External').map((entry) => entry['Format']), subtitleFormats, ); expect( subtitleProfiles.indexWhere((entry) => entry['Method'] == 'Embed'), lessThan(subtitleProfiles.indexWhere((entry) => entry['Method'] == 'External')), ); }); test('path-encodes reserved ids for browse and watch-state endpoints', () async { final captured = []; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { captured.add(request.url); return jsonResponse({'Items': []}); }), ); addTearDown(scoped.close); final item = testMediaItem( id: 'folder/item #1?x', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ); try { await scoped.fetchChildren('folder/show #1?x'); } catch (_) { // This URL-only test does not initialize JellyfinApiCache; fetchChildren // may fail after the request when it tries to cache the mock response. } await scoped.fetchClientSideEpisodeQueue('folder/show #1?x'); await scoped.markWatched(item); await scoped.markUnwatched(item); await scoped.rate(item, 7); await scoped.rate(item, -1); final paths = captured.map((u) => u.path).toList(); expect(paths, contains('/Shows/folder%2Fshow%20%231%3Fx/Seasons')); expect(paths, contains('/Shows/folder%2Fshow%20%231%3Fx/Episodes')); expect(paths, contains('/UserPlayedItems/folder%2Fitem%20%231%3Fx')); expect(paths.where((p) => p == '/UserPlayedItems/folder%2Fitem%20%231%3Fx'), hasLength(2)); expect(paths.where((p) => p == '/UserItems/folder%2Fitem%20%231%3Fx/Rating'), hasLength(2)); }); test('removeFromContinueWatching is unsupported for Jellyfin and does not call the server', () async { var requested = false; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { requested = true; return http.Response('', 500); }), ); addTearDown(scoped.close); final item = testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ); await expectLater(scoped.removeFromContinueWatching(item), throwsA(isA())); expect(requested, isFalse); }); test('getPlaybackInitialization URL-encodes appended api_key', () async { final scoped = JellyfinClient.forTesting( connection: _conn(accessToken: 'tok+with spaces/?&'), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { return jsonResponse({ 'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': [ {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, ], }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { return jsonResponse({ 'MediaSources': [ {'Id': 'src-1', 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1'}, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ), selectedMediaIndex: 0, qualityPreset: TranscodeQualityPreset.p720_2mbps, ), ); expect(result.videoUrl, contains('api_key=tok%2Bwith+spaces%2F%3F%26')); expect(Uri.parse(result.videoUrl!).queryParameters['api_key'], 'tok+with spaces/?&'); }); test('getPlaybackInitialization builds fallback URL for external subtitle without DeliveryUrl', () async { final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { return jsonResponse({ 'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': [ { 'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': [ {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsExternal': true}, ], }, ], }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { return jsonResponse({ 'MediaSources': [ {'Id': 'src-1'}, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ), selectedMediaIndex: 0, ), ); expect(result.externalSubtitles, hasLength(1)); 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'); }); test('live TV stream resolution requires an HLS transcode', () async { final requests = []; String? capturedBody; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { requests.add(request.url); capturedBody = request.body; if (request.url.path == '/Items/channel-1/PlaybackInfo') { return jsonResponse({ 'PlaySessionId': 'live-session-1', 'MediaSources': [ { 'Id': 'source-1', 'Container': 'ts', 'LiveStreamId': 'open-stream-1', 'TranscodingUrl': '/Videos/channel-1/live.m3u8?PlaySessionId=live-session-1', }, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); final resolution = await scoped.liveTv.resolveStreamUrl('channel-1'); expect(requests.single.path, '/Items/channel-1/PlaybackInfo'); expect(requests.single.queryParameters['AutoOpenLiveStream'], 'true'); expect(requests.single.queryParameters['EnableTranscoding'], 'true'); expect(requests.single.queryParameters['EnableDirectPlay'], 'false'); expect(requests.single.queryParameters['EnableDirectStream'], 'false'); expect(requests.single.queryParameters['AllowVideoStreamCopy'], 'true'); expect(requests.single.queryParameters['AllowAudioStreamCopy'], 'true'); final body = jsonDecode(capturedBody!) as Map; expect(body['AutoOpenLiveStream'], isTrue); expect(body['EnableTranscoding'], isTrue); expect(body['EnableDirectPlay'], isFalse); expect(body['EnableDirectStream'], isFalse); expect(resolution, isNotNull); expect(resolution!.playSessionId, 'live-session-1'); expect(resolution.mediaSourceId, 'source-1'); expect(resolution.liveStreamId, 'open-stream-1'); expect(resolution.playMethod, 'Transcode'); final uri = Uri.parse(resolution.url); expect(uri.path, '/Videos/channel-1/live.m3u8'); expect(uri.queryParameters['PlaySessionId'], 'live-session-1'); expect(uri.queryParameters['api_key'], 'tok-abc'); }); test('live TV stream resolution recovers identity from a negotiated HLS URL', () async { final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Items/channel-1/PlaybackInfo') { return jsonResponse({ 'MediaSources': [ { 'Container': 'ts', 'TranscodingUrl': '/Videos/channel-1/live.m3u8?MediaSourceId=source-url&LiveStreamId=live-url&PlaySessionId=play-url', }, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); final resolution = await scoped.liveTv.resolveStreamUrl('channel-1'); expect(resolution, isNotNull); expect(resolution!.playSessionId, 'play-url'); expect(resolution.mediaSourceId, 'source-url'); expect(resolution.liveStreamId, 'live-url'); expect(resolution.playMethod, 'Transcode'); }); test('live TV stream resolution rejects a non-HLS fallback URL', () async { final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Items/channel-1/PlaybackInfo') { return jsonResponse({ 'MediaSources': [ {'DirectStreamUrl': '/Videos/channel-1/stream.ts'}, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); expect(await scoped.liveTv.resolveStreamUrl('channel-1'), isNull); }); test('buildTrickplayTileUrl wires width, sheet index, api_key, and DeviceId', () { 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['DeviceId'], 'dev-xyz'); expect(uri.queryParameters.containsKey('MediaSourceId'), isFalse); }); test('buildTrickplayTileUrl appends MediaSourceId when provided', () { // Multi-source items need the param; without it Jellyfin returns the // primary source's tiles even if the user picked a non-default version. final url = client.buildTrickplayTileUrl('item-99', 320, 0, mediaSourceId: 'src-2'); expect(Uri.parse(url).queryParameters['MediaSourceId'], 'src-2'); }); test('buildTrickplayTileUrl URL-encodes special chars in itemId', () { final url = client.buildTrickplayTileUrl('item with spaces & chars', 160, 1); // Path segments are encoded once; the `+` form for spaces is also // valid per RFC 3986 — Uri.parse normalizes back to the original. expect(url, contains('/Videos/item%20with%20spaces%20%26%20chars/Trickplay/160/1.jpg')); }); test('thumbnailUrl resolves a relative path against baseUrl with api_key', () { final url = client.thumbnailUrl('/Items/item-99/Images/Primary?tag=abc'); final uri = Uri.parse(url); expect(uri.scheme, 'https'); expect(uri.host, 'jf.example.com'); expect(uri.path, '/Items/item-99/Images/Primary'); expect(uri.queryParameters['tag'], 'abc'); expect(uri.queryParameters['api_key'], 'tok-abc'); }); test('thumbnailUrl preserves reverse-proxy subpaths for relative artwork paths', () { final proxied = JellyfinClient.forTesting( connection: _conn(baseUrl: 'https://jf.example.com/jellyfin'), httpClient: MockClient((_) async => http.Response('{}', 200)), ); addTearDown(proxied.close); final url = proxied.thumbnailUrl('/Items/item-99/Images/Primary?tag=abc'); final uri = Uri.parse(url); expect(uri.path, '/jellyfin/Items/item-99/Images/Primary'); expect(uri.queryParameters['tag'], 'abc'); expect(uri.queryParameters['api_key'], 'tok-abc'); }); test('negotiated bare relative TranscodingUrl preserves reverse-proxy subpaths', () async { final scoped = JellyfinClient.forTesting( connection: _conn(baseUrl: 'https://jf.example.com/jellyfin'), httpClient: MockClient((request) async { if (request.url.path == '/jellyfin/Users/user-1/Items/item-1') { return jsonResponse({ 'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': [ {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, ], }); } if (request.url.path == '/jellyfin/Items/item-1/PlaybackInfo') { return jsonResponse({ 'PlaySessionId': 'play-session-direct', 'MediaSources': [ { 'Id': 'src-1', 'TranscodingUrl': 'Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', }, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: testMediaItem( id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ), selectedMediaIndex: 0, qualityPreset: TranscodeQualityPreset.p720_2mbps, ), ); 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'); }); test('selected source never inherits another source nested trickplay', () async { final metadata = testMediaItem( id: 'item-trickplay', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv-1', ); final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-trickplay') { return jsonResponse({ 'Id': 'item-trickplay', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': [ {'Id': 'src-a', 'Container': 'mkv', 'MediaStreams': []}, {'Id': 'src-b', 'Container': 'mp4', 'MediaStreams': []}, ], 'Trickplay': { 'src-a': { '160': { 'Width': 160, 'Height': 90, 'TileWidth': 4, 'TileHeight': 4, 'ThumbnailCount': 16, 'Interval': 10000, }, }, }, }); } if (request.url.path == '/Items/item-trickplay/PlaybackInfo') { return jsonResponse({ 'PlaySessionId': 'play-b', 'MediaSources': [ { 'Id': 'src-b', 'Container': 'mp4', 'DirectStreamUrl': '/Videos/item-trickplay/stream?MediaSourceId=src-b', 'MediaStreams': [], }, ], }); } return http.Response('{}', 404); }), ); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( PlaybackInitializationOptions( metadata: metadata, selectedMediaIndex: 1, selectedMediaSourceId: 'src-b', qualityPreset: TranscodeQualityPreset.original, ), ); expect(result.mediaInfo?.mediaSourceId, 'src-b'); expect(result.mediaInfo?.trickplayByWidth, isNull); expect(result.selectedVersion?.id, 'src-b'); expect(Uri.parse(result.videoUrl!).queryParameters['MediaSourceId'], 'src-b'); expect(await scoped.createScrubPreviewSource(item: metadata, mediaSource: result.mediaInfo!), isNull); }); test('thumbnailUrl honours width/height hints', () { final url = client.thumbnailUrl('/Items/x/Images/Primary', width: 200, height: 300); final uri = Uri.parse(url); expect(uri.queryParameters['maxWidth'], '200'); expect(uri.queryParameters['maxHeight'], '300'); }); test('thumbnailUrl does not prefix already absolute artwork URLs', () { final url = client.thumbnailUrl('https://jf.example.com/Items/x/Images/Primary?tag=abc', width: 200); final uri = Uri.parse(url); expect(uri.scheme, 'https'); expect(uri.host, 'jf.example.com'); expect(uri.path, '/Items/x/Images/Primary'); expect(url, isNot(contains('https://jf.example.comhttps://jf.example.com'))); expect(uri.queryParameters['tag'], 'abc'); expect(uri.queryParameters['maxWidth'], '200'); expect(uri.queryParameters['api_key'], 'tok-abc'); }); test('thumbnailUrl preserves existing auth and size parameters', () { final url = client.thumbnailUrl( 'https://other.example/Items/x/Images/Primary?api_key=existing&maxWidth=100', width: 200, height: 300, ); final uri = Uri.parse(url); expect(uri.host, 'other.example'); expect(uri.queryParameters['api_key'], 'existing'); expect(uri.queryParameters['maxWidth'], '100'); expect(uri.queryParameters['maxHeight'], '300'); }); test('thumbnailUrl returns empty string for null/empty path', () { expect(client.thumbnailUrl(null), ''); expect(client.thumbnailUrl(''), ''); }); test('every request carries the SDK-style MediaBrowser Authorization header', () { // Findroid + the official Jellyfin SDK send this exact header shape. // Some setups (Jellyfin 10.9+ behind reverse proxies) reject requests // that only carry the legacy X-Emby-Token header, returning a 404 from // the proxy/routing layer instead of a 401. We send both. final headers = client.defaultHeadersForTesting; final auth = headers['Authorization']; expect(auth, isNotNull); expect(auth, startsWith('MediaBrowser ')); expect(auth, contains('Client="Plezy"')); expect(auth, contains('Device="Plezy"')); expect(auth, contains('DeviceId="dev-xyz"')); expect(auth, contains(RegExp(r'Version="[^"]+"'))); expect(auth, contains('Token="tok-abc"')); // Belt-and-suspenders: legacy Emby token header is still present for // older servers that prefer it. expect(headers['X-Emby-Token'], 'tok-abc'); expect(headers['Accept'], 'application/json'); }); test('fetchLibraryContent sends a bounded paged Items request', () async { Uri? captured; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { captured = req.url; return jsonResponse({ 'Items': [ { 'Id': 'movie-1', 'Type': 'Movie', 'Name': 'Movie', 'BackdropImageTags': ['backdrop-0', 'backdrop-1', 'backdrop-2'], }, ], 'TotalRecordCount': 123, }); }), ); addTearDown(scoped.close); final page = await scoped.fetchLibraryContent( 'lib-1', const LibraryQuery(kind: MediaKind.movie, offset: 50, limit: 25), ); expect(page.items.single.id, 'movie-1'); expect(page.items.single.backdropPaths!.map((url) => Uri.parse(url).path).toList(), [ '/Items/movie-1/Images/Backdrop/0', '/Items/movie-1/Images/Backdrop/1', '/Items/movie-1/Images/Backdrop/2', ]); expect(page.totalCount, 123); expect(captured, isNotNull); expect(captured!.path, '/Items'); expect(captured!.queryParameters['ParentId'], 'lib-1'); expect(captured!.queryParameters['StartIndex'], '50'); expect(captured!.queryParameters['Limit'], '25'); expect(captured!.queryParameters['EnableTotalRecordCount'], 'true'); expect(captured!.queryParameters['IncludeItemTypes'], 'Movie'); expect(captured!.queryParameters['Fields'], isNot(contains('MediaSources'))); expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo'); expect(captured!.queryParameters['ImageTypeLimit'], '3'); }); test('music browse and detail requests use leaf-appropriate fields', () async { final captured = []; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { captured.add(req.url); return jsonResponse({'Items': const [], 'TotalRecordCount': 0}); }), ); addTearDown(scoped.close); await scoped.fetchLibraryContent('lib-1', const LibraryQuery(kind: MediaKind.album, offset: 0, limit: 20)); await scoped.fetchLibraryContent('lib-1', const LibraryQuery(kind: MediaKind.track, offset: 0, limit: 20)); await scoped.fetchArtistAlbums( testMediaItem(id: 'artist-1', backend: MediaBackend.jellyfin, kind: MediaKind.artist), ); await scoped.fetchAlbumTracks('album-1'); final albumBrowse = captured[0].queryParameters; final trackBrowse = captured[1].queryParameters; final artistAlbums = captured[2].queryParameters; final albumTracks = captured[3].queryParameters; expect(albumBrowse['Fields'], 'PremiereDate,OriginalTitle,SortName'); expect(albumBrowse['EnableUserData'], 'false'); expect(trackBrowse['Fields'], 'UserData,PremiereDate,OriginalTitle,SortName'); expect(albumBrowse['IncludeItemTypes'], 'MusicAlbum'); expect(trackBrowse['IncludeItemTypes'], 'Audio'); expect(trackBrowse.containsKey('EnableUserData'), isFalse); expect(artistAlbums['Fields'], 'PremiereDate,OriginalTitle,SortName'); expect(artistAlbums['EnableUserData'], 'false'); expect(albumTracks['Fields'], 'UserData,PremiereDate,OriginalTitle,SortName'); }); test('fetchLibraryFiltersWithValues adds unwatched boolean filter', () async { Uri? captured; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { captured = req.url; return jsonResponse({ 'Genres': ['Drama', 'Action'], 'OfficialRatings': ['PG-13'], 'Tags': ['Holiday'], 'Years': [2024, 1999], }); }), ); addTearDown(scoped.close); final result = await scoped.fetchLibraryFiltersWithValues('lib-1'); final musicResult = await scoped.fetchLibraryFiltersWithValues('lib-1', libraryKind: MediaKind.artist); expect(captured, isNotNull); expect(captured!.path, '/Items/Filters'); expect(captured!.queryParameters['ParentId'], 'lib-1'); expect(captured!.queryParameters['userId'], 'user-1'); expect(result.filters.map((filter) => filter.filter), [ 'unwatched', 'favorite', 'genre', 'year', 'contentRating', 'tag', ]); expect(result.filters.first.filterType, 'boolean'); expect(result.filters.first.key, 'jellyfin:unwatched'); expect(result.filters.first.title, 'Unwatched'); expect(musicResult.filters.first.title, 'Unplayed'); expect(result.filters[1].filterType, 'boolean'); expect(result.filters[1].key, 'jellyfin:favorite'); expect(result.filters[1].title, 'Favorites'); expect(result.cachedValues.containsKey('unwatched'), isFalse); expect(result.cachedValues.containsKey('favorite'), isFalse); expect(result.cachedValues['genre']!.map((value) => value.key), ['Action', 'Drama']); expect(result.cachedValues['year']!.map((value) => value.key), ['2024', '1999']); }); test('fetchLibraryContent uses sentinel total fallback when server omits total', () async { final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); final limit = int.parse(req.url.queryParameters['Limit'] ?? '25'); return jsonResponse({ 'Items': [ for (var i = start; i < start + limit; i++) {'Id': 'movie-$i', 'Type': 'Movie', 'Name': 'Movie $i'}, ], }); }), ); addTearDown(scoped.close); final page = await scoped.fetchLibraryContent( 'lib-1', const LibraryQuery(kind: MediaKind.movie, offset: 50, limit: 25), ); expect(page.items.length, 25); expect(page.totalCount, 76); }); test('fetchLibraryPagedContent uses library kind only when query kind is absent', () async { final captured = []; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { captured.add(req.url); return jsonResponse({'Items': const [], 'TotalRecordCount': 0}); }), ); addTearDown(scoped.close); await scoped.fetchLibraryPagedContent( 'lib-1', query: const LibraryQuery(offset: 0, limit: 20), libraryKind: MediaKind.show, ); await scoped.fetchLibraryPagedContent( 'lib-1', query: const LibraryQuery(kind: MediaKind.episode, offset: 0, limit: 20), libraryKind: MediaKind.show, ); expect(captured.first.queryParameters['IncludeItemTypes'], 'Series'); expect(captured[1].queryParameters['IncludeItemTypes'], 'Episode'); }); test('fetchLibraryFolders splits folder/media queries and orders folders first', () async { const allChildren = [ {'Id': 'track-z', 'Type': 'Audio', 'Name': 'Z Track', 'IsFolder': false}, {'Id': 'series-a', 'Type': 'Series', 'Name': 'A Show', 'IsFolder': true}, {'Id': 'folder-z', 'Type': 'Folder', 'Name': 'Z Folder', 'IsFolder': true}, {'Id': 'movie-m', 'Type': 'Movie', 'Name': 'Movie', 'IsFolder': false}, ]; final captured = []; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { captured.add(req.url); final foldersOnly = req.url.queryParameters['IncludeItemTypes'] == 'Folder,CollectionFolder'; final items = allChildren.where((c) => (c['Type'] == 'Folder') == foldersOnly).toList(); return jsonResponse({'Items': items, 'TotalRecordCount': items.length}); }), ); addTearDown(scoped.close); final items = await scoped.fetchLibraryFolders('lib-1'); expect(captured, hasLength(2)); final folderQuery = captured.firstWhere((u) => u.queryParameters.containsKey('IncludeItemTypes')); final mediaQuery = captured.firstWhere((u) => u.queryParameters.containsKey('ExcludeItemTypes')); for (final uri in [folderQuery, mediaQuery]) { expect(uri.path, '/Items'); expect(uri.queryParameters['ParentId'], 'lib-1'); expect(uri.queryParameters['Recursive'], 'false'); expect(uri.queryParameters['EnableTotalRecordCount'], 'true'); expect(uri.queryParameters['SortBy'], 'SortName'); expect(uri.queryParameters['SortOrder'], 'Ascending'); // Slim field sets: per-item count fields are expensive server-side // and Overview is never rendered in the tree. expect(uri.queryParameters['Fields'], isNot(contains('MediaSources'))); expect(uri.queryParameters['Fields'], isNot(contains('RecursiveItemCount'))); expect(uri.queryParameters['Fields'], isNot(contains('ChildCount'))); expect(uri.queryParameters['Fields'], isNot(contains('Overview'))); } // User data on folder dtos triggers a per-folder recursive unplayed // count on the server; folder rows render no watch state, so skip it. expect(folderQuery.queryParameters['IncludeItemTypes'], 'Folder,CollectionFolder'); expect(folderQuery.queryParameters['EnableUserData'], 'false'); expect(folderQuery.queryParameters['Fields'], isNot(contains('UserData'))); // Media rows keep user data (watched state, series unwatched badge). expect(mediaQuery.queryParameters['ExcludeItemTypes'], 'Folder,CollectionFolder'); expect(mediaQuery.queryParameters['Fields'], contains('UserData')); expect(items.map((item) => item.id), ['folder-z', 'series-a', 'movie-m', 'track-z']); // Folder rows classify as MediaKind.folder so the tree never reads raw. expect(items.first.kind, MediaKind.folder); expect(items.first.raw?['IsFolder'], isTrue); expect(items[1].kind, MediaKind.show); }); test('fetchFolderChildren pages direct folder contents', () async { final mediaStarts = []; final pages = >[]; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { if (req.url.queryParameters.containsKey('IncludeItemTypes')) { // Folders query — this directory has none. return jsonResponse({'Items': const [], 'TotalRecordCount': 0}); } mediaStarts.add(req.url.queryParameters['StartIndex']); final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); const total = 501; final end = start == 0 ? 500 : total; return jsonResponse({ 'Items': [ for (var i = start; i < end; i++) {'Id': 'child-$i', 'Type': 'Movie', 'Name': 'Child $i', 'IsFolder': false}, ], 'TotalRecordCount': total, }); }), ); addTearDown(scoped.close); final items = await scoped.fetchFolderChildren( testMediaItem(id: 'folder-1', backend: MediaBackend.jellyfin, kind: MediaKind.folder), onPage: pages.add, ); expect(mediaStarts, ['0', '500']); expect(items, hasLength(501)); // onPage surfaces accumulated items after intermediate pages only; the // final page is covered by the returned list. expect(pages, hasLength(1)); expect(pages.single, hasLength(500)); expect(pages.single.first.id, 'child-0'); }); test('fetchFolderChildren pages show/season children through onPage', () async { final starts = []; final pages = >[]; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { if (req.url.path.contains('/Shows/')) { // A season id is not a series — falls through to the ParentId query. return http.Response('Not Found', 404); } starts.add(req.url.queryParameters['StartIndex']); final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); const total = 501; final end = start == 0 ? 500 : total; return jsonResponse({ 'Items': [ for (var i = start; i < end; i++) {'Id': 'ep-$i', 'Type': 'Episode', 'Name': 'Episode $i'}, ], 'TotalRecordCount': total, }); }), ); addTearDown(scoped.close); final items = await scoped.fetchFolderChildren( testMediaItem(id: 'season-1', backend: MediaBackend.jellyfin, kind: MediaKind.season), onPage: pages.add, ); expect(starts, ['0', '500']); expect(items, hasLength(501)); // Large seasons render incrementally in the folder tree too: the // metadata-hierarchy path must not sever the onPage chain. expect(pages, hasLength(1)); expect(pages.single, hasLength(500)); expect(pages.single.first.id, 'ep-0'); }); test('fetchClientSideEpisodeQueue pages past the first 200 episodes', () async { final starts = []; final sortBy = []; final sortOrder = []; final pagedClient = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { starts.add(req.url.queryParameters['StartIndex']); sortBy.add(req.url.queryParameters['SortBy']); sortOrder.add(req.url.queryParameters['SortOrder']); final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); const total = 250; final end = (start + 200).clamp(0, total); final items = [ for (var i = start; i < end; i++) { 'Id': 'ep-$i', 'Type': 'Episode', 'Name': 'Episode $i', 'SeriesId': 'show-1', 'UserData': {'PlayCount': 0}, }, ]; return jsonResponse({'Items': items, 'TotalRecordCount': total}); }), ); addTearDown(pagedClient.close); final result = await pagedClient.fetchClientSideEpisodeQueue('show-1'); expect(result, hasLength(250)); expect(starts, ['0', '200']); expect(sortBy, everyElement('ParentIndexNumber,IndexNumber,SortName')); expect(sortOrder, everyElement('Ascending,Ascending,Ascending')); }); test('fetchPersonMedia queries items by person id', () async { Uri? captured; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { captured = req.url; return jsonResponse({ 'Items': [ {'Id': 'movie-1', 'Type': 'Movie', 'Name': 'Movie'}, ], 'TotalRecordCount': 1, }); }), ); addTearDown(scoped.close); final result = await scoped.fetchPersonMedia('person-1'); expect(result.single.id, 'movie-1'); expect(captured, isNotNull); expect(captured!.path, '/Items'); expect(captured!.queryParameters['userId'], 'user-1'); expect(captured!.queryParameters['PersonIds'], 'person-1'); expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series'); expect(captured!.queryParameters['Recursive'], 'true'); expect(captured!.queryParameters['SortBy'], 'PremiereDate,ProductionYear,SortName'); expect(captured!.queryParameters['SortOrder'], 'Descending,Descending,Ascending'); expect(captured!.queryParameters['CollapseBoxSetItems'], 'false'); expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo'); expect(captured!.queryParameters['ImageTypeLimit'], '3'); }); test('fetchItemWithOnDeck keeps resumable NextUp semantics for show detail lookup', () async { Uri? capturedNextUp; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { if (req.url.path == '/Users/user-1/Items/show-1') { return jsonResponse({'Id': 'show-1', 'Type': 'Series', 'Name': 'Show 1'}); } if (req.url.path == '/Shows/NextUp') { capturedNextUp = req.url; return jsonResponse({'Items': []}); } return http.Response('not found', 404); }), ); addTearDown(scoped.close); await scoped.fetchItemWithOnDeck('show-1'); expect(capturedNextUp, isNotNull); expect(capturedNextUp!.queryParameters['seriesId'], 'show-1'); expect(capturedNextUp!.queryParameters['Limit'], '1'); expect(capturedNextUp!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo'); expect(capturedNextUp!.queryParameters['ImageTypeLimit'], '3'); expect(capturedNextUp!.queryParameters.containsKey('EnableResumable'), isFalse); expect(capturedNextUp!.queryParameters.containsKey('NextUpDateCutoff'), isFalse); }); test('fetchPlaybackExtras loads native Jellyfin media segments', () async { final requests = []; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { requests.add(req.url); if (req.url.path == '/Users/user-1/Items/item-1') { return jsonResponse({'Id': 'item-1', 'Type': 'Episode', 'Name': 'Episode', 'Chapters': []}); } if (req.url.path == '/MediaSegments/item-1') { return jsonResponse({ 'Items': [ {'Type': 'Intro', 'StartTicks': 50000000, 'EndTicks': 450000000}, {'Type': 'Outro', 'StartTicks': 900000000, 'EndTicks': 1000000000}, ], }); } return http.Response('not found', 404); }), ); addTearDown(scoped.close); final extras = await scoped.fetchPlaybackExtras('item-1'); expect(requests.map((uri) => uri.path), contains('/MediaSegments/item-1')); expect(extras.markers.map((m) => m.type), ['intro', 'credits']); expect(extras.markers.first.startTimeOffset, 5000); expect(extras.markers.first.endTimeOffset, 45000); }); test('fetchPlaybackExtras falls back to OP/ED chapters when media segments are unavailable', () async { final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { if (req.url.path == '/Users/user-1/Items/item-1') { return jsonResponse({ 'Id': 'item-1', 'Type': 'Episode', 'Name': 'Episode', 'RunTimeTicks': 1200000000, 'Chapters': [ {'Name': 'OP', 'StartPositionTicks': 100000000}, {'Name': 'Episode', 'StartPositionTicks': 450000000}, {'Name': 'ED', 'StartPositionTicks': 900000000}, ], }); } if (req.url.path == '/MediaSegments/item-1') { return http.Response('not found', 404); } return http.Response('not found', 404); }), ); addTearDown(scoped.close); final extras = await scoped.fetchPlaybackExtras('item-1'); expect(extras.markers.map((m) => m.type), ['intro', 'credits']); expect(extras.markers.first.endTimeOffset, 45000); expect(extras.markers.last.endTimeOffset, 120000); }); test('fetchContinueWatching merges resume with non-resumable Next Up', () async { final requests = []; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { requests.add(req.url); if (req.url.path == '/UserItems/Resume') { return jsonResponse({ 'Items': [ {'Id': 'resume-show-1', 'Type': 'Episode', 'Name': 'Resume Show 1', 'SeriesId': 'show-1'}, {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, ], }); } if (req.url.path == '/Shows/NextUp') { return jsonResponse({ 'Items': [ {'Id': 'next-show-1', 'Type': 'Episode', 'Name': 'Next Show 1', 'SeriesId': 'show-1'}, {'Id': 'next-show-2', 'Type': 'Episode', 'Name': 'Next Show 2', 'SeriesId': 'show-2'}, ], }); } return http.Response('not found', 404); }), ); addTearDown(scoped.close); final items = await scoped.fetchContinueWatching(count: 3); expect(items.map((item) => item.id), ['resume-show-1', 'resume-movie-1', 'next-show-2']); final resume = requests.singleWhere((uri) => uri.path == '/UserItems/Resume'); expect(resume.queryParameters['userId'], 'user-1'); expect(resume.queryParameters['Limit'], '3'); expect(resume.queryParameters['MediaTypes'], 'Video'); expect(resume.queryParameters['Recursive'], 'true'); expect(resume.queryParameters['EnableTotalRecordCount'], 'false'); expect(resume.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo'); expect(resume.queryParameters['ImageTypeLimit'], '3'); 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,Logo'); expect(nextUp.queryParameters['ImageTypeLimit'], '3'); // Bounds the server's unbounded GetNextUpSeriesKeys scan (#1784). expect( DateTime.parse(nextUp.queryParameters['NextUpDateCutoff']!), isNot(null), reason: 'must be a parseable ISO-8601 instant', ); }); test('fetchContinueWatching orders a recently watched series Next Up above an older resume item', () async { final requests = []; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { requests.add(req.url); if (req.url.path == '/UserItems/Resume') { return jsonResponse({ 'Items': [ { 'Id': 'resume-old', 'Type': 'Movie', 'Name': 'Old Movie', 'UserData': {'LastPlayedDate': '2020-01-01T00:00:00.0000000Z'}, }, ], }); } if (req.url.path == '/Shows/NextUp') { return jsonResponse({ 'Items': [ {'Id': 'next-recent', 'Type': 'Episode', 'Name': 'Next Recent', 'SeriesId': 'show-recent'}, ], }); } if (req.url.path == '/Items') { return jsonResponse({ 'Items': [ { 'Id': 'ep-played', 'Type': 'Episode', 'SeriesId': 'show-recent', 'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'}, }, ], }); } return http.Response('not found', 404); }), ); addTearDown(scoped.close); final items = await scoped.fetchContinueWatching(count: 10); // The Next Up episode inherits its series' recent last-played date, so it // sorts above the older resume item (issue #1266). expect(items.map((item) => item.id), ['next-recent', 'resume-old']); final lookup = requests.singleWhere((uri) => uri.path == '/Items'); expect(lookup.queryParameters['userId'], 'user-1'); // Scoped to the one series that needs a date, not a server-wide episode // sort: the unscoped form pegs Jellyfin 12.0-rc3 for seconds (#1699). expect(lookup.queryParameters['ParentId'], 'show-recent'); expect(lookup.queryParameters['IncludeItemTypes'], 'Episode'); expect(lookup.queryParameters['Recursive'], 'true'); expect(lookup.queryParameters['SortBy'], 'DatePlayed'); expect(lookup.queryParameters['SortOrder'], 'Descending'); expect(lookup.queryParameters['Limit'], '1'); expect(lookup.queryParameters['Fields'], 'UserData'); // No Filters=IsPlayed: a series' newest engagement can sit on an episode // with a LastPlayedDate but Played==false (see _attachSeriesLastPlayed). expect(lookup.queryParameters.containsKey('Filters'), isFalse); }); test('fetchContinueWatching issues one last-played lookup per pending series', () async { final requests = []; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { requests.add(req.url); if (req.url.path == '/UserItems/Resume') return jsonResponse({'Items': []}); if (req.url.path == '/Shows/NextUp') { return jsonResponse({ 'Items': [ for (var i = 1; i <= 6; i++) {'Id': 'next-$i', 'Type': 'Episode', 'Name': 'Next $i', 'SeriesId': 'show-$i'}, // Second row for an already-pending series: still one lookup. {'Id': 'next-1b', 'Type': 'Episode', 'Name': 'Next 1b', 'SeriesId': 'show-1'}, ], }); } if (req.url.path == '/Items') { final seriesId = req.url.queryParameters['ParentId']!; return jsonResponse({ 'Items': [ { 'Id': 'played-$seriesId', 'Type': 'Episode', 'SeriesId': seriesId, 'UserData': {'LastPlayedDate': '2026-06-0${seriesId.split('-').last}T00:00:00.0000000Z'}, }, ], }); } return http.Response('not found', 404); }), ); addTearDown(scoped.close); final items = await scoped.fetchContinueWatching(count: 10); final lookups = requests.where((uri) => uri.path == '/Items').toList(); expect( lookups.map((uri) => uri.queryParameters['ParentId']).toList(), unorderedEquals(['show-1', 'show-2', 'show-3', 'show-4', 'show-5', 'show-6']), reason: 'a series pending on two Next Up rows must not be looked up twice', ); // Series 6 played most recently, series 1 least; the shelf follows the // stamped dates rather than Next Up's own row order. expect(items.map((item) => item.id).take(2), ['next-6', 'next-5']); }); test('fetchContinueWatching caps last-played lookups on an uncapped shelf', () async { final requests = []; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { requests.add(req.url); if (req.url.path == '/UserItems/Resume') return jsonResponse({'Items': []}); if (req.url.path == '/Shows/NextUp') { return jsonResponse({ 'Items': [ for (var i = 0; i < 60; i++) {'Id': 'next-$i', 'Type': 'Episode', 'Name': 'Next $i', 'SeriesId': 'show-$i'}, ], }); } if (req.url.path == '/Items') { return jsonResponse({ 'Items': [ { 'Id': 'played-${req.url.queryParameters['ParentId']}', 'Type': 'Episode', 'SeriesId': req.url.queryParameters['ParentId'], 'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'}, }, ], }); } return http.Response('not found', 404); }), ); addTearDown(scoped.close); final items = await scoped.fetchContinueWatching(count: null); final lookups = requests.where((uri) => uri.path == '/Items').toList(); // 60 series on the shelf, but the enrichment stays bounded — an unbounded // fan-out here is what the #1699 fix exists to prevent. expect(lookups, hasLength(24)); // Next Up order decides which series get dated: the newest ones. expect( lookups.map((uri) => uri.queryParameters['ParentId']).toList(), unorderedEquals([for (var i = 0; i < 24; i++) 'show-$i']), ); // Every row is still returned; the undated tail just sorts by addedAt. expect(items, hasLength(60)); }); /// Next Up rows for [seriesCount] distinct series, nothing resumable. Map stalledShelfRoutes(int seriesCount) => { 'Items': [ for (var i = 0; i < seriesCount; i++) {'Id': 'next-$i', 'Type': 'Episode', 'Name': 'Next $i', 'SeriesId': 'show-$i'}, ], }; test('fetchContinueWatching abandons last-played lookups when connects go silent', () { fakeAsync((async) { final requests = []; final silent = Completer(); final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) { requests.add(req.url); if (req.url.path == '/UserItems/Resume') return Future.value(jsonResponse({'Items': []})); if (req.url.path == '/Shows/NextUp') return Future.value(jsonResponse(stalledShelfRoutes(24))); // Accepted, then never answered — how the #1699 server behaved. if (req.url.path == '/Items') return silent.future; return Future.value(http.Response('not found', 404)); }), ); List? items; unawaited(scoped.fetchContinueWatching(count: null).then((result) => items = result)); async.flushMicrotasks(); // Exactly one batch open, exactly _seriesLastPlayedConcurrency wide — a // wider burst is the failure mode this change exists to remove. expect( requests.where((uri) => uri.path == '/Items').length, 4, reason: 'the first batch must be four lookups, not the whole shelf', ); // Six batches on the shared 10s/120s defaults would block for minutes. async.elapse(const Duration(milliseconds: 3900)); async.flushMicrotasks(); expect(items, isNull, reason: 'a second batch runs after the first request timeout'); expect(requests.where((uri) => uri.path == '/Items').length, 8); async.elapse(const Duration(milliseconds: 200)); async.flushMicrotasks(); expect(items, isNotNull, reason: 'the shared budget must end the pass, not the per-request timeout'); expect(items!.map((item) => item.id), [for (var i = 0; i < 24; i++) 'next-$i']); expect( requests.where((uri) => uri.path == '/Items').length, 8, reason: 'no batch may start after the budget expires', ); scoped.close(); }); }); test('fetchContinueWatching honours the last-played budget when a response body stalls', () { fakeAsync((async) { final requests = []; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient.streaming((req, _) async { requests.add(req.url); if (req.url.path == '/UserItems/Resume') { return http.StreamedResponse( http.ByteStream.fromBytes(utf8.encode(jsonEncode({'Items': []}))), 200, headers: {'content-type': 'application/json'}, ); } if (req.url.path == '/Shows/NextUp') { return http.StreamedResponse( http.ByteStream.fromBytes(utf8.encode(jsonEncode(stalledShelfRoutes(24)))), 200, headers: {'content-type': 'application/json'}, ); } // Headers land just inside the per-request budget, then the body // never arrives. `MediaServerHttpClient` times connect and receive // separately, so this lookup would otherwise run for 2.5s + 3s and // two batches would outlast the enrichment's whole ceiling. await Future.delayed(const Duration(milliseconds: 2500)); return http.StreamedResponse( StreamController>().stream, 200, headers: {'content-type': 'application/json'}, ); }), ); List? items; unawaited(scoped.fetchContinueWatching(count: null).then((result) => items = result)); async.elapse(const Duration(milliseconds: 3900)); async.flushMicrotasks(); expect(items, isNull); async.elapse(const Duration(milliseconds: 200)); async.flushMicrotasks(); expect(items, isNotNull, reason: 'a stalled body must not outlast the shared budget'); expect(items!.map((item) => item.id), [for (var i = 0; i < 24; i++) 'next-$i']); expect( requests.where((uri) => uri.path == '/Items').length, 4, reason: 'the first batch is still mid-receive when the budget fires', ); scoped.close(); }); }); test('fetchContinueWatching keeps other series dated when one lookup fails', () async { final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { if (req.url.path == '/UserItems/Resume') { return jsonResponse({ 'Items': [ { 'Id': 'resume-mid', 'Type': 'Movie', 'Name': 'Mid Movie', 'UserData': {'LastPlayedDate': '2026-05-01T00:00:00.0000000Z'}, }, ], }); } if (req.url.path == '/Shows/NextUp') { return jsonResponse({ 'Items': [ {'Id': 'next-ok', 'Type': 'Episode', 'Name': 'Next Ok', 'SeriesId': 'show-ok'}, {'Id': 'next-broken', 'Type': 'Episode', 'Name': 'Next Broken', 'SeriesId': 'show-broken'}, ], }); } if (req.url.path == '/Items') { if (req.url.queryParameters['ParentId'] == 'show-broken') { return http.Response('Internal error', 500); } return jsonResponse({ 'Items': [ { 'Id': 'played-ok', 'Type': 'Episode', 'SeriesId': 'show-ok', 'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'}, }, ], }); } return http.Response('not found', 404); }), ); addTearDown(scoped.close); final items = await scoped.fetchContinueWatching(count: 10); // The dated series still outranks the resume item; the failed one keeps a // null date and falls to the bottom instead of sinking the whole shelf. expect(items.map((item) => item.id), ['next-ok', 'resume-mid', 'next-broken']); }); test('fetchContinueWatching does not let resume items starve Next Up under the limit', () async { final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { if (req.url.path == '/UserItems/Resume') { return jsonResponse({ 'Items': [ { 'Id': 'resume-old-1', 'Type': 'Movie', 'Name': 'Old Movie 1', 'UserData': {'LastPlayedDate': '2021-01-01T00:00:00.0000000Z'}, }, { 'Id': 'resume-old-2', 'Type': 'Movie', 'Name': 'Old Movie 2', 'UserData': {'LastPlayedDate': '2022-01-01T00:00:00.0000000Z'}, }, ], }); } if (req.url.path == '/Shows/NextUp') { return jsonResponse({ 'Items': [ {'Id': 'next-recent', 'Type': 'Episode', 'Name': 'Next Recent', 'SeriesId': 'show-recent'}, ], }); } if (req.url.path == '/Items') { return jsonResponse({ 'Items': [ { 'Id': 'ep-played', 'Type': 'Episode', 'SeriesId': 'show-recent', 'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'}, }, ], }); } return http.Response('not found', 404); }), ); addTearDown(scoped.close); // count equals the number of resume items: the old resume-first merge would // have filled the limit and dropped Next Up entirely. final items = await scoped.fetchContinueWatching(count: 2); expect(items.map((item) => item.id), ['next-recent', 'resume-old-2']); }); test('fetchContinueWatching keeps resume items when Next Up fails', () async { final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { if (req.url.path == '/UserItems/Resume') { return jsonResponse({ 'Items': [ {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, ], }); } if (req.url.path == '/Shows/NextUp') { return http.Response('server error', 500); } return http.Response('not found', 404); }), ); addTearDown(scoped.close); final items = await scoped.fetchContinueWatching(); expect(items.map((item) => item.id), ['resume-movie-1']); }); test('fetchContinueWatching omits Limit when count is null', () async { final requests = []; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { requests.add(req.url); if (req.url.path == '/UserItems/Resume') { return jsonResponse({ 'Items': [ {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, {'Id': 'resume-movie-2', 'Type': 'Movie', 'Name': 'Resume Movie 2'}, ], }); } if (req.url.path == '/Shows/NextUp') { return jsonResponse({ 'Items': [ {'Id': 'next-show-1', 'Type': 'Episode', 'Name': 'Next Show 1', 'SeriesId': 'show-1'}, {'Id': 'next-show-2', 'Type': 'Episode', 'Name': 'Next Show 2', 'SeriesId': 'show-2'}, ], }); } return http.Response('not found', 404); }), ); addTearDown(scoped.close); final items = await scoped.fetchContinueWatching(count: null); expect(items.map((item) => item.id), ['resume-movie-1', 'resume-movie-2', 'next-show-1', 'next-show-2']); final resume = requests.singleWhere((uri) => uri.path == '/UserItems/Resume'); expect(resume.queryParameters.containsKey('Limit'), isFalse); final nextUp = requests.singleWhere((uri) => uri.path == '/Shows/NextUp'); expect(nextUp.queryParameters.containsKey('Limit'), isFalse); }); }); group('JellyfinClient.fetchGlobalHubs URL builders', () { late List captured; JellyfinClient buildClient() { captured = []; final mock = MockClient((req) async { captured.add(req.url); return jsonResponse({'Items': []}); }); return JellyfinClient.forTesting(connection: _conn(), httpClient: mock); } Uri capturedNextUpRequest() => captured.singleWhere((uri) => uri.path == '/Shows/NextUp'); test('global preview defaults to shared limit and marks filled previews as more', () async { captured = []; final mock = MockClient((req) async { captured.add(req.url); return jsonResponse({ 'Items': [ for (var i = 0; i < defaultHubPreviewLimit; i++) {'Id': 'movie-$i', 'Type': 'Movie', 'Name': 'Movie $i'}, ], }); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); final hubs = await client.fetchGlobalHubs(includePlaybackHubs: false); expect(captured.single.queryParameters['Limit'], defaultHubPreviewLimit.toString()); expect(hubs.single.items, hasLength(defaultHubPreviewLimit)); expect(hubs.single.more, isTrue); }); test('global Next Up excludes resumable episodes and bounds the server scan with a date cutoff', () async { final client = buildClient(); addTearDown(client.close); await client.fetchGlobalHubs(limit: 12); final resume = captured.singleWhere((uri) => uri.path == '/UserItems/Resume'); expect(resume.queryParameters['EnableTotalRecordCount'], 'false'); final nextUp = capturedNextUpRequest(); expect(nextUp.queryParameters['userId'], 'user-1'); expect(nextUp.queryParameters['Limit'], '12'); expect(nextUp.queryParameters['EnableResumable'], 'false'); expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false'); expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo'); expect(nextUp.queryParameters['ImageTypeLimit'], '3'); expect(nextUp.queryParameters['NextUpDateCutoff'], isNotNull, reason: 'bounds the server-side scan (#1784)'); }); test('can skip global playback hubs', () async { final client = buildClient(); addTearDown(client.close); await client.fetchGlobalHubs(limit: 12, includePlaybackHubs: false); expect(captured.map((uri) => uri.path), ['/Users/user-1/Items/Latest']); expect(captured.single.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode'); expect(captured.single.queryParameters['Limit'], '12'); }); }); group('JellyfinClient.fetchLibraryHubs URL builders', () { late List captured; JellyfinClient buildClient() { captured = []; final mock = MockClient((req) async { captured.add(req.url); return jsonResponse({'Items': []}); }); return JellyfinClient.forTesting(connection: _conn(), httpClient: mock); } test('show library Next Up excludes resumable episodes and bounds the server scan with a date cutoff', () async { final client = buildClient(); addTearDown(client.close); await client.fetchLibraryHubs('lib-99', libraryName: 'Shows', limit: 12, libraryKind: MediaKind.show); final nextUp = captured.singleWhere((uri) => uri.path == '/Shows/NextUp'); expect(nextUp.queryParameters['ParentId'], 'lib-99'); expect(nextUp.queryParameters['userId'], 'user-1'); expect(nextUp.queryParameters['Limit'], '12'); expect(nextUp.queryParameters['EnableResumable'], 'false'); expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false'); expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo'); expect(nextUp.queryParameters['ImageTypeLimit'], '3'); expect(nextUp.queryParameters['NextUpDateCutoff'], isNotNull, reason: 'bounds the server-side scan (#1784)'); }); test('movie library skips Next Up and disables resume total count', () async { final client = buildClient(); addTearDown(client.close); await client.fetchLibraryHubs('lib-99', libraryName: 'Movies', limit: 12, libraryKind: MediaKind.movie); expect(captured.where((uri) => uri.path == '/Shows/NextUp'), isEmpty); final resume = captured.singleWhere((uri) => uri.path == '/UserItems/Resume'); expect(resume.queryParameters['ParentId'], 'lib-99'); expect(resume.queryParameters['Limit'], '12'); expect(resume.queryParameters['EnableTotalRecordCount'], 'false'); }); test('can skip library playback hubs', () async { final client = buildClient(); addTearDown(client.close); await client.fetchLibraryHubs('lib-99', libraryName: 'Movies', limit: 12, includePlaybackHubs: false); expect(captured.map((uri) => uri.path), ['/Users/user-1/Items/Latest']); expect(captured.single.queryParameters['ParentId'], 'lib-99'); expect(captured.single.queryParameters['Limit'], '12'); }); }); group('JellyfinClient.fetchMoreHubItems URL builders', () { Uri? captured; JellyfinClient buildClient() { captured = null; final mock = MockClient((req) async { captured = req.url; return http.Response('[]', 200, headers: {'content-type': 'application/json'}); }); return JellyfinClient.forTesting(connection: _conn(), httpClient: mock); } test('global "home.recent" uses the pageable Items catalogue with the provided limit', () async { final client = buildClient(); await client.fetchMoreHubItems('home.recent', limit: 80); expect(captured, isNotNull); expect(captured!.path, '/Items'); expect(captured!.queryParameters['userId'], 'user-1'); expect(captured!.queryParameters['StartIndex'], '0'); expect(captured!.queryParameters['Limit'], '80'); expect(captured!.queryParameters['Recursive'], 'true'); expect(captured!.queryParameters['EnableTotalRecordCount'], 'true'); expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode,Video,MusicVideo,Photo'); expect(captured!.queryParameters['SortBy'], 'DateCreated,SortName,ProductionYear'); expect(captured!.queryParameters['SortOrder'], 'Descending,Descending,Descending'); expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo'); expect(captured!.queryParameters['ImageTypeLimit'], '3'); expect(captured!.queryParameters.containsKey('ParentId'), isFalse); client.close(); }); test('global "home.continue" hits /UserItems/Resume with userId', () async { final client = buildClient(); await client.fetchMoreHubItems('home.continue'); expect(captured, isNotNull); expect(captured!.path, '/UserItems/Resume'); expect(captured!.queryParameters['userId'], 'user-1'); expect(captured!.queryParameters['Limit'], '50'); expect(captured!.queryParameters['StartIndex'], '0'); expect(captured!.queryParameters['MediaTypes'], 'Video'); expect(captured!.queryParameters['Recursive'], 'true'); expect(captured!.queryParameters['EnableTotalRecordCount'], 'true'); expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo'); expect(captured!.queryParameters['ImageTypeLimit'], '3'); expect(captured!.queryParameters.containsKey('ParentId'), isFalse); client.close(); }); test('global "home.nextup" hits /Shows/NextUp with userId', () async { final client = buildClient(); await client.fetchMoreHubItems('home.nextup', limit: 25); expect(captured, isNotNull); expect(captured!.path, '/Shows/NextUp'); expect(captured!.queryParameters['userId'], 'user-1'); expect(captured!.queryParameters['Limit'], '25'); expect(captured!.queryParameters['StartIndex'], '0'); expect(captured!.queryParameters.containsKey('ParentId'), isFalse); expect(captured!.queryParameters['EnableResumable'], 'false'); expect(captured!.queryParameters['EnableTotalRecordCount'], 'true'); expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo'); expect(captured!.queryParameters['ImageTypeLimit'], '3'); expect(captured!.queryParameters['NextUpDateCutoff'], isNotNull, reason: 'bounds the server-side scan (#1784)'); client.close(); }); test('library-scoped "library.{id}.recent" pages Items within its parent', () async { final client = buildClient(); await client.fetchMoreHubItems('library.lib-99.recent', limit: 30); expect(captured, isNotNull); expect(captured!.path, '/Items'); expect(captured!.queryParameters['userId'], 'user-1'); expect(captured!.queryParameters['ParentId'], 'lib-99'); expect(captured!.queryParameters['StartIndex'], '0'); expect(captured!.queryParameters['Limit'], '30'); expect(captured!.queryParameters['Recursive'], 'true'); expect(captured!.queryParameters['EnableTotalRecordCount'], 'true'); expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode,Video,MusicVideo,Photo'); expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo'); expect(captured!.queryParameters['ImageTypeLimit'], '3'); client.close(); }); test('library-scoped "library.{id}.continue" forwards ParentId to Resume', () async { final client = buildClient(); await client.fetchMoreHubItems('library.lib-99.continue'); expect(captured, isNotNull); expect(captured!.path, '/UserItems/Resume'); expect(captured!.queryParameters['ParentId'], 'lib-99'); expect(captured!.queryParameters['userId'], 'user-1'); expect(captured!.queryParameters['StartIndex'], '0'); expect(captured!.queryParameters['Recursive'], 'true'); expect(captured!.queryParameters['EnableTotalRecordCount'], 'true'); expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo'); expect(captured!.queryParameters['ImageTypeLimit'], '3'); client.close(); }); test('library-scoped "library.{id}.nextup" forwards ParentId to NextUp', () async { final client = buildClient(); await client.fetchMoreHubItems('library.lib-99.nextup'); expect(captured, isNotNull); expect(captured!.path, '/Shows/NextUp'); expect(captured!.queryParameters['ParentId'], 'lib-99'); expect(captured!.queryParameters['userId'], 'user-1'); expect(captured!.queryParameters['StartIndex'], '0'); expect(captured!.queryParameters['EnableResumable'], 'false'); expect(captured!.queryParameters['EnableTotalRecordCount'], 'true'); expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo'); expect(captured!.queryParameters['ImageTypeLimit'], '3'); expect(captured!.queryParameters['NextUpDateCutoff'], isNotNull, reason: 'bounds the server-side scan (#1784)'); client.close(); }); test('library-scoped "library.{id}.latestalbums" hits Latest with slim music album fields', () async { final client = buildClient(); await client.fetchMoreHubItems('library.lib-99.latestalbums', limit: 30); expect(captured, isNotNull); expect(captured!.path, '/Users/user-1/Items/Latest'); expect(captured!.queryParameters['ParentId'], 'lib-99'); expect(captured!.queryParameters['Limit'], '30'); // Album FOLDER dtos: count/user-data fields would each cost the server // a recursive per-album COUNT query (#1552). expect(captured!.queryParameters['Fields'], 'PremiereDate,OriginalTitle,SortName'); expect(captured!.queryParameters['EnableUserData'], 'false'); client.close(); }); test('library-scoped "library.{id}.recentlyplayed" queries played audio with slim track fields', () async { final client = buildClient(); await client.fetchMoreHubItems('library.lib-99.recentlyplayed'); expect(captured, isNotNull); expect(captured!.path, '/Items'); expect(captured!.queryParameters['ParentId'], 'lib-99'); expect(captured!.queryParameters['userId'], 'user-1'); expect(captured!.queryParameters['IncludeItemTypes'], 'Audio'); expect(captured!.queryParameters['Recursive'], 'true'); expect(captured!.queryParameters['Filters'], 'IsPlayed'); expect(captured!.queryParameters['SortBy'], 'DatePlayed'); expect(captured!.queryParameters['Fields'], 'UserData,PremiereDate,OriginalTitle,SortName'); client.close(); }); test('unknown identifier returns empty without hitting the network', () async { final client = buildClient(); final items = await client.fetchMoreHubItems('totally.unknown'); expect(items, isEmpty); expect(captured, isNull); client.close(); }); test('paged Resume hub sends requested offset and parses total count', () async { Uri? requestUri; final client = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { requestUri = req.url; return jsonResponse({ 'TotalRecordCount': 30, 'Items': [ {'Id': 'resume-20', 'Name': 'Resume', 'Type': 'Movie'}, ], }); }), ); addTearDown(client.close); final page = await client.fetchMoreHubItemsPage('home.continue', start: 20, size: 10); expect(page.items.single.id, 'resume-20'); expect(page.totalCount, 30); expect(page.offset, 20); expect(requestUri, isNotNull); expect(requestUri!.path, '/UserItems/Resume'); expect(requestUri!.queryParameters['StartIndex'], '20'); expect(requestUri!.queryParameters['Limit'], '10'); expect(requestUri!.queryParameters['EnableTotalRecordCount'], 'true'); }); test('paged Recently Added sends the requested offset and parses total count', () async { Uri? requestUri; final client = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { requestUri = req.url; return jsonResponse({ 'TotalRecordCount': 321, 'Items': [ {'Id': 'recent-20', 'Name': 'Recent', 'Type': 'Movie'}, ], }); }), ); addTearDown(client.close); final page = await client.fetchMoreHubItemsPage('home.recent', start: 20, size: 10); expect(page.items.single.id, 'recent-20'); expect(page.totalCount, 321); expect(page.offset, 20); expect(requestUri, isNotNull); expect(requestUri!.path, '/Items'); expect(requestUri!.queryParameters['StartIndex'], '20'); expect(requestUri!.queryParameters['Limit'], '10'); expect(requestUri!.queryParameters['EnableTotalRecordCount'], 'true'); expect(requestUri!.queryParameters.containsKey('ParentId'), isFalse); }); test('paged hub first-page errors throw while list helper keeps empty fallback', () async { var requestCount = 0; final client = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { requestCount++; return http.Response('server error', 500); }), ); addTearDown(client.close); await expectLater(client.fetchMoreHubItemsPage('home.continue', start: 0, size: 10), throwsA(isA())); final items = await client.fetchMoreHubItems('home.continue'); expect(items, isEmpty); expect(requestCount, 2); }); test('paged hub later-page errors throw instead of truncating', () async { var requestCount = 0; final client = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((req) async { requestCount++; return http.Response('server error', 500); }), ); addTearDown(client.close); await expectLater(client.fetchMoreHubItemsPage('home.continue', start: 20, size: 10), throwsA(isA())); expect(requestCount, 1); }); }); group('JellyfinClient.fetchCollections', () { test('uses boxsets view instead of selected media library parent', () async { final requests = []; final mock = MockClient((req) async { requests.add(req.url); if (req.url.path == '/Users/user-1/Views') { return jsonResponse({ 'Items': [ {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies'}, {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, ], }); } if (req.url.path == '/Items') { return jsonResponse({ 'TotalRecordCount': 1, 'Items': [ {'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'}, ], }); } return http.Response('not found', 404); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); final collections = await client.fetchCollections('lib-movies'); expect(collections.map((c) => c.id).toList(), ['collection-1']); expect(collections.single.kind, MediaKind.collection); expect(requests.map((u) => u.path).toList(), ['/Users/user-1/Views', '/Items']); final itemsRequest = requests.singleWhere((u) => u.path == '/Items'); expect(itemsRequest.queryParameters['ParentId'], 'lib-boxsets'); expect(itemsRequest.queryParameters['ParentId'], isNot('lib-movies')); expect(itemsRequest.queryParameters['IncludeItemTypes'], 'BoxSet'); expect(itemsRequest.queryParameters['Recursive'], 'true'); expect(itemsRequest.queryParameters['StartIndex'], '0'); expect(itemsRequest.queryParameters['Limit'], '36'); expect(itemsRequest.queryParameters['SortBy'], 'SortName'); expect(itemsRequest.queryParameters['SortOrder'], 'Ascending'); expect(itemsRequest.queryParameters['Fields'], 'RecursiveItemCount,ChildCount,OriginalTitle,SortName,Overview'); expect(itemsRequest.queryParameters.containsKey('EnableTotalRecordCount'), isFalse); expect(itemsRequest.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo'); expect(itemsRequest.queryParameters['ImageTypeLimit'], '3'); }); test('fetchCollectionsPage uses requested collection page bounds', () async { Uri? itemsRequest; final mock = MockClient((req) async { if (req.url.path == '/Users/user-1/Views') { return jsonResponse({ 'Items': [ {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, ], }); } if (req.url.path == '/Items') { itemsRequest = req.url; return jsonResponse({ 'TotalRecordCount': 30, 'Items': [ {'Id': 'collection-20', 'Name': 'Collection 20', 'Type': 'BoxSet'}, ], }); } return http.Response('not found', 404); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); final page = await client.fetchCollectionsPage('lib-movies', start: 20, size: 10); expect(page.totalCount, 30); expect(page.offset, 20); expect(page.items.single.id, 'collection-20'); expect(itemsRequest, isNotNull); expect(itemsRequest!.queryParameters['ParentId'], 'lib-boxsets'); expect(itemsRequest!.queryParameters['StartIndex'], '20'); expect(itemsRequest!.queryParameters['Limit'], '10'); expect(itemsRequest!.queryParameters.containsKey('EnableTotalRecordCount'), isFalse); }); test('fetchCollectionsPage uses sentinel total when total count is missing', () async { final mock = MockClient((req) async { if (req.url.path == '/Users/user-1/Views') { return jsonResponse({ 'Items': [ {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, ], }); } if (req.url.path == '/Items') { return jsonResponse({ 'Items': [ {'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'}, {'Id': 'collection-2', 'Name': 'Collection 2', 'Type': 'BoxSet'}, ], }); } return http.Response('not found', 404); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); final page = await client.fetchCollectionsPage('lib-movies', size: 2); expect(page.items.map((c) => c.id).toList(), ['collection-1', 'collection-2']); expect(page.totalCount, 3); }); test('walks boxsets view in pages', () async { final itemRequests = []; final mock = MockClient((req) async { if (req.url.path == '/Users/user-1/Views') { return jsonResponse({ 'Items': [ {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, ], }); } if (req.url.path == '/Items') { itemRequests.add(req.url); final start = req.url.queryParameters['StartIndex']; return jsonResponse({ 'TotalRecordCount': 2, 'Items': [ {'Id': start == '0' ? 'collection-1' : 'collection-2', 'Name': 'Collection', 'Type': 'BoxSet'}, ], }); } return http.Response('not found', 404); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); final collections = await client.fetchCollections('lib-movies'); expect(collections.map((c) => c.id).toList(), ['collection-1', 'collection-2']); expect(itemRequests.map((u) => u.queryParameters['StartIndex']).toList(), ['0', '1']); expect(itemRequests.every((u) => u.queryParameters['Limit'] == '36'), isTrue); }); test('returns empty when boxsets view is missing', () async { var itemsRequested = false; final mock = MockClient((req) async { if (req.url.path == '/Users/user-1/Views') { return jsonResponse({ 'Items': [ {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies'}, ], }); } if (req.url.path == '/Items') { itemsRequested = true; return jsonResponse({'Items': []}); } return http.Response('not found', 404); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); final collections = await client.fetchCollections('lib-movies'); expect(collections, isEmpty); expect(itemsRequested, isFalse); }); test('fetchCollectionPage uses Jellyfin item paging', () async { Uri? itemsRequest; final mock = MockClient((req) async { if (req.url.path == '/Items') { itemsRequest = req.url; return jsonResponse({ 'TotalRecordCount': 25, 'Items': [ {'Id': 'movie-1', 'Name': 'Movie 1', 'Type': 'Movie'}, ], }); } return http.Response('not found', 404); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); final page = await client.fetchCollectionPage('collection-1', start: 20, size: 5); expect(page.totalCount, 25); expect(page.offset, 20); expect(page.items.single.id, 'movie-1'); expect(itemsRequest, isNotNull); expect(itemsRequest!.queryParameters['ParentId'], 'collection-1'); expect(itemsRequest!.queryParameters['StartIndex'], '20'); expect(itemsRequest!.queryParameters['Limit'], '5'); expect(itemsRequest!.queryParameters.containsKey('Recursive'), isFalse); }); }); group('JellyfinClient.fetchLibraries view filtering', () { test('drops boxsets and playlists views — they surface as per-library tabs instead', () async { // Jellyfin's `/Users/{userId}/Views` returns the user's collection // (BoxSet) and playlist roots as top-level "library" views. Surfacing // them in the library list duplicates content that's already exposed as // tabs on each real library, matching the Plex shape. final mock = MockClient((req) async { if (req.url.path == '/Users/user-1/Views') { return http.Response( ''' { "Items": [ {"Id": "lib-movies", "Name": "Movies", "CollectionType": "movies", "Type": "CollectionFolder"}, {"Id": "lib-shows", "Name": "TV Shows", "CollectionType": "tvshows", "Type": "CollectionFolder"}, {"Id": "lib-music", "Name": "Music", "CollectionType": "music", "Type": "CollectionFolder"}, {"Id": "lib-coll", "Name": "Collections", "CollectionType": "boxsets", "Type": "CollectionFolder"}, {"Id": "lib-pl", "Name": "Playlists", "CollectionType": "playlists", "Type": "ManualPlaylistsFolder"} ] } ''', 200, headers: {'content-type': 'application/json'}, ); } return http.Response('not found', 404); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); final libraries = await client.fetchLibraries(); expect(libraries.map((l) => l.id), ['lib-movies', 'lib-shows', 'lib-music']); client.close(); }); }); group('JellyfinClient paged media lists', () { test('fetchPersonMediaPage uses requested page bounds', () async { final routed = _routedClient({ '/Items': { 'Items': [ {'Id': 'movie-1', 'Name': 'Movie', 'Type': 'Movie'}, ], 'TotalRecordCount': 40, }, }); final page = await routed.client.fetchPersonMediaPage('person-1', start: 20, size: 10); expect(page.items.single.id, 'movie-1'); expect(page.totalCount, 40); expect(page.offset, 20); final query = routed.requests['/Items']!.queryParameters; expect(query['PersonIds'], 'person-1'); expect(query['StartIndex'], '20'); expect(query['Limit'], '10'); }); test('fetchPlayableDescendantsPage uses requested page bounds', () async { final routed = _routedClient({ '/Items': { 'Items': [ {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, ], 'TotalRecordCount': 40, }, }); final page = await routed.client.fetchPlayableDescendantsPage('show-1', start: 20, size: 10); expect(page.items.single.id, 'episode-1'); expect(page.totalCount, 40); expect(page.offset, 20); final query = routed.requests['/Items']!.queryParameters; expect(query['ParentId'], 'show-1'); expect(query['Recursive'], 'true'); // Audio rides along so albums/artists/audio playlists expand to tracks. expect(query['IncludeItemTypes'], 'Movie,Episode,Audio'); expect(query['StartIndex'], '20'); expect(query['Limit'], '10'); }); test('fetchSeasonEpisodesPage uses Jellyfin episode endpoint scoped to season', () async { final routed = _routedClient({ '/Shows/show-1/Episodes': { 'Items': [ {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, ], 'TotalRecordCount': 40, }, }); final page = await routed.client.fetchSeasonEpisodesPage('show-1', 'season-1', start: 20, size: 10); expect(page.items.single.id, 'episode-1'); expect(page.totalCount, 40); expect(page.offset, 20); final query = routed.requests['/Shows/show-1/Episodes']!.queryParameters; expect(query['SeasonId'], 'season-1'); expect(query['StartIndex'], '20'); expect(query['Limit'], '10'); expect(query['EnableTotalRecordCount'], 'true'); expect(query['IsMissing'], 'false'); expect(query['IsVirtualUnaired'], 'false'); expect(query['Fields']!.split(','), contains('MediaSources')); expect(query.containsKey('SortBy'), isFalse); expect(query.containsKey('SortOrder'), isFalse); }); test('fetchChildrenPage orders direct episode children by season and episode index', () async { final routed = _routedClient({ '/Shows/season-1/Seasons': {'Items': []}, '/Items': { 'Items': [ {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, ], 'TotalRecordCount': 40, }, }); final page = await routed.client.fetchChildrenPage('season-1', start: 20, size: 10); expect(page.items.single.id, 'episode-1'); expect(page.totalCount, 40); expect(page.offset, 20); final query = routed.requests['/Items']!.queryParameters; expect(query['ParentId'], 'season-1'); expect(query['StartIndex'], '20'); expect(query['Limit'], '10'); expect(query['SortBy'], 'ParentIndexNumber,IndexNumber,SortName'); expect(query['SortOrder'], 'Ascending,Ascending,Ascending'); }); test('fetchPlayableFolderDescendants includes generic video but excludes audio', () async { final routed = _routedClient({ '/Items': { 'Items': [ {'Id': 'video-1', 'Name': 'Home Video', 'Type': 'Video'}, ], 'TotalRecordCount': 1, }, }); final items = await routed.client.fetchPlayableFolderDescendants('folder-1'); expect(items.single.kind, MediaKind.clip); final query = routed.requests['/Items']!.queryParameters; expect(query['ParentId'], 'folder-1'); expect(query['Recursive'], 'true'); expect(query['IncludeItemTypes'], 'Movie,Episode,Video,MusicVideo'); expect(query['IncludeItemTypes'], isNot(contains('Audio'))); }); test('fetchPlayableDescendants cancellation stops before a second page', () async { final abort = AbortController(); final starts = []; final client = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { starts.add(request.url.queryParameters['StartIndex']); abort.abort(); return jsonResponse({ 'Items': List.generate(500, (i) => {'Id': 'movie-$i', 'Name': 'Movie $i', 'Type': 'Movie'}), 'TotalRecordCount': 501, }); }), ); addTearDown(client.close); await expectLater( client.fetchPlayableDescendants('collection-1', abort: abort), throwsA(isA().having((e) => e.isCancellation, 'isCancellation', isTrue)), ); expect(starts, ['0']); }); test('fetchPlayableFolderDescendants cancellation stops before a second page', () async { final abort = AbortController(); final starts = []; final client = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { starts.add(request.url.queryParameters['StartIndex']); abort.abort(); return jsonResponse({ 'Items': List.generate(500, (i) => {'Id': 'video-$i', 'Name': 'Video $i', 'Type': 'Video'}), 'TotalRecordCount': 501, }); }), ); addTearDown(client.close); await expectLater( client.fetchPlayableFolderDescendants('folder-1', abort: abort), throwsA(isA().having((e) => e.isCancellation, 'isCancellation', isTrue)), ); expect(starts, ['0']); }); test('fetchClientSideEpisodeQueue cancellation stops before a second page and sort', () async { final abort = AbortController(); final starts = []; final client = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { starts.add(request.url.queryParameters['StartIndex']); abort.abort(); return jsonResponse({ 'Items': List.generate( 200, (i) => {'Id': 'episode-$i', 'Name': 'Episode $i', 'Type': 'Episode', 'IndexNumber': i + 1}, ), 'TotalRecordCount': 201, }); }), ); addTearDown(client.close); await expectLater( client.fetchClientSideEpisodeQueue('show-1', abort: abort), throwsA(isA().having((e) => e.isCancellation, 'isCancellation', isTrue)), ); expect(starts, ['0']); }); test('fetchChildren walks generic children pages', () async { final itemRequests = []; final mock = MockClient((req) async { if (req.url.path == '/Shows/season-1/Seasons') { return jsonResponse({'Items': []}); } if (req.url.path == '/Items') { itemRequests.add(req.url); final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); final count = start == 0 ? 500 : 1; return jsonResponse({ 'Items': List.generate(count, (i) => {'Id': 'episode-${start + i}', 'Name': 'Episode', 'Type': 'Episode'}), 'TotalRecordCount': 501, }); } return http.Response('not found', 404); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); final items = await client.fetchChildren('season-1'); expect(items.length, 501); expect(itemRequests.map((u) => u.queryParameters['StartIndex']), ['0', '500']); expect(itemRequests.every((u) => u.queryParameters['Limit'] == '500'), isTrue); }); }); group('JellyfinClient.fetchPlaylists filtering', () { JellyfinClient buildClient() { final mock = MockClient((req) async { if (req.url.path == '/Items') { final requestedMediaType = req.url.queryParameters['MediaTypes']?.toLowerCase(); final items = [ {'Id': 'video-1', 'Name': 'Video Playlist', 'Type': 'Playlist', 'MediaType': 'Video'}, {'Id': 'audio-1', 'Name': 'Audio Playlist', 'Type': 'Playlist', 'MediaType': 'Audio'}, {'Id': 'photo-1', 'Name': 'Photo Playlist', 'Type': 'Playlist', 'MediaType': 'Photo'}, ].where((item) { if (requestedMediaType == null) return true; return (item['MediaType'] as String).toLowerCase() == requestedMediaType; }).toList(); return jsonResponse({'Items': items, 'TotalRecordCount': items.length}); } return http.Response('not found', 404); }); return JellyfinClient.forTesting(connection: _conn(), httpClient: mock); } test('returns only requested playlist media type', () async { final client = buildClient(); final playlists = await client.fetchPlaylists(playlistType: 'video'); expect(playlists.map((p) => p.id), ['video-1']); client.close(); }); test('fetchPlaylistsPage sends direct filtered offset and returns filtered total', () async { final requests = []; final allItems = List.generate( 100, (i) => { 'Id': '${i.isEven ? 'video' : 'audio'}-$i', 'Name': 'Playlist $i', 'Type': 'Playlist', 'MediaType': i.isEven ? 'Video' : 'Audio', }, ); final mock = MockClient((req) async { if (req.url.path != '/Items') return http.Response('not found', 404); requests.add(req.url); final mediaType = req.url.queryParameters['MediaTypes']; final filtered = allItems.where((item) => item['MediaType'] == mediaType).toList(); final start = int.parse(req.url.queryParameters['StartIndex']!); final limit = int.parse(req.url.queryParameters['Limit']!); return jsonResponse({ 'Items': sliceFakePage(filtered, start: start, size: limit), 'TotalRecordCount': filtered.length, }); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); final page = await client.fetchPlaylistsPage(playlistType: 'video', start: 20, size: 10); expect(page.items.map((item) => item.id), List.generate(10, (i) => 'video-${40 + i * 2}')); expect(page.totalCount, 50); expect(page.offset, 20); expect(requests, hasLength(1)); expect(requests.single.queryParameters['StartIndex'], '20'); expect(requests.single.queryParameters['Limit'], '10'); expect(requests.single.queryParameters['MediaTypes'], 'Video'); expect(requests.single.queryParameters['IncludeItemTypes'], 'Playlist'); }); test('large sparse filtered pages perform one server-filtered request each', () async { final requests = []; final allItems = List.generate( 600, (i) => { 'Id': '${i.isEven ? 'video' : 'audio'}-$i', 'Name': 'Playlist $i', 'Type': 'Playlist', 'MediaType': i.isEven ? 'Video' : 'Audio', }, ); final mock = MockClient((req) async { if (req.url.path != '/Items') return http.Response('not found', 404); requests.add(req.url); final mediaType = req.url.queryParameters['MediaTypes']; final filtered = allItems.where((item) => item['MediaType'] == mediaType).toList(); final start = int.parse(req.url.queryParameters['StartIndex']!); final limit = int.parse(req.url.queryParameters['Limit']!); return jsonResponse({ 'Items': sliceFakePage(filtered, start: start, size: limit), 'TotalRecordCount': filtered.length, }); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); final pages = >[]; for (final start in [0, 100, 200]) { pages.add(await client.fetchPlaylistsPage(playlistType: 'video', start: start, size: 100)); } expect(pages.expand((page) => page.items).map((item) => item.id), List.generate(300, (i) => 'video-${i * 2}')); expect(pages.map((page) => page.totalCount), everyElement(300)); expect(requests, hasLength(3)); expect(requests.map((uri) => uri.queryParameters['StartIndex']), ['0', '100', '200']); expect(requests.every((uri) => uri.queryParameters['Limit'] == '100'), isTrue); expect(requests.every((uri) => uri.queryParameters['MediaTypes'] == 'Video'), isTrue); }); test('fetchPlaylists complete helper walks direct filtered pages linearly', () async { final requests = []; final videos = List.generate( 300, (i) => {'Id': 'video-$i', 'Name': 'Playlist $i', 'Type': 'Playlist', 'MediaType': 'Video'}, ); final mock = MockClient((req) async { if (req.url.path != '/Items') return http.Response('not found', 404); requests.add(req.url); final start = int.parse(req.url.queryParameters['StartIndex']!); final limit = int.parse(req.url.queryParameters['Limit']!); return jsonResponse({ 'Items': sliceFakePage(videos, start: start, size: limit), 'TotalRecordCount': videos.length, }); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); final playlists = await client.fetchPlaylists(playlistType: 'video'); expect(playlists.map((item) => item.id), List.generate(300, (i) => 'video-$i')); expect(requests, hasLength(2)); expect(requests.map((uri) => uri.queryParameters['StartIndex']), ['0', '200']); expect(requests.every((uri) => uri.queryParameters['Limit'] == '200'), isTrue); expect(requests.every((uri) => uri.queryParameters['MediaTypes'] == 'Video'), isTrue); }); test('unsupported playlist type returns empty without a request', () async { var requestCount = 0; final client = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((_) async { requestCount++; return http.Response('unexpected', 500); }), ); addTearDown(client.close); final page = await client.fetchPlaylistsPage(playlistType: 'unsupported', start: 20, size: 10); expect(page.items, isEmpty); expect(page.totalCount, 0); expect(page.offset, 20); expect(requestCount, 0); }); test('fetchPlaylistPage uses requested item page bounds', () async { Uri? requestUri; final mock = MockClient((req) async { if (req.url.path == '/Playlists/pl-1/Items') { requestUri = req.url; return jsonResponse({ 'Items': [ {'Id': 'movie-1', 'Name': 'Movie', 'Type': 'Movie'}, ], 'TotalRecordCount': 40, }); } return http.Response('not found', 404); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); final page = await client.fetchPlaylistPage('pl-1', start: 20, size: 10); expect(page.items.single.id, 'movie-1'); expect(page.totalCount, 40); expect(page.offset, 20); expect(requestUri, isNotNull); expect(requestUri!.queryParameters['StartIndex'], '20'); expect(requestUri!.queryParameters['Limit'], '10'); }); test('fetchPlaylistPage uses minimal fallback total when total count is missing', () async { final mock = MockClient((req) async { if (req.url.path == '/Playlists/pl-1/Items') { return jsonResponse({ 'Items': List.generate(10, (i) => {'Id': 'movie-$i', 'Name': 'Movie', 'Type': 'Movie'}), }); } return http.Response('not found', 404); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); final page = await client.fetchPlaylistPage('pl-1', start: 20, size: 10); expect(page.items.length, 10); expect(page.totalCount, 31); expect(page.offset, 20); }); test('absolutizes playlist thumbnail artwork with reverse-proxy subpath', () async { final mock = MockClient((req) async { if (req.url.path == '/jellyfin/Items') { return jsonResponse({ 'Items': [ { 'Id': 'video-1', 'Name': 'Video Playlist', 'Type': 'Playlist', 'MediaType': 'Video', 'ImageTags': {'Primary': 'tag 1'}, }, ], }); } return http.Response('not found', 404); }); final client = JellyfinClient.forTesting( connection: _conn(baseUrl: 'https://jf.example.com/jellyfin'), httpClient: mock, ); addTearDown(client.close); final playlists = await client.fetchPlaylists(playlistType: 'video'); final uri = Uri.parse(playlists.single.thumbPath!); expect(uri.path, '/jellyfin/Items/video-1/Images/Primary'); expect(uri.queryParameters['tag'], 'tag 1'); expect(uri.queryParameters['api_key'], 'tok-abc'); }); test('fetchEditableMetadataItem requests full item dto without limited fields', () async { Uri? capturedUri; final client = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { capturedUri = request.url; return jsonResponse({ 'Id': 'folder/item #1?x', 'Name': 'Movie', 'Type': 'Movie', 'ProviderIds': {'Tmdb': '1'}, }); }), ); addTearDown(client.close); final item = await client.fetchEditableMetadataItem('folder/item #1?x'); expect(item?['ProviderIds'], {'Tmdb': '1'}); expect(capturedUri!.path, '/Users/user-1/Items/folder%2Fitem%20%231%3Fx'); expect(capturedUri!.queryParameters.containsKey('Fields'), isFalse); }); test('updateMetadataItem posts full dto to item update endpoint', () async { Uri? capturedUri; String? capturedBody; final client = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { capturedUri = request.url; capturedBody = request.body; return http.Response('', 204); }), ); addTearDown(client.close); final success = await client.updateMetadataItem('item-1', { 'Id': 'item-1', 'Name': 'Edited', 'Type': 'Movie', 'ProviderIds': {'Tmdb': '123'}, 'Tags': ['Favorite'], }); expect(success, isTrue); expect(capturedUri!.path, '/Items/item-1'); final body = jsonDecode(capturedBody!) as Map; expect(body['Name'], 'Edited'); expect(body['ProviderIds'], {'Tmdb': '123'}); expect(body['Tags'], ['Favorite']); }); test('remote image search and apply use Jellyfin image endpoints', () async { final requests = []; final client = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Items/item-1/RemoteImages') { return jsonResponse({ 'TotalRecordCount': 1, 'Providers': ['TheMovieDb'], 'Images': [ {'ProviderName': 'TheMovieDb', 'Url': 'https://img.example/poster.jpg', 'Type': 'Primary'}, ], }); } return http.Response('', 204); }), ); addTearDown(client.close); final result = await client.getRemoteImages( 'item-1', imageType: 'Primary', limit: 20, providerName: 'TheMovieDb', ); final success = await client.downloadRemoteImage( 'item-1', imageType: 'Primary', imageUrl: 'https://img.example/poster.jpg', ); expect((result['Images'] as List).single['Url'], 'https://img.example/poster.jpg'); expect(success, isTrue); expect(requests[0].path, '/Items/item-1/RemoteImages'); expect(requests[0].queryParameters['type'], 'Primary'); expect(requests[0].queryParameters['limit'], '20'); expect(requests[0].queryParameters['providerName'], 'TheMovieDb'); expect(requests[1].path, '/Items/item-1/RemoteImages/Download'); expect(requests[1].queryParameters['type'], 'Primary'); expect(requests[1].queryParameters['imageUrl'], 'https://img.example/poster.jpg'); }); test('uploadItemImage sends binary image body and image content type', () async { Uri? capturedUri; List? capturedBody; Map? capturedHeaders; final client = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { capturedUri = request.url; capturedBody = request.bodyBytes; capturedHeaders = request.headers; return http.Response('', 204); }), ); addTearDown(client.close); final success = await client.uploadItemImage( 'item-1', imageType: 'Primary', bytes: [0xff, 0xd8, 0xff, 0x00], contentType: 'image/jpeg', ); expect(success, isTrue); expect(capturedUri!.path, '/Items/item-1/Images/Primary'); expect(capturedBody, [0xff, 0xd8, 0xff, 0x00]); expect(capturedHeaders!['Content-Type'] ?? capturedHeaders!['content-type'], 'image/jpeg'); }); test('smart=true returns empty without network I/O', () async { var requestCount = 0; final client = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((_) async { requestCount++; return http.Response('unexpected', 500); }), ); addTearDown(client.close); final playlists = await client.fetchPlaylists(playlistType: 'video', smart: true); expect(playlists, isEmpty); expect(requestCount, 0); }); }); }