From 8f862d1ca02e97fc18dcdabf5987d601d232f84d Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 13 Jun 2026 17:12:48 +0200 Subject: [PATCH] fix(jellyfin): sort series episodes close #1329 --- lib/screens/media_detail_screen.dart | 73 +++++++++++++++++-- .../jellyfin_client/parts/browse.dart | 7 ++ test/services/jellyfin_client_urls_test.dart | 45 ++++++++++++ 3 files changed, 119 insertions(+), 6 deletions(-) diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 446ceb3f..28394440 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -22,6 +22,7 @@ import '../focus/key_event_utils.dart'; import '../focus/input_mode_tracker.dart'; import '../focus/card_focus_scope.dart'; import '../widgets/focus_builders.dart'; +import '../media/library_query.dart'; import '../media/media_hub.dart'; import '../utils/provider_extensions.dart'; import '../utils/plex_season_display.dart'; @@ -2739,11 +2740,10 @@ class _MediaDetailScreenState extends State _hasLoadedEpisodes = false; }); try { - final firstPage = await client.fetchPlayableDescendantsPage(_metadata.id, start: 0, size: _episodesPageSize); + final firstPage = await _fetchFlattenedEpisodePage(client, ServerId(serverId), start: 0, size: _episodesPageSize); if (!mounted || generation != _episodesLoadGeneration) return; - final enriched = _enrichPlayableEpisodes(firstPage.items, ServerId(serverId)); setStateIfMounted(() { - _allEpisodes = _allEpisodes.completeInitialLoad(enriched, firstPage.totalCount); + _allEpisodes = _allEpisodes.completeInitialLoad(firstPage.items, firstPage.totalCount); _episodes = _allEpisodes.items; _hasLoadedEpisodes = true; }); @@ -2756,6 +2756,68 @@ class _MediaDetailScreenState extends State } } + MediaItem? get _flattenedSeasonForDirectEpisodePaging { + if (_metadata.isSeason) { + if (_seasons.length == 1 && _seasons.first.isSeason) return _seasons.first; + final full = _fullMetadata; + if (full != null && full.isSeason) return full; + return _metadata; + } + if (_showEpisodesDirectly && _seasons.length == 1 && _seasons.first.isSeason) { + return _seasons.first; + } + return null; + } + + Future> _fetchFlattenedEpisodePage( + MediaServerClient client, + ServerId serverId, { + required int start, + required int size, + }) async { + final season = _flattenedSeasonForDirectEpisodePaging; + if (season != null) { + final page = await client.fetchChildrenPage(season.id, start: start, size: size); + return LibraryPage( + items: _enrichDirectSeasonEpisodes(page.items, season: season, serverId: serverId), + totalCount: page.totalCount, + offset: page.offset, + ); + } + + final page = await client.fetchPlayableDescendantsPage(_metadata.id, start: start, size: size); + return LibraryPage( + items: _enrichPlayableEpisodes(page.items, serverId), + totalCount: page.totalCount, + offset: page.offset, + ); + } + + List _enrichDirectSeasonEpisodes( + List episodes, { + required MediaItem season, + required ServerId serverId, + }) { + if (_metadata.isShow) { + return normalizeSeasonEpisodes(episodes, show: _fullMetadata ?? _metadata, season: season); + } + + return _enrichPlayableEpisodes(episodes, serverId) + .map( + (episode) => _withFallbackLibrary( + episode.copyWith( + parentId: episode.parentId ?? season.id, + parentTitle: episode.parentTitle ?? season.title, + parentIndex: episode.parentIndex ?? season.index, + grandparentId: episode.grandparentId ?? season.grandparentId ?? season.parentId, + grandparentTitle: episode.grandparentTitle ?? season.grandparentTitle ?? season.parentTitle, + ), + season, + ), + ) + .toList(); + } + List _enrichPlayableEpisodes(List episodes, ServerId serverId) { // Enrich each episode with serverId/serverName/parent fields — backends // don't always populate them on recursive queries, and hierarchy-aware @@ -2795,13 +2857,12 @@ class _MediaDetailScreenState extends State _allEpisodes = _allEpisodes.startLoadMore(); }); try { - final page = await client.fetchPlayableDescendantsPage(_metadata.id, start: offset, size: _episodesPageSize); + final page = await _fetchFlattenedEpisodePage(client, ServerId(serverId), start: offset, size: _episodesPageSize); if (!mounted || generation != _episodesLoadGeneration) return; - final enriched = _enrichPlayableEpisodes(page.items, ServerId(serverId)); setStateIfMounted(() { _allEpisodes = _allEpisodes.completeLoadMore( expectedOffset: offset, - pageItems: enriched, + pageItems: page.items, total: page.totalCount, ); _episodes = _allEpisodes.items; diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 5b1e00d5..db0a2199 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -90,6 +90,10 @@ const _childrenPageSize = 500; const _pagedListPageSize = 200; const _playableDescendantTypes = 'Movie,Episode'; const _playableFolderDescendantTypes = 'Movie,Episode,Video,MusicVideo'; +const _episodeOrderQueryParameters = { + 'SortBy': 'ParentIndexNumber,IndexNumber,SortName', + 'SortOrder': 'Ascending,Ascending,Ascending', +}; bool _isJellyfinFolderDto(Map item) { final type = (item['Type'] as String?)?.toLowerCase(); @@ -551,6 +555,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { 'Fields': _episodeRowFields, 'StartIndex': '$startIndex', 'Limit': '$_childrenPageSize', + ..._episodeOrderQueryParameters, ...jellyfinImageQueryParameters, }, ); @@ -644,6 +649,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { 'Limit': pageSize.toString(), 'EnableTotalRecordCount': 'true', 'Fields': _episodeRowFields, + ..._episodeOrderQueryParameters, ...jellyfinImageQueryParameters, }, abort: abort, @@ -884,6 +890,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { 'Fields': _queueFields, 'StartIndex': '$startIndex', 'Limit': '$_episodeQueuePageSize', + ..._episodeOrderQueryParameters, ...jellyfinImageQueryParameters, }, ); diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 555d30b8..31efb302 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -194,6 +194,8 @@ void main() { 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 { @@ -1787,10 +1789,14 @@ void main() { test('fetchClientSideEpisodeQueue pages past the first 200 episodes', () async { final starts = []; + final sortBy = []; + final sortOrder = []; 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); @@ -1817,6 +1823,8 @@ void main() { 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 { @@ -2898,6 +2906,43 @@ void main() { expect(requestUri!.queryParameters['Limit'], '10'); }); + test('fetchChildrenPage orders direct episode children by season and episode index', () async { + Uri? requestUri; + final mock = MockClient((req) async { + if (req.url.path == '/Shows/season-1/Seasons') { + return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + } + if (req.url.path == '/Items') { + 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.fetchChildrenPage('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['ParentId'], 'season-1'); + expect(requestUri!.queryParameters['StartIndex'], '20'); + expect(requestUri!.queryParameters['Limit'], '10'); + expect(requestUri!.queryParameters['SortBy'], 'ParentIndexNumber,IndexNumber,SortName'); + expect(requestUri!.queryParameters['SortOrder'], 'Ascending,Ascending,Ascending'); + }); + test('fetchPlayableFolderDescendants includes generic video but excludes audio', () async { Uri? requestUri; final mock = MockClient((req) async {