import 'dart:async'; import 'package:dio/dio.dart'; import 'package:uuid/uuid.dart'; import 'storage_service.dart'; import '../client/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'; 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(); // 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', if (pin != null) '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 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 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, }) async* { if (connections.isEmpty) { appLogger.w('No connections available for server discovery'); return; } const preferredTimeout = Duration(seconds: 2); const raceTimeout = Duration(seconds: 4); 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}, ); _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, ); 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, ).then((result) { completedTests++; 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'); return; // No working connections found } appLogger.i( 'Connection race found first working endpoint', error: { 'uri': firstCandidate.url, 'type': firstCandidate.connection.displayType, }, ); } final firstConnection = _updateConnectionUrl( firstCandidate.connection, firstCandidate.url, ); yield firstConnection; 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, ); 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) ?? 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) { 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, ) 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: const Duration(seconds: 4), ); 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; } } /// 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 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; }