diff --git a/lib/exceptions/media_server_exceptions.dart b/lib/exceptions/media_server_exceptions.dart index 84143af1..f28404cf 100644 --- a/lib/exceptions/media_server_exceptions.dart +++ b/lib/exceptions/media_server_exceptions.dart @@ -93,5 +93,12 @@ class MediaServerHttpException extends MediaServerException { type == MediaServerHttpErrorType.receiveTimeout; @override - String toString() => 'MediaServerHttpException(${type.name}: $message)'; + String toString() { + final parts = [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(': ')})'; + } } diff --git a/lib/profiles/active_profile_binder.dart b/lib/profiles/active_profile_binder.dart index d686f109..cda441a9 100644 --- a/lib/profiles/active_profile_binder.dart +++ b/lib/profiles/active_profile_binder.dart @@ -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 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> _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> _connectFromServers( PlexAccountConnection account, String userToken, diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 6f3c46e2..83dcc0f8 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -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 _getClientsApi(String path, {Map? 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 verifyToken(String authToken) async { final response = await _getUser(authToken); @@ -164,8 +183,8 @@ class PlexAuthService { /// Fetch available Plex servers for the authenticated user Future> 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 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); } /// Get home users for the authenticated user Future 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); } @@ -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) || diff --git a/test/profiles/active_profile_binder_test.dart b/test/profiles/active_profile_binder_test.dart index 050c5099..0e945cee 100644 --- a/test/profiles/active_profile_binder_test.dart +++ b/test/profiles/active_profile_binder_test.dart @@ -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 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> refreshTokensForProfile( + PlexAccountConnection connection, { + Duration timeout = MediaServerTimeouts.perServerConnect, + }) async { + refreshCalls++; + lastConnection = connection; + return connection.servers.map((server) => server.clientIdentifier).toSet(); + } } diff --git a/test/services/plex_auth_service_test.dart b/test/services/plex_auth_service_test.dart new file mode 100644 index 00000000..7a214c1a --- /dev/null +++ b/test/services/plex_auth_service_test.dart @@ -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 = []; + 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 = []; + 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().having((e) => e.statusCode, 'statusCode', 401)), + ); + expect(hosts, ['clients.plex.tv']); + }); + }); +} + +Map _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, + }, + ], +}; diff --git a/test/services/plex_server_connection_candidates_test.dart b/test/services/plex_server_connection_candidates_test.dart index a77870ae..6e841466 100644 --- a/test/services/plex_server_connection_candidates_test.dart +++ b/test/services/plex_server_connection_candidates_test.dart @@ -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)); + }); }); } diff --git a/test/utils/media_server_http_exception_test.dart b/test/utils/media_server_http_exception_test.dart index 90439646..d26f079b 100644 --- a/test/utils/media_server_http_exception_test.dart +++ b/test/utils/media_server_http_exception_test.dart @@ -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', () {