Files
plezy/lib/services/plex_auth_service.dart
T
Matt Vogel e9218e0a3d Fix duplicate Plex notifications and Flutter app name on login
Connection probe requests in testConnectionWithLatency were sent without
X-Plex-Client-Identifier, X-Plex-Product, or X-Plex-Device-Name headers.
Plex treated each anonymous probe as a new unknown device and fired a
"New Device" notification for every server tested (one per shared/owned
server), while displaying "Flutter" as the device name from the HTTP
user-agent.

Pass clientIdentifier through findBestWorkingConnection and all connection
test helpers so every probe request identifies itself as "Plezy" with the
persistent client UUID. This prevents spurious notifications and ensures
the device shows the correct app name in Plex's device list.

https://claude.ai/code/session_01V5VraujkNmk5GGPLyZ33fN
2026-02-26 08:21:06 -05:00

959 lines
32 KiB
Dart

import 'dart:async';
import 'package:dio/dio.dart';
import 'package:uuid/uuid.dart';
import 'storage_service.dart';
import 'plex_client.dart';
import '../models/plex_user_profile.dart';
import '../models/plex_home.dart';
import '../models/user_switch_response.dart';
import '../utils/app_logger.dart';
import '../utils/connection_constants.dart';
/// Redacts the middle of an IP address or hostname for safe logging.
/// E.g. `192.168.1.50` → `192.***.***.50`, `my.server.example.com` → `my.***.***. com`.
String _redactHost(String host) {
// Strip brackets from IPv6
final bare = host.startsWith('[') && host.endsWith(']') ? host.substring(1, host.length - 1) : host;
// IPv6
if (bare.contains(':')) {
final parts = bare.split(':');
if (parts.length > 2) {
return '${parts.first}:***:${parts.last}';
}
return bare;
}
// IPv4
final ipParts = bare.split('.');
if (ipParts.length == 4 && ipParts.every((p) => int.tryParse(p) != null)) {
return '${ipParts.first}.***.***.${ipParts.last}';
}
// Hostname
final hostParts = bare.split('.');
if (hostParts.length >= 3) {
return '${hostParts.first}.***.${hostParts.last}';
}
return bare;
}
class PlexAuthService {
static const String _appName = 'Plezy';
static const String _plexApiBase = 'https://plex.tv/api/v2';
static const String _clientsApi = 'https://clients.plex.tv/api/v2';
final Dio _dio;
final String _clientIdentifier;
PlexAuthService._(this._dio, this._clientIdentifier);
static Future<PlexAuthService> create() async {
final storage = await StorageService.getInstance();
final dio = Dio(BaseOptions(
connectTimeout: ConnectionTimeouts.plexTvConnect,
receiveTimeout: ConnectionTimeouts.plexTvReceive,
));
// Get or create client identifier
String? clientIdentifier = storage.getClientIdentifier();
if (clientIdentifier == null) {
clientIdentifier = const Uuid().v4();
await storage.saveClientIdentifier(clientIdentifier);
}
return PlexAuthService._(dio, clientIdentifier);
}
String get clientIdentifier => _clientIdentifier;
Options _getCommonOptions({String? authToken}) {
final headers = {
'Accept': 'application/json',
'X-Plex-Product': _appName,
'X-Plex-Client-Identifier': _clientIdentifier,
};
if (authToken != null) {
headers['X-Plex-Token'] = authToken;
}
return Options(headers: headers);
}
Future<Response> _getUser(String authToken) {
return _dio.get('$_plexApiBase/user', options: _getCommonOptions(authToken: authToken));
}
/// Verify if a plex.tv token is valid
Future<bool> verifyToken(String authToken) async {
try {
await _getUser(authToken);
return true;
} catch (e) {
return false;
}
}
/// Create a PIN for authentication
Future<Map<String, dynamic>> createPin() async {
final response = await _dio.post('$_plexApiBase/pins?strong=true', options: _getCommonOptions());
return response.data as Map<String, dynamic>;
}
/// Construct the Auth App URL for the user to visit
String getAuthUrl(String pinCode) {
final params = {'clientID': _clientIdentifier, 'code': pinCode, 'context[device][product]': _appName};
final queryString = params.entries
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
.join('&');
return 'https://app.plex.tv/auth#?$queryString';
}
/// Poll the PIN to check if it has been claimed
Future<String?> checkPin(int pinId) async {
try {
final response = await _dio.get('$_plexApiBase/pins/$pinId', options: _getCommonOptions());
final data = response.data as Map<String, dynamic>;
return data['authToken'] as String?;
} catch (e) {
return null;
}
}
/// Poll the PIN until it's claimed or timeout
Future<String?> pollPinUntilClaimed(
int pinId, {
Duration timeout = const Duration(minutes: 2),
bool Function()? shouldCancel,
}) async {
final endTime = DateTime.now().add(timeout);
while (DateTime.now().isBefore(endTime)) {
// Check if polling should be cancelled
if (shouldCancel != null && shouldCancel()) {
return null;
}
final token = await checkPin(pinId);
if (token != null) {
return token;
}
// Wait 1 second before polling again
await Future.delayed(const Duration(seconds: 1));
}
return null; // Timeout
}
/// Fetch available Plex servers for the authenticated user
Future<List<PlexServer>> fetchServers(String authToken) async {
final response = await _dio.get(
'$_clientsApi/resources?includeHttps=1&includeRelay=1&includeIPv6=1',
options: _getCommonOptions(authToken: authToken),
);
final List<dynamic> resources = response.data as List<dynamic>;
// Filter for server resources and map to PlexServer objects
final servers = <PlexServer>[];
final invalidServers = <Map<String, dynamic>>[];
for (final resource in resources.where((r) => r['provides'] == 'server')) {
try {
final server = PlexServer.fromJson(resource as Map<String, dynamic>);
servers.add(server);
} catch (e) {
// Collect invalid servers for debugging
invalidServers.add(resource as Map<String, dynamic>);
continue;
}
}
// If we have invalid servers but some valid ones, that's okay
// If we have no valid servers but some invalid ones, throw with debug info
if (servers.isEmpty && invalidServers.isNotEmpty) {
throw ServerParsingException(
'No valid servers found. All ${invalidServers.length} server(s) have malformed data.',
invalidServers,
);
}
return servers;
}
/// Get user information
Future<Map<String, dynamic>> getUserInfo(String authToken) async {
final response = await _getUser(authToken);
return response.data as Map<String, dynamic>;
}
/// Get user profile with preferences (audio/subtitle settings)
Future<PlexUserProfile> getUserProfile(String authToken) async {
final response = await _dio.get('$_clientsApi/user', options: _getCommonOptions(authToken: authToken));
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 _dio.get('$_clientsApi/home/users', options: _getCommonOptions(authToken: authToken));
return PlexHome.fromJson(response.data as Map<String, dynamic>);
}
/// Switch to a different user in the home
Future<UserSwitchResponse> switchToUser(String userUUID, String currentToken, {String? pin}) async {
final queryParams = {
'includeSubscriptions': '1',
'includeProviders': '1',
'includeSettings': '1',
'includeSharedSettings': '1',
'X-Plex-Product': _appName,
'X-Plex-Version': '1.1.0',
'X-Plex-Client-Identifier': _clientIdentifier,
'X-Plex-Platform': 'Flutter',
'X-Plex-Platform-Version': '3.8.1',
'X-Plex-Token': currentToken,
'X-Plex-Language': 'en',
'pin': ?pin,
};
final queryString = queryParams.entries
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
.join('&');
final response = await _dio.post(
'$_clientsApi/home/users/$userUUID/switch?$queryString',
options: Options(headers: {'Accept': 'application/json', 'Content-Length': '0'}),
);
return UserSwitchResponse.fromJson(response.data as Map<String, dynamic>);
}
}
/// Helper class to track connection candidates during testing
class _ConnectionCandidate {
final PlexConnection connection;
final String url;
final bool isPlexDirectUri;
final bool isHttps;
_ConnectionCandidate(this.connection, this.url, this.isPlexDirectUri, this.isHttps);
}
/// Represents a Plex Media Server
class PlexServer {
final String name;
final String clientIdentifier;
final String accessToken;
final List<PlexConnection> connections;
final bool owned;
final String? product;
final String? platform;
final DateTime? lastSeenAt;
final bool presence;
PlexServer({
required this.name,
required this.clientIdentifier,
required this.accessToken,
required this.connections,
required this.owned,
this.product,
this.platform,
this.lastSeenAt,
this.presence = false,
});
factory PlexServer.fromJson(Map<String, dynamic> json) {
// Validate required fields first
if (!_isValidServerJson(json)) {
throw FormatException(
'Invalid server data: missing required fields (name, clientIdentifier, accessToken, or connections)',
);
}
final List<dynamic> connectionsJson = json['connections'] as List<dynamic>;
final connections = <PlexConnection>[];
// Parse connections and generate HTTP fallbacks for HTTPS connections
for (final c in connectionsJson) {
try {
final connection = PlexConnection.fromJson(c as Map<String, dynamic>);
connections.add(connection);
// Generate HTTP fallback for HTTPS connections
if (connection.protocol == 'https') {
connections.add(connection.toHttpFallback());
}
} catch (e) {
// Skip invalid connections rather than failing the entire server
continue;
}
}
// If no valid connections were parsed, this server is unusable
if (connections.isEmpty) {
throw FormatException('Server has no valid connections');
}
DateTime? lastSeenAt;
if (json['lastSeenAt'] != null) {
try {
lastSeenAt = DateTime.parse(json['lastSeenAt'] as String);
} catch (e) {
lastSeenAt = null;
}
}
return PlexServer(
name: json['name'] as String, // Safe because validated above
clientIdentifier: json['clientIdentifier'] as String, // Safe because validated above
accessToken: json['accessToken'] as String, // Safe because validated above
connections: connections,
owned: json['owned'] as bool? ?? false,
product: json['product'] as String?,
platform: json['platform'] as String?,
lastSeenAt: lastSeenAt,
presence: json['presence'] as bool? ?? false,
);
}
/// Validates that server JSON contains all required fields with correct types
static bool _isValidServerJson(Map<String, dynamic> json) {
// Check for required string fields
if (json['name'] is! String || (json['name'] as String).isEmpty) {
return false;
}
if (json['clientIdentifier'] is! String || (json['clientIdentifier'] as String).isEmpty) {
return false;
}
if (json['accessToken'] is! String || (json['accessToken'] as String).isEmpty) {
return false;
}
// Check for connections array
if (json['connections'] is! List || (json['connections'] as List).isEmpty) {
return false;
}
return true;
}
Map<String, dynamic> toJson() {
return {
'name': name,
'clientIdentifier': clientIdentifier,
'accessToken': accessToken,
'connections': connections.map((c) => c.toJson()).toList(),
'owned': owned,
'product': product,
'platform': platform,
'lastSeenAt': lastSeenAt?.toIso8601String(),
'presence': presence,
};
}
/// Check if server is online using the presence field
bool get isOnline => presence;
PlexConnection? _selectBest(Iterable<PlexConnection> candidates) {
final local = candidates.where((c) => c.local && !c.relay).toList();
if (local.isNotEmpty) return local.first;
final remote = candidates.where((c) => !c.local && !c.relay).toList();
if (remote.isNotEmpty) return remote.first;
final relay = candidates.where((c) => c.relay).toList();
if (relay.isNotEmpty) return relay.first;
if (candidates.isNotEmpty) return candidates.first;
return null;
}
/// Get the best connection URL
/// Priority: local > remote > relay
PlexConnection? getBestConnection() {
return _selectBest(connections);
}
/// Find the best working connection by testing them
/// Returns a Stream that emits connections progressively:
/// 1. First emission: The first connection that responds successfully
/// 2. Second emission (optional): The best connection after latency testing
/// Priority: local > remote > relay, then HTTPS > HTTP, then lowest latency
/// Tests both plex.direct URI and direct IP for each connection
/// HTTPS connections are tested first, with HTTP as fallback
Stream<PlexConnection> findBestWorkingConnection({String? preferredUri, String? clientIdentifier}) async* {
if (connections.isEmpty) {
appLogger.w('No connections available for server discovery');
return;
}
const preferredTimeout = ConnectionTimeouts.preferredEndpointProbe;
const raceTimeout = ConnectionTimeouts.connectionRace;
final candidates = _buildPrioritizedCandidates();
if (candidates.isEmpty) {
appLogger.w('No connection candidates generated for server discovery');
return;
}
final totalCandidates = candidates.length;
appLogger.d(
'Starting server connection discovery',
error: {'preferred': preferredUri, 'candidateCount': totalCandidates},
);
for (final conn in connections) {
final redactedUri = conn.uri.replaceAll(RegExp(r'//[^:/]+'), '//${_redactHost(conn.address)}');
appLogger.d(
'Raw API connection',
error: {
'uri': redactedUri,
'address': _redactHost(conn.address),
'local': conn.local,
'relay': conn.relay,
'protocol': conn.protocol,
},
);
}
_ConnectionCandidate? firstCandidate;
// Fast-path: if we have a cached working URI, probe it with a short timeout
if (preferredUri != null) {
final cachedCandidate = _candidateForUrl(preferredUri);
if (cachedCandidate != null) {
appLogger.d('Testing cached endpoint before running full race', error: {'uri': preferredUri});
final result = await PlexClient.testConnectionWithLatency(
cachedCandidate.url,
accessToken,
timeout: preferredTimeout,
clientIdentifier: clientIdentifier,
);
if (result.success) {
appLogger.i('Cached endpoint succeeded, using immediately', error: {'uri': preferredUri});
firstCandidate = cachedCandidate;
} else {
appLogger.w('Cached endpoint failed, falling back to candidate race', error: {'uri': preferredUri});
}
}
}
// If no cached candidate or it failed, race candidates to find first success
if (firstCandidate == null) {
final completer = Completer<_ConnectionCandidate?>();
int completedTests = 0;
appLogger.d('Running connection race to find first working endpoint', error: {'candidateCount': totalCandidates});
for (final candidate in candidates) {
PlexClient.testConnectionWithLatency(candidate.url, accessToken, timeout: raceTimeout, clientIdentifier: clientIdentifier).then((result) {
completedTests++;
if (!result.success) {
appLogger.w(
'Connection candidate failed',
error: {
'url': candidate.url,
'type': candidate.connection.displayType,
'https': candidate.isHttps,
'error': result.error,
'latencyMs': result.latencyMs,
},
);
}
if (result.success && !completer.isCompleted) {
completer.complete(candidate);
}
if (completedTests == candidates.length && !completer.isCompleted) {
completer.complete(null);
}
});
}
firstCandidate = await completer.future;
if (firstCandidate == null) {
appLogger.e(
'No working server connections after race',
error: {
'server': name,
'candidateCount': totalCandidates,
'types': candidates.map((c) => c.connection.displayType).toSet().toList(),
},
);
return; // No working connections found
}
appLogger.i(
'Connection race found first working endpoint',
error: {'uri': firstCandidate.url, 'type': firstCandidate.connection.displayType},
);
}
// Attempt HTTPS upgrade on the Phase 1 winner before emitting
final upgradedFirstCandidate = await _upgradeCandidateToHttpsIfPossible(firstCandidate, clientIdentifier: clientIdentifier);
final emitCandidate = upgradedFirstCandidate ?? firstCandidate;
final firstConnection = _updateConnectionUrl(emitCandidate.connection, emitCandidate.url);
yield firstConnection;
if (upgradedFirstCandidate != null && upgradedFirstCandidate.url != firstCandidate.url) {
appLogger.i(
'Phase 1 winner upgraded to HTTPS',
error: {'from': firstCandidate.url, 'to': upgradedFirstCandidate.url},
);
}
appLogger.d(
'Emitted first working connection, continuing latency tests in background',
error: {'uri': firstConnection.uri},
);
// Phase 2: Continue testing in background to find best connection
// Test each candidate 2-3 times and average the latency
final candidateResults = <_ConnectionCandidate, ConnectionTestResult>{};
await Future.wait(
candidates.map((candidate) async {
final result = await PlexClient.testConnectionWithAverageLatency(candidate.url, accessToken, attempts: 2, clientIdentifier: clientIdentifier);
if (result.success) {
candidateResults[candidate] = result;
}
}),
);
// If no connections succeeded, we're done
if (candidateResults.isEmpty) {
appLogger.w('Latency sweep found no additional working endpoints');
return;
}
appLogger.d(
'Completed latency sweep for server connections',
error: {'successfulCandidates': candidateResults.length},
);
// Find the best connection considering priority, latency, and URL type
final bestCandidate = _selectBestCandidateWithLatency(candidateResults);
// Emit the best connection if it's different from the first one
if (bestCandidate != null) {
final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible(bestCandidate, clientIdentifier: clientIdentifier) ?? bestCandidate;
final bestConnection = _updateConnectionUrl(upgradedCandidate.connection, upgradedCandidate.url);
if (bestConnection.uri != firstConnection.uri) {
appLogger.i('Latency sweep selected better endpoint', error: {'uri': bestConnection.uri});
yield bestConnection;
} else {
appLogger.d('Latency sweep confirmed initial endpoint is optimal', error: {'uri': bestConnection.uri});
}
}
}
/// Update a connection's URI to use the specified URL
PlexConnection _updateConnectionUrl(PlexConnection connection, String url) {
// If the URL matches the original URI, return as-is
if (url == connection.uri) {
return connection;
}
// Otherwise, create a new connection with the directUrl as the uri
return PlexConnection(
protocol: connection.protocol,
address: connection.address,
port: connection.port,
uri: url,
local: connection.local,
relay: connection.relay,
ipv6: connection.ipv6,
);
}
_ConnectionCandidate? _candidateForUrl(String url) {
for (final connection in connections) {
final httpUrl = connection.httpDirectUrl;
if (httpUrl == url) {
return _ConnectionCandidate(connection, httpUrl, false, false);
}
final uri = connection.uri;
if (uri == url) {
final isHttps = uri.startsWith('https://');
final parsedHost = Uri.tryParse(uri)?.host ?? '';
final isPlexDirect = parsedHost.toLowerCase().contains('plex.direct');
return _ConnectionCandidate(connection, uri, isPlexDirect, isHttps);
}
}
return null;
}
List<_ConnectionCandidate> _buildPrioritizedCandidates({Set<String>? excludeUrls}) {
final seen = <String>{};
if (excludeUrls != null) {
seen.addAll(excludeUrls);
}
final httpsLocal = <_ConnectionCandidate>[];
final httpsRemote = <_ConnectionCandidate>[];
final httpsRelay = <_ConnectionCandidate>[];
final httpLocal = <_ConnectionCandidate>[];
final httpRemote = <_ConnectionCandidate>[];
final httpRelay = <_ConnectionCandidate>[];
List<_ConnectionCandidate> bucketFor(PlexConnection connection, bool isHttps) {
if (isHttps) {
if (connection.relay) return httpsRelay;
if (connection.local) return httpsLocal;
return httpsRemote;
} else {
if (connection.relay) return httpRelay;
if (connection.local) return httpLocal;
return httpRemote;
}
}
void addCandidate(PlexConnection connection, String url, bool isPlexDirectUri, bool isHttps) {
if (url.isEmpty || seen.contains(url)) {
return;
}
seen.add(url);
bucketFor(connection, isHttps).add(_ConnectionCandidate(connection, url, isPlexDirectUri, isHttps));
}
for (final connection in connections) {
// Skip endpoints that are never reachable from an external client:
// Docker bridge addresses and IPv6 link-local / all-zeros addresses.
if (_isUnreachableAddress(connection.address)) {
continue;
}
// First, try the actual connection URI (may be HTTPS plex.direct)
final isPlexDirect = connection.uri.contains('.plex.direct');
final isHttps = connection.protocol == 'https';
addCandidate(connection, connection.uri, isPlexDirect, isHttps);
// For HTTPS connections, also add HTTP direct IP as fallback
// This provides backward compatibility and fallback for cert issues
if (isHttps) {
addCandidate(connection, connection.httpDirectUrl, false, false);
}
}
return [...httpsLocal, ...httpsRemote, ...httpsRelay, ...httpLocal, ...httpRemote, ...httpRelay];
}
List<String> prioritizedEndpointUrls({String? preferredFirst}) {
final urls = <String>[];
final exclude = <String>{};
if (preferredFirst != null && preferredFirst.isNotEmpty) {
urls.add(preferredFirst);
exclude.add(preferredFirst);
}
final candidates = _buildPrioritizedCandidates(excludeUrls: exclude);
urls.addAll(candidates.map((candidate) => candidate.url));
return urls;
}
Future<_ConnectionCandidate?> _upgradeCandidateToHttpsIfPossible(_ConnectionCandidate candidate, {String? clientIdentifier}) async {
final currentUrl = candidate.url;
if (currentUrl.startsWith('https://')) {
return null;
}
late final String httpsUrl;
bool resultingIsPlexDirect = candidate.isPlexDirectUri;
if (candidate.isPlexDirectUri) {
if (!currentUrl.startsWith('http://')) {
return null;
}
httpsUrl = currentUrl.replaceFirst('http://', 'https://');
} else {
// Raw IP endpoints can't present HTTPS certificates—prefer their plex.direct alias.
final plexDirectUri = candidate.connection.uri;
if (plexDirectUri.isEmpty) {
return null;
}
if (plexDirectUri.startsWith('https://')) {
httpsUrl = plexDirectUri;
} else if (plexDirectUri.startsWith('http://')) {
httpsUrl = plexDirectUri.replaceFirst('http://', 'https://');
} else {
return null;
}
final upgradedHost = Uri.tryParse(httpsUrl)?.host;
if (upgradedHost == null || !upgradedHost.toLowerCase().endsWith('.plex.direct')) {
appLogger.d(
'Skipping HTTPS upgrade for raw IP candidate: no plex.direct alias available',
error: {'candidate': currentUrl, 'target': httpsUrl},
);
return null;
}
resultingIsPlexDirect = true;
}
if (httpsUrl == currentUrl) {
return null;
}
appLogger.d('Attempting HTTPS upgrade for candidate endpoint', error: {'from': currentUrl, 'to': httpsUrl});
final result = await PlexClient.testConnectionWithLatency(
httpsUrl,
accessToken,
timeout: ConnectionTimeouts.connectionRace,
clientIdentifier: clientIdentifier,
);
if (!result.success) {
appLogger.w('HTTPS upgrade failed, staying on HTTP candidate', error: {'url': currentUrl});
return null;
}
appLogger.i('HTTPS upgrade succeeded for candidate endpoint', error: {'httpsUrl': httpsUrl});
final httpsConnection = PlexConnection(
protocol: 'https',
address: candidate.connection.address,
port: candidate.connection.port,
uri: httpsUrl,
local: candidate.connection.local,
relay: candidate.connection.relay,
ipv6: candidate.connection.ipv6,
);
return _ConnectionCandidate(httpsConnection, httpsUrl, resultingIsPlexDirect, true);
}
Future<PlexConnection?> upgradeConnectionToHttps(PlexConnection current) async {
if (current.uri.startsWith('https://')) {
return current;
}
final baseConnection = _findMatchingBaseConnection(current);
if (baseConnection == null) {
return null;
}
final candidate = _ConnectionCandidate(
baseConnection,
current.uri,
current.uri.contains('.plex.direct'),
current.uri.startsWith('https://'),
);
final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible(candidate);
if (upgradedCandidate == null) {
return null;
}
return _updateConnectionUrl(upgradedCandidate.connection, upgradedCandidate.url);
}
PlexConnection? _findMatchingBaseConnection(PlexConnection connection) {
for (final base in connections) {
final sameAddress = base.address == connection.address;
final samePort = base.port == connection.port;
final sameLocal = base.local == connection.local;
final sameRelay = base.relay == connection.relay;
if (sameAddress && samePort && sameLocal && sameRelay) {
return base;
}
}
return null;
}
/// Select the best candidate considering priority, latency, and URL type preference
_ConnectionCandidate? _selectBestCandidateWithLatency(Map<_ConnectionCandidate, ConnectionTestResult> results) {
// Group candidates by connection type (local/remote/relay)
final localCandidates = results.entries.where((e) => e.key.connection.local && !e.key.connection.relay).toList();
final remoteCandidates = results.entries.where((e) => !e.key.connection.local && !e.key.connection.relay).toList();
final relayCandidates = results.entries.where((e) => e.key.connection.relay).toList();
// Find best in each category
return _findLowestLatencyCandidate(localCandidates) ??
_findLowestLatencyCandidate(remoteCandidates) ??
_findLowestLatencyCandidate(relayCandidates);
}
/// Find the candidate with lowest latency, preferring HTTPS and plex.direct URI on tie
_ConnectionCandidate? _findLowestLatencyCandidate(
List<MapEntry<_ConnectionCandidate, ConnectionTestResult>> entries,
) {
if (entries.isEmpty) return null;
// Sort by latency first, then by protocol (HTTPS > HTTP), then by URL type (prefer plex.direct)
entries.sort((a, b) {
final latencyCompare = a.value.latencyMs.compareTo(b.value.latencyMs);
if (latencyCompare != 0) return latencyCompare;
// If latencies are equal, prefer HTTPS over HTTP
final aIsHttps = a.key.isHttps;
final bIsHttps = b.key.isHttps;
if (aIsHttps && !bIsHttps) return -1;
if (!aIsHttps && bIsHttps) return 1;
// If latencies and protocols are equal, prefer plex.direct URI (isPlexDirectUri = true)
if (a.key.isPlexDirectUri && !b.key.isPlexDirectUri) return -1;
if (!a.key.isPlexDirectUri && b.key.isPlexDirectUri) return 1;
return 0;
});
return entries.first.key;
}
/// Returns true if the address is known to be unreachable from external
/// clients (Docker bridge networks, IPv6 link-local, or all-zeros).
static bool _isUnreachableAddress(String address) {
// Docker bridge subnets (172.17.0.0/16, 172.18.0.0/16, etc.)
// Docker uses 172.17-31.x.x by default.
final dockerPattern = RegExp(r'^172\.(1[7-9]|2[0-9]|3[01])\.');
if (dockerPattern.hasMatch(address)) {
return true;
}
// IPv6 all-zeros (::) or link-local (fe80::)
final normalized = address.replaceAll('-', ':').toLowerCase();
if (normalized == '::' || normalized == '0000:0000:0000:0000:0000:0000:0000:0000') {
return true;
}
// Condensed all-zeros variants
if (RegExp(r'^(0+:){7}0+$').hasMatch(normalized)) {
return true;
}
if (normalized.startsWith('fe80:') || normalized.startsWith('fe80::')) {
return true;
}
return false;
}
}
/// Represents a connection to a Plex server
class PlexConnection {
final String protocol;
final String address;
final int port;
final String uri;
final bool local;
final bool relay;
final bool ipv6;
PlexConnection({
required this.protocol,
required this.address,
required this.port,
required this.uri,
required this.local,
required this.relay,
required this.ipv6,
});
factory PlexConnection.fromJson(Map<String, dynamic> json) {
// Validate required fields
if (!_isValidConnectionJson(json)) {
throw FormatException('Invalid connection data: missing required fields (protocol, address, port, or uri)');
}
return PlexConnection(
protocol: json['protocol'] as String, // Safe because validated above
address: json['address'] as String, // Safe because validated above
port: json['port'] as int, // Safe because validated above
uri: json['uri'] as String, // Safe because validated above
local: json['local'] as bool? ?? false,
relay: json['relay'] as bool? ?? false,
ipv6: json['IPv6'] as bool? ?? false,
);
}
/// Validates that connection JSON contains all required fields with correct types
static bool _isValidConnectionJson(Map<String, dynamic> json) {
// Check for required string fields
if (json['protocol'] is! String || (json['protocol'] as String).isEmpty) {
return false;
}
if (json['address'] is! String || (json['address'] as String).isEmpty) {
return false;
}
if (json['uri'] is! String || (json['uri'] as String).isEmpty) {
return false;
}
// Check for required port (integer)
if (json['port'] is! int) {
return false;
}
return true;
}
Map<String, dynamic> toJson() {
return {
'protocol': protocol,
'address': address,
'port': port,
'uri': uri,
'local': local,
'relay': relay,
'IPv6': ipv6,
};
}
/// Get the direct URL constructed from address and port
/// This bypasses plex.direct DNS and connects directly to the IP
String get directUrl => '$protocol://$address:$port';
/// Always return an HTTP URL that points directly at the IP/port combo.
String get httpDirectUrl {
final needsBrackets = address.contains(':') && !address.startsWith('[');
final safeAddress = needsBrackets ? '[$address]' : address;
return 'http://$safeAddress:$port';
}
String get displayType {
if (relay) return 'Relay';
if (local) return 'Local';
return 'Remote';
}
/// Create an HTTP fallback version of this HTTPS connection
/// This allows testing HTTP when HTTPS is unavailable (e.g., certificate issues)
PlexConnection toHttpFallback() {
assert(protocol == 'https', 'Can only create HTTP fallback for HTTPS connections');
return PlexConnection(
protocol: 'http',
address: address,
port: port,
uri: uri.replaceFirst('https://', 'http://'),
local: local,
relay: relay,
ipv6: ipv6,
);
}
}
/// Custom exception for server parsing errors that includes debug data
class ServerParsingException implements Exception {
final String message;
final List<Map<String, dynamic>> invalidServerData;
ServerParsingException(this.message, this.invalidServerData);
@override
String toString() => message;
}