diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index f3993e22..6a3d0d51 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -201,13 +201,20 @@ abstract class MediaServerClient { Future> fetchContinueWatching({int count = 20}); /// Curated home-screen hubs across all libraries (Plex Discover; Jellyfin - /// synthesizes `Latest` + `Resume` + `NextUp`). - Future> fetchGlobalHubs({int limit = 10}); + /// synthesizes `Latest` plus optional `Resume` + `NextUp`). + Future> fetchGlobalHubs({int limit = 10, 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" /// / "Next Up" hubs aren't all identically named on the home screen. - Future> fetchLibraryHubs(String libraryId, {required String libraryName, int limit = 10}); + /// [includePlaybackHubs] lets surfaces that already render Continue + /// Watching skip duplicate playback rows. + Future> fetchLibraryHubs( + String libraryId, { + required String libraryName, + int limit = 10, + bool includePlaybackHubs = true, + }); /// "More like this" recommendations for [id]. Future> fetchRelatedHubs(String id, {int count = 10}); diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 956ca9f3..b535c324 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -519,6 +519,7 @@ class _DiscoverScreenState extends State final hubsFuture = multiServerProvider.aggregationService.getHubsFromAllServers( hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, useGlobalHubs: context.settingsRead(SettingsService.useGlobalHubs), + includePlaybackHubs: false, ); // Wait for OnDeck to complete and show it immediately diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index fb8a93da..7e25e440 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -1253,13 +1253,16 @@ class _LibraryBrowseTabState extends BaseLibraryTabState prefetchEnd) continue; - final thumb = items[i].thumbPath; + final item = items[i]; + final thumb = item.posterThumb(mode: episodePosterMode); if (thumb == null || thumb.isEmpty) continue; + final imageType = item.usesWideAspectRatio(episodePosterMode) ? ImageType.thumb : ImageType.poster; final imageUrl = MediaImageHelper.getOptimizedImageUrl( client: client, @@ -1267,8 +1270,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 0 ? scaledWidth.round() : 0, displayHeight: scaledHeight.isFinite && scaledHeight > 0 ? scaledHeight.round() : 0, - imageType: ImageType.poster, + imageType: imageType, ); precacheImage( diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index db3c7b06..0995337c 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -95,6 +95,7 @@ class DataAggregationService { int? limit, Set? hiddenLibraryKeys, bool useGlobalHubs = true, + bool includePlaybackHubs = true, }) async { final clients = _serverManager.onlineClients; if (clients.isEmpty) { @@ -111,8 +112,13 @@ class DataAggregationService { final client = entry.value; try { final hubs = useGlobalHubs - ? await client.fetchGlobalHubs(limit: limit ?? 10) - : await _fetchLibraryHubsForClient(client, limit: limit ?? 10, hiddenLibraryKeys: hiddenLibraryKeys); + ? await client.fetchGlobalHubs(limit: limit ?? 10, includePlaybackHubs: includePlaybackHubs) + : await _fetchLibraryHubsForClient( + client, + limit: limit ?? 10, + hiddenLibraryKeys: hiddenLibraryKeys, + includePlaybackHubs: includePlaybackHubs, + ); return _postProcessHubs( hubs, serverId: serverId, @@ -141,6 +147,7 @@ class DataAggregationService { MediaServerClient client, { required int limit, Set? hiddenLibraryKeys, + required bool includePlaybackHubs, }) async { final libs = await client.fetchLibraries(); final visible = libs.where((l) { @@ -148,10 +155,32 @@ class DataAggregationService { if (l.hidden) return false; if (hiddenLibraryKeys != null && hiddenLibraryKeys.contains(l.globalKey)) return false; return true; - }); - final futures = visible.map((l) => client.fetchLibraryHubs(l.id, libraryName: l.title, limit: limit)); - final results = await Future.wait(futures); - return [for (final list in results) ...list]; + }).toList(); + + const concurrency = 3; + final all = []; + for (var start = 0; start < visible.length; start += concurrency) { + final batch = visible.skip(start).take(concurrency); + final results = await Future.wait( + batch.map((l) async { + try { + return await client.fetchLibraryHubs( + l.id, + libraryName: l.title, + limit: limit, + includePlaybackHubs: includePlaybackHubs, + ); + } catch (e, st) { + appLogger.e('Failed to fetch library hubs for ${l.globalKey}', error: e, stackTrace: st); + return []; + } + }), + ); + for (final list in results) { + all.addAll(list); + } + } + return all; } /// Filter hidden-library items, optionally split multi-library "Recently diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 10f39947..458421e1 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -1091,17 +1091,34 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc } @override - Future> fetchGlobalHubs({int limit = 10}) async { + Future> fetchGlobalHubs({int limit = 10, bool includePlaybackHubs = true}) async { // Jellyfin doesn't expose a single "hubs" endpoint, so we synthesise the - // home rows from three separate calls. The richer Plex Discover surface + // home rows from Latest plus optional playback rows. The richer Plex Discover surface // is intentionally left untranslated — see ServerCapabilities.richHubs. + final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { + 'Limit': limit.toString(), + 'Fields': _browseFields, + 'IncludeItemTypes': 'Movie,Series,Episode', + ...jellyfinImageQueryParameters, + }); + + if (!includePlaybackHubs) { + final latest = await latestFuture; + return [ + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'home.recent', + title: t.discover.recentlyAdded, + type: 'mixed', + items: latest, + serverId: serverId, + serverName: serverName, + ), + ].where((h) => h.items.isNotEmpty).toList(); + } + final results = await Future.wait([ - _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { - 'Limit': limit.toString(), - 'Fields': _browseFields, - 'IncludeItemTypes': 'Movie,Series,Episode', - ...jellyfinImageQueryParameters, - }), + latestFuture, _safeFetchItemsArray('/UserItems/Resume', { 'userId': connection.userId, 'Limit': limit.toString(), @@ -1152,7 +1169,12 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc } @override - Future> fetchLibraryHubs(String libraryId, {required String libraryName, int limit = 10}) async { + Future> fetchLibraryHubs( + String libraryId, { + required String libraryName, + int limit = 10, + bool includePlaybackHubs = true, + }) async { // Mirror the Jellyfin web client's per-library "Suggestions" tab: // Continue Watching + Next Up (TV libraries) + Recently Added. // @@ -1160,13 +1182,30 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc // We probe the library kind first to decide whether to ask for NextUp // — querying it for a movie library is harmless (returns []), but // skipping the request keeps the wire chatter tighter. + final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { + 'Limit': limit.toString(), + 'ParentId': libraryId, + 'Fields': _browseFields, + ...jellyfinImageQueryParameters, + }); + + if (!includePlaybackHubs) { + final latest = await latestFuture; + return [ + JellyfinMappers.syntheticHub( + mapItem: _mapItem, + identifier: 'library.$libraryId.recent', + title: t.discover.recentlyAddedIn(library: libraryName), + type: 'mixed', + items: latest, + serverId: serverId, + serverName: serverName, + ), + ].where((h) => h.items.isNotEmpty).toList(); + } + final results = await Future.wait([ - _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { - 'Limit': limit.toString(), - 'ParentId': libraryId, - 'Fields': _browseFields, - ...jellyfinImageQueryParameters, - }), + latestFuture, _safeFetchItemsArray('/UserItems/Resume', { 'userId': connection.userId, 'ParentId': libraryId, diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index af4daa53..8d36bffe 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -3037,13 +3037,18 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements } @override - Future> fetchGlobalHubs({int limit = 10}) async { + Future> fetchGlobalHubs({int limit = 10, bool includePlaybackHubs = true}) async { final hubs = await _getGlobalHubs(limit: limit); return hubs.map((h) => PlexMappers.mediaHub(h)).toList(); } @override - Future> fetchLibraryHubs(String libraryId, {required String libraryName, int limit = 10}) async { + Future> fetchLibraryHubs( + String libraryId, { + required String libraryName, + int limit = 10, + bool includePlaybackHubs = true, + }) async { // libraryName is unused: Plex's /hubs/sections/{id} returns hubs already // titled per-library (e.g. "Recently Added in Movies"). final hubs = await _getLibraryHubs(libraryId, limit: limit); diff --git a/lib/utils/media_image_helper.dart b/lib/utils/media_image_helper.dart index 1774cd00..1b2a947c 100644 --- a/lib/utils/media_image_helper.dart +++ b/lib/utils/media_image_helper.dart @@ -129,22 +129,27 @@ class MediaImageHelper { if (basePath.startsWith('http://') || basePath.startsWith('https://')) { // Self-contained Jellyfin URLs already carry their own auth - // (`api_key=...`). Append `MaxWidth/MaxHeight` so we still get DPR + // (`api_key=...`). Append `maxWidth/maxHeight` so we still get DPR // scaling and cache-bucket rounding — Jellyfin's image endpoint // honours those query params. if (basePath.contains('api_key=')) { if (!enableTranscoding) return basePath; - if (basePath.contains('MaxWidth=') || basePath.contains('maxWidth=') || basePath.contains('Width=')) { - return basePath; - } final (width, height) = calculateOptimalDimensions( maxWidth: maxWidth, maxHeight: maxHeight, devicePixelRatio: devicePixelRatio, imageType: imageType, ); - final separator = basePath.contains('?') ? '&' : '?'; - return '$basePath${separator}MaxWidth=$width&MaxHeight=$height'; + final uri = Uri.parse(basePath); + final params = Map.from(uri.queryParameters); + final lowerKeys = params.keys.map((k) => k.toLowerCase()).toSet(); + if (!lowerKeys.contains('maxwidth') && !lowerKeys.contains('width')) { + params['maxWidth'] = '$width'; + } + if (!lowerKeys.contains('maxheight') && !lowerKeys.contains('height')) { + params['maxHeight'] = '$height'; + } + return uri.replace(queryParameters: params).toString(); } // EPG / external URL — proxy through the server's transcoder. Plex diff --git a/lib/widgets/optimized_media_image.dart b/lib/widgets/optimized_media_image.dart index 946e3391..5477f18c 100644 --- a/lib/widgets/optimized_media_image.dart +++ b/lib/widgets/optimized_media_image.dart @@ -280,7 +280,7 @@ class OptimizedMediaImage extends StatelessWidget { maxWidth: effectiveWidth, maxHeight: effectiveHeight, devicePixelRatio: devicePixelRatio, - enableTranscoding: enableTranscoding && MediaImageHelper.shouldTranscode(imagePath), + enableTranscoding: enableTranscoding, imageType: imageType, ); diff --git a/test/services/data_aggregation_bridge_test.dart b/test/services/data_aggregation_bridge_test.dart index d16979fc..729b2def 100644 --- a/test/services/data_aggregation_bridge_test.dart +++ b/test/services/data_aggregation_bridge_test.dart @@ -1,9 +1,29 @@ +import 'dart:convert'; + import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +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/services/data_aggregation_service.dart'; +import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; +JellyfinConnection _conn() => JellyfinConnection( + id: 'srv-1/user-1', + baseUrl: 'https://jf.example.com', + serverName: 'Home', + serverMachineId: 'srv-1', + userId: 'user-1', + userName: 'edde', + accessToken: 'tok-abc', + deviceId: 'dev-xyz', + createdAt: DateTime.fromMillisecondsSinceEpoch(0), +); + +http.Response _json(Object body) => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}); + /// Smoke tests for the surviving cross-server aggregation surface on /// [DataAggregationService]. Single-server passthroughs were removed in /// favour of `context.tryGetMediaClientForServer(...).()`; what's @@ -34,5 +54,62 @@ void main() { expect(await service.searchAcrossServers('hello'), isEmpty); expect(await service.getOnDeckFromAllServers(), isEmpty); }); + + test('per-library hubs skip playback rows and fetch in bounded batches', () async { + final captured = []; + var activeLatest = 0; + var maxActiveLatest = 0; + + final client = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((req) async { + captured.add(req.url); + if (req.url.path == '/Users/user-1/Views') { + return _json({ + 'Items': [ + {'Id': 'lib-1', 'Name': 'Lib 1', 'CollectionType': 'movies'}, + {'Id': 'lib-2', 'Name': 'Lib 2', 'CollectionType': 'movies'}, + {'Id': 'lib-3', 'Name': 'Lib 3', 'CollectionType': 'tvshows'}, + {'Id': 'lib-4', 'Name': 'Lib 4', 'CollectionType': 'tvshows'}, + ], + }); + } + if (req.url.path == '/Users/user-1/Items/Latest') { + activeLatest++; + if (activeLatest > maxActiveLatest) maxActiveLatest = activeLatest; + try { + await Future.delayed(const Duration(milliseconds: 10)); + final parentId = req.url.queryParameters['ParentId']!; + return _json({ + 'Items': [ + {'Id': 'item-$parentId', 'Type': 'Movie', 'Name': 'Latest $parentId', 'ParentLibraryId': parentId}, + ], + }); + } finally { + activeLatest--; + } + } + return http.Response('unexpected request', 500); + }), + ); + addTearDown(client.close); + manager.debugRegisterJellyfinClientForTesting(client); + + final hubs = await service.getHubsFromAllServers(useGlobalHubs: false, includePlaybackHubs: false); + + expect(hubs.map((h) => h.identifier), [ + 'library.lib-1.recent', + 'library.lib-2.recent', + 'library.lib-3.recent', + 'library.lib-4.recent', + ]); + expect(hubs.map((h) => h.items.single.id), ['item-lib-1', 'item-lib-2', 'item-lib-3', 'item-lib-4']); + expect(maxActiveLatest, lessThanOrEqualTo(3)); + expect(captured.where((uri) => uri.path == '/UserItems/Resume' || uri.path == '/Shows/NextUp'), isEmpty); + expect( + captured.where((uri) => uri.path == '/Users/user-1/Items/Latest').map((uri) => uri.queryParameters['ParentId']), + ['lib-1', 'lib-2', 'lib-3', 'lib-4'], + ); + }); }); } diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index ddf1d253..8472c1c0 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -950,6 +950,17 @@ void main() { expect(nextUp.queryParameters['ImageTypeLimit'], '1'); expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse); }); + + test('can skip global playback hubs', () async { + final client = buildClient(); + addTearDown(client.close); + + await client.fetchGlobalHubs(limit: 12, includePlaybackHubs: false); + + expect(captured.map((uri) => uri.path), ['/Users/user-1/Items/Latest']); + expect(captured.single.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode'); + expect(captured.single.queryParameters['Limit'], '12'); + }); }); group('JellyfinClient.fetchLibraryHubs URL builders', () { @@ -980,6 +991,17 @@ void main() { expect(nextUp.queryParameters['ImageTypeLimit'], '1'); expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse); }); + + test('can skip library playback hubs', () async { + final client = buildClient(); + addTearDown(client.close); + + await client.fetchLibraryHubs('lib-99', libraryName: 'Movies', limit: 12, includePlaybackHubs: false); + + expect(captured.map((uri) => uri.path), ['/Users/user-1/Items/Latest']); + expect(captured.single.queryParameters['ParentId'], 'lib-99'); + expect(captured.single.queryParameters['Limit'], '12'); + }); }); group('JellyfinClient.fetchMoreHubItems URL builders', () { diff --git a/test/utils/media_image_helper_test.dart b/test/utils/media_image_helper_test.dart new file mode 100644 index 00000000..bc73ecc0 --- /dev/null +++ b/test/utils/media_image_helper_test.dart @@ -0,0 +1,62 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/utils/media_image_helper.dart'; + +void main() { + group('MediaImageHelper.getOptimizedImageUrl', () { + test('adds size hints to absolute Jellyfin artwork URLs', () { + final url = MediaImageHelper.getOptimizedImageUrl( + thumbPath: 'https://jf.example/Items/item-1/Images/Primary?tag=abc&api_key=token', + maxWidth: 120, + maxHeight: 180, + devicePixelRatio: 2, + ); + + final uri = Uri.parse(url); + expect(uri.queryParameters['tag'], 'abc'); + expect(uri.queryParameters['api_key'], 'token'); + expect(uri.queryParameters['maxWidth'], '240'); + expect(uri.queryParameters['maxHeight'], '360'); + }); + + test('preserves existing Jellyfin size hints and fills missing dimension', () { + final url = MediaImageHelper.getOptimizedImageUrl( + thumbPath: 'https://jf.example/Items/item-1/Images/Primary?api_key=token&maxWidth=100', + maxWidth: 120, + maxHeight: 180, + devicePixelRatio: 2, + ); + + final uri = Uri.parse(url); + expect(uri.queryParameters['api_key'], 'token'); + expect(uri.queryParameters['maxWidth'], '100'); + expect(uri.queryParameters['maxHeight'], '360'); + }); + + test('leaves non-Jellyfin external URLs unchanged without a proxy client', () { + const original = 'https://images.example/poster.jpg'; + + final url = MediaImageHelper.getOptimizedImageUrl( + thumbPath: original, + maxWidth: 120, + maxHeight: 180, + devicePixelRatio: 2, + ); + + expect(url, original); + }); + + test('leaves Jellyfin artwork unchanged when transcoding is disabled', () { + const original = 'https://jf.example/Items/item-1/Images/Primary?tag=abc&api_key=token'; + + final url = MediaImageHelper.getOptimizedImageUrl( + thumbPath: original, + maxWidth: 120, + maxHeight: 180, + devicePixelRatio: 2, + enableTranscoding: false, + ); + + expect(url, original); + }); + }); +}