From a607904a358f64ac1f725d763eb10005e80af8a0 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 9 Nov 2025 21:24:44 +0100 Subject: [PATCH] fix: better server selector errors --- lib/screens/server_selection_screen.dart | 90 ++++++++++++++-- lib/services/plex_auth_service.dart | 125 ++++++++++++++++++++--- 2 files changed, 190 insertions(+), 25 deletions(-) diff --git a/lib/screens/server_selection_screen.dart b/lib/screens/server_selection_screen.dart index c42ac967..ca10e951 100644 --- a/lib/screens/server_selection_screen.dart +++ b/lib/screens/server_selection_screen.dart @@ -1,4 +1,6 @@ +import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../services/plex_auth_service.dart'; import '../services/storage_service.dart'; import '../services/server_connection_service.dart'; @@ -27,6 +29,7 @@ class _ServerSelectionScreenState extends State { bool _isLoading = true; String? _errorMessage; String? _currentServerUrl; + List>? _debugServerData; @override void initState() { @@ -52,12 +55,58 @@ class _ServerSelectionScreenState extends State { setState(() { _servers = servers; _isLoading = false; + _debugServerData = null; // Clear any previous debug data }); } catch (e) { setState(() { - _errorMessage = 'Failed to load servers: $e'; + _errorMessage = _getErrorMessage(e); _isLoading = false; + // Store debug data if it's a parsing exception + if (e is ServerParsingException) { + _debugServerData = e.invalidServerData; + } else { + _debugServerData = null; + } }); + appLogger.e('Failed to load servers', error: e); + } + } + + String _getErrorMessage(dynamic error) { + if (error is ServerParsingException) { + return 'Found ${error.invalidServerData.length} server(s) with malformed data. No valid servers available.'; + } else if (error is FormatException) { + // Handle JSON parsing errors with more user-friendly messages + if (error.message.contains('Invalid server data')) { + return 'Some servers have incomplete information and were skipped. Please check your Plex.tv account.'; + } else if (error.message.contains('Invalid connection data')) { + return 'Server connection information is incomplete. Please try again.'; + } + return 'Server information is malformed: ${error.message}'; + } else if (error.toString().contains('SocketException') || + error.toString().contains('TimeoutException')) { + return 'Network connection failed. Please check your internet connection and try again.'; + } else if (error.toString().contains('401') || + error.toString().contains('Unauthorized')) { + return 'Authentication failed. Please sign in again.'; + } else if (error.toString().contains('404') || + error.toString().contains('Not Found')) { + return 'Plex service unavailable. Please try again later.'; + } + + return 'Failed to load servers: ${error.toString()}'; + } + + Future _copyDebugDataToClipboard() async { + if (_debugServerData == null) return; + + final jsonString = const JsonEncoder.withIndent(' ').convert(_debugServerData); + await Clipboard.setData(ClipboardData(text: jsonString)); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Server debug data copied to clipboard')), + ); } } @@ -199,18 +248,41 @@ class _ServerSelectionScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - _errorMessage!, - style: TextStyle( - color: Theme.of(context).colorScheme.error, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text( + _errorMessage!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadServers, - child: const Text('Retry'), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: _loadServers, + child: const Text('Retry'), + ), + if (_debugServerData != null) ...[ + const SizedBox(width: 16), + OutlinedButton.icon( + onPressed: _copyDebugDataToClipboard, + icon: const Icon(Icons.copy), + label: const Text('Copy Debug Data'), + ), + ], + ], ), + if (_debugServerData != null) ...[ + const SizedBox(height: 12), + Text( + 'Debug data available for ${_debugServerData!.length} server(s)', + style: Theme.of(context).textTheme.bodySmall, + ), + ], ], ), ) diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 24d9d07a..54b5ec4a 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -143,10 +143,30 @@ class PlexAuthService { final List resources = response.data as List; // Filter for server resources and map to PlexServer objects - return resources - .where((r) => r['provides'] == 'server') - .map((r) => PlexServer.fromJson(r as Map)) - .toList(); + final servers = []; + final invalidServers = >[]; + + for (final resource in resources.where((r) => r['provides'] == 'server')) { + try { + final server = PlexServer.fromJson(resource as Map); + servers.add(server); + } catch (e) { + // Collect invalid servers for debugging + invalidServers.add(resource as Map); + 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, + ); + } + + return servers; } /// Get user information @@ -249,20 +269,35 @@ class PlexServer { }); factory PlexServer.fromJson(Map json) { + // Validate required fields first + if (!_isValidServerJson(json)) { + throw FormatException('Invalid server data: missing required fields (name, clientIdentifier, accessToken, or connections)'); + } + final List connectionsJson = json['connections'] as List; final connections = []; // Parse connections and generate HTTP fallbacks for HTTPS connections for (final c in connectionsJson) { - final connection = PlexConnection.fromJson(c as Map); - connections.add(connection); + try { + final connection = PlexConnection.fromJson(c as Map); + connections.add(connection); - // Generate HTTP fallback for HTTPS connections - if (connection.protocol == 'https') { - connections.add(connection.toHttpFallback()); + // Generate HTTP fallback for HTTPS connections + if (connection.protocol == 'https') { + 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 FormatException('Server has no valid connections'); + } + DateTime? lastSeenAt; if (json['lastSeenAt'] != null) { try { @@ -273,9 +308,9 @@ class PlexServer { } return PlexServer( - name: json['name'] as String, - clientIdentifier: json['clientIdentifier'] as String, - accessToken: json['accessToken'] as String, + 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: json['owned'] as bool? ?? false, product: json['product'] as String?, @@ -285,6 +320,27 @@ class PlexServer { ); } + /// Validates that server JSON contains all required fields with correct types + static bool _isValidServerJson(Map 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 toJson() { return { 'name': name, @@ -516,17 +572,43 @@ class PlexConnection { }); factory PlexConnection.fromJson(Map json) { + // Validate required fields + if (!_isValidConnectionJson(json)) { + throw FormatException('Invalid connection data: missing required fields (protocol, address, port, or uri)'); + } + return PlexConnection( - protocol: json['protocol'] as String, - address: json['address'] as String, - port: json['port'] as int, - uri: json['uri'] as String, + protocol: json['protocol'] as String, // Safe because validated above + address: json['address'] as String, // Safe because validated above + port: json['port'] as int, // Safe because validated above + uri: json['uri'] as String, // Safe because validated above local: json['local'] as bool? ?? false, relay: json['relay'] as bool? ?? false, ipv6: json['IPv6'] as bool? ?? false, ); } + /// Validates that connection JSON contains all required fields with correct types + static bool _isValidConnectionJson(Map 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; + } + + // Check for required port (integer) + if (json['port'] is! int) { + return false; + } + + return true; + } + Map toJson() { return { 'protocol': protocol, @@ -565,3 +647,14 @@ class PlexConnection { ); } } + +/// Custom exception for server parsing errors that includes debug data +class ServerParsingException implements Exception { + final String message; + final List> invalidServerData; + + ServerParsingException(this.message, this.invalidServerData); + + @override + String toString() => message; +}