diff --git a/lib/media/episode_collection.dart b/lib/media/episode_collection.dart index c138842d..e5b1e210 100644 --- a/lib/media/episode_collection.dart +++ b/lib/media/episode_collection.dart @@ -39,8 +39,15 @@ Future 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 fetchFirstEpisodeForSeason(MediaServerClient client, String seasonRatingKey) async { - final page = await client.fetchChildrenPage(seasonRatingKey, start: 0, size: 1); +Future 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> fetchSeasonEpisodePage( MediaServerClient client, { required MediaItem show, @@ -145,7 +152,10 @@ Future> 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( items: normalizeSeasonEpisodes(page.items, show: show, season: season), totalCount: page.totalCount, @@ -159,6 +169,7 @@ List normalizeSeasonEpisodes( required MediaItem season, }) { return episodes + .where((episode) => episode.kind == MediaKind.episode) .map( (episode) => _withFallbackLibrary( episode.copyWith( diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index d3983a4e..23b068be 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -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> 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. /// diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 28394440..19bb0265 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -2769,6 +2769,11 @@ class _MediaDetailScreenState extends State return null; } + String? _seriesIdForSeason(MediaItem season) { + if (_metadata.isShow) return (_fullMetadata ?? _metadata).id; + return season.grandparentId ?? season.parentId ?? _metadata.grandparentId ?? _metadata.parentId; + } + Future> _fetchFlattenedEpisodePage( MediaServerClient client, ServerId serverId, { @@ -2777,7 +2782,11 @@ class _MediaDetailScreenState extends State }) 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( items: _enrichDirectSeasonEpisodes(page.items, season: season, serverId: serverId), totalCount: page.totalCount, @@ -2991,7 +3000,11 @@ class _MediaDetailScreenState extends State } 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) { diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 69364ed9..db0d1c9a 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -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; } - diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index db0a2199..9ad2b99b 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -658,6 +658,39 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize); } + @override + Future> 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, }, diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 37b40ef5..675ae0a6 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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> fetchSeasonEpisodesPage( + String seriesId, + String seasonId, { + int? start, + int? size, + AbortController? abort, + }) { + return fetchChildrenPage(seasonId, start: start, size: size, abort: abort); + } + @override Future> fetchPlayableDescendants(String parentId) async { final leaves = await _fetchAllPages( diff --git a/lib/utils/download_version_utils.dart b/lib/utils/download_version_utils.dart index 82ba52c9..a5d53e54 100644 --- a/lib/utils/download_version_utils.dart +++ b/lib/utils/download_version_utils.dart @@ -98,13 +98,17 @@ Future?> 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; } } diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 31efb302..53c54c72 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -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 { diff --git a/test/utils/episode_collection_test.dart b/test/utils/episode_collection_test.dart index f079bf0f..9fd9b709 100644 --- a/test/utils/episode_collection_test.dart +++ b/test/utils/episode_collection_test.dart @@ -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> seasonPageBySeason; + final seasonEpisodePageCalls = <({String seriesId, String seasonId, int? start, int? size})>[]; + + @override + Future> 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 []; + final offset = start ?? 0; + final limit = size ?? all.length; + final end = (offset + limit).clamp(0, all.length).toInt(); + final items = offset >= all.length ? const [] : 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');