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
959 lines
32 KiB
Dart
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;
|
|
}
|