From 15b22f75a8e6523097e83ab83c18015393888c19 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:43:08 +0200 Subject: [PATCH] refactor: replace dio with package:http --- lib/providers/user_profile_provider.dart | 6 +- .../libraries/tabs/library_browse_tab.dart | 25 +- lib/screens/settings/logs_screen.dart | 13 +- lib/services/discord_rpc_service.dart | 38 +- lib/services/download_manager_service.dart | 13 +- lib/services/image_cache_service.dart | 53 +-- lib/services/plex_auth_service.dart | 75 ++-- lib/services/plex_client.dart | 395 +++++++++--------- lib/services/server_registry.dart | 7 +- lib/services/update_service.dart | 5 +- lib/utils/connection_constants.dart | 8 +- lib/utils/endpoint_failover_interceptor.dart | 135 ------ lib/utils/error_message_utils.dart | 10 +- lib/utils/http_client.dart | 60 --- lib/utils/platform_http_client_io.dart | 25 ++ lib/utils/platform_http_client_stub.dart | 6 + lib/utils/plex_http_client.dart | 364 ++++++++++++++++ lib/utils/plex_http_exception.dart | 82 ++++ macos/Podfile.lock | 11 +- pubspec.lock | 56 ++- pubspec.yaml | 4 +- 21 files changed, 839 insertions(+), 552 deletions(-) delete mode 100644 lib/utils/http_client.dart create mode 100644 lib/utils/platform_http_client_io.dart create mode 100644 lib/utils/platform_http_client_stub.dart create mode 100644 lib/utils/plex_http_client.dart create mode 100644 lib/utils/plex_http_exception.dart diff --git a/lib/providers/user_profile_provider.dart b/lib/providers/user_profile_provider.dart index 0f19474a..3cae6323 100644 --- a/lib/providers/user_profile_provider.dart +++ b/lib/providers/user_profile_provider.dart @@ -1,5 +1,5 @@ -import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; +import '../utils/plex_http_exception.dart'; import '../models/plex_home.dart'; import '../models/plex_home_user.dart'; import '../models/plex_user_profile.dart'; @@ -302,8 +302,8 @@ class UserProfileProvider extends ChangeNotifier { return true; } catch (e) { // Check if it's a PIN validation error - if (e is DioException && e.response?.statusCode == 403) { - final errors = e.response?.data['errors'] as List?; + if (e is PlexHttpException && e.statusCode == 403) { + final errors = (e.responseData is Map) ? (e.responseData as Map)['errors'] as List? : null; if (errors != null && errors.isNotEmpty) { final errorCode = errors.first['code'] as int?; final errorMessage = errors.first['message'] as String?; diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 18f2894d..5b73b92b 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -4,7 +4,8 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:dio/dio.dart'; +import '../../../utils/plex_http_client.dart'; +import '../../../utils/plex_http_exception.dart'; import '../../../focus/dpad_navigator.dart'; import '../../../focus/input_mode_tracker.dart'; import '../../../../services/plex_client.dart'; @@ -203,7 +204,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _loadedItems = {}; final Set _loadingRanges = {}; - CancelToken? _cancelToken; + AbortController? _cancelToken; int _requestId = 0; int _firstCharactersRequestId = 0; static const int _fetchSize = 200; @@ -229,7 +230,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _loadContent() async { // Cancel any pending request - _cancelToken?.cancel(); + _cancelToken?.abort(); _retryTimer?.cancel(); - _cancelToken = CancelToken(); + _cancelToken = AbortController(); // Use a generation counter for the filter/sort loading phase final generation = ++_requestId; final firstCharactersGeneration = ++_firstCharactersRequestId; @@ -458,9 +459,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _loadItems() async { final currentRequestId = ++_requestId; - _cancelToken?.cancel(); + _cancelToken?.abort(); _retryTimer?.cancel(); - _cancelToken = CancelToken(); + _cancelToken = AbortController(); setState(() { isLoading = true; @@ -485,7 +486,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState { try { final response = await httpClient.post( 'https://ice.plezy.app/logs', - data: logText, - options: Options(contentType: 'text/plain'), + body: logText, + headers: {'Content-Type': 'text/plain'}, ); if (!mounted) return; Navigator.of(context).pop(); // dismiss loading - final id = - (jsonDecode(response.data is String ? response.data : jsonEncode(response.data)) - as Map)['id'] - as String; + final data = response.data is String ? jsonDecode(response.data) : response.data; + final id = (data as Map)['id'] as String; showDialog( context: context, diff --git a/lib/services/discord_rpc_service.dart b/lib/services/discord_rpc_service.dart index 72855a0b..f87bcd9e 100644 --- a/lib/services/discord_rpc_service.dart +++ b/lib/services/discord_rpc_service.dart @@ -1,12 +1,11 @@ import 'dart:async'; import 'dart:io'; -import 'dart:typed_data'; import 'package:dart_discord_presence/dart_discord_presence.dart'; -import 'package:dio/dio.dart'; +import 'package:http/http.dart' as http; import '../models/plex_metadata.dart'; -import '../utils/http_client.dart'; +import '../utils/plex_http_client.dart'; import '../utils/app_logger.dart'; import 'plex_client.dart'; import 'settings_service.dart'; @@ -300,29 +299,28 @@ class DiscordRPCService { if (imageUrl.isEmpty) return null; // Fetch image data - final imageResponse = await httpClient.get>( + final imageBytes = await httpClient.getBytes( imageUrl, - options: Options(responseType: ResponseType.bytes, receiveTimeout: const Duration(seconds: 10)), + timeout: const Duration(seconds: 10), ); - - final imageBytes = imageResponse.data; - if (imageBytes == null || imageBytes.isEmpty) return null; + if (imageBytes.isEmpty) return null; // Upload to Litterbox - final formData = FormData.fromMap({ - 'reqtype': 'fileupload', - 'time': '1h', - 'fileToUpload': MultipartFile.fromBytes(Uint8List.fromList(imageBytes), filename: 'thumbnail.jpg'), - }); + final uploadRequest = http.MultipartRequest('POST', Uri.parse(_litterboxUrl)) + ..fields['reqtype'] = 'fileupload' + ..fields['time'] = '1h' + ..files.add(http.MultipartFile.fromBytes( + 'fileToUpload', + imageBytes, + filename: 'thumbnail.jpg', + )); - final uploadResponse = await httpClient.post( - _litterboxUrl, - data: formData, - options: Options(receiveTimeout: const Duration(seconds: 15)), - ); + final uploadStreamed = await httpClient.inner + .send(uploadRequest) + .timeout(const Duration(seconds: 15)); + final uploadedUrl = (await uploadStreamed.stream.bytesToString()).trim(); - final uploadedUrl = uploadResponse.data?.trim(); - if (uploadedUrl != null && uploadedUrl.startsWith('http')) { + if (uploadedUrl.startsWith('http')) { // Cache the URL with 1 hour expiry (matching Litterbox) _litterboxCache[thumbPath] = _CachedUrl(uploadedUrl, DateTime.now().add(const Duration(hours: 1))); appLogger.d('Uploaded and cached thumbnail: $uploadedUrl'); diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index dbbd6cbd..a79f2075 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -2,10 +2,9 @@ import 'dart:async'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; -import 'package:dio/dio.dart'; import 'package:path/path.dart' as path; import 'package:plezy/utils/content_utils.dart'; -import 'package:plezy/utils/http_client.dart'; +import 'package:plezy/utils/plex_http_client.dart'; import '../database/app_database.dart'; import '../database/download_operations.dart'; import 'settings_service.dart'; @@ -50,7 +49,7 @@ class DownloadManagerService { final AppDatabase _database; final DownloadStorageService _storageService; final PlexApiCache _apiCache = PlexApiCache.instance; - final Dio _dio; + final PlexHttpClient _http; // Stream controller for download progress updates final _progressController = StreamController.broadcast(); @@ -119,10 +118,10 @@ class DownloadManagerService { /// Await this before reading download state from the DB to avoid races. late final Future recoveryFuture; - DownloadManagerService({required AppDatabase database, required DownloadStorageService storageService, Dio? dio}) + DownloadManagerService({required AppDatabase database, required DownloadStorageService storageService, PlexHttpClient? http}) : _database = database, _storageService = storageService, - _dio = dio ?? httpClient; + _http = http ?? httpClient; /// Initialize background_downloader with callbacks, notifications, and concurrency config. Future _initializeFileDownloader() async { @@ -957,7 +956,7 @@ class DownloadManagerService { await file.parent.create(recursive: true); // Download the artwork - await _dio.download(url, filePath); + await _http.downloadFile(url, filePath); appLogger.i('Downloaded artwork: $artworkPath -> $filePath'); } catch (e, stack) { appLogger.w('Failed to download artwork: $artworkPath', error: e, stackTrace: stack); @@ -1062,7 +1061,7 @@ class DownloadManagerService { // Download subtitle file final file = File(subtitlePath); await file.parent.create(recursive: true); - await _dio.download(subtitleUrl, subtitlePath); + await _http.downloadFile(subtitleUrl, subtitlePath); appLogger.d('Downloaded subtitle ${subtitle.id} for $globalKey'); } diff --git a/lib/services/image_cache_service.dart b/lib/services/image_cache_service.dart index c26775dd..4a4afce3 100644 --- a/lib/services/image_cache_service.dart +++ b/lib/services/image_cache_service.dart @@ -1,15 +1,16 @@ import 'dart:io'; -import 'package:dio/dio.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:http/http.dart' as http; -import '../utils/http_client.dart'; +import '../utils/plex_http_client.dart'; /// Custom cache manager for Plex image transcoding with HTTP/2 multiplexing. /// -/// Uses Dio with [Http2Adapter] so all platforms benefit from HTTP/2 connection -/// multiplexing — many concurrent image downloads over a single connection -/// instead of being limited to a handful of HTTP/1.1 connections. +/// Uses the platform-native HTTP client so iOS/macOS (CupertinoClient) and +/// Android (CronetClient) benefit from HTTP/2 connection multiplexing — +/// many concurrent image downloads over a single connection instead of +/// being limited to a handful of HTTP/1.1 connections. class PlexImageCacheManager extends CacheManager with ImageCacheManager { static const _key = 'plexImageCache'; @@ -21,56 +22,50 @@ class PlexImageCacheManager extends CacheManager with ImageCacheManager { _key, stalePeriod: const Duration(days: 14), maxNrOfCacheObjects: 3000, - fileService: _DioFileService( - Dio()..httpClientAdapter = createHttp2Adapter(), - ), + fileService: _HttpFileService(httpClient.inner), ), ); } -class _DioFileService extends FileService { - final Dio _dio; +class _HttpFileService extends FileService { + final http.Client _client; - _DioFileService(this._dio); + _HttpFileService(this._client); @override Future get( String url, { Map? headers, }) async { - final response = await _dio.get( - url, - options: Options( - headers: headers, - responseType: ResponseType.stream, - ), - ); - return _DioGetResponse(response); + final request = http.Request('GET', Uri.parse(url)); + if (headers != null) request.headers.addAll(headers); + final response = await _client.send(request); + return _HttpGetResponse(response); } } -class _DioGetResponse implements FileServiceResponse { - final Response _response; +class _HttpGetResponse implements FileServiceResponse { + final http.StreamedResponse _response; final DateTime _receivedTime = DateTime.now(); - _DioGetResponse(this._response); + _HttpGetResponse(this._response); @override - Stream> get content => _response.data!.stream; + Stream> get content => _response.stream; @override int? get contentLength { - final value = _header(HttpHeaders.contentLengthHeader); + final value = _response.headers[HttpHeaders.contentLengthHeader]; return value != null ? int.tryParse(value) : null; } @override - int get statusCode => _response.statusCode ?? 200; + int get statusCode => _response.statusCode; @override DateTime get validTill { var ageDuration = const Duration(days: 7); - final controlHeader = _header(HttpHeaders.cacheControlHeader); + final controlHeader = _response.headers[HttpHeaders.cacheControlHeader]; if (controlHeader != null) { for (final setting in controlHeader.split(',')) { final s = setting.trim().toLowerCase(); @@ -85,17 +80,15 @@ class _DioGetResponse implements FileServiceResponse { } @override - String? get eTag => _header(HttpHeaders.etagHeader); + String? get eTag => _response.headers[HttpHeaders.etagHeader]; @override String get fileExtension { - final contentTypeHeader = _header(HttpHeaders.contentTypeHeader); + final contentTypeHeader = _response.headers[HttpHeaders.contentTypeHeader]; if (contentTypeHeader != null) { final ct = ContentType.parse(contentTypeHeader); return '.${ct.subType}'; } return ''; } - - String? _header(String name) => _response.headers.value(name); } diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index 5dcb728b..39b8538a 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'package:dio/dio.dart'; import 'package:uuid/uuid.dart'; import 'storage_service.dart'; import 'plex_client.dart'; @@ -8,7 +7,8 @@ import '../models/plex_home.dart'; import '../models/user_switch_response.dart'; import '../utils/app_logger.dart'; import '../utils/connection_constants.dart'; -import '../utils/http_client.dart'; +import '../utils/plex_http_client.dart'; +import '../utils/plex_http_exception.dart'; /// Redacts the middle of an IP address or hostname for safe logging. /// E.g. `192.168.1.50` → `192.***.***.50`, `my.server.example.com` → `my.***.***. com`. @@ -45,16 +45,17 @@ class PlexAuthService { static const String _plexApiBase = 'https://plex.tv/api/v2'; static const String _clientsApi = 'https://clients.plex.tv/api/v2'; - final Dio _dio; + final PlexHttpClient _http; final String _clientIdentifier; - PlexAuthService._(this._dio, this._clientIdentifier); + PlexAuthService._(this._http, this._clientIdentifier); static Future create() async { final storage = await StorageService.getInstance(); - final dio = Dio( - BaseOptions(connectTimeout: ConnectionTimeouts.plexTvConnect, receiveTimeout: ConnectionTimeouts.plexTvReceive), - )..httpClientAdapter = createHttp2Adapter(); + final http = PlexHttpClient( + connectTimeout: ConnectionTimeouts.plexTvConnect, + receiveTimeout: ConnectionTimeouts.plexTvReceive, + ); // Get or create client identifier String? clientIdentifier = storage.getClientIdentifier(); @@ -63,12 +64,12 @@ class PlexAuthService { await storage.saveClientIdentifier(clientIdentifier); } - return PlexAuthService._(dio, clientIdentifier); + return PlexAuthService._(http, clientIdentifier); } String get clientIdentifier => _clientIdentifier; - Options _getCommonOptions({String? authToken}) { + Map _getCommonHeaders({String? authToken}) { final headers = { 'Accept': 'application/json', 'X-Plex-Product': _appName, @@ -79,18 +80,30 @@ class PlexAuthService { headers['X-Plex-Token'] = authToken; } - return Options(headers: headers); + return headers; } - Future _getUser(String authToken) { - return _dio.get('$_plexApiBase/user', options: _getCommonOptions(authToken: authToken)); + Future _getUser(String authToken) { + return _http.get('$_plexApiBase/user', headers: _getCommonHeaders(authToken: authToken)); + } + + /// Throw [PlexHttpException] if the response indicates a client/server error. + void _checkStatus(PlexResponse response) { + if (response.statusCode >= 400) { + throw PlexHttpException( + type: PlexHttpErrorType.unknown, + statusCode: response.statusCode, + responseData: response.data, + message: 'HTTP ${response.statusCode}', + ); + } } /// Verify if a plex.tv token is valid Future verifyToken(String authToken) async { try { - await _getUser(authToken); - return true; + final response = await _getUser(authToken); + return response.statusCode == 200; } catch (e) { return false; } @@ -98,8 +111,8 @@ class PlexAuthService { /// Create a PIN for authentication Future> createPin() async { - final response = await _dio.post('$_plexApiBase/pins?strong=true', options: _getCommonOptions()); - + final response = await _http.post('$_plexApiBase/pins?strong=true', headers: _getCommonHeaders()); + _checkStatus(response); return response.data as Map; } @@ -117,7 +130,7 @@ class PlexAuthService { /// Poll the PIN to check if it has been claimed Future checkPin(int pinId) async { try { - final response = await _dio.get('$_plexApiBase/pins/$pinId', options: _getCommonOptions()); + final response = await _http.get('$_plexApiBase/pins/$pinId', headers: _getCommonHeaders()); final data = response.data as Map; return data['authToken'] as String?; @@ -154,11 +167,13 @@ class PlexAuthService { /// Fetch available Plex servers for the authenticated user Future> fetchServers(String authToken) async { - final response = await _dio.get( + final response = await _http.get( '$_clientsApi/resources?includeHttps=1&includeRelay=1&includeIPv6=1', - options: _getCommonOptions(authToken: authToken), + headers: _getCommonHeaders(authToken: authToken), ); + _checkStatus(response); + final List resources = response.data as List; // Filter for server resources and map to PlexServer objects @@ -191,21 +206,21 @@ class PlexAuthService { /// Get user information Future> getUserInfo(String authToken) async { final response = await _getUser(authToken); - + _checkStatus(response); return response.data as Map; } /// Get user profile with preferences (audio/subtitle settings) Future getUserProfile(String authToken) async { - final response = await _dio.get('$_clientsApi/user', options: _getCommonOptions(authToken: authToken)); - + final response = await _http.get('$_clientsApi/user', headers: _getCommonHeaders(authToken: authToken)); + _checkStatus(response); return PlexUserProfile.fromJson(response.data as Map); } /// Get home users for the authenticated user Future getHomeUsers(String authToken) async { - final response = await _dio.get('$_clientsApi/home/users', options: _getCommonOptions(authToken: authToken)); - + final response = await _http.get('$_clientsApi/home/users', headers: _getCommonHeaders(authToken: authToken)); + _checkStatus(response); return PlexHome.fromJson(response.data as Map); } @@ -226,15 +241,13 @@ class PlexAuthService { 'pin': ?pin, }; - final queryString = queryParams.entries - .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') - .join('&'); - - final response = await _dio.post( - '$_clientsApi/home/users/$userUUID/switch?$queryString', - options: Options(headers: {'Accept': 'application/json', 'Content-Length': '0'}), + final response = await _http.post( + '$_clientsApi/home/users/$userUUID/switch', + queryParameters: queryParams, + headers: {'Accept': 'application/json', 'Content-Length': '0'}, ); + _checkStatus(response); return UserSwitchResponse.fromJson(response.data as Map); } } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 3df59e56..d0806346 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -1,12 +1,10 @@ import 'dart:async'; -import 'dart:convert'; import '../utils/isolate_helper.dart'; import 'dart:math'; import 'package:flutter/foundation.dart'; -import 'package:dio/dio.dart'; - -import '../utils/http_client.dart'; +import '../utils/plex_http_client.dart'; +import '../utils/plex_http_exception.dart'; import '../models/livetv_capture_buffer.dart'; import '../models/livetv_channel.dart'; import '../models/livetv_dvr.dart'; @@ -99,14 +97,9 @@ class ConnectionTestResult { ConnectionTestResult({required this.success, required this.latencyMs, this.error}); } -// Top-level function required by tryIsolateRun() -String _decodeUtf8(List bytes) { - return utf8.decode(bytes, allowMalformed: true); -} - class PlexClient { PlexConfig config; - late final Dio _dio; + late final PlexHttpClient _http; final EndpointFailoverManager? _endpointManager; final Future Function(String newBaseUrl)? _onEndpointChanged; final VoidCallback? _onAllEndpointsExhausted; @@ -137,19 +130,6 @@ class PlexClient { /// Get current offline mode state bool get isOfflineMode => _offlineMode; - /// Custom response decoder that handles malformed UTF-8 gracefully. - /// Large responses are decoded in a background isolate to avoid ANR. - static FutureOr _lenientUtf8Decoder( - List responseBytes, - RequestOptions requestOptions, - ResponseBody responseBody, - ) { - if (responseBytes.length > 50 * 1024) { - return tryIsolateRun(() => _decodeUtf8(responseBytes)); - } - return utf8.decode(responseBytes, allowMalformed: true); - } - /// Create a fully initialized PlexClient. /// Fetches /media/providers to discover libraries (including individually shared items) and EPG providers. static Future create( @@ -187,43 +167,82 @@ class PlexClient { LogRedactionManager.registerServerUrl(config.baseUrl); LogRedactionManager.registerToken(config.token); - _dio = Dio( - BaseOptions( - baseUrl: config.baseUrl, - headers: config.headers, - connectTimeout: ConnectionTimeouts.connect, - receiveTimeout: ConnectionTimeouts.receive, - validateStatus: (status) => status != null && status < 500, - responseType: ResponseType.json, - contentType: 'application/json; charset=utf-8', - responseDecoder: _lenientUtf8Decoder, - ), + _http = PlexHttpClient( + baseUrl: config.baseUrl, + defaultHeaders: config.headers, + connectTimeout: ConnectionTimeouts.connect, + receiveTimeout: ConnectionTimeouts.receive, ); - _dio.httpClientAdapter = createHttp2Adapter(); - _dio.transformer = BackgroundTransformer(); + } - // Add interceptor for logging (optional, can be disabled in production) - _dio.interceptors.add( - LogInterceptor(requestBody: false, responseBody: false, error: true, requestHeader: false, responseHeader: false), - ); + bool _failoverSwitching = false; - if (_endpointManager != null) { - _dio.interceptors.add( - EndpointFailoverInterceptor( - dio: _dio, - endpointManager: _endpointManager, - onEndpointSwitch: _handleEndpointSwitch, - onAllEndpointsExhausted: _onAllEndpointsExhausted, - ), - ); + /// Execute a GET request with endpoint failover retry. On timeout/connection + /// errors the next endpoint is tried (once). Non-GET methods are not retried. + Future _getWithFailover( + String path, { + Map? queryParameters, + Map? headers, + Duration? timeout, + AbortController? abort, + }) async { + final gen = _endpointManager?.generation; + try { + return await _http.get(path, + queryParameters: queryParameters, + headers: headers, + timeout: timeout, + abort: abort); + } on PlexHttpException catch (e) { + if (!_shouldAttemptFailover(e) || + _failoverSwitching || + _endpointManager == null || + gen != _endpointManager.generation) { + rethrow; + } + + if (!_endpointManager.hasFallback) { + _endpointManager.resetToFirst(); + _onAllEndpointsExhausted?.call(); + rethrow; + } + + final failedEndpoint = _endpointManager.current; + final nextBaseUrl = _endpointManager.moveToNext(); + if (nextBaseUrl == null) rethrow; + + _failoverSwitching = true; + try { + appLogger.i( + 'Switching Plex endpoint after GET failure', + error: {'from': failedEndpoint, 'to': nextBaseUrl, 'path': path}, + ); + await _handleEndpointSwitch(nextBaseUrl); + final response = await _http.get(path, + queryParameters: queryParameters, + headers: headers, + timeout: timeout, + abort: abort); + appLogger.i('Endpoint failover retry succeeded', + error: {'newEndpoint': nextBaseUrl}); + return response; + } finally { + _failoverSwitching = false; + } } } + bool _shouldAttemptFailover(PlexHttpException e) { + return e.type == PlexHttpErrorType.connectionTimeout || + e.type == PlexHttpErrorType.receiveTimeout || + e.type == PlexHttpErrorType.connectionError; + } + /// Fetch /media/providers and parse libraries + EPG providers from the response. /// This discovers individually shared items that don't appear in /library/sections. Future _initMediaProviders() async { try { - final response = await _dio.get('/media/providers'); + final response = await _getWithFailover('/media/providers'); final container = _getMediaContainer(response); if (container == null) { _providerLibraries = []; @@ -343,15 +362,10 @@ class PlexClient { final stopwatch = Stopwatch()..start(); try { - final dio = Dio( - BaseOptions( - baseUrl: baseUrl, - connectTimeout: timeout, - receiveTimeout: timeout, - validateStatus: (status) => status != null && status < 500, - responseType: ResponseType.json, - contentType: 'application/json; charset=utf-8', - ), + final client = PlexHttpClient( + baseUrl: baseUrl, + connectTimeout: timeout, + receiveTimeout: timeout, ); final headers = {'X-Plex-Token': token}; @@ -361,7 +375,7 @@ class PlexClient { headers['X-Plex-Device-Name'] = 'Plezy'; } - final response = await dio.get('/', options: Options(headers: headers)); + final response = await client.get('/', headers: headers); stopwatch.stop(); final success = response.statusCode == 200; @@ -371,23 +385,21 @@ class PlexClient { latencyMs: stopwatch.elapsedMilliseconds, error: success ? null : 'HTTP ${response.statusCode}', ); - } catch (e) { + } on PlexHttpException catch (e) { stopwatch.stop(); - String error; - if (e is DioException) { - error = switch (e.type) { - DioExceptionType.connectionTimeout => 'Connection timeout', - DioExceptionType.receiveTimeout => 'Receive timeout', - DioExceptionType.connectionError => 'Connection error', - _ => e.type.name, - }; - if (e.response?.statusCode != null) { - error += ' (HTTP ${e.response!.statusCode})'; - } - } else { - error = e.runtimeType.toString(); + String error = switch (e.type) { + PlexHttpErrorType.connectionTimeout => 'Connection timeout', + PlexHttpErrorType.receiveTimeout => 'Receive timeout', + PlexHttpErrorType.connectionError => 'Connection error', + _ => e.type.name, + }; + if (e.statusCode != null) { + error += ' (HTTP ${e.statusCode})'; } return ConnectionTestResult(success: false, latencyMs: stopwatch.elapsedMilliseconds, error: error); + } catch (e) { + stopwatch.stop(); + return ConnectionTestResult(success: false, latencyMs: stopwatch.elapsedMilliseconds, error: e.runtimeType.toString()); } } @@ -428,7 +440,7 @@ class PlexClient { // ============================================================================ /// Extract MediaContainer from API response - Map? _getMediaContainer(Response response) { + Map? _getMediaContainer(PlexResponse response) { if (response.data is Map && response.data.containsKey('MediaContainer')) { return response.data['MediaContainer']; } @@ -443,7 +455,7 @@ class PlexClient { /// Extract list of PlexMetadata from response /// Automatically tags all items with this client's serverId and serverName - List _extractMetadataList(Response response) { + List _extractMetadataList(PlexResponse response) { final container = _getMediaContainer(response); if (container != null && container['Metadata'] != null) { return (container['Metadata'] as List).map((json) => _createTaggedMetadata(json)).toList(); @@ -452,7 +464,7 @@ class PlexClient { } /// Extract first metadata JSON from response (returns raw Map or null) - Map? _getFirstMetadataJson(Response response) { + Map? _getFirstMetadataJson(PlexResponse response) { final container = _getMediaContainer(response); if (container != null && container['Metadata'] != null && (container['Metadata'] as List).isNotEmpty) { return container['Metadata'][0] as Map; @@ -461,7 +473,7 @@ class PlexClient { } /// Generic helper to extract and map Directory list from response - List _extractDirectoryList(Response response, T Function(Map) fromJson) { + List _extractDirectoryList(PlexResponse response, T Function(Map) fromJson) { final container = _getMediaContainer(response); if (container != null && container['Directory'] != null) { return (container['Directory'] as List).map((json) => fromJson(json as Map)).toList(); @@ -470,7 +482,7 @@ class PlexClient { } /// Extract PlexLibrary list from response with auto-tagging - List _extractLibraryList(Response response) { + List _extractLibraryList(PlexResponse response) { final container = _getMediaContainer(response); if (container != null && container['Directory'] != null) { return (container['Directory'] as List) @@ -484,7 +496,7 @@ class PlexClient { } /// Extract PlexPlaylist list from response with auto-tagging - List _extractPlaylistList(Response response) { + List _extractPlaylistList(PlexResponse response) { final container = _getMediaContainer(response); if (container != null && container['Metadata'] != null) { return (container['Metadata'] as List) @@ -504,7 +516,7 @@ class PlexClient { /// Get server identity Future> getServerIdentity() async { - final response = await _dio.get('/identity'); + final response = await _getWithFailover('/identity'); return response.data; } @@ -512,7 +524,7 @@ class PlexClient { /// Returns true only if the server responds with HTTP 200. Future isHealthy() async { try { - final response = await _dio.get('/identity'); + final response = await _getWithFailover('/identity'); return response.statusCode == 200; } catch (e) { return false; @@ -522,7 +534,7 @@ class PlexClient { /// Get running background tasks (thumbnail generation, credit detection, etc.) Future> getActivities() async { try { - final response = await _dio.get('/activities'); + final response = await _getWithFailover('/activities'); final container = _getMediaContainer(response); if (container == null) return []; final activityList = container['Activity'] as List?; @@ -536,7 +548,7 @@ class PlexClient { /// Cancel a running background task by its UUID. Future cancelActivity(String uuid) async { - await _dio.delete('/activities/$uuid'); + await _http.delete('/activities/$uuid'); } /// Get library sections @@ -546,7 +558,7 @@ class PlexClient { Future> getLibraries() async { if (_providerLibraries.isNotEmpty) return _providerLibraries; // Fallback for old servers that don't support /media/providers - final response = await _dio.get('/library/sections'); + final response = await _getWithFailover('/library/sections'); return _extractLibraryList(response); } @@ -556,7 +568,7 @@ class PlexClient { int? start, int? size, Map? filters, - CancelToken? cancelToken, + AbortController? abort, }) async { final queryParams = {}; if (start != null) queryParams['X-Plex-Container-Start'] = start; @@ -571,10 +583,10 @@ class PlexClient { ? '/library/shared/all' : '/library/sections/$sectionId/all'; - final response = await _dio.get( + final response = await _getWithFailover( endpoint, queryParameters: queryParams, - cancelToken: cancelToken, + abort: abort, ); final items = _extractMetadataList(response); @@ -596,7 +608,7 @@ class PlexClient { /// Get the server's machine identifier Future getMachineIdentifier() async { try { - final response = await _dio.get('/'); + final response = await _getWithFailover('/'); final container = _getMediaContainer(response); if (container == null) return null; return container['machineIdentifier'] as String?; @@ -639,7 +651,7 @@ class PlexClient { // because OnDeck is only available from network response, not cache return await _fetchWithCacheFallback>( cacheKey: cacheKey, - networkCall: () => _dio.get( + networkCall: () => _http.get( '/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1, 'includeOnDeck': 1}, ), @@ -691,7 +703,7 @@ class PlexClient { return _fetchWithCacheFallback( cacheKey: cacheKey, networkCall: () => - _dio.get('/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1}), + _http.get('/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1}), parseCache: (cachedData) => _parseMetadataWithImagesFromCachedResponse(cachedData), parseResponse: (response) { final metadataJson = _getFirstMetadataJson(response); @@ -722,9 +734,9 @@ class PlexClient { /// Use this to get fresh data when cross-device sync is needed. Future _fetchWithCacheFallback({ required String cacheKey, - required Future Function() networkCall, + required Future Function() networkCall, required T? Function(dynamic cachedData) parseCache, - required T? Function(Response response) parseResponse, + required T? Function(PlexResponse response) parseResponse, bool cacheResponse = true, }) async { if (_offlineMode) { @@ -751,9 +763,9 @@ class PlexClient { /// already populated the cache (e.g. playback after visiting detail screen). Future _fetchWithCacheFirst({ required String cacheKey, - required Future Function() networkCall, + required Future Function() networkCall, required T? Function(dynamic cachedData) parseCache, - required T? Function(Response response) parseResponse, + required T? Function(PlexResponse response) parseResponse, bool cacheResponse = true, }) async { final cached = await _cache.get(serverId, cacheKey); @@ -777,7 +789,7 @@ class PlexClient { PlexCacheParser.extractFirstMetadata(data); /// Wraps an API call that returns a boolean success status - Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage) async { + Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage) async { try { final response = await apiCall(); return response.statusCode == 200; @@ -789,8 +801,8 @@ class PlexClient { /// Wraps an API call that returns a list, returning empty list on error Future> _wrapListApiCall( - Future Function() apiCall, - List Function(Response response) parseResponse, + Future Function() apiCall, + List Function(PlexResponse response) parseResponse, String errorMessage, ) async { try { @@ -901,7 +913,7 @@ class PlexClient { } return _wrapBoolApiCall( - () => _dio.put('/library/metadata/$ratingKey/prefs', queryParameters: queryParams), + () => _http.put('/library/metadata/$ratingKey/prefs', queryParameters: queryParams), 'Failed to set metadata preferences', ); } @@ -925,7 +937,7 @@ class PlexClient { // Use PUT request on /library/parts/{partId} return _wrapBoolApiCall( - () => _dio.put('/library/parts/$partId', queryParameters: queryParams), + () => _http.put('/library/parts/$partId', queryParameters: queryParams), 'Failed to select streams', ); } @@ -944,7 +956,7 @@ class PlexClient { int forced = 0, }) async { return _wrapListApiCall( - () => _dio.get('/library/metadata/$ratingKey/subtitles', queryParameters: { + () => _http.get('/library/metadata/$ratingKey/subtitles', queryParameters: { 'language': language, if (title != null && title.isNotEmpty) 'title': title, 'hearingImpaired': hearingImpaired, @@ -971,7 +983,7 @@ class PlexClient { required String providerTitle, }) async { return _wrapBoolApiCall( - () => _dio.put('/library/metadata/$ratingKey/subtitles', queryParameters: { + () => _http.put('/library/metadata/$ratingKey/subtitles', queryParameters: { 'key': key, 'codec': codec, 'language': language, @@ -987,7 +999,7 @@ class PlexClient { /// Uses /library/search (same endpoint as Plex Web) which finds shared content. /// Only returns movies and shows, filtering out other types. Future> search(String query, {int limit = 30}) async { - final response = await _dio.get( + final response = await _getWithFailover( '/library/search', queryParameters: { 'query': query, @@ -1026,7 +1038,7 @@ class PlexClient { /// Get recently added media (filtered to video content only) Future> getRecentlyAdded({int limit = 50}) async { - final response = await _dio.get( + final response = await _getWithFailover( '/library/recentlyAdded', queryParameters: {'X-Plex-Container-Size': limit, 'includeGuids': 1}, ); @@ -1040,7 +1052,7 @@ class PlexClient { /// Uses /hubs?identifier=home.continue,home.ondeck which respects the /// server's OnDeckWindow preference (unlike /library/onDeck). Future> getContinueWatching({int count = 20}) async { - final response = await _dio.get('/hubs', queryParameters: { + final response = await _getWithFailover('/hubs', queryParameters: { 'identifier': 'home.continue,home.ondeck', 'count': count, 'includeGuids': 1, @@ -1081,7 +1093,7 @@ class PlexClient { return await _fetchWithCacheFallback>( cacheKey: endpoint, - networkCall: () => _dio.get(endpoint), + networkCall: () => _http.get(endpoint), parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData), parseResponse: (response) => _extractMetadataList(response), ) ?? @@ -1095,7 +1107,7 @@ class PlexClient { return await _fetchWithCacheFallback>( cacheKey: endpoint, - networkCall: () => _dio.get(endpoint), + networkCall: () => _http.get(endpoint), parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData), parseResponse: (response) => _extractMetadataList(response), ) ?? @@ -1116,13 +1128,11 @@ class PlexClient { /// Returns the raw bytes, or null on failure. Future downloadBifFile(int partId) async { try { - final response = await _dio.get>( - '/library/parts/$partId/indexes/sd', - options: Options(responseType: ResponseType.bytes, receiveTimeout: const Duration(seconds: 30)), + final bytes = await _http.getBytes( + '${_http.baseUrl}/library/parts/$partId/indexes/sd', + timeout: const Duration(seconds: 30), ); - if (response.statusCode == 200 && response.data != null) { - return Uint8List.fromList(response.data!); - } + if (bytes.isNotEmpty) return bytes; return null; } catch (_) { return null; @@ -1137,7 +1147,7 @@ class PlexClient { final data = await fetch>( cacheKey: '/library/metadata/$ratingKey', networkCall: () => - _dio.get('/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1}), + _http.get('/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1}), parseCache: (cached) => cached as Map?, parseResponse: (response) => response.data as Map?, ); @@ -1224,7 +1234,7 @@ class PlexClient { data = await _fetchWithCacheFallback>( cacheKey: '/library/metadata/$ratingKey', networkCall: () => - _dio.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}), + _http.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}), parseCache: (cached) => cached as Map?, parseResponse: (response) => response.data as Map?, ); @@ -1242,7 +1252,7 @@ class PlexClient { final data = await _fetchWithCacheFirst>( cacheKey: '/library/metadata/$ratingKey', networkCall: () => - _dio.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}), + _http.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}), parseCache: (cached) => cached as Map?, parseResponse: (response) => response.data as Map?, ); @@ -1310,7 +1320,7 @@ class PlexClient { /// /// If [metadata] is provided, emits a [WatchStateEvent] for UI updates. Future markAsWatched(String ratingKey, {PlexMetadata? metadata}) async { - await _dio.get('/:/scrobble', queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'}); + await _getWithFailover('/:/scrobble', queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'}); if (metadata != null) { WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: true); } @@ -1320,7 +1330,7 @@ class PlexClient { /// /// If [metadata] is provided, emits a [WatchStateEvent] for UI updates. Future markAsUnwatched(String ratingKey, {PlexMetadata? metadata}) async { - await _dio.get('/:/unscrobble', queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'}); + await _getWithFailover('/:/unscrobble', queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'}); if (metadata != null) { WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: false); } @@ -1333,7 +1343,7 @@ class PlexClient { required String state, // 'playing', 'paused', 'stopped', 'buffering' int? duration, }) async { - await _dio.post( + await _http.post( '/:/timeline', queryParameters: { 'ratingKey': ratingKey, @@ -1359,7 +1369,7 @@ class PlexClient { required int duration, required int playbackTime, }) async { - final response = await _dio.get( + final response = await _getWithFailover( '/:/timeline', queryParameters: { 'ratingKey': ratingKey, @@ -1372,7 +1382,7 @@ class PlexClient { 'X-Plex-Session-Identifier': sessionIdentifier, }, ); - if (response.statusCode != null && response.statusCode != 200) { + if (response.statusCode != 200) { appLogger.e('Live timeline returned ${response.statusCode}: ${response.data}'); return null; } @@ -1415,14 +1425,14 @@ class PlexClient { /// Remove item from Continue Watching (On Deck) without affecting watch status or progress /// This uses the same endpoint Plex Web uses to hide items from Continue Watching Future removeFromOnDeck(String ratingKey) async { - await _dio.put('/actions/removeFromContinueWatching', queryParameters: {'ratingKey': ratingKey}); + await _http.put('/actions/removeFromContinueWatching', queryParameters: {'ratingKey': ratingKey}); } /// Rate a media item (0.0-10.0 scale, where each integer = half a star) /// Pass -1 to clear an existing rating Future rateItem(String ratingKey, double rating) { return _wrapBoolApiCall( - () => _dio.put( + () => _http.put( '/:/rate', queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library', 'rating': rating}, ), @@ -1434,14 +1444,14 @@ class PlexClient { /// This permanently removes the item and its associated files from the server /// Returns true if deletion was successful, false otherwise Future deleteMediaItem(String ratingKey) { - return _wrapBoolApiCall(() => _dio.delete('/library/metadata/$ratingKey'), 'Failed to delete media item'); + return _wrapBoolApiCall(() => _http.delete('/library/metadata/$ratingKey'), 'Failed to delete media item'); } /// Get preferences for a library section. /// /// Returns a map of setting id --> value for all settings in the library. Future> getLibrarySectionPrefs(String sectionId) async { - final response = await _dio.get('/library/sections/$sectionId/prefs'); + final response = await _getWithFailover('/library/sections/$sectionId/prefs'); final container = _getMediaContainer(response); if (container == null) return {}; final settings = container['Setting']; @@ -1453,7 +1463,7 @@ class PlexClient { /// Get available filters for a library section Future> getLibraryFilters(String sectionId) async { if (sectionId == 'shared') return []; - final response = await _dio.get('/library/sections/$sectionId/filters'); + final response = await _getWithFailover('/library/sections/$sectionId/filters'); return _extractDirectoryList(response, PlexFilter.fromJson); } @@ -1467,13 +1477,13 @@ class PlexClient { if (type != null) queryParams['type'] = type; if (filters != null) queryParams.addAll(filters); - final response = await _dio.get('/library/sections/$sectionId/firstCharacter', queryParameters: queryParams); + final response = await _getWithFailover('/library/sections/$sectionId/firstCharacter', queryParameters: queryParams); return _extractDirectoryList(response, PlexFirstCharacter.fromJson); } /// Get filter values (e.g., list of genres, years, etc.) Future> getFilterValues(String filterKey) async { - final response = await _dio.get(filterKey); + final response = await _getWithFailover(filterKey); return _extractDirectoryList(response, PlexFilterValue.fromJson); } @@ -1490,7 +1500,7 @@ class PlexClient { } try { // Use the dedicated sorts endpoint - final response = await _dio.get('/library/sections/$sectionId/sorts'); + final response = await _getWithFailover('/library/sections/$sectionId/sorts'); // Parse the Directory array (not Sort array) per the API spec final sorts = _extractDirectoryList(response, PlexSort.fromJson); @@ -1546,7 +1556,7 @@ class PlexClient { /// Returns a list of recommendation hubs like "Trending Movies", "Top in Genre", etc. Future> getLibraryHubs(String sectionId, {int limit = 10}) async { try { - final response = await _dio.get( + final response = await _getWithFailover( '/hubs/sections/$sectionId', queryParameters: {'count': limit, 'includeGuids': 1}, ); @@ -1564,7 +1574,7 @@ class PlexClient { /// This matches the official Plex client's home page layout. Future> getGlobalHubs({int limit = 10}) async { try { - final response = await _dio.get('/hubs', queryParameters: {'count': limit, 'includeGuids': 1}); + final response = await _getWithFailover('/hubs', queryParameters: {'count': limit, 'includeGuids': 1}); final sid = serverId; final sname = serverName; return await tryIsolateRun(() => _processHubResponse(response.data as Map, sid, sname)); @@ -1577,7 +1587,7 @@ class PlexClient { /// Get full content from a hub using its hub key /// Returns the complete list of metadata items in the hub Future> getHubContent(String hubKey) async { - return _wrapListApiCall(() => _dio.get(hubKey), (response) { + return _wrapListApiCall(() => _http.get(hubKey), (response) { final allItems = _extractMetadataList(response); // Filter to only video content (movies, shows, seasons, episodes) return allItems.where((item) { @@ -1590,7 +1600,7 @@ class PlexClient { /// Returns the list of metadata items in the playlist Future> getPlaylist(String playlistId) { return _wrapListApiCall( - () => _dio.get('/playlists/$playlistId/items'), + () => _http.get('/playlists/$playlistId/items'), _extractMetadataList, 'Failed to get playlist', ); @@ -1606,7 +1616,7 @@ class PlexClient { } return _wrapListApiCall( - () => _dio.get('/playlists', queryParameters: queryParams), + () => _http.get('/playlists', queryParameters: queryParams), _extractPlaylistList, 'Failed to get playlists', ); @@ -1616,7 +1626,7 @@ class PlexClient { /// Returns the playlist details (not the items) Future getPlaylistMetadata(String playlistId) async { try { - final response = await _dio.get('/playlists/$playlistId'); + final response = await _getWithFailover('/playlists/$playlistId'); final container = _getMediaContainer(response); if (container == null || container['Metadata'] == null) { @@ -1651,7 +1661,7 @@ class PlexClient { queryParams['playQueueID'] = playQueueId.toString(); } - final response = await _dio.post('/playlists', queryParameters: queryParams); + final response = await _http.post('/playlists', queryParameters: queryParams); final container = _getMediaContainer(response); if (container == null || container['Metadata'] == null) { @@ -1673,7 +1683,7 @@ class PlexClient { /// Delete a playlist Future deletePlaylist(String playlistId) { - return _wrapBoolApiCall(() => _dio.delete('/playlists/$playlistId'), 'Failed to delete playlist'); + return _wrapBoolApiCall(() => _http.delete('/playlists/$playlistId'), 'Failed to delete playlist'); } /// Add items to a playlist @@ -1684,7 +1694,7 @@ class PlexClient { 'Adding to playlist $playlistId with URI: ${uri.substring(0, uri.length > 100 ? 100 : uri.length)}${uri.length > 100 ? "..." : ""}', ); final result = await _wrapBoolApiCall( - () => _dio.put('/playlists/$playlistId/items', queryParameters: {'uri': uri}), + () => _http.put('/playlists/$playlistId/items', queryParameters: {'uri': uri}), 'Failed to add to playlist', ); if (result) { @@ -1698,7 +1708,7 @@ class PlexClient { /// [playlistItemId] - The playlist item ID to remove (from the item's playlistItemID field) Future removeFromPlaylist({required String playlistId, required String playlistItemId}) { return _wrapBoolApiCall( - () => _dio.delete('/playlists/$playlistId/items/$playlistItemId'), + () => _http.delete('/playlists/$playlistId/items/$playlistItemId'), 'Failed to remove from playlist', ); } @@ -1715,7 +1725,7 @@ class PlexClient { }) async { appLogger.d('Moving playlist item $playlistItemId after $afterPlaylistItemId in playlist $playlistId'); final result = await _wrapBoolApiCall( - () => _dio.put( + () => _http.put( '/playlists/$playlistId/items/$playlistItemId/move', queryParameters: {'after': afterPlaylistItemId}, ), @@ -1729,7 +1739,7 @@ class PlexClient { /// Clear all items from a playlist Future clearPlaylist(String playlistId) { - return _wrapBoolApiCall(() => _dio.delete('/playlists/$playlistId/items'), 'Failed to clear playlist'); + return _wrapBoolApiCall(() => _http.delete('/playlists/$playlistId/items'), 'Failed to clear playlist'); } /// Update playlist metadata (e.g., title, summary) @@ -1747,7 +1757,7 @@ class PlexClient { } return _wrapBoolApiCall( - () => _dio.put('/library/metadata/$playlistId', queryParameters: queryParams), + () => _http.put('/library/metadata/$playlistId', queryParameters: queryParams), 'Failed to update playlist', ); } @@ -1807,7 +1817,7 @@ class PlexClient { } return _wrapBoolApiCall( - () => _dio.put('/library/sections/$sectionId/all', queryParameters: queryParams), + () => _http.put('/library/sections/$sectionId/all', queryParameters: queryParams), 'Failed to update metadata', ); } @@ -1815,7 +1825,7 @@ class PlexClient { /// Get available artwork (posters or backgrounds) for a media item Future>> getAvailableArtwork(String ratingKey, String element) async { try { - final response = await _dio.get('/library/metadata/$ratingKey/$element'); + final response = await _getWithFailover('/library/metadata/$ratingKey/$element'); final container = _getMediaContainer(response); if (container != null && container['Metadata'] != null) { return (container['Metadata'] as List).cast>(); @@ -1831,7 +1841,7 @@ class PlexClient { Future setArtworkFromUrl(String ratingKey, String element, String url) { final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element; return _wrapBoolApiCall( - () => _dio.put('/library/metadata/$ratingKey/$setElement', queryParameters: {'url': url}), + () => _http.put('/library/metadata/$ratingKey/$setElement', queryParameters: {'url': url}), 'Failed to set artwork from URL', ); } @@ -1840,10 +1850,10 @@ class PlexClient { Future uploadArtwork(String ratingKey, String element, List bytes) { final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element; return _wrapBoolApiCall( - () => _dio.put( + () => _http.put( '/library/metadata/$ratingKey/$setElement', - data: bytes, - options: Options(headers: {'Content-Length': bytes.length}, contentType: 'application/octet-stream'), + body: bytes, + headers: {'Content-Type': 'application/octet-stream', 'Content-Length': '${bytes.length}'}, ), 'Failed to upload artwork', ); @@ -1852,7 +1862,7 @@ class PlexClient { /// Update per-media advanced preferences Future updateMetadataPrefs(String ratingKey, Map prefs) { return _wrapBoolApiCall( - () => _dio.put('/library/metadata/$ratingKey/prefs', queryParameters: prefs), + () => _http.put('/library/metadata/$ratingKey/prefs', queryParameters: prefs), 'Failed to update metadata preferences', ); } @@ -1865,7 +1875,7 @@ class PlexClient { /// Returns collections as PlexMetadata objects with type="collection" Future> getLibraryCollections(String sectionId) async { return _wrapListApiCall( - () => _dio.get('/library/sections/$sectionId/collections', queryParameters: {'includeGuids': 1}), + () => _http.get('/library/sections/$sectionId/collections', queryParameters: {'includeGuids': 1}), (response) { final allItems = _extractMetadataList(response); // Collections should have type="collection" @@ -1881,7 +1891,7 @@ class PlexClient { /// Returns the list of metadata items in the collection Future> getCollectionItems(String collectionId) { return _wrapListApiCall( - () => _dio.get('/library/collections/$collectionId/children'), + () => _http.get('/library/collections/$collectionId/children'), _extractMetadataList, 'Failed to get collection items', ); @@ -1892,7 +1902,7 @@ class PlexClient { Future deleteCollection(String sectionId, String collectionId) async { appLogger.d('Deleting collection: sectionId=$sectionId, collectionId=$collectionId'); final result = await _wrapBoolApiCall( - () => _dio.delete('/library/collections/$collectionId'), + () => _http.delete('/library/collections/$collectionId'), 'Failed to delete collection', ); if (result) { @@ -1912,7 +1922,7 @@ class PlexClient { }) async { try { appLogger.d('Creating collection: sectionId=$sectionId, title=$title, type=$type'); - final response = await _dio.post( + final response = await _http.post( '/library/collections', queryParameters: {'type': ?type, 'title': title, 'smart': 0, 'sectionId': sectionId, 'uri': uri}, ); @@ -1942,7 +1952,7 @@ class PlexClient { Future addToCollection({required String collectionId, required String uri}) async { appLogger.d('Adding items to collection: collectionId=$collectionId'); final result = await _wrapBoolApiCall( - () => _dio.put('/library/collections/$collectionId/items', queryParameters: {'uri': uri}), + () => _http.put('/library/collections/$collectionId/items', queryParameters: {'uri': uri}), 'Failed to add items to collection', ); if (result) { @@ -1956,7 +1966,7 @@ class PlexClient { Future removeFromCollection({required String collectionId, required String itemId}) async { appLogger.d('Removing item from collection: collectionId=$collectionId, itemId=$itemId'); final result = await _wrapBoolApiCall( - () => _dio.delete('/library/collections/$collectionId/items/$itemId'), + () => _http.delete('/library/collections/$collectionId/items/$itemId'), 'Failed to remove item from collection', ); if (result) { @@ -1998,7 +2008,7 @@ class PlexClient { queryParams['key'] = key; } - final response = await _dio.post('/playQueues', queryParameters: queryParams); + final response = await _http.post('/playQueues', queryParameters: queryParams); return PlayQueueResponse.fromJson(response.data, serverId: serverId, serverName: serverName); } catch (e) { @@ -2027,7 +2037,7 @@ class PlexClient { queryParams['center'] = center; } - final response = await _dio.get('/playQueues/$playQueueId', queryParameters: queryParams); + final response = await _getWithFailover('/playQueues/$playQueueId', queryParameters: queryParams); return PlayQueueResponse.fromJson(response.data, serverId: serverId, serverName: serverName); } catch (e) { @@ -2040,7 +2050,7 @@ class PlexClient { /// The currently selected item is maintained Future shufflePlayQueue(int playQueueId) async { try { - final response = await _dio.put('/playQueues/$playQueueId/shuffle'); + final response = await _http.put('/playQueues/$playQueueId/shuffle'); return PlayQueueResponse.fromJson(response.data); } catch (e) { appLogger.e('Failed to shuffle play queue: $e'); @@ -2050,7 +2060,7 @@ class PlexClient { /// Clear all items from a play queue Future clearPlayQueue(int playQueueId) { - return _wrapBoolApiCall(() => _dio.delete('/playQueues/$playQueueId/items'), 'Failed to clear play queue'); + return _wrapBoolApiCall(() => _http.delete('/playQueues/$playQueueId/items'), 'Failed to clear play queue'); } /// Create a play queue for a TV show (all episodes) @@ -2092,7 +2102,7 @@ class PlexClient { /// Extract both Metadata and Directory entries from response /// Folders can come back as either type /// Automatically tags all items with this client's serverId and serverName - List _extractMetadataAndDirectories(Response response) { + List _extractMetadataAndDirectories(PlexResponse response) { final List items = []; final container = _getMediaContainer(response); @@ -2163,7 +2173,7 @@ class PlexClient { /// Returns the top-level folder structure for filesystem-based browsing Future> getLibraryFolders(String sectionId) async { try { - final response = await _dio.get( + final response = await _getWithFailover( '/library/sections/$sectionId/folder', queryParameters: {'includeCollections': 0}, ); @@ -2178,7 +2188,7 @@ class PlexClient { /// Returns files and subfolders within the given folder Future> getFolderChildren(String folderKey) async { try { - final response = await _dio.get(folderKey); + final response = await _getWithFailover(folderKey); return _extractMetadataAndDirectories(response); } catch (e) { appLogger.e('Failed to get folder children: $e'); @@ -2201,22 +2211,22 @@ class PlexClient { /// Scan/refresh a library section to detect new files Future scanLibrary(String sectionId) async { - await _dio.get('/library/sections/$sectionId/refresh'); + await _getWithFailover('/library/sections/$sectionId/refresh'); } /// Refresh metadata for a library section Future refreshLibraryMetadata(String sectionId) async { - await _dio.get('/library/sections/$sectionId/refresh?force=1'); + await _getWithFailover('/library/sections/$sectionId/refresh?force=1'); } /// Empty trash for a library section Future emptyLibraryTrash(String sectionId) async { - await _dio.put('/library/sections/$sectionId/emptyTrash'); + await _http.put('/library/sections/$sectionId/emptyTrash'); } /// Analyze library section Future analyzeLibrary(String sectionId) async { - await _dio.get('/library/sections/$sectionId/analyze'); + await _getWithFailover('/library/sections/$sectionId/analyze'); } // ============================================================================ @@ -2227,7 +2237,7 @@ class PlexClient { /// Uses X-Plex-Container-Size: 1 to get totalSize with minimal data transfer. Future getLibraryTotalCount(String sectionId) async { try { - final response = await _dio.get( + final response = await _getWithFailover( '/library/sections/$sectionId/all', queryParameters: {'X-Plex-Container-Start': 0, 'X-Plex-Container-Size': 1}, ); @@ -2244,7 +2254,7 @@ class PlexClient { /// Uses the allLeaves endpoint to count all episodes. Future getLibraryEpisodeCount(String sectionId) async { try { - final response = await _dio.get( + final response = await _getWithFailover( '/library/sections/$sectionId/allLeaves', queryParameters: {'X-Plex-Container-Start': 0, 'X-Plex-Container-Size': 1}, ); @@ -2266,7 +2276,7 @@ class PlexClient { final epochSeconds = since.millisecondsSinceEpoch ~/ 1000; queryParams['viewedAt>'] = epochSeconds; } - final response = await _dio.get('/status/sessions/history/all', queryParameters: queryParams); + final response = await _getWithFailover('/status/sessions/history/all', queryParameters: queryParams); final container = _getMediaContainer(response); return container?['totalSize'] as int? ?? container?['size'] as int? ?? 0; } catch (e) { @@ -2281,7 +2291,7 @@ class PlexClient { /// Get all DVR devices configured on this server Future> getDvrs() async { - return _wrapListApiCall(() => _dio.get('/livetv/dvrs'), (response) { + return _wrapListApiCall(() => _http.get('/livetv/dvrs'), (response) { final container = _getMediaContainer(response); if (container != null && container['Dvr'] != null) { return (container['Dvr'] as List).map((json) => LiveTvDvr.fromJson(json as Map)).toList(); @@ -2298,7 +2308,7 @@ class PlexClient { /// Get EPG channels using provider lineup endpoints (matches official Plex web client) Future> getEpgChannels({String? lineup}) async { - List parseChannels(Response response) { + List parseChannels(PlexResponse response) { final container = _getMediaContainer(response); if (container != null && container['Channel'] is List && (container['Channel'] as List).isNotEmpty) { appLogger.d('EPG channel sample: ${(container['Channel'] as List).first}'); @@ -2330,7 +2340,7 @@ class PlexClient { final allChannels = []; for (final provider in _providerEpg) { try { - final response = await _dio.get('/${provider.identifier}/lineups/dvr/channels'); + final response = await _getWithFailover('/${provider.identifier}/lineups/dvr/channels'); allChannels.addAll(parseChannels(response)); } catch (e) { appLogger.e('Failed to get EPG channels from ${provider.identifier}', error: e); @@ -2370,7 +2380,7 @@ class PlexClient { for (final provider in providers) { try { final programs = await _wrapListApiCall( - () => _dio.get(provider.gridEndpoint, queryParameters: queryParams), + () => _http.get(provider.gridEndpoint, queryParameters: queryParams), (response) => _parseEpgGridResponse(response, provider.identifier), 'Failed to get EPG grid from ${provider.identifier}', ); @@ -2385,7 +2395,7 @@ class PlexClient { } /// Parse an EPG grid response into a list of [LiveTvProgram] objects. - List _parseEpgGridResponse(Response response, String providerIdentifier) { + List _parseEpgGridResponse(PlexResponse response, String providerIdentifier) { final container = _getMediaContainer(response); if (container != null && container['Metadata'] is List && (container['Metadata'] as List).isNotEmpty) { appLogger.d('EPG grid sample from $providerIdentifier: ${(container['Metadata'] as List).first}'); @@ -2415,7 +2425,7 @@ class PlexClient { for (final provider in providers) { try { - final response = await _dio.get( + final response = await _getWithFailover( '/${provider.identifier}/hubs/discover', queryParameters: { 'count': count, @@ -2548,12 +2558,12 @@ class PlexClient { try { final sessionIdentifier = generateSessionIdentifier(); - final response = await _dio.post( + final response = await _http.post( '/livetv/dvrs/$dvrKey/channels/$channelIdentifier/tune', queryParameters: {'X-Plex-Session-Identifier': sessionIdentifier}, ); - if (response.statusCode != null && response.statusCode! >= 400) { + if (response.statusCode >= 400) { appLogger.w('Tune channel returned status ${response.statusCode}'); return null; } @@ -2729,22 +2739,19 @@ class PlexClient { if (config.token != null) 'X-Plex-Token': config.token!, }; - // Manual query encoding — Dio encodes spaces as '+' but Plex requires '%20'. + // Manual query encoding — use '%20' for spaces as Plex requires. final queryString = allParams.entries .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') .join('&'); - // Decision — bare Dio so no default X-Plex-* HTTP headers leak through. - final decisionDio = Dio( - BaseOptions( - headers: {'Accept-Language': 'en'}, - connectTimeout: ConnectionTimeouts.connect, - receiveTimeout: ConnectionTimeouts.receive, - validateStatus: (status) => status != null && status < 500, - ), + // Decision — separate client so no default X-Plex-* HTTP headers leak through. + final decisionClient = PlexHttpClient( + connectTimeout: ConnectionTimeouts.connect, + receiveTimeout: ConnectionTimeouts.receive, + defaultHeaders: {'Accept-Language': 'en'}, ); final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString'; - final decisionResponse = await decisionDio.getUri(Uri.parse(decisionUrl)); + final decisionResponse = await decisionClient.get(decisionUrl); if (decisionResponse.statusCode != 200) { appLogger.w('Decision returned ${decisionResponse.statusCode}'); @@ -2774,7 +2781,7 @@ class PlexClient { /// Get active live TV sessions Future> getLiveTvSessions() { return _wrapListApiCall( - () => _dio.get('/livetv/sessions'), + () => _http.get('/livetv/sessions'), _extractMetadataList, 'Failed to get live TV sessions', ); @@ -2794,9 +2801,9 @@ class PlexClient { /// Get favorite channels from the Plex cloud. Future> getFavoriteChannels() async { try { - final response = await _dio.get( + final response = await _http.get( _favoriteChannelsUrl, - options: Options(headers: _providerVersionHeader), + headers: _providerVersionHeader, ); final container = _getMediaContainer(response); if (container != null && container['FavoriteChannel'] != null) { @@ -2814,10 +2821,10 @@ class PlexClient { /// Update favorite channels on the Plex cloud. Future setFavoriteChannels(List channels) async { try { - await _dio.put( + await _http.put( _favoriteChannelsUrl, - data: channels.map((c) => c.toJson()).toList(), - options: Options(headers: _providerVersionHeader), + body: channels.map((c) => c.toJson()).toList(), + headers: _providerVersionHeader, ); } catch (e) { appLogger.e('Failed to update favorite channels', error: e); @@ -2830,7 +2837,7 @@ class PlexClient { } appLogger.i('Applying Plex endpoint switch', error: newBaseUrl); - _dio.options.baseUrl = newBaseUrl; + _http.baseUrl = newBaseUrl; config = config.copyWith(baseUrl: newBaseUrl); LogRedactionManager.registerServerUrl(newBaseUrl); diff --git a/lib/services/server_registry.dart b/lib/services/server_registry.dart index c51b500e..12c3b37f 100644 --- a/lib/services/server_registry.dart +++ b/lib/services/server_registry.dart @@ -1,8 +1,7 @@ import 'dart:convert'; -import 'package:dio/dio.dart'; - import '../utils/app_logger.dart'; +import '../utils/plex_http_exception.dart'; import 'plex_auth_service.dart'; import 'storage_service.dart'; @@ -125,8 +124,8 @@ class ServerRegistry { await saveServers(updatedServers); appLogger.i('Refreshed ${updatedServers.length} servers from API'); return ServerRefreshResult.success; - } on DioException catch (e) { - if (e.response?.statusCode == 401) { + } on PlexHttpException catch (e) { + if (e.statusCode == 401) { appLogger.w('Plex token is invalid (401), re-authentication required'); return ServerRefreshResult.authError; } diff --git a/lib/services/update_service.dart b/lib/services/update_service.dart index fdbd3900..0859ed95 100644 --- a/lib/services/update_service.dart +++ b/lib/services/update_service.dart @@ -2,9 +2,8 @@ import 'dart:io'; import 'package:auto_updater/auto_updater.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:dio/dio.dart'; import 'package:logger/logger.dart'; -import 'package:plezy/utils/http_client.dart'; +import 'package:plezy/utils/plex_http_client.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// Service to check for new versions on GitHub @@ -157,7 +156,7 @@ class UpdateService { final response = await httpClient.get( 'https://api.github.com/repos/$_githubRepo/releases/latest', - options: Options(headers: {'Accept': 'application/vnd.github+json'}), + headers: {'Accept': 'application/vnd.github+json'}, ); if (response.statusCode == 200) { diff --git a/lib/utils/connection_constants.dart b/lib/utils/connection_constants.dart index 56589c6f..58049c34 100644 --- a/lib/utils/connection_constants.dart +++ b/lib/utils/connection_constants.dart @@ -8,18 +8,18 @@ class ConnectionTimeouts { /// parallel (used in [PlexServer.findBestWorkingConnection]). static const connectionRace = Duration(seconds: 2); - /// Dio connect timeout for individual HTTP requests to a Plex server. + /// HTTP connect timeout for individual HTTP requests to a Plex server. static const connect = Duration(seconds: 10); /// Per-server connection budget: preferred probe + race + HTTPS upgrade attempt + 1s buffer. static const perServerConnect = Duration(milliseconds: 1500 + 2000 + 2000 + 1000); - /// Dio receive timeout for streaming/large responses from a Plex server. + /// HTTP receive timeout for streaming/large responses from a Plex server. static const receive = Duration(seconds: 120); - /// Dio connect timeout for plex.tv / clients.plex.tv API requests. + /// HTTP connect timeout for plex.tv / clients.plex.tv API requests. static const plexTvConnect = Duration(seconds: 5); - /// Dio receive timeout for plex.tv / clients.plex.tv API responses. + /// HTTP receive timeout for plex.tv / clients.plex.tv API responses. static const plexTvReceive = Duration(seconds: 10); } diff --git a/lib/utils/endpoint_failover_interceptor.dart b/lib/utils/endpoint_failover_interceptor.dart index 27ae2123..f50c64f1 100644 --- a/lib/utils/endpoint_failover_interceptor.dart +++ b/lib/utils/endpoint_failover_interceptor.dart @@ -1,12 +1,5 @@ -import 'dart:ui' show VoidCallback; - -import 'package:dio/dio.dart'; - import '../utils/app_logger.dart'; -/// Key used to stamp requests with the failover generation they were issued under. -const _generationKey = '_failoverGeneration'; - /// Maintains the list of endpoints we can cycle through when one fails. class EndpointFailoverManager { EndpointFailoverManager(List urls) { @@ -72,131 +65,3 @@ class EndpointFailoverManager { _currentIndex = _currentIndex.clamp(0, _endpoints.length - 1); } } - -/// Dio interceptor that retries failed requests on the next available endpoint. -class EndpointFailoverInterceptor extends Interceptor { - EndpointFailoverInterceptor({ - required Dio dio, - required this.endpointManager, - required Future Function(String newBaseUrl) onEndpointSwitch, - this.onAllEndpointsExhausted, - }) : _dio = dio, - _onEndpointSwitch = onEndpointSwitch; - - final Dio _dio; - final EndpointFailoverManager endpointManager; - final Future Function(String newBaseUrl) _onEndpointSwitch; - final VoidCallback? onAllEndpointsExhausted; - bool _isSwitching = false; - - @override - void onRequest(RequestOptions options, RequestInterceptorHandler handler) { - // Stamp every outgoing request with the current failover generation so - // we can detect stale requests in onError. - options.extra[_generationKey] = endpointManager.generation; - handler.next(options); - } - - @override - void onError(DioException err, ErrorInterceptorHandler handler) async { - if (_isSwitching || !_shouldAttemptFailover(err)) { - handler.next(err); - return; - } - - // If the endpoint changed since this request was dispatched, the request - // timed out on an already-abandoned endpoint. Don't cascade another switch. - final requestGeneration = err.requestOptions.extra[_generationKey] as int?; - if (requestGeneration != null && requestGeneration != endpointManager.generation) { - appLogger.d( - 'Skipping failover for stale request (generation $requestGeneration != ${endpointManager.generation})', - error: {'path': err.requestOptions.path}, - ); - handler.next(err); - return; - } - - if (!endpointManager.hasFallback) { - // All endpoints exhausted — reset to first so the next failure cycle - // starts from the preferred endpoint (handles transient network outages). - endpointManager.resetToFirst(); - onAllEndpointsExhausted?.call(); - handler.next(err); - return; - } - - final failedEndpoint = endpointManager.current; - appLogger.w( - 'Endpoint request failed, evaluating failover', - error: {'endpoint': failedEndpoint, 'type': err.type.name, 'statusCode': err.response?.statusCode}, - stackTrace: err.stackTrace, - ); - - final nextBaseUrl = endpointManager.moveToNext(); - if (nextBaseUrl == null) { - appLogger.w('Endpoint failure but no fallback endpoints remain', error: {'failedEndpoint': failedEndpoint}); - handler.next(err); - return; - } - - _isSwitching = true; - try { - appLogger.i( - 'Switching Plex endpoint after request failure', - error: {'from': failedEndpoint, 'to': nextBaseUrl, 'path': err.requestOptions.path}, - ); - await _onEndpointSwitch(nextBaseUrl); - final response = await _retryRequest(err.requestOptions); - appLogger.i('Endpoint failover retry succeeded', error: {'newEndpoint': nextBaseUrl}); - handler.resolve(response); - } on DioException catch (dioError) { - appLogger.w( - 'Endpoint failover retry failed', - error: {'newEndpoint': nextBaseUrl, 'type': dioError.type.name, 'statusCode': dioError.response?.statusCode}, - stackTrace: dioError.stackTrace, - ); - handler.next(dioError); - } catch (_) { - handler.next(err); - } finally { - _isSwitching = false; - } - } - - bool _shouldAttemptFailover(DioException error) { - if (error.type == DioExceptionType.connectionTimeout || - error.type == DioExceptionType.receiveTimeout || - error.type == DioExceptionType.sendTimeout || - error.type == DioExceptionType.connectionError) { - return true; - } - - return false; - } - - Future> _retryRequest(RequestOptions requestOptions) { - final options = Options( - method: requestOptions.method, - headers: requestOptions.headers, - responseType: requestOptions.responseType, - contentType: requestOptions.contentType, - followRedirects: requestOptions.followRedirects, - receiveDataWhenStatusError: requestOptions.receiveDataWhenStatusError, - validateStatus: requestOptions.validateStatus, - sendTimeout: requestOptions.sendTimeout, - receiveTimeout: requestOptions.receiveTimeout, - extra: requestOptions.extra, - listFormat: requestOptions.listFormat, - ); - - return _dio.request( - requestOptions.path, - data: requestOptions.data, - queryParameters: requestOptions.queryParameters, - options: options, - cancelToken: requestOptions.cancelToken, - onSendProgress: requestOptions.onSendProgress, - onReceiveProgress: requestOptions.onReceiveProgress, - ); - } -} diff --git a/lib/utils/error_message_utils.dart b/lib/utils/error_message_utils.dart index a3aa85e8..8f9a2ef0 100644 --- a/lib/utils/error_message_utils.dart +++ b/lib/utils/error_message_utils.dart @@ -1,14 +1,14 @@ -import 'package:dio/dio.dart'; import '../i18n/strings.g.dart'; import 'app_logger.dart'; +import 'plex_http_exception.dart'; /// Shared helpers for translating network errors into user-friendly messages. -String mapDioErrorToMessage(DioException error, {required String context}) { +String mapHttpErrorToMessage(PlexHttpException error, {required String context}) { switch (error.type) { - case DioExceptionType.connectionTimeout: - case DioExceptionType.receiveTimeout: + case PlexHttpErrorType.connectionTimeout: + case PlexHttpErrorType.receiveTimeout: return t.errors.connectionTimeout(context: context); - case DioExceptionType.connectionError: + case PlexHttpErrorType.connectionError: return t.errors.connectionFailed; default: appLogger.e('Error loading $context', error: error); diff --git a/lib/utils/http_client.dart b/lib/utils/http_client.dart deleted file mode 100644 index e1c3030e..00000000 --- a/lib/utils/http_client.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:dio/dio.dart'; -import 'package:dio/io.dart'; -import 'package:dio_http2_adapter/dio_http2_adapter.dart'; - -import 'app_logger.dart'; - -/// Shared HTTP/2 connection pool. All Dio instances that go through -/// [createHttp2Adapter] reuse the same pool, so connections to the same -/// host are multiplexed instead of duplicated. -final _connectionManager = ConnectionManager(idleTimeout: const Duration(seconds: 15)); -final _http1Fallback = IOHttpClientAdapter(); - -/// Returns an [HttpClientAdapter] that tries HTTP/2 first and falls back -/// to HTTP/1.1 when the server rejects h2 ALPN negotiation. -HttpClientAdapter createHttp2Adapter() => _Http2WithFallbackAdapter(Http2Adapter(_connectionManager)); - -/// Shared [Dio] instance for ad-hoc HTTP requests that don't go through -/// [PlexClient]. Reuses the global HTTP/2 connection pool. -final httpClient = Dio()..httpClientAdapter = createHttp2Adapter(); - -class _Http2WithFallbackAdapter implements HttpClientAdapter { - _Http2WithFallbackAdapter(this._h2); - - final Http2Adapter _h2; - - static final _http1Hosts = {'plex.tv'}; - - @override - Future fetch( - RequestOptions options, - Stream? requestStream, - Future? cancelFuture, - ) async { - if (_http1Hosts.contains(options.uri.host)) { - return _http1Fallback.fetch(options, requestStream, cancelFuture); - } - try { - return await _h2.fetch(options, requestStream, cancelFuture); - } on HandshakeException { - appLogger.d('H2 handshake failed for ${options.uri.host}, falling back to HTTP/1.1'); - _http1Hosts.add(options.uri.host); - return _http1Fallback.fetch(options, requestStream, cancelFuture); - } catch (e) { - if (e.toString().contains('HTTP/2')) { - appLogger.d('H2 connection error for ${options.uri.host}, falling back to HTTP/1.1'); - _http1Hosts.add(options.uri.host); - return _http1Fallback.fetch(options, requestStream, cancelFuture); - } - rethrow; - } - } - - @override - void close({bool force = false}) { - _h2.close(force: force); - } -} diff --git a/lib/utils/platform_http_client_io.dart b/lib/utils/platform_http_client_io.dart new file mode 100644 index 00000000..d4500086 --- /dev/null +++ b/lib/utils/platform_http_client_io.dart @@ -0,0 +1,25 @@ +import 'dart:io' show Platform; + +import 'package:cronet_http/cronet_http.dart'; +import 'package:cupertino_http/cupertino_http.dart'; +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart'; + +/// Shared Cronet engine so all clients reuse the same connection pool. +CronetEngine? _sharedEngine; + +http.Client createPlatformClient() { + if (Platform.isAndroid) { + _sharedEngine ??= CronetEngine.build( + cacheMode: CacheMode.memory, + cacheMaxSize: 2 * 1024 * 1024, + enableBrotli: true, + enableHttp2: true, + ); + return CronetClient.fromCronetEngine(_sharedEngine!); + } + if (Platform.isIOS || Platform.isMacOS) { + return CupertinoClient.defaultSessionConfiguration(); + } + return IOClient(); +} diff --git a/lib/utils/platform_http_client_stub.dart b/lib/utils/platform_http_client_stub.dart new file mode 100644 index 00000000..c3c37ec6 --- /dev/null +++ b/lib/utils/platform_http_client_stub.dart @@ -0,0 +1,6 @@ +import 'package:http/http.dart' as http; + +/// Fallback stub — should never be called; actual implementation is selected +/// via conditional imports in `plex_http_client.dart`. +http.Client createPlatformClient() => + throw UnsupportedError('No platform HTTP client available'); diff --git a/lib/utils/plex_http_client.dart b/lib/utils/plex_http_client.dart new file mode 100644 index 00000000..40afa581 --- /dev/null +++ b/lib/utils/plex_http_client.dart @@ -0,0 +1,364 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:http/http.dart' as http; + +import 'app_logger.dart'; +import 'isolate_helper.dart'; +import 'log_redaction_manager.dart'; +import 'plex_http_exception.dart'; + +// Platform-specific imports are conditional +import 'platform_http_client_stub.dart' + if (dart.library.io) 'platform_http_client_io.dart' as platform; + +/// Response from [PlexHttpClient] requests. +class PlexResponse { + final int statusCode; + + /// Parsed JSON body (`Map` or `List`), or raw `String` + /// for non-JSON responses. + final dynamic data; + + final Map headers; + + PlexResponse({ + required this.statusCode, + this.data, + required this.headers, + }); +} + +/// Abort controller for cancelling in-flight HTTP requests. +/// +/// Uses the `package:http` [AbortableRequest] mechanism so the underlying +/// transport (IOClient, CronetClient, CupertinoClient) actually cancels +/// the network operation. +class AbortController { + final _completer = Completer(); + + /// The future that triggers abort when completed. + Future get trigger => _completer.future; + + bool get isAborted => _completer.isCompleted; + + void abort() { + if (!_completer.isCompleted) _completer.complete(); + } +} + +/// HTTP client wrapper providing base URL, default headers, JSON parsing, +/// timeouts, logging, and optional endpoint failover. +class PlexHttpClient { + final http.Client _client; + + PlexHttpClient({ + http.Client? client, + this.baseUrl = '', + Map defaultHeaders = const {}, + this.connectTimeout = const Duration(seconds: 10), + this.receiveTimeout = const Duration(seconds: 120), + }) : _client = client ?? platform.createPlatformClient(), + defaultHeaders = Map.of(defaultHeaders); + + /// The underlying [http.Client] for direct streaming / multipart requests. + http.Client get inner => _client; + + String baseUrl; + Map defaultHeaders; + Duration connectTimeout; + Duration receiveTimeout; + + // --------------------------------------------------------------------------- + // Public request methods + // --------------------------------------------------------------------------- + + Future get( + String path, { + Map? queryParameters, + Map? headers, + Duration? timeout, + AbortController? abort, + }) => + _send('GET', path, + queryParameters: queryParameters, + headers: headers, + timeout: timeout, + abort: abort); + + Future post( + String path, { + Map? queryParameters, + Map? headers, + Object? body, + Duration? timeout, + AbortController? abort, + }) => + _send('POST', path, + queryParameters: queryParameters, + headers: headers, + body: body, + timeout: timeout, + abort: abort); + + Future put( + String path, { + Map? queryParameters, + Map? headers, + Object? body, + Duration? timeout, + AbortController? abort, + }) => + _send('PUT', path, + queryParameters: queryParameters, + headers: headers, + body: body, + timeout: timeout, + abort: abort); + + Future delete( + String path, { + Map? queryParameters, + Map? headers, + Duration? timeout, + AbortController? abort, + }) => + _send('DELETE', path, + queryParameters: queryParameters, + headers: headers, + timeout: timeout, + abort: abort); + + /// Fetch raw bytes (e.g. images, BIF files, subtitles). + Future getBytes( + String url, { + Map? headers, + Duration? timeout, + }) async { + final uri = _isAbsoluteUrl(url) ? Uri.parse(url) : _buildUri(url, null); + final request = http.Request('GET', uri); + request.headers.addAll({...defaultHeaders, ...?headers}); + + final sw = Stopwatch()..start(); + try { + final streamed = await _client + .send(request) + .timeout(timeout ?? connectTimeout); + + final bytes = await streamed.stream + .toBytes() + .timeout(timeout ?? receiveTimeout); + + sw.stop(); + _logResponse('GET', uri, streamed.statusCode, sw.elapsedMilliseconds); + return bytes; + } catch (e) { + sw.stop(); + throw PlexHttpException.from(e, uri: uri); + } + } + + /// Stream-download a URL directly into a file. + Future downloadFile( + String url, + String filePath, { + Map? headers, + Duration? timeout, + }) async { + final uri = _isAbsoluteUrl(url) ? Uri.parse(url) : _buildUri(url, null); + final request = http.Request('GET', uri); + request.headers.addAll({...defaultHeaders, ...?headers}); + + try { + final streamed = await _client + .send(request) + .timeout(timeout ?? connectTimeout); + + final file = File(filePath); + final sink = file.openWrite(); + try { + await streamed.stream.pipe(sink); + } finally { + await sink.close(); + } + } catch (e) { + throw PlexHttpException.from(e, uri: uri); + } + } + + /// Send a streamed request (for image cache etc). + Future sendStreamed(http.BaseRequest request) => + _client.send(request); + + void close() => _client.close(); + + // --------------------------------------------------------------------------- + // Core send implementation + // --------------------------------------------------------------------------- + + Future _send( + String method, + String path, { + Map? queryParameters, + Map? headers, + Object? body, + Duration? timeout, + AbortController? abort, + }) async { + final uri = _isAbsoluteUrl(path) + ? _appendQuery(Uri.parse(path), queryParameters) + : _buildUri(path, queryParameters); + + final mergedHeaders = { + ...defaultHeaders, + ...?headers, + }; + + // Build the request — use AbortableRequest when abort is provided + final http.Request request; + if (abort != null) { + request = http.AbortableRequest(method, uri, abortTrigger: abort.trigger); + } else { + request = http.Request(method, uri); + } + request.headers.addAll(mergedHeaders); + _setBody(request, body); + + final sw = Stopwatch()..start(); + try { + // Phase 1: send + receive headers (connect timeout) + final streamed = await _client + .send(request) + .timeout(timeout ?? connectTimeout); + + // Phase 2: consume body (receive timeout) + final bytes = await streamed.stream + .toBytes() + .timeout(timeout ?? receiveTimeout); + + sw.stop(); + _logResponse(method, uri, streamed.statusCode, sw.elapsedMilliseconds); + + final data = await _decodeBody(bytes, streamed.headers); + return PlexResponse( + statusCode: streamed.statusCode, + data: data, + headers: streamed.headers, + ); + } catch (e) { + sw.stop(); + throw PlexHttpException.from(e, uri: uri); + } + } + + // --------------------------------------------------------------------------- + // URI building + // --------------------------------------------------------------------------- + + /// Build a full URI from [baseUrl] + [path] + [queryParameters]. + /// Uses [Uri.encodeComponent] which encodes spaces as `%20` (not `+`). + Uri _buildUri(String path, Map? queryParameters) { + final base = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/'; + final cleanPath = path.startsWith('/') ? path.substring(1) : path; + final query = _encodeQuery(queryParameters); + final full = query.isEmpty ? '$base$cleanPath' : '$base$cleanPath?$query'; + return Uri.parse(full); + } + + /// Append query parameters to an already-parsed URI. + Uri _appendQuery(Uri uri, Map? queryParameters) { + if (queryParameters == null || queryParameters.isEmpty) return uri; + final query = _encodeQuery(queryParameters); + if (query.isEmpty) return uri; + final existing = uri.query; + final combined = existing.isEmpty ? query : '$existing&$query'; + return uri.replace(query: combined); + } + + /// Encode query params with `%20` for spaces (not `+`). + /// Null values are omitted (supports Dart's `?value` map entries). + static String _encodeQuery(Map? params) { + if (params == null || params.isEmpty) return ''; + final parts = []; + for (final entry in params.entries) { + if (entry.value == null) continue; + parts.add( + '${Uri.encodeComponent(entry.key)}=' + '${Uri.encodeComponent(entry.value.toString())}', + ); + } + return parts.join('&'); + } + + static bool _isAbsoluteUrl(String url) => + url.startsWith('http://') || url.startsWith('https://'); + + // --------------------------------------------------------------------------- + // Body serialization + // --------------------------------------------------------------------------- + + /// Set the request body, choosing encoding based on the body type. + void _setBody(http.Request request, Object? body) { + if (body == null) return; + + if (body is List) { + request.bodyBytes = Uint8List.fromList(body); + return; + } + + if (body is String) { + request.body = body; + return; + } + + // Map or List → JSON encode + request.body = jsonEncode(body); + // Only set content-type if the caller hasn't already + if (!request.headers.containsKey('content-type')) { + request.headers['content-type'] = 'application/json; charset=utf-8'; + } + } + + // --------------------------------------------------------------------------- + // Response decoding + // --------------------------------------------------------------------------- + + /// Decode the response body: lenient UTF-8, then JSON parse if applicable. + /// Large payloads are decoded in a background isolate. + Future _decodeBody( + List bytes, Map headers) async { + if (bytes.isEmpty) return null; + + final contentType = headers['content-type'] ?? ''; + final isJson = contentType.contains('json'); + + // For large JSON payloads, do both UTF-8 decode and JSON parse in a + // single isolate roundtrip to avoid two context switches. + if (isJson && bytes.length > 50 * 1024) { + return await tryIsolateRun( + () => jsonDecode(utf8.decode(bytes, allowMalformed: true))); + } + + final body = bytes.length > 50 * 1024 + ? await tryIsolateRun(() => utf8.decode(bytes, allowMalformed: true)) + : utf8.decode(bytes, allowMalformed: true); + + return isJson ? jsonDecode(body) : body; + } + + // --------------------------------------------------------------------------- + // Logging + // --------------------------------------------------------------------------- + + void _logResponse(String method, Uri uri, int statusCode, int ms) { + appLogger.d( + '$method ${LogRedactionManager.redact(uri.toString())} → $statusCode (${ms}ms)', + ); + } +} + +/// Shared [PlexHttpClient] instance for ad-hoc requests (update checks, +/// log uploads, image fetches, etc). No base URL or default Plex headers. +final httpClient = PlexHttpClient(); diff --git a/lib/utils/plex_http_exception.dart b/lib/utils/plex_http_exception.dart new file mode 100644 index 00000000..c067be7f --- /dev/null +++ b/lib/utils/plex_http_exception.dart @@ -0,0 +1,82 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:http/http.dart'; + +enum PlexHttpErrorType { + connectionTimeout, + receiveTimeout, + connectionError, + cancelled, + unknown, +} + +class PlexHttpException implements Exception { + final PlexHttpErrorType type; + final String? message; + final int? statusCode; + final dynamic responseData; + final Uri? requestUri; + + PlexHttpException({ + required this.type, + this.message, + this.statusCode, + this.responseData, + this.requestUri, + }); + + /// Map a caught exception to a [PlexHttpException]. + factory PlexHttpException.from(Object error, {Uri? uri}) { + if (error is PlexHttpException) return error; + + if (error is RequestAbortedException) { + return PlexHttpException( + type: PlexHttpErrorType.cancelled, + message: error.message, + requestUri: error.uri ?? uri, + ); + } + + if (error is TimeoutException) { + return PlexHttpException( + type: PlexHttpErrorType.connectionTimeout, + message: error.message, + requestUri: uri, + ); + } + + if (error is SocketException) { + return PlexHttpException( + type: PlexHttpErrorType.connectionError, + message: error.message, + requestUri: uri, + ); + } + + if (error is HttpException) { + return PlexHttpException( + type: PlexHttpErrorType.connectionError, + message: error.message, + requestUri: uri, + ); + } + + if (error is ClientException) { + return PlexHttpException( + type: PlexHttpErrorType.connectionError, + message: error.message, + requestUri: error.uri ?? uri, + ); + } + + return PlexHttpException( + type: PlexHttpErrorType.unknown, + message: error.toString(), + requestUri: uri, + ); + } + + @override + String toString() => 'PlexHttpException(${type.name}: $message)'; +} diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 371c63f2..f5195f07 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -4,6 +4,9 @@ PODS: - Sparkle - connectivity_plus (0.0.1): - FlutterMacOS + - cupertino_http (0.0.1): + - Flutter + - FlutterMacOS - device_info_plus (0.0.1): - FlutterMacOS - file_picker (0.0.1): @@ -21,7 +24,7 @@ PODS: - screen_retriever_macos (0.0.1): - FlutterMacOS - Sentry/HybridSDK (8.58.0) - - sentry_flutter (9.15.0): + - sentry_flutter (9.16.0): - Flutter - FlutterMacOS - Sentry/HybridSDK (= 8.58.0) @@ -69,6 +72,7 @@ PODS: DEPENDENCIES: - auto_updater_macos (from `Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/macos`) - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`) + - cupertino_http (from `Flutter/ephemeral/.symlinks/plugins/cupertino_http/darwin`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - FlutterMacOS (from `Flutter/ephemeral`) @@ -97,6 +101,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/macos connectivity_plus: :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos + cupertino_http: + :path: Flutter/ephemeral/.symlinks/plugins/cupertino_http/darwin device_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos file_picker: @@ -133,6 +139,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118 connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e + cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 @@ -142,7 +149,7 @@ SPEC CHECKSUMS: package_info_plus: f0052d280d17aa382b932f399edf32507174e870 screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f Sentry: d587a8fe91ca13503ecd69a1905f3e8a0fcf61be - sentry_flutter: 8939e491bc1511868118c96cae47d945e6f69798 + sentry_flutter: 31101687061fb85211ebab09ce6eb8db4e9ba74f shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb Sparkle: f4355f9ebbe9b7d932df4980d70f13922ac97b2a sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 diff --git a/pubspec.lock b/pubspec.lock index 1410e828..c32e8c09 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -261,6 +261,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + cronet_http: + dependency: "direct main" + description: + name: cronet_http + sha256: "07bfb4c6158aef72f8004631826abaeecdeaa2b6042f5f8916b8db20e1d01b4a" + url: "https://pub.dev" + source: hosted + version: "1.6.0" cross_file: dependency: transitive description: @@ -293,6 +301,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.2" + cupertino_http: + dependency: "direct main" + description: + name: cupertino_http + sha256: "82cbec60c90bf785a047a9525688b6dacac444e177e1d5a5876963d3c50369e8" + url: "https://pub.dev" + source: hosted + version: "2.4.0" dart_code_linter: dependency: "direct dev" description: @@ -341,30 +357,6 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.3" - dio: - dependency: "direct main" - description: - name: dio - sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c - url: "https://pub.dev" - source: hosted - version: "5.9.2" - dio_http2_adapter: - dependency: "direct main" - description: - name: dio_http2_adapter - sha256: "79f3d69b155b92a786c8734bd11860390b986210d4e07cbb6a5c8c806a7187b2" - url: "https://pub.dev" - source: hosted - version: "2.7.0" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" - url: "https://pub.dev" - source: hosted - version: "2.1.2" drift: dependency: "direct main" description: @@ -537,14 +529,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" - http2: - dependency: transitive - description: - name: http2 - sha256: "382d3aefc5bd6dc68c6b892d7664f29b5beb3251611ae946a98d35158a82bbfa" - url: "https://pub.dev" - source: hosted - version: "2.3.1" http_multi_server: dependency: transitive description: @@ -561,6 +545,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + http_profile: + dependency: transitive + description: + name: http_profile + sha256: "7e679e355b09aaee2ab5010915c932cce3f2d1c11c3b2dc177891687014ffa78" + url: "https://pub.dev" + source: hosted + version: "0.1.0" in_app_review: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 7c1153d6..168a5f63 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,7 +10,6 @@ dependencies: flutter: sdk: flutter intl: ^0.20.2 - dio: ^5.9.2 json_annotation: ^4.9.0 shared_preferences: ^2.2.2 cached_network_image: ^3.4.1 @@ -59,12 +58,13 @@ dependencies: url: https://github.com/edde746/sentry-dart path: packages/flutter ref: build/fetch-native-zip - dio_http2_adapter: ^2.7.0 auto_updater: git: url: https://github.com/edde746/auto_updater path: packages/auto_updater ref: 9e150f7 + cupertino_http: ^2.4.0 + cronet_http: ^1.6.0 dev_dependencies: flutter_test: