From 187eeb19d3f2b8ba12d2e6a28b353d089fb6b4f3 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 7 May 2026 04:14:31 +0200 Subject: [PATCH] fix(plex): retry slow library hubs --- lib/services/image_cache_service.dart | 7 +++-- lib/services/plex_client.dart | 14 +++++++--- lib/utils/media_server_timeouts.dart | 4 +++ test/services/plex_home_retry_test.dart | 36 +++++++++++++++++++++++++ 4 files changed, 55 insertions(+), 6 deletions(-) diff --git a/lib/services/image_cache_service.dart b/lib/services/image_cache_service.dart index 30bc5152..1dcc6f7b 100644 --- a/lib/services/image_cache_service.dart +++ b/lib/services/image_cache_service.dart @@ -6,6 +6,8 @@ import 'package:http/http.dart' as http; import '../utils/media_server_http_client.dart'; +final _artworkHttpClient = MediaServerHttpClient(usePlexApiClient: true); + /// Shared cache manager for media-server image artwork. Used for both Plex and /// Jellyfin artwork (the class name predates Jellyfin support — it's /// backend-neutral). @@ -13,7 +15,8 @@ import '../utils/media_server_http_client.dart'; /// Uses the platform-native HTTP client so iOS/macOS (CupertinoClient) and /// Android (CronetClient) benefit from HTTP/2 connection multiplexing — /// many concurrent image downloads over a single connection instead of -/// being limited to a handful of HTTP/1.1 connections. +/// being limited to a handful of HTTP/1.1 connections. On Linux this uses the +/// same finite-connection tuning as Plex API traffic. class PlexImageCacheManager extends ce_cache.DefaultCacheManager { static final PlexImageCacheManager instance = PlexImageCacheManager._(); @@ -21,7 +24,7 @@ class PlexImageCacheManager extends ce_cache.DefaultCacheManager { : super( stalePeriod: const Duration(days: 14), maxNrOfCacheObjects: 3000, - httpClientFactory: () => _SharedHttpClient(httpClient.inner), + httpClientFactory: () => _SharedHttpClient(_artworkHttpClient.inner), ); } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index a63543eb..af4daa53 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1542,10 +1542,16 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements /// Returns a list of recommendation hubs like "Trending Movies", "Top in Genre", etc. Future> _getLibraryHubs(String sectionId, {int limit = 10}) async { try { - final response = await _getWithFailover( - '/hubs/sections/$sectionId', - queryParameters: {'count': limit, 'includeGuids': 1}, - allowEndpointFailover: false, + final response = await retryTransientMediaServerCall( + operation: 'Plex library hubs', + attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts, + call: (timeout, abort) => _getWithFailover( + '/hubs/sections/$sectionId', + queryParameters: {'count': limit, 'includeGuids': 1}, + timeout: timeout, + abort: abort, + allowEndpointFailover: false, + ), ); final sid = serverId; final sname = serverName; diff --git a/lib/utils/media_server_timeouts.dart b/lib/utils/media_server_timeouts.dart index b9fa70c3..4dbcdc9d 100644 --- a/lib/utils/media_server_timeouts.dart +++ b/lib/utils/media_server_timeouts.dart @@ -11,6 +11,10 @@ class MediaServerTimeouts { /// while Plex wakes idle disks, but should not block forever. static const homeHubAttemptTimeouts = [Duration(seconds: 10), Duration(seconds: 5), Duration(milliseconds: 2500)]; + /// Retry budget for per-library home hub rows (`/hubs/sections/{id}`). These + /// can be slower than the top-level home hub call on remote Plex servers. + static const libraryHubAttemptTimeouts = [Duration(seconds: 10), Duration(seconds: 8), Duration(seconds: 5)]; + /// Timeout for probing a cached/preferred endpoint before falling back to /// the full candidate race (used in [PlexServer.findBestWorkingConnection]). static const preferredEndpointProbe = Duration(milliseconds: 1500); diff --git a/test/services/plex_home_retry_test.dart b/test/services/plex_home_retry_test.dart index 3cad1050..268d786e 100644 --- a/test/services/plex_home_retry_test.dart +++ b/test/services/plex_home_retry_test.dart @@ -94,6 +94,42 @@ void main() { expect(client.config.baseUrl, primary); expect(httpClient.requests.map((r) => r.url.origin), everyElement(primary)); }); + + test('fetchLibraryHubs retries transient failures without switching Plex endpoints', () async { + final db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + addTearDown(db.close); + + const primary = 'http://primary:32400'; + const fallback = 'http://fallback:32400'; + final httpClient = _SequenceClient([ + (_) async => throw TimeoutException('queued behind image downloads'), + (_) async => _jsonResponse(_globalHubsPayload()), + ]); + final client = PlexClient.forTesting( + config: PlexConfig( + baseUrl: primary, + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: 'test', + ), + serverId: 'server-id', + serverName: 'Server', + httpClient: httpClient, + prioritizedEndpoints: const [primary, fallback], + ); + addTearDown(client.close); + + final hubs = await client.fetchLibraryHubs('4', libraryName: 'Movies', limit: 12); + + expect(hubs, hasLength(1)); + expect(client.config.baseUrl, primary); + expect(httpClient.requests, hasLength(2)); + expect(httpClient.requests.map((r) => r.url.origin), everyElement(primary)); + expect(httpClient.requests.map((r) => r.url.path), everyElement('/hubs/sections/4')); + expect(httpClient.requests.map((r) => r.url.queryParameters['count']), everyElement('12')); + }); }); }