fix(plex): stabilize Linux endpoint failover
This commit is contained in:
@@ -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<String>? 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) {
|
||||
|
||||
@@ -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<String>? 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<MediaServerResponse> _getWithFailover(
|
||||
String path, {
|
||||
@@ -294,6 +304,7 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements
|
||||
Map<String, String>? 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<List<PlexHubDto>> _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<String, dynamic>;
|
||||
|
||||
@@ -70,7 +70,10 @@ class MediaServerHttpClient {
|
||||
Map<String, String> 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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/services/plex_auth_service.dart';
|
||||
|
||||
Map<String, dynamic> _serverJson(Map<String, dynamic> connection) => {
|
||||
Map<String, dynamic> _serverJson(Map<String, dynamic> connection) => _serverJsonWithConnections([connection]);
|
||||
|
||||
Map<String, dynamic> _serverJsonWithConnections(List<Map<String, dynamic>> connections) => {
|
||||
'name': 'Home Server',
|
||||
'clientIdentifier': 'srv-1',
|
||||
'accessToken': 'token-1',
|
||||
'owned': true,
|
||||
'connections': [connection],
|
||||
'connections': connections,
|
||||
};
|
||||
|
||||
Map<String, dynamic> _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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user