fix(plex): use promoted home hubs

close #1004
This commit is contained in:
edde746
2026-05-11 13:18:43 +02:00
parent fd1eaf9d96
commit fcd8198fcb
5 changed files with 165 additions and 103 deletions
+3 -2
View File
@@ -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;
+9 -99
View File
@@ -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;
}
}
+33 -2
View File
@@ -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,
@@ -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 = <Uri>[];
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']);
});
});
}
+58
View File
@@ -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<String, dynamic> _globalHubsPayload() => {
],
},
};
Map<String, dynamic> _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'},
],
},
],
},
};