diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 23f90157..12d777df 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -41,6 +41,9 @@ export 'package:spotiflac_android/services/history_database.dart' show HistoryLookupRequest, HistoryBatchLookupRequest; part 'download_queue_provider_paths.dart'; +part 'download_queue_provider_progress.dart'; +part 'download_queue_provider_verification.dart'; +part 'download_queue_provider_connectivity.dart'; part 'download_queue_provider_native_worker.dart'; part 'download_queue_provider_finalization.dart'; part 'download_queue_provider_replaygain.dart'; @@ -92,24 +95,6 @@ final _qualityFilenameTokenPattern = RegExp( caseSensitive: false, ); -class _ProgressUpdate { - final DownloadStatus status; - final double progress; - final double? speedMBps; - final int? bytesReceived; - final int? bytesTotal; - final String preparationStage; - - const _ProgressUpdate({ - required this.status, - required this.progress, - this.speedMBps, - this.bytesReceived, - this.bytesTotal, - this.preparationStage = '', - }); -} - class DownloadQueueNotifier extends Notifier { Timer? _queuePersistDebounce; Future _queuePersistenceWrite = Future.value(); @@ -163,9 +148,7 @@ class DownloadQueueNotifier extends Notifier { bool _networkPausedByWifiOnly = false; List? _lastConnectivityResults; DateTime _lastConnectionCleanupAt = DateTime.fromMillisecondsSinceEpoch(0); - DateTime _lastReconnectRetryPromptAt = DateTime.fromMillisecondsSinceEpoch( - 0, - ); + DateTime _lastReconnectRetryPromptAt = DateTime.fromMillisecondsSinceEpoch(0); static const _connectionCleanupDebounce = Duration(seconds: 2); String? _lastServiceTrackName; String? _lastServiceArtistName; @@ -194,49 +177,6 @@ class DownloadQueueNotifier extends Notifier { // then computes and writes album gain/peak to every track in the album. final Map _albumRgData = {}; - double _normalizeProgressForUi(double value) { - final clamped = value.clamp(0.0, 1.0).toDouble(); - if (clamped <= 0) return 0; - if (clamped >= 1) return 1; - final rounded = double.parse(clamped.toStringAsFixed(2)); - return rounded == 0 ? 0.01 : rounded; - } - - double _normalizeSpeedForUi(double value) { - if (value <= 0) return 0; - return double.parse(value.toStringAsFixed(1)); - } - - int _normalizeBytesForUi(int value) { - if (value <= 0) return 0; - return (value ~/ _bytesUiStep) * _bytesUiStep; - } - - bool _shouldUpdateProgressNotification({ - required String trackName, - required String artistName, - required int progress, - required int total, - required int queueCount, - }) { - final safeTotal = total > 0 ? total : 1; - final percent = ((progress * 100) / safeTotal).round().clamp(0, 100); - final changed = - trackName != _lastNotifTrackName || - artistName != _lastNotifArtistName || - percent != _lastNotifPercent || - queueCount != _lastNotifQueueCount; - if (!changed) { - return false; - } - - _lastNotifTrackName = trackName; - _lastNotifArtistName = artistName; - _lastNotifPercent = percent; - _lastNotifQueueCount = queueCount; - return true; - } - @override DownloadQueueState build() { ref.listen(settingsProvider, (previous, next) { @@ -350,201 +290,6 @@ class DownloadQueueNotifier extends Notifier { } } - /// Completes when the app is in the foreground. Verification challenges - /// can only be handled there: launching a browser from the background is - /// blocked by the OS and the challenge would expire unseen. - Future _waitForForeground() async { - if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) { - return; - } - final completer = Completer(); - final listener = AppLifecycleListener( - onResume: () { - if (!completer.isCompleted) completer.complete(); - }, - ); - try { - await completer.future; - } finally { - listener.dispose(); - } - } - - Future _openVerificationAndWait(String extensionId) { - final key = extensionId.trim().toLowerCase(); - final activeFlow = _verificationFlowsByExtension[key]; - if (activeFlow != null) { - _log.i( - 'Joining active verification flow for $extensionId instead of opening another challenge', - ); - return activeFlow; - } - - final flow = _runVerificationFlow(extensionId, key); - _verificationFlowsByExtension[key] = flow; - return flow; - } - - Future _runVerificationFlow(String extensionId, String key) async { - try { - return await openVerificationAndAwaitGrant( - extensionId, - browserMode: ref - .read(settingsProvider) - .extensionVerificationBrowserMode, - awaitForeground: (normalizedExtensionId) async { - if (WidgetsBinding.instance.lifecycleState != - AppLifecycleState.resumed) { - _log.i( - 'Verification required for $normalizedExtensionId while app is in ' - 'background; deferring challenge until the app is foregrounded', - ); - try { - await _notificationService.showVerificationRequired(); - } catch (error) { - _log.w( - 'Failed to show the verification-required notification: $error', - ); - } - await _waitForForeground(); - try { - await _notificationService.cancelVerificationRequired(); - } catch (error) { - _log.w( - 'Failed to clear the verification-required notification: $error', - ); - } - } - }, - ); - } finally { - _verificationFlowsByExtension.remove(key); - } - } - - Future _handleVerificationRequiredDownload( - DownloadItem item, - String errorMsg, - String? verificationService, - ) async { - final targetService = (verificationService ?? '').trim().isNotEmpty - ? verificationService!.trim() - : item.service; - if (_verificationRetryGuard.hasRetriedAfterGrant(item.id, targetService)) { - _log.e( - 'Verification was already completed once for ${item.track.name} on $targetService; not opening another challenge', - ); - updateItemStatus( - item.id, - DownloadStatus.failed, - error: errorMsg, - errorType: DownloadErrorType.verificationRequired, - ); - _failedInSession++; - return true; - } - _log.i( - 'Download for ${item.track.name} requires verification; waiting for $targetService grant', - ); - updateItemStatus( - item.id, - DownloadStatus.downloading, - error: 'Waiting for verification', - errorType: DownloadErrorType.verificationRequired, - ); - - final verified = await _openVerificationAndWait(targetService); - final current = _findItemById(item.id); - if (current == null || _isLocallyCancelled(item.id, item: current)) { - _log.i('Verification completed after item was removed or cancelled'); - return true; - } - - // Only a completed grant consumes the automatic retry. If bootstrap or - // browser launch failed before a challenge opened, a later attempt must - // still be allowed after the network changes. - _verificationRetryGuard.recordVerificationResult( - item.id, - targetService, - granted: verified, - ); - if (verified) { - _log.i( - 'Verification complete for $targetService; retrying ${item.track.name}', - ); - updateItemStatus( - item.id, - DownloadStatus.queued, - progress: 0, - speedMBps: 0, - error: 'Retrying after verification', - errorType: DownloadErrorType.verificationRequired, - ); - _saveQueueToStorage(); - return true; - } - - _log.e('Verification did not complete for $targetService'); - updateItemStatus( - item.id, - DownloadStatus.failed, - error: errorMsg, - errorType: DownloadErrorType.verificationRequired, - ); - _failedInSession++; - return true; - } - - Duration _rateLimitBackoffDelay(String errorMsg) { - final lower = errorMsg.toLowerCase(); - final retryAfterMatch = RegExp( - r'retry[- ]?after(?: seconds)?[:= ]+(\d+)', - caseSensitive: false, - ).firstMatch(lower); - final parsedSeconds = retryAfterMatch == null - ? null - : int.tryParse(retryAfterMatch.group(1) ?? ''); - final seconds = (parsedSeconds ?? 30).clamp(5, 300).toInt(); - return Duration(seconds: seconds); - } - - Future _handleRateLimitedDownload( - DownloadItem item, - String errorMsg, - ) async { - if (_rateLimitRetriedItemIds.contains(item.id)) { - return false; - } - _rateLimitRetriedItemIds.add(item.id); - - final delay = _rateLimitBackoffDelay(errorMsg); - _log.i( - 'Rate limited while downloading ${item.track.name}; retrying after ${delay.inSeconds}s', - ); - updateItemStatus( - item.id, - DownloadStatus.downloading, - error: 'Rate limited, retrying after ${delay.inSeconds}s', - errorType: DownloadErrorType.rateLimit, - ); - - await Future.delayed(delay); - final current = _findItemById(item.id); - if (current == null || _isLocallyCancelled(item.id, item: current)) { - return true; - } - updateItemStatus( - item.id, - DownloadStatus.queued, - progress: 0, - speedMBps: 0, - error: 'Retrying after rate limit', - errorType: DownloadErrorType.rateLimit, - ); - _saveQueueToStorage(); - return true; - } - void _saveQueueToStorage() { _queuePersistDebounce?.cancel(); _queuePersistDebounce = Timer(_queuePersistDebounceDuration, () { @@ -604,392 +349,6 @@ class DownloadQueueNotifier extends Notifier { } } - void _startMultiProgressPolling() { - _idleProgressPollTick = 0; - _progressPoller.start(useStream: Platform.isAndroid || Platform.isIOS); - } - - /// Idle-cadence gate for the fallback polling timer: polls every tick while - /// a download is active, but only every [_idleProgressPollEveryTicks]th - /// tick while idle-but-queued, and not at all while paused/empty. - bool _shouldPollDownloadProgressTick() { - final currentItems = state.items; - final hasQueuedItems = currentItems.any( - (item) => item.status == DownloadStatus.queued, - ); - final hasActiveItems = currentItems.any( - (item) => - item.status == DownloadStatus.downloading || - item.status == DownloadStatus.finalizing, - ); - - if (!hasActiveItems) { - if (state.isPaused || !hasQueuedItems) { - _idleProgressPollTick = 0; - return false; - } - - _idleProgressPollTick = - (_idleProgressPollTick + 1) % _idleProgressPollEveryTicks; - return _idleProgressPollTick == 0; - } - - _idleProgressPollTick = 0; - return true; - } - - void _processAllDownloadProgress(Map allProgress) { - final rawItems = allProgress['items']; - final items = rawItems is Map - ? rawItems.map((key, value) => MapEntry(key.toString(), value)) - : const {}; - final currentItems = state.items; - final lookup = state.lookup; - final queuedCount = lookup.queuedCount; - final downloadingCount = lookup.activeDownloadsCount; - DownloadItem? firstDownloading; - bool hasFinalizingItem = lookup.finalizingCount > 0; - String? finalizingTrackName; - String? finalizingArtistName; - if (downloadingCount > 0 || hasFinalizingItem) { - for (final item in currentItems) { - if (firstDownloading == null && - item.status == DownloadStatus.downloading) { - firstDownloading = item; - } - if (finalizingTrackName == null && - item.status == DownloadStatus.finalizing) { - hasFinalizingItem = true; - finalizingTrackName = item.track.name; - finalizingArtistName = item.track.artistName; - } - if ((downloadingCount == 0 || firstDownloading != null) && - (!hasFinalizingItem || finalizingTrackName != null)) { - break; - } - } - } - final progressUpdates = {}; - - for (final entry in items.entries) { - final itemId = entry.key; - final localItem = lookup.byItemId[itemId]; - if (localItem == null) { - continue; - } - if (_isPausePending(itemId)) { - PlatformBridge.clearItemProgress(itemId).catchError((_) {}); - continue; - } - if (localItem.status == DownloadStatus.skipped) { - PlatformBridge.clearItemProgress(itemId).catchError((_) {}); - continue; - } - if (localItem.status == DownloadStatus.completed || - localItem.status == DownloadStatus.failed) { - continue; - } - if (localItem.status == DownloadStatus.finalizing) { - PlatformBridge.clearItemProgress(itemId).catchError((_) {}); - hasFinalizingItem = true; - finalizingTrackName = localItem.track.name; - finalizingArtistName = localItem.track.artistName; - continue; - } - final rawItemProgress = entry.value; - if (rawItemProgress is! Map) { - continue; - } - final itemProgress = Map.from(rawItemProgress); - final bytesReceived = - (itemProgress['bytes_received'] as num?)?.toInt() ?? 0; - final bytesTotal = (itemProgress['bytes_total'] as num?)?.toInt() ?? 0; - final speedMBps = (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0; - final isDownloading = itemProgress['is_downloading'] as bool? ?? false; - final status = itemProgress['status'] as String? ?? 'downloading'; - final progressFromBackend = - (itemProgress['progress'] as num?)?.toDouble() ?? 0.0; - final hasRealProgress = - status != 'preparing' && - (bytesReceived > 0 || bytesTotal > 0 || progressFromBackend > 0); - - if (status == 'finalizing') { - progressUpdates[itemId] = const _ProgressUpdate( - status: DownloadStatus.finalizing, - progress: 1.0, - ); - hasFinalizingItem = true; - finalizingTrackName = localItem.track.name; - finalizingArtistName = localItem.track.artistName; - continue; - } - - if (status == 'preparing') { - progressUpdates[itemId] = _ProgressUpdate( - status: DownloadStatus.downloading, - progress: 0.0, - speedMBps: 0, - bytesReceived: 0, - bytesTotal: 0, - preparationStage: itemProgress['stage']?.toString() ?? '', - ); - - if (LogBuffer.loggingEnabled) { - _log.d('Preparing [$itemId]: waiting for real download bytes'); - } - continue; - } - - if (isDownloading || hasRealProgress) { - double percentage = 0.0; - if (bytesTotal > 0) { - percentage = bytesReceived / bytesTotal; - } else { - percentage = progressFromBackend; - } - final normalizedProgress = _normalizeProgressForUi(percentage); - final normalizedSpeed = _normalizeSpeedForUi(speedMBps); - final normalizedBytes = _normalizeBytesForUi(bytesReceived); - - progressUpdates[itemId] = _ProgressUpdate( - status: DownloadStatus.downloading, - progress: normalizedProgress, - speedMBps: normalizedSpeed, - bytesReceived: normalizedBytes, - bytesTotal: bytesTotal, - ); - - if (LogBuffer.loggingEnabled) { - final mbReceived = bytesReceived / (1024 * 1024); - final mbTotal = bytesTotal / (1024 * 1024); - if (bytesTotal > 0) { - _log.d( - 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s', - ); - } else { - _log.d( - 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (stream/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s', - ); - } - } - } - } - - if (progressUpdates.isNotEmpty) { - var updatedItems = currentItems; - bool changed = false; - final changedIndices = []; - - for (final entry in progressUpdates.entries) { - final index = lookup.indexByItemId[entry.key]; - if (index == null) continue; - final current = updatedItems[index]; - if (current.status == DownloadStatus.skipped || - current.status == DownloadStatus.completed || - current.status == DownloadStatus.failed) { - continue; - } - final update = entry.value; - if (current.status == DownloadStatus.finalizing && - update.status != DownloadStatus.finalizing) { - continue; - } - final next = current.copyWith( - status: update.status, - progress: update.progress, - speedMBps: update.speedMBps ?? current.speedMBps, - bytesReceived: update.bytesReceived ?? current.bytesReceived, - bytesTotal: update.bytesTotal ?? current.bytesTotal, - preparationStage: update.preparationStage, - ); - if (current.status != next.status || - current.progress != next.progress || - current.speedMBps != next.speedMBps || - current.bytesReceived != next.bytesReceived || - current.bytesTotal != next.bytesTotal || - current.preparationStage != next.preparationStage) { - if (!changed) { - updatedItems = List.from(updatedItems); - changed = true; - } - updatedItems[index] = next; - changedIndices.add(index); - } - } - - if (changed) { - state = state.copyWith( - items: updatedItems, - lookup: state.lookup.updatedForIndices( - previousItems: currentItems, - nextItems: updatedItems, - changedIndices: changedIndices, - ), - ); - } - } - - if (hasFinalizingItem && finalizingTrackName != null) { - final safeArtistName = finalizingArtistName ?? ''; - if (Platform.isAndroid) { - _maybeUpdateAndroidDownloadService( - trackName: finalizingTrackName, - artistName: _notificationService.embeddingMetadataLabel, - progress: 100, - total: 100, - queueCount: queuedCount, - status: 'finalizing', - ); - } else if (finalizingTrackName != _lastFinalizingTrackName || - safeArtistName != _lastFinalizingArtistName) { - _notificationService.showDownloadFinalizing( - trackName: finalizingTrackName, - artistName: safeArtistName, - ); - _lastFinalizingTrackName = finalizingTrackName; - _lastFinalizingArtistName = safeArtistName; - } - return; - } - _lastFinalizingTrackName = null; - _lastFinalizingArtistName = null; - - if (items.isNotEmpty) { - if (downloadingCount > 0 && firstDownloading != null) { - final rawProgress = items[firstDownloading.id]; - if (rawProgress is! Map) { - return; - } - final selectedProgress = Map.from(rawProgress); - final bytesReceived = - (selectedProgress['bytes_received'] as num?)?.toInt() ?? 0; - final bytesTotal = - (selectedProgress['bytes_total'] as num?)?.toInt() ?? 0; - final backendStatus = - selectedProgress['status'] as String? ?? 'downloading'; - final trackName = downloadingCount == 1 - ? firstDownloading.track.name - : '$downloadingCount downloads'; - final artistName = downloadingCount == 1 - ? firstDownloading.track.artistName - : 'Downloading...'; - - int notifProgress = bytesReceived; - int notifTotal = bytesTotal; - - final progressPercent = - (selectedProgress['progress'] as num?)?.toDouble() ?? 0.0; - if (backendStatus == 'preparing') { - notifProgress = 0; - notifTotal = 0; - } else if (bytesTotal <= 0) { - notifProgress = (progressPercent * 100).toInt(); - notifTotal = 100; - } - final serviceStatus = notifTotal <= 0 ? 'preparing' : 'downloading'; - - if (!Platform.isAndroid && - _shouldUpdateProgressNotification( - trackName: trackName, - artistName: artistName, - progress: notifProgress, - total: notifTotal, - queueCount: queuedCount, - )) { - final safeNotifTotal = notifTotal > 0 ? notifTotal : 1; - _notificationService.showDownloadProgress( - trackName: trackName, - artistName: artistName, - progress: notifProgress, - total: safeNotifTotal, - ); - } - - if (Platform.isAndroid) { - _maybeUpdateAndroidDownloadService( - trackName: firstDownloading.track.name, - artistName: firstDownloading.track.artistName, - progress: notifProgress, - total: notifTotal, - queueCount: queuedCount, - status: serviceStatus, - ); - } - } - } - } - - void _maybeUpdateAndroidDownloadService({ - required String trackName, - required String artistName, - required int progress, - required int total, - required int queueCount, - String status = 'downloading', - }) { - final now = DateTime.now(); - final progressBucket = total <= 0 - ? -1 - : (() { - final progressPercent = ((progress * 100) / total) - .round() - .clamp(0, 100) - .toInt(); - return progressPercent == 100 - ? 100 - : ((progressPercent ~/ _serviceProgressStepPercent) * - _serviceProgressStepPercent) - .clamp(0, 100) - .toInt(); - })(); - - final didContentChange = - trackName != _lastServiceTrackName || - artistName != _lastServiceArtistName || - status != _lastServiceStatus || - queueCount != _lastServiceQueueCount || - progressBucket != _lastServicePercent; - final allowHeartbeat = - now.difference(_lastServiceUpdateAt) >= const Duration(seconds: 5); - - if (!didContentChange && !allowHeartbeat) { - return; - } - - _lastServiceTrackName = trackName; - _lastServiceArtistName = artistName; - _lastServiceStatus = status; - _lastServicePercent = progressBucket; - _lastServiceQueueCount = queueCount; - _lastServiceUpdateAt = now; - - PlatformBridge.updateDownloadServiceProgress( - trackName: trackName, - artistName: artistName, - progress: progress, - total: total, - queueCount: queueCount, - status: status, - ).catchError((_) {}); - } - - void _stopProgressPolling() { - _progressPoller.stop(); - _idleProgressPollTick = 0; - _lastServiceTrackName = null; - _lastServiceArtistName = null; - _lastServiceStatus = null; - _lastServicePercent = -1; - _lastServiceQueueCount = -1; - _lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0); - _lastFinalizingTrackName = null; - _lastFinalizingArtistName = null; - _lastNotifTrackName = null; - _lastNotifArtistName = null; - _lastNotifPercent = -1; - _lastNotifQueueCount = -1; - } - bool _isSafMode(AppSettings settings) { return Platform.isAndroid && settings.storageMode == 'saf' && @@ -1594,8 +953,7 @@ class DownloadQueueNotifier extends Notifier { (item) => (item.status == DownloadStatus.failed || item.status == DownloadStatus.skipped) && - (!networkOnly || - item.errorType == DownloadErrorType.network), + (!networkOnly || item.errorType == DownloadErrorType.network), ) .map((item) => item.id) .toSet(); @@ -1810,151 +1168,6 @@ class DownloadQueueNotifier extends Notifier { return null; } - bool _hasWifiConnection(List results) { - return results.contains(ConnectivityResult.wifi); - } - - void _startConnectivityMonitoring() { - _connectivitySub?.cancel(); - _connectivitySub = Connectivity().onConnectivityChanged.listen( - _handleConnectivityResults, - onError: (Object error, StackTrace stackTrace) { - _log.w('Connectivity monitoring failed: $error'); - }, - cancelOnError: false, - ); - } - - void _stopConnectivityMonitoring({bool clearNetworkPause = true}) { - if (clearNetworkPause) { - _networkPausedByWifiOnly = false; - } - // Keep listening while network-failed items remain so the reconnect - // retry prompt can still fire when the queue is otherwise idle. - if (_hasNetworkFailedItems) return; - _connectivitySub?.cancel(); - _connectivitySub = null; - } - - bool get _hasNetworkFailedItems => state.items.any( - (item) => - item.status == DownloadStatus.failed && - item.errorType == DownloadErrorType.network, - ); - - /// Offers a one-tap retry for network-failed items once connectivity - /// returns, debounced so network flapping doesn't spam snackbars. - void _maybeOfferRetryAfterReconnect(List results) { - if (results.every((result) => result == ConnectivityResult.none)) return; - final failedCount = state.items - .where( - (item) => - item.status == DownloadStatus.failed && - item.errorType == DownloadErrorType.network, - ) - .length; - if (failedCount == 0) return; - final now = DateTime.now(); - if (now.difference(_lastReconnectRetryPromptAt) < - const Duration(minutes: 1)) { - return; - } - _lastReconnectRetryPromptAt = now; - - final context = AppNavigationService.rootNavigatorKey.currentContext; - if (context == null || !context.mounted) return; - final l10n = context.l10n; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.queueNetworkFailedOffline(failedCount)), - action: SnackBarAction( - label: l10n.dialogRetry, - onPressed: () => retryAllFailed(networkOnly: true), - ), - ), - ); - } - - void _handleDownloadNetworkModeChanged(String mode) { - if (mode == 'wifi_only') { - if (state.isProcessing || _networkPausedByWifiOnly) { - _startConnectivityMonitoring(); - } - return; - } - - final shouldResume = _networkPausedByWifiOnly && state.isPaused; - // Keep monitoring active in every mode while downloading so idle - // connections are still recycled when the network switches. - if (state.isProcessing) { - _networkPausedByWifiOnly = false; - _startConnectivityMonitoring(); - } else { - _stopConnectivityMonitoring(); - } - if (shouldResume) { - resumeQueue(); - } - } - - /// Closes idle backend HTTP connections when the connectivity set changes - /// (e.g. WiFi <-> cellular). Stale sockets bound to the old interface would - /// otherwise be reused, stalling the first request after a network switch. - /// Applies to every download network mode. Fire-and-forget with a light - /// debounce so network flapping does not spam the bridge. - void _maybeCleanupOnNetworkChange(List results) { - final previous = _lastConnectivityResults; - final unchanged = - previous != null && _connectivitySetEquals(previous, results); - _lastConnectivityResults = List.unmodifiable(results); - if (previous == null || unchanged) return; - - final now = DateTime.now(); - if (now.difference(_lastConnectionCleanupAt) < _connectionCleanupDebounce) { - return; - } - _lastConnectionCleanupAt = now; - - _log.i('Network changed, closing idle backend connections'); - unawaited( - PlatformBridge.cleanupConnections().catchError((Object e) { - _log.w('Failed to clean up connections after network change: $e'); - }), - ); - } - - bool _connectivitySetEquals( - List a, - List b, - ) { - final setA = a.toSet(); - final setB = b.toSet(); - return setA.length == setB.length && setA.containsAll(setB); - } - - void _handleConnectivityResults(List results) { - _maybeCleanupOnNetworkChange(results); - _maybeOfferRetryAfterReconnect(results); - - final settings = ref.read(settingsProvider); - if (settings.downloadNetworkMode != 'wifi_only') return; - - if (_hasWifiConnection(results)) { - if (_networkPausedByWifiOnly && state.isPaused) { - _networkPausedByWifiOnly = false; - _log.i('WiFi restored, resuming network-paused queue'); - resumeQueue(); - } - return; - } - - if (state.isProcessing && !state.isPaused) { - _networkPausedByWifiOnly = true; - _log.w('WiFi connection lost, pausing active queue'); - pauseQueue(); - } - } - DownloadErrorType _downloadErrorTypeFromBackend(String? errorType) { switch (errorType) { case 'not_found': diff --git a/lib/providers/download_queue_provider_connectivity.dart b/lib/providers/download_queue_provider_connectivity.dart new file mode 100644 index 00000000..7c71ef6f --- /dev/null +++ b/lib/providers/download_queue_provider_connectivity.dart @@ -0,0 +1,150 @@ +// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member +part of 'download_queue_provider.dart'; + +extension _DownloadQueueConnectivity on DownloadQueueNotifier { + bool _hasWifiConnection(List results) { + return results.contains(ConnectivityResult.wifi); + } + + void _startConnectivityMonitoring() { + _connectivitySub?.cancel(); + _connectivitySub = Connectivity().onConnectivityChanged.listen( + _handleConnectivityResults, + onError: (Object error, StackTrace stackTrace) { + _log.w('Connectivity monitoring failed: $error'); + }, + cancelOnError: false, + ); + } + + void _stopConnectivityMonitoring({bool clearNetworkPause = true}) { + if (clearNetworkPause) { + _networkPausedByWifiOnly = false; + } + // Keep listening while network-failed items remain so the reconnect + // retry prompt can still fire when the queue is otherwise idle. + if (_hasNetworkFailedItems) return; + _connectivitySub?.cancel(); + _connectivitySub = null; + } + + bool get _hasNetworkFailedItems => state.items.any( + (item) => + item.status == DownloadStatus.failed && + item.errorType == DownloadErrorType.network, + ); + + /// Offers a one-tap retry for network-failed items once connectivity + /// returns, debounced so network flapping doesn't spam snackbars. + void _maybeOfferRetryAfterReconnect(List results) { + if (results.every((result) => result == ConnectivityResult.none)) return; + final failedCount = state.items + .where( + (item) => + item.status == DownloadStatus.failed && + item.errorType == DownloadErrorType.network, + ) + .length; + if (failedCount == 0) return; + final now = DateTime.now(); + if (now.difference(_lastReconnectRetryPromptAt) < + const Duration(minutes: 1)) { + return; + } + _lastReconnectRetryPromptAt = now; + + final context = AppNavigationService.rootNavigatorKey.currentContext; + if (context == null || !context.mounted) return; + final l10n = context.l10n; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.queueNetworkFailedOffline(failedCount)), + action: SnackBarAction( + label: l10n.dialogRetry, + onPressed: () => retryAllFailed(networkOnly: true), + ), + ), + ); + } + + void _handleDownloadNetworkModeChanged(String mode) { + if (mode == 'wifi_only') { + if (state.isProcessing || _networkPausedByWifiOnly) { + _startConnectivityMonitoring(); + } + return; + } + + final shouldResume = _networkPausedByWifiOnly && state.isPaused; + // Keep monitoring active in every mode while downloading so idle + // connections are still recycled when the network switches. + if (state.isProcessing) { + _networkPausedByWifiOnly = false; + _startConnectivityMonitoring(); + } else { + _stopConnectivityMonitoring(); + } + if (shouldResume) { + resumeQueue(); + } + } + + /// Closes idle backend HTTP connections when the connectivity set changes + /// (e.g. WiFi <-> cellular). Stale sockets bound to the old interface would + /// otherwise be reused, stalling the first request after a network switch. + /// Applies to every download network mode. Fire-and-forget with a light + /// debounce so network flapping does not spam the bridge. + void _maybeCleanupOnNetworkChange(List results) { + final previous = _lastConnectivityResults; + final unchanged = + previous != null && _connectivitySetEquals(previous, results); + _lastConnectivityResults = List.unmodifiable(results); + if (previous == null || unchanged) return; + + final now = DateTime.now(); + if (now.difference(_lastConnectionCleanupAt) < + DownloadQueueNotifier._connectionCleanupDebounce) { + return; + } + _lastConnectionCleanupAt = now; + + _log.i('Network changed, closing idle backend connections'); + unawaited( + PlatformBridge.cleanupConnections().catchError((Object e) { + _log.w('Failed to clean up connections after network change: $e'); + }), + ); + } + + bool _connectivitySetEquals( + List a, + List b, + ) { + final setA = a.toSet(); + final setB = b.toSet(); + return setA.length == setB.length && setA.containsAll(setB); + } + + void _handleConnectivityResults(List results) { + _maybeCleanupOnNetworkChange(results); + _maybeOfferRetryAfterReconnect(results); + + final settings = ref.read(settingsProvider); + if (settings.downloadNetworkMode != 'wifi_only') return; + + if (_hasWifiConnection(results)) { + if (_networkPausedByWifiOnly && state.isPaused) { + _networkPausedByWifiOnly = false; + _log.i('WiFi restored, resuming network-paused queue'); + resumeQueue(); + } + return; + } + + if (state.isProcessing && !state.isPaused) { + _networkPausedByWifiOnly = true; + _log.w('WiFi connection lost, pausing active queue'); + pauseQueue(); + } + } +} diff --git a/lib/providers/download_queue_provider_progress.dart b/lib/providers/download_queue_provider_progress.dart new file mode 100644 index 00000000..9ed43d99 --- /dev/null +++ b/lib/providers/download_queue_provider_progress.dart @@ -0,0 +1,455 @@ +// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member +part of 'download_queue_provider.dart'; + +class _ProgressUpdate { + final DownloadStatus status; + final double progress; + final double? speedMBps; + final int? bytesReceived; + final int? bytesTotal; + final String preparationStage; + + const _ProgressUpdate({ + required this.status, + required this.progress, + this.speedMBps, + this.bytesReceived, + this.bytesTotal, + this.preparationStage = '', + }); +} + +extension _DownloadQueueProgress on DownloadQueueNotifier { + double _normalizeProgressForUi(double value) { + final clamped = value.clamp(0.0, 1.0).toDouble(); + if (clamped <= 0) return 0; + if (clamped >= 1) return 1; + final rounded = double.parse(clamped.toStringAsFixed(2)); + return rounded == 0 ? 0.01 : rounded; + } + + double _normalizeSpeedForUi(double value) { + if (value <= 0) return 0; + return double.parse(value.toStringAsFixed(1)); + } + + int _normalizeBytesForUi(int value) { + if (value <= 0) return 0; + return (value ~/ DownloadQueueNotifier._bytesUiStep) * + DownloadQueueNotifier._bytesUiStep; + } + + bool _shouldUpdateProgressNotification({ + required String trackName, + required String artistName, + required int progress, + required int total, + required int queueCount, + }) { + final safeTotal = total > 0 ? total : 1; + final percent = ((progress * 100) / safeTotal).round().clamp(0, 100); + final changed = + trackName != _lastNotifTrackName || + artistName != _lastNotifArtistName || + percent != _lastNotifPercent || + queueCount != _lastNotifQueueCount; + if (!changed) { + return false; + } + + _lastNotifTrackName = trackName; + _lastNotifArtistName = artistName; + _lastNotifPercent = percent; + _lastNotifQueueCount = queueCount; + return true; + } + + void _startMultiProgressPolling() { + _idleProgressPollTick = 0; + _progressPoller.start(useStream: Platform.isAndroid || Platform.isIOS); + } + + /// Idle-cadence gate for the fallback polling timer: polls every tick while + /// a download is active, but only every [_idleProgressPollEveryTicks]th + /// tick while idle-but-queued, and not at all while paused/empty. + bool _shouldPollDownloadProgressTick() { + final currentItems = state.items; + final hasQueuedItems = currentItems.any( + (item) => item.status == DownloadStatus.queued, + ); + final hasActiveItems = currentItems.any( + (item) => + item.status == DownloadStatus.downloading || + item.status == DownloadStatus.finalizing, + ); + + if (!hasActiveItems) { + if (state.isPaused || !hasQueuedItems) { + _idleProgressPollTick = 0; + return false; + } + + _idleProgressPollTick = + (_idleProgressPollTick + 1) % + DownloadQueueNotifier._idleProgressPollEveryTicks; + return _idleProgressPollTick == 0; + } + + _idleProgressPollTick = 0; + return true; + } + + void _processAllDownloadProgress(Map allProgress) { + final rawItems = allProgress['items']; + final items = rawItems is Map + ? rawItems.map((key, value) => MapEntry(key.toString(), value)) + : const {}; + final currentItems = state.items; + final lookup = state.lookup; + final queuedCount = lookup.queuedCount; + final downloadingCount = lookup.activeDownloadsCount; + DownloadItem? firstDownloading; + bool hasFinalizingItem = lookup.finalizingCount > 0; + String? finalizingTrackName; + String? finalizingArtistName; + if (downloadingCount > 0 || hasFinalizingItem) { + for (final item in currentItems) { + if (firstDownloading == null && + item.status == DownloadStatus.downloading) { + firstDownloading = item; + } + if (finalizingTrackName == null && + item.status == DownloadStatus.finalizing) { + hasFinalizingItem = true; + finalizingTrackName = item.track.name; + finalizingArtistName = item.track.artistName; + } + if ((downloadingCount == 0 || firstDownloading != null) && + (!hasFinalizingItem || finalizingTrackName != null)) { + break; + } + } + } + final progressUpdates = {}; + + for (final entry in items.entries) { + final itemId = entry.key; + final localItem = lookup.byItemId[itemId]; + if (localItem == null) { + continue; + } + if (_isPausePending(itemId)) { + PlatformBridge.clearItemProgress(itemId).catchError((_) {}); + continue; + } + if (localItem.status == DownloadStatus.skipped) { + PlatformBridge.clearItemProgress(itemId).catchError((_) {}); + continue; + } + if (localItem.status == DownloadStatus.completed || + localItem.status == DownloadStatus.failed) { + continue; + } + if (localItem.status == DownloadStatus.finalizing) { + PlatformBridge.clearItemProgress(itemId).catchError((_) {}); + hasFinalizingItem = true; + finalizingTrackName = localItem.track.name; + finalizingArtistName = localItem.track.artistName; + continue; + } + final rawItemProgress = entry.value; + if (rawItemProgress is! Map) { + continue; + } + final itemProgress = Map.from(rawItemProgress); + final bytesReceived = + (itemProgress['bytes_received'] as num?)?.toInt() ?? 0; + final bytesTotal = (itemProgress['bytes_total'] as num?)?.toInt() ?? 0; + final speedMBps = (itemProgress['speed_mbps'] as num?)?.toDouble() ?? 0.0; + final isDownloading = itemProgress['is_downloading'] as bool? ?? false; + final status = itemProgress['status'] as String? ?? 'downloading'; + final progressFromBackend = + (itemProgress['progress'] as num?)?.toDouble() ?? 0.0; + final hasRealProgress = + status != 'preparing' && + (bytesReceived > 0 || bytesTotal > 0 || progressFromBackend > 0); + + if (status == 'finalizing') { + progressUpdates[itemId] = const _ProgressUpdate( + status: DownloadStatus.finalizing, + progress: 1.0, + ); + hasFinalizingItem = true; + finalizingTrackName = localItem.track.name; + finalizingArtistName = localItem.track.artistName; + continue; + } + + if (status == 'preparing') { + progressUpdates[itemId] = _ProgressUpdate( + status: DownloadStatus.downloading, + progress: 0.0, + speedMBps: 0, + bytesReceived: 0, + bytesTotal: 0, + preparationStage: itemProgress['stage']?.toString() ?? '', + ); + + if (LogBuffer.loggingEnabled) { + _log.d('Preparing [$itemId]: waiting for real download bytes'); + } + continue; + } + + if (isDownloading || hasRealProgress) { + double percentage = 0.0; + if (bytesTotal > 0) { + percentage = bytesReceived / bytesTotal; + } else { + percentage = progressFromBackend; + } + final normalizedProgress = _normalizeProgressForUi(percentage); + final normalizedSpeed = _normalizeSpeedForUi(speedMBps); + final normalizedBytes = _normalizeBytesForUi(bytesReceived); + + progressUpdates[itemId] = _ProgressUpdate( + status: DownloadStatus.downloading, + progress: normalizedProgress, + speedMBps: normalizedSpeed, + bytesReceived: normalizedBytes, + bytesTotal: bytesTotal, + ); + + if (LogBuffer.loggingEnabled) { + final mbReceived = bytesReceived / (1024 * 1024); + final mbTotal = bytesTotal / (1024 * 1024); + if (bytesTotal > 0) { + _log.d( + 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB) @ ${speedMBps.toStringAsFixed(2)} MB/s', + ); + } else { + _log.d( + 'Progress [$itemId]: ${(percentage * 100).toStringAsFixed(1)}% (stream/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s', + ); + } + } + } + } + + if (progressUpdates.isNotEmpty) { + var updatedItems = currentItems; + bool changed = false; + final changedIndices = []; + + for (final entry in progressUpdates.entries) { + final index = lookup.indexByItemId[entry.key]; + if (index == null) continue; + final current = updatedItems[index]; + if (current.status == DownloadStatus.skipped || + current.status == DownloadStatus.completed || + current.status == DownloadStatus.failed) { + continue; + } + final update = entry.value; + if (current.status == DownloadStatus.finalizing && + update.status != DownloadStatus.finalizing) { + continue; + } + final next = current.copyWith( + status: update.status, + progress: update.progress, + speedMBps: update.speedMBps ?? current.speedMBps, + bytesReceived: update.bytesReceived ?? current.bytesReceived, + bytesTotal: update.bytesTotal ?? current.bytesTotal, + preparationStage: update.preparationStage, + ); + if (current.status != next.status || + current.progress != next.progress || + current.speedMBps != next.speedMBps || + current.bytesReceived != next.bytesReceived || + current.bytesTotal != next.bytesTotal || + current.preparationStage != next.preparationStage) { + if (!changed) { + updatedItems = List.from(updatedItems); + changed = true; + } + updatedItems[index] = next; + changedIndices.add(index); + } + } + + if (changed) { + state = state.copyWith( + items: updatedItems, + lookup: state.lookup.updatedForIndices( + previousItems: currentItems, + nextItems: updatedItems, + changedIndices: changedIndices, + ), + ); + } + } + + if (hasFinalizingItem && finalizingTrackName != null) { + final safeArtistName = finalizingArtistName ?? ''; + if (Platform.isAndroid) { + _maybeUpdateAndroidDownloadService( + trackName: finalizingTrackName, + artistName: _notificationService.embeddingMetadataLabel, + progress: 100, + total: 100, + queueCount: queuedCount, + status: 'finalizing', + ); + } else if (finalizingTrackName != _lastFinalizingTrackName || + safeArtistName != _lastFinalizingArtistName) { + _notificationService.showDownloadFinalizing( + trackName: finalizingTrackName, + artistName: safeArtistName, + ); + _lastFinalizingTrackName = finalizingTrackName; + _lastFinalizingArtistName = safeArtistName; + } + return; + } + _lastFinalizingTrackName = null; + _lastFinalizingArtistName = null; + + if (items.isNotEmpty) { + if (downloadingCount > 0 && firstDownloading != null) { + final rawProgress = items[firstDownloading.id]; + if (rawProgress is! Map) { + return; + } + final selectedProgress = Map.from(rawProgress); + final bytesReceived = + (selectedProgress['bytes_received'] as num?)?.toInt() ?? 0; + final bytesTotal = + (selectedProgress['bytes_total'] as num?)?.toInt() ?? 0; + final backendStatus = + selectedProgress['status'] as String? ?? 'downloading'; + final trackName = downloadingCount == 1 + ? firstDownloading.track.name + : '$downloadingCount downloads'; + final artistName = downloadingCount == 1 + ? firstDownloading.track.artistName + : 'Downloading...'; + + int notifProgress = bytesReceived; + int notifTotal = bytesTotal; + + final progressPercent = + (selectedProgress['progress'] as num?)?.toDouble() ?? 0.0; + if (backendStatus == 'preparing') { + notifProgress = 0; + notifTotal = 0; + } else if (bytesTotal <= 0) { + notifProgress = (progressPercent * 100).toInt(); + notifTotal = 100; + } + final serviceStatus = notifTotal <= 0 ? 'preparing' : 'downloading'; + + if (!Platform.isAndroid && + _shouldUpdateProgressNotification( + trackName: trackName, + artistName: artistName, + progress: notifProgress, + total: notifTotal, + queueCount: queuedCount, + )) { + final safeNotifTotal = notifTotal > 0 ? notifTotal : 1; + _notificationService.showDownloadProgress( + trackName: trackName, + artistName: artistName, + progress: notifProgress, + total: safeNotifTotal, + ); + } + + if (Platform.isAndroid) { + _maybeUpdateAndroidDownloadService( + trackName: firstDownloading.track.name, + artistName: firstDownloading.track.artistName, + progress: notifProgress, + total: notifTotal, + queueCount: queuedCount, + status: serviceStatus, + ); + } + } + } + } + + void _maybeUpdateAndroidDownloadService({ + required String trackName, + required String artistName, + required int progress, + required int total, + required int queueCount, + String status = 'downloading', + }) { + final now = DateTime.now(); + final progressBucket = total <= 0 + ? -1 + : (() { + final progressPercent = ((progress * 100) / total) + .round() + .clamp(0, 100) + .toInt(); + return progressPercent == 100 + ? 100 + : ((progressPercent ~/ + DownloadQueueNotifier + ._serviceProgressStepPercent) * + DownloadQueueNotifier._serviceProgressStepPercent) + .clamp(0, 100) + .toInt(); + })(); + + final didContentChange = + trackName != _lastServiceTrackName || + artistName != _lastServiceArtistName || + status != _lastServiceStatus || + queueCount != _lastServiceQueueCount || + progressBucket != _lastServicePercent; + final allowHeartbeat = + now.difference(_lastServiceUpdateAt) >= const Duration(seconds: 5); + + if (!didContentChange && !allowHeartbeat) { + return; + } + + _lastServiceTrackName = trackName; + _lastServiceArtistName = artistName; + _lastServiceStatus = status; + _lastServicePercent = progressBucket; + _lastServiceQueueCount = queueCount; + _lastServiceUpdateAt = now; + + PlatformBridge.updateDownloadServiceProgress( + trackName: trackName, + artistName: artistName, + progress: progress, + total: total, + queueCount: queueCount, + status: status, + ).catchError((_) {}); + } + + void _stopProgressPolling() { + _progressPoller.stop(); + _idleProgressPollTick = 0; + _lastServiceTrackName = null; + _lastServiceArtistName = null; + _lastServiceStatus = null; + _lastServicePercent = -1; + _lastServiceQueueCount = -1; + _lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0); + _lastFinalizingTrackName = null; + _lastFinalizingArtistName = null; + _lastNotifTrackName = null; + _lastNotifArtistName = null; + _lastNotifPercent = -1; + _lastNotifQueueCount = -1; + } +} diff --git a/lib/providers/download_queue_provider_verification.dart b/lib/providers/download_queue_provider_verification.dart new file mode 100644 index 00000000..3cecb891 --- /dev/null +++ b/lib/providers/download_queue_provider_verification.dart @@ -0,0 +1,199 @@ +// ignore_for_file: invalid_use_of_protected_member, invalid_use_of_visible_for_testing_member +part of 'download_queue_provider.dart'; + +extension _DownloadQueueVerificationGate on DownloadQueueNotifier { + /// Completes when the app is in the foreground. Verification challenges + /// can only be handled there: launching a browser from the background is + /// blocked by the OS and the challenge would expire unseen. + Future _waitForForeground() async { + if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) { + return; + } + final completer = Completer(); + final listener = AppLifecycleListener( + onResume: () { + if (!completer.isCompleted) completer.complete(); + }, + ); + try { + await completer.future; + } finally { + listener.dispose(); + } + } + + Future _openVerificationAndWait(String extensionId) { + final key = extensionId.trim().toLowerCase(); + final activeFlow = _verificationFlowsByExtension[key]; + if (activeFlow != null) { + _log.i( + 'Joining active verification flow for $extensionId instead of opening another challenge', + ); + return activeFlow; + } + + final flow = _runVerificationFlow(extensionId, key); + _verificationFlowsByExtension[key] = flow; + return flow; + } + + Future _runVerificationFlow(String extensionId, String key) async { + try { + return await openVerificationAndAwaitGrant( + extensionId, + browserMode: ref + .read(settingsProvider) + .extensionVerificationBrowserMode, + awaitForeground: (normalizedExtensionId) async { + if (WidgetsBinding.instance.lifecycleState != + AppLifecycleState.resumed) { + _log.i( + 'Verification required for $normalizedExtensionId while app is in ' + 'background; deferring challenge until the app is foregrounded', + ); + try { + await _notificationService.showVerificationRequired(); + } catch (error) { + _log.w( + 'Failed to show the verification-required notification: $error', + ); + } + await _waitForForeground(); + try { + await _notificationService.cancelVerificationRequired(); + } catch (error) { + _log.w( + 'Failed to clear the verification-required notification: $error', + ); + } + } + }, + ); + } finally { + _verificationFlowsByExtension.remove(key); + } + } + + Future _handleVerificationRequiredDownload( + DownloadItem item, + String errorMsg, + String? verificationService, + ) async { + final targetService = (verificationService ?? '').trim().isNotEmpty + ? verificationService!.trim() + : item.service; + if (_verificationRetryGuard.hasRetriedAfterGrant(item.id, targetService)) { + _log.e( + 'Verification was already completed once for ${item.track.name} on $targetService; not opening another challenge', + ); + updateItemStatus( + item.id, + DownloadStatus.failed, + error: errorMsg, + errorType: DownloadErrorType.verificationRequired, + ); + _failedInSession++; + return true; + } + _log.i( + 'Download for ${item.track.name} requires verification; waiting for $targetService grant', + ); + updateItemStatus( + item.id, + DownloadStatus.downloading, + error: 'Waiting for verification', + errorType: DownloadErrorType.verificationRequired, + ); + + final verified = await _openVerificationAndWait(targetService); + final current = _findItemById(item.id); + if (current == null || _isLocallyCancelled(item.id, item: current)) { + _log.i('Verification completed after item was removed or cancelled'); + return true; + } + + // Only a completed grant consumes the automatic retry. If bootstrap or + // browser launch failed before a challenge opened, a later attempt must + // still be allowed after the network changes. + _verificationRetryGuard.recordVerificationResult( + item.id, + targetService, + granted: verified, + ); + if (verified) { + _log.i( + 'Verification complete for $targetService; retrying ${item.track.name}', + ); + updateItemStatus( + item.id, + DownloadStatus.queued, + progress: 0, + speedMBps: 0, + error: 'Retrying after verification', + errorType: DownloadErrorType.verificationRequired, + ); + _saveQueueToStorage(); + return true; + } + + _log.e('Verification did not complete for $targetService'); + updateItemStatus( + item.id, + DownloadStatus.failed, + error: errorMsg, + errorType: DownloadErrorType.verificationRequired, + ); + _failedInSession++; + return true; + } + + Duration _rateLimitBackoffDelay(String errorMsg) { + final lower = errorMsg.toLowerCase(); + final retryAfterMatch = RegExp( + r'retry[- ]?after(?: seconds)?[:= ]+(\d+)', + caseSensitive: false, + ).firstMatch(lower); + final parsedSeconds = retryAfterMatch == null + ? null + : int.tryParse(retryAfterMatch.group(1) ?? ''); + final seconds = (parsedSeconds ?? 30).clamp(5, 300).toInt(); + return Duration(seconds: seconds); + } + + Future _handleRateLimitedDownload( + DownloadItem item, + String errorMsg, + ) async { + if (_rateLimitRetriedItemIds.contains(item.id)) { + return false; + } + _rateLimitRetriedItemIds.add(item.id); + + final delay = _rateLimitBackoffDelay(errorMsg); + _log.i( + 'Rate limited while downloading ${item.track.name}; retrying after ${delay.inSeconds}s', + ); + updateItemStatus( + item.id, + DownloadStatus.downloading, + error: 'Rate limited, retrying after ${delay.inSeconds}s', + errorType: DownloadErrorType.rateLimit, + ); + + await Future.delayed(delay); + final current = _findItemById(item.id); + if (current == null || _isLocallyCancelled(item.id, item: current)) { + return true; + } + updateItemStatus( + item.id, + DownloadStatus.queued, + progress: 0, + speedMBps: 0, + error: 'Retrying after rate limit', + errorType: DownloadErrorType.rateLimit, + ); + _saveQueueToStorage(); + return true; + } +}