fix(app): harden playback and plex flows
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -261,7 +261,6 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
||||
@override
|
||||
void dispose() {
|
||||
_statusSubscription?.cancel();
|
||||
_serverManager.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user