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.
1069 lines
37 KiB
Dart
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;
|
|
}
|