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 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 _getUser(String authToken) { return _dio.get('$_plexApiBase/user', options: _getCommonOptions(authToken: authToken)); } /// Verify if a plex.tv token is valid Future verifyToken(String authToken) async { try { await _getUser(authToken); return true; } catch (e) { return false; } } /// Create a PIN for authentication Future> createPin() async { final response = await _dio.post('$_plexApiBase/pins?strong=true', options: _getCommonOptions()); 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}; 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 checkPin(int pinId) async { try { final response = await _dio.get('$_plexApiBase/pins/$pinId', options: _getCommonOptions()); final data = response.data as Map; return data['authToken'] as String?; } catch (e) { return null; } } /// Poll the PIN until it's claimed or timeout Future 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> fetchServers(String authToken) async { final response = await _dio.get( '$_clientsApi/resources?includeHttps=1&includeRelay=1&includeIPv6=1', options: _getCommonOptions(authToken: authToken), ); 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); return response.data as Map; } /// Get user profile with preferences (audio/subtitle settings) Future getUserProfile(String authToken) async { final response = await _dio.get('$_clientsApi/user', options: _getCommonOptions(authToken: authToken)); return PlexUserProfile.fromJson(response.data as Map); } /// Get home users for the authenticated user Future getHomeUsers(String authToken) async { final response = await _dio.get('$_clientsApi/home/users', options: _getCommonOptions(authToken: authToken)); return PlexHome.fromJson(response.data as Map); } /// Switch to a different user in the home 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': '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); } } /// 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 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); // 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 const 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 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; PlexConnection? _selectBest(Iterable 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 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? excludeUrls}) { 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 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 prioritizedEndpointUrls({String? preferredFirst}) { final urls = []; final exclude = {}; 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 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> 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 (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: 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 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 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> invalidServerData; ServerParsingException(this.message, this.invalidServerData); @override String toString() => message; }