Files
plezy/test/services/jellyfin_client_urls_test.dart
T
edde746 4872adcde3 feat(player): keep the session's explicit track choices across episodes
Episode advance carried live player state, so the viewer's choice only
survived while every episode could serve it: one episode without the
picked audio or subtitle fell back, and the fallback became the carry for
the rest of the session. The screen now keeps the last explicit audio,
subtitle, and secondary-subtitle choices for its lifetime; automatic
outcomes never overwrite them, so the choice retries on every following
episode and reattaches as soon as a catalog can serve it again.

Audio catches up with the subtitle carry from #1785. The old matcher
required raw language equality (a 'sv' pick never found a 'swe' row) and
otherwise took the first same-language track, flipping a commentary or
alternate-mix pick back to the main mix on every episode. Audio now uses
the same evidence bands as subtitles: bridged language parity is
authoritative, a unique title match vouches for untagged tracks, codec
and channel-count parity only break ties, and an ambiguous catalog
declines to the server's own choice instead of guessing. The synthesized
source descriptor also prefers the row's own title over the display title
that collapses to the bare language.

Episode advance previously sent no audio hint to negotiation at all, so a
transcode baked in the server's default audio no matter what was playing.
Both backends now resolve the carried semantics against the new episode's
streams: Jellyfin sends the resolved AudioStreamIndex, Plex feeds the
transcode decision, an explicit per-part stream id always wins, and a
failed match falls back to the server's pick.

close #1785
2026-08-04 16:10:02 +02:00

4535 lines
178 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/media/library_query.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_playlist.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/models/transcode_quality_preset.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/playback_initialization_types.dart';
import 'package:plezy/services/subtitle_preference.dart';
import 'package:plezy/utils/device_identity.dart';
import 'package:plezy/utils/media_server_http_client.dart';
import '../test_helpers/backend_client_fixtures.dart';
import '../test_helpers/http_fixtures.dart';
import '../test_helpers/paged_fakes.dart';
import '../test_helpers/media_items.dart';
JellyfinConnection _conn({String accessToken = 'tok-abc', String baseUrl = 'https://jf.example.com'}) =>
testJellyfinConnection(
baseUrl: baseUrl,
userName: 'edde',
accessToken: accessToken,
deviceId: 'dev-xyz',
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
);
JellyfinClient _clientWithPlaybackInfo(
Future<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));
}),
);
}
Future<({PlaybackInitializationResult result, Uri playbackInfoUri, Map<String, dynamic> playbackInfoBody})>
_initializeJellyfinAudioCarry({int? selectedAudioStreamId, AudioTrack? preferredAudioTrack}) async {
late Uri playbackInfoUri;
late String playbackInfoBody;
final client = _clientWithPlaybackInfo(
(request) async {
playbackInfoUri = request.url;
playbackInfoBody = request.body;
return jsonResponse({
'MediaSources': [
{'Id': 'src-1'},
],
});
},
itemSources: [
{
'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', 'Title': 'Main'},
],
},
],
);
try {
final result = await client.getPlaybackInitialization(
PlaybackInitializationOptions(
metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.episode,
serverId: 'srv-1',
),
selectedMediaIndex: 0,
selectedAudioStreamId: selectedAudioStreamId,
preferredAudioTrack: preferredAudioTrack,
),
);
return (
result: result,
playbackInfoUri: playbackInfoUri,
playbackInfoBody: jsonDecode(playbackInfoBody) as Map<String, dynamic>,
);
} finally {
client.close();
}
}
/// 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('concurrent fetchLibraries calls share one /Views request', () async {
// At cold start LibrariesProvider.loadLibraries() and
// DataAggregationService.getHubsFromAllServers ask for libraries at the
// same time, and the hub fan-out waits serially behind its copy. Two
// identical round trips was a full RTT of dead cold-start latency (#1784).
var views = 0;
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) async {
if (req.url.path == '/Users/user-1/Views') views++;
return http.Response(
jsonEncode({
'Items': [
{'Id': 'lib-1', 'Name': 'Movies', 'CollectionType': 'movies'},
],
}),
200,
headers: {'content-type': 'application/json'},
);
}),
);
addTearDown(scoped.close);
final results = await Future.wait([scoped.fetchLibraries(), scoped.fetchLibraries()]);
expect(views, 1);
expect(results.map((libs) => libs.single.id), ['lib-1', 'lib-1']);
// Single-flight, not a cache: a later pass still sees server-side changes.
await scoped.fetchLibraries();
expect(views, 2);
});
test('concurrent fetchItem calls for one id share a single request', () async {
// Opening a detail screen fires `_loadFullMetadata` and, via
// `_initWatchlistState`, `fetchExternalIds` — both a full-detail GET for
// the same id at the same time. Each makes the server rebuild the whole
// dto (People, Chapters and MediaSources are a DB query apiece).
var detailFetches = 0;
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) async {
if (req.url.path == '/Users/user-1/Items/item-1') detailFetches++;
return http.Response(
jsonEncode({'Id': 'item-1', 'Name': 'Item', 'Type': 'Movie'}),
200,
headers: {'content-type': 'application/json'},
);
}),
);
addTearDown(scoped.close);
final results = await Future.wait([scoped.fetchItem('item-1'), scoped.fetchItem('item-1')]);
expect(detailFetches, 1);
expect(results.map((item) => item?.id), ['item-1', 'item-1']);
// Different ids never share, and a later pass re-fetches — single-flight,
// not a cache, so nothing here can serve a stale item.
await scoped.fetchItem('item-2');
await scoped.fetchItem('item-1');
expect(detailFetches, 2);
});
test('buildDirectStreamUrl includes static flag, api_key, and device id', () {
final url = client.buildDirectStreamUrl('item-99');
final uri = Uri.parse(url);
expect(uri.scheme, 'https');
expect(uri.host, 'jf.example.com');
expect(uri.path, '/Videos/item-99/stream');
expect(uri.queryParameters['Static'], 'true');
expect(uri.queryParameters['api_key'], 'tok-abc');
expect(uri.queryParameters['DeviceId'], 'dev-xyz');
expect(uri.queryParameters.containsKey('Container'), isFalse);
});
test('buildDirectStreamUrl appends Container when provided', () {
final url = client.buildDirectStreamUrl('item-99', container: 'mp4');
expect(Uri.parse(url).queryParameters['Container'], 'mp4');
});
test('buildDirectStreamUrl appends MediaSourceId when provided', () {
// Items with multiple `MediaSources` need this param to disambiguate;
// without it Jellyfin defaults to the primary source even if the URL's
// {itemId} matches a non-primary.
final url = client.buildDirectStreamUrl('item-99', mediaSourceId: 'src-2');
expect(Uri.parse(url).queryParameters['MediaSourceId'], 'src-2');
});
test('buildDirectStreamUrl appends AudioStreamIndex when provided', () {
final url = client.buildDirectStreamUrl('item-99', audioStreamIndex: 4);
expect(Uri.parse(url).queryParameters['AudioStreamIndex'], '4');
});
test('buildDirectStreamUrl omits MediaSourceId by default', () {
final url = client.buildDirectStreamUrl('item-99');
expect(Uri.parse(url).queryParameters.containsKey('MediaSourceId'), isFalse);
});
test('buildDirectStreamUrl path-encodes reserved item id characters', () {
final url = client.buildDirectStreamUrl('folder/item #1?x');
expect(Uri.parse(url).path, '/Videos/folder%2Fitem%20%231%3Fx/stream');
});
test('buildAudioDirectStreamUrl targets /Audio with the same static-stream contract', () {
final url = client.buildAudioDirectStreamUrl('track-7');
final uri = Uri.parse(url);
expect(uri.path, '/Audio/track-7/stream');
expect(uri.queryParameters['Static'], 'true');
expect(uri.queryParameters['api_key'], 'tok-abc');
expect(uri.queryParameters['DeviceId'], 'dev-xyz');
expect(uri.queryParameters.containsKey('Container'), isFalse);
expect(uri.queryParameters.containsKey('MediaSourceId'), isFalse);
});
test('buildAudioDirectStreamUrl appends Container and MediaSourceId when provided', () {
final url = client.buildAudioDirectStreamUrl('track-7', container: 'flac', mediaSourceId: 'src-9');
final uri = Uri.parse(url);
expect(uri.queryParameters['Container'], 'flac');
expect(uri.queryParameters['MediaSourceId'], 'src-9');
});
test('buildDirectStreamUrl canonicalizes a mixed-case scheme from stored config', () async {
// This URL bypasses Dart's Uri normalization on its way to the player,
// and FFmpeg's protocol lookup is case-sensitive — a stored
// "Https://..." base URL fails with "Protocol not found" (#1465).
final mixedCase = await JellyfinClient.create(_conn(baseUrl: 'Https://jf.example.com/'));
addTearDown(mixedCase.close);
final url = mixedCase.buildDirectStreamUrl('item-99');
expect(url, startsWith('https://jf.example.com/Videos/'));
});
test('fetchSortOptions exposes the broad Jellyfin sort set', () async {
final sorts = await client.fetchSortOptions('lib-1');
expect(sorts.map((sort) => sort.key).toList(), [
'title',
'rating',
'criticRating',
'addedAt',
'lastViewedAt',
'viewCount',
'productionYear',
'runtime',
'officialRating',
'originallyAvailableAt',
'startDate',
'airTime',
'studio',
'random',
]);
});
test('fetchSortOptions adds episode added sort only for shows', () async {
final showSorts = await client.fetchSortOptions('lib-1', libraryType: 'show');
expect(showSorts.map((sort) => sort.key).toList(), [
'title',
'rating',
'criticRating',
'addedAt',
'episode.addedAt',
'lastViewedAt',
'viewCount',
'productionYear',
'runtime',
'officialRating',
'originallyAvailableAt',
'startDate',
'airTime',
'studio',
'random',
]);
final movieSorts = await client.fetchSortOptions('lib-1', libraryType: 'movie');
expect(movieSorts.map((sort) => sort.key), isNot(contains('episode.addedAt')));
});
test('fetchExtras combines local trailers and special features as playable videos', () async {
const itemId = 'movie/id #1?x';
final encodedItemId = Uri.encodeComponent(itemId);
final requests = <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,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 strips sidecar identity from direct-played embedded subtitles', () async {
// Jellyfin answers `Method: External` subtitle profiles with an
// `External` delivery method plus a DeliveryUrl even for streams that
// stay inside a direct-played container. Direct play never fetches those
// URLs, so the rows must not keep an identity that makes track matching
// wait for a sidecar (issue #1696).
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((request) async {
if (request.url.path == '/Users/user-1/Items/item-1') {
return jsonResponse({
'Id': 'item-1',
'Type': 'Movie',
'Name': 'Movie',
'MediaSources': [
{'Id': 'src-1', 'Container': 'mkv', 'MediaStreams': []},
],
});
}
if (request.url.path == '/Items/item-1/PlaybackInfo') {
return jsonResponse({
'MediaSources': [
{
'Id': 'src-1',
'Container': 'mkv',
'SupportsDirectPlay': true,
'DefaultSubtitleStreamIndex': 3,
'MediaStreams': [
{'Index': 1, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn', 'IsDefault': true},
{
'Index': 3,
'Type': 'Subtitle',
'Codec': 'ass',
'Language': 'eng',
'DisplayTitle': 'English Forced - ASS',
'IsDefault': true,
'IsForced': true,
'IsExternal': false,
'DeliveryMethod': 'External',
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/0/Stream.ass',
},
{
'Index': 4,
'Type': 'Subtitle',
'Codec': 'ass',
'Language': 'eng',
'DisplayTitle': 'English - ASS',
'IsExternal': false,
'DeliveryMethod': 'External',
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/4/0/Stream.ass',
},
{
'Index': 5,
'Type': 'Subtitle',
'Codec': 'srt',
'Language': 'swe',
'DisplayTitle': 'Swedish - SRT',
'IsExternal': true,
'DeliveryMethod': 'External',
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/5/0/Stream.srt',
},
],
},
],
});
}
return http.Response('{}', 404);
}),
);
addTearDown(scoped.close);
final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions(
metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
),
selectedMediaIndex: 0,
),
);
expect(result.isTranscoding, isFalse);
expect(result.playMethod, 'DirectPlay');
final tracks = result.mediaInfo!.subtitleTracks;
expect(tracks.map((track) => track.id), [3, 4, 5]);
// Embedded rows lose the delivery hint they cannot honour.
for (final track in tracks.where((track) => track.id != 5)) {
expect(track.key, isNull, reason: 'embedded row ${track.id} kept a delivery URL');
expect(track.usesExternalDelivery, isFalse);
expect(track.isExternal, isFalse);
}
// A genuine separate file is absent from the container either way, and
// direct play does load it, so it keeps its sidecar identity.
final sidecarRow = tracks.singleWhere((track) => track.id == 5);
expect(sidecarRow.key, '/Videos/item-1/src-1/Subtitles/5/0/Stream.srt');
expect(sidecarRow.isExternalFile, isTrue);
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [5]);
// The server default survives normalization so selection can honour it.
expect(result.mediaInfo!.defaultSubtitleStreamIndex, 3);
expect(tracks.singleWhere((track) => track.id == 3).selected, isTrue);
});
test('getPlaybackInitialization keeps original playback on the static stream with no bitrate cap', () async {
final requests = <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,
// Jellyfin never returns a direct-play URL, only this one.
'TranscodingUrl':
'/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode',
'MediaStreams': [
{'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'},
{
'Index': 3,
'Type': 'Subtitle',
'Codec': 'srt',
'Language': 'eng',
'DisplayTitle': 'English - SRT',
'IsExternal': true,
'DeliveryMethod': 'External',
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt',
},
],
},
],
});
}
return http.Response('{}', 404);
}),
);
addTearDown(scoped.close);
final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions(
metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
),
selectedMediaIndex: 0,
),
);
final playbackInfoRequest = requests.firstWhere((uri) => uri.path == '/Items/item-1/PlaybackInfo');
expect(playbackInfoRequest.queryParameters.containsKey('MaxStreamingBitrate'), isFalse);
expect(playbackInfoRequest.queryParameters.containsKey('StartTimeTicks'), isFalse);
expect(playbackInfoRequest.queryParameters['MediaSourceId'], 'src-1');
final body = jsonDecode(playbackInfoBody!) as Map<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, 'DirectPlay');
expect(result.playSessionId, isNull);
expect(result.activeAudioStreamId, isNull);
expect(result.mediaInfo!.audioTracks.single.selected, isTrue);
final uri = Uri.parse(result.videoUrl!);
expect(uri.path, '/Videos/item-1/stream');
expect(uri.queryParameters['MediaSourceId'], 'src-1');
expect(uri.queryParameters.containsKey('PlaySessionId'), isFalse);
expect(uri.queryParameters['api_key'], 'tok-abc');
expect(result.mediaInfo!.subtitleTracks, hasLength(1));
expect(result.externalSubtitles, hasLength(1));
expect(result.subtitleSidecars.single.sourceStreamId, 3);
expect(result.externalSubtitles.single.title, 'English');
final subtitleUri = Uri.parse(result.externalSubtitles.single.uri!);
expect(subtitleUri.path, '/Videos/item-1/src-1/Subtitles/3/Stream.srt');
expect(subtitleUri.queryParameters['api_key'], 'tok-abc');
});
test('getPlaybackInitialization maps semantic subtitle preferences to current source rows', () async {
Uri? playbackInfoUri;
String? playbackInfoBody;
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((request) async {
if (request.url.path == '/Users/user-1/Items/item-1') {
return jsonResponse({
'Id': 'item-1',
'Type': 'Movie',
'Name': 'Movie',
'MediaSources': [
{
'Id': 'src-1',
'Container': 'mkv',
'MediaStreams': [
{'Index': 0, 'Type': 'Video'},
{'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng'},
{'Index': 4, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'fra'},
{'Index': 5, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsForced': true},
],
},
],
});
}
if (request.url.path == '/Items/item-1/PlaybackInfo') {
playbackInfoUri = request.url;
playbackInfoBody = request.body;
return jsonResponse({
'PlaySessionId': 'play-session-direct',
'MediaSources': [
{
'Id': 'src-1',
'Container': 'mkv',
'DefaultSubtitleStreamIndex': 4,
'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-direct',
'MediaStreams': [
{'Index': 0, 'Type': 'Video'},
{
'Index': 3,
'Type': 'Subtitle',
'Codec': 'srt',
'Language': 'eng',
'DisplayTitle': 'English - SRT',
'DeliveryMethod': 'External',
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt',
},
{
'Index': 4,
'Type': 'Subtitle',
'Codec': 'srt',
'Language': 'fra',
'DisplayTitle': 'French - SRT',
'DeliveryMethod': 'External',
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/4/Stream.srt',
},
{
'Index': 5,
'Type': 'Subtitle',
'Codec': 'srt',
'Language': 'eng',
'DisplayTitle': 'English Forced - SRT',
'IsForced': true,
'DeliveryMethod': 'External',
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/5/Stream.srt',
},
],
},
],
});
}
return http.Response('{}', 404);
}),
);
addTearDown(scoped.close);
Future<PlaybackInitializationResult> initialize(SubtitlePreference preference) {
return scoped.getPlaybackInitialization(
PlaybackInitializationOptions(
metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
),
selectedMediaIndex: 0,
preferredSubtitleTrack: preference,
// Transcoded playback is the case where the server delivers these
// streams out-of-band; direct play keeps the embedded tracks and is
// covered by the sidecar-identity test above.
qualityPreset: TranscodeQualityPreset.p720_2mbps,
),
);
}
void expectRequestedSubtitleIndex(int? expected) {
expect(playbackInfoUri!.queryParameters['SubtitleStreamIndex'], expected?.toString());
final playbackInfoJson = jsonDecode(playbackInfoBody!) as Map<String, dynamic>;
expect(playbackInfoJson['SubtitleStreamIndex'], expected);
}
final result = await initialize(
const SubtitlePreference.track(
SubtitleTrack(id: 'source:4', title: 'French - SRT', language: 'fra', codec: 'srt'),
),
);
expectRequestedSubtitleIndex(4);
await initialize(
const SubtitlePreference.intent(
SubtitleIntent(language: 'eng', forced: false, title: 'English - SRT', codec: 'srt'),
),
);
expectRequestedSubtitleIndex(3);
await initialize(
const SubtitlePreference.track(
SubtitleTrack(id: 'source:3', title: 'French - SRT', language: 'fra', codec: 'srt'),
),
);
expectRequestedSubtitleIndex(4);
await initialize(
const SubtitlePreference.track(
SubtitleTrack(id: 'source:3', title: 'English Forced - SRT', language: 'eng', codec: 'srt', isForced: true),
),
);
expectRequestedSubtitleIndex(5);
await initialize(const SubtitlePreference.off());
expectRequestedSubtitleIndex(-1);
await initialize(
const SubtitlePreference.intent(
SubtitleIntent(language: 'jpn', forced: false, title: 'Japanese - SRT', codec: 'srt'),
),
);
expectRequestedSubtitleIndex(null);
expect(result.playMethod, 'Transcode');
expect(result.mediaInfo!.subtitleTracks, hasLength(3));
expect(result.mediaInfo!.subtitleTracks.every((track) => track.usesExternalDelivery), isTrue);
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [3, 4, 5]);
expect(result.externalSubtitles.map((subtitle) => Uri.parse(subtitle.uri!).path), [
'/Videos/item-1/src-1/Subtitles/3/Stream.srt',
'/Videos/item-1/src-1/Subtitles/4/Stream.srt',
'/Videos/item-1/src-1/Subtitles/5/Stream.srt',
]);
});
test('getPlaybackInitialization ignores TranscodingUrl for original playback static fallback', () async {
final requests = <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('single-source direct play still pins MediaSourceId when the source id equals the item id', () async {
// The real-world shape for an ordinary Jellyfin episode: exactly one
// MediaSource whose Id is the item's own GUID. Plezy used to drop
// MediaSourceId here, leaving Jellyfin to resolve its own first sorted
// source — a different file as soon as the item gains an alternate
// version. Every official client sends it unconditionally.
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((request) async {
if (request.url.path == '/Users/user-1/Items/item-1') {
return jsonResponse({
'Id': 'item-1',
'Type': 'Episode',
'Name': 'Episode',
'MediaSources': [
{
'Id': 'item-1',
'Container': 'mkv',
'MediaStreams': [
{'Index': 0, 'Type': 'Video'},
],
},
],
});
}
if (request.url.path == '/Items/item-1/PlaybackInfo') {
return jsonResponse({
'MediaSources': [
{
'Id': 'item-1',
'Container': 'mkv',
'MediaStreams': [
{'Index': 0, 'Type': 'Video'},
],
},
],
});
}
return http.Response('{}', 404);
}),
);
addTearDown(scoped.close);
final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions(
metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.episode,
serverId: 'srv-1',
),
selectedMediaIndex: 0,
),
);
expect(result.playMethod, 'DirectPlay');
final uri = Uri.parse(result.videoUrl!);
expect(uri.path, '/Videos/item-1/stream');
expect(uri.queryParameters['Static'], 'true');
expect(uri.queryParameters['MediaSourceId'], 'item-1');
expect(uri.queryParameters['Container'], 'mkv');
});
test('selected external audio is sent to PlaybackInfo but omitted from static fallback URL', () async {
Uri? playbackInfoUri;
String? playbackInfoBody;
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((request) async {
if (request.url.path == '/Users/user-1/Items/item-1') {
return jsonResponse({
'Id': 'item-1',
'Type': 'Movie',
'Name': 'Movie',
'MediaSources': [
{
'Id': 'src-1',
'Container': 'mkv',
'MediaStreams': [
{'Index': 0, 'Type': 'Video'},
{'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'IsDefault': true},
{'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn', 'DeliveryMethod': 'External'},
],
},
],
});
}
if (request.url.path == '/Items/item-1/PlaybackInfo') {
playbackInfoUri = request.url;
playbackInfoBody = request.body;
return jsonResponse({
'MediaSources': [
{'Id': 'src-1'},
],
});
}
return http.Response('{}', 404);
}),
);
addTearDown(scoped.close);
final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions(
metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
),
selectedMediaIndex: 0,
selectedAudioStreamId: 4,
),
);
expect(playbackInfoUri!.queryParameters['AudioStreamIndex'], '4');
final body = jsonDecode(playbackInfoBody!) as Map<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('semantic carried audio resolves against the selected source before PlaybackInfo negotiation', () async {
final initialized = await _initializeJellyfinAudioCarry(
preferredAudioTrack: const AudioTrack(id: 'source:99', language: 'jpn', title: 'Main', codec: 'flac'),
);
expect(initialized.playbackInfoUri.queryParameters['AudioStreamIndex'], '4');
expect(initialized.playbackInfoBody['AudioStreamIndex'], 4);
expect(initialized.result.activeAudioStreamId, 4);
expect(initialized.result.mediaInfo!.audioTracks.singleWhere((track) => track.id == 4).selected, isTrue);
});
test('explicit Jellyfin audio stream wins over a conflicting semantic carry', () async {
final initialized = await _initializeJellyfinAudioCarry(
selectedAudioStreamId: 1,
preferredAudioTrack: const AudioTrack(id: 'source:99', language: 'jpn', title: 'Main', codec: 'flac'),
);
expect(initialized.playbackInfoUri.queryParameters['AudioStreamIndex'], '1');
expect(initialized.playbackInfoBody['AudioStreamIndex'], 1);
expect(initialized.result.activeAudioStreamId, 1);
});
test('unresolvable semantic audio carry lets Jellyfin choose the stream', () async {
final initialized = await _initializeJellyfinAudioCarry(
preferredAudioTrack: const AudioTrack(id: 'source:99', language: 'swe'),
);
expect(initialized.playbackInfoUri.queryParameters.containsKey('AudioStreamIndex'), isFalse);
expect(initialized.playbackInfoBody.containsKey('AudioStreamIndex'), isFalse);
expect(initialized.result.activeAudioStreamId, isNull);
});
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 embedded and external subtitle delivery', () async {
Uri? capturedUri;
String? capturedBody;
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((request) async {
capturedUri = request.url;
capturedBody = request.body;
return jsonResponse({'MediaSources': []});
}),
);
addTearDown(scoped.close);
await scoped.getPlaybackInfo(
'item-1',
maxStreamingBitrate: 5000000,
mediaSourceId: 'src-1',
audioStreamIndex: 1,
subtitleStreamIndex: 2,
);
expect(capturedUri!.queryParameters['MaxStreamingBitrate'], '5000000');
expect(capturedUri!.queryParameters.containsKey('IsPlayback'), isFalse);
expect(capturedUri!.queryParameters.containsKey('AutoOpenLiveStream'), isFalse);
expect(capturedUri!.queryParameters['MediaSourceId'], 'src-1');
expect(capturedUri!.queryParameters['AudioStreamIndex'], '1');
expect(capturedUri!.queryParameters['SubtitleStreamIndex'], '2');
final body = jsonDecode(capturedBody!) as Map<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);
const subtitleFormats = ['srt', 'ass', 'ssa', 'vtt', 'pgssub', 'dvdsub', 'dvbsub'];
final subtitleProfiles = [
for (final entry in profile['SubtitleProfiles'] as List<dynamic>) entry as Map<String, dynamic>,
];
// Every format is offered both ways, Embed first: the server picks per
// play method, so direct play reports its container streams as embedded
// while a remux or transcode still hands back sidecar URLs.
expect(
subtitleProfiles.where((entry) => entry['Method'] == 'Embed').map((entry) => entry['Format']),
subtitleFormats,
);
expect(
subtitleProfiles.where((entry) => entry['Method'] == 'External').map((entry) => entry['Format']),
subtitleFormats,
);
expect(
subtitleProfiles.indexWhere((entry) => entry['Method'] == 'Embed'),
lessThan(subtitleProfiles.indexWhere((entry) => entry['Method'] == 'External')),
);
});
test('path-encodes reserved ids for browse and watch-state endpoints', () async {
final captured = <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 TranscodingUrl preserves reverse-proxy subpaths', () async {
final scoped = JellyfinClient.forTesting(
connection: _conn(baseUrl: 'https://jf.example.com/jellyfin'),
httpClient: MockClient((request) async {
if (request.url.path == '/jellyfin/Users/user-1/Items/item-1') {
return jsonResponse({
'Id': 'item-1',
'Type': 'Movie',
'Name': 'Movie',
'MediaSources': [
{'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []},
],
});
}
if (request.url.path == '/jellyfin/Items/item-1/PlaybackInfo') {
return jsonResponse({
'PlaySessionId': 'play-session-direct',
'MediaSources': [
{
'Id': 'src-1',
'TranscodingUrl': 'Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct',
},
],
});
}
return http.Response('{}', 404);
}),
);
addTearDown(scoped.close);
final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions(
metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
),
selectedMediaIndex: 0,
qualityPreset: TranscodeQualityPreset.p720_2mbps,
),
);
final uri = Uri.parse(result.videoUrl!);
expect(uri.path, '/jellyfin/Videos/item-1/stream');
expect(uri.queryParameters['PlaySessionId'], 'play-session-direct');
expect(uri.queryParameters['api_key'], 'tok-abc');
});
test('selected source never inherits another source nested trickplay', () async {
final metadata = testMediaItem(
id: 'item-trickplay',
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
serverId: 'srv-1',
);
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((request) async {
if (request.url.path == '/Users/user-1/Items/item-trickplay') {
return jsonResponse({
'Id': 'item-trickplay',
'Type': 'Movie',
'Name': 'Movie',
'MediaSources': [
{'Id': 'src-a', 'Container': 'mkv', 'MediaStreams': []},
{'Id': 'src-b', 'Container': 'mp4', 'MediaStreams': []},
],
'Trickplay': {
'src-a': {
'160': {
'Width': 160,
'Height': 90,
'TileWidth': 4,
'TileHeight': 4,
'ThumbnailCount': 16,
'Interval': 10000,
},
},
},
});
}
if (request.url.path == '/Items/item-trickplay/PlaybackInfo') {
return jsonResponse({
'PlaySessionId': 'play-b',
'MediaSources': [
{
'Id': 'src-b',
'Container': 'mp4',
'DirectStreamUrl': '/Videos/item-trickplay/stream?MediaSourceId=src-b',
'MediaStreams': [],
},
],
});
}
return http.Response('{}', 404);
}),
);
addTearDown(scoped.close);
final result = await scoped.getPlaybackInitialization(
PlaybackInitializationOptions(
metadata: metadata,
selectedMediaIndex: 1,
selectedMediaSourceId: 'src-b',
qualityPreset: TranscodeQualityPreset.original,
),
);
expect(result.mediaInfo?.mediaSourceId, 'src-b');
expect(result.mediaInfo?.trickplayByWidth, isNull);
expect(result.selectedVersion?.id, 'src-b');
expect(Uri.parse(result.videoUrl!).queryParameters['MediaSourceId'], 'src-b');
expect(await scoped.createScrubPreviewSource(item: metadata, mediaSource: result.mediaInfo!), isNull);
});
test('thumbnailUrl honours width/height hints', () {
final url = client.thumbnailUrl('/Items/x/Images/Primary', width: 200, height: 300);
final uri = Uri.parse(url);
expect(uri.queryParameters['maxWidth'], '200');
expect(uri.queryParameters['maxHeight'], '300');
});
test('thumbnailUrl does not prefix already absolute artwork URLs', () {
final url = client.thumbnailUrl('https://jf.example.com/Items/x/Images/Primary?tag=abc', width: 200);
final uri = Uri.parse(url);
expect(uri.scheme, 'https');
expect(uri.host, 'jf.example.com');
expect(uri.path, '/Items/x/Images/Primary');
expect(url, isNot(contains('https://jf.example.comhttps://jf.example.com')));
expect(uri.queryParameters['tag'], 'abc');
expect(uri.queryParameters['maxWidth'], '200');
expect(uri.queryParameters['api_key'], 'tok-abc');
});
test('thumbnailUrl preserves existing auth and size parameters', () {
final url = client.thumbnailUrl(
'https://other.example/Items/x/Images/Primary?api_key=existing&maxWidth=100',
width: 200,
height: 300,
);
final uri = Uri.parse(url);
expect(uri.host, 'other.example');
expect(uri.queryParameters['api_key'], 'existing');
expect(uri.queryParameters['maxWidth'], '100');
expect(uri.queryParameters['maxHeight'], '300');
});
test('thumbnailUrl returns empty string for null/empty path', () {
expect(client.thumbnailUrl(null), '');
expect(client.thumbnailUrl(''), '');
});
test('every request carries the SDK-style MediaBrowser Authorization header', () {
// Findroid + the official Jellyfin SDK send this exact header shape.
// Some setups (Jellyfin 10.9+ behind reverse proxies) reject requests
// that only carry the legacy X-Emby-Token header, returning a 404 from
// the proxy/routing layer instead of a 401. We send both.
final headers = client.defaultHeadersForTesting;
final auth = headers['Authorization'];
expect(auth, isNotNull);
expect(auth, startsWith('MediaBrowser '));
expect(auth, contains('Client="Plezy"'));
expect(auth, contains('Device="Plezy"'));
expect(auth, contains('DeviceId="dev-xyz"'));
expect(auth, contains(RegExp(r'Version="[^"]+"')));
expect(auth, contains('Token="tok-abc"'));
// Belt-and-suspenders: legacy Emby token header is still present for
// older servers that prefer it.
expect(headers['X-Emby-Token'], 'tok-abc');
expect(headers['Accept'], 'application/json');
});
test('fetchLibraryContent sends a bounded paged Items request', () async {
Uri? captured;
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) async {
captured = req.url;
return jsonResponse({
'Items': [
{
'Id': 'movie-1',
'Type': 'Movie',
'Name': 'Movie',
'BackdropImageTags': ['backdrop-0', 'backdrop-1', 'backdrop-2'],
},
],
'TotalRecordCount': 123,
});
}),
);
addTearDown(scoped.close);
final page = await scoped.fetchLibraryContent(
'lib-1',
const LibraryQuery(kind: MediaKind.movie, offset: 50, limit: 25),
);
expect(page.items.single.id, 'movie-1');
expect(page.items.single.backdropPaths!.map((url) => Uri.parse(url).path).toList(), [
'/Items/movie-1/Images/Backdrop/0',
'/Items/movie-1/Images/Backdrop/1',
'/Items/movie-1/Images/Backdrop/2',
]);
expect(page.totalCount, 123);
expect(captured, isNotNull);
expect(captured!.path, '/Items');
expect(captured!.queryParameters['ParentId'], 'lib-1');
expect(captured!.queryParameters['StartIndex'], '50');
expect(captured!.queryParameters['Limit'], '25');
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
expect(captured!.queryParameters['IncludeItemTypes'], 'Movie');
expect(captured!.queryParameters['Fields'], isNot(contains('MediaSources')));
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
});
test('music browse and detail requests use leaf-appropriate fields', () async {
final captured = <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,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
});
test('fetchItemWithOnDeck keeps resumable NextUp semantics for show detail lookup', () async {
Uri? capturedNextUp;
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) async {
if (req.url.path == '/Users/user-1/Items/show-1') {
return jsonResponse({'Id': 'show-1', 'Type': 'Series', 'Name': 'Show 1'});
}
if (req.url.path == '/Shows/NextUp') {
capturedNextUp = req.url;
return jsonResponse({'Items': []});
}
return http.Response('not found', 404);
}),
);
addTearDown(scoped.close);
await scoped.fetchItemWithOnDeck('show-1');
expect(capturedNextUp, isNotNull);
expect(capturedNextUp!.queryParameters['seriesId'], 'show-1');
expect(capturedNextUp!.queryParameters['Limit'], '1');
expect(capturedNextUp!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
expect(capturedNextUp!.queryParameters['ImageTypeLimit'], '3');
expect(capturedNextUp!.queryParameters.containsKey('EnableResumable'), isFalse);
expect(capturedNextUp!.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
});
test('fetchPlaybackExtras loads native Jellyfin media segments', () async {
final requests = <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,Logo');
expect(resume.queryParameters['ImageTypeLimit'], '3');
final nextUp = requests.singleWhere((uri) => uri.path == '/Shows/NextUp');
expect(nextUp.queryParameters['userId'], 'user-1');
expect(nextUp.queryParameters['Limit'], '3');
expect(nextUp.queryParameters['EnableResumable'], 'false');
expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false');
expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
expect(nextUp.queryParameters['ImageTypeLimit'], '3');
// Bounds the server's unbounded GetNextUpSeriesKeys scan (#1784).
expect(
DateTime.parse(nextUp.queryParameters['NextUpDateCutoff']!),
isNot(null),
reason: 'must be a parseable ISO-8601 instant',
);
});
test('fetchContinueWatching orders a recently watched series Next Up above an older resume item', () async {
final requests = <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');
// Scoped to the one series that needs a date, not a server-wide episode
// sort: the unscoped form pegs Jellyfin 12.0-rc3 for seconds (#1699).
expect(lookup.queryParameters['ParentId'], 'show-recent');
expect(lookup.queryParameters['IncludeItemTypes'], 'Episode');
expect(lookup.queryParameters['Recursive'], 'true');
expect(lookup.queryParameters['SortBy'], 'DatePlayed');
expect(lookup.queryParameters['SortOrder'], 'Descending');
expect(lookup.queryParameters['Limit'], '1');
expect(lookup.queryParameters['Fields'], 'UserData');
// No Filters=IsPlayed: a series' newest engagement can sit on an episode
// with a LastPlayedDate but Played==false (see _attachSeriesLastPlayed).
expect(lookup.queryParameters.containsKey('Filters'), isFalse);
});
test('fetchContinueWatching issues one last-played lookup per pending series', () async {
final requests = <Uri>[];
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) async {
requests.add(req.url);
if (req.url.path == '/UserItems/Resume') return jsonResponse({'Items': []});
if (req.url.path == '/Shows/NextUp') {
return jsonResponse({
'Items': [
for (var i = 1; i <= 6; i++)
{'Id': 'next-$i', 'Type': 'Episode', 'Name': 'Next $i', 'SeriesId': 'show-$i'},
// Second row for an already-pending series: still one lookup.
{'Id': 'next-1b', 'Type': 'Episode', 'Name': 'Next 1b', 'SeriesId': 'show-1'},
],
});
}
if (req.url.path == '/Items') {
final seriesId = req.url.queryParameters['ParentId']!;
return jsonResponse({
'Items': [
{
'Id': 'played-$seriesId',
'Type': 'Episode',
'SeriesId': seriesId,
'UserData': {'LastPlayedDate': '2026-06-0${seriesId.split('-').last}T00:00:00.0000000Z'},
},
],
});
}
return http.Response('not found', 404);
}),
);
addTearDown(scoped.close);
final items = await scoped.fetchContinueWatching(count: 10);
final lookups = requests.where((uri) => uri.path == '/Items').toList();
expect(
lookups.map((uri) => uri.queryParameters['ParentId']).toList(),
unorderedEquals(['show-1', 'show-2', 'show-3', 'show-4', 'show-5', 'show-6']),
reason: 'a series pending on two Next Up rows must not be looked up twice',
);
// Series 6 played most recently, series 1 least; the shelf follows the
// stamped dates rather than Next Up's own row order.
expect(items.map((item) => item.id).take(2), ['next-6', 'next-5']);
});
test('fetchContinueWatching caps last-played lookups on an uncapped shelf', () async {
final requests = <Uri>[];
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) async {
requests.add(req.url);
if (req.url.path == '/UserItems/Resume') return jsonResponse({'Items': []});
if (req.url.path == '/Shows/NextUp') {
return jsonResponse({
'Items': [
for (var i = 0; i < 60; i++)
{'Id': 'next-$i', 'Type': 'Episode', 'Name': 'Next $i', 'SeriesId': 'show-$i'},
],
});
}
if (req.url.path == '/Items') {
return jsonResponse({
'Items': [
{
'Id': 'played-${req.url.queryParameters['ParentId']}',
'Type': 'Episode',
'SeriesId': req.url.queryParameters['ParentId'],
'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'},
},
],
});
}
return http.Response('not found', 404);
}),
);
addTearDown(scoped.close);
final items = await scoped.fetchContinueWatching(count: null);
final lookups = requests.where((uri) => uri.path == '/Items').toList();
// 60 series on the shelf, but the enrichment stays bounded — an unbounded
// fan-out here is what the #1699 fix exists to prevent.
expect(lookups, hasLength(24));
// Next Up order decides which series get dated: the newest ones.
expect(
lookups.map((uri) => uri.queryParameters['ParentId']).toList(),
unorderedEquals([for (var i = 0; i < 24; i++) 'show-$i']),
);
// Every row is still returned; the undated tail just sorts by addedAt.
expect(items, hasLength(60));
});
/// Next Up rows for [seriesCount] distinct series, nothing resumable.
Map<String, Object> stalledShelfRoutes(int seriesCount) => {
'Items': [
for (var i = 0; i < seriesCount; i++)
{'Id': 'next-$i', 'Type': 'Episode', 'Name': 'Next $i', 'SeriesId': 'show-$i'},
],
};
test('fetchContinueWatching abandons last-played lookups when connects go silent', () {
fakeAsync((async) {
final requests = <Uri>[];
final silent = Completer<http.Response>();
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) {
requests.add(req.url);
if (req.url.path == '/UserItems/Resume') return Future.value(jsonResponse({'Items': []}));
if (req.url.path == '/Shows/NextUp') return Future.value(jsonResponse(stalledShelfRoutes(24)));
// Accepted, then never answered — how the #1699 server behaved.
if (req.url.path == '/Items') return silent.future;
return Future.value(http.Response('not found', 404));
}),
);
List<MediaItem>? items;
unawaited(scoped.fetchContinueWatching(count: null).then((result) => items = result));
async.flushMicrotasks();
// Exactly one batch open, exactly _seriesLastPlayedConcurrency wide — a
// wider burst is the failure mode this change exists to remove.
expect(
requests.where((uri) => uri.path == '/Items').length,
4,
reason: 'the first batch must be four lookups, not the whole shelf',
);
// Six batches on the shared 10s/120s defaults would block for minutes.
async.elapse(const Duration(milliseconds: 3900));
async.flushMicrotasks();
expect(items, isNull, reason: 'a second batch runs after the first request timeout');
expect(requests.where((uri) => uri.path == '/Items').length, 8);
async.elapse(const Duration(milliseconds: 200));
async.flushMicrotasks();
expect(items, isNotNull, reason: 'the shared budget must end the pass, not the per-request timeout');
expect(items!.map((item) => item.id), [for (var i = 0; i < 24; i++) 'next-$i']);
expect(
requests.where((uri) => uri.path == '/Items').length,
8,
reason: 'no batch may start after the budget expires',
);
scoped.close();
});
});
test('fetchContinueWatching honours the last-played budget when a response body stalls', () {
fakeAsync((async) {
final requests = <Uri>[];
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient.streaming((req, _) async {
requests.add(req.url);
if (req.url.path == '/UserItems/Resume') {
return http.StreamedResponse(
http.ByteStream.fromBytes(utf8.encode(jsonEncode({'Items': []}))),
200,
headers: {'content-type': 'application/json'},
);
}
if (req.url.path == '/Shows/NextUp') {
return http.StreamedResponse(
http.ByteStream.fromBytes(utf8.encode(jsonEncode(stalledShelfRoutes(24)))),
200,
headers: {'content-type': 'application/json'},
);
}
// Headers land just inside the per-request budget, then the body
// never arrives. `MediaServerHttpClient` times connect and receive
// separately, so this lookup would otherwise run for 2.5s + 3s and
// two batches would outlast the enrichment's whole ceiling.
await Future<void>.delayed(const Duration(milliseconds: 2500));
return http.StreamedResponse(
StreamController<List<int>>().stream,
200,
headers: {'content-type': 'application/json'},
);
}),
);
List<MediaItem>? items;
unawaited(scoped.fetchContinueWatching(count: null).then((result) => items = result));
async.elapse(const Duration(milliseconds: 3900));
async.flushMicrotasks();
expect(items, isNull);
async.elapse(const Duration(milliseconds: 200));
async.flushMicrotasks();
expect(items, isNotNull, reason: 'a stalled body must not outlast the shared budget');
expect(items!.map((item) => item.id), [for (var i = 0; i < 24; i++) 'next-$i']);
expect(
requests.where((uri) => uri.path == '/Items').length,
4,
reason: 'the first batch is still mid-receive when the budget fires',
);
scoped.close();
});
});
test('fetchContinueWatching keeps other series dated when one lookup fails', () async {
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) async {
if (req.url.path == '/UserItems/Resume') {
return jsonResponse({
'Items': [
{
'Id': 'resume-mid',
'Type': 'Movie',
'Name': 'Mid Movie',
'UserData': {'LastPlayedDate': '2026-05-01T00:00:00.0000000Z'},
},
],
});
}
if (req.url.path == '/Shows/NextUp') {
return jsonResponse({
'Items': [
{'Id': 'next-ok', 'Type': 'Episode', 'Name': 'Next Ok', 'SeriesId': 'show-ok'},
{'Id': 'next-broken', 'Type': 'Episode', 'Name': 'Next Broken', 'SeriesId': 'show-broken'},
],
});
}
if (req.url.path == '/Items') {
if (req.url.queryParameters['ParentId'] == 'show-broken') {
return http.Response('Internal error', 500);
}
return jsonResponse({
'Items': [
{
'Id': 'played-ok',
'Type': 'Episode',
'SeriesId': 'show-ok',
'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'},
},
],
});
}
return http.Response('not found', 404);
}),
);
addTearDown(scoped.close);
final items = await scoped.fetchContinueWatching(count: 10);
// The dated series still outranks the resume item; the failed one keeps a
// null date and falls to the bottom instead of sinking the whole shelf.
expect(items.map((item) => item.id), ['next-ok', 'resume-mid', 'next-broken']);
});
test('fetchContinueWatching does not let resume items starve Next Up under the limit', () async {
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) async {
if (req.url.path == '/UserItems/Resume') {
return jsonResponse({
'Items': [
{
'Id': 'resume-old-1',
'Type': 'Movie',
'Name': 'Old Movie 1',
'UserData': {'LastPlayedDate': '2021-01-01T00:00:00.0000000Z'},
},
{
'Id': 'resume-old-2',
'Type': 'Movie',
'Name': 'Old Movie 2',
'UserData': {'LastPlayedDate': '2022-01-01T00:00:00.0000000Z'},
},
],
});
}
if (req.url.path == '/Shows/NextUp') {
return jsonResponse({
'Items': [
{'Id': 'next-recent', 'Type': 'Episode', 'Name': 'Next Recent', 'SeriesId': 'show-recent'},
],
});
}
if (req.url.path == '/Items') {
return jsonResponse({
'Items': [
{
'Id': 'ep-played',
'Type': 'Episode',
'SeriesId': 'show-recent',
'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'},
},
],
});
}
return http.Response('not found', 404);
}),
);
addTearDown(scoped.close);
// count equals the number of resume items: the old resume-first merge would
// have filled the limit and dropped Next Up entirely.
final items = await scoped.fetchContinueWatching(count: 2);
expect(items.map((item) => item.id), ['next-recent', 'resume-old-2']);
});
test('fetchContinueWatching keeps resume items when Next Up fails', () async {
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) async {
if (req.url.path == '/UserItems/Resume') {
return jsonResponse({
'Items': [
{'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'},
],
});
}
if (req.url.path == '/Shows/NextUp') {
return http.Response('server error', 500);
}
return http.Response('not found', 404);
}),
);
addTearDown(scoped.close);
final items = await scoped.fetchContinueWatching();
expect(items.map((item) => item.id), ['resume-movie-1']);
});
test('fetchContinueWatching omits Limit when count is null', () async {
final requests = <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 and bounds the server scan with a date cutoff', () async {
final client = buildClient();
addTearDown(client.close);
await client.fetchGlobalHubs(limit: 12);
final resume = captured.singleWhere((uri) => uri.path == '/UserItems/Resume');
expect(resume.queryParameters['EnableTotalRecordCount'], 'false');
final nextUp = capturedNextUpRequest();
expect(nextUp.queryParameters['userId'], 'user-1');
expect(nextUp.queryParameters['Limit'], '12');
expect(nextUp.queryParameters['EnableResumable'], 'false');
expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false');
expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
expect(nextUp.queryParameters['ImageTypeLimit'], '3');
expect(nextUp.queryParameters['NextUpDateCutoff'], isNotNull, reason: 'bounds the server-side scan (#1784)');
});
test('can skip global playback hubs', () async {
final client = buildClient();
addTearDown(client.close);
await client.fetchGlobalHubs(limit: 12, includePlaybackHubs: false);
expect(captured.map((uri) => uri.path), ['/Users/user-1/Items/Latest']);
expect(captured.single.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode');
expect(captured.single.queryParameters['Limit'], '12');
});
});
group('JellyfinClient.fetchLibraryHubs URL builders', () {
late List<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 and bounds the server scan with a date cutoff', () async {
final client = buildClient();
addTearDown(client.close);
await client.fetchLibraryHubs('lib-99', libraryName: 'Shows', limit: 12, libraryKind: MediaKind.show);
final nextUp = captured.singleWhere((uri) => uri.path == '/Shows/NextUp');
expect(nextUp.queryParameters['ParentId'], 'lib-99');
expect(nextUp.queryParameters['userId'], 'user-1');
expect(nextUp.queryParameters['Limit'], '12');
expect(nextUp.queryParameters['EnableResumable'], 'false');
expect(nextUp.queryParameters['EnableTotalRecordCount'], 'false');
expect(nextUp.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
expect(nextUp.queryParameters['ImageTypeLimit'], '3');
expect(nextUp.queryParameters['NextUpDateCutoff'], isNotNull, reason: 'bounds the server-side scan (#1784)');
});
test('movie library skips Next Up and disables resume total count', () async {
final client = buildClient();
addTearDown(client.close);
await client.fetchLibraryHubs('lib-99', libraryName: 'Movies', limit: 12, libraryKind: MediaKind.movie);
expect(captured.where((uri) => uri.path == '/Shows/NextUp'), isEmpty);
final resume = captured.singleWhere((uri) => uri.path == '/UserItems/Resume');
expect(resume.queryParameters['ParentId'], 'lib-99');
expect(resume.queryParameters['Limit'], '12');
expect(resume.queryParameters['EnableTotalRecordCount'], 'false');
});
test('can skip library playback hubs', () async {
final client = buildClient();
addTearDown(client.close);
await client.fetchLibraryHubs('lib-99', libraryName: 'Movies', limit: 12, includePlaybackHubs: false);
expect(captured.map((uri) => uri.path), ['/Users/user-1/Items/Latest']);
expect(captured.single.queryParameters['ParentId'], 'lib-99');
expect(captured.single.queryParameters['Limit'], '12');
});
});
group('JellyfinClient.fetchMoreHubItems URL builders', () {
Uri? captured;
JellyfinClient buildClient() {
captured = null;
final mock = MockClient((req) async {
captured = req.url;
return http.Response('[]', 200, headers: {'content-type': 'application/json'});
});
return JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
}
test('global "home.recent" uses the pageable Items catalogue with the provided limit', () async {
final client = buildClient();
await client.fetchMoreHubItems('home.recent', limit: 80);
expect(captured, isNotNull);
expect(captured!.path, '/Items');
expect(captured!.queryParameters['userId'], 'user-1');
expect(captured!.queryParameters['StartIndex'], '0');
expect(captured!.queryParameters['Limit'], '80');
expect(captured!.queryParameters['Recursive'], 'true');
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode,Video,MusicVideo,Photo');
expect(captured!.queryParameters['SortBy'], 'DateCreated,SortName,ProductionYear');
expect(captured!.queryParameters['SortOrder'], 'Descending,Descending,Descending');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
client.close();
});
test('global "home.continue" hits /UserItems/Resume with userId', () async {
final client = buildClient();
await client.fetchMoreHubItems('home.continue');
expect(captured, isNotNull);
expect(captured!.path, '/UserItems/Resume');
expect(captured!.queryParameters['userId'], 'user-1');
expect(captured!.queryParameters['Limit'], '50');
expect(captured!.queryParameters['StartIndex'], '0');
expect(captured!.queryParameters['MediaTypes'], 'Video');
expect(captured!.queryParameters['Recursive'], 'true');
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
client.close();
});
test('global "home.nextup" hits /Shows/NextUp with userId', () async {
final client = buildClient();
await client.fetchMoreHubItems('home.nextup', limit: 25);
expect(captured, isNotNull);
expect(captured!.path, '/Shows/NextUp');
expect(captured!.queryParameters['userId'], 'user-1');
expect(captured!.queryParameters['Limit'], '25');
expect(captured!.queryParameters['StartIndex'], '0');
expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
expect(captured!.queryParameters['EnableResumable'], 'false');
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
expect(captured!.queryParameters['NextUpDateCutoff'], isNotNull, reason: 'bounds the server-side scan (#1784)');
client.close();
});
test('library-scoped "library.{id}.recent" pages Items within its parent', () async {
final client = buildClient();
await client.fetchMoreHubItems('library.lib-99.recent', limit: 30);
expect(captured, isNotNull);
expect(captured!.path, '/Items');
expect(captured!.queryParameters['userId'], 'user-1');
expect(captured!.queryParameters['ParentId'], 'lib-99');
expect(captured!.queryParameters['StartIndex'], '0');
expect(captured!.queryParameters['Limit'], '30');
expect(captured!.queryParameters['Recursive'], 'true');
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode,Video,MusicVideo,Photo');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
client.close();
});
test('library-scoped "library.{id}.continue" forwards ParentId to Resume', () async {
final client = buildClient();
await client.fetchMoreHubItems('library.lib-99.continue');
expect(captured, isNotNull);
expect(captured!.path, '/UserItems/Resume');
expect(captured!.queryParameters['ParentId'], 'lib-99');
expect(captured!.queryParameters['userId'], 'user-1');
expect(captured!.queryParameters['StartIndex'], '0');
expect(captured!.queryParameters['Recursive'], 'true');
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
client.close();
});
test('library-scoped "library.{id}.nextup" forwards ParentId to NextUp', () async {
final client = buildClient();
await client.fetchMoreHubItems('library.lib-99.nextup');
expect(captured, isNotNull);
expect(captured!.path, '/Shows/NextUp');
expect(captured!.queryParameters['ParentId'], 'lib-99');
expect(captured!.queryParameters['userId'], 'user-1');
expect(captured!.queryParameters['StartIndex'], '0');
expect(captured!.queryParameters['EnableResumable'], 'false');
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
expect(captured!.queryParameters['ImageTypeLimit'], '3');
expect(captured!.queryParameters['NextUpDateCutoff'], isNotNull, reason: 'bounds the server-side scan (#1784)');
client.close();
});
test('library-scoped "library.{id}.latestalbums" hits Latest with slim music album fields', () async {
final client = buildClient();
await client.fetchMoreHubItems('library.lib-99.latestalbums', limit: 30);
expect(captured, isNotNull);
expect(captured!.path, '/Users/user-1/Items/Latest');
expect(captured!.queryParameters['ParentId'], 'lib-99');
expect(captured!.queryParameters['Limit'], '30');
// Album FOLDER dtos: count/user-data fields would each cost the server
// a recursive per-album COUNT query (#1552).
expect(captured!.queryParameters['Fields'], 'PremiereDate,OriginalTitle,SortName');
expect(captured!.queryParameters['EnableUserData'], 'false');
client.close();
});
test('library-scoped "library.{id}.recentlyplayed" queries played audio with slim track fields', () async {
final client = buildClient();
await client.fetchMoreHubItems('library.lib-99.recentlyplayed');
expect(captured, isNotNull);
expect(captured!.path, '/Items');
expect(captured!.queryParameters['ParentId'], 'lib-99');
expect(captured!.queryParameters['userId'], 'user-1');
expect(captured!.queryParameters['IncludeItemTypes'], 'Audio');
expect(captured!.queryParameters['Recursive'], 'true');
expect(captured!.queryParameters['Filters'], 'IsPlayed');
expect(captured!.queryParameters['SortBy'], 'DatePlayed');
expect(captured!.queryParameters['Fields'], 'UserData,PremiereDate,OriginalTitle,SortName');
client.close();
});
test('unknown identifier returns empty without hitting the network', () async {
final client = buildClient();
final items = await client.fetchMoreHubItems('totally.unknown');
expect(items, isEmpty);
expect(captured, isNull);
client.close();
});
test('paged Resume hub sends requested offset and parses total count', () async {
Uri? requestUri;
final client = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) async {
requestUri = req.url;
return jsonResponse({
'TotalRecordCount': 30,
'Items': [
{'Id': 'resume-20', 'Name': 'Resume', 'Type': 'Movie'},
],
});
}),
);
addTearDown(client.close);
final page = await client.fetchMoreHubItemsPage('home.continue', start: 20, size: 10);
expect(page.items.single.id, 'resume-20');
expect(page.totalCount, 30);
expect(page.offset, 20);
expect(requestUri, isNotNull);
expect(requestUri!.path, '/UserItems/Resume');
expect(requestUri!.queryParameters['StartIndex'], '20');
expect(requestUri!.queryParameters['Limit'], '10');
expect(requestUri!.queryParameters['EnableTotalRecordCount'], 'true');
});
test('paged Recently Added sends the requested offset and parses total count', () async {
Uri? requestUri;
final client = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) async {
requestUri = req.url;
return jsonResponse({
'TotalRecordCount': 321,
'Items': [
{'Id': 'recent-20', 'Name': 'Recent', 'Type': 'Movie'},
],
});
}),
);
addTearDown(client.close);
final page = await client.fetchMoreHubItemsPage('home.recent', start: 20, size: 10);
expect(page.items.single.id, 'recent-20');
expect(page.totalCount, 321);
expect(page.offset, 20);
expect(requestUri, isNotNull);
expect(requestUri!.path, '/Items');
expect(requestUri!.queryParameters['StartIndex'], '20');
expect(requestUri!.queryParameters['Limit'], '10');
expect(requestUri!.queryParameters['EnableTotalRecordCount'], 'true');
expect(requestUri!.queryParameters.containsKey('ParentId'), isFalse);
});
test('paged hub first-page errors throw while list helper keeps empty fallback', () async {
var requestCount = 0;
final client = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) async {
requestCount++;
return http.Response('server error', 500);
}),
);
addTearDown(client.close);
await expectLater(client.fetchMoreHubItemsPage('home.continue', start: 0, size: 10), throwsA(isA<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,OriginalTitle,SortName,Overview');
expect(itemsRequest.queryParameters.containsKey('EnableTotalRecordCount'), isFalse);
expect(itemsRequest.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Logo');
expect(itemsRequest.queryParameters['ImageTypeLimit'], '3');
});
test('fetchCollectionsPage uses requested collection page bounds', () async {
Uri? itemsRequest;
final mock = MockClient((req) async {
if (req.url.path == '/Users/user-1/Views') {
return jsonResponse({
'Items': [
{'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'},
],
});
}
if (req.url.path == '/Items') {
itemsRequest = req.url;
return jsonResponse({
'TotalRecordCount': 30,
'Items': [
{'Id': 'collection-20', 'Name': 'Collection 20', 'Type': 'BoxSet'},
],
});
}
return http.Response('not found', 404);
});
final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
addTearDown(client.close);
final page = await client.fetchCollectionsPage('lib-movies', start: 20, size: 10);
expect(page.totalCount, 30);
expect(page.offset, 20);
expect(page.items.single.id, 'collection-20');
expect(itemsRequest, isNotNull);
expect(itemsRequest!.queryParameters['ParentId'], 'lib-boxsets');
expect(itemsRequest!.queryParameters['StartIndex'], '20');
expect(itemsRequest!.queryParameters['Limit'], '10');
expect(itemsRequest!.queryParameters.containsKey('EnableTotalRecordCount'), isFalse);
});
test('fetchCollectionsPage uses sentinel total when total count is missing', () async {
final mock = MockClient((req) async {
if (req.url.path == '/Users/user-1/Views') {
return jsonResponse({
'Items': [
{'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'},
],
});
}
if (req.url.path == '/Items') {
return jsonResponse({
'Items': [
{'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'},
{'Id': 'collection-2', 'Name': 'Collection 2', 'Type': 'BoxSet'},
],
});
}
return http.Response('not found', 404);
});
final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
addTearDown(client.close);
final page = await client.fetchCollectionsPage('lib-movies', size: 2);
expect(page.items.map((c) => c.id).toList(), ['collection-1', 'collection-2']);
expect(page.totalCount, 3);
});
test('walks boxsets view in pages', () async {
final itemRequests = <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);
});
});
}