From a809cbbceff340dd3bb4640eaa909a7458b8dae9 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 12 Jul 2026 00:31:40 +0200 Subject: [PATCH] fix(plex): surface server failures --- lib/services/plex_client.dart | 13 +++-- lib/services/plex_client/parts/live_tv.dart | 10 ++-- lib/utils/media_server_timeouts.dart | 4 ++ .../plex_client_http_contract_test.dart | 56 +++++++++++++++++++ 4 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 test/services/plex_client_http_contract_test.dart diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 7d0c6ebc..66a46e64 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -811,7 +811,7 @@ class PlexClient @override Future checkHealth() async { try { - final response = await _getWithFailover('/'); + final response = await _getWithFailover('/', timeout: MediaServerTimeouts.plexProbe); return response.statusCode == 200 ? HealthStatus.online : HealthStatus.offline; } on MediaServerHttpException catch (e) { if (e.statusCode == 401 || e.statusCode == 403) return HealthStatus.authError; @@ -841,7 +841,8 @@ class PlexClient /// Cancel a running background task by its UUID. Future cancelActivity(String uuid) async { - await _http.delete('/activities/$uuid'); + final response = await _http.delete('/activities/$uuid'); + throwIfHttpError(response); } /// Get library sections @@ -1699,7 +1700,8 @@ class PlexClient /// Remove item from Continue Watching (On Deck) without affecting watch status or progress /// This uses the same endpoint Plex Web uses to hide items from Continue Watching Future removeFromOnDeck(String ratingKey) async { - await _http.put('/actions/removeFromContinueWatching', queryParameters: {'ratingKey': ratingKey}); + final response = await _http.put('/actions/removeFromContinueWatching', queryParameters: {'ratingKey': ratingKey}); + throwIfHttpError(response); } /// Delete a media item from the library @@ -2585,6 +2587,7 @@ class PlexClient '/library/collections', queryParameters: {'type': ?type, 'title': title, 'smart': 0, 'sectionId': sectionId, 'uri': uri}, ); + throwIfHttpError(response); appLogger.d('Create collection response: ${response.statusCode}'); // Extract the collection ID from the response @@ -2713,6 +2716,7 @@ class PlexClient } final response = await _http.post('/playQueues', queryParameters: queryParams); + throwIfHttpError(response); return _parsePlayQueueResponse( response.data, @@ -2951,7 +2955,8 @@ class PlexClient /// Empty trash for a library section Future emptyLibraryTrash(String sectionId) async { - await _http.put('/library/sections/$sectionId/emptyTrash'); + final response = await _http.put('/library/sections/$sectionId/emptyTrash'); + throwIfHttpError(response); } /// Analyze library section diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index 056580b5..b763177f 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -1141,10 +1141,12 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin { /// Update favorite channels on the Plex cloud. Future setFavoriteChannels(List channels) async { try { - await _http.put( - _favoriteChannelsUrl, - body: channels.map((c) => c.toJson()).toList(), - headers: _providerVersionHeader, + await _expectOk( + () => _http.put( + _favoriteChannelsUrl, + body: channels.map((c) => c.toJson()).toList(), + headers: _providerVersionHeader, + ), ); } catch (e) { appLogger.e('Failed to update favorite channels', error: e); diff --git a/lib/utils/media_server_timeouts.dart b/lib/utils/media_server_timeouts.dart index 0b131f29..a20cfebc 100644 --- a/lib/utils/media_server_timeouts.dart +++ b/lib/utils/media_server_timeouts.dart @@ -44,6 +44,10 @@ class MediaServerTimeouts { static const plexTvReceive = Duration(seconds: 10); + /// Authenticated health probe timeout. Health sweeps await every server, so + /// a stale Plex endpoint must not hold the whole sweep for [receive]. + static const plexProbe = Duration(seconds: 8); + /// Probe + token-validate timeout — Jellyfin servers respond fast on /// `/System/Info/Public` and `/Users/Me`. static const jellyfinProbe = Duration(seconds: 8); diff --git a/test/services/plex_client_http_contract_test.dart b/test/services/plex_client_http_contract_test.dart new file mode 100644 index 00000000..d941ffa6 --- /dev/null +++ b/test/services/plex_client_http_contract_test.dart @@ -0,0 +1,56 @@ +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/database/app_database.dart'; +import 'package:plezy/exceptions/media_server_exceptions.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/models/plex/plex_config.dart'; +import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/services/plex_client.dart'; + +void main() { + late AppDatabase db; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + }); + + tearDown(() => db.close()); + + PlexClient makeClient(Future Function(http.Request request) handler) { + return PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example.com', + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: '1', + ), + serverId: ServerId('server-id'), + httpClient: MockClient(handler), + ); + } + + test('void mutations surface non-success responses', () async { + final client = makeClient((_) async => http.Response('rejected', 500)); + addTearDown(client.close); + + for (final mutation in Function()>[ + () => client.cancelActivity('activity-id'), + () => client.removeFromOnDeck('item-id'), + () => client.emptyLibraryTrash('library-id'), + ]) { + await expectLater(mutation(), throwsA(isA())); + } + }); + + test('nullable creation APIs reject non-success response bodies', () async { + final client = makeClient((_) async => http.Response('rejected', 500)); + addTearDown(client.close); + + expect(await client.createCollectionFromUri(sectionId: '1', title: 'Collection', uri: 'server://items'), isNull); + expect(await client.createPlayQueue(uri: 'server://items', type: 'video'), isNull); + }); +}