Files
plezy/test/services/jellyfin_client_urls_test.dart
T
edde746 a56b9a3dfb Merge the deduplication and dead-code removal pass
Consolidates duplicated logic behind shared implementations — paginated
grid tabs, focus chrome, cached remote stores, sheet selection columns,
the server artifact store and a test fixture layer — and removes code
that had become unreachable. Net reduction of about 5,500 lines with no
behaviour change.

Where a fix had landed separately in code that moved into a shared
helper, the fix was re-applied inside the helper rather than left behind
in the copy that went away.
2026-07-26 19:41:23 +02:00

3982 lines
156 KiB
Dart

import 'dart:convert';
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/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<http.Response> Function(http.Request request) playbackInfo, {
List<Map<String, dynamic>>? 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<String, Uri> requests}) _routedClient(Map<String, Object> routes) {
final requests = <String, Uri>{};
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('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 = <Uri>[];
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,Thumb,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 = <Uri>[];
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': <Object>[], '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': <Object>[], '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<String, dynamic>;
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<String, dynamic> body})>[];
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((request) async {
requests.add((path: request.url.path, body: jsonDecode(request.body) as Map<String, dynamic>));
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 = <Uri>[];
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<String, dynamic>;
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<String, dynamic>)['MediaSourceId'], 'src-2');
});
test('resolveDownload keeps the static stream after non-authentication enrichment failures', () async {
final cases = <(String, Future<http.Response> 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<MediaServerHttpException>().having((error) => error.statusCode, 'statusCode', 401)),
);
});
test('getPlaybackInitialization uses static playback after non-authentication negotiation failures', () async {
final cases = <(String, Future<http.Response> 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<PlaybackException>().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 = <Uri>[];
final playbackInfoBodies = <String>[];
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<String, dynamic>;
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 uses negotiated DirectStreamUrl when transcode URL is absent', () 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': []},
],
});
}
if (request.url.path == '/Items/item-1/PlaybackInfo') {
return jsonResponse({
'PlaySessionId': 'play-session-direct',
'MediaSources': [
{
'Id': 'src-1',
'DirectStreamUrl': '/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,
),
);
expect(result.isTranscoding, isFalse);
expect(result.playMethod, 'DirectStream');
expect(result.fallbackReason, isNull);
expect(result.playSessionId, 'play-session-direct');
final uri = Uri.parse(result.videoUrl!);
expect(uri.path, '/Videos/item-1/stream');
expect(uri.queryParameters['PlaySessionId'], 'play-session-direct');
expect(uri.queryParameters['api_key'], 'tok-abc');
});
test('getPlaybackInitialization prefers DirectStreamUrl over TranscodingUrl for original playback', () async {
final requests = <Uri>[];
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,
'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct',
'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<String, dynamic>;
expect(body.containsKey('MaxStreamingBitrate'), isFalse);
expect(body.containsKey('StartTimeTicks'), isFalse);
final profile = body['DeviceProfile'] as Map<String, dynamic>;
expect(profile.containsKey('MaxStreamingBitrate'), isFalse);
expect(result.isTranscoding, isFalse);
expect(result.playMethod, 'DirectStream');
expect(result.playSessionId, 'play-session-direct');
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['PlaySessionId'], 'play-session-direct');
expect(uri.queryParameters['PlaySessionId'], isNot('play-session-transcode'));
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,
'DirectStreamUrl': '/Videos/item-1/stream?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<PlaybackInitializationResult> initialize(SubtitleTrack preference) {
return scoped.getPlaybackInitialization(
PlaybackInitializationOptions(
metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
),
selectedMediaIndex: 0,
preferredSubtitleTrack: preference,
),
);
}
void expectRequestedSubtitleIndex(int? expected) {
expect(playbackInfoUri!.queryParameters['SubtitleStreamIndex'], expected?.toString());
final playbackInfoJson = jsonDecode(playbackInfoBody!) as Map<String, dynamic>;
expect(playbackInfoJson['SubtitleStreamIndex'], expected);
}
final result = await initialize(
const SubtitleTrack(id: 'source:4', title: 'French - SRT', language: 'fra', codec: 'srt'),
);
expectRequestedSubtitleIndex(4);
await initialize(const SubtitleTrack(id: 'navigation', title: 'English - SRT', language: 'eng', codec: 'srt'));
expectRequestedSubtitleIndex(3);
await initialize(const SubtitleTrack(id: 'source:3', title: 'French - SRT', language: 'fra', codec: 'srt'));
expectRequestedSubtitleIndex(4);
await initialize(
const SubtitleTrack(
id: 'source:3',
title: 'English Forced - SRT',
language: 'eng',
codec: 'srt',
isForced: true,
),
);
expectRequestedSubtitleIndex(5);
await initialize(SubtitleTrack.off);
expectRequestedSubtitleIndex(-1);
await initialize(const SubtitleTrack(id: 'navigation', title: 'Japanese - SRT', language: 'jpn', codec: 'srt'));
expectRequestedSubtitleIndex(null);
expect(result.playMethod, 'DirectStream');
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 = <Uri>[];
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('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<String, dynamic>;
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<String, dynamic>;
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<String, dynamic>;
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<String, dynamic>;
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<http.Response> 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<Object>()), reason: name);
}
});
test('video download keeps the static stream for unusable negotiation sources', () async {
final cases =
<({Map<String, dynamic> response, List<Map<String, dynamic>>? itemSources, String? expectedSourceId})>[
(
response: {'MediaSources': <Object?>[]},
itemSources: [
{'Container': 'mkv', 'MediaStreams': <Object?>[]},
],
expectedSourceId: null,
),
(
response: {
'MediaSources': ['invalid'],
},
itemSources: [
{'Container': 'mkv', 'MediaStreams': <Object?>[]},
],
expectedSourceId: null,
),
(
response: {
'MediaSources': [
{'Id': 'other', 'MediaStreams': <Object?>[]},
],
},
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 external subtitle support', () 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<String, dynamic>;
final profile = body['DeviceProfile'] as Map<String, dynamic>;
expect(profile['MaxStreamingBitrate'], 5000000);
expect(profile.containsKey('MaxStaticBitrate'), isFalse);
expect(profile.containsKey('MusicStreamingTranscodingBitrate'), isFalse);
expect(profile['DirectPlayProfiles'], isNotEmpty);
final directPlayProfile = (profile['DirectPlayProfiles'] as List<dynamic>).first as Map<String, dynamic>;
expect(directPlayProfile['VideoCodec'], contains('mpeg2video'));
expect(directPlayProfile['AudioCodec'], contains('mp2'));
expect(profile['TranscodingProfiles'], isNotEmpty);
expect(profile['CodecProfiles'], isEmpty);
final subtitleProfiles = profile['SubtitleProfiles'] as List<dynamic>;
expect(
subtitleProfiles.map((profile) => (profile as Map<String, dynamic>)['Format']),
containsAll(['srt', 'ass', 'ssa', 'vtt', 'pgssub', 'dvdsub', 'dvbsub']),
);
expect(subtitleProfiles.every((profile) => (profile as Map<String, dynamic>)['Method'] == 'External'), isTrue);
});
test('path-encodes reserved ids for browse and watch-state endpoints', () async {
final captured = <Uri>[];
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((request) async {
captured.add(request.url);
return jsonResponse({'Items': <Object>[]});
}),
);
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<UnsupportedError>()));
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 = <Uri>[];
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<String, dynamic>;
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 DirectStreamUrl 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',
'DirectStreamUrl': '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,Thumb,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
});
test('music browse and detail requests use leaf-appropriate fields', () async {
final captured = <Uri>[];
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 = <Uri>[];
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 = <Uri>[];
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 = <String?>[];
final pages = <List<MediaItem>>[];
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 = <String?>[];
final pages = <List<MediaItem>>[];
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 = <String?>[];
final sortBy = <String?>[];
final sortOrder = <String?>[];
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,Thumb,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,Thumb,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 = <Uri>[];
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 = <Uri>[];
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,Thumb,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,Thumb,Logo');
expect(nextUp.queryParameters['ImageTypeLimit'], '3');
expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
});
test('fetchContinueWatching orders a recently watched series Next Up above an older resume item', () async {
final requests = <Uri>[];
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');
expect(lookup.queryParameters['IncludeItemTypes'], 'Episode');
expect(lookup.queryParameters['Recursive'], 'true');
expect(lookup.queryParameters['SortBy'], 'DatePlayed');
expect(lookup.queryParameters['SortOrder'], 'Descending');
expect(lookup.queryParameters['Limit'], '200');
// 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 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 = <Uri>[];
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<Uri> 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 without 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,Thumb,Logo');
expect(nextUp.queryParameters['ImageTypeLimit'], '3');
expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
});
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<Uri> 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 without 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,Thumb,Logo');
expect(nextUp.queryParameters['ImageTypeLimit'], '3');
expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
});
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,Thumb,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,Thumb,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,Thumb,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
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,Thumb,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,Thumb,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,Thumb,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
expect(captured!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
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<Exception>()));
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<Exception>()));
expect(requestCount, 1);
});
});
group('JellyfinClient.fetchCollections', () {
test('uses boxsets view instead of selected media library parent', () async {
final requests = <Uri>[];
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,UserData,PremiereDate,OriginalTitle,SortName,Overview',
);
expect(itemsRequest.queryParameters.containsKey('EnableTotalRecordCount'), isFalse);
expect(itemsRequest.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,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 = <Uri>[];
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': <Object>[]},
'/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 = <String?>[];
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<MediaServerHttpException>().having((e) => e.isCancellation, 'isCancellation', isTrue)),
);
expect(starts, ['0']);
});
test('fetchPlayableFolderDescendants cancellation stops before a second page', () async {
final abort = AbortController();
final starts = <String?>[];
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<MediaServerHttpException>().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 = <String?>[];
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<MediaServerHttpException>().having((e) => e.isCancellation, 'isCancellation', isTrue)),
);
expect(starts, ['0']);
});
test('fetchChildren walks generic children pages', () async {
final itemRequests = <Uri>[];
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 = <Uri>[];
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 = <Uri>[];
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 = <LibraryPage<MediaPlaylist>>[];
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 = <Uri>[];
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<String, dynamic>;
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 = <Uri>[];
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<int>? capturedBody;
Map<String, String>? 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);
});
});
}