fix(tvos): avoid unsupported native clients
This commit is contained in:
@@ -880,6 +880,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
DownloadFilter filter = DownloadFilter.all,
|
||||
int? maxCount,
|
||||
}) async {
|
||||
if (!_downloadManager.downloadsSupported) return 0;
|
||||
|
||||
final globalKey = metadata.globalKey;
|
||||
final config = versionConfig ?? DownloadVersionConfig();
|
||||
|
||||
@@ -942,6 +944,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
DownloadFilter filter = DownloadFilter.all,
|
||||
bool expandShows = true,
|
||||
}) async {
|
||||
if (!_downloadManager.downloadsSupported) return 0;
|
||||
|
||||
if (await DownloadManagerService.shouldBlockDownloadOnCellular()) {
|
||||
throw CellularDownloadBlockedException();
|
||||
}
|
||||
@@ -988,6 +992,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
int mediaIndex = 0,
|
||||
DownloadVersionConfig? versionConfig,
|
||||
}) async {
|
||||
if (!_downloadManager.downloadsSupported) return false;
|
||||
|
||||
_requireActiveProfileId();
|
||||
final globalKey = metadata.globalKey;
|
||||
|
||||
@@ -1384,6 +1390,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
/// Resume queued downloads that were interrupted by app kill.
|
||||
/// Call after a [MediaServerClient] becomes available (e.g. after server connect on launch).
|
||||
void resumeQueuedDownloads(MediaServerClient client) {
|
||||
if (!_downloadManager.downloadsSupported) return;
|
||||
_downloadManager.resumeQueuedDownloads(client);
|
||||
}
|
||||
|
||||
@@ -1653,6 +1660,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
///
|
||||
/// Returns titles of newly queued items (for snackbar display).
|
||||
Future<List<String>> executeSyncRules(MultiServerManager serverManager, {bool force = false}) async {
|
||||
if (!_downloadManager.downloadsSupported) return [];
|
||||
|
||||
final profileId = _activeProfileId;
|
||||
if (profileId == null || profileId.isEmpty) return [];
|
||||
if (_syncRules.isEmpty) return [];
|
||||
@@ -1676,6 +1685,8 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
/// Execute a single sync rule immediately (eager path for `addToPlaylist` /
|
||||
/// `addToCollection`). Bypasses the cooldown.
|
||||
Future<SyncRuleResult?> executeSyncRuleFor(String globalKey, MultiServerManager serverManager) async {
|
||||
if (!_downloadManager.downloadsSupported) return null;
|
||||
|
||||
final profileId = _activeProfileId;
|
||||
if (profileId == null || profileId.isEmpty) return null;
|
||||
if (!_syncRules.containsKey(globalKey)) return null;
|
||||
|
||||
@@ -30,6 +30,8 @@ import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
typedef MediaClientResolver = MediaServerClient? Function(String serverId, {String? clientScopeId});
|
||||
|
||||
const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD');
|
||||
|
||||
class _DownloadContext {
|
||||
final MediaItem metadata;
|
||||
final DownloadQueueItem queueItem;
|
||||
@@ -56,6 +58,7 @@ class DownloadManagerService {
|
||||
final AppDatabase _database;
|
||||
final DownloadStorageService _storageService;
|
||||
final MediaServerHttpClient _http;
|
||||
final bool? _downloadsSupportedOverride;
|
||||
|
||||
final _progressController = StreamController<DownloadProgress>.broadcast();
|
||||
Stream<DownloadProgress> get progressStream => _progressController.stream;
|
||||
@@ -92,6 +95,7 @@ class DownloadManagerService {
|
||||
// Prevents concurrent _processQueue calls
|
||||
bool _isProcessingQueue = false;
|
||||
bool _disposed = false;
|
||||
bool _loggedDownloadsUnsupported = false;
|
||||
|
||||
// Debounce timers for DB progress writes (keyed by globalKey).
|
||||
// UI progress streams are still real-time; only the DB write is debounced.
|
||||
@@ -106,6 +110,11 @@ class DownloadManagerService {
|
||||
int _consecutiveQueueFailures = 0;
|
||||
static const _maxConsecutiveFailures = 3;
|
||||
|
||||
static bool get platformDownloadsSupported => downloadsSupportedFor(tvosBuild: _tvosBuild);
|
||||
|
||||
@visibleForTesting
|
||||
static bool downloadsSupportedFor({required bool tvosBuild}) => !tvosBuild;
|
||||
|
||||
static Future<bool> shouldBlockDownloadOnCellular() async {
|
||||
final List<ConnectivityResult> connectivity;
|
||||
try {
|
||||
@@ -137,10 +146,25 @@ class DownloadManagerService {
|
||||
required AppDatabase database,
|
||||
required DownloadStorageService storageService,
|
||||
MediaServerHttpClient? http,
|
||||
@visibleForTesting bool? downloadsSupportedOverride,
|
||||
}) : _database = database,
|
||||
_storageService = storageService,
|
||||
_downloadsSupportedOverride = downloadsSupportedOverride,
|
||||
_http = http ?? httpClient;
|
||||
|
||||
bool get downloadsSupported => _downloadsSupportedOverride ?? platformDownloadsSupported;
|
||||
|
||||
bool _skipDownloadsUnsupported(String operation) {
|
||||
if (downloadsSupported) return false;
|
||||
if (!_loggedDownloadsUnsupported) {
|
||||
_loggedDownloadsUnsupported = true;
|
||||
appLogger.i('Downloads are unavailable on this platform; skipping $operation');
|
||||
} else {
|
||||
appLogger.d('Skipping $operation: downloads unavailable on this platform');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void setClientResolver(MediaClientResolver resolver) {
|
||||
_clientResolver = resolver;
|
||||
}
|
||||
@@ -366,6 +390,7 @@ class DownloadManagerService {
|
||||
/// Initialize background_downloader with callbacks, notifications, and concurrency config.
|
||||
Future<void> _initializeFileDownloader() async {
|
||||
if (_fileDownloaderInitialized) return;
|
||||
if (_skipDownloadsUnsupported('FileDownloader initialization')) return;
|
||||
|
||||
FileDownloader()
|
||||
.registerCallbacks(
|
||||
@@ -396,6 +421,8 @@ class DownloadManagerService {
|
||||
/// Uses background_downloader's rescheduleKilledTasks for native recovery,
|
||||
/// then scans drift for orphaned items.
|
||||
Future<void> recoverInterruptedDownloads() async {
|
||||
if (_skipDownloadsUnsupported('download recovery')) return;
|
||||
|
||||
try {
|
||||
unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Initializing FileDownloader', category: 'downloads')));
|
||||
await _initializeFileDownloader();
|
||||
@@ -489,6 +516,8 @@ class DownloadManagerService {
|
||||
/// Resume queued downloads that have no active processing.
|
||||
/// Call after a [MediaServerClient] becomes available (e.g. after server connect on launch).
|
||||
void resumeQueuedDownloads(MediaServerClient client) {
|
||||
if (_skipDownloadsUnsupported('queued download resume')) return;
|
||||
|
||||
_fallbackClient = client;
|
||||
|
||||
if (_isOffline) {
|
||||
@@ -683,6 +712,8 @@ class DownloadManagerService {
|
||||
bool downloadArtwork = true,
|
||||
int mediaIndex = 0,
|
||||
}) async {
|
||||
if (_skipDownloadsUnsupported('queue download')) return;
|
||||
|
||||
final globalKey = metadata.globalKey;
|
||||
|
||||
final existing = await _database.getDownloadedMedia(globalKey);
|
||||
@@ -724,6 +755,7 @@ class DownloadManagerService {
|
||||
/// Process the download queue — prepares and enqueues items with background_downloader.
|
||||
/// Non-blocking: returns after all queued items are enqueued (downloads run natively).
|
||||
Future<void> _processQueue(MediaServerClient client) async {
|
||||
if (_skipDownloadsUnsupported('download queue processing')) return;
|
||||
if (_isProcessingQueue) return;
|
||||
_isProcessingQueue = true;
|
||||
_fallbackClient = client;
|
||||
@@ -762,7 +794,9 @@ class DownloadManagerService {
|
||||
Future<void> _cleanupStaleDownload(String globalKey) async {
|
||||
final existingTaskId = await _database.getBgTaskId(globalKey);
|
||||
if (existingTaskId != null) {
|
||||
if (downloadsSupported) {
|
||||
await FileDownloader().cancelTaskWithId(existingTaskId);
|
||||
}
|
||||
await _database.updateBgTaskId(globalKey, null);
|
||||
appLogger.d('Cancelled stale bg task $existingTaskId for $globalKey');
|
||||
}
|
||||
@@ -776,6 +810,8 @@ class DownloadManagerService {
|
||||
MediaServerClient client,
|
||||
DownloadQueueItem queueItem,
|
||||
) async {
|
||||
if (_skipDownloadsUnsupported('download enqueue')) return false;
|
||||
|
||||
try {
|
||||
// Guard: don't re-enqueue an item that's already completed or was deleted
|
||||
final existing = await _database.getDownloadedMedia(globalKey);
|
||||
@@ -954,7 +990,7 @@ class DownloadManagerService {
|
||||
|
||||
// If this item is being paused, the holding queue promoted it — cancel it
|
||||
if (_pausingKeys.contains(globalKey)) {
|
||||
FileDownloader().cancelTaskWithId(update.task.taskId);
|
||||
if (downloadsSupported) FileDownloader().cancelTaskWithId(update.task.taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1014,7 +1050,7 @@ class DownloadManagerService {
|
||||
case TaskStatus.running:
|
||||
// If this item is being paused, the holding queue promoted it — cancel it
|
||||
if (_pausingKeys.contains(globalKey)) {
|
||||
FileDownloader().cancelTaskWithId(update.task.taskId);
|
||||
if (downloadsSupported) FileDownloader().cancelTaskWithId(update.task.taskId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -1520,7 +1556,7 @@ class DownloadManagerService {
|
||||
try {
|
||||
_cancelDownloadTimers(globalKey);
|
||||
final bgTaskId = await _database.getBgTaskId(globalKey);
|
||||
if (bgTaskId != null) {
|
||||
if (bgTaskId != null && downloadsSupported) {
|
||||
final task = await FileDownloader().taskForId(bgTaskId);
|
||||
if (task != null && task is DownloadTask) {
|
||||
// Normal mode: native pause support
|
||||
@@ -1529,6 +1565,8 @@ class DownloadManagerService {
|
||||
// SAF mode (UriDownloadTask) or task not found: cancel (re-download on resume)
|
||||
await FileDownloader().cancelTaskWithId(bgTaskId);
|
||||
}
|
||||
} else if (bgTaskId != null) {
|
||||
await _database.updateBgTaskId(globalKey, null);
|
||||
}
|
||||
_pendingDownloadContext.remove(globalKey);
|
||||
await _transitionStatus(globalKey, DownloadStatus.paused);
|
||||
@@ -1540,6 +1578,8 @@ class DownloadManagerService {
|
||||
|
||||
/// Resume a paused download
|
||||
Future<void> resumeDownload(String globalKey, MediaServerClient client) async {
|
||||
if (_skipDownloadsUnsupported('download resume')) return;
|
||||
|
||||
final bgTaskId = await _database.getBgTaskId(globalKey);
|
||||
|
||||
// Try native resume first (only works for normal-mode DownloadTask that was paused)
|
||||
@@ -1567,6 +1607,8 @@ class DownloadManagerService {
|
||||
|
||||
/// Retry a failed download
|
||||
Future<void> retryDownload(String globalKey, MediaServerClient client) async {
|
||||
if (_skipDownloadsUnsupported('download retry')) return;
|
||||
|
||||
_autoRetryTimers.remove(globalKey)?.cancel();
|
||||
await _database.clearDownloadError(globalKey);
|
||||
await _database.updateBgTaskId(globalKey, null);
|
||||
@@ -1582,8 +1624,11 @@ class DownloadManagerService {
|
||||
_cancelDownloadTimers(globalKey);
|
||||
final bgTaskId = await _database.getBgTaskId(globalKey);
|
||||
if (bgTaskId != null) {
|
||||
if (downloadsSupported) {
|
||||
await FileDownloader().cancelTaskWithId(bgTaskId);
|
||||
}
|
||||
await _database.updateBgTaskId(globalKey, null);
|
||||
}
|
||||
_pendingDownloadContext.remove(globalKey);
|
||||
await _transitionStatus(globalKey, DownloadStatus.cancelled);
|
||||
await _database.removeFromQueue(globalKey);
|
||||
@@ -1593,8 +1638,11 @@ class DownloadManagerService {
|
||||
_cancelDownloadTimers(globalKey);
|
||||
final bgTaskId = await _database.getBgTaskId(globalKey);
|
||||
if (bgTaskId != null) {
|
||||
if (downloadsSupported) {
|
||||
await FileDownloader().cancelTaskWithId(bgTaskId);
|
||||
}
|
||||
await _database.updateBgTaskId(globalKey, null);
|
||||
}
|
||||
_pendingDownloadContext.remove(globalKey);
|
||||
|
||||
final parsed = parseGlobalKey(globalKey);
|
||||
|
||||
@@ -12,6 +12,8 @@ import 'managed_http_client.dart';
|
||||
/// Shared Cronet engine so all clients reuse the same connection pool.
|
||||
CronetEngine? _sharedEngine;
|
||||
|
||||
const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD');
|
||||
|
||||
bool _loggedPlatformClient = false;
|
||||
|
||||
void _logPlatformClient(String platform, String client) {
|
||||
@@ -20,6 +22,17 @@ void _logPlatformClient(String platform, String client) {
|
||||
appLogger.i('Platform HTTP client', error: {'platform': platform, 'client': client});
|
||||
}
|
||||
|
||||
http.Client _createTunedIoClient(String debugLabel) {
|
||||
return ManagedHttpClient(
|
||||
IOClient(
|
||||
HttpClient()
|
||||
..maxConnectionsPerHost = 12
|
||||
..idleTimeout = const Duration(seconds: 90),
|
||||
),
|
||||
debugLabel: debugLabel,
|
||||
);
|
||||
}
|
||||
|
||||
http.Client createPlatformClient() {
|
||||
if (Platform.isAndroid) {
|
||||
_sharedEngine ??= CronetEngine.build(
|
||||
@@ -31,9 +44,11 @@ http.Client createPlatformClient() {
|
||||
_logPlatformClient('android', 'CronetClient');
|
||||
return ManagedHttpClient(CronetClient.fromCronetEngine(_sharedEngine!), debugLabel: 'CronetClient');
|
||||
}
|
||||
if (Platform.isIOS && _tvosBuild) {
|
||||
_logPlatformClient('tvos', 'IOClient (tvOS tuned)');
|
||||
return _createTunedIoClient('IOClient (tvOS tuned)');
|
||||
}
|
||||
if (Platform.isIOS || Platform.isMacOS) {
|
||||
// cupertino_http relies on the objective_c FFI dylib, which isn't
|
||||
// available on tvOS. Fall back to IOClient if the init fails.
|
||||
try {
|
||||
final client = CupertinoClient.defaultSessionConfiguration();
|
||||
_logPlatformClient(Platform.isIOS ? 'ios' : 'macos', 'CupertinoClient');
|
||||
@@ -62,14 +77,7 @@ http.Client createPlatformClient() {
|
||||
http.Client createPlexApiClient() {
|
||||
if (Platform.isLinux) {
|
||||
_logPlatformClient('linux', 'IOClient (Plex API tuned)');
|
||||
return ManagedHttpClient(
|
||||
IOClient(
|
||||
HttpClient()
|
||||
..maxConnectionsPerHost = 12
|
||||
..idleTimeout = const Duration(seconds: 90),
|
||||
),
|
||||
debugLabel: 'IOClient (Plex API tuned)',
|
||||
);
|
||||
return _createTunedIoClient('IOClient (Plex API tuned)');
|
||||
}
|
||||
return createPlatformClient();
|
||||
}
|
||||
|
||||
@@ -72,6 +72,25 @@ void main() {
|
||||
await db.close();
|
||||
});
|
||||
|
||||
group('DownloadManagerService — platform support', () {
|
||||
test('disables downloads only for tvOS builds', () {
|
||||
expect(DownloadManagerService.downloadsSupportedFor(tvosBuild: true), isFalse);
|
||||
expect(DownloadManagerService.downloadsSupportedFor(tvosBuild: false), isTrue);
|
||||
});
|
||||
|
||||
test('recovery is a no-op when downloads are unsupported', () async {
|
||||
final unsupportedManager = DownloadManagerService(
|
||||
database: db,
|
||||
storageService: DownloadStorageService.instance,
|
||||
downloadsSupportedOverride: false,
|
||||
);
|
||||
|
||||
await unsupportedManager.recoverInterruptedDownloads();
|
||||
|
||||
unsupportedManager.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
group('DownloadProvider — initial state', () {
|
||||
test('starts with empty downloads/metadata maps and no sync rules', () async {
|
||||
final p = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
|
||||
@@ -330,6 +349,25 @@ void main() {
|
||||
serverId: 'srv',
|
||||
);
|
||||
|
||||
test('queueDownload is a no-op when downloads are unsupported', () async {
|
||||
final unsupportedManager = DownloadManagerService(
|
||||
database: db,
|
||||
storageService: DownloadStorageService.instance,
|
||||
downloadsSupportedOverride: false,
|
||||
)..recoveryFuture = Future<void>.value();
|
||||
final p = DownloadProvider.forTesting(downloadManager: unsupportedManager, database: db);
|
||||
await p.ensureInitialized();
|
||||
|
||||
final queued = await p.queueDownload(movie, _ScopedTestClient(serverId: 'srv', scopedServerId: 'srv'));
|
||||
|
||||
expect(queued, 0);
|
||||
expect(p.downloads, isEmpty);
|
||||
expect(await db.getDownloadedMedia('srv:1'), isNull);
|
||||
|
||||
p.dispose();
|
||||
unsupportedManager.dispose();
|
||||
});
|
||||
|
||||
test('download getters only expose active-profile owned physical rows', () async {
|
||||
await db.addDownloadOwner(profileId: 'profile-a', globalKey: 'srv:1');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user