// ignore_for_file: prefer_initializing_formals import 'dart:async'; import '../media/ids.dart'; import 'dart:io'; import 'package:background_downloader/background_downloader.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:path/path.dart' as path; import 'package:plezy/utils/media_server_http_client.dart'; import '../exceptions/media_server_exceptions.dart'; import '../database/app_database.dart'; import '../database/download_operations.dart'; import '../media/download_resolution.dart'; import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../media/media_item_merge.dart'; import '../media/media_item_types.dart'; import '../media/media_kind.dart'; import '../media/media_server_client.dart'; import 'api_cache.dart'; import 'download_artwork_helpers.dart'; import 'download_artwork_service.dart'; import 'jellyfin_cache_resolver.dart'; import 'plex_api_cache.dart'; import 'settings_service.dart'; import 'saf_storage_service.dart'; import 'package:saf_util/saf_util_platform_interface.dart' show SafDocumentFile; import '../models/download_models.dart'; import '../services/offline_mode_source.dart'; import '../services/download_storage_service.dart'; import '../i18n/strings.g.dart'; import '../utils/app_logger.dart'; import '../utils/serial_future_queue.dart'; import '../utils/active_client_scope.dart'; import '../utils/codec_utils.dart'; import '../utils/global_key_utils.dart'; import '../utils/storage_failure.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; typedef MediaClientResolver = MediaServerClient? Function(ServerId serverId, {String? clientScopeId}); typedef _NativeTaskForId = Future Function(String taskId); typedef _NativeResumeTask = Future Function(DownloadTask task); typedef _EpisodeStorageDeletion = ({String? seasonDirUri, String? showDirUri}); /// The background_downloader entry points the recovery path drives. Injected as a whole /// in tests so relocated-storage recovery can be exercised without platform channels. typedef NativeDownloaderOps = ({ Future> Function() allTasks, Future> Function() allRecords, Future Function(String taskId) deleteRecord, Future Function(Iterable taskIds) cancelTaskIds, Future Function() cleanUpOrphanedTempFiles, Future<(List, List)> Function() rescheduleKilledTasks, }); typedef NativeTaskPartition = ({List current, List stale}); typedef DownloadLocationSnapshot = ({String? path, String? type}); @visibleForTesting NativeTaskPartition partitionNativeTasks(Iterable tasks, String? currentTaskId) { final current = []; final stale = []; for (final task in tasks) { if (currentTaskId != null && task.taskId == currentTaskId) { current.add(task); } else { stale.add(task); } } return (current: current, stale: stale); } /// Whether [task] writes into a directory left behind by a previous location of the /// app's private storage. /// /// A [BaseDirectory.root] task carries its whole target directory in the downloader's /// own persisted store, so one enqueued before the app moved to adoptable storage (or /// before an iOS container UUID change) resumes writing where the app owns nothing. /// Legitimate root-anchored tasks sit under [baseAppDirPath] or under the configured /// [customRootPath], which does not move with the app; anything else is a leftover with /// no recoverable partial data. /// /// [rootBasePath] is the downloader's own resolved path for [BaseDirectory.root] /// (`Task.baseDirectoryPath`). It is needed because the [Task] constructor strips one /// leading separator from `directory`, so the stored value must be rejoined the same way /// `Task.filePath` does before it can be compared with a real directory. @visibleForTesting bool isRelocatedRootTaskDirectory({ required Task task, required String rootBasePath, required String baseAppDirPath, String? customRootPath, }) { // A SAF task also declares BaseDirectory.root, but its directory is a content:// tree // URI owned by a document provider, not a path that moves with the app. if (task is UriTask) return false; if (task.baseDirectory != BaseDirectory.root) return false; if (task.directory.isEmpty) return false; final directory = path.join(rootBasePath, task.directory); if (path.equals(directory, baseAppDirPath) || path.isWithin(baseAppDirPath, directory)) return false; if (customRootPath != null && (path.equals(directory, customRootPath) || path.isWithin(customRootPath, directory))) { return false; } return true; } const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD'); class _DownloadContext { final MediaItem metadata; final DownloadQueueItem queueItem; final String filePath; // Absolute path (normal) or SAF dir URI (SAF mode) final String extension; final MediaServerClient client; final int? showYear; final bool isSafMode; final String? safRootUri; final List? subtitles; _DownloadContext({ required this.metadata, required this.queueItem, required this.filePath, required this.extension, required this.client, this.showYear, this.safRootUri, this.isSafMode = false, this.subtitles, }); } class DownloadManagerService { final AppDatabase _database; final DownloadStorageService _storageService; final MediaServerHttpClient _http; final DownloadArtworkService _artworkService; final SafStorageOperations _safStorage; final bool? _downloadsSupportedOverride; final Future Function(MediaServerClient)? _queueProcessorOverride; final Future Function()? _nativeRecoveryOverride; final Future Function()? _fileDownloaderInitializerOverride; final NativeDownloaderOps? _nativeOpsOverride; final DownloadLocationSnapshot Function()? _downloadLocationReader; final Future Function(String?)? _downloadPathWriter; final Future Function(String?)? _downloadPathTypeWriter; final Future Function()? _downloadStorageRefresher; final SerialFutureQueue _safOwnershipQueue = SerialFutureQueue(); final _progressController = StreamController.broadcast(); Stream get progressStream => _progressController.stream; @visibleForTesting void debugEmitProgress(DownloadProgress progress) { if (!_disposed) _progressController.add(progress); } final _deletionProgressController = StreamController.broadcast(); Stream get deletionProgressStream => _deletionProgressController.stream; final Map _pendingDownloadContext = {}; Future? _supplementaryRepairFuture; // Resolve the correct MediaServerClient for a given serverId/scope // (constructor-injected). Falls back to _fallbackClient when no serverId // is available. final MediaClientResolver _clientResolver; MediaServerClient? _fallbackClient; OfflineModeSource? _offlineSource; bool _fileDownloaderInitialized = false; static const _downloadGroup = 'video_downloads'; static const _maxAppRetries = 3; static const _nativeRetries = 5; static const _defaultAutoRetryDelay = Duration(seconds: 30); static const _progressDebounceDelay = Duration(seconds: 2); static const _videoExtensions = {'.mp4', '.ogv', '.mkv', '.m4v', '.avi'}; // Keys currently being paused — prevents holding queue from promoting them final Set _pausingKeys = {}; // Keys currently being cancelled — prevents queue promotion/completion races. final Set _cancellingKeys = {}; // Keys whose completion callback is in-flight — prevents orphan scan from re-queuing them final Set _completingKeys = {}; // Prevents concurrent _processQueue calls bool _isProcessingQueue = false; bool _isRepairingArtwork = false; bool _disposed = false; bool _queueBlockedByStorageFailure = 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. final Map _progressDebounceTimers = {}; // App-level auto-retry timers for downloads that exhausted native retries. // Keyed by globalKey; each timer fires a fresh re-enqueue after a delay. final Map _autoRetryTimers = {}; final Duration _autoRetryDelay; // Circuit breaker: consecutive instant failures in _processQueue. // Stops the queue when all items fail with the same error (e.g. DNS). int _consecutiveQueueFailures = 0; static const _maxConsecutiveFailures = 3; static bool get platformDownloadsSupported => downloadsSupportedFor(tvosBuild: _tvosBuild); @visibleForTesting static bool downloadsSupportedFor({required bool tvosBuild}) => !tvosBuild; /// Cancels native work and discards resumable partial files so startup can /// recover enough space to reopen the application database. static Future discardInterruptedNativeDownloadsAfterStorageFailure() async { final downloader = FileDownloader(); try { await downloader.reset(group: _downloadGroup); } catch (error, stackTrace) { appLogger.w('Failed to cancel native downloads during storage recovery', error: error, stackTrace: stackTrace); } try { await downloader.database.deleteAllRecords(group: _downloadGroup); } catch (error, stackTrace) { appLogger.w('Failed to discard partial downloads during storage recovery', error: error, stackTrace: stackTrace); } } static Future shouldBlockDownloadOnCellular() async { final List connectivity; try { connectivity = await Connectivity().checkConnectivity(); } catch (e) { // connectivity_plus can throw PlatformException on Windows — don't block return false; } return shouldBlockDownloadOnCellularWith(connectivity); } /// Same check as [shouldBlockDownloadOnCellular] but uses a pre-read /// connectivity result so callers that already queried connectivity don't /// pay for a second platform round-trip. static Future shouldBlockDownloadOnCellularWith(List connectivity) async { final settings = await SettingsService.getInstance(); if (!settings.read(SettingsService.downloadOnWifiOnly)) return false; if (connectivity.isEmpty) return false; return connectivity.contains(ConnectivityResult.mobile) && !connectivity.contains(ConnectivityResult.wifi) && !connectivity.contains(ConnectivityResult.ethernet); } /// Future that completes when interrupted download recovery finishes. /// Await this before reading download state from the DB to avoid races. late final Future recoveryFuture; // Public parameter names are used by tests and app setup; the private fields // cannot be initializing formals without exposing private named parameters. DownloadManagerService({ required AppDatabase database, required DownloadStorageService storageService, required MediaClientResolver clientResolver, MediaServerHttpClient? http, @visibleForTesting SafStorageOperations? safStorage, @visibleForTesting this._downloadsSupportedOverride, @visibleForTesting Future Function(MediaServerClient)? queueProcessorOverride, @visibleForTesting Future Function()? fileDownloaderInitializerOverride, @visibleForTesting Future Function()? nativeRecoveryOverride, @visibleForTesting NativeDownloaderOps? nativeOpsOverride, @visibleForTesting DownloadLocationSnapshot Function()? downloadLocationReader, @visibleForTesting Future Function(String?)? downloadPathWriter, @visibleForTesting Future Function(String?)? downloadPathTypeWriter, @visibleForTesting Future Function()? downloadStorageRefresher, @visibleForTesting Duration autoRetryDelay = _defaultAutoRetryDelay, }) : _queueProcessorOverride = queueProcessorOverride, _autoRetryDelay = autoRetryDelay, _nativeRecoveryOverride = nativeRecoveryOverride, _database = database, _fileDownloaderInitializerOverride = fileDownloaderInitializerOverride, _nativeOpsOverride = nativeOpsOverride, _storageService = storageService, _clientResolver = clientResolver, _http = http ?? httpClient, _safStorage = safStorage ?? SafStorageService.instance, _downloadLocationReader = downloadLocationReader, _downloadPathWriter = downloadPathWriter, _downloadPathTypeWriter = downloadPathTypeWriter, _downloadStorageRefresher = downloadStorageRefresher, _artworkService = DownloadArtworkService(storageService: storageService, http: http ?? httpClient); bool get downloadsSupported => _downloadsSupportedOverride ?? platformDownloadsSupported; NativeDownloaderOps get _nativeOps => _nativeOpsOverride ?? ( allTasks: () => FileDownloader().allTasks(group: _downloadGroup), allRecords: () => FileDownloader().database.allRecords(group: _downloadGroup), deleteRecord: FileDownloader().database.deleteRecordWithId, cancelTaskIds: FileDownloader().cancelTasksWithIds, cleanUpOrphanedTempFiles: FileDownloader().cleanUpOrphanedTempFiles, rescheduleKilledTasks: FileDownloader().rescheduleKilledTasks, ); 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; } /// Inject the offline-mode source. When `isOffline`, queue/resume paths skip /// network work and defer until connectivity returns. void setOfflineSource(OfflineModeSource? source) { _offlineSource = source; } bool get _isOffline => _offlineSource?.isOffline ?? false; Future _serializeSafOwnership(Future Function() action) => _safOwnershipQueue.run(action); DownloadLocationSnapshot _readDownloadLocation() { final reader = _downloadLocationReader; if (reader != null) return reader(); final settings = SettingsService.instanceOrNull; if (settings == null) { return (path: _storageService.safBaseUri, type: _storageService.isUsingSaf ? 'saf' : null); } return ( path: settings.read(SettingsService.customDownloadPath), type: settings.read(SettingsService.customDownloadPathType), ); } Future _writeDownloadPath(String? value) async { final writer = _downloadPathWriter; if (writer != null) { await writer(value); return; } await SettingsService.instance.write(SettingsService.customDownloadPath, value); } Future _writeDownloadPathType(String? value) async { final writer = _downloadPathTypeWriter; if (writer != null) { await writer(value); return; } await SettingsService.instance.write(SettingsService.customDownloadPathType, value); } Future _refreshDownloadStorage() async { final refresher = _downloadStorageRefresher; if (refresher != null) { await refresher(); return; } await _storageService.refreshCustomPath(); } Future _canonicalRootForLocation(DownloadLocationSnapshot location) async { if (location.type != 'saf' || location.path == null) return null; return _safStorage.resolvePersistedPermissionUri(location.path!); } Future setDownloadLocation({required String path, required String pathType}) { return _serializeSafOwnership(() => _installDownloadLocation((path: path, type: pathType))); } Future resetDownloadLocation() { return _serializeSafOwnership(() => _installDownloadLocation((path: null, type: null))); } Future _installDownloadLocation(DownloadLocationSnapshot next) async { final previous = _readDownloadLocation(); final previousRoot = await _canonicalRootForLocation(previous); final nextRoot = await _canonicalRootForLocation(next); if (next.type == 'saf' && next.path != null && nextRoot == null) { throw DownloadStorageException( 'Selected SAF root has no persisted permission', next.path!, StateError('Persisted SAF permission is unavailable'), ); } var storageRefreshStarted = false; try { await _writeDownloadPath(next.path); await _writeDownloadPathType(next.type); storageRefreshStarted = true; await _refreshDownloadStorage(); } catch (error, stackTrace) { Object? rollbackError; try { await _writeDownloadPath(previous.path); } catch (error) { rollbackError = error; } try { await _writeDownloadPathType(previous.type); } catch (error) { rollbackError ??= error; } try { await _refreshDownloadStorage(); } catch (error) { rollbackError ??= error; } if (rollbackError != null) { appLogger.e('Failed to restore download location after transition failure', error: rollbackError); } if (!storageRefreshStarted && nextRoot != null && nextRoot != previousRoot) { await _releaseSafRootIfUnowned(nextRoot); } Error.throwWithStackTrace(error, stackTrace); } if (previousRoot != null && previousRoot != nextRoot) { await _releaseSafRootIfUnowned(previousRoot); } } Future _releaseSafRootIfUnowned(String safRootUri) async { if (await _database.countDownloadsReferencingSafRoot(safRootUri) > 0) { return false; } final selected = _readDownloadLocation(); final selectedUri = selected.type == 'saf' ? selected.path : null; if (selectedUri != null) { if (selectedUri == safRootUri) return false; final selectedRoot = await _safStorage.resolvePersistedPermissionUri(selectedUri); if (selectedRoot == null || selectedRoot == safRootUri) return false; } return _safStorage.releasePersistedPermission(safRootUri); } Future _replaceDownloadSafRootClaim(String globalKey, String? nextRoot) async { final existing = await _database.getDownloadedMedia(globalKey); if (existing == null) return null; final previousRoot = existing.safRootUri; if (previousRoot == nextRoot) return nextRoot; await _database.updateDownloadSafRoot(globalKey, nextRoot); if (previousRoot != null) { await _releaseSafRootIfUnowned(previousRoot); } return nextRoot; } Future _deleteDownloadRowAndRelease(String globalKey) { return _serializeSafOwnership(() async { final row = await _database.getDownloadedMedia(globalKey); String? root = row?.safRootUri; final videoUri = row?.videoFilePath; if (root == null && videoUri != null && Uri.tryParse(videoUri)?.scheme == 'content') { root = await _safStorage.resolvePersistedPermissionUri(videoUri); } final deletedRoot = await _database.deleteDownload(globalKey); root ??= deletedRoot; if (root != null) { await _releaseSafRootIfUnowned(root); } }); } @visibleForTesting Future debugClaimDownloadSafRoot(String globalKey, String uri) { return _serializeSafOwnership(() async { final root = await _safStorage.resolvePersistedPermissionUri(uri); if (root == null) { throw StateError('SAF root claim could not be canonicalized'); } await _replaceDownloadSafRootClaim(globalKey, root); }); } @visibleForTesting Future debugClearDownloadSafRoot(String globalKey) { return _serializeSafOwnership(() async { await _replaceDownloadSafRootClaim(globalKey, null); }); } @visibleForTesting Future debugDeleteDownloadRowAndRelease(String globalKey) { return _deleteDownloadRowAndRelease(globalKey); } /// Look up the correct client for [serverId]. /// Returns null if the server is offline — callers should skip/defer the work. MediaServerClient? _getClient(ServerId? serverId, {String? clientScopeId}) { if (serverId != null) { return _clientResolver(serverId, clientScopeId: clientScopeId); } return _fallbackClient; } Future _getClientForDownloadKey(String globalKey) async { final parsed = parseGlobalKey(globalKey); if (parsed == null) return _getClient(null); final record = await _database.getDownloadedMedia(globalKey); return _getClient(parsed.serverId, clientScopeId: record?.clientScopeId); } String? activeClientScopeIdForServer(ServerId serverId) { final client = _getClient(serverId); return resolveActiveClientScopeId(serverId: serverId, cacheServerId: client?.cacheServerId); } /// Returns the cache namespace visible to [activeProfileId] for [serverId]. /// /// MediaBrowser backends prefer the persisted profile-to-user binding so a /// cold launch and a profile switch cannot inherit the physical download /// row's creator scope. A live scope is used only when no persisted binding /// exists. Future profileClientScopeIdForServer(ServerId serverId, String? activeProfileId) async { if (activeProfileId == null || activeProfileId.isEmpty) return null; final backend = await _backendForServer(serverId); if (backend == null) return null; if (backend.usesMediaBrowserApi) { final persisted = await JellyfinCacheResolver(_database).findProfileScopeId(serverId, activeProfileId); return persisted ?? activeClientScopeIdForServer(serverId); } return buildPlexProfileScopeId(serverId: serverId, profileId: activeProfileId); } /// Bulk-load pinned metadata. Profile-visible hydration reads only exact /// owner namespaces; it never pre-merges another user's rows. Future> getAllPinnedMetadata({bool preferActiveScope = false, String? activeProfileId}) async { if (preferActiveScope) { if (activeProfileId == null || activeProfileId.isEmpty) return {}; final ownerKeys = await _database.getDownloadOwnerKeysForProfile(activeProfileId); final allowedByBackend = >{ for (final backend in MediaBackend.values) backend: {}, }; for (final item in await _database.getAllDownloadedMetadata()) { if (!ownerKeys.contains(item.globalKey)) continue; final serverId = ServerId(item.serverId); final backend = await _backendForServer(serverId); if (backend == null) continue; final scopeId = await profileClientScopeIdForServer(serverId, activeProfileId); if (scopeId != null) allowedByBackend[backend]!.add(ServerId(scopeId)); } final results = await Future.wait( MediaBackend.values.map( (backend) => ApiCache.forBackend(backend).getAllPinnedMetadata(cacheServerIds: allowedByBackend[backend]), ), ); return {for (final result in results) ...result}; } final results = await Future.wait( MediaBackend.values.map((backend) => ApiCache.forBackend(backend).getAllPinnedMetadata()), ); return {for (final result in results) ...result}; } Future lookupMetadata( ServerId serverId, String itemId, { bool preferActiveScope = false, String? activeProfileId, }) async { if (preferActiveScope) { final activeScopeId = await profileClientScopeIdForServer(serverId, activeProfileId); if (activeScopeId == null) return null; return _lookupMetadata(serverId, itemId, clientScopeId: activeScopeId); } final download = await _database.getDownloadedMedia(buildGlobalKey(serverId, itemId)); for (final scopeId in _metadataScopeCandidates(serverId, downloadedClientScopeId: download?.clientScopeId)) { final hit = await _lookupMetadata(serverId, itemId, clientScopeId: scopeId == serverId ? null : scopeId); if (hit != null) return hit; } return null; } List _metadataScopeCandidates(ServerId serverId, {String? downloadedClientScopeId}) { final candidates = [ ?downloadedClientScopeId, ?_getClient(serverId, clientScopeId: downloadedClientScopeId)?.cacheServerId, serverId, ]; return { for (final id in candidates) if (id.isNotEmpty) id, }.toList(growable: false); } Future fetchAndPinMetadata( ServerId serverId, String itemId, { bool preferActiveScope = false, String? activeProfileId, }) async { final download = await _database.getDownloadedMedia(buildGlobalKey(serverId, itemId)); String? clientScopeId; if (preferActiveScope) { clientScopeId = await profileClientScopeIdForServer(serverId, activeProfileId); if (clientScopeId == null) return null; } else { clientScopeId = download?.clientScopeId; } final client = _getClient(serverId, clientScopeId: clientScopeId); if (client == null || (preferActiveScope && client.cacheServerId != clientScopeId)) return null; try { final metadata = await client.fetchItem(itemId); if (metadata == null) return null; await ApiCache.forBackend(client.backend).pinForOffline(ServerId(client.cacheServerId), itemId); return metadata; } catch (e) { appLogger.d('fetchAndPinMetadata failed for $serverId:$itemId', error: e); return null; } } /// Resolve which backend the cache row for [serverId] uses. Reads the /// `Connections` table directly so the lookup works even when the server /// is currently offline (the connection persists across launches). /// /// [JellyfinCacheResolver] reconciles bare machine ids with compound /// `${serverMachineId}/$userId` MediaBrowser connection ids without treating /// `_` or `%` as wildcards. Future _backendForServer(ServerId serverId) async { // Prefer a live client — `MediaServerClient.backend` is in memory. final live = _getClient(serverId); if (live != null) return live.backend; final row = await JellyfinCacheResolver(_database).findConnection(serverId); if (row == null) return null; // Keep the persisted discriminator intact even though both MediaBrowser // values dispatch to the same cache implementation. return switch (row.kind) { 'jellyfin' => MediaBackend.jellyfin, 'emby' => MediaBackend.emby, 'plex' => MediaBackend.plex, _ => null, }; } /// Backend-aware metadata lookup. Dispatches to the right `getMetadata` /// helper so callers don't have to thread backend identity through every /// layer. /// /// When [_backendForServer] can't resolve the backend (no live client and /// no `connections` row — happens when a server has been removed but old /// download rows still reference it), fan out to every registered backend /// cache instead of silently defaulting to Plex. Otherwise MediaBrowser /// items would render with blank metadata after a connection is severed. Future _lookupMetadata(ServerId serverId, String itemId, {String? clientScopeId}) async { final backend = await _backendForServer(serverId); final live = _getClient(serverId, clientScopeId: clientScopeId); if (backend != null) { return ApiCache.forBackend( backend, ).getMetadata(ServerId(clientScopeId ?? live?.cacheServerId ?? serverId), itemId); } appLogger.w('Cache lookup for $serverId:$itemId — backend unresolved; trying all registered backends'); for (final candidate in MediaBackend.values) { if (clientScopeId != null && clientScopeId.isNotEmpty) { final scopedHit = await ApiCache.forBackend(candidate).getMetadata(ServerId(clientScopeId), itemId); if (scopedHit != null) return scopedHit; } final hit = await ApiCache.forBackend(candidate).getMetadata(serverId, itemId); if (hit != null) return hit; } return null; } Set _offlineMetadataIds(DownloadedMediaItem row) => { row.ratingKey, ?row.parentRatingKey, ?row.grandparentRatingKey, }; /// Preserve Plex metadata while full logout temporarily leaves downloads /// without a profile owner. /// /// Available metadata is sanitized into the transfer cache, while the /// durable download always moves to the neutral scope even when its leaf /// cache row is missing. The cache and scope changes share one transaction. Future preparePlexMetadataForLogoutTransfer() async { final rows = await getAllDownloads(); final cache = PlexApiCache.instance; for (final row in rows) { final publicServerId = ServerId(row.serverId); final owners = await _database.getValidDownloadOwnersForKey(row.globalKey); final rowScope = PlexProfileScopeId.tryParse(row.clientScopeId ?? ''); final ownerHasPlexScope = owners.any( (owner) => owner.backend == MediaBackend.plex.id || PlexProfileScopeId.tryParse(owner.clientScopeId ?? '') != null, ); if (rowScope == null && !ownerHasPlexScope && await _backendForServer(publicServerId) != MediaBackend.plex) { continue; } final sourceScopes = {}; for (final owner in owners) { final persistedScope = PlexProfileScopeId.tryParse(owner.clientScopeId ?? ''); if (persistedScope != null && persistedScope.publicServerId == publicServerId) { sourceScopes[persistedScope] = persistedScope; } final derivedScope = buildPlexProfileScopeId(serverId: publicServerId, profileId: owner.profileId); sourceScopes[derivedScope] = derivedScope; } if (rowScope != null && rowScope.publicServerId == publicServerId) { sourceScopes[rowScope] = rowScope; } final transferScope = buildPlexTransferScopeId(publicServerId); final metadataIds = _offlineMetadataIds(row); await _database.transaction(() async { for (final id in metadataIds) { for (final sourceScope in sourceScopes.values) { final copied = await cache.copyPinnedMetadata( sourceServerId: sourceScope.cacheServerId, destinationServerId: transferScope.cacheServerId, ratingKey: id, stripProfileState: true, ); if (copied) break; } } await _database.updateDownloadedMediaClientScope(row.globalKey, transferScope); for (final id in metadataIds) { await cache.deleteAllProfileRowsForItem(publicServerId, id); } }); } } /// Move ownerless full-logout metadata into the adopting profile's private /// Plex namespace before profile-visible cache hydration runs. Future adoptTransferredPlexMetadataForProfile(String profileId, {bool Function()? isStillActive}) async { if (profileId.isEmpty || isStillActive != null && !isStillActive()) return; final owners = {for (final owner in await _database.getDownloadOwnersForProfile(profileId)) owner.globalKey: owner}; final rows = await getAllDownloads(); final cache = PlexApiCache.instance; for (final row in rows) { if (isStillActive != null && !isStillActive()) return; final owner = owners[row.globalKey]; final transferScope = PlexTransferScopeId.tryParse(row.clientScopeId ?? '') ?? PlexTransferScopeId.tryParse(owner?.clientScopeId ?? ''); if (owner == null || transferScope == null || transferScope.publicServerId != ServerId(row.serverId)) continue; final destinationScope = buildPlexProfileScopeId(serverId: transferScope.publicServerId, profileId: profileId); final metadataIds = _offlineMetadataIds(row); await _database.transaction(() async { for (final id in metadataIds) { await cache.copyPinnedMetadata( sourceServerId: transferScope.cacheServerId, destinationServerId: destinationScope.cacheServerId, ratingKey: id, ); } await _database.updateDownloadedMediaClientScope(row.globalKey, destinationScope); await _database.updateDownloadOwnerScope( profileId: profileId, globalKey: row.globalKey, backendId: MediaBackend.plex.id, clientScopeId: destinationScope, ); for (final id in metadataIds) { await cache.deleteForItem(transferScope.cacheServerId, id); } }); } } /// Backend-aware "ensure cached & pin". MediaBrowser backends load playback /// extras; Jellyfin can additionally cache native media segments. Other /// backends only need the item metadata row. Then pin cached rows so they /// survive general cache eviction. Future _pinMetadataForOffline(MediaServerClient client, MediaItem metadata) async { final serverId = metadata.serverId; if (serverId == null) { appLogger.w('Cannot pin metadata without serverId'); return; } if (client.backend.usesMediaBrowserApi) { try { await client.fetchPlaybackExtras(metadata.id); } catch (e) { appLogger.w('fetchPlaybackExtras failed during offline-pin for ${metadata.globalKey}', error: e); } } else { try { await client.fetchItem(metadata.id); } catch (e) { appLogger.w('fetchItem failed during offline-pin for ${metadata.globalKey}', error: e); } } await ApiCache.forBackend(client.backend).pinForOffline(ServerId(client.cacheServerId), metadata.id); } Future deleteMetadataForOwner({ required String globalKey, required ServerId serverId, required String itemId, required String profileId, String? backendId, String? clientScopeId, }) async { final scopeId = clientScopeId?.trim(); if (scopeId != null && scopeId.isNotEmpty && backendId != null) { if (await _database.hasDownloadOwnerForCacheScope(globalKey, backendId: backendId, clientScopeId: scopeId)) { return; } final backend = MediaBackend.fromId(backendId); await ApiCache.forBackend(backend).deleteForItem(ServerId(scopeId), itemId); return; } if (backendId == null || backendId == MediaBackend.plex.id) { final scope = buildPlexProfileScopeId(serverId: serverId, profileId: profileId); await PlexApiCache.instance.deleteForItem(scope.cacheServerId, itemId); } } Future _deleteForItemByServer(ServerId serverId, String itemId, {String? clientScopeId}) async { final backend = await _backendForServer(serverId); final live = _getClient(serverId, clientScopeId: clientScopeId); if (backend != null) { await ApiCache.forBackend( backend, ).deleteForItem(ServerId(clientScopeId ?? live?.cacheServerId ?? serverId), itemId); return; } // Backend unresolved — purge from every registered backend so a stale // row from either side doesn't outlive the deletion. Idempotent; // missing rows are no-ops. appLogger.w('Cache delete for $serverId:$itemId — backend unresolved; clearing all registered backends'); for (final candidate in MediaBackend.values) { if (clientScopeId != null && clientScopeId.isNotEmpty) { await ApiCache.forBackend(candidate).deleteForItem(ServerId(clientScopeId), itemId); } await ApiCache.forBackend(candidate).deleteForItem(serverId, itemId); } } /// Initialize background_downloader with callbacks, notifications, and concurrency config. Future _initializeFileDownloader() async { if (_fileDownloaderInitialized) return; if (_skipDownloadsUnsupported('FileDownloader initialization')) return; FileDownloader() .registerCallbacks( group: _downloadGroup, taskStatusCallback: _onTaskStatusChanged, taskProgressCallback: _onTaskProgress, ) .configureNotificationForGroup( _downloadGroup, running: TaskNotification('{displayName}', t.downloads.notificationDownloading), complete: TaskNotification('{displayName}', t.downloads.notificationComplete), error: TaskNotification('{displayName}', t.downloads.errorDownloadFailed), paused: TaskNotification('{displayName}', t.downloads.notificationPaused), progressBar: true, ); // Plex servers can reject concurrent media downloads. await FileDownloader().configure(globalConfig: (Config.holdingQueue, (1, 1, 1))); await FileDownloader().trackTasks(); // Deliver status updates from iOS background-to-foreground transitions await FileDownloader().resumeFromBackground(); _fileDownloaderInitialized = true; } /// Recover downloads that were interrupted when the app was killed. /// Uses background_downloader's rescheduleKilledTasks for native recovery, /// then scans drift for orphaned items. Future recoverInterruptedDownloads() async { if (_skipDownloadsUnsupported('download recovery')) return; try { try { final repaired = await _database.repairMissingQueuedDownloadEntries(); if (repaired > 0) { appLogger.i('Repaired $repaired missing download queue item(s)'); } } catch (e, st) { appLogger.e('Failed to repair missing download queue items', error: e, stackTrace: st); } final nativeRecoveryOverride = _nativeRecoveryOverride; if (nativeRecoveryOverride != null) { await nativeRecoveryOverride(); return; } // Strictly before the downloader is wired up. Initialization registers our status // callbacks and calls resumeFromBackground, which can deliver a failure a relocated // task already hit on the old path — and a failed row is no longer restartable. // rescheduleKilledTasks, further down, would re-enqueue it against that path again. await _purgeRelocatedDownloadRecords(); unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Initializing FileDownloader', category: 'downloads'))); await (_fileDownloaderInitializerOverride?.call() ?? _initializeFileDownloader()); final deletedTempFiles = await _nativeOps.cleanUpOrphanedTempFiles(); if (deletedTempFiles > 0) { appLogger.i('Deleted $deletedTempFiles orphaned downloader temp file(s)'); } // Let background_downloader re-enqueue tasks killed by the OS unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Rescheduling killed tasks', category: 'downloads'))); final (rescheduled, _) = await _nativeOps.rescheduleKilledTasks(); if (rescheduled.isNotEmpty) { appLogger.i('Rescheduled ${rescheduled.length} killed download task(s)'); } await _reconcileNativeDownloadTasks(); await _reconcileSafGrantOwnership(); // One-time migration: normalize stored file paths that may contain a // doubled base-dir prefix from an earlier bug in the recovery callback. // Re-run on v2 to also fix paths without a leading / that the v1 migration missed. final prefs = (await SettingsService.getInstance()).prefs; if ((prefs.getInt('download_paths_normalized_version') ?? 0) < 2) { final allItems = await _database.select(_database.downloadedMedia).get(); var fixed = 0; for (final item in allItems) { if (item.videoFilePath != null) { final vfp = item.videoFilePath!; var normalized = await _storageService.toRelativePath(vfp); // If toRelativePath didn't help, try extracting from downloads/ onward // for paths that lack a leading / but contain nested base-dir fragments if (normalized == vfp) { final idx = vfp.indexOf('downloads/'); if (idx > 0) normalized = vfp.substring(idx); } appLogger.d('Path migration: videoFilePath="$vfp", normalized="$normalized"'); if (normalized != vfp) { await _database.updateVideoFilePath(item.globalKey, normalized); fixed++; } } if (item.thumbPath != null) { final tp = item.thumbPath!; var normalized = await _storageService.toRelativePath(tp); if (normalized == tp) { final idx = tp.indexOf('downloads/'); if (idx > 0) normalized = tp.substring(idx); } if (normalized != tp) { await _database.updateArtworkPaths(globalKey: item.globalKey, thumbPath: normalized); } } } if (fixed > 0) appLogger.i('Normalized $fixed corrupted download path(s)'); await prefs.setInt('download_paths_normalized_version', 2); } // Scan drift for orphaned items stuck in 'downloading' unawaited(Sentry.addBreadcrumb(Breadcrumb(message: 'Scanning for orphaned downloads', category: 'downloads'))); final allDownloads = await _database.select(_database.downloadedMedia).get(); for (final item in allDownloads) { if (item.status == DownloadStatus.downloading.index) { // Skip items whose completion callback is already in-flight (race with trackTasks) if (_completingKeys.contains(item.globalKey)) { appLogger.d('Skipping orphan check for ${item.globalKey}: completion in progress'); continue; } // Video already downloaded but post-processing didn't complete. // Keep the queue row as durable supplementary-download intent. if (item.videoFilePath != null) { appLogger.i('Download ${item.globalKey} has video but incomplete post-processing, completing'); await _database.updateDownloadStatus(item.globalKey, DownloadStatus.completed.index); _emitProgress(item.globalKey, DownloadStatus.completed, 100); continue; } Task? bgTask; if (item.bgTaskId != null) { bgTask = await FileDownloader().taskForId(item.bgTaskId!); } if (bgTask == null) { // No active bg task — orphan, re-queue it appLogger.i('Re-queuing orphaned download: ${item.globalKey}'); await _database.updateDownloadStatus(item.globalKey, DownloadStatus.queued.index); await _database.updateBgTaskId(item.globalKey, null); await _database.addToQueue(mediaGlobalKey: item.globalKey); } } } } catch (e) { appLogger.e('Failed to recover interrupted downloads', error: e); } } Future _reconcileNativeDownloadTasks() async { if (!downloadsSupported) return; // An injected ops seam stands in for a wired-up downloader. if (_nativeOpsOverride == null && !_fileDownloaderInitialized) return; final List nativeTasks; try { nativeTasks = await _nativeOps.allTasks(); } catch (e) { appLogger.w('Failed to enumerate native download tasks during recovery', error: e); return; } if (nativeTasks.isEmpty) return; final tasksByGlobalKey = >{}; final rootTasks = []; for (final task in nativeTasks) { if (task.baseDirectory == BaseDirectory.root) { rootTasks.add(task); continue; } final globalKey = task.metaData; if (globalKey.isEmpty) continue; (tasksByGlobalKey[globalKey] ??= []).add(task); } // A root-anchored task carries its absolute directory in the downloader's own // persisted store, so one enqueued before the app's private storage moved resumes // writing where the app no longer owns anything. Those cannot be resumed at all. final relocatedTasksByGlobalKey = >{}; if (rootTasks.isNotEmpty) { final rootBasePath = await Task.baseDirectoryPath(BaseDirectory.root); final baseAppDirPath = await _storageService.baseAppDirectoryPath(); final customRootPath = _storageService.customFileRootPath; for (final task in rootTasks) { final relocated = isRelocatedRootTaskDirectory( task: task, rootBasePath: rootBasePath, baseAppDirPath: baseAppDirPath, customRootPath: customRootPath, ); if (relocated) { (relocatedTasksByGlobalKey[task.metaData] ??= []).add(task); } else if (task.metaData.isNotEmpty) { (tasksByGlobalKey[task.metaData] ??= []).add(task); } } } if (relocatedTasksByGlobalKey.isEmpty && tasksByGlobalKey.isEmpty) return; final rows = await _database.select(_database.downloadedMedia).get(); final rowsByGlobalKey = {for (final row in rows) row.globalKey: row}; for (final entry in relocatedTasksByGlobalKey.entries) { await _discardRelocatedNativeTasks(entry.key, entry.value, rowsByGlobalKey[entry.key]); } for (final entry in tasksByGlobalKey.entries) { final globalKey = entry.key; final tasks = entry.value; final row = rowsByGlobalKey[globalKey]; if (row == null) { await _cancelNativeTaskIds( globalKey, tasks.map((task) => task.taskId), reason: 'no download row during recovery', ); continue; } switch (DownloadStatus.values[row.status]) { case DownloadStatus.downloading: await _reconcileDownloadingNativeTasks(row, tasks); case DownloadStatus.paused: await _reconcilePausedNativeTasks(row, tasks); case DownloadStatus.queued: await _cancelNativeTaskIds( globalKey, tasks.map((task) => task.taskId), reason: 'queued download during recovery', ); await _database.addToQueue(mediaGlobalKey: globalKey); case DownloadStatus.completed: case DownloadStatus.failed: case DownloadStatus.cancelled: case DownloadStatus.partial: await _cancelNativeTaskIds( globalKey, tasks.map((task) => task.taskId), reason: 'download status ${DownloadStatus.values[row.status]} during recovery', ); } } } /// Drop downloader records whose target directory belongs to a previous location of the /// app's private storage, and requeue the download so it restarts under the current one. /// /// Runs before the downloader is initialized, and therefore before /// [FileDownloader.rescheduleKilledTasks], for two reasons. Reschedule re-enqueues every /// enqueued/running record it finds missing natively, carrying the relocated absolute /// directory over verbatim. And initialization registers our status callbacks and calls /// [FileDownloader.resumeFromBackground], which can deliver a failure such a task already /// hit — marking the row failed, which [_requeueRelocatedDownload] deliberately will not /// restart. Reading and deleting records needs no initialization: the record store is /// Dart-side, as [discardInterruptedNativeDownloadsAfterStorageFailure] also relies on. Future _purgeRelocatedDownloadRecords() async { if (!downloadsSupported) return; final List records; try { records = await _nativeOps.allRecords(); } catch (e) { appLogger.w('Failed to enumerate download records during recovery', error: e); return; } final rootRecords = records.where((record) => record.task.baseDirectory == BaseDirectory.root).toList(); if (rootRecords.isEmpty) return; final rootBasePath = await Task.baseDirectoryPath(BaseDirectory.root); final baseAppDirPath = await _storageService.baseAppDirectoryPath(); final customRootPath = _storageService.customFileRootPath; for (final record in rootRecords) { if (!isRelocatedRootTaskDirectory( task: record.task, rootBasePath: rootBasePath, baseAppDirPath: baseAppDirPath, customRootPath: customRootPath, )) { continue; } final globalKey = record.task.metaData; appLogger.i( 'Dropping download record ${record.taskId} for $globalKey targeting relocated ' 'storage: ${record.task.directory}', ); try { await _nativeOps.deleteRecord(record.taskId); } catch (e) { appLogger.w('Failed to drop relocated download record ${record.taskId}', error: e); continue; } await _cancelNativeTaskIds(globalKey, [record.taskId], reason: 'relocated app storage before rescheduling'); if (globalKey.isNotEmpty) await _requeueRelocatedDownload(globalKey); } } /// Cancel [tasks] that target storage the app no longer owns and put a restartable /// download back in the queue so it downloads again under the current location. Future _discardRelocatedNativeTasks(String globalKey, List tasks, DownloadedMediaItem? row) async { appLogger.i( 'Discarding ${tasks.length} download task(s) for $globalKey targeting relocated storage: ' '${tasks.map((task) => task.directory).toSet().join(', ')}', ); await _cancelNativeTaskIds( globalKey, tasks.map((task) => task.taskId), reason: 'relocated app storage during recovery', ); if (row != null) await _requeueRelocatedDownload(row.globalKey, row: row); } /// Send a download whose bytes are stranded on a previous storage location back to the /// queue. A finished or already-abandoned row keeps its status: there is nothing to /// restart, and its stored path is relative and therefore still valid. Future _requeueRelocatedDownload(String globalKey, {DownloadedMediaItem? row}) async { final current = row ?? await _database.getDownloadedMedia(globalKey); if (current == null) return; final restartable = switch (DownloadStatus.values[current.status]) { DownloadStatus.queued || DownloadStatus.downloading || DownloadStatus.paused => true, DownloadStatus.completed || DownloadStatus.failed || DownloadStatus.cancelled || DownloadStatus.partial => false, }; if (!restartable) return; await _database.updateBgTaskId(globalKey, null); await _database.updateDownloadStatus(globalKey, DownloadStatus.queued.index); await _database.addToQueue(mediaGlobalKey: globalKey); } @visibleForTesting Future debugRecoverRelocatedDownloads() async { await _purgeRelocatedDownloadRecords(); await _reconcileNativeDownloadTasks(); } @visibleForTesting Future debugReconcileSafGrantOwnership({List nativeTasks = const []}) { return _reconcileSafGrantOwnership(nativeTasks: nativeTasks); } Future _reconcileSafGrantOwnership({List? nativeTasks}) { return _serializeSafOwnership(() async { List tasks; if (nativeTasks != null) { tasks = nativeTasks; } else { try { tasks = await _nativeOps.allTasks(); } catch (error) { appLogger.w('SAF grant reconciliation deferred: native task enumeration failed', error: error); return; } } final rows = await _database.select(_database.downloadedMedia).get(); final rowsByGlobalKey = {for (final row in rows) row.globalKey: row}; final activeUriTasks = {}; for (final task in tasks) { if (task is! UriDownloadTask || task.metaData.isEmpty) continue; final row = rowsByGlobalKey[task.metaData]; if (row?.bgTaskId == task.taskId) { activeUriTasks[row!.globalKey] = task; } } final owners = {}; var resolutionFailed = false; final selected = _readDownloadLocation(); final selectedUri = selected.type == 'saf' ? selected.path : null; if (selectedUri != null) { final selectedRoot = await _safStorage.resolvePersistedPermissionUri(selectedUri); if (selectedRoot == null) { resolutionFailed = true; } else { owners.add(selectedRoot); } } for (final row in rows) { final activeTask = activeUriTasks[row.globalKey]; String? candidate; if (activeTask != null) { candidate = activeTask.directoryUri.toString(); } else if (row.safRootUri != null) { candidate = row.safRootUri; } else { final videoUri = row.videoFilePath; if (videoUri != null && Uri.tryParse(videoUri)?.scheme == 'content') { candidate = videoUri; } } if (candidate == null) continue; final canonicalRoot = await _safStorage.resolvePersistedPermissionUri(candidate); if (canonicalRoot == null) { resolutionFailed = true; continue; } owners.add(canonicalRoot); if (row.safRootUri != canonicalRoot) { await _database.updateDownloadSafRoot(row.globalKey, canonicalRoot); } } final persistedRoots = await _safStorage.getPersistedPermissionUris(); if (persistedRoots == null || resolutionFailed) return; for (final persistedRoot in persistedRoots) { if (!owners.contains(persistedRoot)) { await _safStorage.releasePersistedPermission(persistedRoot); } } }); } Future _retainUniqueCurrentNativeTask( DownloadedMediaItem row, List tasks, { required String statusLabel, }) async { final partition = partitionNativeTasks(tasks, row.bgTaskId); if (partition.current.length != 1) return partition.current.length; await _cancelNativeTaskIds( row.globalKey, partition.stale.map((task) => task.taskId), reason: 'duplicate $statusLabel task during recovery', ); return 1; } Future _reconcileDownloadingNativeTasks(DownloadedMediaItem row, List tasks) async { final currentMatchCount = await _retainUniqueCurrentNativeTask(row, tasks, statusLabel: 'downloading'); if (currentMatchCount == 1) return; if (currentMatchCount > 1) { appLogger.w('Multiple native tasks share current task id ${row.bgTaskId} for ${row.globalKey}; re-queueing'); await _cancelNativeTaskIds( row.globalKey, tasks.map((task) => task.taskId), reason: 'duplicate current task id during recovery', ); await _database.updateBgTaskId(row.globalKey, null); await _database.updateDownloadProgress(row.globalKey, 0, 0, 0); await _transitionStatus(row.globalKey, DownloadStatus.queued); await _database.addToQueue(mediaGlobalKey: row.globalKey); return; } if (tasks.length == 1) { final taskId = tasks.single.taskId; appLogger.i('Adopting recovered native task $taskId for ${row.globalKey}'); await _database.updateBgTaskId(row.globalKey, taskId); return; } await _cancelNativeTaskIds( row.globalKey, tasks.map((task) => task.taskId), reason: 'ambiguous downloading tasks during recovery', ); await _database.updateBgTaskId(row.globalKey, null); await _database.updateDownloadProgress(row.globalKey, 0, 0, 0); await _transitionStatus(row.globalKey, DownloadStatus.queued); await _database.addToQueue(mediaGlobalKey: row.globalKey); } Future _reconcilePausedNativeTasks(DownloadedMediaItem row, List tasks) async { final currentMatchCount = await _retainUniqueCurrentNativeTask(row, tasks, statusLabel: 'paused'); if (currentMatchCount == 1) return; if (currentMatchCount > 1) { appLogger.w( 'Multiple paused native tasks share current task id ${row.bgTaskId} for ${row.globalKey}; clearing task', ); } await _cancelNativeTaskIds( row.globalKey, tasks.map((task) => task.taskId), reason: 'unexpected paused native tasks during recovery', ); await _database.updateBgTaskId(row.globalKey, null); } Future _cancelNativeTaskIds(String globalKey, Iterable taskIds, {required String reason}) async { if (!downloadsSupported) return; final ids = taskIds.where((id) => id.isNotEmpty).toSet(); if (ids.isEmpty) return; try { final cancelled = await _nativeOps.cancelTaskIds(ids); if (cancelled) { appLogger.d('Cancelled ${ids.length} native task(s) for $globalKey ($reason): ${ids.join(', ')}'); } } catch (e) { appLogger.w('Failed to cancel native tasks for $globalKey ($reason): ${ids.join(', ')}', error: e); } } /// 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) { appLogger.d('Skipping resumeQueuedDownloads — offline'); return; } unawaited(repairPendingSupplementaryDownloads()); unawaited(repairMissingArtworkForDownloads()); unawaited( _database .getNextQueueItem() .then((item) { if (item != null) { appLogger.i('Resuming queued downloads after app restart'); _processQueue(client); } }) .catchError((e, st) { appLogger.e('Failed to resume queued downloads', error: e, stackTrace: st); }), ); } /// Best-effort repair for downloads that completed while supplementary /// artwork was missing, corrupt, or skipped by older queue logic. Future repairMissingArtworkForDownloads() async { if (_isRepairingArtwork || _isOffline) return; _isRepairingArtwork = true; try { final rows = await _database.select(_database.downloadedMedia).get(); final ensuredParentKeys = {}; for (final row in rows) { if (row.status != DownloadStatus.completed.index) continue; final client = await _getClientForDownloadKey(row.globalKey); if (client == null) continue; final metadata = await _lookupMetadata(ServerId(row.serverId), row.ratingKey, clientScopeId: row.clientScopeId); if (metadata == null) continue; final withServer = _repairMetadataWithServer(metadata, ServerId(row.serverId)); await _artworkService.ensureArtworkForMetadata(withServer, client); await _backfillArtworkPath(row, withServer); if (!withServer.isEpisode) continue; await _repairParentArtwork( ServerId(row.serverId), withServer.grandparentId, client, ensuredParentKeys, clientScopeId: row.clientScopeId, ); await _repairParentArtwork( ServerId(row.serverId), withServer.parentId, client, ensuredParentKeys, clientScopeId: row.clientScopeId, ); } } catch (e, st) { appLogger.w('Missing artwork repair failed', error: e, stackTrace: st); } finally { _isRepairingArtwork = false; } } Future _repairParentArtwork( ServerId serverId, String? ratingKey, MediaServerClient client, Set ensuredKeys, { String? clientScopeId, }) async { if (ratingKey == null || ratingKey.isEmpty) return; final globalKey = buildGlobalKey(ServerId(serverId), ratingKey); if (!ensuredKeys.add(globalKey)) return; final cached = await _lookupMetadata(ServerId(serverId), ratingKey, clientScopeId: clientScopeId); var metadata = cached; if (!_isOffline) { try { final fetched = await client.fetchItem(ratingKey); if (fetched != null) { metadata = mergeFetchedMediaItem(fallbackServerId: serverId, existing: cached, fetched: fetched); await ApiCache.forBackend(client.backend).pinForOffline(ServerId(client.cacheServerId), metadata.id); } } catch (e) { appLogger.d('Artwork repair parent metadata fetch failed for $globalKey', error: e); } } if (metadata == null) return; final withServer = _repairMetadataWithServer(metadata, ServerId(serverId)); await _artworkService.ensureArtworkForMetadata(withServer, client); } MediaItem _repairMetadataWithServer(MediaItem metadata, ServerId serverId) { return metadata.serverId == null ? metadata.copyWith(serverId: serverId) : metadata; } Future _backfillArtworkPath(DownloadedMediaItem row, MediaItem metadata) async { final thumbPath = metadata.thumbPath; if (thumbPath == null || thumbPath.isEmpty) return; final normalized = artworkStorageKey(thumbPath); if (row.thumbPath == normalized) return; await _database.updateArtworkPaths(globalKey: row.globalKey, thumbPath: normalized); if (_disposed) return; _progressController.add( DownloadProgress( globalKey: row.globalKey, status: DownloadStatus.values[row.status], progress: row.status == DownloadStatus.completed.index ? 100 : row.progress, downloadedBytes: row.downloadedBytes, totalBytes: row.totalBytes ?? 0, thumbPath: normalized, ), ); } /// Repairs completed videos whose retained queue row records unsettled /// supplementary work. Concurrent reconnects share one repair pass. Future repairPendingSupplementaryDownloads() { if (_disposed || _isOffline) return Future.value(); final activeRepair = _supplementaryRepairFuture; if (activeRepair != null) return activeRepair; final repair = _repairPendingSupplementaryDownloads(); _supplementaryRepairFuture = repair; return repair.whenComplete(() { if (identical(_supplementaryRepairFuture, repair)) { _supplementaryRepairFuture = null; } }); } Future _repairPendingSupplementaryDownloads() async { final List queueItems; try { queueItems = await _database.getPendingSupplementaryQueueItems(); } catch (e, st) { appLogger.w('Could not read pending supplementary downloads', error: e, stackTrace: st); return; } for (final queueItem in queueItems) { if (_disposed || _isOffline) return; final globalKey = queueItem.mediaGlobalKey; try { final client = await _getClientForDownloadKey(globalKey); if (client == null) { appLogger.d('Deferring supplementary download $globalKey: server offline'); continue; } final metadata = await _resolveMetadata(globalKey); if (metadata == null) { appLogger.w('No metadata for deferred supplementary download: $globalKey'); continue; } final record = await _database.getDownloadedMedia(globalKey); int? showYear; if (metadata.isEpisode && metadata.grandparentId != null && metadata.serverId != null) { showYear = await _fetchShowYear( ServerId(metadata.serverId!), metadata.grandparentId, clientScopeId: record?.clientScopeId, ); } final settled = await _runSupplementaryDownloads( globalKey, metadata, client, isRepair: true, downloadArtwork: queueItem.downloadArtwork, downloadSubtitles: queueItem.downloadSubtitles, record: record, showYear: showYear, ); if (settled.artwork && settled.subtitles) { await _database.removeFromQueue(globalKey); appLogger.i('Deferred supplementary downloads completed for $globalKey'); } else { await _database.updateSupplementaryQueueIntent( globalKey, downloadSubtitles: !settled.subtitles, downloadArtwork: !settled.artwork, ); } } catch (e, st) { appLogger.w('Deferred supplementary downloads failed for $globalKey', error: e, stackTrace: st); } } } /// Cancel any per-download timers (progress debounce + auto-retry) for [key]. /// Idempotent; safe to call from any terminal/pause path. void _cancelDownloadTimers(String key) { _progressDebounceTimers.remove(key)?.cancel(); _autoRetryTimers.remove(key)?.cancel(); } /// Delete a file if it exists and log the deletion /// Returns true if file was deleted, false otherwise Future _deleteFileIfExists(File file, String description) async { try { if (await file.exists()) { await file.delete(); appLogger.i('Deleted $description: ${file.path}'); return true; } } catch (e) { appLogger.w('Failed to delete $description: ${file.path}', error: e); } return false; } /// Delete a SAF file or directory. Missing targets are a silent no-op. Future _tryDeleteSaf(String uri, {required bool isDir, required String description}) async { final ok = await _safStorage.delete(uri, isDir: isDir); if (ok) appLogger.i('Deleted $description: $uri'); } /// Recursively delete a SAF directory — lists children in parallel, deletes /// leaves, recurses into subdirectories, then removes the dir itself. /// Manual recursion because DocumentsProvider-level recursion isn't guaranteed /// across providers. Future _deleteSafDirRecursive(String dirUri, {required String description}) async { final saf = _safStorage; final children = await saf.list(dirUri); if (children != null && children.isNotEmpty) { await Future.wait( children.map((child) { return child.isDir ? _deleteSafDirRecursive(child.uri, description: description) : saf.delete(child.uri, isDir: false); }), ); } await _tryDeleteSaf(dirUri, isDir: true, description: description); } /// Walk a chain of SAF directory URIs (deepest-first) and delete each that is empty. /// Stops at the first non-empty directory. Skips missing/null entries. Future _deleteEmptySafDirsInOrder(List dirUris) async { final saf = _safStorage; for (final uri in dirUris) { if (uri == null) break; if (!await saf.exists(uri, isDir: true)) continue; final children = await saf.list(uri); if (children == null || children.isNotEmpty) break; if (!await saf.delete(uri, isDir: true)) break; appLogger.i('Cleaned up empty SAF directory: $uri'); } } /// Find a SAF file in [dirUri] whose name (minus extension) matches [baseName]. Future _findSafFileByBaseName(String dirUri, String baseName) async { final children = await _safStorage.list(dirUri); if (children == null) return null; for (final child in children) { if (!child.isDir && path.basenameWithoutExtension(child.name) == baseName) return child; } return null; } /// Delete the canonical target file and any pre-existing numbered duplicates /// in [safDirUri] before re-enqueueing a SAF download. SAF DocumentsProviders /// auto-number on createDocument when a name conflict exists, which would /// otherwise produce "name (1).ext" / "name.ext (1)" corrupt duplicates on /// every app-level retry. Future _cleanupSafTargetFile(String safDirUri, String safFileName) async { final children = await _safStorage.list(safDirUri); if (children == null) return; // Match BOTH numbering schemes a DocumentsProvider may use: // "S02E11 - The Hunt (1).mkv" - inserted before extension (most providers) // "S02E11 - The Hunt.mkv (1)" - appended after full name (Downloads tree) final base = path.basenameWithoutExtension(safFileName); final ext = path.extension(safFileName); final dup = RegExp( '^${RegExp.escape(base)} \\(\\d+\\)${RegExp.escape(ext)}\$|' '^${RegExp.escape(safFileName)} \\(\\d+\\)\$', ); await Future.wait([ for (final child in children) if (!child.isDir && (child.name == safFileName || dup.hasMatch(child.name))) _tryDeleteSaf( child.uri, isDir: false, description: child.name == safFileName ? 'stale SAF video before re-download' : 'stale SAF numbered duplicate', ), ]); } Future queueDownload({ required MediaItem metadata, required MediaServerClient client, int priority = 0, bool downloadSubtitles = true, bool downloadArtwork = true, int mediaIndex = 0, }) async { if (_skipDownloadsUnsupported('queue download')) return; _resumeQueueAfterStorageFailure('new download'); final globalKey = metadata.globalKey; final outcome = await _database.insertQueuedDownload( serverId: ServerId(metadata.serverId!), clientScopeId: client.cacheServerId == metadata.serverId ? null : client.cacheServerId, ratingKey: metadata.id, globalKey: globalKey, type: metadata.kind.id, parentRatingKey: metadata.parentId, grandparentRatingKey: metadata.grandparentId, mediaIndex: mediaIndex, mediaSourceId: _mediaSourceIdForIndex(metadata, mediaIndex), priority: priority, downloadSubtitles: downloadSubtitles, downloadArtwork: downloadArtwork, ); if (outcome == QueueDownloadOutcome.unchanged) { appLogger.i('Download already active, paused, or completed for $globalKey'); return; } if (outcome == QueueDownloadOutcome.admitted) { // Metadata pinning is useful for offline preparation, but the durable // download request must remain executable if cache persistence fails. try { await _pinMetadataForOffline(client, metadata); } catch (e, st) { appLogger.w('Failed to pin metadata for queued download $globalKey', error: e, stackTrace: st); } } _emitProgress(globalKey, DownloadStatus.queued, 0); unawaited(_processQueue(client)); } String? _mediaSourceIdForIndex(MediaItem metadata, int mediaIndex) { final versions = metadata.mediaVersions; if (versions == null || mediaIndex < 0 || mediaIndex >= versions.length) return null; final id = versions[mediaIndex].id.trim(); return id.isEmpty ? null : id; } /// 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') || _queueBlockedByStorageFailure) return; final queueProcessorOverride = _queueProcessorOverride; if (queueProcessorOverride != null) { _fallbackClient = client; await queueProcessorOverride(client); return; } if (_isProcessingQueue) return; _isProcessingQueue = true; _fallbackClient = client; try { await (_fileDownloaderInitializerOverride?.call() ?? _initializeFileDownloader()); while (!_queueBlockedByStorageFailure) { if (_consecutiveQueueFailures >= _maxConsecutiveFailures) { appLogger.w('Circuit breaker: $_consecutiveQueueFailures consecutive failures, pausing queue'); break; } final nextItem = await _database.getNextQueueItem(); if (nextItem == null) break; // Resolve the correct client for the item's server/scope — skip if unavailable. final itemClient = await _getClientForDownloadKey(nextItem.mediaGlobalKey); if (itemClient == null) { appLogger.d('Skipping queued download ${nextItem.mediaGlobalKey}: server offline'); break; } final enqueued = await _prepareAndEnqueueDownload(nextItem.mediaGlobalKey, itemClient, nextItem); if (enqueued) { _consecutiveQueueFailures = 0; } else { _consecutiveQueueFailures++; } } } finally { _isProcessingQueue = false; } } /// Cancel any lingering background task and reset progress before re-enqueuing. Future _cleanupStaleDownload(String globalKey) async { final existingTaskId = await _database.getBgTaskId(globalKey); await _database.updateBgTaskId(globalKey, null); _pendingDownloadContext.remove(globalKey); await _cancelNativeTasksForGlobalKey( globalKey, includeTaskId: existingTaskId, reason: 'stale task before re-download', ); await _database.updateDownloadProgress(globalKey, 0, 0, 0); } Future _requeueDownload(String globalKey, {MediaServerClient? fallbackClient}) async { await _transitionStatus(globalKey, DownloadStatus.queued); await _database.addToQueue(mediaGlobalKey: globalKey); final client = await _getClientForDownloadKey(globalKey) ?? fallbackClient; if (client != null) unawaited(_processQueue(client)); } Future _cancelNativeTask(String globalKey, String taskId, {required String reason}) => _cancelNativeTaskIds(globalKey, [taskId], reason: reason); Future _cancelNativeTasksForGlobalKey( String globalKey, { String? includeTaskId, String? exceptTaskId, required String reason, }) async { if (!downloadsSupported) return; final taskIds = {}; if (includeTaskId != null && includeTaskId != exceptTaskId) taskIds.add(includeTaskId); if (!_fileDownloaderInitialized && taskIds.isEmpty) return; try { final nativeTasks = await FileDownloader().allTasks(group: _downloadGroup); for (final task in nativeTasks) { if (task.metaData == globalKey && task.taskId != exceptTaskId) taskIds.add(task.taskId); } } catch (e) { appLogger.w('Failed to enumerate native tasks for $globalKey ($reason)', error: e); } await _cancelNativeTaskIds(globalKey, taskIds, reason: reason); } Future _downloadForCurrentTaskSession( String globalKey, String taskId, { required String event, bool cancelStale = false, DownloadStatus? requiredStatus, }) async { final existing = await _database.getDownloadedMedia(globalKey); final currentTaskId = existing?.bgTaskId; final statusMatches = requiredStatus == null || existing?.status == requiredStatus.index; if (existing != null && currentTaskId == taskId && statusMatches) return existing; appLogger.d( 'Ignoring stale download $event for $globalKey from task $taskId ' '(current task: ${currentTaskId ?? 'none'}, status: ${existing?.status ?? 'none'})', ); if (cancelStale) { await _cancelNativeTask(globalKey, taskId, reason: 'stale $event'); } return null; } bool _isNativeTaskActiveStatus(TaskStatus status) { return status == TaskStatus.enqueued || status == TaskStatus.running || status == TaskStatus.waitingToRetry; } Future _isInactiveForEnqueue(String globalKey) async { if (_cancellingKeys.contains(globalKey)) return true; final existing = await _database.getDownloadedMedia(globalKey); return existing == null || existing.status == DownloadStatus.completed.index || existing.status == DownloadStatus.cancelled.index; } Future _isCancelledOrDeleted(String globalKey) async { if (_cancellingKeys.contains(globalKey)) return true; final existing = await _database.getDownloadedMedia(globalKey); return existing == null || existing.status == DownloadStatus.cancelled.index; } Future _cancelEnqueuedTaskIfInactive(String globalKey, String taskId) async { if (!await _isCancelledOrDeleted(globalKey)) return false; if (downloadsSupported) { await FileDownloader().cancelTaskWithId(taskId); } await _database.updateBgTaskId(globalKey, null); await _database.removeFromQueue(globalKey); _pendingDownloadContext.remove(globalKey); return true; } /// Hand a prepared task to the native downloader, recording its id first so a /// concurrent cancel can find it. Returns true if the download went inactive /// while enqueueing and the task was dropped again. Future _enqueuePreparedTask(String globalKey, Task task, String kind) async { await _database.updateBgTaskId(globalKey, task.taskId); final success = await FileDownloader().enqueue(task); if (!success) throw Exception('Failed to enqueue $kind task'); if (await _cancelEnqueuedTaskIfInactive(globalKey, task.taskId)) { return true; } appLogger.i('Enqueued $kind task ${task.taskId} for $globalKey'); return false; } /// Resolve metadata, video URL, and file path, then enqueue a background download task. /// Returns true if successfully enqueued, false if it failed immediately. Future _prepareAndEnqueueDownload( String globalKey, MediaServerClient client, DownloadQueueItem queueItem, ) async { if (_skipDownloadsUnsupported('download enqueue')) return false; if (_cancellingKeys.contains(globalKey)) return true; if (_queueBlockedByStorageFailure) return true; try { // Guard: don't re-enqueue an item that's already completed or was deleted final existing = await _database.getDownloadedMedia(globalKey); if (_cancellingKeys.contains(globalKey) || existing == null || existing.status == DownloadStatus.completed.index || existing.status == DownloadStatus.cancelled.index) { appLogger.d('Skipping enqueue for $globalKey: already completed or deleted'); await _database.removeFromQueue(globalKey); return true; } appLogger.i('Preparing download for $globalKey'); await _cleanupStaleDownload(globalKey); if (await _isInactiveForEnqueue(globalKey)) { appLogger.d('Skipping enqueue for $globalKey: inactive before transition'); await _database.removeFromQueue(globalKey); return true; } if (_queueBlockedByStorageFailure) return true; await _transitionStatus(globalKey, DownloadStatus.downloading); final parsed = parseGlobalKey(globalKey); if (parsed == null) throw Exception('Invalid globalKey: $globalKey'); final serverId = parsed.serverId; final ratingKey = parsed.ratingKey; MediaItem? metadata = await _lookupMetadata(serverId, ratingKey, clientScopeId: existing.clientScopeId); if (metadata == null) { // Cache miss — try re-fetching from server (cache may have been cleared between queue and prepare) appLogger.w('Cache miss for $globalKey, attempting network re-fetch'); try { final fetched = await client.fetchItem(ratingKey); if (fetched != null) metadata = fetched.copyWith(serverId: serverId); } catch (e) { appLogger.w('Network re-fetch failed for $globalKey', error: e); } if (metadata == null) { throw Exception('Metadata not found in cache and could not be fetched for $globalKey'); } } final selectedMediaIndex = existing.mediaIndex; var resolution = await client.resolveDownload( metadata, mediaIndex: selectedMediaIndex, mediaSourceId: existing.mediaSourceId, ); if (resolution.videoUrl == null) { // Cache miss for the per-version fields — refresh from network. appLogger.w('No video URL from cache for $globalKey, retrying via network'); final fetched = await client.fetchItem(ratingKey); if (fetched != null) metadata = fetched.copyWith(serverId: serverId); resolution = await client.resolveDownload( metadata, mediaIndex: selectedMediaIndex, mediaSourceId: existing.mediaSourceId, ); if (resolution.videoUrl == null) throw Exception('Could not get video URL for $globalKey'); } if (resolution.mediaSourceId != null && resolution.mediaSourceId != existing.mediaSourceId) { await _database.updateDownloadMediaSource(globalKey, resolution.mediaSourceId); } if (await _isCancelledOrDeleted(globalKey)) { appLogger.d('Skipping enqueue for $globalKey: cancelled during preparation'); await _database.removeFromQueue(globalKey); _pendingDownloadContext.remove(globalKey); return true; } final ext = downloadExtensionFromUrl(resolution.videoUrl!) ?? 'mp4'; // Look up show year for episodes final showYear = metadata.isEpisode ? await _fetchShowYear(serverId, metadata.grandparentId, clientScopeId: existing.clientScopeId) : null; // Build display name for notifications. Episodes lead with the show, // tracks with the artist — same "container - leaf" pattern. final trackArtist = metadata.trackArtistTitle; final displayName = metadata.isEpisode ? '${metadata.grandparentTitle ?? metadata.displayTitle} - ${metadata.displayTitle}' : metadata.kind == MediaKind.track && trackArtist != null && trackArtist.isNotEmpty ? '$trackArtist - ${metadata.displayTitle}' : metadata.displayTitle; // Get WiFi-only setting for native enforcement final settings = await SettingsService.getInstance(); final requiresWiFi = settings.read(SettingsService.downloadOnWifiOnly); final MediaItem resolvedMetadata = metadata; final becameInactive = await _serializeSafOwnership(() async { if (_queueBlockedByStorageFailure) return true; final metadata = resolvedMetadata; final safBaseUri = _storageService.safBaseUri; final DownloadTask task; final String filePath; final String? safRootUri; if (_storageService.isUsingSaf && safBaseUri != null) { final rootUri = await _safStorage.resolvePersistedPermissionUri(safBaseUri); if (rootUri == null) { throw StateError('Selected SAF root has no persisted permission'); } await _replaceDownloadSafRootClaim(globalKey, rootUri); // SAF mode: use UriDownloadTask (writes directly to content:// URI, // with no pause/resume support). final target = _storageService.safTarget(metadata, ext, showYear: showYear, serverId: serverId); final safDirUri = await _safStorage.createNestedDirectories(rootUri, target.components); if (safDirUri == null) { throw Exception('Failed to create SAF directory'); } await _cleanupSafTargetFile(safDirUri, target.fileName); task = UriDownloadTask( url: resolution.videoUrl!, filename: target.fileName, directoryUri: Uri.parse(safDirUri), group: _downloadGroup, updates: Updates.statusAndProgress, requiresWiFi: requiresWiFi, retries: _nativeRetries, allowPause: false, metaData: globalKey, displayName: displayName, ); filePath = safDirUri; safRootUri = rootUri; } else { await _replaceDownloadSafRootClaim(globalKey, null); // Normal mode: use DownloadTask with pause/resume support. final String downloadFilePath; if (metadata.isMovie) { downloadFilePath = await _storageService.getMovieVideoPath(metadata, ext); } else if (metadata.isEpisode) { downloadFilePath = await _storageService.getEpisodeVideoPath(metadata, ext, showYear: showYear); } else { downloadFilePath = await _storageService.getVideoFilePath(serverId, metadata.id, ext); } // Clean up partial files from previous attempts to prevent // background_downloader from creating numbered copies (File (1).mp4). await Future.wait([ _deleteFileIfExists(File(downloadFilePath), 'stale video before re-download'), _deleteFileIfExists(File('$downloadFilePath.part'), 'stale .part before re-download'), ]); await File(downloadFilePath).parent.create(recursive: true); final taskLocation = await _storageService.resolveTaskDirectory(downloadFilePath); task = DownloadTask( url: resolution.videoUrl!, filename: path.basename(downloadFilePath), directory: taskLocation.directory, baseDirectory: taskLocation.baseDirectory, group: _downloadGroup, updates: Updates.statusAndProgress, requiresWiFi: requiresWiFi, retries: _nativeRetries, allowPause: true, metaData: globalKey, displayName: displayName, ); filePath = downloadFilePath; safRootUri = null; } _pendingDownloadContext[globalKey] = _DownloadContext( metadata: metadata, queueItem: queueItem, filePath: filePath, extension: ext, client: client, showYear: showYear, isSafMode: safRootUri != null, safRootUri: safRootUri, subtitles: resolution.externalSubtitlesResolved ? resolution.externalSubtitles : null, ); return _enqueuePreparedTask(globalKey, task, safRootUri != null ? 'SAF download' : 'download'); }); if (becameInactive) return true; return true; } catch (e, st) { if (await _isCancelledOrDeleted(globalKey)) { appLogger.d('Ignoring enqueue failure for inactive download $globalKey', error: e); await _database.removeFromQueue(globalKey); _pendingDownloadContext.remove(globalKey); return true; } appLogger.e('Failed to prepare download for $globalKey', error: e, stackTrace: st); final existing = await _database.getDownloadedMedia(globalKey); if (_isRetryablePrepareFailure(e) && existing != null && existing.retryCount < _maxAppRetries && existing.status != DownloadStatus.completed.index && existing.status != DownloadStatus.cancelled.index) { await _scheduleDownloadRetry( globalKey, client, existing.retryCount, e.toString(), processQueueAfterProgress: false, ); } else { await _transitionStatus(globalKey, DownloadStatus.failed, errorMessage: e.toString()); await _database.removeFromQueue(globalKey); } _pendingDownloadContext.remove(globalKey); return false; } } /// Callback: background_downloader progress update void _onTaskProgress(TaskProgressUpdate update) { if (_disposed) return; unawaited( _handleTaskProgress(update).catchError((Object e, StackTrace st) { appLogger.e('Error handling download progress for ${update.task.metaData}', error: e, stackTrace: st); }), ); } @visibleForTesting Future debugHandleTaskProgress(TaskProgressUpdate update) => _handleTaskProgress(update); Future _handleTaskProgress(TaskProgressUpdate update) async { if (_disposed) return; final globalKey = update.task.metaData; if (globalKey.isEmpty || update.progress < 0) return; final existing = await _downloadForCurrentTaskSession( globalKey, update.task.taskId, event: 'progress', cancelStale: true, ); if (existing == null) return; if (existing.status != DownloadStatus.downloading.index) { appLogger.d('Ignoring progress for inactive download $globalKey from task ${update.task.taskId}'); await _cancelNativeTask(globalKey, update.task.taskId, reason: 'progress for inactive download'); return; } // If this item is being paused, the holding queue promoted it — cancel it if (_pausingKeys.contains(globalKey)) { await _cancelNativeTask(globalKey, update.task.taskId, reason: 'pause in progress'); return; } if (_cancellingKeys.contains(globalKey)) { await _cancelNativeTask(globalKey, update.task.taskId, reason: 'cancellation in progress'); return; } final progress = (update.progress * 100).round().clamp(0, 100); final speedBytesPerSec = update.hasNetworkSpeed ? update.networkSpeed * 1024 * 1024 : 0.0; final totalBytes = update.hasExpectedFileSize ? update.expectedFileSize : 0; final downloadedBytes = totalBytes > 0 ? (update.progress * totalBytes).round() : 0; _progressController.add( DownloadProgress( globalKey: globalKey, status: DownloadStatus.downloading, progress: progress, downloadedBytes: downloadedBytes, totalBytes: totalBytes, speed: speedBytesPerSec, currentFile: 'video', ), ); // Debounce DB writes — only the latest progress value is persisted after // a 2-second settle period. The stream above provides real-time UI updates; // the DB write is only for crash-recovery state. _progressDebounceTimers[globalKey]?.cancel(); _progressDebounceTimers[globalKey] = Timer(_progressDebounceDelay, () { _progressDebounceTimers.remove(globalKey); _database.updateDownloadProgress(globalKey, progress, downloadedBytes, totalBytes).catchError((e) { appLogger.w('Failed to update download progress in DB', error: e); }); }); } /// Callback: background_downloader status change void _onTaskStatusChanged(TaskStatusUpdate update) { if (_disposed) return; unawaited( _handleTaskStatusChanged(update).catchError((Object e, StackTrace st) { appLogger.e('Error handling download status for ${update.task.metaData}', error: e, stackTrace: st); }), ); } @visibleForTesting Future debugHandleTaskStatus(TaskStatusUpdate update) => _handleTaskStatusChanged(update); Future _handleTaskStatusChanged(TaskStatusUpdate update) async { if (_disposed) return; final globalKey = update.task.metaData; if (globalKey.isEmpty) return; appLogger.d('Background task status: ${update.status} for $globalKey (task ${update.task.taskId})'); final existing = await _downloadForCurrentTaskSession( globalKey, update.task.taskId, event: 'status ${update.status}', cancelStale: _isNativeTaskActiveStatus(update.status), requiredStatus: DownloadStatus.downloading, ); if (existing == null) return; try { switch (update.status) { case TaskStatus.complete: await _onDownloadComplete(globalKey, update.task); case TaskStatus.failed: await _onDownloadFailed(globalKey, update.task.taskId, update.exception); case TaskStatus.notFound: await _onDownloadPermanentlyFailed(globalKey, update.task.taskId, t.downloads.errorFileNotFound); case TaskStatus.canceled: if (_pausingKeys.contains(globalKey) || _cancellingKeys.contains(globalKey)) break; await _onDownloadCanceled(globalKey, update.task.taskId); case TaskStatus.paused: appLogger.d('Download paused by system for $globalKey'); case TaskStatus.waitingToRetry: appLogger.d('Download waiting to retry for $globalKey'); case TaskStatus.enqueued: case TaskStatus.running: // If this item is being paused, the holding queue promoted it — cancel it if (_pausingKeys.contains(globalKey)) { await _cancelNativeTask(globalKey, update.task.taskId, reason: 'pause in progress'); } if (_cancellingKeys.contains(globalKey)) { await _cancelNativeTask(globalKey, update.task.taskId, reason: 'cancellation in progress'); } break; } } catch (e) { appLogger.e('Error handling download status change for $globalKey', error: e); } } /// Handle a system-initiated cancel — re-queue unless already completed. Future _onDownloadCanceled(String globalKey, String taskId) async { if (_completingKeys.contains(globalKey)) return; final existing = await _downloadForCurrentTaskSession( globalKey, taskId, event: 'system cancellation', requiredStatus: DownloadStatus.downloading, ); if (existing == null) return; _cancelDownloadTimers(globalKey); _pendingDownloadContext.remove(globalKey); appLogger.w('Download cancelled by system for $globalKey, re-queuing'); await _database.updateBgTaskId(globalKey, null); await _requeueDownload(globalKey); } void _resumeQueueAfterStorageFailure(String operation) { if (!_queueBlockedByStorageFailure) return; appLogger.i('Resuming download queue after storage failure: $operation'); _queueBlockedByStorageFailure = false; _consecutiveQueueFailures = 0; } Future _handleStorageFullFailure(String globalKey, String taskId) async { _queueBlockedByStorageFailure = true; for (final timer in _autoRetryTimers.values) { timer.cancel(); } _autoRetryTimers.clear(); if (downloadsSupported) { try { await FileDownloader().database.deleteRecordWithId(taskId); } catch (error, stackTrace) { appLogger.w('Failed to discard partial download for $globalKey', error: error, stackTrace: stackTrace); } try { await FileDownloader().reset(group: _downloadGroup); } catch (error, stackTrace) { appLogger.w( 'Failed to stop native download queue after storage exhaustion', error: error, stackTrace: stackTrace, ); } } final errorMessage = t.downloads.storageFull; final failedKeys = await _database.failActiveDownloadsForStorageFull(errorMessage); for (final key in failedKeys) { _cancelDownloadTimers(key); _pendingDownloadContext.remove(key); _emitProgress(key, DownloadStatus.failed, 0, errorMessage: errorMessage); } appLogger.e('Device storage exhausted; stopped ${failedKeys.length} active download(s)'); } bool _isRetryablePrepareFailure(Object error) { if (error is! MediaServerHttpException || error.isCancellation) return false; final status = error.statusCode; if (status == 401 || status == 403) return false; return error.isTransient || status != null && status >= 500; } Future _scheduleDownloadRetry( String globalKey, MediaServerClient client, int retryCount, String errorMessage, { required bool processQueueAfterProgress, }) async { appLogger.w( 'Download failed for $globalKey (attempt ${retryCount + 1}/$_maxAppRetries), ' 'scheduling auto-retry in ${_autoRetryDelay.inSeconds}s: $errorMessage', ); await _transitionStatus(globalKey, DownloadStatus.failed, errorMessage: errorMessage); await _database.removeFromQueue(globalKey); _autoRetryTimers.remove(globalKey)?.cancel(); _autoRetryTimers[globalKey] = Timer(_autoRetryDelay, () { _autoRetryTimers.remove(globalKey); unawaited(_performAutoRetry(globalKey)); }); if (processQueueAfterProgress) unawaited(_processQueue(client)); } bool _isStorageFullDownloadFailure(TaskException? exception) { return exception != null && isStorageFullMessage(exception.description); } /// Handle a failed download — stop the queue on storage exhaustion, /// otherwise auto-retry if retries remain. Future _onDownloadFailed(String globalKey, String taskId, TaskException? exception) async { if (_cancellingKeys.contains(globalKey)) { appLogger.d('Ignoring failure for $globalKey: cancellation in progress'); return; } if (_completingKeys.contains(globalKey)) { appLogger.d('Ignoring failure event for $globalKey: completion in progress'); return; } final existing = await _downloadForCurrentTaskSession( globalKey, taskId, event: 'failure', requiredStatus: DownloadStatus.downloading, ); if (existing == null) return; _cancelDownloadTimers(globalKey); _pendingDownloadContext.remove(globalKey); if (_isStorageFullDownloadFailure(exception)) { await _handleStorageFullFailure(globalKey, taskId); return; } final errorMessage = exception?.description ?? t.downloads.errorDownloadFailed; final retryCount = existing.retryCount; // DNS/connection errors fail instantly and exhaust native retries in milliseconds, // creating a retry storm. Treat them as permanent failures. final isNetworkError = errorMessage.contains('Unable to resolve host') || errorMessage.contains('No address associated with hostname') || errorMessage.contains('Network is unreachable') || errorMessage.contains('Connection refused'); final isServerError = errorMessage.contains('500 Internal Server Error'); final client = await _getClientForDownloadKey(globalKey); final hadProgress = existing.downloadedBytes > 0; if (!isNetworkError && !isServerError && retryCount < _maxAppRetries && client != null) { // App-level auto-retry: schedule a fresh download after a delay. // Each new task gets 5 native retries with Range-based resume. await _scheduleDownloadRetry(globalKey, client, retryCount, errorMessage, processQueueAfterProgress: hadProgress); } else { if (isNetworkError) { appLogger.w('Network error for $globalKey, failing permanently (no auto-retry): $errorMessage'); } final userMessage = isServerError ? t.downloads.serverErrorBitrate : errorMessage; await _onDownloadPermanentlyFailed(globalKey, taskId, userMessage); } } /// Handle a non-retryable failure (e.g. 404) — fail immediately without auto-retry. Future _onDownloadPermanentlyFailed(String globalKey, String taskId, String errorMessage) async { if (_cancellingKeys.contains(globalKey)) { appLogger.d('Ignoring permanent failure for $globalKey: cancellation in progress'); return; } if (_completingKeys.contains(globalKey)) { appLogger.d('Ignoring permanent failure event for $globalKey: completion in progress'); return; } final existing = await _downloadForCurrentTaskSession( globalKey, taskId, event: 'permanent failure', requiredStatus: DownloadStatus.downloading, ); if (existing == null) return; _cancelDownloadTimers(globalKey); _pendingDownloadContext.remove(globalKey); appLogger.e('Download permanently failed for $globalKey: $errorMessage'); await _transitionStatus(globalKey, DownloadStatus.failed, errorMessage: errorMessage); await _database.removeFromQueue(globalKey); // Try to enqueue more items from the queue final client = await _getClientForDownloadKey(globalKey); if (client != null) unawaited(_processQueue(client)); } /// Execute an app-level auto-retry: transition back to queued and re-enqueue. Future _performAutoRetry(String globalKey) async { if (_disposed) return; final client = await _getClientForDownloadKey(globalKey); if (client == null) { appLogger.w('Cannot auto-retry $globalKey: no client available'); return; } final existing = await _database.getDownloadedMedia(globalKey); if (existing == null || existing.status != DownloadStatus.failed.index) { // Download was cancelled/deleted/retried by user during the delay return; } appLogger.i('Auto-retrying download for $globalKey'); await _cleanupStaleDownload(globalKey); // The retry delay already throttles preparation failures; do not let the // immediate queue circuit breaker suppress the next scheduled attempt. _consecutiveQueueFailures = 0; await _requeueDownload(globalKey, fallbackClient: client); } /// Handle a completed video download — store path, download supplementary content, mark done. Future _onDownloadComplete(String globalKey, Task task) async { _consecutiveQueueFailures = 0; // Prevent duplicate concurrent completions (e.g. trackTasks replaying events) if (_completingKeys.contains(globalKey)) { appLogger.d('Already processing completion for $globalKey, skipping'); return; } _completingKeys.add(globalKey); try { // Fresh DB check — bail if already completed (guards against race with orphan scan) final existingCheck = await _downloadForCurrentTaskSession( globalKey, task.taskId, event: 'completion', requiredStatus: DownloadStatus.downloading, ); if (_cancellingKeys.contains(globalKey) || existingCheck == null) { appLogger.d('Download no longer active for $globalKey, skipping completion'); return; } // Flush any pending debounced progress write + cancel any scheduled retry. _cancelDownloadTimers(globalKey); final ctx = _pendingDownloadContext.remove(globalKey); // ── Phase 1 (critical): resolve and store the video file path ── final String storedPath; if (ctx != null) { // Happy path: context available from this session if (ctx.isSafMode) { // UriDownloadTask wrote directly to SAF — find the file URI final child = await _safStorage.getChild(ctx.filePath, [task.filename]); if (child != null) { storedPath = child.uri; } else { final safRootUri = ctx.safRootUri; if (safRootUri == null) { throw StateError('SAF download context has no root claim'); } storedPath = await _resolveSafStoredPath(ctx.metadata, ctx.extension, ctx.showYear, safRootUri) ?? ''; if (storedPath.isEmpty) { throw Exception('Cannot determine SAF file URI'); } } } else { storedPath = await _storageService.toRelativePath(ctx.filePath); } } else { // Recovery path: context missing (app was restarted) final existing = await _database.getDownloadedMedia(globalKey); if (existing?.videoFilePath != null && existing?.status == DownloadStatus.completed.index) { appLogger.d('Download already completed for $globalKey'); return; } if (existing?.videoFilePath != null) { // Video path set but status not completed — just finish up storedPath = existing!.videoFilePath!; } else if (task is UriDownloadTask) { // SAF mode recovery: restore the row's originating root before // resolving any path. The selected root may have changed. var safRootUri = existing?.safRootUri; safRootUri ??= await _safStorage.resolvePersistedPermissionUri(task.directoryUri.toString()); if (safRootUri == null) { throw StateError('Cannot recover SAF root ownership'); } await _serializeSafOwnership(() => _replaceDownloadSafRootClaim(globalKey, safRootUri)); final directChild = await _safStorage.getChild(task.directoryUri.toString(), [task.filename]); if (directChild != null) { storedPath = directChild.uri; } else { final parsed = parseGlobalKey(globalKey); if (parsed == null) { throw Exception('Invalid globalKey for recovery: $globalKey'); } final metadata = await _lookupMetadata( parsed.serverId, parsed.ratingKey, clientScopeId: existing?.clientScopeId, ); if (metadata == null) { throw Exception('No metadata for SAF recovery of $globalKey'); } final ext = downloadExtensionFromUrl(task.url) ?? 'mp4'; storedPath = await _resolveSafStoredPathForRecovery( metadata, ext, safRootUri, clientScopeId: existing?.clientScopeId, ) ?? ''; if (storedPath.isEmpty) { throw Exception('Cannot resolve SAF path on recovery'); } } } else { // Normal mode recovery: reconstruct from task storedPath = await _storageService.toRelativePath('${task.directory}/${task.filename}'); } } await _database.updateVideoFilePath(globalKey, storedPath); appLogger.d('Video download completed for $globalKey'); // ── Phase 2 (best-effort): supplementary downloads ── final persistedQueueItem = await (_database.select( _database.downloadQueue, )..where((t) => t.mediaGlobalKey.equals(globalKey))).getSingleOrNull(); final queueItem = ctx?.queueItem ?? persistedQueueItem; final downloadArtwork = queueItem?.downloadArtwork ?? true; final downloadSubtitles = queueItem?.downloadSubtitles ?? true; var artworkSettled = !downloadArtwork; var subtitlesSettled = !downloadSubtitles; try { final metadata = ctx?.metadata ?? await _resolveMetadata(globalKey); final client = ctx?.client ?? await _getClientForDownloadKey(globalKey); if (metadata != null && client != null) { final settled = await _runSupplementaryDownloads( globalKey, metadata, client, isRepair: false, downloadArtwork: downloadArtwork, downloadSubtitles: downloadSubtitles, record: existingCheck, showYear: ctx?.showYear, preresolvedSubtitles: ctx?.subtitles, ); artworkSettled = settled.artwork; subtitlesSettled = settled.subtitles; } } catch (e, st) { appLogger.w('Supplementary downloads failed for $globalKey (video is saved)', error: e, stackTrace: st); } // The primary video is terminal independently of supplementary outcome. await _transitionStatus(globalKey, DownloadStatus.completed); try { if (artworkSettled && subtitlesSettled) { await _database.removeFromQueue(globalKey); } else if (persistedQueueItem != null) { await _database.updateSupplementaryQueueIntent( globalKey, downloadSubtitles: !subtitlesSettled, downloadArtwork: !artworkSettled, ); } else { await _database.addToQueue( mediaGlobalKey: globalKey, priority: queueItem?.priority ?? 0, downloadSubtitles: !subtitlesSettled, downloadArtwork: !artworkSettled, ); } } catch (e, st) { appLogger.e('Failed to settle supplementary queue state for $globalKey', error: e, stackTrace: st); } appLogger.i('Download completed for $globalKey'); } catch (e) { appLogger.e('Post-download processing failed for $globalKey', error: e); await _transitionStatus( globalKey, DownloadStatus.failed, errorMessage: t.downloads.errorPostProcessing(error: e), ); await _database.removeFromQueue(globalKey); } finally { _completingKeys.remove(globalKey); // Always advance the queue, even after errors final nextClient = await _getClientForDownloadKey(globalKey); if (nextClient != null) unawaited(_processQueue(nextClient)); } } Future _resolveMetadata(String globalKey) async { final parsed = parseGlobalKey(globalKey); if (parsed == null) return null; final record = await _database.getDownloadedMedia(globalKey); return _lookupMetadata(parsed.serverId, parsed.ratingKey, clientScopeId: record?.clientScopeId); } /// Look up the year of the parent show for an episode (used for folder naming). Future _fetchShowYear(ServerId serverId, String? grandparentRatingKey, {String? clientScopeId}) async { if (grandparentRatingKey == null) return null; return (await _lookupMetadata(serverId, grandparentRatingKey, clientScopeId: clientScopeId))?.year; } Future _resolveSafStoredPath(MediaItem metadata, String ext, int? showYear, String safRootUri) async { final target = _storageService.safTarget(metadata, ext, showYear: showYear, serverId: metadata.serverId); final dirUri = await _safStorage.createNestedDirectories(safRootUri, target.components); if (dirUri == null) return null; final child = await _safStorage.getChild(dirUri, [target.fileName]); return child?.uri; } @visibleForTesting Future debugResolveSafRecoveryShowYear(MediaItem metadata, {String? clientScopeId}) { return _resolveSafRecoveryShowYear(metadata, clientScopeId: clientScopeId); } Future _resolveSafStoredPathForRecovery( MediaItem metadata, String ext, String safRootUri, { String? clientScopeId, }) async { final showYear = await _resolveSafRecoveryShowYear(metadata, clientScopeId: clientScopeId); return await _resolveSafStoredPath(metadata, ext, showYear, safRootUri) ?? (showYear == null ? null : await _resolveSafStoredPath(metadata, ext, null, safRootUri)); } Future _resolveSafRecoveryShowYear(MediaItem metadata, {String? clientScopeId}) { final serverId = metadata.serverId; if (!metadata.isEpisode || serverId == null) return Future.value(); return _fetchShowYear(ServerId(serverId), metadata.grandparentId, clientScopeId: clientScopeId); } /// Best-effort supplementary work for an already-stored video (artwork, /// chapter thumbnails, external subtitles); reports which half settled so the /// caller can do its own queue-row bookkeeping. Shared by the completion path /// and the deferred-repair path: [record] carries the media-source /// coordinates for re-resolving subtitles, [preresolvedSubtitles] skips that /// re-resolve, and [showYear] is caller-supplied because the paths differ. Future<({bool artwork, bool subtitles})> _runSupplementaryDownloads( String globalKey, MediaItem metadata, MediaServerClient client, { required bool isRepair, required bool downloadArtwork, required bool downloadSubtitles, required DownloadedMediaItem? record, required int? showYear, List? preresolvedSubtitles, }) async { var artworkSettled = !downloadArtwork; if (downloadArtwork) { final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client, isRepair: isRepair); final chapterArtworkSettled = metadata.serverId == null ? false : await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client); artworkSettled = itemArtworkSettled && chapterArtworkSettled; } var subtitlesSettled = !downloadSubtitles; if (downloadSubtitles) { try { var subtitles = preresolvedSubtitles; if (subtitles == null) { final resolution = await client.resolveDownload( metadata, mediaIndex: record?.mediaIndex ?? 0, mediaSourceId: record?.mediaSourceId, ); if (resolution.externalSubtitlesResolved) { subtitles = resolution.externalSubtitles; } else { appLogger.d('Subtitle enrichment remains deferred for $globalKey'); } } if (subtitles != null) { subtitlesSettled = await _downloadSubtitles( globalKey, metadata, subtitles, client, isRepair: isRepair, showYear: showYear, ); } } catch (e, st) { appLogger.w('Could not resolve subtitles for $globalKey', error: e, stackTrace: st); } } return (artwork: artworkSettled, subtitles: subtitlesSettled); } Future _downloadArtwork( String globalKey, MediaItem metadata, MediaServerClient client, { required bool isRepair, }) async { if (metadata.serverId == null) return false; try { if (!isRepair) { _emitProgress(globalKey, DownloadStatus.downloading, 0, currentFile: 'artwork'); } final serverId = metadata.serverId!; final specs = client.resolveDownloadArtwork(metadata); final artworkSettled = await _artworkService.ensureArtworkSpecs(ServerId(serverId), specs); final storedThumbPath = metadata.thumbPath == null ? null : artworkStorageKey(metadata.thumbPath!); await _database.updateArtworkPaths(globalKey: globalKey, thumbPath: storedThumbPath); _emitProgressWithArtwork(globalKey, isRepair: isRepair, thumbPath: storedThumbPath); appLogger.d(artworkSettled ? 'Artwork downloaded for $globalKey' : 'Artwork remains incomplete for $globalKey'); return artworkSettled; } catch (e, st) { appLogger.w('Failed to download artwork for $globalKey', error: e, stackTrace: st); return false; } } /// Download a single artwork blob if not already on disk. The [spec] carries /// both the storage key (used to hash the local filename) and the absolute /// URL to fetch. Future _downloadSingleArtwork(ServerId serverId, DownloadArtworkSpec spec) { return _artworkService.downloadSingleArtwork(serverId, spec); } /// Download all artwork for a metadata item (public method for parent metadata) /// Downloads thumb/poster, clearLogo, and background art Future downloadArtworkForMetadata(MediaItem metadata, MediaServerClient client) async { if (metadata.serverId == null) return; final serverId = metadata.serverId!; await _artworkService.ensureArtworkSpecs(ServerId(serverId), client.resolveDownloadArtwork(metadata)); } /// Download chapter thumbnail images for a media item. Future _downloadChapterThumbnails(ServerId serverId, String ratingKey, MediaServerClient client) async { try { final extras = await client.fetchPlaybackExtras(ratingKey); var allSettled = true; var downloadedCount = 0; for (final chapter in extras.chapters) { final thumb = chapter.thumb; if (thumb == null || thumb.isEmpty) continue; final url = client.thumbnailUrl(thumb); if (url.isEmpty) { allSettled = false; continue; } final settled = await _downloadSingleArtwork(serverId, DownloadArtworkSpec(localKey: thumb, url: url)); if (settled) { downloadedCount++; } else { allSettled = false; } } if (extras.chapters.isNotEmpty) { appLogger.d('Downloaded $downloadedCount/${extras.chapters.length} chapter thumbnails'); } return allSettled; } catch (e, st) { appLogger.w('Failed to download chapter thumbnails', error: e, stackTrace: st); return false; } } /// [showYear]: For episodes, pass the show's premiere year (not the episode's year) Future _downloadSubtitles( String globalKey, MediaItem metadata, List subtitles, MediaServerClient client, { required bool isRepair, int? showYear, }) async { if (!isRepair) { _emitProgress(globalKey, DownloadStatus.downloading, 0, currentFile: 'subtitles'); } var allSettled = true; for (final subtitle in subtitles) { try { final extension = CodecUtils.getSubtitleExtension(subtitle.codec); final String subtitlePath; if (_storageService.isUsingSaf) { subtitlePath = await _storageService.getSubtitlePath( ServerId(metadata.serverId!), metadata.id, subtitle.id, extension, ); } else if (metadata.isEpisode) { subtitlePath = await _storageService.getEpisodeSubtitlePath( metadata, subtitle.id, extension, showYear: showYear, ); } else if (metadata.isMovie) { subtitlePath = await _storageService.getMovieSubtitlePath(metadata, subtitle.id, extension); } else { subtitlePath = await _storageService.getSubtitlePath( ServerId(metadata.serverId!), metadata.id, subtitle.id, extension, ); } final file = File(subtitlePath); if (await file.exists()) { appLogger.d('Subtitle ${subtitle.id} already exists for $globalKey'); continue; } await file.parent.create(recursive: true); await _http.downloadFile(subtitle.url, subtitlePath); appLogger.d('Downloaded subtitle ${subtitle.id} for $globalKey'); } catch (e, st) { allSettled = false; appLogger.w('Failed to download subtitle ${subtitle.id} for $globalKey', error: e, stackTrace: st); } } return allSettled; } void _emitProgress( String globalKey, DownloadStatus status, int progress, { String? errorMessage, String? currentFile, }) { if (_disposed) return; _progressController.add( DownloadProgress( globalKey: globalKey, status: status, progress: progress, errorMessage: errorMessage, currentFile: currentFile, ), ); } /// Update download status in database and emit progress notification. /// /// This helper combines two common operations: /// 1. Update status in the database /// 2. Emit progress to listeners /// /// Default progress is 0 for most statuses, 100 for completed. Future _transitionStatus(String globalKey, DownloadStatus status, {int? progress, String? errorMessage}) async { await _database.updateDownloadStatus(globalKey, status.index); if (status == DownloadStatus.failed && errorMessage != null) { await _database.updateDownloadError(globalKey, errorMessage); } _emitProgress( globalKey, status, progress ?? (status == DownloadStatus.completed ? 100 : 0), errorMessage: errorMessage, ); } /// Emit progress update with artwork paths so DownloadProvider can sync void _emitProgressWithArtwork(String globalKey, {required bool isRepair, String? thumbPath}) { if (_disposed) return; _progressController.add( DownloadProgress( globalKey: globalKey, status: isRepair ? DownloadStatus.completed : DownloadStatus.downloading, progress: isRepair ? 100 : 0, currentFile: 'artwork', thumbPath: thumbPath, ), ); } /// Pause a download (works for both downloading and queued items) Future pauseDownload(String globalKey) async { // Mark as pausing synchronously so callbacks from holding-queue promotions // can detect and cancel promoted tasks before any await yields. _pausingKeys.add(globalKey); try { _cancelDownloadTimers(globalKey); final bgTaskId = await _database.getBgTaskId(globalKey); await _cancelNativeTasksForGlobalKey(globalKey, exceptTaskId: bgTaskId, reason: 'duplicate task before pause'); if (bgTaskId != null && downloadsSupported) { final task = await FileDownloader().taskForId(bgTaskId); if (task != null && task is DownloadTask) { // Normal mode: native pause support await FileDownloader().pause(task); } else { // 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); await _database.removeFromQueue(globalKey); } finally { _pausingKeys.remove(globalKey); } } /// Resume a paused download Future resumeDownload(String globalKey, MediaServerClient client) async { if (_skipDownloadsUnsupported('download resume')) return; _resumeQueueAfterStorageFailure('manual resume'); final bgTaskId = await _database.getBgTaskId(globalKey); // Try native resume first (only works for normal-mode DownloadTask that was paused) if (bgTaskId != null && await _tryResumeNativeTask(globalKey, bgTaskId)) return; // Native resume failed or not supported (SAF mode) — re-enqueue from scratch await _cleanupStaleDownload(globalKey); await _requeueDownload(globalKey, fallbackClient: client); } Future _tryResumeNativeTask( String globalKey, String bgTaskId, { _NativeTaskForId? taskForId, _NativeResumeTask? resumeTask, }) async { await _cancelNativeTasksForGlobalKey(globalKey, exceptTaskId: bgTaskId, reason: 'duplicate task before resume'); try { final task = await (taskForId ?? FileDownloader().taskForId)(bgTaskId); if (task == null || task is! DownloadTask) return false; final resumed = await (resumeTask ?? FileDownloader().resume)(task); if (!resumed) { appLogger.w('Native resume returned false for $globalKey; re-enqueuing from scratch'); return false; } await _transitionStatus(globalKey, DownloadStatus.downloading); appLogger.i('Resumed download via background_downloader for $globalKey'); return true; } catch (e) { appLogger.w('Native resume failed for $globalKey; re-enqueuing from scratch', error: e); return false; } } @visibleForTesting Future debugTryResumeNativeTask( String globalKey, String bgTaskId, { required Future Function(String taskId) taskForId, required Future Function(DownloadTask task) resumeTask, }) { return _tryResumeNativeTask(globalKey, bgTaskId, taskForId: taskForId, resumeTask: resumeTask); } /// Retry a failed download Future retryDownload(String globalKey, MediaServerClient client) async { if (_skipDownloadsUnsupported('download retry')) return; _resumeQueueAfterStorageFailure('manual retry'); _autoRetryTimers.remove(globalKey)?.cancel(); await _cleanupStaleDownload(globalKey); await _database.clearDownloadError(globalKey); await _requeueDownload(globalKey, fallbackClient: client); } /// Cancel a download Future cancelDownload(String globalKey) async { _cancellingKeys.add(globalKey); try { _cancelDownloadTimers(globalKey); final bgTaskId = await _database.getBgTaskId(globalKey); await _database.updateBgTaskId(globalKey, null); await _cancelNativeTasksForGlobalKey(globalKey, includeTaskId: bgTaskId, reason: 'user cancellation'); _pendingDownloadContext.remove(globalKey); await _transitionStatus(globalKey, DownloadStatus.cancelled); await _database.removeFromQueue(globalKey); } finally { _cancellingKeys.remove(globalKey); } } /// Cancels native work before removing the durable row and reconciling its /// persisted SAF grant. Future cancelAndRemoveDownload(String globalKey) async { await cancelDownload(globalKey); await _deleteDownloadRowAndRelease(globalKey); } Future deleteDownload(String globalKey) async { _cancellingKeys.add(globalKey); try { _cancelDownloadTimers(globalKey); final bgTaskId = await _database.getBgTaskId(globalKey); await _database.updateBgTaskId(globalKey, null); await _cancelNativeTasksForGlobalKey(globalKey, includeTaskId: bgTaskId, reason: 'delete download'); _pendingDownloadContext.remove(globalKey); final parsed = parseGlobalKey(globalKey); if (parsed == null) { await _deleteDownloadRowAndRelease(globalKey); return; } final serverId = parsed.serverId; final ratingKey = parsed.ratingKey; final downloadRecord = await _database.getDownloadedMedia(globalKey); final clientScopeId = downloadRecord?.clientScopeId; final metadata = await _lookupMetadata(serverId, ratingKey, clientScopeId: clientScopeId); if (metadata == null) { // Fallback deletion without progress await _deleteMediaFilesWithMetadata(serverId, ratingKey, clientScopeId: clientScopeId); await _deleteForItemByServer(serverId, ratingKey, clientScopeId: clientScopeId); await _deleteDownloadRowAndRelease(globalKey); return; } final totalItems = await _getTotalItemsToDelete(metadata, serverId, clientScopeId: clientScopeId); _emitDeletionProgress( DeletionProgress( globalKey: globalKey, itemTitle: metadata.displayTitle, currentItem: 0, totalItems: totalItems, ), ); await _deleteMediaFilesWithMetadata(serverId, ratingKey, clientScopeId: clientScopeId); await _deleteForItemByServer(serverId, ratingKey, clientScopeId: clientScopeId); await _deleteDownloadRowAndRelease(globalKey); _emitDeletionProgress( DeletionProgress( globalKey: globalKey, itemTitle: metadata.displayTitle, currentItem: totalItems, totalItems: totalItems, ), ); } finally { _cancellingKeys.remove(globalKey); } } void _emitDeletionProgress(DeletionProgress progress) { if (_disposed) return; _deletionProgressController.add(progress); } /// Calculate total items to delete (for progress tracking) Future _getTotalItemsToDelete(MediaItem metadata, ServerId serverId, {String? clientScopeId}) async { switch (metadata.kind) { case MediaKind.episode: case MediaKind.movie: return 1; case MediaKind.season: final episodes = await _database.getEpisodesBySeason(metadata.id, serverId: serverId); return episodes.length; case MediaKind.show: final episodes = await _database.getEpisodesByShow(metadata.id, serverId: serverId); return episodes.length; case MediaKind.album: final tracks = await _database.getTracksByAlbum(metadata.id, serverId: serverId); return tracks.length; case MediaKind.artist: final tracks = await _database.getTracksByArtist(metadata.id, serverId: serverId); return tracks.length; default: return 1; } } Future _deleteMediaFilesWithMetadata(ServerId serverId, String ratingKey, {String? clientScopeId}) async { try { final gk = buildGlobalKey(ServerId(serverId), ratingKey); final downloadRecord = await _database.getDownloadedMedia(gk); final scopeId = clientScopeId ?? downloadRecord?.clientScopeId; final metadata = await _lookupMetadata(serverId, ratingKey, clientScopeId: scopeId); if (metadata == null) { // Fallback: Try database record if (downloadRecord?.videoFilePath != null) { await _deleteByFilePath(downloadRecord!); return; } appLogger.w('Cannot delete - no metadata for $gk'); return; } switch (metadata.kind) { case MediaKind.episode: await _deleteEpisodeFiles(metadata, serverId, clientScopeId: scopeId); break; case MediaKind.season: await _deleteSeasonFiles(metadata, serverId, clientScopeId: scopeId); break; case MediaKind.show: await _deleteShowFiles(metadata, serverId, clientScopeId: scopeId); break; case MediaKind.movie: await _deleteMovieFiles(metadata, serverId, clientScopeId: scopeId); break; // Tracks live in the generic downloads/{serverId}/{ratingKey}/ layout // (both file and SAF mode), so deletion is DB-record-driven rather // than storage-template-driven like movies/episodes. case MediaKind.track: if (downloadRecord != null) await _deleteTrackByRecord(downloadRecord); break; case MediaKind.album: await _deleteTracksInContainer( tracks: await _database.getTracksByAlbum(metadata.id, serverId: serverId), serverId: serverId, clientScopeId: scopeId, containerKey: metadata.id, containerTitle: metadata.displayTitle, ); break; case MediaKind.artist: await _deleteTracksInContainer( tracks: await _database.getTracksByArtist(metadata.id, serverId: serverId), serverId: serverId, clientScopeId: scopeId, containerKey: metadata.id, containerTitle: metadata.displayTitle, ); break; default: appLogger.w('Unknown type for deletion: ${metadata.kind.id}'); } } catch (e, stack) { appLogger.e('Error deleting files', error: e, stackTrace: stack); } } /// Get chapter thumb paths from cached metadata. Backend-aware: routes /// through the resolved [MediaServerClient] so Jellyfin items return /// their `/Items/.../Images/Chapter/...?tag=...` paths and Plex items /// return their `/library/parts/.../indexes/sd/...` paths. Both shapes /// hash through [DownloadStorageService] the same way. /// /// `fetchPlaybackExtras` consults each backend's cache first, so this /// stays cheap during deletion (no network round-trip when the metadata /// is already cached, which it always is for downloaded items). Future> _getChapterThumbPaths(ServerId serverId, String ratingKey, {String? clientScopeId}) async { try { final client = _getClient(serverId, clientScopeId: clientScopeId); if (client == null) return []; final extras = await client.fetchPlaybackExtras(ratingKey); return extras.chapters .map((ch) => ch.thumb) .where((thumb) => thumb != null && thumb.isNotEmpty) .cast() .toList(); } catch (e) { appLogger.w('Error getting chapter thumb paths for $ratingKey', error: e); return []; } } /// Delete chapter thumbnails for a media item (with reference counting). /// /// Pre-loads all chapter paths for other items on the same server in one pass, /// then checks membership in a Set — O(items * chapters) instead of /// O(thumbs * items * chapters) with repeated DB queries. Future _deleteChapterThumbnails(ServerId serverId, String ratingKey, {String? clientScopeId}) async { try { final record = await _database.getDownloadedMedia(buildGlobalKey(ServerId(serverId), ratingKey)); final scopeId = clientScopeId ?? record?.clientScopeId; final thumbPaths = await _getChapterThumbPaths(serverId, ratingKey, clientScopeId: scopeId); if (thumbPaths.isEmpty) { appLogger.d('No chapter thumbnails to delete for $ratingKey'); return; } final otherItems = await _database.getDownloadsByServerId(serverId); final inUseThumbPaths = {}; for (final item in otherItems) { if (item.ratingKey == ratingKey) continue; final itemChapterPaths = await _getChapterThumbPaths( serverId, item.ratingKey, clientScopeId: item.clientScopeId, ); inUseThumbPaths.addAll(itemChapterPaths); } int deletedCount = 0; int preservedCount = 0; for (final thumbPath in thumbPaths) { try { if (inUseThumbPaths.contains(thumbPath)) { appLogger.d('Preserving chapter thumbnail (in use): $thumbPath'); preservedCount++; continue; } final artworkPath = await _storageService.getArtworkPathFromThumb(serverId, thumbPath); if (await _deleteFileIfExists(File(artworkPath), 'chapter thumbnail')) { deletedCount++; } } catch (e) { appLogger.w('Failed to delete chapter thumbnail: $thumbPath', error: e); } } if (deletedCount > 0 || preservedCount > 0) { appLogger.i('Deleted $deletedCount of ${thumbPaths.length} chapter thumbnails ($preservedCount preserved)'); } } catch (e, stack) { appLogger.w('Error deleting chapter thumbnails for $ratingKey', error: e, stackTrace: stack); } } Future _deleteEpisodeFiles( MediaItem episode, ServerId serverId, { String? clientScopeId, bool skipStorageVideoAndParents = false, }) async { try { final parentMetadata = episode.grandparentId != null ? await _lookupMetadata(serverId, episode.grandparentId!, clientScopeId: clientScopeId) : null; final showYear = parentMetadata?.year; final storageDeletion = await _deleteEpisodeStorageVideo( episode, showYear: showYear, skipVideo: skipStorageVideoAndParents, ); final thumbPath = await _storageService.getEpisodeThumbnailPath(episode, showYear: showYear); await _deleteFileIfExists(File(thumbPath), 'episode thumbnail'); final subsDir = await _storageService.getEpisodeSubtitlesDirectory(episode, showYear: showYear); if (await subsDir.exists()) { await subsDir.delete(recursive: true); appLogger.i('Deleted episode subtitles: ${subsDir.path}'); } await _deleteChapterThumbnails(serverId, episode.id, clientScopeId: clientScopeId); if (!skipStorageVideoAndParents) { await _cleanupEpisodeStorageParents(episode, showYear, storageDeletion); // Safety net: verify the actual DB-recorded file is gone. await _ensureDbFileDeleted(serverId, episode.id); } } catch (e, stack) { final storageLabel = _storageService.isUsingSaf ? 'SAF ' : ''; appLogger.e('Error deleting ${storageLabel}episode files', error: e, stackTrace: stack); } } Future _deleteSeasonFiles(MediaItem season, ServerId serverId, {String? clientScopeId}) async { try { final parentMetadata = season.parentId != null ? await _lookupMetadata(serverId, season.parentId!, clientScopeId: clientScopeId) : null; final showYear = parentMetadata?.year; final episodesInSeason = await _database.getEpisodesBySeason(season.id, serverId: serverId); final storageLabel = _storageService.isUsingSaf ? ' (SAF)' : ''; appLogger.d('Deleting ${episodesInSeason.length} episodes in season ${season.id}$storageLabel'); await _deleteEpisodesInCollection( episodes: episodesInSeason, serverId: serverId, clientScopeId: clientScopeId, parentKey: season.id, parentTitle: season.displayTitle, ); await _deleteSeasonStorageDirectory(season, showYear); } catch (e, stack) { final storageLabel = _storageService.isUsingSaf ? 'SAF ' : ''; appLogger.e('Error deleting ${storageLabel}season files', error: e, stackTrace: stack); } } /// Delete episodes in a collection (season or show). In SAF mode, cleans up /// app-private subtitle/thumbnail assets per episode — the SAF video files /// and parent directories are wiped in one recursive call by the caller. Future _deleteEpisodesInCollection({ required List episodes, required ServerId serverId, String? clientScopeId, required String parentKey, required String parentTitle, }) async { final isSaf = _storageService.isUsingSaf; for (int i = 0; i < episodes.length; i++) { final episode = episodes[i]; final episodeGlobalKey = buildGlobalKey(ServerId(serverId), episode.ratingKey); _emitDeletionProgress( DeletionProgress( globalKey: buildGlobalKey(ServerId(serverId), parentKey), itemTitle: parentTitle, currentItem: i + 1, totalItems: episodes.length, currentOperation: 'Deleting episode ${i + 1} of ${episodes.length}', ), ); if (isSaf) { final episodeScopeId = episode.clientScopeId ?? clientScopeId; final episodeMetadata = await _lookupMetadata( ServerId(serverId), episode.ratingKey, clientScopeId: episodeScopeId, ); if (episodeMetadata != null) { await _deleteEpisodeFiles( episodeMetadata, serverId, clientScopeId: episodeScopeId, skipStorageVideoAndParents: true, ); } else { await _deleteChapterThumbnails(ServerId(serverId), episode.ratingKey, clientScopeId: episodeScopeId); await _deleteByFilePath(episode); } } else { await _deleteChapterThumbnails( ServerId(serverId), episode.ratingKey, clientScopeId: episode.clientScopeId ?? clientScopeId, ); await _deleteByFilePath(episode); } await _deleteForItemByServer( ServerId(serverId), episode.ratingKey, clientScopeId: episode.clientScopeId ?? clientScopeId, ); await _deleteDownloadRowAndRelease(episodeGlobalKey); } } /// Delete a single downloaded track. File deletion runs off the DB record /// (video + .part + empty-parent cleanup via [_deleteByFilePath], which also /// handles SAF URIs); the album-cover thumb is reference-counted because /// every track of an album shares the same artwork blob. Future _deleteTrackByRecord(DownloadedMediaItem record) async { final parsed = parseGlobalKey(record.globalKey); final keepThumb = parsed != null && record.thumbPath != null && await _isThumbPathInUseByOthers(parsed.serverId, record.thumbPath!, excludingGlobalKey: record.globalKey); await _deleteByFilePath(record, deleteThumb: !keepThumb); } /// Whether any other download row on [serverId] references [thumbPath]. /// Mirrors the chapter-thumbnail in-use check: shared artwork survives /// until the last referencing download is deleted. Future _isThumbPathInUseByOthers( ServerId serverId, String thumbPath, { required String excludingGlobalKey, }) async { final rows = await _database.getDownloadsByServerId(serverId); return rows.any((row) => row.globalKey != excludingGlobalKey && row.thumbPath == thumbPath); } /// Delete every downloaded track of an album/artist container, mirroring /// [_deleteEpisodesInCollection]: per-track deletion progress, file cleanup, /// per-item server-side residue, then the DB rows. Future _deleteTracksInContainer({ required List tracks, required ServerId serverId, String? clientScopeId, required String containerKey, required String containerTitle, }) async { appLogger.d('Deleting ${tracks.length} tracks in container $containerKey'); for (int i = 0; i < tracks.length; i++) { final track = tracks[i]; final trackGlobalKey = buildGlobalKey(ServerId(serverId), track.ratingKey); _emitDeletionProgress( DeletionProgress( globalKey: buildGlobalKey(ServerId(serverId), containerKey), itemTitle: containerTitle, currentItem: i + 1, totalItems: tracks.length, currentOperation: 'Deleting track ${i + 1} of ${tracks.length}', ), ); await _deleteTrackByRecord(track); await _deleteForItemByServer( ServerId(serverId), track.ratingKey, clientScopeId: track.clientScopeId ?? clientScopeId, ); await _deleteDownloadRowAndRelease(trackGlobalKey); } } Future _deleteShowFiles(MediaItem show, ServerId serverId, {String? clientScopeId}) async { try { final episodesInShow = await _database.getEpisodesByShow(show.id, serverId: serverId); final storageLabel = _storageService.isUsingSaf ? ' (SAF)' : ''; appLogger.d('Deleting ${episodesInShow.length} episodes in show ${show.id}$storageLabel'); await _deleteEpisodesInCollection( episodes: episodesInShow, serverId: serverId, clientScopeId: clientScopeId, parentKey: show.id, parentTitle: show.displayTitle, ); await _deleteShowStorageDirectory(show); } catch (e, stack) { final storageLabel = _storageService.isUsingSaf ? 'SAF ' : ''; appLogger.e('Error deleting ${storageLabel}show files', error: e, stackTrace: stack); } } Future _deleteMovieFiles(MediaItem movie, ServerId serverId, {String? clientScopeId}) async { try { await _deleteMovieStorageDirectory(movie); await _deleteChapterThumbnails(serverId, movie.id, clientScopeId: clientScopeId); // Safety net: verify the actual DB-recorded file is gone await _ensureDbFileDeleted(serverId, movie.id); } catch (e, stack) { final storageLabel = _storageService.isUsingSaf ? 'SAF ' : ''; appLogger.e('Error deleting ${storageLabel}movie files', error: e, stackTrace: stack); } } /// Delete one media directory and everything under it, on either storage backend. /// [safComponents] and [fileDirectory] are thunks so only the branch that runs /// resolves its path — the file-mode getters create the directory as a side effect. Future _deleteStorageDirectory({ required List Function() safComponents, required Future Function() fileDirectory, required String label, }) async { if (_storageService.isUsingSaf) { final safBaseUri = _storageService.safBaseUri; if (safBaseUri == null) return; final dir = await _safStorage.getChild(safBaseUri, safComponents()); if (dir != null) { await _deleteSafDirRecursive(dir.uri, description: '$label directory'); } return; } final dir = await fileDirectory(); if (await dir.exists()) { await dir.delete(recursive: true); appLogger.i('Deleted $label directory: ${dir.path}'); } } Future _deleteMovieStorageDirectory(MediaItem movie) { return _deleteStorageDirectory( safComponents: () => _storageService.getMovieSafPathComponents(movie), fileDirectory: () => _storageService.getMovieDirectory(movie), label: 'movie', ); } Future<_EpisodeStorageDeletion> _deleteEpisodeStorageVideo( MediaItem episode, { required int? showYear, required bool skipVideo, }) async { if (_storageService.isUsingSaf) { if (skipVideo) return (seasonDirUri: null, showDirUri: null); final safBaseUri = _storageService.safBaseUri; if (safBaseUri == null) return (seasonDirUri: null, showDirUri: null); final resolved = await Future.wait([ _safStorage.getChild(safBaseUri, _storageService.getEpisodeSafPathComponents(episode, showYear: showYear)), _safStorage.getChild(safBaseUri, _storageService.getShowSafPathComponents(episode, showYear: showYear)), ]); final seasonDirUri = resolved.first?.uri; final showDirUri = resolved[1]?.uri; if (seasonDirUri != null) { final file = await _findSafFileByBaseName(seasonDirUri, _storageService.getEpisodeSafBaseName(episode)); if (file != null) { await _tryDeleteSaf(file.uri, isDir: false, description: 'SAF episode video'); } } return (seasonDirUri: seasonDirUri, showDirUri: showDirUri); } if (!skipVideo) { final videoPathTemplate = await _storageService.getEpisodeVideoPath(episode, 'tmp', showYear: showYear); final videoPathWithoutExt = videoPathTemplate.substring(0, videoPathTemplate.lastIndexOf('.')); final actualVideoFile = await _findFileWithAnyExtension(videoPathWithoutExt); if (actualVideoFile != null) { await _deleteFileIfExists(actualVideoFile, 'episode video'); await _deleteFileIfExists(File('${actualVideoFile.path}.part'), 'partial download'); } } return (seasonDirUri: null, showDirUri: null); } Future _cleanupEpisodeStorageParents(MediaItem episode, int? showYear, _EpisodeStorageDeletion deletion) async { if (_storageService.isUsingSaf) { await _deleteEmptySafDirsInOrder([deletion.seasonDirUri, deletion.showDirUri]); return; } await _cleanupEmptyDirectories(episode, showYear); } Future _deleteSeasonStorageDirectory(MediaItem season, int? showYear) async { await _deleteStorageDirectory( safComponents: () => _storageService.getSeasonSafPathComponents(season, showYear: showYear), fileDirectory: () => _storageService.getSeasonDirectory(season, showYear: showYear), label: 'season', ); // Drop the parent show directory too if the deleted season left it empty. if (_storageService.isUsingSaf) { final safBaseUri = _storageService.safBaseUri; if (safBaseUri == null) return; final showDir = await _safStorage.getChild( safBaseUri, _storageService.getShowSafPathComponents(season, showYear: showYear), ); await _deleteEmptySafDirsInOrder([showDir?.uri]); return; } await _cleanupShowDirectory(season, showYear); } Future _deleteShowStorageDirectory(MediaItem show) { return _deleteStorageDirectory( safComponents: () => _storageService.getShowSafPathComponents(show), fileDirectory: () => _storageService.getShowDirectory(show), label: 'show', ); } /// Safety net: after metadata-based deletion, verify the actual DB-recorded /// video file is gone. If not, delete it and clean up parent directories. Future _ensureDbFileDeleted(ServerId serverId, String ratingKey) async { try { final globalKey = buildGlobalKey(ServerId(serverId), ratingKey); final record = await _database.getDownloadedMedia(globalKey); if (record?.videoFilePath == null) return; final storedPath = record!.videoFilePath!; if (_storageService.isSafUri(storedPath)) { // SAF mode: parent cleanup is handled by the type-specific SAF helpers — // here we only verify the video URI itself is gone. if (await _safStorage.exists(storedPath, isDir: false)) { appLogger.w('Safety net: SAF video still exists after metadata deletion, deleting: $storedPath'); await _safStorage.delete(storedPath, isDir: false); } return; } final videoPath = await _storageService.ensureAbsolutePath(storedPath); final videoFile = File(videoPath); if (await videoFile.exists()) { appLogger.w('Safety net: video still exists after metadata deletion, deleting: $videoPath'); } await _deleteFilesystemVideoAssets(videoPath); } catch (e, stack) { appLogger.w('Safety net deletion failed', error: e, stackTrace: stack); } } /// Walk up from a directory toward the downloads root, removing empty dirs. Future _cleanupEmptyParentDirectories(Directory dir) async { try { final downloadsDir = await _storageService.getDownloadsDirectory(); var current = dir; while (current.path != downloadsDir.path && current.path.startsWith(downloadsDir.path)) { if (!await current.exists()) { current = current.parent; continue; } final contents = await current.list().toList(); if (contents.isEmpty) { await current.delete(); appLogger.i('Cleaned up empty directory: ${current.path}'); current = current.parent; } else { break; } } } catch (e) { appLogger.w('Error cleaning up parent directories', error: e); } } /// Clean up empty directories after deleting episode (file mode only — the /// SAF deleters call [_deleteEmptySafDirsInOrder] directly). Future _cleanupEmptyDirectories(MediaItem episode, int? showYear) async { if (_storageService.isUsingSaf) return; final seasonDir = await _storageService.getSeasonDirectory(episode, showYear: showYear); if (await seasonDir.exists()) { final contents = await seasonDir.list().toList(); final hasVideos = contents.any( (e) => _videoExtensions.any((ext) => e.path.endsWith(ext)) || e.path.contains('_subs'), ); if (!hasVideos) { if (!await _isSeasonArtworkInUse(episode, showYear)) { await seasonDir.delete(recursive: true); appLogger.i('Deleted empty season directory: ${seasonDir.path}'); await _cleanupShowDirectory(episode, showYear); } } } } /// Clean up show directory if empty (file mode only). Future _cleanupShowDirectory(MediaItem metadata, int? showYear) async { if (_storageService.isUsingSaf) return; final showDir = await _storageService.getShowDirectory(metadata, showYear: showYear); if (await showDir.exists()) { final contents = await showDir.list().toList(); final hasSeasons = contents.any((e) => e is Directory && e.path.contains('Season ')); if (!hasSeasons) { if (!await _isShowArtworkInUse(metadata, showYear)) { await showDir.delete(recursive: true); appLogger.i('Deleted empty show directory: ${showDir.path}'); } } } } Future _isSeasonArtworkInUse(MediaItem episode, int? _) async { final seasonKey = episode.parentId; if (seasonKey == null) return false; final otherEpisodes = await _database.getEpisodesBySeason(seasonKey); return otherEpisodes.any((e) => e.globalKey != episode.globalKey); } Future _isShowArtworkInUse(MediaItem metadata, int? _) async { final showKey = metadata.grandparentId ?? metadata.parentId ?? metadata.id; // Use targeted query instead of full table scan final showEpisodes = await _database.getEpisodesByShow(showKey); return showEpisodes.any((item) => item.globalKey != metadata.globalKey); } Future _findFileWithAnyExtension(String pathWithoutExt) async { final dir = Directory(path.dirname(pathWithoutExt)); final baseName = path.basename(pathWithoutExt); if (!await dir.exists()) return null; try { final files = await dir .list() .where( (e) => e is File && path.basenameWithoutExtension(e.path) == baseName && _videoExtensions.contains(path.extension(e.path).toLowerCase()), ) .toList(); return files.isNotEmpty ? files.first as File : null; } catch (e) { appLogger.w('Error finding file: $pathWithoutExt', error: e); return null; } } /// Delete a downloaded file and the sidecars derived from its path. Sidecar /// cleanup is independent of the primary file because interrupted or manual /// video removal must not strand `.part` files or subtitle directories. Future _deleteFilesystemVideoAssets(String videoPath) async { final videoFile = File(videoPath); await _deleteFileIfExists(videoFile, 'video file'); await _deleteFileIfExists(File('$videoPath.part'), 'partial download'); final subsPath = videoPath.replaceAll(RegExp(r'\.[^.]+$'), '_subs'); final subsDir = Directory(subsPath); if (await subsDir.exists()) { await subsDir.delete(recursive: true); appLogger.i('Deleted subtitles: $subsPath'); } await _cleanupEmptyParentDirectories(videoFile.parent); } /// Fallback deletion using file paths from database. /// /// [deleteThumb] lets callers preserve a shared artwork blob — album-cover /// thumbs are deduped by path hash across every track of the album, so a /// single-track delete must keep the file while sibling rows reference it. Future _deleteByFilePath(DownloadedMediaItem record, {bool deleteThumb = true}) async { try { if (record.videoFilePath != null && _storageService.isSafUri(record.videoFilePath!)) { // Metadata is gone by the time this fallback runs, so parent-dir cleanup // is not attempted here — SAF URIs don't expose a parent reliably. await _tryDeleteSaf(record.videoFilePath!, isDir: false, description: 'SAF video file'); } else if (record.videoFilePath != null) { final videoPath = await _storageService.ensureAbsolutePath(record.videoFilePath!); await _deleteFilesystemVideoAssets(videoPath); } // thumbPath is a server-side API path (Plex /library/metadata/.../thumb, // Jellyfin /Items/.../Images/Primary), not a local file path — // resolve it via getArtworkPathFromThumb if (deleteThumb && record.thumbPath != null) { final parsed = parseGlobalKey(record.globalKey); if (parsed != null) { final thumbPath = await _storageService.getArtworkPathFromThumb(parsed.serverId, record.thumbPath!); await _deleteFileIfExists(File(thumbPath), 'thumbnail'); } } } catch (e, stack) { appLogger.e('Error in fallback deletion', error: e, stackTrace: stack); } } Future> getAllDownloads() { return _database.select(_database.downloadedMedia).get(); } Future getDownloadedMedia(String globalKey) { return _database.getDownloadedMedia(globalKey); } /// Save metadata for a media item (show, season, movie, or episode) /// Used to persist parent metadata (shows/seasons) for offline display. /// /// All backends have read-path cache-through, so the work is just to /// hit `client.fetchItem` (idempotent) and pin the resulting row. Future saveMetadata(MediaItem metadata, MediaServerClient client) async { if (metadata.serverId == null) { appLogger.w('Cannot save metadata without serverId'); return; } await _pinMetadataForOffline(client, metadata); } void dispose() { _disposed = true; for (final timer in _progressDebounceTimers.values) { timer.cancel(); } _progressDebounceTimers.clear(); for (final timer in _autoRetryTimers.values) { timer.cancel(); } _autoRetryTimers.clear(); _pendingDownloadContext.clear(); _completingKeys.clear(); _pausingKeys.clear(); _cancellingKeys.clear(); _progressController.close(); _deletionProgressController.close(); } } String? downloadExtensionFromUrl(String url) { final uri = Uri.tryParse(url); if (uri == null) return null; final lastSegment = uri.pathSegments.isEmpty ? '' : uri.pathSegments.last; final lastDot = lastSegment.lastIndexOf('.'); if (lastDot != -1 && lastDot < lastSegment.length - 1) { return _safeDownloadExtension(lastSegment.substring(lastDot + 1)); } for (final entry in uri.queryParameters.entries) { if (entry.key.toLowerCase() == 'container') { return _safeDownloadExtension(entry.value); } } return null; } String? _safeDownloadExtension(String raw) { final ext = raw.split(RegExp(r'[,|]')).first.trim().replaceFirst(RegExp(r'^\.+'), '').toLowerCase(); if (ext.isEmpty || !RegExp(r'^[a-z0-9][a-z0-9._-]{0,39}$').hasMatch(ext)) return null; return ext; }