diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index f45c1dad..51c92884 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -22,6 +22,9 @@ import 'media_playlist.dart'; import 'playback_report_metadata.dart'; import 'server_capabilities.dart'; +/// Default number of items requested for horizontal hub previews. +const int defaultHubPreviewLimit = 20; + /// Backend-neutral client for a single media server (Plex or Jellyfin). /// /// Each implementation wraps the per-backend HTTP layer and exposes the same @@ -225,7 +228,7 @@ abstract class MediaServerClient { /// Curated home-screen hubs across all libraries (Plex Discover; Jellyfin /// synthesizes `Latest` plus optional `Resume` + `NextUp`). - Future> fetchGlobalHubs({int limit = 10, bool includePlaybackHubs = true}); + Future> fetchGlobalHubs({int limit = defaultHubPreviewLimit, bool includePlaybackHubs = true}); /// Hubs scoped to a single library section. [libraryName] is baked into /// the title of synthetic hubs (Jellyfin) so per-library "Recently Added" @@ -236,7 +239,7 @@ abstract class MediaServerClient { Future> fetchLibraryHubs( String libraryId, { required String libraryName, - int limit = 10, + int limit = defaultHubPreviewLimit, bool includePlaybackHubs = true, MediaKind? libraryKind, }); diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 1863eb0d..d748a7ac 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../i18n/strings.g.dart'; import '../../../media/media_hub.dart'; import '../../../media/media_item.dart'; +import '../../../media/media_server_client.dart'; import '../../../mixins/item_updatable.dart'; import '../../../mixins/watch_state_aware.dart'; import '../../../services/settings_service.dart'; @@ -185,7 +186,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState> fetchGlobalHubs({int limit = 10, bool includePlaybackHubs = true}) async { + Future> fetchGlobalHubs({int limit = defaultHubPreviewLimit, bool includePlaybackHubs = true}) async { // Jellyfin doesn't expose a single "hubs" endpoint, so we synthesise the // home rows from Latest plus optional playback rows. The richer Plex Discover surface // is intentionally left untranslated — see ServerCapabilities.richHubs. @@ -919,6 +919,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { title: t.discover.recentlyAdded, type: 'mixed', items: latest, + previewLimit: limit, serverId: serverId, serverName: serverName, ), @@ -953,6 +954,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { title: t.discover.continueWatching, type: 'mixed', items: results[1], + previewLimit: limit, serverId: serverId, serverName: serverName, ), @@ -962,6 +964,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { title: t.discover.nextUp, type: 'episode', items: results[2], + previewLimit: limit, serverId: serverId, serverName: serverName, ), @@ -971,6 +974,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { title: t.discover.recentlyAdded, type: 'mixed', items: results.first, + previewLimit: limit, serverId: serverId, serverName: serverName, ), @@ -981,7 +985,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { Future> fetchLibraryHubs( String libraryId, { required String libraryName, - int limit = 10, + int limit = defaultHubPreviewLimit, bool includePlaybackHubs = true, MediaKind? libraryKind, }) async { @@ -1007,6 +1011,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { title: t.discover.recentlyAddedIn(library: libraryName), type: 'mixed', items: latest, + previewLimit: limit, serverId: serverId, serverName: serverName, ), @@ -1046,6 +1051,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { title: t.discover.continueWatchingIn(library: libraryName), type: 'mixed', items: results[1], + previewLimit: limit, serverId: serverId, serverName: serverName, ), @@ -1055,6 +1061,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { title: t.discover.nextUpIn(library: libraryName), type: 'episode', items: results[2], + previewLimit: limit, serverId: serverId, serverName: serverName, ), @@ -1064,6 +1071,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { title: t.discover.recentlyAddedIn(library: libraryName), type: 'mixed', items: results.first, + previewLimit: limit, serverId: serverId, serverName: serverName, ), diff --git a/lib/services/jellyfin_mappers.dart b/lib/services/jellyfin_mappers.dart index 472bd85d..5587b02f 100644 --- a/lib/services/jellyfin_mappers.dart +++ b/lib/services/jellyfin_mappers.dart @@ -265,6 +265,7 @@ class JellyfinMappers { required ServerId serverId, String? serverName, MediaItem? Function(Map)? mapItem, + int? previewLimit, }) { final mapper = mapItem ?? ((it) => mediaItem(it, serverId: serverId, serverName: serverName, absolutizer: null)); final mappedItems = items.map(mapper).whereType().toList(); @@ -275,7 +276,7 @@ class JellyfinMappers { type: type, items: mappedItems, size: mappedItems.length, - more: items.length >= 20, + more: previewLimit != null && items.length >= previewLimit, serverId: serverId, serverName: serverName, ); diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index e79bcaae..84d1a396 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1888,7 +1888,11 @@ class PlexClient /// Get library hubs (recommendations for a specific library section) /// Returns a list of recommendation hubs like "Trending Movies", "Top in Genre", etc. - Future> _getLibraryHubs(String sectionId, {int limit = 10, String? libraryName}) async { + Future> _getLibraryHubs( + String sectionId, { + int limit = defaultHubPreviewLimit, + String? libraryName, + }) async { try { final response = await retryTransientMediaServerCall( operation: 'Plex library hubs', @@ -1922,7 +1926,7 @@ class PlexClient /// Get global hubs (home page recommendations) /// Returns actual home page hubs like "Recently Added Movies", "Recently Added TV", etc. /// This matches the official Plex client's home page layout. - Future> _getGlobalHubs({int limit = 10}) async { + Future> _getGlobalHubs({int limit = defaultHubPreviewLimit}) async { try { final hubKey = _providerPromotedHubKey ?? _providerHomeHubKey ?? '/hubs'; final response = await retryTransientMediaServerCall( @@ -3714,7 +3718,7 @@ class PlexClient } @override - Future> fetchGlobalHubs({int limit = 10, bool includePlaybackHubs = true}) async { + Future> fetchGlobalHubs({int limit = defaultHubPreviewLimit, bool includePlaybackHubs = true}) async { final hubs = await _getGlobalHubs(limit: limit); return hubs.map((h) => PlexMappers.mediaHub(h)).toList(); } @@ -3723,7 +3727,7 @@ class PlexClient Future> fetchLibraryHubs( String libraryId, { required String libraryName, - int limit = 10, + int limit = defaultHubPreviewLimit, bool includePlaybackHubs = true, MediaKind? libraryKind, }) async { diff --git a/test/screens/discover_screen_test.dart b/test/screens/discover_screen_test.dart index 848ffb18..faa5c602 100644 --- a/test/screens/discover_screen_test.dart +++ b/test/screens/discover_screen_test.dart @@ -241,7 +241,8 @@ class _FakeMediaServerClient implements MediaServerClient { Future> fetchContinueWatching({int? count = 20}) async => const []; @override - Future> fetchGlobalHubs({int limit = 10, bool includePlaybackHubs = true}) async => hubs; + Future> fetchGlobalHubs({int limit = defaultHubPreviewLimit, bool includePlaybackHubs = true}) async => + hubs; @override dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); diff --git a/test/services/data_aggregation_bridge_test.dart b/test/services/data_aggregation_bridge_test.dart index c2c65989..0ef4809a 100644 --- a/test/services/data_aggregation_bridge_test.dart +++ b/test/services/data_aggregation_bridge_test.dart @@ -7,6 +7,7 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/database/app_database.dart'; +import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_client.dart'; @@ -366,6 +367,10 @@ void main() { captured.where((uri) => uri.path == '/Users/user-1/Items/Latest').map((uri) => uri.queryParameters['ParentId']), ['lib-1', 'lib-2', 'lib-3', 'lib-4'], ); + expect( + captured.where((uri) => uri.path == '/Users/user-1/Items/Latest').map((uri) => uri.queryParameters['Limit']), + everyElement(defaultHubPreviewLimit.toString()), + ); }); test('global home layout falls back to per-library hubs for Jellyfin', () async { @@ -414,6 +419,10 @@ void main() { captured.where((uri) => uri.path == '/Users/user-1/Items/Latest').map((uri) => uri.queryParameters['ParentId']), ['movies', 'shows'], ); + expect( + captured.where((uri) => uri.path == '/Users/user-1/Items/Latest').map((uri) => uri.queryParameters['Limit']), + everyElement(defaultHubPreviewLimit.toString()), + ); }); test('Plex home layout keeps promoted hubs instead of splitting by preview libraries', () async { @@ -472,6 +481,7 @@ void main() { expect(hubs.single.libraryId, isNull); expect(hubs.single.items, hasLength(7)); expect(captured.map((uri) => uri.path), ['/hubs/promoted']); + expect(captured.single.queryParameters['count'], defaultHubPreviewLimit.toString()); }); }); } diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 9148e76a..3ecdf72a 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -8,6 +8,7 @@ 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_server_client.dart'; import 'package:plezy/models/transcode_quality_preset.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/playback_initialization_types.dart'; @@ -2163,6 +2164,30 @@ void main() { Uri capturedNextUpRequest() => captured.singleWhere((uri) => uri.path == '/Shows/NextUp'); + test('global preview defaults to shared limit and marks filled previews as more', () async { + captured = []; + final mock = MockClient((req) async { + captured.add(req.url); + return http.Response( + jsonEncode({ + 'Items': [ + for (var i = 0; i < defaultHubPreviewLimit; i++) {'Id': 'movie-$i', 'Type': 'Movie', 'Name': 'Movie $i'}, + ], + }), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); + addTearDown(client.close); + + final hubs = await client.fetchGlobalHubs(includePlaybackHubs: false); + + expect(captured.single.queryParameters['Limit'], defaultHubPreviewLimit.toString()); + expect(hubs.single.items, hasLength(defaultHubPreviewLimit)); + expect(hubs.single.more, isTrue); + }); + test('global Next Up excludes resumable episodes without date cutoff', () async { final client = buildClient(); addTearDown(client.close);