@@ -526,13 +526,14 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
// 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<DiscoverScreen>
|
||||
|
||||
// Sort hubs by the user's library order
|
||||
final libraryOrder = context.read<LibrariesProvider>().libraries;
|
||||
if (libraryOrder.isNotEmpty) {
|
||||
if (!useGlobalHubs && libraryOrder.isNotEmpty) {
|
||||
final orderMap = <String, int>{};
|
||||
for (var i = 0; i < libraryOrder.length; i++) {
|
||||
orderMap[libraryOrder[i].globalKey] = i;
|
||||
|
||||
@@ -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<List<MediaHub>> 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 <MediaHub>[];
|
||||
@@ -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<MediaHub> _postProcessHubs(
|
||||
List<MediaHub> hubs, {
|
||||
required String serverId,
|
||||
Set<String>? hiddenLibraryKeys,
|
||||
List<MediaLibrary>? libraries,
|
||||
required bool splitRecentlyAdded,
|
||||
}) {
|
||||
/// Filter hidden-library items and drop empty hubs.
|
||||
List<MediaHub> _postProcessHubs(List<MediaHub> hubs, {required String serverId, Set<String>? hiddenLibraryKeys}) {
|
||||
var filtered = hubs;
|
||||
if (hiddenLibraryKeys != null && hiddenLibraryKeys.isNotEmpty) {
|
||||
filtered = filtered
|
||||
@@ -212,10 +199,6 @@ class DataAggregationService {
|
||||
.whereType<MediaHub>()
|
||||
.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<MediaHub> _splitRecentlyAddedHubs(List<MediaHub> hubs, List<MediaLibrary>? libraries) {
|
||||
final result = <MediaHub>[];
|
||||
|
||||
for (final hub in hubs) {
|
||||
final hubId = hub.identifier?.toLowerCase() ?? '';
|
||||
if (!hubId.contains('.recent')) {
|
||||
result.add(hub);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Group items by libraryId
|
||||
final groups = <String, List<MediaItem>>{};
|
||||
final ungrouped = <MediaItem>[];
|
||||
|
||||
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<MediaLibrary>? 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,6 +182,12 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements
|
||||
/// Libraries parsed from /media/providers (includes individually shared items)
|
||||
List<PlexLibraryDto> _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<void> 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<String>? 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 = <PlexLibraryDto>[];
|
||||
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<List<PlexHubDto>> _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,
|
||||
|
||||
Reference in New Issue
Block a user