@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user