fix: expand home hub previews

close #1268
This commit is contained in:
edde746
2026-06-07 17:35:56 +02:00
parent e8df18e7b2
commit eef73e6447
9 changed files with 67 additions and 13 deletions
+5 -2
View File
@@ -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<List<MediaHub>> fetchGlobalHubs({int limit = 10, bool includePlaybackHubs = true});
Future<List<MediaHub>> 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<List<MediaHub>> fetchLibraryHubs(
String libraryId, {
required String libraryName,
int limit = 10,
int limit = defaultHubPreviewLimit,
bool includePlaybackHubs = true,
MediaKind? libraryKind,
});
@@ -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<MediaHub, LibraryR
await client.fetchLibraryHubs(
widget.library.id,
libraryName: widget.library.title,
limit: 12,
limit: defaultHubPreviewLimit,
libraryKind: widget.library.kind,
),
);
+3 -2
View File
@@ -263,11 +263,12 @@ class DataAggregationService {
try {
final serverLibraries = libraries?[serverId];
final shouldUseGlobalHubs = useGlobalHubs && client.capabilities.richHubs;
final hubItemLimit = limit ?? defaultHubPreviewLimit;
final hubs = shouldUseGlobalHubs
? await client.fetchGlobalHubs(limit: limit ?? 10, includePlaybackHubs: includePlaybackHubs)
? await client.fetchGlobalHubs(limit: hubItemLimit, includePlaybackHubs: includePlaybackHubs)
: await _fetchLibraryHubsForClient(
client,
limit: limit ?? 10,
limit: hubItemLimit,
hiddenLibraryKeys: hiddenLibraryKeys,
includePlaybackHubs: includePlaybackHubs,
libraries: useGlobalHubs ? serverLibraries : null,
+10 -2
View File
@@ -899,7 +899,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
}
@override
Future<List<MediaHub>> fetchGlobalHubs({int limit = 10, bool includePlaybackHubs = true}) async {
Future<List<MediaHub>> 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<List<MediaHub>> 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,
),
+2 -1
View File
@@ -265,6 +265,7 @@ class JellyfinMappers {
required ServerId serverId,
String? serverName,
MediaItem? Function(Map<String, dynamic>)? mapItem,
int? previewLimit,
}) {
final mapper = mapItem ?? ((it) => mediaItem(it, serverId: serverId, serverName: serverName, absolutizer: null));
final mappedItems = items.map(mapper).whereType<MediaItem>().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,
);
+8 -4
View File
@@ -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<List<PlexHubDto>> _getLibraryHubs(String sectionId, {int limit = 10, String? libraryName}) async {
Future<List<PlexHubDto>> _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<List<PlexHubDto>> _getGlobalHubs({int limit = 10}) async {
Future<List<PlexHubDto>> _getGlobalHubs({int limit = defaultHubPreviewLimit}) async {
try {
final hubKey = _providerPromotedHubKey ?? _providerHomeHubKey ?? '/hubs';
final response = await retryTransientMediaServerCall(
@@ -3714,7 +3718,7 @@ class PlexClient
}
@override
Future<List<MediaHub>> fetchGlobalHubs({int limit = 10, bool includePlaybackHubs = true}) async {
Future<List<MediaHub>> 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<List<MediaHub>> fetchLibraryHubs(
String libraryId, {
required String libraryName,
int limit = 10,
int limit = defaultHubPreviewLimit,
bool includePlaybackHubs = true,
MediaKind? libraryKind,
}) async {
+2 -1
View File
@@ -241,7 +241,8 @@ class _FakeMediaServerClient implements MediaServerClient {
Future<List<MediaItem>> fetchContinueWatching({int? count = 20}) async => const [];
@override
Future<List<MediaHub>> fetchGlobalHubs({int limit = 10, bool includePlaybackHubs = true}) async => hubs;
Future<List<MediaHub>> fetchGlobalHubs({int limit = defaultHubPreviewLimit, bool includePlaybackHubs = true}) async =>
hubs;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
@@ -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());
});
});
}
@@ -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);