Files
plezy/lib/services/plex_auth_service.dart
edde746andGitHub ae555ed5d7 feat(settings): account preferences, stored on the server instead of the device (#2112)
Preferences that belong to the media-server account had nowhere to live in Plezy. Jellyfin keeps a user's audio/subtitle language, subtitle mode and several library options in `UserConfiguration`; plex.tv keeps the same language choices plus watched indicators and review visibility on the account. Plezy read four of those fields for auto-track selection and could never show or change any of them.

Adds an Account preferences section under Connections. One account edits in place; several show a picker first, scoped to the active profile's own connections so a managed Plex Home user never edits the owner's record. Every row is gated on what the backend can actually store.

It also moves "rewatching in Next Up" onto the account. There is no `UserConfiguration` field for it, but the per-user `DisplayPreferences` store is keyed `(userId, displayPreferencesId, client)` with no device component, so the switch goes there and follows the account.

`AccountRef` keys by account, not `clientScopeId`: MediaBrowser is `{machineId}/{userId}`, Plex is (account, Home user). Writes are patch-shaped because both backends replace whole objects — Jellyfin's `POST /Users/{id}/Configuration` and its `DisplayPreferences` row both reset omitted fields, so each write re-reads, merges only the patched keys, and posts back. Plex takes its changes as query parameters with an empty body, and its `experience` blob and the PMS `/accounts/1` mirror are deliberately untouched.

`AccountPreferencesController` owns a single repository above the profile session, so changing a language in settings reaches the next playback without a restart. Emby is gated out of rewatching through `MediaBrowserDialect.supportsNextUpRewatching`.

close #1910
2026-08-25 08:42:48 +02:00

1101 lines
38 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 '../media/account_preferences.dart';
import '../models/plex/plex_account_preferences.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>);
}
/// Fetch the account preferences stored by plex.tv.
///
/// This reads `/user/profile`; it deliberately does not touch the per-device
/// `experience` settings blob or the PMS `/accounts/1` mirror.
Future<AccountPreferences> getAccountPreferences(String authToken) async {
final response = await _getClientsApi('/user/profile', headers: _getCommonHeaders(authToken: authToken));
_checkStatus(response);
return PlexAccountPreferences.fromProfileJson(response.data as Map<String, dynamic>);
}
/// Update the account preferences stored by plex.tv.
///
/// Plex requires a partial write shaped as query parameters with an empty
/// body. The `experience` settings blob and PMS `/accounts/1` mirror remain
/// deliberately untouched.
Future<AccountPreferences> updateAccountPreferences(String authToken, AccountPreferencesPatch patch) async {
final response = await _http.put(
'$_clientsApi/user/profile',
queryParameters: PlexAccountPreferences.queryParametersFor(patch),
headers: _getCommonHeaders(authToken: authToken),
);
_checkStatus(response);
final data = response.data;
if (data is Map<String, dynamic> && data.isNotEmpty) {
return PlexAccountPreferences.fromProfileJson(data);
}
return getAccountPreferences(authToken);
}
/// 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;
}