diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index f17fc5c8..cf2117d5 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -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> 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 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; diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index c3a47afb..ae0306df 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -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.broadcast(); Stream 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 shouldBlockDownloadOnCellular() async { final List 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 _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 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 _processQueue(MediaServerClient client) async { + if (_skipDownloadsUnsupported('download queue processing')) return; if (_isProcessingQueue) return; _isProcessingQueue = true; _fallbackClient = client; @@ -762,7 +794,9 @@ class DownloadManagerService { Future _cleanupStaleDownload(String globalKey) async { final existingTaskId = await _database.getBgTaskId(globalKey); if (existingTaskId != null) { - await FileDownloader().cancelTaskWithId(existingTaskId); + 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 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 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,7 +1624,10 @@ class DownloadManagerService { _cancelDownloadTimers(globalKey); final bgTaskId = await _database.getBgTaskId(globalKey); if (bgTaskId != null) { - await FileDownloader().cancelTaskWithId(bgTaskId); + if (downloadsSupported) { + await FileDownloader().cancelTaskWithId(bgTaskId); + } + await _database.updateBgTaskId(globalKey, null); } _pendingDownloadContext.remove(globalKey); await _transitionStatus(globalKey, DownloadStatus.cancelled); @@ -1593,7 +1638,10 @@ class DownloadManagerService { _cancelDownloadTimers(globalKey); final bgTaskId = await _database.getBgTaskId(globalKey); if (bgTaskId != null) { - await FileDownloader().cancelTaskWithId(bgTaskId); + if (downloadsSupported) { + await FileDownloader().cancelTaskWithId(bgTaskId); + } + await _database.updateBgTaskId(globalKey, null); } _pendingDownloadContext.remove(globalKey); diff --git a/lib/utils/platform_http_client_io.dart b/lib/utils/platform_http_client_io.dart index 20c155fb..dbcbeb83 100644 --- a/lib/utils/platform_http_client_io.dart +++ b/lib/utils/platform_http_client_io.dart @@ -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(); } diff --git a/test/providers/download_provider_test.dart b/test/providers/download_provider_test.dart index ce9d59de..fabb7a23 100644 --- a/test/providers/download_provider_test.dart +++ b/test/providers/download_provider_test.dart @@ -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.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');