fix(jellyfin): use season episode endpoint
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user