fix(jellyfin): sort series episodes

close #1329
This commit is contained in:
edde746
2026-06-13 17:12:48 +02:00
parent b56608f94f
commit 8f862d1ca0
3 changed files with 119 additions and 6 deletions
+67 -6
View File
@@ -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<MediaDetailScreen>
_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<MediaDetailScreen>
}
}
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<LibraryPage<MediaItem>> _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<MediaItem>(
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<MediaItem>(
items: _enrichPlayableEpisodes(page.items, serverId),
totalCount: page.totalCount,
offset: page.offset,
);
}
List<MediaItem> _enrichDirectSeasonEpisodes(
List<MediaItem> 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<MediaItem> _enrichPlayableEpisodes(List<MediaItem> 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<MediaDetailScreen>
_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;
@@ -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<String, dynamic> 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,
},
);
@@ -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 = <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);
@@ -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': <Object>[]}), 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 {