fix(jellyfin): use season episode endpoint

This commit is contained in:
edde746
2026-06-14 18:34:12 +02:00
parent 5e2bd4efa8
commit 01a5d11ef5
9 changed files with 203 additions and 12 deletions
@@ -2906,6 +2906,44 @@ void main() {
expect(requestUri!.queryParameters['Limit'], '10');
});
test('fetchSeasonEpisodesPage uses Jellyfin episode endpoint scoped to season', () async {
Uri? requestUri;
final mock = MockClient((req) async {
if (req.url.path == '/Shows/show-1/Episodes') {
requestUri = req.url;
return http.Response(
jsonEncode({
'Items': [
{'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'},
],
'TotalRecordCount': 40,
}),
200,
headers: {'content-type': 'application/json'},
);
}
return http.Response('not found', 404);
});
final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
addTearDown(client.close);
final page = await 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);
expect(requestUri, isNotNull);
expect(requestUri!.queryParameters['SeasonId'], 'season-1');
expect(requestUri!.queryParameters['StartIndex'], '20');
expect(requestUri!.queryParameters['Limit'], '10');
expect(requestUri!.queryParameters['EnableTotalRecordCount'], 'true');
expect(requestUri!.queryParameters['IsMissing'], 'false');
expect(requestUri!.queryParameters['IsVirtualUnaired'], 'false');
expect(requestUri!.queryParameters['Fields']!.split(','), contains('MediaSources'));
expect(requestUri!.queryParameters.containsKey('SortBy'), isFalse);
expect(requestUri!.queryParameters.containsKey('SortOrder'), isFalse);
});
test('fetchChildrenPage orders direct episode children by season and episode index', () async {
Uri? requestUri;
final mock = MockClient((req) async {
+67
View File
@@ -43,6 +43,8 @@ MediaItem _episode(
durationMs: durationMs,
);
MediaItem _clip(String id) => MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.clip, title: 'Clip');
class _RecordingClient implements MediaServerClient {
_RecordingClient({this.childrenByParent = const {}, this.childrenPageByParent = const {}, this.itemsById = const {}});
@@ -76,6 +78,30 @@ class _RecordingClient implements MediaServerClient {
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _SeasonPagingRecordingClient extends _RecordingClient implements SeasonEpisodePagingClient {
_SeasonPagingRecordingClient({this.seasonPageBySeason = const {}});
final Map<({String seriesId, String seasonId}), List<MediaItem>> seasonPageBySeason;
final seasonEpisodePageCalls = <({String seriesId, String seasonId, int? start, int? size})>[];
@override
Future<LibraryPage<MediaItem>> fetchSeasonEpisodesPage(
String seriesId,
String seasonId, {
int? start,
int? size,
abort,
}) async {
seasonEpisodePageCalls.add((seriesId: seriesId, seasonId: seasonId, start: start, size: size));
final all = seasonPageBySeason[(seriesId: seriesId, seasonId: seasonId)] ?? const <MediaItem>[];
final offset = start ?? 0;
final limit = size ?? all.length;
final end = (offset + limit).clamp(0, all.length).toInt();
final items = offset >= all.length ? const <MediaItem>[] : all.sublist(offset, end);
return LibraryPage(items: items, totalCount: all.length, offset: offset);
}
}
void main() {
test('defaultPlaybackSeason skips specials when a regular season exists', () {
final special = _season('specials', index: 0);
@@ -180,6 +206,21 @@ void main() {
expect(client.childrenPageCalls, [(parentId: 'season-1', start: 0, size: 1)]);
});
test('fetchFirstEpisodeForSeason uses season episode paging when available', () async {
final episode = _episode('episode-1');
final client = _SeasonPagingRecordingClient(
seasonPageBySeason: {
(seriesId: 'show-1', seasonId: 'season-1'): [episode, _episode('episode-2')],
},
);
final result = await fetchFirstEpisodeForSeason(client, 'season-1', seriesId: 'show-1');
expect(result, same(episode));
expect(client.childrenPageCalls, isEmpty);
expect(client.seasonEpisodePageCalls, [(seriesId: 'show-1', seasonId: 'season-1', start: 0, size: 1)]);
});
test('fetchSeasonEpisodePage normalizes show and season identity', () async {
final show = MediaItem(
id: 'show-1',
@@ -213,6 +254,32 @@ void main() {
expect(page.items.single.libraryId, show.libraryId);
});
test('fetchSeasonEpisodePage uses season episode paging when available', () async {
final show = MediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show');
final season = _season('season-1');
final row = _episode('episode-1');
final client = _SeasonPagingRecordingClient(
seasonPageBySeason: {
(seriesId: show.id, seasonId: season.id): [row],
},
);
final page = await fetchSeasonEpisodePage(client, show: show, season: season, start: 0, size: 10);
expect(client.childrenPageCalls, isEmpty);
expect(client.seasonEpisodePageCalls, [(seriesId: show.id, seasonId: season.id, start: 0, size: 10)]);
expect(page.items.single.id, row.id);
});
test('normalizeSeasonEpisodes ignores non-episode rows', () {
final show = MediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show');
final season = _season('season-1');
final normalized = normalizeSeasonEpisodes([_clip('extra-1'), _episode('episode-1')], show: show, season: season);
expect(normalized.map((item) => item.id), ['episode-1']);
});
test('fetchRepresentativeVersions uses paged lookup for season metadata', () async {
final versions = [const MediaVersion(id: '1080', videoResolution: '1080')];
final episodeRow = _episode('episode-1');