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'; import '../exceptions/media_server_exceptions.dart'; import '../models/plex/plex_user_profile.dart'; import '../models/plex/plex_home.dart'; import '../models/plex/plex_home_user.dart'; import '../models/plex/plex_switch_response.dart'; import '../utils/app_logger.dart'; import '../utils/device_identity.dart'; import '../utils/endpoint_race.dart'; import '../utils/json_utils.dart'; import '../utils/media_server_timeouts.dart'; import '../utils/media_server_http_client.dart'; import '../utils/poll_with_backoff.dart'; import '../utils/url_utils.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 MediaServerHttpClient _http; final String _clientIdentifier; final String _appVersion; final String _platformVersion; final String _platform; final String? _deviceName; PlexAuthService._( this._http, this._clientIdentifier, this._appVersion, this._platformVersion, this._platform, this._deviceName, ); @visibleForTesting PlexAuthService.forTesting({ required MediaServerHttpClient http, String clientIdentifier = 'test-client', String appVersion = 'test', String platformVersion = 'test', String platform = 'Flutter', String? deviceName, }) : this._(http, clientIdentifier, appVersion, platformVersion, platform, deviceName); /// 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(); static Future create() async { final storage = await StorageService.getInstance(); final http = MediaServerHttpClient( connectTimeout: MediaServerTimeouts.plexTvConnect, receiveTimeout: MediaServerTimeouts.plexTvReceive, ); final clientIdentifier = await storage.getOrCreateClientIdentifier(); final packageInfo = await PackageInfo.fromPlatform(); final identity = await DeviceIdentityService.resolve(); return PlexAuthService._( http, clientIdentifier, packageInfo.version, Platform.operatingSystemVersion, identity.platform, sanitizeHeaderValue(identity.deviceName), ); } String get clientIdentifier => _clientIdentifier; Map _getCommonHeaders({String? authToken}) { final headers = { 'Accept': 'application/json', 'X-Plex-Product': _appName, 'X-Plex-Client-Identifier': _clientIdentifier, 'X-Plex-Platform': _platform, 'X-Plex-Device-Name': ?_deviceName, }; if (authToken != null) { headers['X-Plex-Token'] = authToken; } return headers; } Future _getUser(String authToken) { return _http.get( '$_plexApiBase/user', headers: _getCommonHeaders(authToken: authToken), timeout: MediaServerTimeouts.plexTvReceive, ); } 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); if (response.statusCode == 200) return true; if (response.statusCode == 401 || response.statusCode == 403) return false; _checkStatus(response); return false; } /// Create a PIN for authentication Future> createPin() async { final response = await _http.post( '$_plexApiBase/pins?strong=true', headers: _getCommonHeaders(), timeout: MediaServerTimeouts.plexTvReceive, ); _checkStatus(response); return response.data as Map; } /// Construct the Auth App URL for the user to visit String getAuthUrl(String pinCode) { final params = {'clientID': _clientIdentifier, 'code': pinCode, 'context[device][product]': _appName}; return 'https://app.plex.tv/auth#?${encodeQueryParameters(params)}'; } /// Poll the PIN to check if it has been claimed Future checkPin(int pinId) async { final response = await _http.get( '$_plexApiBase/pins/$pinId', headers: _getCommonHeaders(), timeout: MediaServerTimeouts.plexTvReceive, ); if (response.statusCode == 404 || response.statusCode == 410) { throw const MediaServerPinExpiredException(); } if (response.statusCode == 401 || response.statusCode == 403) { throw MediaServerAuthException('Plex PIN check rejected', statusCode: response.statusCode); } _checkStatus(response); final data = response.data as Map; return data['authToken'] as String?; } /// Poll the PIN until it's claimed or timeout. /// /// Uses an exponential backoff (1s → 2s → 4s, capped at 5s) so a stalled /// claim doesn't hammer plex.tv every second for two minutes. Future pollPinUntilClaimed( int pinId, { Duration timeout = const Duration(minutes: 2), bool Function()? shouldCancel, }) { return pollWithBackoff( probe: () => checkPin(pinId), endTime: DateTime.now().add(timeout), shouldCancel: shouldCancel, ); } /// Fetch available Plex servers for the authenticated user Future> fetchServers(String authToken) async { final response = await _getClientsApi( '/resources?includeHttps=1&includeRelay=1&includeIPv6=1', headers: _getCommonHeaders(authToken: authToken), ); _checkStatus(response); final List resources = response.data as List; // Filter for server resources and map to PlexServer objects final servers = []; final invalidServers = >[]; for (final resource in resources.where((r) => r['provides'] == 'server')) { try { final server = PlexServer.fromJson(resource as Map); servers.add(server); } catch (e) { // Collect invalid servers for debugging invalidServers.add(resource as Map); 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> getUserInfo(String authToken) async { final response = await _getUser(authToken); _checkStatus(response); return response.data as Map; } /// Get user profile with preferences (audio/subtitle settings) Future getUserProfile(String authToken) async { 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 _getClientsApi('/home/users', headers: _getCommonHeaders(authToken: authToken)); _checkStatus(response); return PlexHome.fromJson(response.data as Map); } /// Switch to a different user in the home, returning the freshly minted /// user-level token Future 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': _appVersion, 'X-Plex-Client-Identifier': _clientIdentifier, 'X-Plex-Platform': _platform, 'X-Plex-Platform-Version': _platformVersion, 'X-Plex-Token': currentToken, 'X-Plex-Language': 'en', 'pin': ?pin, }; final response = await _http.post( '$_clientsApi/home/users/$userUUID/switch', queryParameters: queryParams, headers: {'Accept': 'application/json', 'Content-Length': '0'}, ); _checkStatus(response); return parsePlexSwitchAuthToken(response.data as Map); } } /// Classification of an endpoint URL by the server's published connection type. enum PlexNetworkClass { local, remote, relay, unknown } /// Helper class to track connection candidates during testing class _ConnectionCandidate { final PlexConnection connection; final String url; final bool isPlexDirectUri; final bool isHttps; const _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 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 json) { // Validate required fields first if (!_isValidServerJson(json)) { throw const FormatException( 'Invalid server data: missing required fields (name, clientIdentifier, accessToken, or connections)', ); } final List connectionsJson = json['connections'] as List; final connections = []; // Parse connections and generate HTTP fallbacks for HTTPS connections for (final c in connectionsJson) { try { final connection = PlexConnection.fromJson(c as Map); connections.add(connection); if (_allowsHttpFallback(connection)) { 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 const FormatException('Server has no valid connections'); } final lastSeenAt = DateTime.tryParse(json['lastSeenAt']?.toString() ?? ''); 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: flexibleBool(json['owned']), product: _optionalScalarString(json['product']), platform: _optionalScalarString(json['platform']), lastSeenAt: lastSeenAt, presence: flexibleBool(json['presence']), ); } /// Validates that server JSON contains all required fields with correct types static bool _isValidServerJson(Map 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 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; /// 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 findBestWorkingConnection({ String? preferredUri, String? clientIdentifier, void Function(bool)? onTranscoderCapability, }) async* { if (connections.isEmpty) { appLogger.w('No connections available for server discovery'); return; } 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, }, ); } PlexConnection? firstConnection; await for (final selection in raceEndpointCandidates<_ConnectionCandidate, ConnectionTestResult>( label: 'Plex server connection', candidates: candidates, preferredUrl: preferredUri, candidateForUrl: _candidateForUrl, urlOf: (candidate) => candidate.url, displayTypeOf: (candidate) => candidate.connection.displayType, failureLogFields: (candidate, result) => { 'https': candidate.isHttps, 'error': result.error, 'latencyMs': result.latencyMs, }, probe: (candidate, timeout) => PlexClient.testConnectionWithLatency( candidate.url, accessToken, timeout: timeout, clientIdentifier: clientIdentifier, ), measure: (candidate) => PlexClient.testConnectionWithAverageLatency( candidate.url, accessToken, attempts: 2, clientIdentifier: clientIdentifier, ), isSuccess: (result) => result.success, selectBestCandidate: _selectBestCandidateWithLatency, onFirstSuccess: (_, result) { if (result.transcoderVideo != null) onTranscoderCapability?.call(result.transcoderVideo!); }, )) { if (selection.phase == EndpointRacePhase.first) { final firstCandidate = selection.candidate; // Emit the winner immediately — the HTTPS upgrade probe (up to a full // race timeout when the HTTPS variant is dead) must not gate startup. // The StreamIterator consumer pulls lazily, so the upgrade below runs // off the critical path and lands via the same background-promotion // drain that applies Phase 2 results. firstConnection = _updateConnectionUrl(firstCandidate.connection, firstCandidate.url); yield firstConnection; appLogger.d( 'Emitted first working connection, continuing latency tests in background', error: {'uri': firstConnection.uri}, ); final upgradedFirstCandidate = await _upgradeCandidateToHttpsIfPossible( firstCandidate, clientIdentifier: clientIdentifier, ); if (upgradedFirstCandidate != null && upgradedFirstCandidate.url != firstCandidate.url) { appLogger.i( 'Phase 1 winner upgraded to HTTPS', error: {'from': firstCandidate.url, 'to': upgradedFirstCandidate.url}, ); // Track the upgrade as the effective first connection so the Phase 2 // dedup below doesn't re-emit the same HTTPS endpoint as "better". firstConnection = _updateConnectionUrl(upgradedFirstCandidate.connection, upgradedFirstCandidate.url); yield firstConnection; } continue; } final bestCandidate = selection.candidate; final upgradedCandidate = await _upgradeCandidateToHttpsIfPossible(bestCandidate, clientIdentifier: clientIdentifier) ?? bestCandidate; final bestConnection = _updateConnectionUrl(upgradedCandidate.connection, upgradedCandidate.url); if (firstConnection == null || 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; } /// 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 ?? _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}) { final seen = {}; 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 any client: IPv6 // link-local / all-zeros addresses. Private IPv4 addresses (including // Docker bridge gateways) are deliberately kept — a client running on // the server host itself can reach them, so reachability is probed at // failover time (PlexClient's validateCandidate), not inferred here. if (_isUnreachableAddress(connection.address)) { continue; } // First, try the actual connection URI (may be HTTPS plex.direct) final isPlexDirect = _isPlexDirectUri(connection.uri); final isHttps = connection.protocol == 'https'; addCandidate(connection, connection.uri, isPlexDirect, isHttps); if (_allowsHttpFallback(connection)) { addCandidate(connection, _httpFallbackUrl(connection), false, false); } } final all = [...httpsLocal, ...httpsRemote, ...httpsRelay, ...httpLocal, ...httpRemote, ...httpRelay]; // Failover reachability filter. After discovery, failover should stay within // endpoint families plausible for the current session: when a remote or relay // endpoint is what's working, LAN-only endpoints are unreachable from off-LAN // clients and only pollute the retry chain (see GH #902). Reconnect / // reoptimization widens the search again via _reoptimizeServer. Local is kept // conservative — it may still be reachable over VPN/routed-LAN, but those // setups re-widen on the next connectivity event. if (restrictTo == PlexNetworkClass.remote || restrictTo == PlexNetworkClass.relay) { final filtered = all.where((c) => c.connection.networkClass != PlexNetworkClass.local).toList(); if (filtered.isNotEmpty) return filtered; } return all; } List prioritizedEndpointUrls({String? preferredFirst}) { final urls = []; final exclude = {}; PlexNetworkClass? restrictTo; if (preferredFirst != null && preferredFirst.isNotEmpty) { urls.add(preferredFirst); exclude.add(preferredFirst); restrictTo = networkClassForUrl(preferredFirst); } final candidates = _buildPrioritizedCandidates(excludeUrls: exclude, restrictTo: restrictTo); 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, so prefer their // plex.direct alias. Native custom hostnames on :32400 can be probed on // the same host to avoid permanently downgrading to HTTP when HTTPS works. final originalUri = candidate.connection.uri; if (originalUri.isEmpty) { return null; } if (originalUri.startsWith('https://')) { httpsUrl = originalUri; } else if (originalUri.startsWith('http://')) { httpsUrl = originalUri.replaceFirst('http://', 'https://'); } else { return null; } final upgradedHost = Uri.tryParse(httpsUrl)?.host; if (upgradedHost == null) { return null; } if (upgradedHost.toLowerCase().endsWith('.plex.direct')) { resultingIsPlexDirect = true; } else if (!_isNativePlexHostnameHttps(candidate.connection)) { appLogger.d( 'Skipping HTTPS upgrade for HTTP candidate: no safe HTTPS target available', error: {'candidate': currentUrl, 'target': httpsUrl}, ); return null; } } 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: MediaServerTimeouts.connectionRace, clientIdentifier: clientIdentifier, ); if (!result.success) { appLogger.w( 'HTTPS upgrade failed, staying on HTTP candidate', error: {'url': currentUrl, 'reason': result.error}, ); 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); } /// 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> entries, ) { if (entries.isEmpty) return null; // Sort by protocol first (HTTPS enables H2 multiplexing), then latency, then URL type entries.sort((a, b) { // Prefer HTTPS over HTTP final aIsHttps = a.key.isHttps; final bIsHttps = b.key.isHttps; if (aIsHttps && !bIsHttps) return -1; if (!aIsHttps && bIsHttps) return 1; // Within same protocol, sort by latency final latencyCompare = a.value.latencyMs.compareTo(b.value.latencyMs); if (latencyCompare != 0) return latencyCompare; // Prefer plex.direct URI on tie if (a.key.isPlexDirectUri && !b.key.isPlexDirectUri) return -1; if (!a.key.isPlexDirectUri && b.key.isPlexDirectUri) return 1; return 0; }); return entries.first.key; } /// True when the address is a raw IP (no hostname → no reverse proxy → HTTP /// fallback on an HTTPS port is safe to try). static bool _isIpLiteral(String address) { final bare = address.startsWith('[') && address.endsWith(']') ? address.substring(1, address.length - 1) : address; return InternetAddress.tryParse(bare) != null; } static bool _allowsHttpFallback(PlexConnection connection) { if (connection.protocol != 'https') return false; return _isPlexDirectUri(connection.uri) || _isIpLiteral(connection.address) || _isNativePlexHostnameHttps(connection); } static String _httpFallbackUrl(PlexConnection connection) { if (_isNativePlexHostnameHttps(connection)) { return connection.uri.replaceFirst('https://', 'http://'); } return connection.httpDirectUrl; } static bool _isPlexDirectUri(String uri) { final host = Uri.tryParse(uri)?.host.toLowerCase(); return host?.endsWith('.plex.direct') ?? uri.contains('.plex.direct'); } static bool _isNativePlexHostnameHttps(PlexConnection connection) { if (connection.protocol != 'https' || connection.port != 32400 || _isIpLiteral(connection.address)) { return false; } final uri = Uri.tryParse(connection.uri); if (uri == null || uri.scheme != 'https' || uri.hasQuery || uri.hasFragment) { return false; } if (uri.port != connection.port) { return false; } if (uri.path.isNotEmpty && uri.path != '/') { return false; } final address = _normalizedHost(connection.address); return address.isNotEmpty && uri.host.toLowerCase() == address; } static String _normalizedHost(String host) { final bare = host.startsWith('[') && host.endsWith(']') ? host.substring(1, host.length - 1) : host; 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') || host.endsWith('.ts.net')) { return true; } return false; } static bool _isPrivateOrLocalAddress(InternetAddress address) { final bytes = address.rawAddress; if (address.type == InternetAddressType.IPv4 && bytes.length == 4) { final a = bytes.first; 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) || (a == 192 && b == 168); } if (address.type == InternetAddressType.IPv6 && bytes.length == 16) { final first = bytes.first; 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) { // 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 json) { // Validate required fields if (!_isValidConnectionJson(json)) { throw const 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: flexibleInt(json['port'])!, // Safe because validated above uri: json['uri'] as String, // Safe because validated above local: flexibleBool(json['local']), relay: flexibleBool(json['relay']), ipv6: flexibleBool(json['IPv6']), ); } /// Validates that connection JSON contains all required fields with correct types static bool _isValidConnectionJson(Map 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; } if (flexibleInt(json['port']) == null) { return false; } return true; } Map 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'; } PlexNetworkClass get networkClass { if (relay) return PlexNetworkClass.relay; if (local) return PlexNetworkClass.local; return PlexNetworkClass.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, ); } } /// Default implementation of the `Future> Function(String)` /// fetcher seam injected into `PlexHomeService` and `ConnectionBootstrap`: /// spins up a throwaway [PlexAuthService] for a single `/home/users` call. Future> fetchPlexHomeUsers(String accountToken) async { final auth = await PlexAuthService.create(); try { final home = await auth.getHomeUsers(accountToken); return home.users; } finally { auth.dispose(); } } String? _optionalScalarString(Object? value) => switch (value) { null => null, final String value => value, final num value => value.toString(), final bool value => value.toString(), _ => null, }; /// Custom exception for server parsing errors that includes debug data class ServerParsingException implements Exception { final String message; final List> invalidServerData; ServerParsingException(this.message, this.invalidServerData); @override String toString() => message; }