fix(app): harden playback and plex flows

This commit is contained in:
edde746
2026-05-10 00:35:01 +02:00
parent 822333bf34
commit ec93062600
11 changed files with 207 additions and 34 deletions
@@ -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
+1
View File
@@ -478,6 +478,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
_memoryCheckTimer?.cancel();
_appLifecycleListener.dispose();
_downloadManager.dispose();
_serverManager.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
-1
View File
@@ -261,7 +261,6 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
@override
void dispose() {
_statusSubscription?.cancel();
_serverManager.dispose();
super.dispose();
}
}
+11 -1
View File
@@ -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<PlexPinAuthFlow> {
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() {
@@ -2,6 +2,7 @@ part of '../../video_player_screen.dart';
extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
Future<void> _playNext() async {
if (!mounted) return;
if (_nextEpisode == null || _isLoadingNext) return;
_autoPlayTimer?.cancel();
@@ -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;
@@ -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
+7 -2
View File
@@ -849,6 +849,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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<VideoPlayerScreen> 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);
+29 -21
View File
@@ -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<bool> 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<String?> 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<String, dynamic>;
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<String, dynamic>;
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,
+56 -7
View File
@@ -75,6 +75,13 @@ class _LibraryContentResult {
const _LibraryContentResult({required this.items, required this.totalSize});
}
class _LibrarySectionDetails {
final List<MediaFilter> filters;
final List<MediaSort> 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<PlexHubDto> _processHubResponse(
@@ -182,6 +189,8 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements
/// Server-level preferences fetched from /:/prefs
Map<String, dynamic> _serverPrefs = {};
final Map<String, Future<_LibrarySectionDetails>> _librarySectionDetails = {};
/// Get all fetched server preferences
Map<String, dynamic> get serverPrefs => Map.unmodifiable(_serverPrefs);
@@ -1422,8 +1431,52 @@ class PlexClient with MediaServerCacheMixin, _PlexLiveTvClientMethods implements
/// Get available filters for a library section
Future<List<MediaFilter>> 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<T> _extractFirstNestedList<T>(
Map<String, dynamic> container,
String key,
T Function(Map<String, dynamic>) 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<T> _parseRawList<T>(Object? raw, T Function(Map<String, dynamic>) fromJson) {
if (raw is! List) return [];
return raw.whereType<Map>().map((json) => fromJson(Map<String, dynamic>.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;
@@ -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<http.Response> 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<Object>([
client.getLibraryFilters('1'),
client.fetchSortOptions('1', libraryType: 'movie'),
]);
final filters = results[0] as List<MediaFilter>;
final sorts = results[1] as List<MediaSort>;
expect(requestCount, 1);
expect(filters.map((f) => f.filter), ['genre', 'year']);
expect(sorts.map((s) => s.key), ['addedAt', 'titleSort']);
});
}
Map<String, dynamic> _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'},
],
},
],
},
};