From fc231a9e72e52ab492693c85380b922ad272875a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 22 May 2026 05:24:10 +0200 Subject: [PATCH] fix(playlists): load paginated items mutably close #1100 --- .../playlist/playlist_detail_screen.dart | 6 +- lib/services/playlist_items_loader.dart | 8 +- lib/services/sync_rule_executor.dart | 4 +- test/screens/playlist_detail_screen_test.dart | 153 ++++++++++++++++++ .../jellyfin_sequential_launcher_test.dart | 9 +- 5 files changed, 169 insertions(+), 11 deletions(-) create mode 100644 test/screens/playlist_detail_screen_test.dart diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index be970c8f..80164e8a 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -42,7 +42,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen, GridFocusNodeMixin, FocusableDetailScreenMixin { - static const int _pageSize = 100; + static const int _pageSize = playlistItemsPageSize; @override Object get mediaItem => widget.playlist; @@ -193,7 +193,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen> fetchAllPlaylistItems(MediaServerClient client, String playlistId, {int pageSize = 100}) async { +Future> fetchAllPlaylistItems( + MediaServerClient client, + String playlistId, { + int pageSize = playlistItemsPageSize, +}) async { final all = []; var offset = 0; while (true) { diff --git a/lib/services/sync_rule_executor.dart b/lib/services/sync_rule_executor.dart index 864175b9..a6434243 100644 --- a/lib/services/sync_rule_executor.dart +++ b/lib/services/sync_rule_executor.dart @@ -402,9 +402,7 @@ class SyncRuleExecutor { return SyncRuleResult(globalKey: rule.globalKey, title: displayTitle, queuedCount: queued); } - /// Page through every item in a playlist; the neutral - /// [MediaServerClient.fetchPlaylistItems] caps each page (Plex at 100, - /// Jellyfin honours `limit`). + /// Page through every item in a playlist using the shared playlist page size. Future> _fetchAllPlaylistItems(MediaServerClient client, String playlistId) async { return fetchAllPlaylistItems(client, playlistId); } diff --git a/test/screens/playlist_detail_screen_test.dart b/test/screens/playlist_detail_screen_test.dart new file mode 100644 index 00000000..9d4bc4dc --- /dev/null +++ b/test/screens/playlist_detail_screen_test.dart @@ -0,0 +1,153 @@ +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/i18n/strings.g.dart'; +import 'package:plezy/media/library_query.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/media/media_playlist.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/media/server_capabilities.dart'; +import 'package:plezy/providers/download_provider.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/screens/playlist/playlist_detail_screen.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/download_manager_service.dart'; +import 'package:plezy/services/download_storage_service.dart'; +import 'package:plezy/services/jellyfin_api_cache.dart'; +import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/playlist_items_loader.dart'; +import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + LocaleSettings.setLocaleSync(AppLocale.en); + }); + + testWidgets('loads playlist continuation pages from an unmodifiable first page', (tester) async { + await SettingsService.getInstance(); + + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + JellyfinApiCache.initialize(db); + + final downloadManager = DownloadManagerService(database: db, storageService: DownloadStorageService.instance); + downloadManager.recoveryFuture = Future.value(); + final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db); + await downloadProvider.ensureInitialized(); + + final items = List.generate( + playlistItemsPageSize + 5, + (index) => MediaItem( + id: 'item_$index', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Item $index', + serverId: 'server_1', + serverName: 'Server', + ), + ); + final client = _PagedPlaylistClient(items); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + + addTearDown(() async { + downloadProvider.dispose(); + downloadManager.dispose(); + multiServerProvider.dispose(); + await db.close(); + }); + + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: multiServerProvider), + ChangeNotifierProvider.value(value: downloadProvider), + ], + child: MaterialApp( + theme: monoTheme(dark: true), + home: SizedBox( + width: 1280, + height: 720, + child: PlaylistDetailScreen( + playlist: const MediaPlaylist( + id: 'playlist_1', + backend: MediaBackend.plex, + title: 'Long Playlist', + playlistType: 'video', + serverId: 'server_1', + serverName: 'Server', + ), + ), + ), + ), + ), + ), + ); + + for (var i = 0; i < 10 && client.requestedStarts.length < 2; i++) { + await tester.pump(const Duration(milliseconds: 10)); + } + await tester.pumpAndSettle(); + + expect(client.requestedStarts, [0, playlistItemsPageSize]); + expect(client.requestedSizes, [playlistItemsPageSize, playlistItemsPageSize]); + expect(tester.takeException(), isNull); + + await tester.drag(find.byType(CustomScrollView), const Offset(0, -30000)); + await tester.pumpAndSettle(); + + expect(find.text('Item ${playlistItemsPageSize + 4}'), findsOneWidget); + expect(find.textContaining('Unsupported operation'), findsNothing); + expect(find.text(t.common.retry), findsNothing); + }); +} + +class _PagedPlaylistClient implements MediaServerClient { + final List items; + final List requestedStarts = []; + final List requestedSizes = []; + + _PagedPlaylistClient(this.items); + + @override + String get serverId => 'server_1'; + + @override + String? get serverName => 'Server'; + + @override + MediaBackend get backend => MediaBackend.plex; + + @override + ServerCapabilities get capabilities => ServerCapabilities.plex; + + @override + Future> fetchPlaylistPage(String id, {int? start, int? size, AbortController? abort}) async { + requestedStarts.add(start); + requestedSizes.add(size); + + final offset = start ?? 0; + final limit = size ?? items.length; + return LibraryPage(items: items.skip(offset).take(limit).toList(), totalCount: items.length, offset: offset); + } + + @override + void close() {} + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/test/services/jellyfin_sequential_launcher_test.dart b/test/services/jellyfin_sequential_launcher_test.dart index ba1f8615..983ec4dc 100644 --- a/test/services/jellyfin_sequential_launcher_test.dart +++ b/test/services/jellyfin_sequential_launcher_test.dart @@ -9,6 +9,7 @@ import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/jellyfin_sequential_launcher.dart'; import 'package:plezy/services/media_list_playback_launcher.dart'; +import 'package:plezy/services/playlist_items_loader.dart'; import 'package:plezy/utils/media_server_http_client.dart'; /// Recording fake that satisfies [JellyfinClient] via `implements` + @@ -205,9 +206,9 @@ void main() { testWidgets('playlist path pages through every item', (tester) async { final ctx = await pumpContext(tester); - // 150 items across 2 pages of 100 — the loop must keep paging until + // Enough items to span 2 default playlist pages — the loop must keep paging until // the server returns a short page. - final fetched = List.generate(150, (i) => _ep('p$i')); + final fetched = List.generate(playlistItemsPageSize + 50, (i) => _ep('p$i')); final fakeClient = _RecordingJellyfinClient(playlistItemsResponse: fetched); final playback = PlaybackStateProvider(); @@ -233,10 +234,10 @@ void main() { ); expect(result, isA()); - expect(playback.loadedItems.length, 150); + expect(playback.loadedItems.length, playlistItemsPageSize + 50); expect(fakeClient.fetchPlaylistItemsCalls, hasLength(2)); expect(fakeClient.fetchPlaylistItemsCalls.first.offset, 0); - expect(fakeClient.fetchPlaylistItemsCalls[1].offset, 100); + expect(fakeClient.fetchPlaylistItemsCalls[1].offset, playlistItemsPageSize); }); testWidgets('collection containing a Series entry only seeds playable descendants', (tester) async {