From ec93062600f00c92955121e1ca22665d0bd7c0f2 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 10 May 2026 00:35:01 +0200 Subject: [PATCH] fix(app): harden playback and plex flows --- lib/exceptions/media_server_exceptions.dart | 6 ++ lib/main.dart | 1 + lib/providers/multi_server_provider.dart | 1 - lib/screens/auth/plex_pin_auth_flow.dart | 12 ++- .../parts/episode_navigation.dart | 1 + .../video_player/parts/playback_prompts.dart | 2 +- .../video_player/parts/playback_services.dart | 2 +- lib/screens/video_player_screen.dart | 9 +- lib/services/plex_auth_service.dart | 50 +++++----- lib/services/plex_client.dart | 63 +++++++++++-- test/services/plex_library_details_test.dart | 94 +++++++++++++++++++ 11 files changed, 207 insertions(+), 34 deletions(-) create mode 100644 test/services/plex_library_details_test.dart diff --git a/lib/exceptions/media_server_exceptions.dart b/lib/exceptions/media_server_exceptions.dart index 316092c6..84143af1 100644 --- a/lib/exceptions/media_server_exceptions.dart +++ b/lib/exceptions/media_server_exceptions.dart @@ -31,6 +31,12 @@ class MediaServerAuthException extends MediaServerException { const MediaServerAuthException(super.message, {this.statusCode}); } +/// Auth polling reached a terminal server-side expiry/rejection state before +/// the user completed the external sign-in flow. +class MediaServerPinExpiredException extends MediaServerAuthException { + const MediaServerPinExpiredException() : super('PIN expired before sign-in'); +} + /// HTTP transport / non-2xx errors. Carries the status code (when known), /// the parsed response body, and the originating URI so callers can log /// useful diagnostics. Both Plex and Jellyfin route their HTTP failures diff --git a/lib/main.dart b/lib/main.dart index ce9db314..249ba529 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -478,6 +478,7 @@ class _MainAppState extends State with WidgetsBindingObserver { _memoryCheckTimer?.cancel(); _appLifecycleListener.dispose(); _downloadManager.dispose(); + _serverManager.dispose(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } diff --git a/lib/providers/multi_server_provider.dart b/lib/providers/multi_server_provider.dart index 03e2bdfa..388be775 100644 --- a/lib/providers/multi_server_provider.dart +++ b/lib/providers/multi_server_provider.dart @@ -261,7 +261,6 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi @override void dispose() { _statusSubscription?.cancel(); - _serverManager.dispose(); super.dispose(); } } diff --git a/lib/screens/auth/plex_pin_auth_flow.dart b/lib/screens/auth/plex_pin_auth_flow.dart index 2680097c..ce8eba32 100644 --- a/lib/screens/auth/plex_pin_auth_flow.dart +++ b/lib/screens/auth/plex_pin_auth_flow.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../exceptions/media_server_exceptions.dart'; import '../../i18n/strings.g.dart'; import '../../services/plex_auth_service.dart'; import '../../focus/focusable_button.dart'; @@ -173,11 +174,20 @@ class _PlexPinAuthFlowState extends State { setState(() { _isPolling = false; _qrAuthUrl = null; - _errorMessage = e.toString(); + _errorMessage = _authErrorMessage(e); }); } } + String _authErrorMessage(Object error) { + if (error is MediaServerPinExpiredException) return t.addServer.pinExpired; + if (error is MediaServerAuthException) return error.message; + if (error is MediaServerHttpException) { + return t.addServer.couldNotReachServer(error: error.message.isEmpty ? error.toString() : error.message); + } + return error.toString(); + } + bool _isCurrentAttempt(int attemptId) => mounted && attemptId == _attemptId; void _retry() { diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index a9bc2e60..298cdfb0 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -2,6 +2,7 @@ part of '../../video_player_screen.dart'; extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { Future _playNext() async { + if (!mounted) return; if (_nextEpisode == null || _isLoadingNext) return; _autoPlayTimer?.cancel(); diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart index ecccb00e..784040d6 100644 --- a/lib/screens/video_player/parts/playback_prompts.dart +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -33,6 +33,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { final isKeyboardMode = PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context); final settings = await SettingsService.getInstance(); + if (!mounted) return; final autoPlayEnabled = settings.read(SettingsService.autoPlayNextEpisode); if (skipAutoPlayCountdown && autoPlayEnabled) { @@ -40,7 +41,6 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { return; } - if (!mounted) return; _setPlayerState(() { _showPlayNextDialog = true; _autoPlayCountdown = autoPlayEnabled ? 5 : -1; diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 10c1de17..ff67e341 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -199,7 +199,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { return; } - _setWakelock(isPlaying); + unawaited(_setWakelock(isPlaying)); if (isPlaying) { // Force a texture refresh on resume to unstick stale frames diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 4f4e0707..0b9d8e62 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -849,6 +849,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin int? lastObservedPositionMs; _positionSubscription = player!.streams.position.listen((position) { + final currentPlayer = player; + if (currentPlayer == null) return; + // Fallback for cases where playbackRestart doesn't fire (observed on // some offline Android playback flows). Prevents a permanent loading // spinner. Checking `position > 0` was broken for resume playback — @@ -868,7 +871,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin lastObservedPositionMs = position.inMilliseconds; } - final duration = player!.state.duration; + final duration = currentPlayer.state.duration; if (duration.inMilliseconds > 0 && position.inMilliseconds >= duration.inMilliseconds - 1000 && !_showPlayNextDialog && @@ -1061,7 +1064,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin player!.abandonAudioFocus(); } - _setWakelock(false); + unawaited(_setWakelock(false)); appLogger.d('Wakelock disabled'); // Restore system UI and orientation preferences (skip if navigating to another video) @@ -1204,6 +1207,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (!_isReplacingWithVideo) { await _restoreWindowsDisplayMode(); } + await _positionSubscription?.cancel(); + _positionSubscription = null; await player?.dispose(); } catch (e) { appLogger.d('Error disposing player before navigation', error: e); diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 8dd22deb..6f3c46e2 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -1,7 +1,9 @@ import 'dart:async'; -import 'dart:io' show InternetAddress, InternetAddressType; +import 'dart:io' show InternetAddress, InternetAddressType, Platform; +import 'package:package_info_plus/package_info_plus.dart'; import 'storage_service.dart'; import 'plex_client.dart'; +import '../exceptions/media_server_exceptions.dart'; import '../models/plex/plex_user_profile.dart'; import '../models/plex/plex_home.dart'; import '../models/user_switch_response.dart'; @@ -47,8 +49,10 @@ class PlexAuthService { final MediaServerHttpClient _http; final String _clientIdentifier; + final String _appVersion; + final String _platformVersion; - PlexAuthService._(this._http, this._clientIdentifier); + PlexAuthService._(this._http, this._clientIdentifier, this._appVersion, this._platformVersion); /// Close the underlying HTTP client. Call when the service is short-lived /// (created for a single API call) to avoid leaking sockets. @@ -61,7 +65,8 @@ class PlexAuthService { receiveTimeout: MediaServerTimeouts.plexTvReceive, ); final clientIdentifier = await storage.getOrCreateClientIdentifier(); - return PlexAuthService._(http, clientIdentifier); + final packageInfo = await PackageInfo.fromPlatform(); + return PlexAuthService._(http, clientIdentifier, packageInfo.version, Platform.operatingSystemVersion); } String get clientIdentifier => _clientIdentifier; @@ -92,12 +97,11 @@ class PlexAuthService { /// Verify if a plex.tv token is valid Future verifyToken(String authToken) async { - try { - final response = await _getUser(authToken); - return response.statusCode == 200; - } catch (e) { - return false; - } + 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 @@ -124,18 +128,22 @@ class PlexAuthService { /// Poll the PIN to check if it has been claimed Future checkPin(int pinId) async { - try { - final response = await _http.get( - '$_plexApiBase/pins/$pinId', - headers: _getCommonHeaders(), - timeout: MediaServerTimeouts.plexTvReceive, - ); + final response = await _http.get( + '$_plexApiBase/pins/$pinId', + headers: _getCommonHeaders(), + timeout: MediaServerTimeouts.plexTvReceive, + ); - final data = response.data as Map; - return data['authToken'] as String?; - } catch (e) { - return null; + 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); + } + _checkStatus(response); + + final data = response.data as Map; + return data['authToken'] as String?; } /// Poll the PIN until it's claimed or timeout. @@ -221,10 +229,10 @@ class PlexAuthService { 'includeSettings': '1', 'includeSharedSettings': '1', 'X-Plex-Product': _appName, - 'X-Plex-Version': '1.1.0', + 'X-Plex-Version': _appVersion, 'X-Plex-Client-Identifier': _clientIdentifier, 'X-Plex-Platform': 'Flutter', - 'X-Plex-Platform-Version': '3.8.1', + 'X-Plex-Platform-Version': _platformVersion, 'X-Plex-Token': currentToken, 'X-Plex-Language': 'en', 'pin': ?pin, diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index ca935b9f..2044da16 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -75,6 +75,13 @@ class _LibraryContentResult { const _LibraryContentResult({required this.items, required this.totalSize}); } +class _LibrarySectionDetails { + final List filters; + final List sorts; + + const _LibrarySectionDetails({required this.filters, required this.sorts}); +} + /// Process hub response in an isolate. /// Top-level function so it can be passed to [Isolate.run]. List _processHubResponse( @@ -182,6 +189,8 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements /// Server-level preferences fetched from /:/prefs Map _serverPrefs = {}; + final Map> _librarySectionDetails = {}; + /// Get all fetched server preferences Map get serverPrefs => Map.unmodifiable(_serverPrefs); @@ -1422,8 +1431,52 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements /// Get available filters for a library section Future> getLibraryFilters(String sectionId) async { if (sectionId == 'shared') return []; - final response = await _getWithFailover('/library/sections/$sectionId/filters'); - return _extractDirectoryList(response, MediaFilter.fromJson); + final details = await _getLibrarySectionDetails(sectionId); + return details.filters; + } + + Future<_LibrarySectionDetails> _getLibrarySectionDetails(String sectionId) { + return _librarySectionDetails.putIfAbsent(sectionId, () async { + try { + final response = await _getWithFailover('/library/sections/$sectionId', queryParameters: {'includeDetails': 1}); + return _extractLibrarySectionDetails(response); + } catch (_) { + _librarySectionDetails.remove(sectionId)?.ignore(); + rethrow; + } + }); + } + + _LibrarySectionDetails _extractLibrarySectionDetails(MediaServerResponse response) { + final container = _getMediaContainer(response); + if (container == null) return const _LibrarySectionDetails(filters: [], sorts: []); + return _LibrarySectionDetails( + filters: _extractFirstNestedList(container, 'Filter', MediaFilter.fromJson), + sorts: _extractFirstNestedList(container, 'Sort', MediaSort.fromJson), + ); + } + + List _extractFirstNestedList( + Map container, + String key, + T Function(Map) fromJson, + ) { + final direct = _parseRawList(container[key], fromJson); + if (direct.isNotEmpty) return direct; + + final directories = container['Directory']; + if (directories is! List) return []; + for (final directory in directories) { + if (directory is! Map) continue; + final parsed = _parseRawList(directory[key], fromJson); + if (parsed.isNotEmpty) return parsed; + } + return []; + } + + List _parseRawList(Object? raw, T Function(Map) fromJson) { + if (raw is! List) return []; + return raw.whereType().map((json) => fromJson(Map.from(json))).toList(); } /// Get first characters (alphabet index) for a library section @@ -1480,11 +1533,7 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements ]; } try { - // Use the dedicated sorts endpoint - final response = await _getWithFailover('/library/sections/$sectionId/sorts'); - - // Parse the Directory array (not Sort array) per the API spec - final sorts = _extractDirectoryList(response, MediaSort.fromJson); + final sorts = (await _getLibrarySectionDetails(sectionId)).sorts; if (sorts.isNotEmpty) { return sorts; diff --git a/test/services/plex_library_details_test.dart b/test/services/plex_library_details_test.dart new file mode 100644 index 00000000..c1cdcaaa --- /dev/null +++ b/test/services/plex_library_details_test.dart @@ -0,0 +1,94 @@ +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/media/media_filter.dart'; +import 'package:plezy/media/media_sort.dart'; +import 'package:plezy/models/plex/plex_config.dart'; +import 'package:plezy/services/plex_api_cache.dart'; +import 'package:plezy/services/plex_client.dart'; + +void main() { + late AppDatabase db; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + PlexApiCache.initialize(db); + }); + + tearDown(() async { + await db.close(); + }); + + PlexClient makeClient(Future Function(http.Request request) handler) { + return PlexClient.forTesting( + config: PlexConfig( + baseUrl: 'https://plex.example.com', + token: 'token', + clientIdentifier: 'client-id', + product: 'Plezy', + version: '1', + ), + serverId: 'server-id', + httpClient: MockClient(handler), + ); + } + + test('filters and sorts share the includeDetails section request', () async { + var requestCount = 0; + final client = makeClient((request) async { + requestCount++; + expect(request.url.path, '/library/sections/1'); + expect(request.url.queryParameters['includeDetails'], '1'); + return http.Response(jsonEncode(_sectionDetailsPayload()), 200, headers: {'content-type': 'application/json'}); + }); + addTearDown(client.close); + + final results = await Future.wait([ + client.getLibraryFilters('1'), + client.fetchSortOptions('1', libraryType: 'movie'), + ]); + + final filters = results[0] as List; + final sorts = results[1] as List; + expect(requestCount, 1); + expect(filters.map((f) => f.filter), ['genre', 'year']); + expect(sorts.map((s) => s.key), ['addedAt', 'titleSort']); + }); +} + +Map _sectionDetailsPayload() => { + 'MediaContainer': { + 'Directory': [ + {'key': 'all', 'title': 'All Movies'}, + { + 'key': '/library/sections/1/all?type=1', + 'title': 'Movies', + 'type': '1', + 'Filter': [ + { + 'filter': 'genre', + 'filterType': 'string', + 'key': '/library/sections/1/genre', + 'title': 'Genre', + 'type': 'filter', + }, + { + 'filter': 'year', + 'filterType': 'integer', + 'key': '/library/sections/1/year', + 'title': 'Year', + 'type': 'filter', + }, + ], + 'Sort': [ + {'defaultDirection': 'desc', 'descKey': 'addedAt:desc', 'key': 'addedAt', 'title': 'Date Added'}, + {'defaultDirection': 'asc', 'descKey': 'titleSort:desc', 'key': 'titleSort', 'title': 'Name'}, + ], + }, + ], + }, +};