fix(jellyfin): bound home artwork load

This commit is contained in:
edde746
2026-05-08 06:06:09 +02:00
parent f07feaaa52
commit 5fe5534bae
11 changed files with 286 additions and 37 deletions
+10 -3
View File
@@ -201,13 +201,20 @@ abstract class MediaServerClient {
Future<List<MediaItem>> fetchContinueWatching({int count = 20});
/// Curated home-screen hubs across all libraries (Plex Discover; Jellyfin
/// synthesizes `Latest` + `Resume` + `NextUp`).
Future<List<MediaHub>> fetchGlobalHubs({int limit = 10});
/// synthesizes `Latest` plus optional `Resume` + `NextUp`).
Future<List<MediaHub>> fetchGlobalHubs({int limit = 10, 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"
/// / "Next Up" hubs aren't all identically named on the home screen.
Future<List<MediaHub>> fetchLibraryHubs(String libraryId, {required String libraryName, int limit = 10});
/// [includePlaybackHubs] lets surfaces that already render Continue
/// Watching skip duplicate playback rows.
Future<List<MediaHub>> fetchLibraryHubs(
String libraryId, {
required String libraryName,
int limit = 10,
bool includePlaybackHubs = true,
});
/// "More like this" recommendations for [id].
Future<List<MediaHub>> fetchRelatedHubs(String id, {int count = 10});
+1
View File
@@ -519,6 +519,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final hubsFuture = multiServerProvider.aggregationService.getHubsFromAllServers(
hiddenLibraryKeys: hiddenLibrariesProvider.hiddenLibraryKeys,
useGlobalHubs: context.settingsRead(SettingsService.useGlobalHubs),
includePlaybackHubs: false,
);
// Wait for OnDeck to complete and show it immediately
@@ -1253,13 +1253,16 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
final client = getMediaClientForLibrary();
final devicePixelRatio = MediaImageHelper.effectiveDevicePixelRatio(context);
final episodePosterMode = context.settingsRead(SettingsService.episodePosterMode);
for (var i = 0; i < items.length; i++) {
final index = startIndex + i;
if (index < firstVisible || index > prefetchEnd) continue;
final thumb = items[i].thumbPath;
final item = items[i];
final thumb = item.posterThumb(mode: episodePosterMode);
if (thumb == null || thumb.isEmpty) continue;
final imageType = item.usesWideAspectRatio(episodePosterMode) ? ImageType.thumb : ImageType.poster;
final imageUrl = MediaImageHelper.getOptimizedImageUrl(
client: client,
@@ -1267,8 +1270,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
maxWidth: itemWidth,
maxHeight: itemHeight,
devicePixelRatio: devicePixelRatio,
enableTranscoding: MediaImageHelper.shouldTranscode(thumb),
imageType: ImageType.poster,
imageType: imageType,
);
if (imageUrl.isEmpty) continue;
@@ -1277,7 +1279,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
final (_, memHeight) = MediaImageHelper.getMemCacheDimensions(
displayWidth: scaledWidth.isFinite && scaledWidth > 0 ? scaledWidth.round() : 0,
displayHeight: scaledHeight.isFinite && scaledHeight > 0 ? scaledHeight.round() : 0,
imageType: ImageType.poster,
imageType: imageType,
);
precacheImage(
+35 -6
View File
@@ -95,6 +95,7 @@ class DataAggregationService {
int? limit,
Set<String>? hiddenLibraryKeys,
bool useGlobalHubs = true,
bool includePlaybackHubs = true,
}) async {
final clients = _serverManager.onlineClients;
if (clients.isEmpty) {
@@ -111,8 +112,13 @@ class DataAggregationService {
final client = entry.value;
try {
final hubs = useGlobalHubs
? await client.fetchGlobalHubs(limit: limit ?? 10)
: await _fetchLibraryHubsForClient(client, limit: limit ?? 10, hiddenLibraryKeys: hiddenLibraryKeys);
? await client.fetchGlobalHubs(limit: limit ?? 10, includePlaybackHubs: includePlaybackHubs)
: await _fetchLibraryHubsForClient(
client,
limit: limit ?? 10,
hiddenLibraryKeys: hiddenLibraryKeys,
includePlaybackHubs: includePlaybackHubs,
);
return _postProcessHubs(
hubs,
serverId: serverId,
@@ -141,6 +147,7 @@ class DataAggregationService {
MediaServerClient client, {
required int limit,
Set<String>? hiddenLibraryKeys,
required bool includePlaybackHubs,
}) async {
final libs = await client.fetchLibraries();
final visible = libs.where((l) {
@@ -148,10 +155,32 @@ class DataAggregationService {
if (l.hidden) return false;
if (hiddenLibraryKeys != null && hiddenLibraryKeys.contains(l.globalKey)) return false;
return true;
});
final futures = visible.map((l) => client.fetchLibraryHubs(l.id, libraryName: l.title, limit: limit));
final results = await Future.wait(futures);
return [for (final list in results) ...list];
}).toList();
const concurrency = 3;
final all = <MediaHub>[];
for (var start = 0; start < visible.length; start += concurrency) {
final batch = visible.skip(start).take(concurrency);
final results = await Future.wait(
batch.map((l) async {
try {
return await client.fetchLibraryHubs(
l.id,
libraryName: l.title,
limit: limit,
includePlaybackHubs: includePlaybackHubs,
);
} catch (e, st) {
appLogger.e('Failed to fetch library hubs for ${l.globalKey}', error: e, stackTrace: st);
return <MediaHub>[];
}
}),
);
for (final list in results) {
all.addAll(list);
}
}
return all;
}
/// Filter hidden-library items, optionally split multi-library "Recently
+54 -15
View File
@@ -1091,17 +1091,34 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
}
@override
Future<List<MediaHub>> fetchGlobalHubs({int limit = 10}) async {
Future<List<MediaHub>> fetchGlobalHubs({int limit = 10, bool includePlaybackHubs = true}) async {
// Jellyfin doesn't expose a single "hubs" endpoint, so we synthesise the
// home rows from three separate calls. The richer Plex Discover surface
// home rows from Latest plus optional playback rows. The richer Plex Discover surface
// is intentionally left untranslated — see ServerCapabilities.richHubs.
final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', {
'Limit': limit.toString(),
'Fields': _browseFields,
'IncludeItemTypes': 'Movie,Series,Episode',
...jellyfinImageQueryParameters,
});
if (!includePlaybackHubs) {
final latest = await latestFuture;
return [
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'home.recent',
title: t.discover.recentlyAdded,
type: 'mixed',
items: latest,
serverId: serverId,
serverName: serverName,
),
].where((h) => h.items.isNotEmpty).toList();
}
final results = await Future.wait([
_safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', {
'Limit': limit.toString(),
'Fields': _browseFields,
'IncludeItemTypes': 'Movie,Series,Episode',
...jellyfinImageQueryParameters,
}),
latestFuture,
_safeFetchItemsArray('/UserItems/Resume', {
'userId': connection.userId,
'Limit': limit.toString(),
@@ -1152,7 +1169,12 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
}
@override
Future<List<MediaHub>> fetchLibraryHubs(String libraryId, {required String libraryName, int limit = 10}) async {
Future<List<MediaHub>> fetchLibraryHubs(
String libraryId, {
required String libraryName,
int limit = 10,
bool includePlaybackHubs = true,
}) async {
// Mirror the Jellyfin web client's per-library "Suggestions" tab:
// Continue Watching + Next Up (TV libraries) + Recently Added.
//
@@ -1160,13 +1182,30 @@ class JellyfinClient with MediaServerCacheMixin implements MediaServerClient, Sc
// We probe the library kind first to decide whether to ask for NextUp
// — querying it for a movie library is harmless (returns []), but
// skipping the request keeps the wire chatter tighter.
final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', {
'Limit': limit.toString(),
'ParentId': libraryId,
'Fields': _browseFields,
...jellyfinImageQueryParameters,
});
if (!includePlaybackHubs) {
final latest = await latestFuture;
return [
JellyfinMappers.syntheticHub(
mapItem: _mapItem,
identifier: 'library.$libraryId.recent',
title: t.discover.recentlyAddedIn(library: libraryName),
type: 'mixed',
items: latest,
serverId: serverId,
serverName: serverName,
),
].where((h) => h.items.isNotEmpty).toList();
}
final results = await Future.wait([
_safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', {
'Limit': limit.toString(),
'ParentId': libraryId,
'Fields': _browseFields,
...jellyfinImageQueryParameters,
}),
latestFuture,
_safeFetchItemsArray('/UserItems/Resume', {
'userId': connection.userId,
'ParentId': libraryId,
+7 -2
View File
@@ -3037,13 +3037,18 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements
}
@override
Future<List<MediaHub>> fetchGlobalHubs({int limit = 10}) async {
Future<List<MediaHub>> fetchGlobalHubs({int limit = 10, bool includePlaybackHubs = true}) async {
final hubs = await _getGlobalHubs(limit: limit);
return hubs.map((h) => PlexMappers.mediaHub(h)).toList();
}
@override
Future<List<MediaHub>> fetchLibraryHubs(String libraryId, {required String libraryName, int limit = 10}) async {
Future<List<MediaHub>> fetchLibraryHubs(
String libraryId, {
required String libraryName,
int limit = 10,
bool includePlaybackHubs = true,
}) async {
// libraryName is unused: Plex's /hubs/sections/{id} returns hubs already
// titled per-library (e.g. "Recently Added in Movies").
final hubs = await _getLibraryHubs(libraryId, limit: limit);
+11 -6
View File
@@ -129,22 +129,27 @@ class MediaImageHelper {
if (basePath.startsWith('http://') || basePath.startsWith('https://')) {
// Self-contained Jellyfin URLs already carry their own auth
// (`api_key=...`). Append `MaxWidth/MaxHeight` so we still get DPR
// (`api_key=...`). Append `maxWidth/maxHeight` so we still get DPR
// scaling and cache-bucket rounding — Jellyfin's image endpoint
// honours those query params.
if (basePath.contains('api_key=')) {
if (!enableTranscoding) return basePath;
if (basePath.contains('MaxWidth=') || basePath.contains('maxWidth=') || basePath.contains('Width=')) {
return basePath;
}
final (width, height) = calculateOptimalDimensions(
maxWidth: maxWidth,
maxHeight: maxHeight,
devicePixelRatio: devicePixelRatio,
imageType: imageType,
);
final separator = basePath.contains('?') ? '&' : '?';
return '$basePath${separator}MaxWidth=$width&MaxHeight=$height';
final uri = Uri.parse(basePath);
final params = Map<String, String>.from(uri.queryParameters);
final lowerKeys = params.keys.map((k) => k.toLowerCase()).toSet();
if (!lowerKeys.contains('maxwidth') && !lowerKeys.contains('width')) {
params['maxWidth'] = '$width';
}
if (!lowerKeys.contains('maxheight') && !lowerKeys.contains('height')) {
params['maxHeight'] = '$height';
}
return uri.replace(queryParameters: params).toString();
}
// EPG / external URL — proxy through the server's transcoder. Plex
+1 -1
View File
@@ -280,7 +280,7 @@ class OptimizedMediaImage extends StatelessWidget {
maxWidth: effectiveWidth,
maxHeight: effectiveHeight,
devicePixelRatio: devicePixelRatio,
enableTranscoding: enableTranscoding && MediaImageHelper.shouldTranscode(imagePath),
enableTranscoding: enableTranscoding,
imageType: imageType,
);
@@ -1,9 +1,29 @@
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
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/services/data_aggregation_service.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/multi_server_manager.dart';
JellyfinConnection _conn() => JellyfinConnection(
id: 'srv-1/user-1',
baseUrl: 'https://jf.example.com',
serverName: 'Home',
serverMachineId: 'srv-1',
userId: 'user-1',
userName: 'edde',
accessToken: 'tok-abc',
deviceId: 'dev-xyz',
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
);
http.Response _json(Object body) => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'});
/// Smoke tests for the surviving cross-server aggregation surface on
/// [DataAggregationService]. Single-server passthroughs were removed in
/// favour of `context.tryGetMediaClientForServer(...).<method>()`; what's
@@ -34,5 +54,62 @@ void main() {
expect(await service.searchAcrossServers('hello'), isEmpty);
expect(await service.getOnDeckFromAllServers(), isEmpty);
});
test('per-library hubs skip playback rows and fetch in bounded batches', () async {
final captured = <Uri>[];
var activeLatest = 0;
var maxActiveLatest = 0;
final client = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((req) async {
captured.add(req.url);
if (req.url.path == '/Users/user-1/Views') {
return _json({
'Items': [
{'Id': 'lib-1', 'Name': 'Lib 1', 'CollectionType': 'movies'},
{'Id': 'lib-2', 'Name': 'Lib 2', 'CollectionType': 'movies'},
{'Id': 'lib-3', 'Name': 'Lib 3', 'CollectionType': 'tvshows'},
{'Id': 'lib-4', 'Name': 'Lib 4', 'CollectionType': 'tvshows'},
],
});
}
if (req.url.path == '/Users/user-1/Items/Latest') {
activeLatest++;
if (activeLatest > maxActiveLatest) maxActiveLatest = activeLatest;
try {
await Future<void>.delayed(const Duration(milliseconds: 10));
final parentId = req.url.queryParameters['ParentId']!;
return _json({
'Items': [
{'Id': 'item-$parentId', 'Type': 'Movie', 'Name': 'Latest $parentId', 'ParentLibraryId': parentId},
],
});
} finally {
activeLatest--;
}
}
return http.Response('unexpected request', 500);
}),
);
addTearDown(client.close);
manager.debugRegisterJellyfinClientForTesting(client);
final hubs = await service.getHubsFromAllServers(useGlobalHubs: false, includePlaybackHubs: false);
expect(hubs.map((h) => h.identifier), [
'library.lib-1.recent',
'library.lib-2.recent',
'library.lib-3.recent',
'library.lib-4.recent',
]);
expect(hubs.map((h) => h.items.single.id), ['item-lib-1', 'item-lib-2', 'item-lib-3', 'item-lib-4']);
expect(maxActiveLatest, lessThanOrEqualTo(3));
expect(captured.where((uri) => uri.path == '/UserItems/Resume' || uri.path == '/Shows/NextUp'), isEmpty);
expect(
captured.where((uri) => uri.path == '/Users/user-1/Items/Latest').map((uri) => uri.queryParameters['ParentId']),
['lib-1', 'lib-2', 'lib-3', 'lib-4'],
);
});
});
}
@@ -950,6 +950,17 @@ void main() {
expect(nextUp.queryParameters['ImageTypeLimit'], '1');
expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
});
test('can skip global playback hubs', () async {
final client = buildClient();
addTearDown(client.close);
await client.fetchGlobalHubs(limit: 12, includePlaybackHubs: false);
expect(captured.map((uri) => uri.path), ['/Users/user-1/Items/Latest']);
expect(captured.single.queryParameters['IncludeItemTypes'], 'Movie,Series,Episode');
expect(captured.single.queryParameters['Limit'], '12');
});
});
group('JellyfinClient.fetchLibraryHubs URL builders', () {
@@ -980,6 +991,17 @@ void main() {
expect(nextUp.queryParameters['ImageTypeLimit'], '1');
expect(nextUp.queryParameters.containsKey('NextUpDateCutoff'), isFalse);
});
test('can skip library playback hubs', () async {
final client = buildClient();
addTearDown(client.close);
await client.fetchLibraryHubs('lib-99', libraryName: 'Movies', limit: 12, includePlaybackHubs: false);
expect(captured.map((uri) => uri.path), ['/Users/user-1/Items/Latest']);
expect(captured.single.queryParameters['ParentId'], 'lib-99');
expect(captured.single.queryParameters['Limit'], '12');
});
});
group('JellyfinClient.fetchMoreHubItems URL builders', () {
+62
View File
@@ -0,0 +1,62 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/media_image_helper.dart';
void main() {
group('MediaImageHelper.getOptimizedImageUrl', () {
test('adds size hints to absolute Jellyfin artwork URLs', () {
final url = MediaImageHelper.getOptimizedImageUrl(
thumbPath: 'https://jf.example/Items/item-1/Images/Primary?tag=abc&api_key=token',
maxWidth: 120,
maxHeight: 180,
devicePixelRatio: 2,
);
final uri = Uri.parse(url);
expect(uri.queryParameters['tag'], 'abc');
expect(uri.queryParameters['api_key'], 'token');
expect(uri.queryParameters['maxWidth'], '240');
expect(uri.queryParameters['maxHeight'], '360');
});
test('preserves existing Jellyfin size hints and fills missing dimension', () {
final url = MediaImageHelper.getOptimizedImageUrl(
thumbPath: 'https://jf.example/Items/item-1/Images/Primary?api_key=token&maxWidth=100',
maxWidth: 120,
maxHeight: 180,
devicePixelRatio: 2,
);
final uri = Uri.parse(url);
expect(uri.queryParameters['api_key'], 'token');
expect(uri.queryParameters['maxWidth'], '100');
expect(uri.queryParameters['maxHeight'], '360');
});
test('leaves non-Jellyfin external URLs unchanged without a proxy client', () {
const original = 'https://images.example/poster.jpg';
final url = MediaImageHelper.getOptimizedImageUrl(
thumbPath: original,
maxWidth: 120,
maxHeight: 180,
devicePixelRatio: 2,
);
expect(url, original);
});
test('leaves Jellyfin artwork unchanged when transcoding is disabled', () {
const original = 'https://jf.example/Items/item-1/Images/Primary?tag=abc&api_key=token';
final url = MediaImageHelper.getOptimizedImageUrl(
thumbPath: original,
maxWidth: 120,
maxHeight: 180,
devicePixelRatio: 2,
enableTranscoding: false,
);
expect(url, original);
});
});
}