perf(auth): better connection choice
This commit is contained in:
@@ -6,6 +6,14 @@ import '../models/plex_media_info.dart';
|
|||||||
import '../models/plex_filter.dart';
|
import '../models/plex_filter.dart';
|
||||||
import '../utils/app_logger.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 {
|
class PlexClient {
|
||||||
final PlexConfig config;
|
final PlexConfig config;
|
||||||
late final Dio _dio;
|
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(
|
static Future<bool> testConnectionUrl(
|
||||||
String baseUrl,
|
String baseUrl,
|
||||||
String token, {
|
String token, {
|
||||||
Duration timeout = const Duration(seconds: 5),
|
Duration timeout = const Duration(seconds: 5),
|
||||||
}) async {
|
}) 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 {
|
try {
|
||||||
final dio = Dio(
|
final dio = Dio(
|
||||||
BaseOptions(
|
BaseOptions(
|
||||||
@@ -64,12 +88,57 @@ class PlexClient {
|
|||||||
options: Options(headers: {'X-Plex-Token': token}),
|
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) {
|
} 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
|
/// Get server identity
|
||||||
Future<Map<String, dynamic>> getServerIdentity() async {
|
Future<Map<String, dynamic>> getServerIdentity() async {
|
||||||
final response = await _dio.get('/identity');
|
final response = await _dio.get('/identity');
|
||||||
|
|||||||
+24
-97
@@ -3,15 +3,13 @@ import 'package:flutter/services.dart';
|
|||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import 'package:window_manager/window_manager.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/main_screen.dart';
|
||||||
import 'screens/auth_screen.dart';
|
import 'screens/auth_screen.dart';
|
||||||
import 'services/storage_service.dart';
|
import 'services/storage_service.dart';
|
||||||
import 'services/plex_auth_service.dart';
|
import 'services/plex_auth_service.dart';
|
||||||
|
import 'services/server_connection_service.dart';
|
||||||
import 'services/macos_titlebar_service.dart';
|
import 'services/macos_titlebar_service.dart';
|
||||||
import 'services/fullscreen_state_manager.dart';
|
import 'services/fullscreen_state_manager.dart';
|
||||||
import 'models/plex_user_profile.dart';
|
|
||||||
import 'utils/language_codes.dart';
|
import 'utils/language_codes.dart';
|
||||||
import 'utils/app_logger.dart';
|
import 'utils/app_logger.dart';
|
||||||
|
|
||||||
@@ -97,52 +95,37 @@ class _SetupScreenState extends State<SetupScreen> {
|
|||||||
// Recreate PlexServer from stored data
|
// Recreate PlexServer from stored data
|
||||||
final server = PlexServer.fromJson(serverData);
|
final server = PlexServer.fromJson(serverData);
|
||||||
|
|
||||||
// Test connections to find best working one
|
// Connect using the optimized service
|
||||||
final connection = await server.findBestWorkingConnection();
|
final result = await ServerConnectionService.connectToServer(
|
||||||
|
server,
|
||||||
|
clientIdentifier: clientId,
|
||||||
|
verifyServer: true,
|
||||||
|
fetchUserProfile: plexToken != null,
|
||||||
|
plexToken: plexToken,
|
||||||
|
);
|
||||||
|
|
||||||
if (connection != null) {
|
// Handle result
|
||||||
// Update stored server URL with working connection
|
if (result.isSuccess) {
|
||||||
await storage.saveServerUrl(connection.uri);
|
// Success! Navigate to main screen
|
||||||
|
if (mounted) {
|
||||||
// Create client with working connection
|
Navigator.pushReplacement(
|
||||||
final config = await PlexConfig.create(
|
context,
|
||||||
baseUrl: connection.uri,
|
MaterialPageRoute(
|
||||||
token: server.accessToken,
|
builder: (context) => MainScreen(
|
||||||
clientIdentifier: clientId,
|
client: result.client!,
|
||||||
);
|
userProfile: result.userProfile,
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
return;
|
);
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Server identity check failed
|
|
||||||
await storage.clearCredentials();
|
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
// No working connections found
|
// Connection failed, clear credentials
|
||||||
await storage.clearCredentials();
|
await storage.clearCredentials();
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Error loading or testing server
|
// Error loading or testing server
|
||||||
|
appLogger.e('Error during auto-login', error: e);
|
||||||
await storage.clearCredentials();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const Scaffold(
|
return const Scaffold(
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import '../client/plex_client.dart';
|
import '../client/plex_client.dart';
|
||||||
import '../config/plex_config.dart';
|
|
||||||
import '../models/plex_metadata.dart';
|
import '../models/plex_metadata.dart';
|
||||||
import '../models/plex_user_profile.dart';
|
import '../models/plex_user_profile.dart';
|
||||||
import '../services/storage_service.dart';
|
import '../services/storage_service.dart';
|
||||||
import '../services/plex_auth_service.dart';
|
import '../services/plex_auth_service.dart';
|
||||||
|
import '../services/server_connection_service.dart';
|
||||||
import '../widgets/media_card.dart';
|
import '../widgets/media_card.dart';
|
||||||
import '../widgets/desktop_app_bar.dart';
|
import '../widgets/desktop_app_bar.dart';
|
||||||
import '../widgets/server_list_tile.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
|
// Get client identifier
|
||||||
|
final storage = await StorageService.getInstance();
|
||||||
final clientId = storage.getClientIdentifier();
|
final clientId = storage.getClientIdentifier();
|
||||||
|
|
||||||
if (clientId == null) {
|
if (clientId == null) {
|
||||||
|
// Close loading dialog
|
||||||
|
if (mounted) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('Client identifier not found')),
|
const SnackBar(content: Text('Client identifier not found')),
|
||||||
@@ -265,20 +247,35 @@ class _DiscoverScreenState extends State<DiscoverScreen> with Refreshable {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new client
|
// Connect using the optimized service
|
||||||
final config = await PlexConfig.create(
|
final result = await ServerConnectionService.connectToServer(
|
||||||
baseUrl: connection.uri,
|
server,
|
||||||
token: server.accessToken,
|
|
||||||
clientIdentifier: clientId,
|
clientIdentifier: clientId,
|
||||||
);
|
);
|
||||||
final client = PlexClient(config);
|
|
||||||
|
|
||||||
// Replace current screen with main screen (includes bottom nav)
|
// Close loading dialog
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.pushReplacement(
|
Navigator.pop(context);
|
||||||
context,
|
}
|
||||||
MaterialPageRoute(builder: (context) => MainScreen(client: client)),
|
|
||||||
);
|
// 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')),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../services/plex_auth_service.dart';
|
import '../services/plex_auth_service.dart';
|
||||||
import '../services/storage_service.dart';
|
import '../services/storage_service.dart';
|
||||||
import '../client/plex_client.dart';
|
import '../services/server_connection_service.dart';
|
||||||
import '../config/plex_config.dart';
|
|
||||||
import '../widgets/server_list_tile.dart';
|
import '../widgets/server_list_tile.dart';
|
||||||
import '../widgets/desktop_app_bar.dart';
|
import '../widgets/desktop_app_bar.dart';
|
||||||
import 'main_screen.dart';
|
import 'main_screen.dart';
|
||||||
@@ -66,49 +65,41 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test connections to find best working one
|
// Get client identifier
|
||||||
final connection = await server.findBestWorkingConnection();
|
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
|
// Close loading dialog
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (connection == null) {
|
// Handle result
|
||||||
|
if (result.isSuccess) {
|
||||||
|
// Navigate to main app
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
Navigator.pushReplacement(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text('No working connections found for this server'),
|
MaterialPageRoute(
|
||||||
|
builder: (context) => MainScreen(client: result.client!),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
} else {
|
||||||
}
|
// Show error
|
||||||
|
if (mounted) {
|
||||||
// Store server information
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
final storage = await StorageService.getInstance();
|
SnackBar(content: Text(result.error ?? 'Connection failed')),
|
||||||
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)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -281,9 +281,121 @@ class PlexServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Find the best working connection by testing them
|
/// 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)
|
/// 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;
|
if (connections.isEmpty) return null;
|
||||||
|
|
||||||
// Test all connections simultaneously
|
// Test all connections simultaneously
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user