refactor: clean up download code duplication

This commit is contained in:
edde746
2026-03-03 16:31:02 +01:00
parent 7a979bed2f
commit c6ace690cb
4 changed files with 98 additions and 168 deletions
+58 -127
View File
@@ -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<String, DownloadProgress> 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<DownloadProgress> _getEpisodeDownloadsForShow(String showRatingKey) {
/// Get episode downloads filtered by show and/or season ratingKey.
List<DownloadProgress> _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<DownloadProgress> _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<void> _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<void> _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<void> _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<int> _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<int> _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;
+11 -4
View File
@@ -646,10 +646,17 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> 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),
+29 -28
View File
@@ -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<String> _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<int?> _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<String?> _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<void> _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'),
);
@@ -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