diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 4e74dea2..7fdb8b66 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -526,13 +526,14 @@ class _DiscoverScreenState extends State // stores neutral MediaLibrary objects. // Start OnDeck and hubs fetch in parallel + final useGlobalHubs = context.settingsRead(SettingsService.useGlobalHubs); final onDeckFuture = multiServerProvider.aggregationService.getOnDeckFromAllServers( limit: 20, hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, ); final hubsFuture = multiServerProvider.aggregationService.getHubsFromAllServers( hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys, - useGlobalHubs: context.settingsRead(SettingsService.useGlobalHubs), + useGlobalHubs: useGlobalHubs, includePlaybackHubs: false, ); @@ -597,7 +598,7 @@ class _DiscoverScreenState extends State // Sort hubs by the user's library order final libraryOrder = context.read().libraries; - if (libraryOrder.isNotEmpty) { + if (!useGlobalHubs && libraryOrder.isNotEmpty) { final orderMap = {}; for (var i = 0; i < libraryOrder.length; i++) { orderMap[libraryOrder[i].globalKey] = i; diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index de2337f1..85c0f5a4 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import '../i18n/strings.g.dart'; import '../media/media_hub.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; @@ -87,8 +86,8 @@ class DataAggregationService { } /// Fetch recommendation hubs from all servers as neutral [MediaHub]s. - /// When useGlobalHubs is true (default), rich-hub backends use the global - /// /hubs endpoint to get true home page hubs like "Recently Added Movies". + /// When useGlobalHubs is true (default), rich-hub backends use their true + /// home page hubs (Plex's promoted/global hub endpoint). /// Backends without rich home hubs fall back to per-library hubs so one /// capped "Latest" response cannot hide whole library types. Future> getHubsFromAllServers({ @@ -103,9 +102,10 @@ class DataAggregationService { return []; } - // For global hubs, pre-fetch libraries to split "Recently Added" hubs - // by library and resolve human-readable names. - final libraries = useGlobalHubs ? _groupLibrariesByServer(await getMediaLibrariesFromAllServers()) : null; + // Only fallback clients need a library prefetch when home layout is on; + // rich-hub backends return the intended home rows directly. + final needsLibraryPrefetch = useGlobalHubs && clients.values.any((client) => !client.capabilities.richHubs); + final libraries = needsLibraryPrefetch ? _groupLibrariesByServer(await getMediaLibrariesFromAllServers()) : null; final futures = clients.entries.map((entry) async { final serverId = entry.key; @@ -122,13 +122,7 @@ class DataAggregationService { includePlaybackHubs: includePlaybackHubs, libraries: useGlobalHubs ? serverLibraries : null, ); - return _postProcessHubs( - hubs, - serverId: serverId, - hiddenLibraryKeys: hiddenLibraryKeys, - libraries: serverLibraries, - splitRecentlyAdded: shouldUseGlobalHubs, - ); + return _postProcessHubs(hubs, serverId: serverId, hiddenLibraryKeys: hiddenLibraryKeys); } catch (e, stackTrace) { appLogger.e('Failed to fetch hubs from server $serverId', error: e, stackTrace: stackTrace); return []; @@ -187,15 +181,8 @@ class DataAggregationService { return all; } - /// Filter hidden-library items, optionally split multi-library "Recently - /// Added" hubs by section, and drop empty hubs. - List _postProcessHubs( - List hubs, { - required String serverId, - Set? hiddenLibraryKeys, - List? libraries, - required bool splitRecentlyAdded, - }) { + /// Filter hidden-library items and drop empty hubs. + List _postProcessHubs(List hubs, {required String serverId, Set? hiddenLibraryKeys}) { var filtered = hubs; if (hiddenLibraryKeys != null && hiddenLibraryKeys.isNotEmpty) { filtered = filtered @@ -212,10 +199,6 @@ class DataAggregationService { .whereType() .toList(); } - - if (splitRecentlyAdded) { - filtered = _splitRecentlyAddedHubs(filtered, libraries); - } return filtered; } @@ -260,77 +243,4 @@ class DataAggregationService { return grouped; } - - /// Split "Recently Added" hubs that contain items from multiple libraries - /// into separate per-library hubs, matching the official Plex client behavior. - List _splitRecentlyAddedHubs(List hubs, List? libraries) { - final result = []; - - for (final hub in hubs) { - final hubId = hub.identifier?.toLowerCase() ?? ''; - if (!hubId.contains('.recent')) { - result.add(hub); - continue; - } - - // Group items by libraryId - final groups = >{}; - final ungrouped = []; - - for (final item in hub.items) { - final libraryId = item.libraryId; - if (libraryId == null) { - ungrouped.add(item); - } else { - groups.putIfAbsent(libraryId, () => []).add(item); - } - } - - // Single library (or no groupable items) — keep hub unchanged - if (groups.length <= 1) { - result.add(hub); - continue; - } - - // Multiple libraries — create one hub per library - for (final entry in groups.entries) { - final items = entry.value; - final libraryName = _resolveLibraryName(items.first, libraries); - final title = libraryName != null ? t.discover.recentlyAddedIn(library: libraryName) : hub.title; - - result.add( - hub.copyWith( - title: title, - identifier: '${hub.identifier}_${entry.key}', - size: items.length, - items: items, - libraryId: entry.key, - ), - ); - } - - // Keep ungrouped items in a hub with the original title - if (ungrouped.isNotEmpty) { - result.add(hub.copyWith(size: ungrouped.length, items: ungrouped)); - } - } - - return result; - } - - /// Resolve a library name from an item's [libraryTitle] or by looking up - /// the library in the supplied list. - String? _resolveLibraryName(MediaItem item, List? libraries) { - if (item.libraryTitle != null && item.libraryTitle!.isNotEmpty) { - return item.libraryTitle; - } - if (libraries != null && item.libraryId != null) { - for (final lib in libraries) { - if (lib.id == item.libraryId) { - return lib.title; - } - } - } - return null; - } } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 1cbd35a6..8b657262 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -182,6 +182,12 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements /// Libraries parsed from /media/providers (includes individually shared items) List _providerLibraries = const []; + /// Home hub endpoint advertised by /media/providers (usually /hubs). + String? _providerHomeHubKey; + + /// Promoted home hub endpoint advertised by /media/providers (usually /hubs/promoted). + String? _providerPromotedHubKey; + /// EPG providers parsed from /media/providers @override List<({String identifier, String gridEndpoint})> _providerEpg = const []; @@ -222,6 +228,7 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements Future Function(String newBaseUrl)? onEndpointChanged, VoidCallback? onAllEndpointsExhausted, bool? seedTranscoderVideoSupport, + http.Client? httpClient, }) async { final client = PlexClient._( config, @@ -230,6 +237,7 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements prioritizedEndpoints: prioritizedEndpoints, onEndpointChanged: onEndpointChanged, onAllEndpointsExhausted: onAllEndpointsExhausted, + httpClient: httpClient, ); if (seedTranscoderVideoSupport != null) { client._serverTranscoderCached = seedTranscoderVideoSupport; @@ -282,6 +290,8 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements required http.Client httpClient, List? prioritizedEndpoints, List<({String identifier, String gridEndpoint})> epgProviders = const [], + String? homeHubKey, + String? promotedHubKey, }) { final client = PlexClient._( config, @@ -292,6 +302,8 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements ); client._providerLibraries = const []; client._providerEpg = epgProviders; + client._providerHomeHubKey = homeHubKey; + client._providerPromotedHubKey = promotedHubKey; return client; } @@ -384,6 +396,8 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements if (container == null) { _providerLibraries = []; _providerEpg = []; + _providerHomeHubKey = null; + _providerPromotedHubKey = null; return; } @@ -391,11 +405,15 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements if (providers == null) { _providerLibraries = []; _providerEpg = []; + _providerHomeHubKey = null; + _providerPromotedHubKey = null; return; } final libraries = []; final epg = <({String identifier, String gridEndpoint})>[]; + String? homeHubKey; + String? promotedHubKey; for (final provider in providers) { if (provider is! Map) continue; @@ -409,6 +427,11 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements if (identifier == 'com.plexapp.plugins.library') { for (final feature in features) { if (feature is! Map) continue; + + if (feature['type'] == 'promoted') { + promotedHubKey ??= feature['key'] as String?; + } + if (feature['type'] != 'content') continue; final directories = feature['Directory'] as List?; @@ -420,7 +443,10 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements // Skip entries without id (Home hub) and playlists final id = dir['id']?.toString(); - if (id == null) continue; + if (id == null) { + homeHubKey ??= dir['hubKey'] as String?; + continue; + } if (dir['type'] == 'playlist') continue; final isNumericId = int.tryParse(id) != null; @@ -463,11 +489,15 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements _providerLibraries = libraries; _providerEpg = epg; + _providerHomeHubKey = homeHubKey; + _providerPromotedHubKey = promotedHubKey; appLogger.d('Media providers: ${libraries.length} libraries, ${epg.length} EPG provider(s)'); } catch (e) { appLogger.w('Failed to fetch /media/providers, will fall back to /library/sections', error: e); _providerLibraries = []; _providerEpg = []; + _providerHomeHubKey = null; + _providerPromotedHubKey = null; } } @@ -1617,11 +1647,12 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements /// This matches the official Plex client's home page layout. Future> _getGlobalHubs({int limit = 10}) async { try { + final hubKey = _providerPromotedHubKey ?? _providerHomeHubKey ?? '/hubs'; final response = await retryTransientMediaServerCall( operation: 'Plex global hubs', attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts, call: (timeout, abort) => _getWithFailover( - '/hubs', + hubKey, queryParameters: {'count': limit, 'includeGuids': 1}, timeout: timeout, abort: abort, diff --git a/test/services/data_aggregation_bridge_test.dart b/test/services/data_aggregation_bridge_test.dart index 77ffc40c..00db680e 100644 --- a/test/services/data_aggregation_bridge_test.dart +++ b/test/services/data_aggregation_bridge_test.dart @@ -6,9 +6,12 @@ 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/models/plex/plex_config.dart'; import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; +import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/services/plex_client.dart'; JellyfinConnection _conn() => JellyfinConnection( id: 'srv-1/user-1', @@ -36,6 +39,7 @@ void main() { setUp(() { db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); manager = MultiServerManager(); service = DataAggregationService(manager); }); @@ -159,5 +163,63 @@ void main() { ['movies', 'shows'], ); }); + + test('Plex home layout keeps promoted hubs instead of splitting by preview libraries', () async { + final captured = []; + + final client = PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example.com', + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: 'test', + ), + serverId: 'plex-1', + serverName: 'Plex', + promotedHubKey: '/hubs/promoted', + httpClient: MockClient((req) async { + captured.add(req.url); + if (req.url.path == '/hubs/promoted') { + return _json({ + 'MediaContainer': { + 'Hub': [ + { + 'key': '/hubs/home/recentlyAdded?type=2', + 'title': 'Recently Added TV', + 'type': 'mixed', + 'hubIdentifier': 'home.television.recent', + 'size': 7, + 'more': true, + 'Metadata': [ + for (var i = 1; i <= 7; i++) + { + 'ratingKey': 'show-$i', + 'type': 'show', + 'title': 'Show $i', + 'librarySectionID': i, + 'librarySectionTitle': 'Library $i', + }, + ], + }, + ], + }, + }); + } + return http.Response('unexpected request', 500); + }), + ); + addTearDown(client.close); + manager.debugRegisterClientForTesting(client); + + final hubs = await service.getHubsFromAllServers(useGlobalHubs: true, includePlaybackHubs: false); + + expect(hubs, hasLength(1)); + expect(hubs.single.title, 'Recently Added TV'); + expect(hubs.single.identifier, 'home.television.recent'); + expect(hubs.single.libraryId, isNull); + expect(hubs.single.items, hasLength(7)); + expect(captured.map((uri) => uri.path), ['/hubs/promoted']); + }); }); } diff --git a/test/services/plex_home_retry_test.dart b/test/services/plex_home_retry_test.dart index 268d786e..3179c155 100644 --- a/test/services/plex_home_retry_test.dart +++ b/test/services/plex_home_retry_test.dart @@ -95,6 +95,38 @@ void main() { expect(httpClient.requests.map((r) => r.url.origin), everyElement(primary)); }); + test('fetchGlobalHubs uses promoted hub endpoint advertised by media providers', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + addTearDown(db.close); + + final httpClient = _SequenceClient([ + (_) async => _jsonResponse(_mediaProvidersPayload()), + (_) async => _jsonResponse(_globalHubsPayload()), + ]); + final client = await PlexClient.create( + PlexConfig( + baseUrl: 'http://server:32400', + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: 'test', + ), + serverId: 'server-id', + serverName: 'Server', + httpClient: httpClient, + seedTranscoderVideoSupport: true, + ); + addTearDown(client.close); + + final hubs = await client.fetchGlobalHubs(limit: 12); + + expect(hubs, hasLength(1)); + expect(hubs.single.title, 'Recently Added Movies'); + expect(httpClient.requests.map((r) => r.url.path), ['/media/providers', '/hubs/promoted']); + expect(httpClient.requests.last.url.queryParameters['count'], '12'); + }); + test('fetchLibraryHubs retries transient failures without switching Plex endpoints', () async { final db = AppDatabase.forTesting(NativeDatabase.memory()); PlexApiCache.initialize(db); @@ -157,3 +189,29 @@ Map _globalHubsPayload() => { ], }, }; + +Map _mediaProvidersPayload() => { + 'MediaContainer': { + 'MediaProvider': [ + { + 'identifier': 'com.plexapp.plugins.library', + 'Feature': [ + { + 'type': 'content', + 'Directory': [ + {'title': 'Home', 'hubKey': '/hubs'}, + { + 'id': '1', + 'key': '/library/sections/1', + 'hubKey': '/hubs/sections/1', + 'type': 'movie', + 'title': 'Movies', + }, + ], + }, + {'type': 'promoted', 'key': '/hubs/promoted'}, + ], + }, + ], + }, +};