diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index dafe0c3a..c83ed8bd 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -214,7 +214,6 @@ class DownloadProvider extends ChangeNotifier { notifyListeners(); } - @override @override void dispose() { _progressSubscription?.cancel(); @@ -222,6 +221,10 @@ class DownloadProvider extends ChangeNotifier { super.dispose(); } + /// Ensure metadata has a serverId, falling back to a parent's serverId. + PlexMetadata _ensureServerId(PlexMetadata metadata, String? fallbackServerId) => + metadata.serverId != null ? metadata : metadata.copyWith(serverId: fallbackServerId); + /// All current download progress entries Map get downloads => Map.unmodifiable(_downloads); @@ -333,23 +336,18 @@ class DownloadProvider extends ChangeNotifier { .toList(); } - /// Get all episode downloads (any status) for a specific show - List _getEpisodeDownloadsForShow(String showRatingKey) { + /// Get episode downloads filtered by show and/or season ratingKey. + List _getEpisodeDownloads({ + String? showRatingKey, + String? seasonRatingKey, + }) { return _downloads.entries .where((entry) { final meta = _metadata[entry.key]; - return meta?.type == 'episode' && meta?.grandparentRatingKey == showRatingKey; - }) - .map((entry) => entry.value) - .toList(); - } - - /// Get all episode downloads (any status) for a specific season - List _getEpisodeDownloadsForSeason(String seasonRatingKey) { - return _downloads.entries - .where((entry) { - final meta = _metadata[entry.key]; - return meta?.type == 'episode' && meta?.parentRatingKey == seasonRatingKey; + if (meta?.type != 'episode') return false; + if (showRatingKey != null && meta?.grandparentRatingKey != showRatingKey) return false; + if (seasonRatingKey != null && meta?.parentRatingKey != seasonRatingKey) return false; + return true; }) .map((entry) => entry.value) .toList(); @@ -361,7 +359,7 @@ class DownloadProvider extends ChangeNotifier { return _calculateAggregateProgress( serverId: serverId, ratingKey: showRatingKey, - episodes: _getEpisodeDownloadsForShow(showRatingKey), + episodes: _getEpisodeDownloads(showRatingKey: showRatingKey), entityType: 'show', ); } @@ -372,7 +370,7 @@ class DownloadProvider extends ChangeNotifier { return _calculateAggregateProgress( serverId: serverId, ratingKey: seasonRatingKey, - episodes: _getEpisodeDownloadsForSeason(seasonRatingKey), + episodes: _getEpisodeDownloads(seasonRatingKey: seasonRatingKey), entityType: 'season', ); } @@ -511,12 +509,12 @@ class DownloadProvider extends ChangeNotifier { if (meta == null) { // No metadata stored yet, might be a show/season being queued // Check if any episodes exist for this as a parent - final episodesAsShow = _getEpisodeDownloadsForShow(ratingKey); + final episodesAsShow = _getEpisodeDownloads(showRatingKey: ratingKey); if (episodesAsShow.isNotEmpty) { return getAggregateProgressForShow(serverId, ratingKey); } - final episodesAsSeason = _getEpisodeDownloadsForSeason(ratingKey); + final episodesAsSeason = _getEpisodeDownloads(seasonRatingKey: ratingKey); if (episodesAsSeason.isNotEmpty) { return getAggregateProgressForSeason(serverId, ratingKey); } @@ -691,109 +689,61 @@ class DownloadProvider extends ChangeNotifier { Future _fetchAndStoreParentMetadata(PlexMetadata episode, PlexClient client) async { final serverId = episode.serverId; if (serverId == null) return; + + await _fetchAndStoreRelatedMetadata(serverId: serverId, ratingKey: episode.grandparentRatingKey, client: client); + await _fetchAndStoreRelatedMetadata(serverId: serverId, ratingKey: episode.parentRatingKey, client: client); + } + + /// Fetch, persist, and download artwork for a related metadata item (show or season). + Future _fetchAndStoreRelatedMetadata({ + required String serverId, + required String? ratingKey, + required PlexClient client, + }) async { + if (ratingKey == null) return; + final globalKey = buildGlobalKey(serverId, ratingKey); final storageService = DownloadStorageService.instance; - // Fetch and store show metadata if not already stored - final showRatingKey = episode.grandparentRatingKey; - if (showRatingKey != null) { - final showGlobalKey = buildGlobalKey(serverId, showRatingKey); - - // Try to use existing metadata (set when queueing an entire show) - PlexMetadata? showMetadata = _metadata[showGlobalKey]; - - // If not already cached, fetch full metadata with images - if (showMetadata == null) { - try { - showMetadata = await client.getMetadataWithImages(showRatingKey); - } catch (e) { - appLogger.w('Failed to fetch show metadata for $showRatingKey', error: e); - } - } - - if (showMetadata != null) { - final showWithServer = showMetadata.copyWith(serverId: serverId); - _metadata[showGlobalKey] = showWithServer; - - // Persist to database/API cache for offline usage - await _downloadManager.saveMetadata(showWithServer); - - // Ensure show artwork is downloaded even if metadata already existed - final thumbPath = showWithServer.thumb; - final hasPoster = thumbPath != null && await storageService.artworkExists(serverId, thumbPath); - if (!hasPoster) { - await _downloadManager.downloadArtworkForMetadata(showWithServer, client); - appLogger.d('Downloaded show artwork for $showGlobalKey'); - } - - // Store artwork reference in provider's map for offline display - _artworkPaths[showGlobalKey] = DownloadedArtwork(thumbPath: thumbPath); + PlexMetadata? metadata = _metadata[globalKey]; + if (metadata == null) { + try { + metadata = await client.getMetadataWithImages(ratingKey); + } catch (e) { + appLogger.w('Failed to fetch metadata for $ratingKey', error: e); } } + if (metadata == null) return; - // Fetch and store season metadata if not already stored - final seasonRatingKey = episode.parentRatingKey; - if (seasonRatingKey != null) { - final seasonGlobalKey = buildGlobalKey(serverId, seasonRatingKey); - PlexMetadata? seasonMetadata = _metadata[seasonGlobalKey]; + final withServer = metadata.copyWith(serverId: serverId); + _metadata[globalKey] = withServer; + await _downloadManager.saveMetadata(withServer); - if (seasonMetadata == null) { - try { - seasonMetadata = await client.getMetadataWithImages(seasonRatingKey); - } catch (e) { - appLogger.w('Failed to fetch season metadata for $seasonRatingKey', error: e); - } - } + final thumbPath = withServer.thumb; + final hasPoster = thumbPath != null && await storageService.artworkExists(serverId, thumbPath); + if (!hasPoster) { + await _downloadManager.downloadArtworkForMetadata(withServer, client); + } + _artworkPaths[globalKey] = DownloadedArtwork(thumbPath: thumbPath); + } - if (seasonMetadata != null) { - final seasonWithServer = seasonMetadata.copyWith(serverId: serverId); - _metadata[seasonGlobalKey] = seasonWithServer; - - // Persist to database/API cache for offline usage - await _downloadManager.saveMetadata(seasonWithServer); - - // Ensure season artwork is downloaded even if metadata already existed - final thumbPath = seasonWithServer.thumb; - final hasPoster = thumbPath != null && await storageService.artworkExists(serverId, thumbPath); - if (!hasPoster) { - await _downloadManager.downloadArtworkForMetadata(seasonWithServer, client); - appLogger.d('Downloaded season artwork for $seasonGlobalKey'); - } - - // Store artwork reference in provider's map for offline display - _artworkPaths[seasonGlobalKey] = DownloadedArtwork(thumbPath: thumbPath); - } + /// Store leafCount for a show or season so aggregate progress works. + Future _storeLeafCount(String globalKey, PlexMetadata metadata) async { + if (metadata.leafCount != null && metadata.leafCount! > 0) { + _totalEpisodeCounts[globalKey] = metadata.leafCount!; + await _persistTotalEpisodeCount(globalKey, metadata.leafCount!); } } /// Queue all episodes from a TV show for download Future _queueShowDownload(PlexMetadata show, PlexClient client) async { - final globalKey = show.globalKey; int count = 0; final seasons = await client.getChildren(show.ratingKey); - // Store total episode count from show metadata (leafCount) - if (show.leafCount != null && show.leafCount! > 0) { - _totalEpisodeCounts[globalKey] = show.leafCount!; - await _persistTotalEpisodeCount(globalKey, show.leafCount!); - appLogger.i( - '💾 Stored episode count for show $globalKey: ${show.leafCount}\n' - ' - Show title: ${show.title}\n' - ' - Show type: ${show.type}\n' - ' - Total stored counts: ${_totalEpisodeCounts.length}', - ); - } else { - appLogger.w( - '⚠️ Show $globalKey has no leafCount! Cannot store episode count.\n' - ' - Show title: ${show.title}\n' - ' - Show type: ${show.type}\n' - ' - leafCount value: ${show.leafCount}', - ); - } + await _storeLeafCount(show.globalKey, show); for (final season in seasons) { if (season.type == 'season') { - // Ensure season has serverId from parent show - final seasonWithServer = season.serverId != null ? season : season.copyWith(serverId: show.serverId); + final seasonWithServer = _ensureServerId(season, show.serverId); count += await _queueSeasonDownload(seasonWithServer, client); } } @@ -803,33 +753,14 @@ class DownloadProvider extends ChangeNotifier { /// Queue all episodes from a season for download Future _queueSeasonDownload(PlexMetadata season, PlexClient client) async { - final globalKey = season.globalKey; int count = 0; final episodes = await client.getChildren(season.ratingKey); - // Store total episode count from season metadata (leafCount) - if (season.leafCount != null && season.leafCount! > 0) { - _totalEpisodeCounts[globalKey] = season.leafCount!; - await _persistTotalEpisodeCount(globalKey, season.leafCount!); - appLogger.i( - '💾 Stored episode count for season $globalKey: ${season.leafCount}\n' - ' - Season title: ${season.title}\n' - ' - Season type: ${season.type}\n' - ' - Total stored counts: ${_totalEpisodeCounts.length}', - ); - } else { - appLogger.w( - '⚠️ Season $globalKey has no leafCount! Cannot store episode count.\n' - ' - Season title: ${season.title}\n' - ' - Season type: ${season.type}\n' - ' - leafCount value: ${season.leafCount}', - ); - } + await _storeLeafCount(season.globalKey, season); for (final episode in episodes) { if (episode.type == 'episode') { - // Ensure episode has serverId from parent season - final episodeWithServer = episode.serverId != null ? episode : episode.copyWith(serverId: season.serverId); + final episodeWithServer = _ensureServerId(episode, season.serverId); await _queueSingleDownload(episodeWithServer, client); count++; } @@ -862,7 +793,7 @@ class DownloadProvider extends ChangeNotifier { for (final season in seasons) { if (season.type == 'season') { - final seasonWithServer = season.serverId != null ? season : season.copyWith(serverId: show.serverId); + final seasonWithServer = _ensureServerId(season, show.serverId); queuedCount += await _queueMissingSeasonEpisodes(seasonWithServer, client); } } @@ -880,7 +811,7 @@ class DownloadProvider extends ChangeNotifier { for (final episode in episodes) { if (episode.type == 'episode') { - final episodeWithServer = episode.serverId != null ? episode : episode.copyWith(serverId: season.serverId); + final episodeWithServer = _ensureServerId(episode, season.serverId); final episodeGlobalKey = episodeWithServer.globalKey; diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 21521060..ba3f6045 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -646,10 +646,17 @@ class _MediaDetailScreenState extends State with WatchStateAw onPressed: () async { final client = _getClientForMetadata(context); if (client == null) return; - final count = await downloadProvider.queueDownload(metadata, client); - if (context.mounted) { - final message = count > 1 ? t.downloads.episodesQueued(count: count) : t.downloads.downloadQueued; - showSuccessSnackBar(context, message); + try { + final count = await downloadProvider.queueDownload(metadata, client); + if (context.mounted) { + final message = + count > 1 ? t.downloads.episodesQueued(count: count) : t.downloads.downloadQueued; + showSuccessSnackBar(context, message); + } + } on CellularDownloadBlockedException { + if (context.mounted) { + showErrorSnackBar(context, t.settings.cellularDownloadBlocked); + } } }, icon: const AppIcon(Symbols.download_rounded, fill: 1), diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index 5a922925..b2bd36bb 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -234,6 +234,11 @@ class DownloadManagerService { // background_downloader state bool _fileDownloaderInitialized = false; static const _downloadGroup = 'video_downloads'; + static const _maxAppRetries = 3; + static const _nativeRetries = 5; + static const _autoRetryDelay = 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 = {}; @@ -435,12 +440,7 @@ class DownloadManagerService { if (metadata.type == 'episode' && metadata.grandparentRatingKey != null) { final parsed = parseGlobalKey(globalKey); if (parsed != null) { - final showCached = await _apiCache.get( - parsed.serverId, - '/library/metadata/${metadata.grandparentRatingKey}', - ); - final showJson = PlexCacheParser.extractFirstMetadata(showCached); - if (showJson != null) showYear = PlexMetadata.fromJson(showJson).year; + showYear = await _fetchShowYear(parsed.serverId, metadata.grandparentRatingKey); } } @@ -597,12 +597,9 @@ class DownloadManagerService { final ext = _getExtensionFromUrl(playbackData.videoUrl!) ?? 'mp4'; // Look up show year for episodes - int? showYear; - if (metadata.type == 'episode' && metadata.grandparentRatingKey != null) { - final showCached = await _apiCache.get(serverId, '/library/metadata/${metadata.grandparentRatingKey}'); - final showJson = PlexCacheParser.extractFirstMetadata(showCached); - if (showJson != null) showYear = PlexMetadata.fromJson(showJson).year; - } + final showYear = metadata.type == 'episode' + ? await _fetchShowYear(serverId, metadata.grandparentRatingKey) + : null; // Build display name for notifications final displayName = metadata.type == 'episode' @@ -641,7 +638,7 @@ class DownloadManagerService { group: _downloadGroup, updates: Updates.statusAndProgress, requiresWiFi: requiresWiFi, - retries: 5, + retries: _nativeRetries, metaData: globalKey, displayName: displayName, ); @@ -682,7 +679,7 @@ class DownloadManagerService { group: _downloadGroup, updates: Updates.statusAndProgress, requiresWiFi: requiresWiFi, - retries: 5, + retries: _nativeRetries, allowPause: true, metaData: globalKey, displayName: displayName, @@ -706,7 +703,6 @@ class DownloadManagerService { } catch (e) { appLogger.e('Failed to prepare download for $globalKey', error: e); await _transitionStatus(globalKey, DownloadStatus.failed, errorMessage: e.toString()); - await _database.updateDownloadError(globalKey, e.toString()); await _database.removeFromQueue(globalKey); _pendingDownloadContext.remove(globalKey); } @@ -745,7 +741,7 @@ class DownloadManagerService { // 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(const Duration(seconds: 2), () { + _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); @@ -821,18 +817,17 @@ class DownloadManagerService { } final retryCount = existing?.retryCount ?? 0; - if (retryCount < 3 && _lastClient != null) { + if (retryCount < _maxAppRetries && _lastClient != null) { // App-level auto-retry: schedule a fresh download after a delay. // Each new task gets 5 native retries with Range-based resume. appLogger.w( - 'Download failed for $globalKey (attempt ${retryCount + 1}/3), ' - 'scheduling auto-retry in 30s: $errorMessage', + '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.updateDownloadError(globalKey, errorMessage); await _database.removeFromQueue(globalKey); _autoRetryTimers[globalKey]?.cancel(); - _autoRetryTimers[globalKey] = Timer(const Duration(seconds: 30), () { + _autoRetryTimers[globalKey] = Timer(_autoRetryDelay, () { _autoRetryTimers.remove(globalKey); _performAutoRetry(globalKey); }); @@ -860,7 +855,6 @@ class DownloadManagerService { appLogger.e('Download permanently failed for $globalKey: $errorMessage'); await _transitionStatus(globalKey, DownloadStatus.failed, errorMessage: errorMessage); - await _database.updateDownloadError(globalKey, errorMessage); await _database.removeFromQueue(globalKey); // Try to enqueue more items from the queue @@ -1001,7 +995,6 @@ class DownloadManagerService { } catch (e) { appLogger.e('Post-download processing failed for $globalKey', error: e); await _transitionStatus(globalKey, DownloadStatus.failed, errorMessage: 'Post-processing failed: $e'); - await _database.updateDownloadError(globalKey, 'Post-processing failed: $e'); await _database.removeFromQueue(globalKey); } finally { _completingKeys.remove(globalKey); @@ -1017,6 +1010,15 @@ class DownloadManagerService { return _apiCache.getMetadata(parsed.serverId, parsed.ratingKey); } + /// Look up the year of the parent show for an episode (used for folder naming). + Future _fetchShowYear(String serverId, String? grandparentRatingKey) async { + if (grandparentRatingKey == null) return null; + final showCached = await _apiCache.get(serverId, '/library/metadata/$grandparentRatingKey'); + final showJson = PlexCacheParser.extractFirstMetadata(showCached); + if (showJson != null) return PlexMetadata.fromJson(showJson).year; + return null; + } + /// Re-derive the SAF file URI from metadata (for recovery when context is lost) Future _resolveSafStoredPath(PlexMetadata metadata, String ext, int? showYear) async { final safBaseUri = _storageService.safBaseUri; @@ -1256,6 +1258,9 @@ class DownloadManagerService { /// 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, @@ -1699,11 +1704,7 @@ class DownloadManagerService { final contents = await seasonDir.list().toList(); final hasVideos = contents.any( (e) => - e.path.endsWith('.mp4') || - e.path.endsWith('.ogv') || - e.path.endsWith('.mkv') || - e.path.endsWith('.m4v') || - e.path.endsWith('.avi') || + _videoExtensions.any((ext) => e.path.endsWith(ext)) || e.path.contains('_subs'), ); diff --git a/lib/widgets/deletion_progress_dialog.dart b/lib/widgets/deletion_progress_dialog.dart index 8d28eed9..a6ec2b6e 100644 --- a/lib/widgets/deletion_progress_dialog.dart +++ b/lib/widgets/deletion_progress_dialog.dart @@ -33,15 +33,6 @@ class DeletionProgressDialog extends StatelessWidget { textAlign: TextAlign.center, ), - const SizedBox(height: 24), - - // Progress text - Text( - 'Deleting ${progress.itemTitle}... (${progress.currentItem} of ${progress.totalItems})', - style: Theme.of(context).textTheme.titleMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), // Progress bar