refactor: replace dio with package:http

This commit is contained in:
edde746
2026-04-08 00:43:08 +02:00
parent 8e7431e7e5
commit 15b22f75a8
21 changed files with 839 additions and 552 deletions
+3 -3
View File
@@ -1,5 +1,5 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../utils/plex_http_exception.dart';
import '../models/plex_home.dart'; import '../models/plex_home.dart';
import '../models/plex_home_user.dart'; import '../models/plex_home_user.dart';
import '../models/plex_user_profile.dart'; import '../models/plex_user_profile.dart';
@@ -302,8 +302,8 @@ class UserProfileProvider extends ChangeNotifier {
return true; return true;
} catch (e) { } catch (e) {
// Check if it's a PIN validation error // Check if it's a PIN validation error
if (e is DioException && e.response?.statusCode == 403) { if (e is PlexHttpException && e.statusCode == 403) {
final errors = e.response?.data['errors'] as List?; final errors = (e.responseData is Map) ? (e.responseData as Map)['errors'] as List? : null;
if (errors != null && errors.isNotEmpty) { if (errors != null && errors.isNotEmpty) {
final errorCode = errors.first['code'] as int?; final errorCode = errors.first['code'] as int?;
final errorMessage = errors.first['message'] as String?; final errorMessage = errors.first['message'] as String?;
@@ -4,7 +4,8 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:cached_network_image/cached_network_image.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/dpad_navigator.dart';
import '../../../focus/input_mode_tracker.dart'; import '../../../focus/input_mode_tracker.dart';
import '../../../../services/plex_client.dart'; import '../../../../services/plex_client.dart';
@@ -203,7 +204,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
int _totalSize = 0; int _totalSize = 0;
final Map<int, PlexMetadata> _loadedItems = {}; final Map<int, PlexMetadata> _loadedItems = {};
final Set<int> _loadingRanges = {}; final Set<int> _loadingRanges = {};
CancelToken? _cancelToken; AbortController? _cancelToken;
int _requestId = 0; int _requestId = 0;
int _firstCharactersRequestId = 0; int _firstCharactersRequestId = 0;
static const int _fetchSize = 200; static const int _fetchSize = 200;
@@ -229,7 +230,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
@override @override
void dispose() { void dispose() {
_cancelToken?.cancel(); _cancelToken?.abort();
_retryTimer?.cancel(); _retryTimer?.cancel();
_scrollActivityTimer?.cancel(); _scrollActivityTimer?.cancel();
_scrollIdleTimer?.cancel(); _scrollIdleTimer?.cancel();
@@ -353,9 +354,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
Future<void> _loadContent() async { Future<void> _loadContent() async {
// Cancel any pending request // Cancel any pending request
_cancelToken?.cancel(); _cancelToken?.abort();
_retryTimer?.cancel(); _retryTimer?.cancel();
_cancelToken = CancelToken(); _cancelToken = AbortController();
// Use a generation counter for the filter/sort loading phase // Use a generation counter for the filter/sort loading phase
final generation = ++_requestId; final generation = ++_requestId;
final firstCharactersGeneration = ++_firstCharactersRequestId; final firstCharactersGeneration = ++_firstCharactersRequestId;
@@ -458,9 +459,9 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
Future<void> _loadItems() async { Future<void> _loadItems() async {
final currentRequestId = ++_requestId; final currentRequestId = ++_requestId;
_cancelToken?.cancel(); _cancelToken?.abort();
_retryTimer?.cancel(); _retryTimer?.cancel();
_cancelToken = CancelToken(); _cancelToken = AbortController();
setState(() { setState(() {
isLoading = true; isLoading = true;
@@ -485,7 +486,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
start: 0, start: 0,
size: _calculateInitialFetchSize(), size: _calculateInitialFetchSize(),
filters: filterParams, filters: filterParams,
cancelToken: _cancelToken, abort: _cancelToken,
); );
if (currentRequestId != _requestId) return; if (currentRequestId != _requestId) return;
@@ -537,7 +538,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
start: start, start: start,
size: clampedSize, size: clampedSize,
filters: filterParams, filters: filterParams,
cancelToken: _cancelToken, abort: _cancelToken,
); );
if (currentRequestId != _requestId || !mounted) return false; if (currentRequestId != _requestId || !mounted) return false;
@@ -555,7 +556,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
_prefetchImages(start, result.items); _prefetchImages(start, result.items);
return true; return true;
} catch (e) { } catch (e) {
if (e is DioException && e.type == DioExceptionType.cancel) return false; if (e is PlexHttpException && e.type == PlexHttpErrorType.cancelled) return false;
_retryCount++; _retryCount++;
final delay = Duration(milliseconds: 500 * (1 << _retryCount.clamp(0, 4))); final delay = Duration(milliseconds: 500 * (1 << _retryCount.clamp(0, 4)));
_retryTimer?.cancel(); _retryTimer?.cancel();
@@ -632,8 +633,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<PlexMetadata, LibraryBr
} }
String _getErrorMessage(dynamic error) { String _getErrorMessage(dynamic error) {
if (error is DioException) { if (error is PlexHttpException) {
return mapDioErrorToMessage(error, context: t.libraries.content); return mapHttpErrorToMessage(error, context: t.libraries.content);
} }
return mapUnexpectedErrorToMessage(error, context: t.libraries.content); return mapUnexpectedErrorToMessage(error, context: t.libraries.content);
} }
+5 -8
View File
@@ -2,9 +2,8 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:plezy/utils/http_client.dart'; import 'package:plezy/utils/plex_http_client.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
@@ -135,17 +134,15 @@ class _LogsScreenState extends State<LogsScreen> {
try { try {
final response = await httpClient.post( final response = await httpClient.post(
'https://ice.plezy.app/logs', 'https://ice.plezy.app/logs',
data: logText, body: logText,
options: Options(contentType: 'text/plain'), headers: {'Content-Type': 'text/plain'},
); );
if (!mounted) return; if (!mounted) return;
Navigator.of(context).pop(); // dismiss loading Navigator.of(context).pop(); // dismiss loading
final id = final data = response.data is String ? jsonDecode(response.data) : response.data;
(jsonDecode(response.data is String ? response.data : jsonEncode(response.data)) final id = (data as Map<String, dynamic>)['id'] as String;
as Map<String, dynamic>)['id']
as String;
showDialog( showDialog(
context: context, context: context,
+18 -20
View File
@@ -1,12 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:dart_discord_presence/dart_discord_presence.dart'; 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 '../models/plex_metadata.dart';
import '../utils/http_client.dart'; import '../utils/plex_http_client.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import 'plex_client.dart'; import 'plex_client.dart';
import 'settings_service.dart'; import 'settings_service.dart';
@@ -300,29 +299,28 @@ class DiscordRPCService {
if (imageUrl.isEmpty) return null; if (imageUrl.isEmpty) return null;
// Fetch image data // Fetch image data
final imageResponse = await httpClient.get<List<int>>( final imageBytes = await httpClient.getBytes(
imageUrl, imageUrl,
options: Options(responseType: ResponseType.bytes, receiveTimeout: const Duration(seconds: 10)), timeout: const Duration(seconds: 10),
); );
if (imageBytes.isEmpty) return null;
final imageBytes = imageResponse.data;
if (imageBytes == null || imageBytes.isEmpty) return null;
// Upload to Litterbox // Upload to Litterbox
final formData = FormData.fromMap({ final uploadRequest = http.MultipartRequest('POST', Uri.parse(_litterboxUrl))
'reqtype': 'fileupload', ..fields['reqtype'] = 'fileupload'
'time': '1h', ..fields['time'] = '1h'
'fileToUpload': MultipartFile.fromBytes(Uint8List.fromList(imageBytes), filename: 'thumbnail.jpg'), ..files.add(http.MultipartFile.fromBytes(
}); 'fileToUpload',
imageBytes,
filename: 'thumbnail.jpg',
));
final uploadResponse = await httpClient.post<String>( final uploadStreamed = await httpClient.inner
_litterboxUrl, .send(uploadRequest)
data: formData, .timeout(const Duration(seconds: 15));
options: Options(receiveTimeout: const Duration(seconds: 15)), final uploadedUrl = (await uploadStreamed.stream.bytesToString()).trim();
);
final uploadedUrl = uploadResponse.data?.trim(); if (uploadedUrl.startsWith('http')) {
if (uploadedUrl != null && uploadedUrl.startsWith('http')) {
// Cache the URL with 1 hour expiry (matching Litterbox) // Cache the URL with 1 hour expiry (matching Litterbox)
_litterboxCache[thumbPath] = _CachedUrl(uploadedUrl, DateTime.now().add(const Duration(hours: 1))); _litterboxCache[thumbPath] = _CachedUrl(uploadedUrl, DateTime.now().add(const Duration(hours: 1)));
appLogger.d('Uploaded and cached thumbnail: $uploadedUrl'); appLogger.d('Uploaded and cached thumbnail: $uploadedUrl');
+6 -7
View File
@@ -2,10 +2,9 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:background_downloader/background_downloader.dart'; import 'package:background_downloader/background_downloader.dart';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
import 'package:plezy/utils/content_utils.dart'; 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/app_database.dart';
import '../database/download_operations.dart'; import '../database/download_operations.dart';
import 'settings_service.dart'; import 'settings_service.dart';
@@ -50,7 +49,7 @@ class DownloadManagerService {
final AppDatabase _database; final AppDatabase _database;
final DownloadStorageService _storageService; final DownloadStorageService _storageService;
final PlexApiCache _apiCache = PlexApiCache.instance; final PlexApiCache _apiCache = PlexApiCache.instance;
final Dio _dio; final PlexHttpClient _http;
// Stream controller for download progress updates // Stream controller for download progress updates
final _progressController = StreamController<DownloadProgress>.broadcast(); final _progressController = StreamController<DownloadProgress>.broadcast();
@@ -119,10 +118,10 @@ class DownloadManagerService {
/// Await this before reading download state from the DB to avoid races. /// Await this before reading download state from the DB to avoid races.
late final Future<void> recoveryFuture; late final Future<void> recoveryFuture;
DownloadManagerService({required AppDatabase database, required DownloadStorageService storageService, Dio? dio}) DownloadManagerService({required AppDatabase database, required DownloadStorageService storageService, PlexHttpClient? http})
: _database = database, : _database = database,
_storageService = storageService, _storageService = storageService,
_dio = dio ?? httpClient; _http = http ?? httpClient;
/// Initialize background_downloader with callbacks, notifications, and concurrency config. /// Initialize background_downloader with callbacks, notifications, and concurrency config.
Future<void> _initializeFileDownloader() async { Future<void> _initializeFileDownloader() async {
@@ -957,7 +956,7 @@ class DownloadManagerService {
await file.parent.create(recursive: true); await file.parent.create(recursive: true);
// Download the artwork // Download the artwork
await _dio.download(url, filePath); await _http.downloadFile(url, filePath);
appLogger.i('Downloaded artwork: $artworkPath -> $filePath'); appLogger.i('Downloaded artwork: $artworkPath -> $filePath');
} catch (e, stack) { } catch (e, stack) {
appLogger.w('Failed to download artwork: $artworkPath', error: e, stackTrace: stack); appLogger.w('Failed to download artwork: $artworkPath', error: e, stackTrace: stack);
@@ -1062,7 +1061,7 @@ class DownloadManagerService {
// Download subtitle file // Download subtitle file
final file = File(subtitlePath); final file = File(subtitlePath);
await file.parent.create(recursive: true); await file.parent.create(recursive: true);
await _dio.download(subtitleUrl, subtitlePath); await _http.downloadFile(subtitleUrl, subtitlePath);
appLogger.d('Downloaded subtitle ${subtitle.id} for $globalKey'); appLogger.d('Downloaded subtitle ${subtitle.id} for $globalKey');
} }
+23 -30
View File
@@ -1,15 +1,16 @@
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.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. /// Custom cache manager for Plex image transcoding with HTTP/2 multiplexing.
/// ///
/// Uses Dio with [Http2Adapter] so all platforms benefit from HTTP/2 connection /// Uses the platform-native HTTP client so iOS/macOS (CupertinoClient) and
/// multiplexing — many concurrent image downloads over a single connection /// Android (CronetClient) benefit from HTTP/2 connection multiplexing —
/// instead of being limited to a handful of HTTP/1.1 connections. /// 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 { class PlexImageCacheManager extends CacheManager with ImageCacheManager {
static const _key = 'plexImageCache'; static const _key = 'plexImageCache';
@@ -21,56 +22,50 @@ class PlexImageCacheManager extends CacheManager with ImageCacheManager {
_key, _key,
stalePeriod: const Duration(days: 14), stalePeriod: const Duration(days: 14),
maxNrOfCacheObjects: 3000, maxNrOfCacheObjects: 3000,
fileService: _DioFileService( fileService: _HttpFileService(httpClient.inner),
Dio()..httpClientAdapter = createHttp2Adapter(),
),
), ),
); );
} }
class _DioFileService extends FileService { class _HttpFileService extends FileService {
final Dio _dio; final http.Client _client;
_DioFileService(this._dio); _HttpFileService(this._client);
@override @override
Future<FileServiceResponse> get( Future<FileServiceResponse> get(
String url, { String url, {
Map<String, String>? headers, Map<String, String>? headers,
}) async { }) async {
final response = await _dio.get<ResponseBody>( final request = http.Request('GET', Uri.parse(url));
url, if (headers != null) request.headers.addAll(headers);
options: Options( final response = await _client.send(request);
headers: headers, return _HttpGetResponse(response);
responseType: ResponseType.stream,
),
);
return _DioGetResponse(response);
} }
} }
class _DioGetResponse implements FileServiceResponse { class _HttpGetResponse implements FileServiceResponse {
final Response<ResponseBody> _response; final http.StreamedResponse _response;
final DateTime _receivedTime = DateTime.now(); final DateTime _receivedTime = DateTime.now();
_DioGetResponse(this._response); _HttpGetResponse(this._response);
@override @override
Stream<List<int>> get content => _response.data!.stream; Stream<List<int>> get content => _response.stream;
@override @override
int? get contentLength { int? get contentLength {
final value = _header(HttpHeaders.contentLengthHeader); final value = _response.headers[HttpHeaders.contentLengthHeader];
return value != null ? int.tryParse(value) : null; return value != null ? int.tryParse(value) : null;
} }
@override @override
int get statusCode => _response.statusCode ?? 200; int get statusCode => _response.statusCode;
@override @override
DateTime get validTill { DateTime get validTill {
var ageDuration = const Duration(days: 7); var ageDuration = const Duration(days: 7);
final controlHeader = _header(HttpHeaders.cacheControlHeader); final controlHeader = _response.headers[HttpHeaders.cacheControlHeader];
if (controlHeader != null) { if (controlHeader != null) {
for (final setting in controlHeader.split(',')) { for (final setting in controlHeader.split(',')) {
final s = setting.trim().toLowerCase(); final s = setting.trim().toLowerCase();
@@ -85,17 +80,15 @@ class _DioGetResponse implements FileServiceResponse {
} }
@override @override
String? get eTag => _header(HttpHeaders.etagHeader); String? get eTag => _response.headers[HttpHeaders.etagHeader];
@override @override
String get fileExtension { String get fileExtension {
final contentTypeHeader = _header(HttpHeaders.contentTypeHeader); final contentTypeHeader = _response.headers[HttpHeaders.contentTypeHeader];
if (contentTypeHeader != null) { if (contentTypeHeader != null) {
final ct = ContentType.parse(contentTypeHeader); final ct = ContentType.parse(contentTypeHeader);
return '.${ct.subType}'; return '.${ct.subType}';
} }
return ''; return '';
} }
String? _header(String name) => _response.headers.value(name);
} }
+44 -31
View File
@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'package:dio/dio.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'storage_service.dart'; import 'storage_service.dart';
import 'plex_client.dart'; import 'plex_client.dart';
@@ -8,7 +7,8 @@ import '../models/plex_home.dart';
import '../models/user_switch_response.dart'; import '../models/user_switch_response.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/connection_constants.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. /// 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`. /// 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 _plexApiBase = 'https://plex.tv/api/v2';
static const String _clientsApi = 'https://clients.plex.tv/api/v2'; static const String _clientsApi = 'https://clients.plex.tv/api/v2';
final Dio _dio; final PlexHttpClient _http;
final String _clientIdentifier; final String _clientIdentifier;
PlexAuthService._(this._dio, this._clientIdentifier); PlexAuthService._(this._http, this._clientIdentifier);
static Future<PlexAuthService> create() async { static Future<PlexAuthService> create() async {
final storage = await StorageService.getInstance(); final storage = await StorageService.getInstance();
final dio = Dio( final http = PlexHttpClient(
BaseOptions(connectTimeout: ConnectionTimeouts.plexTvConnect, receiveTimeout: ConnectionTimeouts.plexTvReceive), connectTimeout: ConnectionTimeouts.plexTvConnect,
)..httpClientAdapter = createHttp2Adapter(); receiveTimeout: ConnectionTimeouts.plexTvReceive,
);
// Get or create client identifier // Get or create client identifier
String? clientIdentifier = storage.getClientIdentifier(); String? clientIdentifier = storage.getClientIdentifier();
@@ -63,12 +64,12 @@ class PlexAuthService {
await storage.saveClientIdentifier(clientIdentifier); await storage.saveClientIdentifier(clientIdentifier);
} }
return PlexAuthService._(dio, clientIdentifier); return PlexAuthService._(http, clientIdentifier);
} }
String get clientIdentifier => _clientIdentifier; String get clientIdentifier => _clientIdentifier;
Options _getCommonOptions({String? authToken}) { Map<String, String> _getCommonHeaders({String? authToken}) {
final headers = { final headers = {
'Accept': 'application/json', 'Accept': 'application/json',
'X-Plex-Product': _appName, 'X-Plex-Product': _appName,
@@ -79,18 +80,30 @@ class PlexAuthService {
headers['X-Plex-Token'] = authToken; headers['X-Plex-Token'] = authToken;
} }
return Options(headers: headers); return headers;
} }
Future<Response> _getUser(String authToken) { Future<PlexResponse> _getUser(String authToken) {
return _dio.get('$_plexApiBase/user', options: _getCommonOptions(authToken: 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 /// Verify if a plex.tv token is valid
Future<bool> verifyToken(String authToken) async { Future<bool> verifyToken(String authToken) async {
try { try {
await _getUser(authToken); final response = await _getUser(authToken);
return true; return response.statusCode == 200;
} catch (e) { } catch (e) {
return false; return false;
} }
@@ -98,8 +111,8 @@ class PlexAuthService {
/// Create a PIN for authentication /// Create a PIN for authentication
Future<Map<String, dynamic>> createPin() async { Future<Map<String, dynamic>> 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<String, dynamic>; return response.data as Map<String, dynamic>;
} }
@@ -117,7 +130,7 @@ class PlexAuthService {
/// Poll the PIN to check if it has been claimed /// Poll the PIN to check if it has been claimed
Future<String?> checkPin(int pinId) async { Future<String?> checkPin(int pinId) async {
try { 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<String, dynamic>; final data = response.data as Map<String, dynamic>;
return data['authToken'] as String?; return data['authToken'] as String?;
@@ -154,11 +167,13 @@ class PlexAuthService {
/// Fetch available Plex servers for the authenticated user /// Fetch available Plex servers for the authenticated user
Future<List<PlexServer>> fetchServers(String authToken) async { Future<List<PlexServer>> fetchServers(String authToken) async {
final response = await _dio.get( final response = await _http.get(
'$_clientsApi/resources?includeHttps=1&includeRelay=1&includeIPv6=1', '$_clientsApi/resources?includeHttps=1&includeRelay=1&includeIPv6=1',
options: _getCommonOptions(authToken: authToken), headers: _getCommonHeaders(authToken: authToken),
); );
_checkStatus(response);
final List<dynamic> resources = response.data as List<dynamic>; final List<dynamic> resources = response.data as List<dynamic>;
// Filter for server resources and map to PlexServer objects // Filter for server resources and map to PlexServer objects
@@ -191,21 +206,21 @@ class PlexAuthService {
/// Get user information /// Get user information
Future<Map<String, dynamic>> getUserInfo(String authToken) async { Future<Map<String, dynamic>> getUserInfo(String authToken) async {
final response = await _getUser(authToken); final response = await _getUser(authToken);
_checkStatus(response);
return response.data as Map<String, dynamic>; return response.data as Map<String, dynamic>;
} }
/// Get user profile with preferences (audio/subtitle settings) /// Get user profile with preferences (audio/subtitle settings)
Future<PlexUserProfile> getUserProfile(String authToken) async { Future<PlexUserProfile> 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<String, dynamic>); return PlexUserProfile.fromJson(response.data as Map<String, dynamic>);
} }
/// Get home users for the authenticated user /// Get home users for the authenticated user
Future<PlexHome> getHomeUsers(String authToken) async { Future<PlexHome> 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<String, dynamic>); return PlexHome.fromJson(response.data as Map<String, dynamic>);
} }
@@ -226,15 +241,13 @@ class PlexAuthService {
'pin': ?pin, 'pin': ?pin,
}; };
final queryString = queryParams.entries final response = await _http.post(
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') '$_clientsApi/home/users/$userUUID/switch',
.join('&'); queryParameters: queryParams,
headers: {'Accept': 'application/json', 'Content-Length': '0'},
final response = await _dio.post(
'$_clientsApi/home/users/$userUUID/switch?$queryString',
options: Options(headers: {'Accept': 'application/json', 'Content-Length': '0'}),
); );
_checkStatus(response);
return UserSwitchResponse.fromJson(response.data as Map<String, dynamic>); return UserSwitchResponse.fromJson(response.data as Map<String, dynamic>);
} }
} }
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -1,8 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:dio/dio.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/plex_http_exception.dart';
import 'plex_auth_service.dart'; import 'plex_auth_service.dart';
import 'storage_service.dart'; import 'storage_service.dart';
@@ -125,8 +124,8 @@ class ServerRegistry {
await saveServers(updatedServers); await saveServers(updatedServers);
appLogger.i('Refreshed ${updatedServers.length} servers from API'); appLogger.i('Refreshed ${updatedServers.length} servers from API');
return ServerRefreshResult.success; return ServerRefreshResult.success;
} on DioException catch (e) { } on PlexHttpException catch (e) {
if (e.response?.statusCode == 401) { if (e.statusCode == 401) {
appLogger.w('Plex token is invalid (401), re-authentication required'); appLogger.w('Plex token is invalid (401), re-authentication required');
return ServerRefreshResult.authError; return ServerRefreshResult.authError;
} }
+2 -3
View File
@@ -2,9 +2,8 @@ import 'dart:io';
import 'package:auto_updater/auto_updater.dart'; import 'package:auto_updater/auto_updater.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:dio/dio.dart';
import 'package:logger/logger.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'; import 'package:shared_preferences/shared_preferences.dart';
/// Service to check for new versions on GitHub /// Service to check for new versions on GitHub
@@ -157,7 +156,7 @@ class UpdateService {
final response = await httpClient.get( final response = await httpClient.get(
'https://api.github.com/repos/$_githubRepo/releases/latest', '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) { if (response.statusCode == 200) {
+4 -4
View File
@@ -8,18 +8,18 @@ class ConnectionTimeouts {
/// parallel (used in [PlexServer.findBestWorkingConnection]). /// parallel (used in [PlexServer.findBestWorkingConnection]).
static const connectionRace = Duration(seconds: 2); 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); static const connect = Duration(seconds: 10);
/// Per-server connection budget: preferred probe + race + HTTPS upgrade attempt + 1s buffer. /// Per-server connection budget: preferred probe + race + HTTPS upgrade attempt + 1s buffer.
static const perServerConnect = Duration(milliseconds: 1500 + 2000 + 2000 + 1000); 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); 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); 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); static const plexTvReceive = Duration(seconds: 10);
} }
@@ -1,12 +1,5 @@
import 'dart:ui' show VoidCallback;
import 'package:dio/dio.dart';
import '../utils/app_logger.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. /// Maintains the list of endpoints we can cycle through when one fails.
class EndpointFailoverManager { class EndpointFailoverManager {
EndpointFailoverManager(List<String> urls) { EndpointFailoverManager(List<String> urls) {
@@ -72,131 +65,3 @@ class EndpointFailoverManager {
_currentIndex = _currentIndex.clamp(0, _endpoints.length - 1); _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<void> Function(String newBaseUrl) onEndpointSwitch,
this.onAllEndpointsExhausted,
}) : _dio = dio,
_onEndpointSwitch = onEndpointSwitch;
final Dio _dio;
final EndpointFailoverManager endpointManager;
final Future<void> 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<Response<dynamic>> _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<dynamic>(
requestOptions.path,
data: requestOptions.data,
queryParameters: requestOptions.queryParameters,
options: options,
cancelToken: requestOptions.cancelToken,
onSendProgress: requestOptions.onSendProgress,
onReceiveProgress: requestOptions.onReceiveProgress,
);
}
}
+5 -5
View File
@@ -1,14 +1,14 @@
import 'package:dio/dio.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import 'app_logger.dart'; import 'app_logger.dart';
import 'plex_http_exception.dart';
/// Shared helpers for translating network errors into user-friendly messages. /// 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) { switch (error.type) {
case DioExceptionType.connectionTimeout: case PlexHttpErrorType.connectionTimeout:
case DioExceptionType.receiveTimeout: case PlexHttpErrorType.receiveTimeout:
return t.errors.connectionTimeout(context: context); return t.errors.connectionTimeout(context: context);
case DioExceptionType.connectionError: case PlexHttpErrorType.connectionError:
return t.errors.connectionFailed; return t.errors.connectionFailed;
default: default:
appLogger.e('Error loading $context', error: error); appLogger.e('Error loading $context', error: error);
-60
View File
@@ -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 = <String>{'plex.tv'};
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream<Uint8List>? requestStream,
Future<void>? 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);
}
}
+25
View File
@@ -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();
}
+6
View File
@@ -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');
+364
View File
@@ -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<String, dynamic>` or `List`), or raw `String`
/// for non-JSON responses.
final dynamic data;
final Map<String, String> 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<void>();
/// The future that triggers abort when completed.
Future<void> 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<String, String> 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<String, String> defaultHeaders;
Duration connectTimeout;
Duration receiveTimeout;
// ---------------------------------------------------------------------------
// Public request methods
// ---------------------------------------------------------------------------
Future<PlexResponse> get(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Duration? timeout,
AbortController? abort,
}) =>
_send('GET', path,
queryParameters: queryParameters,
headers: headers,
timeout: timeout,
abort: abort);
Future<PlexResponse> post(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Object? body,
Duration? timeout,
AbortController? abort,
}) =>
_send('POST', path,
queryParameters: queryParameters,
headers: headers,
body: body,
timeout: timeout,
abort: abort);
Future<PlexResponse> put(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Object? body,
Duration? timeout,
AbortController? abort,
}) =>
_send('PUT', path,
queryParameters: queryParameters,
headers: headers,
body: body,
timeout: timeout,
abort: abort);
Future<PlexResponse> delete(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? 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<Uint8List> getBytes(
String url, {
Map<String, String>? 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<void> downloadFile(
String url,
String filePath, {
Map<String, String>? 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<http.StreamedResponse> sendStreamed(http.BaseRequest request) =>
_client.send(request);
void close() => _client.close();
// ---------------------------------------------------------------------------
// Core send implementation
// ---------------------------------------------------------------------------
Future<PlexResponse> _send(
String method,
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Object? body,
Duration? timeout,
AbortController? abort,
}) async {
final uri = _isAbsoluteUrl(path)
? _appendQuery(Uri.parse(path), queryParameters)
: _buildUri(path, queryParameters);
final mergedHeaders = <String, String>{
...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<String, dynamic>? 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<String, dynamic>? 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<String, dynamic>? params) {
if (params == null || params.isEmpty) return '';
final parts = <String>[];
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<int>) {
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<dynamic> _decodeBody(
List<int> bytes, Map<String, String> 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();
+82
View File
@@ -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)';
}
+9 -2
View File
@@ -4,6 +4,9 @@ PODS:
- Sparkle - Sparkle
- connectivity_plus (0.0.1): - connectivity_plus (0.0.1):
- FlutterMacOS - FlutterMacOS
- cupertino_http (0.0.1):
- Flutter
- FlutterMacOS
- device_info_plus (0.0.1): - device_info_plus (0.0.1):
- FlutterMacOS - FlutterMacOS
- file_picker (0.0.1): - file_picker (0.0.1):
@@ -21,7 +24,7 @@ PODS:
- screen_retriever_macos (0.0.1): - screen_retriever_macos (0.0.1):
- FlutterMacOS - FlutterMacOS
- Sentry/HybridSDK (8.58.0) - Sentry/HybridSDK (8.58.0)
- sentry_flutter (9.15.0): - sentry_flutter (9.16.0):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
- Sentry/HybridSDK (= 8.58.0) - Sentry/HybridSDK (= 8.58.0)
@@ -69,6 +72,7 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- auto_updater_macos (from `Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/macos`) - auto_updater_macos (from `Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/macos`)
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/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`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
- FlutterMacOS (from `Flutter/ephemeral`) - FlutterMacOS (from `Flutter/ephemeral`)
@@ -97,6 +101,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/macos :path: Flutter/ephemeral/.symlinks/plugins/auto_updater_macos/macos
connectivity_plus: connectivity_plus:
:path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos
cupertino_http:
:path: Flutter/ephemeral/.symlinks/plugins/cupertino_http/darwin
device_info_plus: device_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
file_picker: file_picker:
@@ -133,6 +139,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118 auto_updater_macos: 3a42f1a06be6981f1a18be37e6e7bf86aa732118
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76 device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
@@ -142,7 +149,7 @@ SPEC CHECKSUMS:
package_info_plus: f0052d280d17aa382b932f399edf32507174e870 package_info_plus: f0052d280d17aa382b932f399edf32507174e870
screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f screen_retriever_macos: 452e51764a9e1cdb74b3c541238795849f21557f
Sentry: d587a8fe91ca13503ecd69a1905f3e8a0fcf61be Sentry: d587a8fe91ca13503ecd69a1905f3e8a0fcf61be
sentry_flutter: 8939e491bc1511868118c96cae47d945e6f69798 sentry_flutter: 31101687061fb85211ebab09ce6eb8db4e9ba74f
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
Sparkle: f4355f9ebbe9b7d932df4980d70f13922ac97b2a Sparkle: f4355f9ebbe9b7d932df4980d70f13922ac97b2a
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
+24 -32
View File
@@ -261,6 +261,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" 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: cross_file:
dependency: transitive dependency: transitive
description: description:
@@ -293,6 +301,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.2" 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: dart_code_linter:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -341,30 +357,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.3" 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: drift:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -537,14 +529,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.6.0" 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: http_multi_server:
dependency: transitive dependency: transitive
description: description:
@@ -561,6 +545,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" 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: in_app_review:
dependency: "direct main" dependency: "direct main"
description: description:
+2 -2
View File
@@ -10,7 +10,6 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
intl: ^0.20.2 intl: ^0.20.2
dio: ^5.9.2
json_annotation: ^4.9.0 json_annotation: ^4.9.0
shared_preferences: ^2.2.2 shared_preferences: ^2.2.2
cached_network_image: ^3.4.1 cached_network_image: ^3.4.1
@@ -59,12 +58,13 @@ dependencies:
url: https://github.com/edde746/sentry-dart url: https://github.com/edde746/sentry-dart
path: packages/flutter path: packages/flutter
ref: build/fetch-native-zip ref: build/fetch-native-zip
dio_http2_adapter: ^2.7.0
auto_updater: auto_updater:
git: git:
url: https://github.com/edde746/auto_updater url: https://github.com/edde746/auto_updater
path: packages/auto_updater path: packages/auto_updater
ref: 9e150f7 ref: 9e150f7
cupertino_http: ^2.4.0
cronet_http: ^1.6.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: