diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 25760a17..8dd22deb 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -1,5 +1,5 @@ import 'dart:async'; -import 'dart:io' show InternetAddress; +import 'dart:io' show InternetAddress, InternetAddressType; import 'storage_service.dart'; import 'plex_client.dart'; import '../models/plex/plex_user_profile.dart'; @@ -604,11 +604,22 @@ class PlexServer { return null; } - /// Classify [url] against the server's published connections. Returns - /// [PlexNetworkClass.unknown] when the URL doesn't match any known endpoint - /// (e.g. a manually-entered custom URL). + /// Classify [url] against the server's published connections. Custom public + /// HTTPS hostnames are treated as remote so failover avoids LAN-only URLs. PlexNetworkClass networkClassForUrl(String url) { - return _candidateForUrl(url)?.connection.networkClass ?? PlexNetworkClass.unknown; + return _candidateForUrl(url)?.connection.networkClass ?? _classifyCustomPreferredUrl(url); + } + + PlexNetworkClass _classifyCustomPreferredUrl(String url) { + final uri = Uri.tryParse(url); + if (uri == null || uri.scheme.toLowerCase() != 'https') return PlexNetworkClass.unknown; + + final host = _normalizedHost(uri.host); + if (host.isEmpty || _isLocalOrPrivateHost(host)) return PlexNetworkClass.unknown; + + // A manually entered HTTPS reverse-proxy hostname behaves like a remote + // endpoint for failover: LAN candidates often cannot be reached from it. + return PlexNetworkClass.remote; } List<_ConnectionCandidate> _buildPrioritizedCandidates({Set? excludeUrls, PlexNetworkClass? restrictTo}) { @@ -906,6 +917,42 @@ class PlexServer { return bare.toLowerCase(); } + static bool _isLocalOrPrivateHost(String host) { + final address = InternetAddress.tryParse(host); + 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')) { + return true; + } + + return false; + } + + static bool _isPrivateOrLocalAddress(InternetAddress address) { + final bytes = address.rawAddress; + if (address.type == InternetAddressType.IPv4 && bytes.length == 4) { + final a = bytes[0]; + final b = bytes[1]; + return a == 0 || + a == 10 || + a == 127 || + (a == 169 && b == 254) || + (a == 172 && b >= 16 && b <= 31) || + (a == 192 && b == 168); + } + + if (address.type == InternetAddressType.IPv6 && bytes.length == 16) { + final first = bytes[0]; + final second = bytes[1]; + final isLoopback = bytes.take(15).every((b) => b == 0) && bytes[15] == 1; + final isUnspecified = bytes.every((b) => b == 0); + return isLoopback || isUnspecified || (first & 0xfe) == 0xfc || (first == 0xfe && (second & 0xc0) == 0x80); + } + + return false; + } + /// Returns true if the address is known to be unreachable from external /// clients (IPv6 link-local or all-zeros). static bool _isUnreachableAddress(String address) { diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index d6407f64..a63543eb 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -255,6 +255,7 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements defaultHeaders: config.headers, connectTimeout: MediaServerTimeouts.connect, receiveTimeout: MediaServerTimeouts.receive, + usePlexApiClient: true, client: httpClient, ); } @@ -270,9 +271,16 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements required String serverId, String? serverName, required http.Client httpClient, + List? prioritizedEndpoints, List<({String identifier, String gridEndpoint})> epgProviders = const [], }) { - final client = PlexClient._(config, serverId: serverId, serverName: serverName, httpClient: httpClient); + final client = PlexClient._( + config, + serverId: serverId, + serverName: serverName, + httpClient: httpClient, + prioritizedEndpoints: prioritizedEndpoints, + ); client._providerLibraries = const []; client._providerEpg = epgProviders; return client; @@ -287,6 +295,8 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements /// Execute a GET request with endpoint failover retry. On timeout/connection /// errors the next endpoint is tried (once). Non-GET methods are not retried. + /// Optional hub surfaces disable endpoint failover so a slow row does not + /// move the whole client away from an otherwise working endpoint. @override Future _getWithFailover( String path, { @@ -294,6 +304,7 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements Map? headers, Duration? timeout, AbortController? abort, + bool allowEndpointFailover = true, }) async { final gen = _endpointManager?.generation; try { @@ -307,7 +318,8 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements throwIfHttpError(response); return response; } on MediaServerHttpException catch (e) { - if (!_shouldAttemptFailover(e) || + if (!allowEndpointFailover || + !_shouldAttemptFailover(e) || _failoverSwitching || _endpointManager == null || gen != _endpointManager.generation) { @@ -1083,6 +1095,7 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements queryParameters: {'identifier': 'home.continue,home.ondeck', 'count': count, 'includeGuids': 1}, timeout: timeout, abort: abort, + allowEndpointFailover: false, ), ); final sid = serverId; @@ -1532,6 +1545,7 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements final response = await _getWithFailover( '/hubs/sections/$sectionId', queryParameters: {'count': limit, 'includeGuids': 1}, + allowEndpointFailover: false, ); final sid = serverId; final sname = serverName; @@ -1556,6 +1570,7 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements queryParameters: {'count': limit, 'includeGuids': 1}, timeout: timeout, abort: abort, + allowEndpointFailover: false, ), ); final sid = serverId; @@ -1571,7 +1586,11 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements /// Get related hubs for a specific metadata item (collections, similar, "more from" director/actor) Future> _getRelatedHubs(String ratingKey, {int count = 10}) async { try { - final response = await _getWithFailover('/hubs/metadata/$ratingKey/related', queryParameters: {'count': count}); + final response = await _getWithFailover( + '/hubs/metadata/$ratingKey/related', + queryParameters: {'count': count}, + allowEndpointFailover: false, + ); final sid = serverId; final sname = serverName; final data = response.data as Map; diff --git a/lib/utils/media_server_http_client.dart b/lib/utils/media_server_http_client.dart index 4c382614..3133d088 100644 --- a/lib/utils/media_server_http_client.dart +++ b/lib/utils/media_server_http_client.dart @@ -70,7 +70,10 @@ class MediaServerHttpClient { Map defaultHeaders = const {}, this.connectTimeout = const Duration(seconds: 10), this.receiveTimeout = const Duration(seconds: 120), - }) : _client = client ?? platform.createPlatformClient(), + // Plex home loads fan out many HTTP/1.1 calls on Linux. Keep that tuning + // opt-in so generic tracker/auth clients stay disposable and closeable. + bool usePlexApiClient = false, + }) : _client = client ?? (usePlexApiClient ? platform.createPlexApiClient() : platform.createPlatformClient()), defaultHeaders = Map.of(defaultHeaders); /// The underlying [http.Client] for direct streaming / multipart requests. diff --git a/lib/utils/platform_http_client_io.dart b/lib/utils/platform_http_client_io.dart index 6136f606..40520f6e 100644 --- a/lib/utils/platform_http_client_io.dart +++ b/lib/utils/platform_http_client_io.dart @@ -1,4 +1,4 @@ -import 'dart:io' show Platform; +import 'dart:io' show HttpClient, Platform; import 'package:cronet_http/cronet_http.dart'; import 'package:cupertino_http/cupertino_http.dart'; @@ -57,3 +57,15 @@ http.Client createPlatformClient() { _logPlatformClient(Platform.operatingSystem, 'IOClient'); return IOClient(); } + +http.Client createPlexApiClient() { + if (Platform.isLinux) { + _logPlatformClient('linux', 'IOClient (Plex API tuned)'); + return IOClient( + HttpClient() + ..maxConnectionsPerHost = 12 + ..idleTimeout = const Duration(seconds: 90), + ); + } + return createPlatformClient(); +} diff --git a/lib/utils/platform_http_client_stub.dart b/lib/utils/platform_http_client_stub.dart index 8a10446f..513718e8 100644 --- a/lib/utils/platform_http_client_stub.dart +++ b/lib/utils/platform_http_client_stub.dart @@ -3,3 +3,5 @@ import 'package:http/http.dart' as http; /// Fallback stub — should never be called; actual implementation is selected /// via conditional imports in `media_server_http_client.dart`. http.Client createPlatformClient() => throw UnsupportedError('No platform HTTP client available'); + +http.Client createPlexApiClient() => throw UnsupportedError('No platform HTTP client available'); diff --git a/test/services/plex_home_retry_test.dart b/test/services/plex_home_retry_test.dart index cc2f5e5d..3cad1050 100644 --- a/test/services/plex_home_retry_test.dart +++ b/test/services/plex_home_retry_test.dart @@ -61,6 +61,39 @@ void main() { expect(httpClient.requests.map((r) => r.url.path), everyElement('/hubs')); expect(httpClient.requests.map((r) => r.url.queryParameters['count']), everyElement('12')); }); + + test('fetchGlobalHubs 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 cold handshakes'), + (_) 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.fetchGlobalHubs(limit: 12); + + expect(hubs, hasLength(1)); + expect(client.config.baseUrl, primary); + expect(httpClient.requests.map((r) => r.url.origin), everyElement(primary)); + }); }); } diff --git a/test/services/plex_server_connection_candidates_test.dart b/test/services/plex_server_connection_candidates_test.dart index 1374826b..a77870ae 100644 --- a/test/services/plex_server_connection_candidates_test.dart +++ b/test/services/plex_server_connection_candidates_test.dart @@ -1,12 +1,14 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/services/plex_auth_service.dart'; -Map _serverJson(Map connection) => { +Map _serverJson(Map connection) => _serverJsonWithConnections([connection]); + +Map _serverJsonWithConnections(List> connections) => { 'name': 'Home Server', 'clientIdentifier': 'srv-1', 'accessToken': 'token-1', 'owned': true, - 'connections': [connection], + 'connections': connections, }; Map _connectionJson({ @@ -108,5 +110,41 @@ void main() { expect(urls, contains('https://plex.example.com')); expect(urls, isNot(contains('http://plex.example.com:32400'))); }); + + test('treats custom public HTTPS preferred endpoint as remote for failover filtering', () { + const preferred = 'https://plex.example.com'; + const localPlexDirect = 'https://192-168-1-50.abc.plex.direct:32400'; + const remotePlexDirect = 'https://203-0-113-10.abc.plex.direct:32400'; + final server = PlexServer.fromJson( + _serverJsonWithConnections([ + _connectionJson(protocol: 'https', address: '192.168.1.50', port: 32400, uri: localPlexDirect, local: true), + _connectionJson(protocol: 'https', address: '203.0.113.10', port: 32400, uri: remotePlexDirect), + ]), + ); + + final urls = server.prioritizedEndpointUrls(preferredFirst: preferred); + + expect(server.networkClassForUrl(preferred), PlexNetworkClass.remote); + expect(urls.first, preferred); + expect(urls, contains(remotePlexDirect)); + expect(urls, isNot(contains(localPlexDirect))); + expect(urls, isNot(contains('http://192.168.1.50:32400'))); + }); + + test('does not treat custom local-looking preferred endpoint as remote', () { + const preferred = 'https://plex.lan'; + 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)); + }); }); }