@@ -28,6 +28,7 @@ mixin PaginatedItemLoader<T, W extends StatefulWidget> on State<W> {
|
||||
|
||||
final Set<int> _loadingRanges = {};
|
||||
AbortController? _cancelToken;
|
||||
Object? _paginationError;
|
||||
|
||||
/// Monotonic generation — bumped on reset/dispose so stale fetches are
|
||||
/// discarded instead of mutating state from a prior load.
|
||||
@@ -37,6 +38,8 @@ mixin PaginatedItemLoader<T, W extends StatefulWidget> on State<W> {
|
||||
Timer? _retryTimer;
|
||||
bool _visibleRangeLoading = false;
|
||||
DateTime? _lastEagerPrefetch;
|
||||
Object? get paginationError => _paginationError;
|
||||
bool get isPaginationLoading => _loadingRanges.isNotEmpty;
|
||||
|
||||
/// Re-invoked by the retry timer. Most recent range-load args.
|
||||
VoidCallback? _scheduledRetry;
|
||||
@@ -49,6 +52,10 @@ mixin PaginatedItemLoader<T, W extends StatefulWidget> on State<W> {
|
||||
/// Override for image prefetch, syncing a base-class `items` list, etc.
|
||||
void onPageLoaded(int _, List<T> _) {}
|
||||
|
||||
/// Hook fired when a lazy page starts or finishes loading, or fails.
|
||||
/// Override when the surrounding UI exposes loading or retry state.
|
||||
void onPaginationStateChanged() {}
|
||||
|
||||
/// Synchronously clear pagination state and bump the generation counter.
|
||||
/// Call from inside the subclass's `setState` before awaiting
|
||||
/// [loadInitialPage]. Aborts any in-flight fetches from the previous load.
|
||||
@@ -61,6 +68,7 @@ mixin PaginatedItemLoader<T, W extends StatefulWidget> on State<W> {
|
||||
_visibleRangeLoading = false;
|
||||
_lastEagerPrefetch = null;
|
||||
_scheduledRetry = null;
|
||||
_paginationError = null;
|
||||
loadedItems.clear();
|
||||
_loadingRanges.clear();
|
||||
totalSize = 0;
|
||||
@@ -265,6 +273,7 @@ mixin PaginatedItemLoader<T, W extends StatefulWidget> on State<W> {
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = null;
|
||||
_loadingRanges.clear();
|
||||
_paginationError = null;
|
||||
_scheduledRetry = null;
|
||||
}
|
||||
|
||||
@@ -276,6 +285,8 @@ mixin PaginatedItemLoader<T, W extends StatefulWidget> on State<W> {
|
||||
final indices = List.generate(clampedSize, (i) => start + i);
|
||||
if (indices.every((i) => _loadingRanges.contains(i) || loadedItems.containsKey(i))) return true;
|
||||
_loadingRanges.addAll(indices);
|
||||
_paginationError = null;
|
||||
onPaginationStateChanged();
|
||||
|
||||
final generation = _requestId;
|
||||
|
||||
@@ -295,6 +306,7 @@ mixin PaginatedItemLoader<T, W extends StatefulWidget> on State<W> {
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e is MediaServerHttpException && e.type == MediaServerHttpErrorType.cancelled) return false;
|
||||
_paginationError = e;
|
||||
_retryCount++;
|
||||
final delay = Duration(milliseconds: 500 * (1 << _retryCount.clamp(0, 4)));
|
||||
_retryTimer?.cancel();
|
||||
@@ -304,6 +316,7 @@ mixin PaginatedItemLoader<T, W extends StatefulWidget> on State<W> {
|
||||
return false;
|
||||
} finally {
|
||||
_loadingRanges.removeAll(indices);
|
||||
onPaginationStateChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../utils/app_logger.dart';
|
||||
import '../utils/continuation_pagination_coordinator.dart';
|
||||
import '../utils/error_message_utils.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../utils/media_server_http_client.dart';
|
||||
import '../utils/plex_library_section_utils.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../widgets/focusable_media_card.dart';
|
||||
@@ -28,6 +29,7 @@ import '../focus/focusable_action_bar.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../mixins/grid_focus_node_mixin.dart';
|
||||
import '../mixins/paginated_item_loader.dart';
|
||||
import 'libraries/sort_bottom_sheet.dart';
|
||||
import 'libraries/content_state_builder.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
@@ -56,7 +58,7 @@ class HubDetailScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
with Refreshable, GridFocusNodeMixin, FocusableDetailScreenMixin {
|
||||
with Refreshable, GridFocusNodeMixin, FocusableDetailScreenMixin, PaginatedItemLoader<MediaItem, HubDetailScreen> {
|
||||
static const int _pageSize = 200;
|
||||
|
||||
List<MediaItem> _items = [];
|
||||
@@ -67,6 +69,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
bool _replaceContinuationItems = false;
|
||||
bool _usesPaginatedLoader = false;
|
||||
|
||||
late final ContinuationPaginationCoordinator<MediaItem> _continuation = ContinuationPaginationCoordinator<MediaItem>(
|
||||
loadPage: _fetchContinuationPage,
|
||||
@@ -111,6 +114,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
scrollController.addListener(_maybeLoadNextHubPage);
|
||||
_items = widget.hub.items;
|
||||
_filteredItems = widget.hub.items;
|
||||
if (widget.hub.more) {
|
||||
@@ -122,6 +126,8 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
scrollController.removeListener(_maybeLoadNextHubPage);
|
||||
disposePagination();
|
||||
_continuation.dispose();
|
||||
_continuationRetryFocusNode.dispose();
|
||||
disposeFocusResources();
|
||||
@@ -269,34 +275,70 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
});
|
||||
}
|
||||
|
||||
bool _shouldUsePaginatedLoader(MediaServerClient client) =>
|
||||
client.backend == MediaBackend.jellyfin && widget.hub.id.endsWith('.recent');
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchPage(int start, int size, AbortController? abort) async {
|
||||
final serverId = widget.hub.serverId;
|
||||
final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId));
|
||||
if (client == null) throw StateError('No media client available for paginated hub');
|
||||
return client.fetchMoreHubItemsPage(widget.hub.id, start: start, size: size, abort: abort);
|
||||
}
|
||||
|
||||
@override
|
||||
void onPageLoaded(int start, List<MediaItem> items) {
|
||||
if (!_usesPaginatedLoader || start == 0 || !mounted) return;
|
||||
setState(() {
|
||||
_items = List.of(_items)..addAll(items);
|
||||
_filteredItems = List.of(_items);
|
||||
});
|
||||
_applySort();
|
||||
_scheduleNextHubPageCheck();
|
||||
}
|
||||
|
||||
@override
|
||||
void onPaginationStateChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _loadMoreItems() async {
|
||||
if (_isLoading) return;
|
||||
|
||||
final serverId = widget.hub.serverId;
|
||||
if (widget.loadItems == null && serverId == null) {
|
||||
final loader = widget.loadItems;
|
||||
if (loader == null && serverId == null) {
|
||||
appLogger.w('Hub has no serverId; cannot load more items for ${widget.hub.id}');
|
||||
return;
|
||||
}
|
||||
|
||||
final client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId));
|
||||
final usesCustomLoader = loader != null;
|
||||
_usesPaginatedLoader = !usesCustomLoader && client != null && _shouldUsePaginatedLoader(client);
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
if (_usesPaginatedLoader) resetPaginationState();
|
||||
});
|
||||
|
||||
try {
|
||||
List<MediaItem> items = const [];
|
||||
var totalCount = 0;
|
||||
var loadedCount = 0;
|
||||
var usesCustomLoader = false;
|
||||
MediaServerClient? client;
|
||||
var initialPageApplied = true;
|
||||
final applied = await _continuation.runNewGeneration(() async {
|
||||
final loader = widget.loadItems;
|
||||
usesCustomLoader = loader != null;
|
||||
client = serverId == null ? null : context.tryGetMediaClientForServer(ServerId(serverId));
|
||||
if (loader == null) {
|
||||
if (_usesPaginatedLoader) {
|
||||
final result = await loadInitialPageWithStatus(_pageSize);
|
||||
initialPageApplied = result.applied;
|
||||
if (!result.applied) return;
|
||||
items = result.page.items;
|
||||
totalCount = result.page.totalCount;
|
||||
loadedCount = result.page.items.length;
|
||||
} else if (loader == null) {
|
||||
final page = client == null
|
||||
? const LibraryPage<MediaItem>(items: [], totalCount: 0)
|
||||
: await client!.fetchMoreHubItemsPage(widget.hub.id, start: 0, size: _pageSize);
|
||||
: await client.fetchMoreHubItemsPage(widget.hub.id, start: 0, size: _pageSize);
|
||||
items = _applySectionFilter(page.items);
|
||||
totalCount = page.totalCount;
|
||||
loadedCount = page.items.length;
|
||||
@@ -307,7 +349,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
}
|
||||
});
|
||||
|
||||
if (!mounted || !applied) return;
|
||||
if (!mounted || !applied || !initialPageApplied) return;
|
||||
setState(() {
|
||||
_items = List.of(items);
|
||||
_filteredItems = List.of(items);
|
||||
@@ -315,14 +357,16 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
});
|
||||
|
||||
_applySort();
|
||||
if (!usesCustomLoader && client != null && loadedCount < totalCount) {
|
||||
_replaceContinuationItems = client!.backend == MediaBackend.plex;
|
||||
if (!usesCustomLoader && !_usesPaginatedLoader && client != null && loadedCount < totalCount) {
|
||||
_replaceContinuationItems = client.backend == MediaBackend.plex;
|
||||
if (_replaceContinuationItems) {
|
||||
_continuation.setContinuation(startIndex: 0, totalCount: 1);
|
||||
} else {
|
||||
_continuation.setContinuation(startIndex: loadedCount, totalCount: totalCount);
|
||||
}
|
||||
unawaited(_continuation.loadRemaining());
|
||||
} else if (_usesPaginatedLoader && loadedCount < totalCount) {
|
||||
_scheduleNextHubPageCheck();
|
||||
}
|
||||
|
||||
appLogger.d('Loaded ${items.length} items for hub: ${widget.hub.title}');
|
||||
@@ -370,13 +414,51 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
_applySort();
|
||||
}
|
||||
|
||||
void _scheduleNextHubPageCheck() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _maybeLoadNextHubPage();
|
||||
});
|
||||
}
|
||||
|
||||
void _maybeLoadNextHubPage() {
|
||||
if (!_usesPaginatedLoader ||
|
||||
loadedItems.length >= totalSize ||
|
||||
isPaginationLoading ||
|
||||
paginationError != null ||
|
||||
!scrollController.hasClients) {
|
||||
return;
|
||||
}
|
||||
final position = scrollController.position;
|
||||
if (position.extentAfter <= position.viewportDimension) {
|
||||
_requestNextHubPage();
|
||||
}
|
||||
}
|
||||
|
||||
void _requestNextHubPage() {
|
||||
if (!_usesPaginatedLoader || loadedItems.length >= totalSize || isPaginationLoading || paginationError != null) {
|
||||
return;
|
||||
}
|
||||
ensureIndexLoaded(loadedItems.length, pageSize: _pageSize);
|
||||
}
|
||||
|
||||
void _handleGridItemFocusChange(int index, bool hasFocus, {required bool isLastRow}) {
|
||||
trackGridItemFocus(index, hasFocus);
|
||||
if (hasFocus && isLastRow) _requestNextHubPage();
|
||||
}
|
||||
|
||||
void _handleContinuationStateChanged() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _retryHubContinuation() => unawaited(_continuation.retry());
|
||||
void _retryHubContinuation() {
|
||||
if (_usesPaginatedLoader) {
|
||||
ensureIndexLoaded(loadedItems.length, pageSize: _pageSize);
|
||||
} else {
|
||||
unawaited(_continuation.retry());
|
||||
}
|
||||
}
|
||||
|
||||
List<MediaItem> _applySectionFilter(List<MediaItem> items) {
|
||||
final sectionFilter = int.tryParse(widget.hub.libraryId ?? '');
|
||||
@@ -408,8 +490,11 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
unawaited(_loadMoreItems());
|
||||
}
|
||||
|
||||
Object? get _pageLoadError => _usesPaginatedLoader ? paginationError : _continuation.error;
|
||||
bool get _isLoadingPage => _usesPaginatedLoader ? isPaginationLoading : _continuation.isLoading;
|
||||
|
||||
Widget _buildContinuationStatusSliver() {
|
||||
final exception = _continuation.error;
|
||||
final exception = _pageLoadError;
|
||||
final error = exception == null ? null : t.messages.errorLoading(error: exception.toString());
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
@@ -530,13 +615,16 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
usesContinueWatchingAction: widget.usesContinueWatchingAction,
|
||||
onNavigateUp: position.isFirstRow ? navigateToAppBar : null,
|
||||
onNavigateDown:
|
||||
_continuation.error != null &&
|
||||
position.index >= position.itemCount - position.columnCount
|
||||
_pageLoadError != null && position.index >= position.itemCount - position.columnCount
|
||||
? _continuationRetryFocusNode.requestFocus
|
||||
: null,
|
||||
onNavigateLeft: position.isGrid && position.isFirstColumn ? () {} : null,
|
||||
onBack: handleBackFromContent,
|
||||
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
|
||||
onFocusChange: (hasFocus) => _handleGridItemFocusChange(
|
||||
index,
|
||||
hasFocus,
|
||||
isLastRow: position.index >= position.itemCount - position.columnCount,
|
||||
),
|
||||
mixedHubContext: isMixedHub,
|
||||
fullBleedImage: fullCardLayout && position.isGrid,
|
||||
);
|
||||
@@ -544,7 +632,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
);
|
||||
},
|
||||
),
|
||||
if (_filteredItems.isNotEmpty && (_continuation.isLoading || _continuation.error != null))
|
||||
if (_filteredItems.isNotEmpty && (_isLoadingPage || _pageLoadError != null))
|
||||
_buildContinuationStatusSliver(),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1511,13 +1511,12 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
].where((h) => h.items.isNotEmpty).toList();
|
||||
}
|
||||
|
||||
/// Re-run the synthetic hub query without the preview limit so the
|
||||
/// hub-detail screen can render the full list. Branches on the
|
||||
/// identifier emitted by [fetchGlobalHubs] / [fetchLibraryHubs]:
|
||||
/// `home.recent` / `library.{id}.recent` → Latest, `*.latestalbums` →
|
||||
/// Latest with the slim music album fields, `*.continue` → Resume,
|
||||
/// `*.nextup` → NextUp, `*.recentlyplayed` / `*.mostplayed` → the music
|
||||
/// played-track queries. Unknown ids return an empty list.
|
||||
/// Expand a synthetic hub so the detail screen can render beyond its
|
||||
/// preview. Recently Added uses the pageable Items endpoint with the same
|
||||
/// date-created ordering and media types as Jellyfin's Latest query.
|
||||
/// Latest Albums retains the grouped, single-page Latest endpoint.
|
||||
/// Continue Watching, Next Up, Recently Played, and Most Played use their
|
||||
/// native pageable endpoints. Unknown ids return an empty list.
|
||||
@override
|
||||
Future<List<MediaItem>> fetchMoreHubItems(String hubId, {int? limit}) async {
|
||||
try {
|
||||
@@ -1551,19 +1550,35 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
||||
final tail = hubId.split('.').last;
|
||||
switch (tail) {
|
||||
case 'recent':
|
||||
return _safeFetchMediaPage(
|
||||
'/Items',
|
||||
{
|
||||
'userId': connection.userId,
|
||||
'ParentId': ?parentId,
|
||||
'Recursive': 'true',
|
||||
'StartIndex': offset.toString(),
|
||||
'Limit': effectiveLimit,
|
||||
'EnableTotalRecordCount': 'true',
|
||||
'IncludeItemTypes': 'Movie,Series,Episode,Video,MusicVideo,Photo',
|
||||
'SortBy': 'DateCreated,SortName,ProductionYear',
|
||||
'SortOrder': 'Descending,Descending,Descending',
|
||||
'Fields': _browseFields,
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
offset: offset,
|
||||
requestedSize: pageSize,
|
||||
abort: abort,
|
||||
);
|
||||
case 'latestalbums':
|
||||
// Jellyfin's Latest endpoint has a Limit but no StartIndex. Expose it
|
||||
// as one bounded page so callers don't infer endless fake pages.
|
||||
// Music album rows keep the slim fields their preview row used
|
||||
// (see [_musicAlbumRowFields]).
|
||||
// Latest groups music into albums but does not expose StartIndex.
|
||||
if (offset > 0) return LibraryPage<MediaItem>(items: const [], totalCount: offset, offset: offset);
|
||||
return _safeFetchMediaPage(
|
||||
'/Users/${_segment(connection.userId)}/Items/Latest',
|
||||
{
|
||||
'Limit': effectiveLimit,
|
||||
'Fields': tail == 'latestalbums' ? _musicAlbumRowFields : _browseFields,
|
||||
if (tail == 'latestalbums') 'EnableUserData': 'false',
|
||||
if (parentId != null) 'ParentId': parentId else 'IncludeItemTypes': 'Movie,Series,Episode',
|
||||
'Fields': _musicAlbumRowFields,
|
||||
'EnableUserData': 'false',
|
||||
'ParentId': ?parentId,
|
||||
...jellyfinImageQueryParameters,
|
||||
},
|
||||
offset: offset,
|
||||
|
||||
@@ -272,9 +272,13 @@ void main() {
|
||||
// Drain the failed Future, then advance past the retry timer's 1s delay
|
||||
// so the timer fires and re-invokes ensureIndexLoaded.
|
||||
await tester.pump();
|
||||
expect(state.paginationError, isA<MediaServerHttpException>());
|
||||
expect(state.isPaginationLoading, isFalse);
|
||||
await tester.pump(const Duration(milliseconds: 1100));
|
||||
// Drain the retry's Future.
|
||||
await tester.pump();
|
||||
expect(state.paginationError, isNull);
|
||||
expect(state.isPaginationLoading, isFalse);
|
||||
|
||||
expect(state.loadedItems.containsKey(220), isTrue);
|
||||
expect(rangeAttempt, greaterThanOrEqualTo(2)); // failed + retry
|
||||
|
||||
@@ -42,7 +42,7 @@ void main() {
|
||||
harness.wrap(
|
||||
HubDetailScreen(
|
||||
hub: MediaHub(
|
||||
id: 'home.recent',
|
||||
id: 'home.continue',
|
||||
title: 'Recent',
|
||||
type: 'movie',
|
||||
items: items.take(5).toList(),
|
||||
@@ -66,6 +66,39 @@ void main() {
|
||||
expect(find.text('Item 203'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Jellyfin Recently Added fetches every page only as the user reaches the end', (tester) async {
|
||||
final items = List.generate(450, (index) => _item(index, backend: MediaBackend.jellyfin));
|
||||
final harness = await _createHarness(items, backend: MediaBackend.jellyfin);
|
||||
|
||||
await tester.pumpWidget(
|
||||
harness.wrap(
|
||||
HubDetailScreen(
|
||||
hub: MediaHub(
|
||||
id: 'home.recent',
|
||||
title: 'Recently Added',
|
||||
type: 'mixed',
|
||||
items: items.take(20).toList(),
|
||||
size: items.length,
|
||||
more: true,
|
||||
serverId: 'server_1',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(harness.client.requestedStarts, [0]);
|
||||
expect(harness.client.requestedSizes, [200]);
|
||||
|
||||
await tester.drag(find.byType(CustomScrollView), const Offset(0, -50000));
|
||||
await tester.pumpAndSettle();
|
||||
expect(harness.client.requestedStarts, [0, 200, 400]);
|
||||
expect(harness.client.requestedSizes, [200, 200, 50]);
|
||||
await tester.drag(find.byType(CustomScrollView), const Offset(0, -50000));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Item 449'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('Plex hub replaces its preview with the full-hub response', (tester) async {
|
||||
final items = List.generate(205, (index) => _item(index, backend: MediaBackend.plex));
|
||||
final harness = await _createHarness(items, backend: MediaBackend.plex);
|
||||
@@ -138,6 +171,7 @@ class _PagedHubClient implements MediaServerClient {
|
||||
|
||||
final List<MediaItem> items;
|
||||
final List<int?> requestedStarts = [];
|
||||
final List<int?> requestedSizes = [];
|
||||
int fullHubRequests = 0;
|
||||
|
||||
@override
|
||||
@@ -161,6 +195,7 @@ class _PagedHubClient implements MediaServerClient {
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
requestedStarts.add(start);
|
||||
requestedSizes.add(size);
|
||||
return fakeLibraryPage(items, start: start, size: size);
|
||||
}
|
||||
|
||||
|
||||
@@ -3142,14 +3142,20 @@ void main() {
|
||||
return JellyfinClient.forTesting(connection: _conn(), httpClient: mock);
|
||||
}
|
||||
|
||||
test('global "home.recent" hits /Users/{userId}/Items/Latest with provided limit', () async {
|
||||
test('global "home.recent" uses the pageable Items catalogue with the provided limit', () async {
|
||||
final client = buildClient();
|
||||
await client.fetchMoreHubItems('home.recent', limit: 80);
|
||||
|
||||
expect(captured, isNotNull);
|
||||
expect(captured!.path, '/Users/user-1/Items/Latest');
|
||||
expect(captured!.path, '/Items');
|
||||
expect(captured!.queryParameters['userId'], 'user-1');
|
||||
expect(captured!.queryParameters['StartIndex'], '0');
|
||||
expect(captured!.queryParameters['Limit'], '80');
|
||||
expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode');
|
||||
expect(captured!.queryParameters['Recursive'], 'true');
|
||||
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
|
||||
expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode,Video,MusicVideo,Photo');
|
||||
expect(captured!.queryParameters['SortBy'], 'DateCreated,SortName,ProductionYear');
|
||||
expect(captured!.queryParameters['SortOrder'], 'Descending,Descending,Descending');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(captured!.queryParameters['ImageTypeLimit'], '3');
|
||||
expect(captured!.queryParameters.containsKey('ParentId'), isFalse);
|
||||
@@ -3192,19 +3198,21 @@ void main() {
|
||||
client.close();
|
||||
});
|
||||
|
||||
test('library-scoped "library.{id}.recent" forwards ParentId to Latest', () async {
|
||||
test('library-scoped "library.{id}.recent" pages Items within its parent', () async {
|
||||
final client = buildClient();
|
||||
await client.fetchMoreHubItems('library.lib-99.recent', limit: 30);
|
||||
|
||||
expect(captured, isNotNull);
|
||||
expect(captured!.path, '/Users/user-1/Items/Latest');
|
||||
expect(captured!.path, '/Items');
|
||||
expect(captured!.queryParameters['userId'], 'user-1');
|
||||
expect(captured!.queryParameters['ParentId'], 'lib-99');
|
||||
expect(captured!.queryParameters['StartIndex'], '0');
|
||||
expect(captured!.queryParameters['Limit'], '30');
|
||||
expect(captured!.queryParameters['Recursive'], 'true');
|
||||
expect(captured!.queryParameters['EnableTotalRecordCount'], 'true');
|
||||
expect(captured!.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode,Video,MusicVideo,Photo');
|
||||
expect(captured!.queryParameters['EnableImageTypes'], 'Primary,Backdrop,Thumb,Logo');
|
||||
expect(captured!.queryParameters['ImageTypeLimit'], '3');
|
||||
// ParentId-scoped Latest should NOT also pin IncludeItemTypes (the
|
||||
// library already constrains the kinds returned).
|
||||
expect(captured!.queryParameters.containsKey('IncludeItemTypes'), isFalse);
|
||||
client.close();
|
||||
});
|
||||
|
||||
@@ -3313,23 +3321,37 @@ void main() {
|
||||
expect(requestUri!.queryParameters['EnableTotalRecordCount'], 'true');
|
||||
});
|
||||
|
||||
test('Latest hub is treated as a single page when offset is requested', () async {
|
||||
var requestCount = 0;
|
||||
test('paged Recently Added sends the requested offset and parses total count', () async {
|
||||
Uri? requestUri;
|
||||
final client = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((req) async {
|
||||
requestCount++;
|
||||
return http.Response('[]', 200, headers: {'content-type': 'application/json'});
|
||||
requestUri = req.url;
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'TotalRecordCount': 321,
|
||||
'Items': [
|
||||
{'Id': 'recent-20', 'Name': 'Recent', 'Type': 'Movie'},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
final page = await client.fetchMoreHubItemsPage('home.recent', start: 20, size: 10);
|
||||
|
||||
expect(page.items, isEmpty);
|
||||
expect(page.totalCount, 20);
|
||||
expect(page.items.single.id, 'recent-20');
|
||||
expect(page.totalCount, 321);
|
||||
expect(page.offset, 20);
|
||||
expect(requestCount, 0);
|
||||
expect(requestUri, isNotNull);
|
||||
expect(requestUri!.path, '/Items');
|
||||
expect(requestUri!.queryParameters['StartIndex'], '20');
|
||||
expect(requestUri!.queryParameters['Limit'], '10');
|
||||
expect(requestUri!.queryParameters['EnableTotalRecordCount'], 'true');
|
||||
expect(requestUri!.queryParameters.containsKey('ParentId'), isFalse);
|
||||
});
|
||||
|
||||
test('paged hub first-page errors throw while list helper keeps empty fallback', () async {
|
||||
|
||||
Reference in New Issue
Block a user