refactor: replace dio with package:http
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+201
-194
File diff suppressed because it is too large
Load Diff
@@ -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,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) {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
@@ -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();
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user