Files
plezy/lib/services/plex_auth_service.dart
T
edde746 84bc4b2618 fix(i18n): translate the strings that still reached the UI in English
Non-English users saw English text in a dozen places and blank labels in
sixteen more.

The English came from sites that produce their copy away from the widget
that renders it, which is what the structural hardcoded-string check
cannot see: picture-in-picture refused with a raw literal instead of the
pipErrors.notSupported key that already existed; the two Jellyfin/Emby
auth throws missing display: rendered their developer message on the
add-server form; ServerParsingException.toString() fed its English into
the localized "Failed to load servers" wrapper; Watch Together
interpolated the whole PeerError, so a failed create read "Failed to
create session: PeerError(PeerErrorType.timeout): Timed out creating
session" and join printed its prefix twice; the hub and playlist
continuation footers rendered exception.toString(); shader rows showed an
English title over an already translated subtitle; the player queue fell
back to the raw Dart enum name; a Plex home user with no title showed
"Unknown"; a failed player start showed "Exception: Failed to initialize
player"; and the tvOS top-shelf header was hardcoded in an extension that
has no Flutter engine.

The blanks came from three recent features that added English keys
without translations. clean_translations.py filled all 21 siblings with
empty strings, so the Android TV resolution switch, every Jellyfin/Emby
recording-rule field, the demuxer row, the Companion Remote address
caption and the Seerr blocklist pill rendered nothing at all.

Two fixes are structural rather than key swaps. ContinuationStatusSliver
now takes an errorContext and calls a new non-logging
localizedLoadErrorText, so no future throw can leak through it. lib/mpv
stays free of user-facing copy: it raises a PlayerInitializationException
sentinel and a PlayerError.playerInitFailed cause tag that the player
screen resolves to localized text. The tvOS section title travels in the
shelf payload, additively, so an older cache still renders.
2026-08-24 05:23:05 +02:00

1069 lines
37 KiB
Dart

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 '../i18n/strings.g.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<PlexAuthService> 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<String, String> _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<MediaServerResponse> _getUser(String authToken) {
return _http.get(
'$_plexApiBase/user',
headers: _getCommonHeaders(authToken: authToken),
timeout: MediaServerTimeouts.plexTvReceive,
);
}
void _checkStatus(MediaServerResponse response) => throwIfHttpError(response);
Future<MediaServerResponse> _getClientsApi(String path, {Map<String, String>? 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<bool> 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<Map<String, dynamic>> createPin() async {
final response = await _http.post(
'$_plexApiBase/pins?strong=true',
headers: _getCommonHeaders(),
timeout: MediaServerTimeouts.plexTvReceive,
);
_checkStatus(response);
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};
return 'https://app.plex.tv/auth#?${encodeQueryParameters(params)}';
}
/// Poll the PIN to check if it has been claimed
Future<String?> 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,
display: t.auth.pinCheckRejected,
);
}
_checkStatus(response);
final data = response.data as Map<String, dynamic>;
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. Transient
/// transport failures are treated as an inconclusive probe: the browser
/// sign-in may still be in progress, so one dropped request must not abort
/// the entire attempt.
Future<String?> pollPinUntilClaimed(
int pinId, {
Duration timeout = const Duration(minutes: 2),
bool Function()? shouldCancel,
Duration initialBackoff = const Duration(seconds: 1),
Duration maxBackoff = const Duration(seconds: 5),
}) {
return pollWithBackoff<String>(
probe: () async {
try {
return await checkPin(pinId);
} on MediaServerHttpException catch (error) {
if (!error.isTransient) rethrow;
return null;
}
},
endTime: DateTime.now().add(timeout),
shouldCancel: shouldCancel,
initial: initialBackoff,
maxBackoff: maxBackoff,
);
}
/// Fetch available Plex servers for the authenticated user
Future<List<PlexServer>> fetchServers(String authToken) async {
final response = await _getClientsApi(
'/resources?includeHttps=1&includeRelay=1&includeIPv6=1',
headers: _getCommonHeaders(authToken: authToken),
);
_checkStatus(response);
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,
display: t.serverSelection.noValidServers,
);
}
return servers;
}
/// Get user information
Future<Map<String, dynamic>> getUserInfo(String authToken) async {
final response = await _getUser(authToken);
_checkStatus(response);
return response.data as Map<String, dynamic>;
}
/// Get user profile with preferences (audio/subtitle settings)
Future<PlexUserProfile> getUserProfile(String authToken) async {
final response = await _getClientsApi('/user', headers: _getCommonHeaders(authToken: authToken));
_checkStatus(response);
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 _getClientsApi('/home/users', headers: _getCommonHeaders(authToken: authToken));
_checkStatus(response);
return PlexHome.fromJson(response.data as Map<String, dynamic>);
}
/// Switch to a different user in the home, returning the freshly minted
/// user-level token
Future<String> 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<String, dynamic>);
}
}
/// 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<PlexConnection> connections;
final bool owned;
final String? product;
final String? platform;
final DateTime? lastSeenAt;
PlexServer({
required this.name,
required this.clientIdentifier,
required this.accessToken,
required this.connections,
required this.owned,
this.product,
this.platform,
this.lastSeenAt,
});
factory PlexServer.fromJson(Map<String, dynamic> json) {
// Validate required fields first
if (!_isValidServerJson(json)) {
throw const 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);
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,
);
}
/// 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(),
};
}
/// Find the best working connection by testing them
/// Returns a Stream that emits connections progressively:
/// 1. First emission: The first connection that responds successfully.
/// Relay is a fallback tier: a relay success is held until every direct
/// candidate has failed, and a cached relay URL gets no head start, so
/// a fast plex.tv relay edge can never beat a working direct endpoint.
/// 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,
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,
tierOf: (candidate) => candidate.connection.relay ? 1 : 0,
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 given url 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<String>? excludeUrls, PlexNetworkClass? restrictTo}) {
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 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<String> prioritizedEndpointUrls({String? preferredFirst}) {
final urls = <String>[];
final exclude = <String>{};
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<MapEntry<_ConnectionCandidate, ConnectionTestResult>> 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<String, dynamic> 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<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;
}
if (flexibleInt(json['port']) == null) {
return false;
}
return true;
}
Map<String, dynamic> toJson() {
return {
'protocol': protocol,
'address': address,
'port': port,
'uri': uri,
'local': local,
'relay': relay,
'IPv6': ipv6,
};
}
/// 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<List<PlexHomeUser>> Function(String)`
/// fetcher seam injected into `PlexHomeService` and `ConnectionBootstrap`:
/// spins up a throwaway [PlexAuthService] for a single `/home/users` call.
Future<List<PlexHomeUser>> 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 {
/// English diagnostic text, kept verbatim for logs and Sentry grouping.
final String message;
/// Localized text rendered by the UI via [toString].
final String display;
final List<Map<String, dynamic>> invalidServerData;
ServerParsingException(this.message, this.invalidServerData, {required this.display});
@override
String toString() => display;
}