fix(plex): tolerate transient resource failures
This commit is contained in:
@@ -93,5 +93,12 @@ class MediaServerHttpException extends MediaServerException {
|
||||
type == MediaServerHttpErrorType.receiveTimeout;
|
||||
|
||||
@override
|
||||
String toString() => 'MediaServerHttpException(${type.name}: $message)';
|
||||
String toString() {
|
||||
final parts = <String>[type.name];
|
||||
if (statusCode != null) parts.add('HTTP $statusCode');
|
||||
if (message.isNotEmpty) parts.add(message);
|
||||
final uri = requestUri;
|
||||
if (uri != null) parts.add('${uri.host}${uri.path}');
|
||||
return 'MediaServerHttpException(${parts.join(': ')})';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +306,9 @@ class ActiveProfileBinder {
|
||||
'ActiveProfileBinder: fetchServers failed with cached token for ${profile.displayName}',
|
||||
error: e,
|
||||
);
|
||||
if (e.isTransient) {
|
||||
return _connectFromCachedServers(account, cachedToken, profile.displayName, error: e);
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
} catch (e, st) {
|
||||
@@ -409,6 +412,11 @@ class ActiveProfileBinder {
|
||||
userToken = null;
|
||||
} else {
|
||||
appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e);
|
||||
if (e.isTransient) {
|
||||
final ids = await _connectFromCachedServers(conn, userToken, profile.displayName, error: e);
|
||||
if (ids.isNotEmpty) await profileConnections.markUsed(profile.id, conn.id);
|
||||
return ids;
|
||||
}
|
||||
return const {};
|
||||
}
|
||||
} catch (e, st) {
|
||||
@@ -427,6 +435,14 @@ class ActiveProfileBinder {
|
||||
userToken = minted;
|
||||
try {
|
||||
servers = await auth.fetchServers(userToken);
|
||||
} on MediaServerHttpException catch (e) {
|
||||
appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e);
|
||||
if (e.isTransient) {
|
||||
final ids = await _connectFromCachedServers(conn, userToken, profile.displayName, error: e);
|
||||
if (ids.isNotEmpty) await profileConnections.markUsed(profile.id, conn.id);
|
||||
return ids;
|
||||
}
|
||||
return const {};
|
||||
} catch (e, st) {
|
||||
appLogger.w('ActiveProfileBinder: fetchServers failed for ${profile.displayName}', error: e, stackTrace: st);
|
||||
return const {};
|
||||
@@ -465,6 +481,12 @@ class ActiveProfileBinder {
|
||||
final List<PlexServer> servers;
|
||||
try {
|
||||
servers = await auth.fetchServers(userToken);
|
||||
} on MediaServerHttpException catch (e, st) {
|
||||
appLogger.w('ActiveProfileBinder: fetchServers failed for $profileLabel', error: e, stackTrace: st);
|
||||
if (e.isTransient) {
|
||||
return _connectFromCachedServers(account, userToken, profileLabel, error: e, stackTrace: st);
|
||||
}
|
||||
return const {};
|
||||
} catch (e, st) {
|
||||
appLogger.w('ActiveProfileBinder: fetchServers failed for $profileLabel', error: e, stackTrace: st);
|
||||
return const {};
|
||||
@@ -472,6 +494,23 @@ class ActiveProfileBinder {
|
||||
return _connectFromServers(account, userToken, servers, profileLabel);
|
||||
}
|
||||
|
||||
Future<Set<String>> _connectFromCachedServers(
|
||||
PlexAccountConnection account,
|
||||
String userToken,
|
||||
String profileLabel, {
|
||||
Object? error,
|
||||
StackTrace? stackTrace,
|
||||
}) async {
|
||||
if (account.servers.isEmpty) return const {};
|
||||
appLogger.w(
|
||||
'ActiveProfileBinder: using cached Plex server metadata for $profileLabel after resource refresh failed',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
final servers = account.servers.map((server) => server.withAccessToken(userToken)).toList(growable: false);
|
||||
return _connectFromServers(account, userToken, servers, profileLabel);
|
||||
}
|
||||
|
||||
Future<Set<String>> _connectFromServers(
|
||||
PlexAccountConnection account,
|
||||
String userToken,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show InternetAddress, InternetAddressType, Platform;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'storage_service.dart';
|
||||
import 'plex_client.dart';
|
||||
@@ -54,6 +55,14 @@ class PlexAuthService {
|
||||
|
||||
PlexAuthService._(this._http, this._clientIdentifier, this._appVersion, this._platformVersion);
|
||||
|
||||
@visibleForTesting
|
||||
PlexAuthService.forTesting({
|
||||
required MediaServerHttpClient http,
|
||||
String clientIdentifier = 'test-client',
|
||||
String appVersion = 'test',
|
||||
String platformVersion = 'test',
|
||||
}) : this._(http, clientIdentifier, appVersion, platformVersion);
|
||||
|
||||
/// Close the underlying HTTP client. Call when the service is short-lived
|
||||
/// (created for a single API call) to avoid leaking sockets.
|
||||
void dispose() => _http.close();
|
||||
@@ -95,6 +104,16 @@ class PlexAuthService {
|
||||
|
||||
void _checkStatus(MediaServerResponse response) => throwIfHttpError(response);
|
||||
|
||||
Future<MediaServerResponse> _getClientsApi(String path, {Map<String, String>? headers, Duration? timeout}) async {
|
||||
try {
|
||||
return await _http.get('$_clientsApi$path', headers: headers, timeout: timeout);
|
||||
} on MediaServerHttpException catch (e) {
|
||||
if (!e.isTransient) rethrow;
|
||||
appLogger.w('Plex clients API request failed; retrying via plex.tv', error: {'path': path, 'type': e.type.name});
|
||||
return _http.get('$_plexApiBase$path', headers: headers, timeout: timeout);
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify if a plex.tv token is valid
|
||||
Future<bool> verifyToken(String authToken) async {
|
||||
final response = await _getUser(authToken);
|
||||
@@ -164,8 +183,8 @@ class PlexAuthService {
|
||||
|
||||
/// Fetch available Plex servers for the authenticated user
|
||||
Future<List<PlexServer>> fetchServers(String authToken) async {
|
||||
final response = await _http.get(
|
||||
'$_clientsApi/resources?includeHttps=1&includeRelay=1&includeIPv6=1',
|
||||
final response = await _getClientsApi(
|
||||
'/resources?includeHttps=1&includeRelay=1&includeIPv6=1',
|
||||
headers: _getCommonHeaders(authToken: authToken),
|
||||
);
|
||||
|
||||
@@ -209,14 +228,14 @@ class PlexAuthService {
|
||||
|
||||
/// Get user profile with preferences (audio/subtitle settings)
|
||||
Future<PlexUserProfile> getUserProfile(String authToken) async {
|
||||
final response = await _http.get('$_clientsApi/user', headers: _getCommonHeaders(authToken: authToken));
|
||||
final response = await _getClientsApi('/user', headers: _getCommonHeaders(authToken: authToken));
|
||||
_checkStatus(response);
|
||||
return PlexUserProfile.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
/// Get home users for the authenticated user
|
||||
Future<PlexHome> getHomeUsers(String authToken) async {
|
||||
final response = await _http.get('$_clientsApi/home/users', headers: _getCommonHeaders(authToken: authToken));
|
||||
final response = await _getClientsApi('/home/users', headers: _getCommonHeaders(authToken: authToken));
|
||||
_checkStatus(response);
|
||||
return PlexHome.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
@@ -374,6 +393,20 @@ class PlexServer {
|
||||
};
|
||||
}
|
||||
|
||||
PlexServer withAccessToken(String token) {
|
||||
return PlexServer(
|
||||
name: name,
|
||||
clientIdentifier: clientIdentifier,
|
||||
accessToken: token,
|
||||
connections: connections,
|
||||
owned: owned,
|
||||
product: product,
|
||||
platform: platform,
|
||||
lastSeenAt: lastSeenAt,
|
||||
presence: presence,
|
||||
);
|
||||
}
|
||||
|
||||
/// Check if server is online using the presence field
|
||||
bool get isOnline => presence;
|
||||
|
||||
@@ -930,7 +963,11 @@ class PlexServer {
|
||||
if (address != null) return _isPrivateOrLocalAddress(address);
|
||||
|
||||
if (host == 'localhost' || !host.contains('.')) return true;
|
||||
if (host.endsWith('.local') || host.endsWith('.lan') || host.endsWith('.home.arpa') || host.endsWith('.internal')) {
|
||||
if (host.endsWith('.local') ||
|
||||
host.endsWith('.lan') ||
|
||||
host.endsWith('.home.arpa') ||
|
||||
host.endsWith('.internal') ||
|
||||
host.endsWith('.ts.net')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -944,6 +981,7 @@ class PlexServer {
|
||||
final b = bytes[1];
|
||||
return a == 0 ||
|
||||
a == 10 ||
|
||||
(a == 100 && b >= 64 && b <= 127) ||
|
||||
a == 127 ||
|
||||
(a == 169 && b == 254) ||
|
||||
(a == 172 && b >= 16 && b <= 31) ||
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
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/connection/connection_registry.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/models/plex/plex_home_user.dart';
|
||||
import 'package:plezy/profiles/active_profile_binder.dart';
|
||||
import 'package:plezy/profiles/active_profile_provider.dart';
|
||||
import 'package:plezy/profiles/plex_home_service.dart';
|
||||
import 'package:plezy/profiles/profile.dart';
|
||||
import 'package:plezy/profiles/profile_connection.dart';
|
||||
import 'package:plezy/profiles/profile_connection_registry.dart';
|
||||
import 'package:plezy/profiles/profile_registry.dart';
|
||||
import 'package:plezy/providers/multi_server_provider.dart';
|
||||
import 'package:plezy/services/data_aggregation_service.dart';
|
||||
import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/services/plex_auth_service.dart';
|
||||
import 'package:plezy/services/storage_service.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
import 'package:plezy/utils/media_server_timeouts.dart';
|
||||
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
@@ -27,6 +35,7 @@ void main() {
|
||||
late ActiveProfileBinder binder;
|
||||
late StorageService storage;
|
||||
late bool shouldDeferInitialBind;
|
||||
late List<PlexHomeUser> fetchedHomeUsers;
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
@@ -35,11 +44,12 @@ void main() {
|
||||
profileConnections = ProfileConnectionRegistry(db);
|
||||
profiles = ProfileRegistry(db);
|
||||
storage = await StorageService.getInstance();
|
||||
fetchedHomeUsers = const [];
|
||||
plexHome = PlexHomeService(
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
plexHomeUserFetcher: (_) async => const [],
|
||||
plexHomeUserFetcher: (_) async => fetchedHomeUsers,
|
||||
);
|
||||
activeProfile = ActiveProfileProvider(
|
||||
registry: profiles,
|
||||
@@ -161,4 +171,135 @@ void main() {
|
||||
expect(binder.consumePlexHomePreVerified('plex-home-a'), isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('Plex Home server refresh fallback', () {
|
||||
Future<({String profileId, _CapturingMultiServerManager manager})> preparePlexHomeBind({
|
||||
required bool protected,
|
||||
required http.Client httpClient,
|
||||
}) async {
|
||||
binder.dispose();
|
||||
multiServerProvider.dispose();
|
||||
|
||||
final capturingManager = _CapturingMultiServerManager();
|
||||
manager = capturingManager;
|
||||
multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
|
||||
binder = ActiveProfileBinder(
|
||||
activeProfile: activeProfile,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
serverManager: manager,
|
||||
multiServerProvider: multiServerProvider,
|
||||
pinPrompt: (_, {String? errorMessage}) async => null,
|
||||
shouldDeferInitialBind: (_) async => false,
|
||||
plexAuth: PlexAuthService.forTesting(http: MediaServerHttpClient(client: httpClient)),
|
||||
);
|
||||
|
||||
final account = PlexAccountConnection(
|
||||
id: 'plex.account',
|
||||
accountToken: 'account-token',
|
||||
clientIdentifier: 'client-id',
|
||||
accountLabel: 'Owner',
|
||||
servers: [_server(accessToken: 'account-server-token')],
|
||||
createdAt: DateTime(2026, 1, 1),
|
||||
);
|
||||
await connections.upsert(account);
|
||||
|
||||
final homeUser = PlexHomeUser(
|
||||
id: 1,
|
||||
uuid: 'home-user-uuid',
|
||||
title: 'Home User',
|
||||
thumb: '',
|
||||
hasPassword: protected,
|
||||
restricted: false,
|
||||
updatedAt: null,
|
||||
admin: true,
|
||||
guest: false,
|
||||
protected: protected,
|
||||
);
|
||||
fetchedHomeUsers = [homeUser];
|
||||
final profileId = plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: homeUser.uuid);
|
||||
await storage.savePlexHomeUsersCache(account.id, [homeUser.toJson()]);
|
||||
await profileConnections.upsert(
|
||||
ProfileConnection(
|
||||
profileId: profileId,
|
||||
connectionId: account.id,
|
||||
userToken: 'home-user-token',
|
||||
userIdentifier: homeUser.uuid,
|
||||
tokenAcquiredAt: DateTime(2026, 1, 1),
|
||||
),
|
||||
);
|
||||
await storage.setActiveProfileId(profileId);
|
||||
await activeProfile.initialize();
|
||||
return (profileId: profileId, manager: capturingManager);
|
||||
}
|
||||
|
||||
test('uses cached server metadata with the active user token after transient resources failure', () async {
|
||||
final prepared = await preparePlexHomeBind(
|
||||
protected: false,
|
||||
httpClient: MockClient((request) async {
|
||||
throw http.ClientException('DNS failed', request.url);
|
||||
}),
|
||||
);
|
||||
|
||||
await binder.rebindActive();
|
||||
|
||||
expect(activeProfile.lastBindingSucceeded, isTrue);
|
||||
expect(binder.debugLastBoundProfileId, prepared.profileId);
|
||||
expect(prepared.manager.refreshCalls, 1);
|
||||
expect(prepared.manager.lastConnection?.servers.single.accessToken, 'home-user-token');
|
||||
expect(prepared.manager.lastConnection?.servers.single.clientIdentifier, 'srv-1');
|
||||
});
|
||||
|
||||
test('does not use cached server metadata after cached token auth failure', () async {
|
||||
final prepared = await preparePlexHomeBind(
|
||||
protected: true,
|
||||
httpClient: MockClient((request) async {
|
||||
return http.Response('{"errors":[]}', 401, headers: {'content-type': 'application/json'});
|
||||
}),
|
||||
);
|
||||
|
||||
await binder.rebindActive();
|
||||
|
||||
expect(activeProfile.lastBindingSucceeded, isFalse);
|
||||
expect(prepared.manager.refreshCalls, 0);
|
||||
final pc = await profileConnections.get(prepared.profileId, 'plex.account');
|
||||
expect(pc?.userToken, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
PlexServer _server({required String accessToken}) {
|
||||
return PlexServer(
|
||||
name: 'Home Server',
|
||||
clientIdentifier: 'srv-1',
|
||||
accessToken: accessToken,
|
||||
connections: [
|
||||
PlexConnection(
|
||||
protocol: 'https',
|
||||
address: '192.168.1.3',
|
||||
port: 32400,
|
||||
uri: 'https://192-168-1-3.machine.plex.direct:32400',
|
||||
local: true,
|
||||
relay: false,
|
||||
ipv6: false,
|
||||
),
|
||||
],
|
||||
owned: true,
|
||||
presence: true,
|
||||
);
|
||||
}
|
||||
|
||||
class _CapturingMultiServerManager extends MultiServerManager {
|
||||
int refreshCalls = 0;
|
||||
PlexAccountConnection? lastConnection;
|
||||
|
||||
@override
|
||||
Future<Set<String>> refreshTokensForProfile(
|
||||
PlexAccountConnection connection, {
|
||||
Duration timeout = MediaServerTimeouts.perServerConnect,
|
||||
}) async {
|
||||
refreshCalls++;
|
||||
lastConnection = connection;
|
||||
return connection.servers.map((server) => server.clientIdentifier).toSet();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:plezy/exceptions/media_server_exceptions.dart';
|
||||
import 'package:plezy/services/plex_auth_service.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
|
||||
void main() {
|
||||
group('PlexAuthService', () {
|
||||
test('fetchServers retries resources through plex.tv after clients host transport failure', () async {
|
||||
final hosts = <String>[];
|
||||
final client = MediaServerHttpClient(
|
||||
client: MockClient((request) async {
|
||||
hosts.add(request.url.host);
|
||||
if (request.url.host == 'clients.plex.tv') {
|
||||
throw http.ClientException('DNS failed', request.url);
|
||||
}
|
||||
return http.Response(jsonEncode([_serverJson()]), 200, headers: {'content-type': 'application/json'});
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
final auth = PlexAuthService.forTesting(http: client);
|
||||
|
||||
final servers = await auth.fetchServers('token');
|
||||
|
||||
expect(hosts, ['clients.plex.tv', 'plex.tv']);
|
||||
expect(servers.single.clientIdentifier, 'srv-1');
|
||||
});
|
||||
|
||||
test('fetchServers does not retry canonical host for HTTP auth failures', () async {
|
||||
final hosts = <String>[];
|
||||
final client = MediaServerHttpClient(
|
||||
client: MockClient((request) async {
|
||||
hosts.add(request.url.host);
|
||||
return http.Response('{"errors":[]}', 401, headers: {'content-type': 'application/json'});
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
final auth = PlexAuthService.forTesting(http: client);
|
||||
|
||||
await expectLater(
|
||||
auth.fetchServers('bad-token'),
|
||||
throwsA(isA<MediaServerHttpException>().having((e) => e.statusCode, 'statusCode', 401)),
|
||||
);
|
||||
expect(hosts, ['clients.plex.tv']);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> _serverJson() => {
|
||||
'name': 'Home Server',
|
||||
'clientIdentifier': 'srv-1',
|
||||
'accessToken': 'server-token',
|
||||
'owned': true,
|
||||
'provides': 'server',
|
||||
'connections': [
|
||||
{
|
||||
'protocol': 'https',
|
||||
'address': '192.168.1.3',
|
||||
'port': 32400,
|
||||
'uri': 'https://192-168-1-3.machine.plex.direct:32400',
|
||||
'local': true,
|
||||
'relay': false,
|
||||
'IPv6': false,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -146,5 +146,37 @@ void main() {
|
||||
expect(urls.first, preferred);
|
||||
expect(urls, contains(localPlexDirect));
|
||||
});
|
||||
|
||||
test('does not treat Tailscale CGNAT preferred endpoint as remote', () {
|
||||
const preferred = 'https://100.90.80.70:32400';
|
||||
const localPlexDirect = 'https://192-168-1-50.abc.plex.direct:32400';
|
||||
final server = PlexServer.fromJson(
|
||||
_serverJsonWithConnections([
|
||||
_connectionJson(protocol: 'https', address: '192.168.1.50', port: 32400, uri: localPlexDirect, local: true),
|
||||
]),
|
||||
);
|
||||
|
||||
final urls = server.prioritizedEndpointUrls(preferredFirst: preferred);
|
||||
|
||||
expect(server.networkClassForUrl(preferred), PlexNetworkClass.unknown);
|
||||
expect(urls.first, preferred);
|
||||
expect(urls, contains(localPlexDirect));
|
||||
});
|
||||
|
||||
test('does not treat Tailscale MagicDNS preferred endpoint as remote', () {
|
||||
const preferred = 'https://plex.tailnet.ts.net:32400';
|
||||
const localPlexDirect = 'https://192-168-1-50.abc.plex.direct:32400';
|
||||
final server = PlexServer.fromJson(
|
||||
_serverJsonWithConnections([
|
||||
_connectionJson(protocol: 'https', address: '192.168.1.50', port: 32400, uri: localPlexDirect, local: true),
|
||||
]),
|
||||
);
|
||||
|
||||
final urls = server.prioritizedEndpointUrls(preferredFirst: preferred);
|
||||
|
||||
expect(server.networkClassForUrl(preferred), PlexNetworkClass.unknown);
|
||||
expect(urls.first, preferred);
|
||||
expect(urls, contains(localPlexDirect));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -89,6 +89,15 @@ void main() {
|
||||
final e = MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'halt');
|
||||
expect(e.toString(), 'MediaServerHttpException(cancelled: halt)');
|
||||
});
|
||||
|
||||
test('toString includes host and path without query parameters', () {
|
||||
final e = MediaServerHttpException(
|
||||
type: MediaServerHttpErrorType.connectionError,
|
||||
message: 'dns failed',
|
||||
requestUri: Uri.parse('https://clients.plex.tv/api/v2/resources?X-Plex-Token=secret'),
|
||||
);
|
||||
expect(e.toString(), 'MediaServerHttpException(connectionError: dns failed: clients.plex.tv/api/v2/resources)');
|
||||
});
|
||||
});
|
||||
|
||||
group('MediaServerHttpException.isTransient', () {
|
||||
|
||||
Reference in New Issue
Block a user