perf(auth): better connection choice

This commit is contained in:
edde746
2025-10-28 07:59:16 +01:00
parent 67934c1f1a
commit abd5900290
6 changed files with 453 additions and 173 deletions
+72 -3
View File
@@ -6,6 +6,14 @@ import '../models/plex_media_info.dart';
import '../models/plex_filter.dart';
import '../utils/app_logger.dart';
/// Result of testing a connection, including success status and latency
class ConnectionTestResult {
final bool success;
final int latencyMs;
ConnectionTestResult({required this.success, required this.latencyMs});
}
class PlexClient {
final PlexConfig config;
late final Dio _dio;
@@ -43,12 +51,28 @@ class PlexClient {
}
}
/// Test connection to a specific URL with token
/// Test connection to a specific URL with token (legacy method)
static Future<bool> testConnectionUrl(
String baseUrl,
String token, {
Duration timeout = const Duration(seconds: 5),
}) async {
final result = await testConnectionWithLatency(
baseUrl,
token,
timeout: timeout,
);
return result.success;
}
/// Test connection to a specific URL with token and measure latency
static Future<ConnectionTestResult> testConnectionWithLatency(
String baseUrl,
String token, {
Duration timeout = const Duration(seconds: 5),
}) async {
final stopwatch = Stopwatch()..start();
try {
final dio = Dio(
BaseOptions(
@@ -64,12 +88,57 @@ class PlexClient {
options: Options(headers: {'X-Plex-Token': token}),
);
return response.statusCode == 200 || response.statusCode == 401;
stopwatch.stop();
final success = response.statusCode == 200 || response.statusCode == 401;
return ConnectionTestResult(
success: success,
latencyMs: stopwatch.elapsedMilliseconds,
);
} catch (e) {
return false;
stopwatch.stop();
return ConnectionTestResult(
success: false,
latencyMs: stopwatch.elapsedMilliseconds,
);
}
}
/// Test connection multiple times and return average latency
static Future<ConnectionTestResult> testConnectionWithAverageLatency(
String baseUrl,
String token, {
int attempts = 3,
Duration timeout = const Duration(seconds: 5),
}) async {
final results = <ConnectionTestResult>[];
for (int i = 0; i < attempts; i++) {
final result = await testConnectionWithLatency(
baseUrl,
token,
timeout: timeout,
);
// If any attempt fails, return failed result immediately
if (!result.success) {
return ConnectionTestResult(
success: false,
latencyMs: result.latencyMs,
);
}
results.add(result);
}
// Calculate average latency from successful attempts
final avgLatency =
results.fold<int>(0, (sum, result) => sum + result.latencyMs) ~/
results.length;
return ConnectionTestResult(success: true, latencyMs: avgLatency);
}
/// Get server identity
Future<Map<String, dynamic>> getServerIdentity() async {
final response = await _dio.get('/identity');
+24 -97
View File
@@ -3,15 +3,13 @@ import 'package:flutter/services.dart';
import 'dart:io' show Platform;
import 'package:media_kit/media_kit.dart';
import 'package:window_manager/window_manager.dart';
import 'client/plex_client.dart';
import 'config/plex_config.dart';
import 'screens/main_screen.dart';
import 'screens/auth_screen.dart';
import 'services/storage_service.dart';
import 'services/plex_auth_service.dart';
import 'services/server_connection_service.dart';
import 'services/macos_titlebar_service.dart';
import 'services/fullscreen_state_manager.dart';
import 'models/plex_user_profile.dart';
import 'utils/language_codes.dart';
import 'utils/app_logger.dart';
@@ -97,52 +95,37 @@ class _SetupScreenState extends State<SetupScreen> {
// Recreate PlexServer from stored data
final server = PlexServer.fromJson(serverData);
// Test connections to find best working one
final connection = await server.findBestWorkingConnection();
// Connect using the optimized service
final result = await ServerConnectionService.connectToServer(
server,
clientIdentifier: clientId,
verifyServer: true,
fetchUserProfile: plexToken != null,
plexToken: plexToken,
);
if (connection != null) {
// Update stored server URL with working connection
await storage.saveServerUrl(connection.uri);
// Create client with working connection
final config = await PlexConfig.create(
baseUrl: connection.uri,
token: server.accessToken,
clientIdentifier: clientId,
);
final client = PlexClient(config);
// Verify server is accessible
try {
await client.getServerIdentity();
// Fetch and cache user profile if we have a plex token
PlexUserProfile? userProfile;
if (plexToken != null) {
userProfile = await _fetchAndCacheUserProfile(plexToken);
}
// Success! Navigate to main screen
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) =>
MainScreen(client: client, userProfile: userProfile),
// Handle result
if (result.isSuccess) {
// Success! Navigate to main screen
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => MainScreen(
client: result.client!,
userProfile: result.userProfile,
),
);
return;
}
} catch (e) {
// Server identity check failed
await storage.clearCredentials();
),
);
}
return;
} else {
// No working connections found
// Connection failed, clear credentials
await storage.clearCredentials();
}
} catch (e) {
// Error loading or testing server
appLogger.e('Error during auto-login', error: e);
await storage.clearCredentials();
}
}
@@ -156,62 +139,6 @@ class _SetupScreenState extends State<SetupScreen> {
}
}
Future<PlexUserProfile?> _fetchAndCacheUserProfile(String plexToken) async {
appLogger.d('Fetching user profile from Plex API');
try {
final authService = await PlexAuthService.create();
final profile = await authService.getUserProfile(plexToken);
appLogger.i(
'Successfully fetched user profile',
error: {
'autoSelectAudio': profile.autoSelectAudio,
'defaultAudioLanguage': profile.defaultAudioLanguage ?? 'not set',
'autoSelectSubtitle': profile.autoSelectSubtitle,
'defaultSubtitleLanguage':
profile.defaultSubtitleLanguage ?? 'not set',
'defaultSubtitleForced': profile.defaultSubtitleForced,
},
);
// Cache the profile
final storage = await StorageService.getInstance();
await storage.saveUserProfile(profile.toJson());
appLogger.d('User profile cached locally');
return profile;
} catch (e) {
appLogger.w(
'Failed to fetch user profile from API, attempting to load from cache',
error: e,
);
// Failed to fetch profile, try to load from cache
final storage = await StorageService.getInstance();
final cachedProfile = storage.getUserProfile();
if (cachedProfile != null) {
final profile = PlexUserProfile.fromJson(cachedProfile);
appLogger.i(
'Loaded user profile from cache',
error: {
'autoSelectAudio': profile.autoSelectAudio,
'defaultAudioLanguage': profile.defaultAudioLanguage ?? 'not set',
'autoSelectSubtitle': profile.autoSelectSubtitle,
'defaultSubtitleLanguage':
profile.defaultSubtitleLanguage ?? 'not set',
'defaultSubtitleForced': profile.defaultSubtitleForced,
},
);
return profile;
}
appLogger.w(
'No cached user profile available, track selection will use defaults',
);
return null;
}
}
@override
Widget build(BuildContext context) {
return const Scaffold(
+33 -36
View File
@@ -2,11 +2,11 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../client/plex_client.dart';
import '../config/plex_config.dart';
import '../models/plex_metadata.dart';
import '../models/plex_user_profile.dart';
import '../services/storage_service.dart';
import '../services/plex_auth_service.dart';
import '../services/server_connection_service.dart';
import '../widgets/media_card.dart';
import '../widgets/desktop_app_bar.dart';
import '../widgets/server_list_tile.dart';
@@ -229,34 +229,16 @@ class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable {
);
}
// Test connections to find best working one
final connection = await server.findBestWorkingConnection();
// Close loading dialog
if (mounted) {
Navigator.pop(context);
}
if (connection == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No working connections found for this server'),
),
);
}
return;
}
// Store server information
final storage = await StorageService.getInstance();
await storage.saveServerData(server.toJson());
await storage.saveServerUrl(connection.uri);
await storage.saveServerAccessToken(server.accessToken);
// Get client identifier
final storage = await StorageService.getInstance();
final clientId = storage.getClientIdentifier();
if (clientId == null) {
// Close loading dialog
if (mounted) {
Navigator.pop(context);
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Client identifier not found')),
@@ -265,20 +247,35 @@ class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable {
return;
}
// Create new client
final config = await PlexConfig.create(
baseUrl: connection.uri,
token: server.accessToken,
// Connect using the optimized service
final result = await ServerConnectionService.connectToServer(
server,
clientIdentifier: clientId,
);
final client = PlexClient(config);
// Replace current screen with main screen (includes bottom nav)
// Close loading dialog
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => MainScreen(client: client)),
);
Navigator.pop(context);
}
// Handle result
if (result.isSuccess) {
// Replace current screen with main screen (includes bottom nav)
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => MainScreen(client: result.client!),
),
);
}
} else {
// Show error
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result.error ?? 'Connection failed')),
);
}
}
}
+26 -35
View File
@@ -1,8 +1,7 @@
import 'package:flutter/material.dart';
import '../services/plex_auth_service.dart';
import '../services/storage_service.dart';
import '../client/plex_client.dart';
import '../config/plex_config.dart';
import '../services/server_connection_service.dart';
import '../widgets/server_list_tile.dart';
import '../widgets/desktop_app_bar.dart';
import 'main_screen.dart';
@@ -66,49 +65,41 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
);
}
// Test connections to find best working one
final connection = await server.findBestWorkingConnection();
// Get client identifier
final storage = await StorageService.getInstance();
final clientId =
storage.getClientIdentifier() ?? widget.authService.clientIdentifier;
// Connect using the optimized service
final result = await ServerConnectionService.connectToServer(
server,
clientIdentifier: clientId,
plexToken: widget.plexToken,
);
// Close loading dialog
if (mounted) {
Navigator.pop(context);
}
if (connection == null) {
// Handle result
if (result.isSuccess) {
// Navigate to main app
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No working connections found for this server'),
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => MainScreen(client: result.client!),
),
);
}
return;
}
// Store server information
final storage = await StorageService.getInstance();
await storage.saveServerData(server.toJson());
await storage.saveServerUrl(connection.uri);
await storage.saveServerAccessToken(server.accessToken);
await storage.savePlexToken(widget.plexToken);
// Get client identifier
final clientId =
storage.getClientIdentifier() ?? widget.authService.clientIdentifier;
// Create client and navigate to main app
final config = await PlexConfig.create(
baseUrl: connection.uri,
token: server.accessToken,
clientIdentifier: clientId,
);
final client = PlexClient(config);
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => MainScreen(client: client)),
);
} else {
// Show error
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result.error ?? 'Connection failed')),
);
}
}
}
+114 -2
View File
@@ -281,9 +281,121 @@ class PlexServer {
}
/// Find the best working connection by testing them
/// Tests ALL connections simultaneously and returns the best working one
/// Returns a Stream that emits connections progressively:
/// 1. First emission: The first connection that responds successfully
/// 2. Second emission (optional): The best connection after latency testing
/// Priority: local > remote > relay (from successful connections)
Future<PlexConnection?> findBestWorkingConnection() async {
Stream<PlexConnection> findBestWorkingConnection() async* {
if (connections.isEmpty) return;
// Phase 1: Race to find first working connection
final completer = Completer<PlexConnection?>();
PlexConnection? firstConnection;
int completedTests = 0;
// Start testing all connections simultaneously
for (final connection in connections) {
PlexClient.testConnectionWithLatency(connection.uri, accessToken).then((
result,
) {
completedTests++;
// If this is the first successful connection, emit it immediately
if (result.success && !completer.isCompleted) {
completer.complete(connection);
}
// If all tests complete without success, complete with null
if (completedTests == connections.length && !completer.isCompleted) {
completer.complete(null);
}
});
}
// Wait for and emit the first successful connection
firstConnection = await completer.future;
if (firstConnection == null) {
return; // No working connections found
}
yield firstConnection;
// Phase 2: Continue testing in background to find best connection
// Test each connection 2-3 times and average the latency
final connectionResults = <PlexConnection, ConnectionTestResult>{};
await Future.wait(
connections.map((connection) async {
final result = await PlexClient.testConnectionWithAverageLatency(
connection.uri,
accessToken,
attempts: 2,
);
if (result.success) {
connectionResults[connection] = result;
}
}),
);
// If no connections succeeded, we're done
if (connectionResults.isEmpty) {
return;
}
// Find the best connection considering both priority and latency
PlexConnection? bestConnection;
int bestLatency = double.maxFinite.toInt();
// Group connections by priority
final localConnections = connectionResults.entries
.where((e) => e.key.local && !e.key.relay)
.toList();
final remoteConnections = connectionResults.entries
.where((e) => !e.key.local && !e.key.relay)
.toList();
final relayConnections = connectionResults.entries
.where((e) => e.key.relay)
.toList();
// Find best local connection
for (final entry in localConnections) {
if (entry.value.latencyMs < bestLatency) {
bestLatency = entry.value.latencyMs;
bestConnection = entry.key;
}
}
// If no local connection, find best remote connection
if (bestConnection == null) {
for (final entry in remoteConnections) {
if (entry.value.latencyMs < bestLatency) {
bestLatency = entry.value.latencyMs;
bestConnection = entry.key;
}
}
}
// If no remote connection, find best relay connection
if (bestConnection == null) {
for (final entry in relayConnections) {
if (entry.value.latencyMs < bestLatency) {
bestLatency = entry.value.latencyMs;
bestConnection = entry.key;
}
}
}
// Emit the best connection if it's different from the first one
if (bestConnection != null && bestConnection.uri != firstConnection.uri) {
yield bestConnection;
}
}
/// Legacy method for backward compatibility - returns first working connection
/// For optimal performance, use findBestWorkingConnection() stream instead
@Deprecated('Use findBestWorkingConnection() stream for optimized connection')
Future<PlexConnection?> findBestWorkingConnectionLegacy() async {
if (connections.isEmpty) return null;
// Test all connections simultaneously
+184
View File
@@ -0,0 +1,184 @@
import 'plex_auth_service.dart';
import 'storage_service.dart';
import '../client/plex_client.dart';
import '../config/plex_config.dart';
import '../models/plex_user_profile.dart';
import '../utils/app_logger.dart';
/// Result of a server connection attempt
class ServerConnectionResult {
final PlexClient? client;
final PlexUserProfile? userProfile;
final String? error;
ServerConnectionResult({this.client, this.userProfile, this.error});
bool get isSuccess => client != null;
}
/// Service for handling optimized server connections
/// Implements fast-first connection with background optimization
class ServerConnectionService {
/// Connect to a Plex server with optimized connection testing
///
/// Returns immediately with first working connection, then continues
/// testing in background to find the optimal connection.
///
/// Parameters:
/// - [server]: The PlexServer to connect to
/// - [clientIdentifier]: Client identifier for the PlexClient
/// - [plexToken]: Optional plex.tv token to save to storage
/// - [verifyServer]: Whether to verify server is accessible before returning
/// - [fetchUserProfile]: Whether to fetch and cache user profile
/// - [onProgress]: Callback for progress updates (e.g., show/hide loading)
static Future<ServerConnectionResult> connectToServer(
PlexServer server, {
required String clientIdentifier,
String? plexToken,
bool verifyServer = false,
bool fetchUserProfile = false,
void Function(String message)? onProgress,
}) async {
PlexConnection? firstConnection;
final storage = await StorageService.getInstance();
try {
// Listen to the connection stream for progressive connection testing
await for (final connection in server.findBestWorkingConnection()) {
if (firstConnection == null) {
// First emission - use this connection immediately
firstConnection = connection;
if (onProgress != null) {
onProgress('Connected to ${connection.displayType} endpoint');
}
// Save server information to storage
await storage.saveServerData(server.toJson());
await storage.saveServerUrl(connection.uri);
await storage.saveServerAccessToken(server.accessToken);
// Save plex token if provided
if (plexToken != null) {
await storage.savePlexToken(plexToken);
}
// Create client with working connection
final config = await PlexConfig.create(
baseUrl: connection.uri,
token: server.accessToken,
clientIdentifier: clientIdentifier,
);
final client = PlexClient(config);
// Verify server is accessible if requested
if (verifyServer) {
try {
await client.getServerIdentity();
} catch (e) {
appLogger.w('Server identity verification failed', error: e);
await storage.clearCredentials();
return ServerConnectionResult(
error: 'Server is not accessible: $e',
);
}
}
// Fetch user profile if requested
PlexUserProfile? userProfile;
if (fetchUserProfile && plexToken != null) {
userProfile = await _fetchAndCacheUserProfile(plexToken);
}
// Return success result
// Note: Stream continues in background to find better connection
return ServerConnectionResult(
client: client,
userProfile: userProfile,
);
} else {
// Second emission - better connection found
// Update stored connection seamlessly for future app launches
await storage.saveServerUrl(connection.uri);
appLogger.d(
'Switched to better connection: ${connection.displayType} (${connection.uri})',
);
}
}
// Handle case where no connections were found
if (firstConnection == null) {
return ServerConnectionResult(
error: 'No working connections found for this server',
);
}
// Should never reach here due to return in the stream loop
return ServerConnectionResult(
error: 'Unexpected error in connection flow',
);
} catch (e) {
appLogger.e('Error connecting to server', error: e);
return ServerConnectionResult(error: 'Connection failed: $e');
}
}
/// Fetch user profile from Plex API and cache it locally
static Future<PlexUserProfile?> _fetchAndCacheUserProfile(
String plexToken,
) async {
appLogger.d('Fetching user profile from Plex API');
try {
final authService = await PlexAuthService.create();
final profile = await authService.getUserProfile(plexToken);
appLogger.i(
'Successfully fetched user profile',
error: {
'autoSelectAudio': profile.autoSelectAudio,
'defaultAudioLanguage': profile.defaultAudioLanguage ?? 'not set',
'autoSelectSubtitle': profile.autoSelectSubtitle,
'defaultSubtitleLanguage':
profile.defaultSubtitleLanguage ?? 'not set',
'defaultSubtitleForced': profile.defaultSubtitleForced,
},
);
// Cache the profile
final storage = await StorageService.getInstance();
await storage.saveUserProfile(profile.toJson());
appLogger.d('User profile cached locally');
return profile;
} catch (e) {
appLogger.w(
'Failed to fetch user profile from API, attempting to load from cache',
error: e,
);
// Failed to fetch profile, try to load from cache
final storage = await StorageService.getInstance();
final cachedProfile = storage.getUserProfile();
if (cachedProfile != null) {
final profile = PlexUserProfile.fromJson(cachedProfile);
appLogger.i(
'Loaded user profile from cache',
error: {
'autoSelectAudio': profile.autoSelectAudio,
'defaultAudioLanguage': profile.defaultAudioLanguage ?? 'not set',
'autoSelectSubtitle': profile.autoSelectSubtitle,
'defaultSubtitleLanguage':
profile.defaultSubtitleLanguage ?? 'not set',
'defaultSubtitleForced': profile.defaultSubtitleForced,
},
);
return profile;
}
appLogger.w(
'No cached user profile available, track selection will use defaults',
);
return null;
}
}
}