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
+16 -5
View File
@@ -39,8 +39,15 @@ Future<void> collectEpisodesForSeason(
/// Fetch just the first episode of a season without walking the entire season.
/// Use this for representative lookups and immediate "play first" actions.
Future<MediaItem?> fetchFirstEpisodeForSeason(MediaServerClient client, String seasonRatingKey) async {
final page = await client.fetchChildrenPage(seasonRatingKey, start: 0, size: 1);
Future<MediaItem?> fetchFirstEpisodeForSeason(
MediaServerClient client,
String seasonRatingKey, {
String? seriesId,
}) async {
final seasonPagingClient = client is SeasonEpisodePagingClient ? client as SeasonEpisodePagingClient : null;
final page = seriesId != null && seasonPagingClient != null
? await seasonPagingClient.fetchSeasonEpisodesPage(seriesId, seasonRatingKey, start: 0, size: 1)
: await client.fetchChildrenPage(seasonRatingKey, start: 0, size: 1);
for (final item in page.items) {
if (item.kind == MediaKind.episode) return item;
}
@@ -136,8 +143,8 @@ int preferredSeasonIndex(
return defaultPlaybackSeasonIndex(seasons);
}
/// Fetch a page of direct season children and normalize the episode identity
/// fields detail rows depend on. Local/session progress stays layered in UI.
/// Fetch a page of season episodes and normalize the episode identity fields
/// detail rows depend on. Local/session progress stays layered in UI.
Future<LibraryPage<MediaItem>> fetchSeasonEpisodePage(
MediaServerClient client, {
required MediaItem show,
@@ -145,7 +152,10 @@ Future<LibraryPage<MediaItem>> fetchSeasonEpisodePage(
required int start,
required int size,
}) async {
final page = await client.fetchChildrenPage(season.id, start: start, size: size);
final seasonPagingClient = client is SeasonEpisodePagingClient ? client as SeasonEpisodePagingClient : null;
final page = seasonPagingClient != null
? await seasonPagingClient.fetchSeasonEpisodesPage(show.id, season.id, start: start, size: size)
: await client.fetchChildrenPage(season.id, start: start, size: size);
return LibraryPage<MediaItem>(
items: normalizeSeasonEpisodes(page.items, show: show, season: season),
totalCount: page.totalCount,
@@ -159,6 +169,7 @@ List<MediaItem> normalizeSeasonEpisodes(
required MediaItem season,
}) {
return episodes
.where((episode) => episode.kind == MediaKind.episode)
.map(
(episode) => _withFallbackLibrary(
episode.copyWith(
+13
View File
@@ -631,6 +631,19 @@ extension MediaServerClientScope on MediaServerClient {
}
}
/// Optional capability for clients that can fetch a season's episodes without
/// listing generic children. Jellyfin uses this to avoid mixing local extras or
/// missing/virtual placeholders into normal season episode rails.
abstract interface class SeasonEpisodePagingClient {
Future<LibraryPage<MediaItem>> fetchSeasonEpisodesPage(
String seriesId,
String seasonId, {
int? start,
int? size,
AbortController? abort,
});
}
/// Cache-aware fetch helpers shared by both backends so the offline-first /
/// network-then-cache pattern lives in one place.
///
+15 -2
View File
@@ -2769,6 +2769,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return null;
}
String? _seriesIdForSeason(MediaItem season) {
if (_metadata.isShow) return (_fullMetadata ?? _metadata).id;
return season.grandparentId ?? season.parentId ?? _metadata.grandparentId ?? _metadata.parentId;
}
Future<LibraryPage<MediaItem>> _fetchFlattenedEpisodePage(
MediaServerClient client,
ServerId serverId, {
@@ -2777,7 +2782,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}) async {
final season = _flattenedSeasonForDirectEpisodePaging;
if (season != null) {
final page = await client.fetchChildrenPage(season.id, start: start, size: size);
final seriesId = _seriesIdForSeason(season);
final seasonPagingClient = client is SeasonEpisodePagingClient ? client as SeasonEpisodePagingClient : null;
final page = seriesId != null && seasonPagingClient != null
? await seasonPagingClient.fetchSeasonEpisodesPage(seriesId, season.id, start: start, size: size)
: await client.fetchChildrenPage(season.id, start: start, size: size);
return LibraryPage<MediaItem>(
items: _enrichDirectSeasonEpisodes(page.items, season: season, serverId: serverId),
totalCount: page.totalCount,
@@ -2991,7 +3000,11 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
} else {
final client = getServerBoundMediaClient(context);
if (client == null) return;
firstEpisode = await fetchFirstEpisodeForSeason(client, firstSeason.id);
firstEpisode = await fetchFirstEpisodeForSeason(
client,
firstSeason.id,
seriesId: _seriesIdForSeason(firstSeason),
);
}
if (firstEpisode == null) {
+1 -2
View File
@@ -93,7 +93,7 @@ class JellyfinClient
_JellyfinLiveTvMethods,
_JellyfinImageDownloadMethods,
_JellyfinMetadataEditMethods
implements MediaServerClient, ScopedMediaServerClient, GracefullyCloseable {
implements MediaServerClient, SeasonEpisodePagingClient, ScopedMediaServerClient, GracefullyCloseable {
JellyfinClient._({required this._connection, required this._http, FavoriteChannelsRepository? favoritesRepository})
: _favoritesRepository = favoritesRepository ?? const SharedPreferencesFavoriteChannelsRepository();
@@ -364,4 +364,3 @@ class JellyfinClient
@override
ApiCache get cache => JellyfinApiCache.instance;
}
@@ -658,6 +658,39 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize);
}
@override
Future<LibraryPage<MediaItem>> fetchSeasonEpisodesPage(
String seriesId,
String seasonId, {
int? start,
int? size,
AbortController? abort,
}) async {
if (isOfflineMode) {
return fetchChildrenPage(seasonId, start: start, size: size, abort: abort);
}
final offset = start ?? 0;
final pageSize = size ?? _pagedListPageSize;
final response = await _http.get(
'/Shows/${_segment(seriesId)}/Episodes',
queryParameters: {
'userId': connection.userId,
'SeasonId': seasonId,
'StartIndex': offset.toString(),
'Limit': pageSize.toString(),
'EnableTotalRecordCount': 'true',
'IsMissing': 'false',
'IsVirtualUnaired': 'false',
'Fields': _episodeRowFields,
...jellyfinImageQueryParameters,
},
abort: abort,
);
throwIfHttpError(response);
return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize);
}
/// Jellyfin folder browsing mirrors Jellyfin Web/Findroid/Swiftfin: query
/// direct children of the library/folder with `Recursive=false`. This is
/// distinct from [fetchLibraryContent], which intentionally recurses through
@@ -890,6 +923,8 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
'Fields': _queueFields,
'StartIndex': '$startIndex',
'Limit': '$_episodeQueuePageSize',
'IsMissing': 'false',
'IsVirtualUnaired': 'false',
..._episodeOrderQueryParameters,
...jellyfinImageQueryParameters,
},
+12 -1
View File
@@ -203,7 +203,7 @@ bool? _parsePlexTranscoderVideoCapability(Object? value) {
class PlexClient
with MediaServerCacheMixin, _PlexLiveTvClientMethods
implements MediaServerClient, GracefullyCloseable {
implements MediaServerClient, SeasonEpisodePagingClient, GracefullyCloseable {
@override
PlexConfig config;
@@ -3335,6 +3335,17 @@ class PlexClient
);
}
@override
Future<LibraryPage<MediaItem>> fetchSeasonEpisodesPage(
String seriesId,
String seasonId, {
int? start,
int? size,
AbortController? abort,
}) {
return fetchChildrenPage(seasonId, start: start, size: size, abort: abort);
}
@override
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
final leaves = await _fetchAllPages(
+6 -2
View File
@@ -98,13 +98,17 @@ Future<List<MediaVersion>?> fetchRepresentativeVersions(MediaServerClient client
String? episodeRatingKey;
if (metadata.kind == MediaKind.season) {
final firstEpisode = await fetchFirstEpisodeForSeason(client, metadata.id);
final firstEpisode = await fetchFirstEpisodeForSeason(
client,
metadata.id,
seriesId: metadata.grandparentId ?? metadata.parentId,
);
episodeRatingKey = firstEpisode?.id;
} else if (metadata.kind == MediaKind.show) {
final seasons = await client.fetchChildren(metadata.id);
final firstSeason = defaultPlaybackSeason(seasons);
if (firstSeason != null) {
final firstEpisode = await fetchFirstEpisodeForSeason(client, firstSeason.id);
final firstEpisode = await fetchFirstEpisodeForSeason(client, firstSeason.id, seriesId: metadata.id);
episodeRatingKey = firstEpisode?.id;
}
}
@@ -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');