mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-02 17:18:36 +02:00
1473 lines
50 KiB
Dart
1473 lines
50 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'package:flutter/material.dart'
|
|
show ScaffoldMessenger, SnackBar, SnackBarAction, Text;
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:spotiflac_android/l10n/l10n.dart';
|
|
import 'package:spotiflac_android/models/download_item.dart';
|
|
import 'package:spotiflac_android/models/settings.dart';
|
|
import 'package:spotiflac_android/models/track.dart';
|
|
import 'package:spotiflac_android/services/app_navigation_service.dart';
|
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
|
import 'package:spotiflac_android/providers/download_verification_retry_guard.dart';
|
|
import 'package:spotiflac_android/providers/download_queue_state.dart';
|
|
import 'package:spotiflac_android/services/app_state_database.dart';
|
|
import 'package:spotiflac_android/services/platform_bridge.dart';
|
|
import 'package:spotiflac_android/services/download_request_payload.dart';
|
|
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
|
import 'package:spotiflac_android/services/notification_service.dart';
|
|
import 'package:spotiflac_android/utils/logger.dart' hide log;
|
|
import 'package:spotiflac_android/utils/file_access.dart';
|
|
import 'package:spotiflac_android/utils/string_utils.dart';
|
|
import 'package:spotiflac_android/utils/artist_utils.dart';
|
|
import 'package:spotiflac_android/utils/audio_format_utils.dart';
|
|
import 'package:spotiflac_android/utils/int_utils.dart';
|
|
import 'package:spotiflac_android/utils/extension_auth_launcher.dart';
|
|
import 'package:spotiflac_android/utils/progress_stream_poller.dart';
|
|
|
|
import 'package:spotiflac_android/providers/download_history_provider.dart';
|
|
|
|
export 'package:spotiflac_android/providers/download_history_provider.dart';
|
|
export 'package:spotiflac_android/providers/download_queue_state.dart';
|
|
|
|
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';
|
|
part 'download_queue_provider_embedding.dart';
|
|
part 'download_queue_provider_single_item.dart';
|
|
|
|
final _log = AppLogger('DownloadQueue');
|
|
|
|
/// Set on queued items when the persisted Android SAF grant fails validation.
|
|
/// The queue UI matches on this to offer re-selecting the download folder.
|
|
const String safPermissionLostErrorMessage =
|
|
'SAF permission invalid or revoked. Please reconfigure download location in Settings.';
|
|
|
|
/// Set on queued items when the iOS download folder bookmark can no longer be
|
|
/// opened. The queue UI matches on this to offer re-selecting the folder.
|
|
const String downloadFolderAccessLostErrorMessage =
|
|
'Download folder access lost. Please re-select your download folder in Settings.';
|
|
|
|
/// Keeps a download in its finalizing state until its durable Library record
|
|
/// has been written. If persistence fails, completion is deliberately not
|
|
/// published so the queue can surface the error instead of losing the file
|
|
/// from the app while it still exists on disk.
|
|
Future<void> persistBeforePublishingDownloadCompletion({
|
|
required Future<void> Function() persist,
|
|
required void Function() publish,
|
|
}) async {
|
|
await persist();
|
|
publish();
|
|
}
|
|
|
|
final _invalidFolderChars = RegExp(r'[<>:"/\\|?*]');
|
|
final _trimDotsAndSpacesRegex = RegExp(r'^[. ]+|[. ]+$');
|
|
final _trimUnderscoresAndSpacesRegex = RegExp(r'^[_ ]+|[_ ]+$');
|
|
final _multiWhitespaceRegex = RegExp(r'\s+');
|
|
final _multiUnderscoreRegex = RegExp(r'_+');
|
|
|
|
/// log10 helper using dart:math's natural log.
|
|
double _log10(num x) => log(x) / ln10;
|
|
final _yearRegex = RegExp(r'^(\d{4})');
|
|
const _defaultOutputFolderName = 'SpotiFLAC';
|
|
const _defaultAndroidMusicSubpath = 'Music/$_defaultOutputFolderName';
|
|
const _maxSafFilenameUtf8Bytes = 180;
|
|
const _maxSafDirSegmentUtf8Bytes = 120;
|
|
final _batchUniqueFilenameTokenPattern = RegExp(
|
|
r'\{(?:title|track(?:_raw)?|track:\d+|playlist_position(?:_raw)?|playlist_position:\d+|playlist position|playlistPosition|position(?::\d+)?)\}',
|
|
caseSensitive: false,
|
|
);
|
|
final _qualityFilenameTokenPattern = RegExp(
|
|
r'\{quality_variant\}',
|
|
caseSensitive: false,
|
|
);
|
|
|
|
class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|
Timer? _queuePersistDebounce;
|
|
Future<void> _queuePersistenceWrite = Future<void>.value();
|
|
final Map<String, String> _persistedQueueJsonById = {};
|
|
StreamSubscription<List<ConnectivityResult>>? _connectivitySub;
|
|
int _downloadCount = 0;
|
|
static const _cleanupInterval = 50;
|
|
static const _progressPollingInterval = Duration(milliseconds: 1200);
|
|
static const _idleProgressPollEveryTicks = 3;
|
|
static const _progressStreamBootstrapTimeout = Duration(seconds: 3);
|
|
static const _queueSchedulingInterval = Duration(milliseconds: 250);
|
|
static const _queuePersistDebounceDuration = Duration(milliseconds: 350);
|
|
static const _nativeWorkerRunIdPrefsKey =
|
|
'download_queue_native_worker_run_id';
|
|
static const _bytesUiStep = 104857; // ~0.1 MiB, matches one-decimal MB UI.
|
|
static const _serviceProgressStepPercent = 2;
|
|
static const _decryptStageSafAccess = 'safAccess';
|
|
static const _decryptStageDecrypt = 'decrypt';
|
|
static const _decryptStageSafWrite = 'safWrite';
|
|
final NotificationService _notificationService = NotificationService();
|
|
final AppStateDatabase _appStateDb = AppStateDatabase.instance;
|
|
// Shared across tracks in a batch: an album's tracks embed the same cover,
|
|
// so fetch it once instead of once per track. LRU-capped; files are deleted
|
|
// on eviction and when the queue drains.
|
|
final Map<String, Future<String?>> _embedCoverCache = {};
|
|
late final ProgressStreamPoller<Map<String, dynamic>> _progressPoller =
|
|
ProgressStreamPoller<Map<String, dynamic>>(
|
|
streamProvider: PlatformBridge.downloadProgressStream,
|
|
pollProvider: PlatformBridge.getAllDownloadProgress,
|
|
onProgress: (allProgress) async =>
|
|
_processAllDownloadProgress(allProgress),
|
|
pollingInterval: _progressPollingInterval,
|
|
bootstrapTimeout: _progressStreamBootstrapTimeout,
|
|
onStreamProcessingError: (e) =>
|
|
_log.w('Progress stream processing failed: $e'),
|
|
onStreamFailed: (error) => _log.w(
|
|
'Download progress stream failed, fallback to polling: $error',
|
|
),
|
|
onStreamTimeout: () =>
|
|
_log.w('Download progress stream timeout, fallback to polling'),
|
|
onPollError: (e) => _log.w('Progress polling failed: $e'),
|
|
shouldPollTick: () => _shouldPollDownloadProgressTick(),
|
|
);
|
|
int _totalQueuedAtStart = 0;
|
|
int _completedInSession = 0;
|
|
int _failedInSession = 0;
|
|
int _queueItemSequence = 0;
|
|
bool _isLoaded = false;
|
|
final Set<String> _ensuredDirs = {};
|
|
int _idleProgressPollTick = 0;
|
|
bool _networkPausedByWifiOnly = false;
|
|
List<ConnectivityResult>? _lastConnectivityResults;
|
|
DateTime _lastConnectionCleanupAt = DateTime.fromMillisecondsSinceEpoch(0);
|
|
DateTime _lastReconnectRetryPromptAt = DateTime.fromMillisecondsSinceEpoch(0);
|
|
static const _connectionCleanupDebounce = Duration(seconds: 2);
|
|
String? _lastServiceTrackName;
|
|
String? _lastServiceArtistName;
|
|
String? _lastServiceStatus;
|
|
int _lastServicePercent = -1;
|
|
int _lastServiceQueueCount = -1;
|
|
DateTime _lastServiceUpdateAt = DateTime.fromMillisecondsSinceEpoch(0);
|
|
String? _lastFinalizingTrackName;
|
|
String? _lastFinalizingArtistName;
|
|
String? _lastNotifTrackName;
|
|
String? _lastNotifArtistName;
|
|
int _lastNotifPercent = -1;
|
|
int _lastNotifQueueCount = -1;
|
|
final Set<String> _locallyCancelledItemIds = {};
|
|
final Set<String> _pausePendingItemIds = {};
|
|
final DownloadVerificationRetryGuard _verificationRetryGuard =
|
|
DownloadVerificationRetryGuard();
|
|
final Map<String, Future<bool>> _verificationFlowsByExtension = {};
|
|
final Set<String> _rateLimitRetriedItemIds = {};
|
|
String? _activeNativeWorkerRunId;
|
|
bool get _hasActiveAndroidNativeWorker =>
|
|
Platform.isAndroid && _activeNativeWorkerRunId?.isNotEmpty == true;
|
|
|
|
// Album ReplayGain accumulator: keyed by album identifier.
|
|
// Stores per-track loudness data until all album tracks are done,
|
|
// then computes and writes album gain/peak to every track in the album.
|
|
final Map<String, _AlbumRgAccumulator> _albumRgData = {};
|
|
|
|
@override
|
|
DownloadQueueState build() {
|
|
ref.listen<AppSettings>(settingsProvider, (previous, next) {
|
|
updateSettings(next);
|
|
if (previous?.downloadNetworkMode != next.downloadNetworkMode) {
|
|
_handleDownloadNetworkModeChanged(next.downloadNetworkMode);
|
|
}
|
|
});
|
|
|
|
ref.onDispose(() {
|
|
_progressPoller.stop();
|
|
_connectivitySub?.cancel();
|
|
_connectivitySub = null;
|
|
if (_queuePersistDebounce?.isActive == true) {
|
|
_queuePersistDebounce?.cancel();
|
|
unawaited(_flushQueueToStorage());
|
|
} else {
|
|
_queuePersistDebounce?.cancel();
|
|
}
|
|
_queuePersistDebounce = null;
|
|
});
|
|
|
|
Future.microtask(() async {
|
|
updateSettings(ref.read(settingsProvider));
|
|
await _initOutputDir();
|
|
await _loadQueueFromStorage();
|
|
});
|
|
return const DownloadQueueState();
|
|
}
|
|
|
|
/// Flush any debounced queue persistence to disk immediately. Called when
|
|
/// the app is backgrounded: the OS may kill the process without a graceful
|
|
/// teardown, and a pending debounce timer would silently drop the most
|
|
/// recent queue mutations.
|
|
Future<void> flushQueuePersistence() async {
|
|
if (_queuePersistDebounce?.isActive == true) {
|
|
_queuePersistDebounce?.cancel();
|
|
await _flushQueueToStorage();
|
|
}
|
|
await _queuePersistenceWrite;
|
|
}
|
|
|
|
Future<void> _loadQueueFromStorage() async {
|
|
if (_isLoaded) return;
|
|
_isLoaded = true;
|
|
|
|
try {
|
|
await _appStateDb.migrateQueueFromSharedPreferences();
|
|
final rows = await _appStateDb.getPendingDownloadQueueRows();
|
|
_persistedQueueJsonById
|
|
..clear()
|
|
..addEntries(
|
|
rows
|
|
.map(
|
|
(row) => MapEntry(
|
|
row['id']?.toString() ?? '',
|
|
row['item_json']?.toString() ?? '',
|
|
),
|
|
)
|
|
.where((entry) => entry.key.isNotEmpty),
|
|
);
|
|
if (rows.isEmpty) {
|
|
_log.d('No queue found in storage');
|
|
return;
|
|
}
|
|
|
|
final pendingItems = <DownloadItem>[];
|
|
for (final row in rows) {
|
|
final itemJson = row['item_json'] as String?;
|
|
if (itemJson == null || itemJson.isEmpty) continue;
|
|
|
|
try {
|
|
final decoded = jsonDecode(itemJson);
|
|
if (decoded is! Map) continue;
|
|
var item = DownloadItem.fromJson(Map<String, dynamic>.from(decoded));
|
|
final normalizedService = _normalizeQueuedService(item.service);
|
|
if (normalizedService != item.service) {
|
|
item = item.copyWith(service: normalizedService);
|
|
}
|
|
if (item.status == DownloadStatus.downloading ||
|
|
item.status == DownloadStatus.finalizing) {
|
|
item = item.copyWith(status: DownloadStatus.queued, progress: 0);
|
|
}
|
|
if (item.status == DownloadStatus.queued) {
|
|
pendingItems.add(item);
|
|
}
|
|
} catch (_) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (pendingItems.isEmpty) {
|
|
_log.d('No pending items to restore');
|
|
await _appStateDb.replacePendingDownloadQueueRows(const []);
|
|
_persistedQueueJsonById.clear();
|
|
return;
|
|
}
|
|
|
|
final normalizedPendingItems = _normalizeRestoredQueueIds(pendingItems);
|
|
state = state.copyWith(items: normalizedPendingItems);
|
|
_saveQueueToStorage();
|
|
_log.i(
|
|
'Restored ${normalizedPendingItems.length} pending items from storage',
|
|
);
|
|
if (await _tryAdoptAndroidNativeWorkerSnapshot(normalizedPendingItems)) {
|
|
return;
|
|
}
|
|
Future.microtask(() => _processQueue());
|
|
} catch (e) {
|
|
_log.e('Failed to load queue from storage: $e');
|
|
}
|
|
}
|
|
|
|
void _saveQueueToStorage() {
|
|
_queuePersistDebounce?.cancel();
|
|
_queuePersistDebounce = Timer(_queuePersistDebounceDuration, () {
|
|
_flushQueueToStorage();
|
|
});
|
|
}
|
|
|
|
Future<void> _flushQueueToStorage() async {
|
|
final operation = _queuePersistenceWrite.then((_) => _writeQueueChanges());
|
|
_queuePersistenceWrite = operation.catchError((_) {});
|
|
await operation;
|
|
}
|
|
|
|
Future<void> _writeQueueChanges() async {
|
|
try {
|
|
final pendingItems = state.items
|
|
.where(
|
|
(item) =>
|
|
item.status == DownloadStatus.queued ||
|
|
item.status == DownloadStatus.downloading ||
|
|
item.status == DownloadStatus.finalizing,
|
|
)
|
|
.toList(growable: false);
|
|
final nowIso = DateTime.now().toIso8601String();
|
|
final currentJsonById = <String, String>{};
|
|
final upserts = <Map<String, dynamic>>[];
|
|
for (final item in pendingItems) {
|
|
final itemJson = jsonEncode(item.toJson());
|
|
currentJsonById[item.id] = itemJson;
|
|
if (_persistedQueueJsonById[item.id] == itemJson) continue;
|
|
upserts.add({
|
|
'id': item.id,
|
|
'item_json': itemJson,
|
|
'status': item.status.name,
|
|
'created_at': item.createdAt.toIso8601String(),
|
|
'updated_at': nowIso,
|
|
});
|
|
}
|
|
final deletedIds = _persistedQueueJsonById.keys
|
|
.where((id) => !currentJsonById.containsKey(id))
|
|
.toList(growable: false);
|
|
if (upserts.isEmpty && deletedIds.isEmpty) return;
|
|
|
|
await _appStateDb.applyPendingDownloadQueueChanges(
|
|
upserts: upserts,
|
|
deletedIds: deletedIds,
|
|
);
|
|
_persistedQueueJsonById
|
|
..clear()
|
|
..addAll(currentJsonById);
|
|
_log.d(
|
|
'Persisted ${upserts.length} changed and removed '
|
|
'${deletedIds.length} queue items',
|
|
);
|
|
} catch (e) {
|
|
_log.e('Failed to save queue to storage: $e');
|
|
}
|
|
}
|
|
|
|
bool _isSafMode(AppSettings settings) {
|
|
return Platform.isAndroid &&
|
|
settings.storageMode == 'saf' &&
|
|
settings.downloadTreeUri.isNotEmpty;
|
|
}
|
|
|
|
String _normalizeQueuedService(String service) {
|
|
final normalized = service.trim();
|
|
if (normalized.isEmpty) {
|
|
return normalized;
|
|
}
|
|
|
|
final replacement = ref
|
|
.read(extensionProvider.notifier)
|
|
.replacedBuiltInDownloadProviderFor(normalized);
|
|
if (replacement != null && replacement.isNotEmpty) {
|
|
return replacement;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
bool _hasActiveDownloadProvider(String service) {
|
|
final normalized = service.trim();
|
|
if (normalized.isEmpty) {
|
|
return false;
|
|
}
|
|
|
|
final extensionState = ref.read(extensionProvider);
|
|
return extensionState.extensions.any(
|
|
(ext) =>
|
|
ext.enabled &&
|
|
ext.hasDownloadProvider &&
|
|
ext.id.toLowerCase() == normalized.toLowerCase(),
|
|
);
|
|
}
|
|
|
|
/// Builds the backend [DownloadRequestPayload] shared by the native-worker
|
|
/// request builder and the inline single-item download path. Fields whose
|
|
/// source value legitimately differs between the two callers (SAF staging,
|
|
/// download strategy) are left as parameters.
|
|
DownloadRequestPayload _buildDownloadRequestPayload({
|
|
required Track track,
|
|
required DownloadItem item,
|
|
required AppSettings settings,
|
|
required ExtensionState extensionState,
|
|
required String quality,
|
|
required String filenameFormat,
|
|
required String outputDir,
|
|
required String outputExt,
|
|
required bool useSaf,
|
|
String? safFileName,
|
|
String? deezerTrackId,
|
|
String? genre,
|
|
String? label,
|
|
String? copyright,
|
|
bool stageSafForDeferredPublish = false,
|
|
}) {
|
|
final resolvedAlbumArtist = _resolveAlbumArtistForMetadata(track, settings);
|
|
final postProcessingEnabled =
|
|
settings.useExtensionProviders &&
|
|
extensionState.extensions.any((e) => e.enabled && e.hasPostProcessing);
|
|
final normalizedTrackNumber =
|
|
(track.trackNumber != null && track.trackNumber! > 0)
|
|
? track.trackNumber!
|
|
: 0;
|
|
final normalizedDiscNumber =
|
|
(track.discNumber != null && track.discNumber! > 0)
|
|
? track.discNumber!
|
|
: 0;
|
|
|
|
String payloadSpotifyId = track.id;
|
|
String payloadQobuzId = '';
|
|
String payloadTidalId = '';
|
|
if (track.id.startsWith('qobuz:')) {
|
|
payloadQobuzId = track.id.substring(6);
|
|
if (_downloadProviderReplacesLegacyProvider(item.service, 'qobuz')) {
|
|
payloadSpotifyId = '';
|
|
}
|
|
}
|
|
if (track.id.startsWith('tidal:')) {
|
|
payloadTidalId = track.id.substring(6);
|
|
if (_downloadProviderReplacesLegacyProvider(item.service, 'tidal')) {
|
|
payloadSpotifyId = '';
|
|
}
|
|
}
|
|
|
|
return DownloadRequestPayload(
|
|
isrc: track.isrc ?? '',
|
|
service: item.service,
|
|
spotifyId: payloadSpotifyId,
|
|
trackName: track.name,
|
|
artistName: track.artistName,
|
|
albumName: track.albumName,
|
|
albumArtist: resolvedAlbumArtist ?? '',
|
|
coverUrl: settings.embedMetadata ? (track.coverUrl ?? '') : '',
|
|
outputDir: outputDir,
|
|
filenameFormat: filenameFormat,
|
|
quality: quality,
|
|
embedMetadata: settings.embedMetadata,
|
|
artistTagMode: settings.artistTagMode,
|
|
embedLyrics:
|
|
settings.embedMetadata &&
|
|
settings.embedLyrics &&
|
|
!_shouldSkipLyrics(extensionState, track.source, item.service),
|
|
embedMaxQualityCover: settings.embedMetadata && settings.maxQualityCover,
|
|
embedReplayGain: settings.embedReplayGain,
|
|
postProcessingEnabled: postProcessingEnabled,
|
|
tidalHighFormat: settings.tidalHighFormat,
|
|
trackNumber: normalizedTrackNumber,
|
|
playlistPosition: _validPlaylistPosition(item),
|
|
discNumber: normalizedDiscNumber,
|
|
totalTracks: track.totalTracks ?? 0,
|
|
totalDiscs: track.totalDiscs ?? 0,
|
|
releaseDate: track.releaseDate ?? '',
|
|
itemId: item.id,
|
|
durationMs: track.duration * 1000,
|
|
source: track.source ?? '',
|
|
genre: genre ?? '',
|
|
label: label ?? '',
|
|
copyright: copyright ?? '',
|
|
composer: track.composer ?? '',
|
|
qobuzId: payloadQobuzId,
|
|
tidalId: payloadTidalId,
|
|
deezerId: deezerTrackId ?? '',
|
|
lyricsMode: settings.lyricsMode,
|
|
storageMode: useSaf ? 'saf' : 'app',
|
|
safTreeUri: useSaf ? settings.downloadTreeUri : '',
|
|
safRelativeDir: useSaf ? outputDir : '',
|
|
safFileName: useSaf ? (safFileName ?? '') : '',
|
|
safOutputExt: useSaf ? outputExt : '',
|
|
outputExt: outputExt,
|
|
stageSafOutput: stageSafForDeferredPublish,
|
|
deferSafPublish: stageSafForDeferredPublish,
|
|
requiresContainerConversion: _shouldRequestContainerConversion(
|
|
item.service,
|
|
outputExt,
|
|
),
|
|
allowQualityVariant: item.preserveQualityVariant,
|
|
qualityVariant: item.preserveQualityVariant
|
|
? qualityVariantStagingLabel(item.id)
|
|
: '',
|
|
songLinkRegion: settings.songLinkRegion,
|
|
);
|
|
}
|
|
|
|
String _newQueueItemId(Track track, {Set<String>? takenIds}) {
|
|
final trimmedIsrc = track.isrc?.trim();
|
|
final trimmedTrackId = track.id.trim();
|
|
final base = (trimmedIsrc != null && trimmedIsrc.isNotEmpty)
|
|
? trimmedIsrc
|
|
: (trimmedTrackId.isNotEmpty ? trimmedTrackId : 'track');
|
|
|
|
while (true) {
|
|
_queueItemSequence++;
|
|
final candidate =
|
|
'$base-${DateTime.now().microsecondsSinceEpoch}-$_queueItemSequence';
|
|
if (takenIds == null || !takenIds.contains(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
List<DownloadItem> _normalizeRestoredQueueIds(List<DownloadItem> items) {
|
|
if (items.isEmpty) return items;
|
|
|
|
final seen = <String>{};
|
|
var regeneratedCount = 0;
|
|
final normalized = <DownloadItem>[];
|
|
|
|
for (final item in items) {
|
|
final trimmedId = item.id.trim();
|
|
final shouldRegenerate = trimmedId.isEmpty || seen.contains(trimmedId);
|
|
if (shouldRegenerate) {
|
|
final newId = _newQueueItemId(item.track, takenIds: seen);
|
|
seen.add(newId);
|
|
normalized.add(item.copyWith(id: newId));
|
|
regeneratedCount++;
|
|
} else {
|
|
seen.add(trimmedId);
|
|
normalized.add(item);
|
|
}
|
|
}
|
|
|
|
if (regeneratedCount > 0) {
|
|
_log.w(
|
|
'Regenerated $regeneratedCount duplicate/empty queue item IDs during restore',
|
|
);
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
void updateSettings(AppSettings settings) {
|
|
state = state.copyWith(
|
|
outputDir: settings.downloadDirectory.isNotEmpty
|
|
? settings.downloadDirectory
|
|
: state.outputDir,
|
|
filenameFormat: settings.filenameFormat,
|
|
singleFilenameFormat: settings.singleFilenameFormat,
|
|
audioQuality: settings.audioQuality,
|
|
autoFallback: settings.autoFallback,
|
|
);
|
|
}
|
|
|
|
String addToQueue(
|
|
Track track,
|
|
String service, {
|
|
String? qualityOverride,
|
|
String? playlistName,
|
|
int? playlistPosition,
|
|
}) {
|
|
final settings = ref.read(settingsProvider);
|
|
updateSettings(settings);
|
|
|
|
final takenIds = state.items.map((item) => item.id).toSet();
|
|
final id = _newQueueItemId(track, takenIds: takenIds);
|
|
final item = DownloadItem(
|
|
id: id,
|
|
track: track,
|
|
service: _normalizeQueuedService(service),
|
|
createdAt: DateTime.now(),
|
|
qualityOverride: qualityOverride,
|
|
playlistName: playlistName,
|
|
playlistPosition: playlistPosition,
|
|
preserveQualityVariant: settings.allowQualityVariants,
|
|
);
|
|
|
|
state = state.copyWith(items: [...state.items, item]);
|
|
_saveQueueToStorage();
|
|
|
|
if (!state.isProcessing) {
|
|
Future.microtask(() => _processQueue());
|
|
}
|
|
|
|
return id;
|
|
}
|
|
|
|
void addMultipleToQueue(
|
|
List<Track> tracks,
|
|
String service, {
|
|
String? qualityOverride,
|
|
String? playlistName,
|
|
List<int?>? playlistPositions,
|
|
}) {
|
|
final settings = ref.read(settingsProvider);
|
|
updateSettings(settings);
|
|
|
|
final takenIds = state.items.map((item) => item.id).toSet();
|
|
final shouldAssignPlaylistPositions =
|
|
playlistName != null && playlistName.trim().isNotEmpty;
|
|
final fromBatch = tracks.length > 1;
|
|
final newItems = tracks.asMap().entries.map((entry) {
|
|
final track = entry.value;
|
|
final index = entry.key;
|
|
final explicitPosition =
|
|
playlistPositions != null &&
|
|
index < playlistPositions.length &&
|
|
(playlistPositions[index] ?? 0) > 0
|
|
? playlistPositions[index]
|
|
: null;
|
|
final id = _newQueueItemId(track, takenIds: takenIds);
|
|
takenIds.add(id);
|
|
return DownloadItem(
|
|
id: id,
|
|
track: track,
|
|
service: _normalizeQueuedService(service),
|
|
createdAt: DateTime.now(),
|
|
qualityOverride: qualityOverride,
|
|
playlistName: playlistName,
|
|
playlistPosition:
|
|
explicitPosition ??
|
|
(shouldAssignPlaylistPositions ? index + 1 : null),
|
|
fromBatch: fromBatch,
|
|
preserveQualityVariant: settings.allowQualityVariants,
|
|
);
|
|
}).toList();
|
|
|
|
state = state.copyWith(items: [...state.items, ...newItems]);
|
|
_saveQueueToStorage();
|
|
|
|
if (!state.isProcessing) {
|
|
Future.microtask(() => _processQueue());
|
|
}
|
|
}
|
|
|
|
void updateItemStatus(
|
|
String id,
|
|
DownloadStatus status, {
|
|
double? progress,
|
|
double? speedMBps,
|
|
String? filePath,
|
|
String? error,
|
|
DownloadErrorType? errorType,
|
|
}) {
|
|
final items = state.items;
|
|
final index = state.lookup.indexByItemId[id] ?? -1;
|
|
if (index == -1) return;
|
|
|
|
final current = items[index];
|
|
final next = current.copyWith(
|
|
status: status,
|
|
progress: progress ?? current.progress,
|
|
speedMBps: speedMBps ?? current.speedMBps,
|
|
filePath: filePath,
|
|
error: error,
|
|
errorType: errorType,
|
|
);
|
|
|
|
if (current.status == next.status &&
|
|
current.progress == next.progress &&
|
|
current.speedMBps == next.speedMBps &&
|
|
current.filePath == next.filePath &&
|
|
current.error == next.error &&
|
|
current.errorType == next.errorType) {
|
|
return;
|
|
}
|
|
|
|
final updatedItems = List<DownloadItem>.from(items);
|
|
updatedItems[index] = next;
|
|
state = state.copyWith(
|
|
items: updatedItems,
|
|
lookup: state.lookup.updatedForIndices(
|
|
previousItems: items,
|
|
nextItems: updatedItems,
|
|
changedIndices: [index],
|
|
),
|
|
);
|
|
|
|
if (Platform.isAndroid && status == DownloadStatus.finalizing) {
|
|
PlatformBridge.clearItemProgress(id).catchError((_) {});
|
|
final queueCount = state.lookup.queuedCount;
|
|
_maybeUpdateAndroidDownloadService(
|
|
trackName: next.track.name,
|
|
artistName: _notificationService.embeddingMetadataLabel,
|
|
progress: 100,
|
|
total: 100,
|
|
queueCount: queueCount,
|
|
status: 'finalizing',
|
|
);
|
|
}
|
|
|
|
if (status == DownloadStatus.completed ||
|
|
status == DownloadStatus.failed ||
|
|
status == DownloadStatus.skipped) {
|
|
_saveQueueToStorage();
|
|
}
|
|
}
|
|
|
|
void updateProgress(String id, double progress, {double? speedMBps}) {
|
|
final item = state.lookup.byItemId[id];
|
|
if (item == null) return;
|
|
if (item.status == DownloadStatus.skipped ||
|
|
item.status == DownloadStatus.completed ||
|
|
item.status == DownloadStatus.failed) {
|
|
return;
|
|
}
|
|
updateItemStatus(
|
|
id,
|
|
DownloadStatus.downloading,
|
|
progress: progress,
|
|
speedMBps: speedMBps,
|
|
);
|
|
}
|
|
|
|
DownloadItem? _findItemById(String id) {
|
|
return state.lookup.byItemId[id];
|
|
}
|
|
|
|
bool _isLocallyCancelled(String id, {DownloadItem? item}) {
|
|
if (_locallyCancelledItemIds.contains(id)) return true;
|
|
final resolved = item ?? _findItemById(id);
|
|
return resolved?.status == DownloadStatus.skipped;
|
|
}
|
|
|
|
bool _isPausePending(String id) => _pausePendingItemIds.contains(id);
|
|
|
|
void _requeueItemForPause(String id) {
|
|
final updatedItems = state.items
|
|
.map((item) {
|
|
if (item.id != id) return item;
|
|
if (item.status == DownloadStatus.completed ||
|
|
item.status == DownloadStatus.failed ||
|
|
item.status == DownloadStatus.skipped) {
|
|
return item;
|
|
}
|
|
return item.copyWith(
|
|
status: DownloadStatus.queued,
|
|
progress: 0,
|
|
speedMBps: 0,
|
|
bytesReceived: 0,
|
|
bytesTotal: 0,
|
|
);
|
|
})
|
|
.toList(growable: false);
|
|
|
|
final currentDownload = state.currentDownload?.id == id
|
|
? null
|
|
: state.currentDownload;
|
|
state = state.copyWith(
|
|
items: updatedItems,
|
|
currentDownload: currentDownload,
|
|
);
|
|
}
|
|
|
|
void _requestNativeCancel(String id) {
|
|
PlatformBridge.cancelDownload(id).catchError((_) {});
|
|
PlatformBridge.clearItemProgress(id).catchError((_) {});
|
|
}
|
|
|
|
void cancelItem(String id) {
|
|
_pausePendingItemIds.remove(id);
|
|
_locallyCancelledItemIds.add(id);
|
|
updateItemStatus(id, DownloadStatus.skipped);
|
|
_requestNativeCancel(id);
|
|
}
|
|
|
|
void dismissItem(String id) {
|
|
final item = _findItemById(id);
|
|
if (item == null) return;
|
|
|
|
final isActive =
|
|
item.status == DownloadStatus.queued ||
|
|
item.status == DownloadStatus.downloading ||
|
|
item.status == DownloadStatus.finalizing;
|
|
final wasFailed =
|
|
item.status == DownloadStatus.failed ||
|
|
item.status == DownloadStatus.skipped;
|
|
|
|
if (isActive) {
|
|
_pausePendingItemIds.remove(id);
|
|
_locallyCancelledItemIds.add(id);
|
|
_requestNativeCancel(id);
|
|
} else {
|
|
_locallyCancelledItemIds.remove(id);
|
|
}
|
|
|
|
if (item.status != DownloadStatus.completed) {
|
|
_purgeAlbumRgEntry(item.track);
|
|
}
|
|
|
|
final items = state.items.where((entry) => entry.id != id).toList();
|
|
final currentDownload = state.currentDownload?.id == id
|
|
? null
|
|
: state.currentDownload;
|
|
state = state.copyWith(items: items, currentDownload: currentDownload);
|
|
_saveQueueToStorage();
|
|
|
|
// Dismissing a failed/skipped item may unblock album RG.
|
|
if (wasFailed) {
|
|
_retriggerAlbumRgChecks();
|
|
}
|
|
}
|
|
|
|
void clearAll() {
|
|
final wasProcessing = state.isProcessing;
|
|
final activeIds = state.items
|
|
.where(
|
|
(item) =>
|
|
item.status == DownloadStatus.queued ||
|
|
item.status == DownloadStatus.downloading ||
|
|
item.status == DownloadStatus.finalizing,
|
|
)
|
|
.map((item) => item.id)
|
|
.toList(growable: false);
|
|
|
|
if (activeIds.isNotEmpty) {
|
|
_pausePendingItemIds.addAll(activeIds);
|
|
_locallyCancelledItemIds.addAll(activeIds);
|
|
for (final id in activeIds) {
|
|
_requestNativeCancel(id);
|
|
}
|
|
}
|
|
|
|
state = state.copyWith(items: [], isPaused: false, currentDownload: null);
|
|
if (_hasActiveAndroidNativeWorker) {
|
|
PlatformBridge.cancelNativeDownloadWorker().catchError((_) {});
|
|
}
|
|
_notificationService.cancelDownloadNotification();
|
|
_saveQueueToStorage();
|
|
_albumRgData.clear();
|
|
if (!wasProcessing) {
|
|
_locallyCancelledItemIds.clear();
|
|
}
|
|
_pausePendingItemIds.clear();
|
|
}
|
|
|
|
void pauseQueue() {
|
|
if (state.isProcessing && !state.isPaused) {
|
|
if (_hasActiveAndroidNativeWorker) {
|
|
PlatformBridge.pauseNativeDownloadWorker().catchError((_) {});
|
|
}
|
|
final activeIds = state.items
|
|
.where(
|
|
(item) =>
|
|
item.status == DownloadStatus.downloading ||
|
|
item.status == DownloadStatus.finalizing,
|
|
)
|
|
.map((item) => item.id)
|
|
.toSet();
|
|
|
|
if (activeIds.isNotEmpty) {
|
|
_pausePendingItemIds.addAll(activeIds);
|
|
for (final id in activeIds) {
|
|
_requestNativeCancel(id);
|
|
_requeueItemForPause(id);
|
|
}
|
|
}
|
|
|
|
state = state.copyWith(isPaused: true, currentDownload: null);
|
|
_notificationService.cancelDownloadNotification();
|
|
_log.i('Queue paused');
|
|
}
|
|
}
|
|
|
|
void resumeQueue() {
|
|
if (state.isPaused) {
|
|
if (_hasActiveAndroidNativeWorker) {
|
|
PlatformBridge.resumeNativeDownloadWorker().catchError((_) {});
|
|
}
|
|
state = state.copyWith(isPaused: false);
|
|
_log.i('Queue resumed');
|
|
if (state.queuedCount > 0 && !state.isProcessing) {
|
|
Future.microtask(() => _processQueue());
|
|
}
|
|
}
|
|
}
|
|
|
|
void togglePause() {
|
|
if (state.isPaused) {
|
|
resumeQueue();
|
|
} else {
|
|
pauseQueue();
|
|
}
|
|
}
|
|
|
|
void retryItem(String id) {
|
|
final item = state.items.where((i) => i.id == id).firstOrNull;
|
|
if (item == null) {
|
|
_log.w('retryItem: Item not found: $id');
|
|
return;
|
|
}
|
|
|
|
if (item.status != DownloadStatus.failed &&
|
|
item.status != DownloadStatus.skipped) {
|
|
_log.w('retryItem: Item status is ${item.status}, not retrying');
|
|
return;
|
|
}
|
|
|
|
_log.i('Retrying item: ${item.track.name} (id: $id)');
|
|
// A cancel issued while the item never started leaves a pre-registered
|
|
// flag in the Go backend that the next attempt would consume and abort
|
|
// instantly; the user asked for a retry, so drop it first.
|
|
unawaited(
|
|
PlatformBridge.resetDownloadCancel(id).catchError((Object e) {
|
|
_log.w('Failed to reset cancel flag for $id: $e');
|
|
}),
|
|
);
|
|
_locallyCancelledItemIds.remove(id);
|
|
_verificationRetryGuard.clearItem(id);
|
|
_rateLimitRetriedItemIds.remove(id);
|
|
|
|
// Purge stale ReplayGain entry for this track so a re-scan doesn't
|
|
// produce duplicate entries that bias album gain.
|
|
_purgeAlbumRgEntry(item.track);
|
|
|
|
final items = state.items.map((i) {
|
|
if (i.id == id) {
|
|
return i.copyWith(
|
|
status: DownloadStatus.queued,
|
|
progress: 0,
|
|
error: null,
|
|
);
|
|
}
|
|
return i;
|
|
}).toList();
|
|
state = state.copyWith(items: items);
|
|
_saveQueueToStorage();
|
|
|
|
if (!state.isProcessing) {
|
|
_log.d('Starting queue processing for retry');
|
|
Future.microtask(() => _processQueue());
|
|
} else {
|
|
_log.d('Queue already processing, item will be picked up');
|
|
}
|
|
}
|
|
|
|
void retryAllFailed({bool networkOnly = false}) {
|
|
final failedIds = state.items
|
|
.where(
|
|
(item) =>
|
|
(item.status == DownloadStatus.failed ||
|
|
item.status == DownloadStatus.skipped) &&
|
|
(!networkOnly || item.errorType == DownloadErrorType.network),
|
|
)
|
|
.map((item) => item.id)
|
|
.toSet();
|
|
if (failedIds.isEmpty) {
|
|
_log.d('retryAllFailed: no failed downloads to retry');
|
|
return;
|
|
}
|
|
|
|
_log.i('Retrying ${failedIds.length} failed download(s)');
|
|
for (final id in failedIds) {
|
|
unawaited(
|
|
PlatformBridge.resetDownloadCancel(id).catchError((Object e) {
|
|
_log.w('Failed to reset cancel flag for $id: $e');
|
|
}),
|
|
);
|
|
}
|
|
_locallyCancelledItemIds.removeAll(failedIds);
|
|
_pausePendingItemIds.removeAll(failedIds);
|
|
|
|
for (final item in state.items) {
|
|
if (!failedIds.contains(item.id)) continue;
|
|
_purgeAlbumRgEntry(item.track);
|
|
}
|
|
|
|
final items = state.items
|
|
.map((item) {
|
|
if (!failedIds.contains(item.id)) return item;
|
|
return item.copyWith(
|
|
status: DownloadStatus.queued,
|
|
progress: 0,
|
|
speedMBps: 0,
|
|
bytesReceived: 0,
|
|
bytesTotal: 0,
|
|
error: null,
|
|
);
|
|
})
|
|
.toList(growable: false);
|
|
|
|
state = state.copyWith(items: items, isPaused: false);
|
|
_saveQueueToStorage();
|
|
|
|
if (!state.isProcessing) {
|
|
Future.microtask(() => _processQueue());
|
|
}
|
|
}
|
|
|
|
/// Reinserts a queued item ahead of all other queued items so the next
|
|
/// free download slot picks it up. During an active native worker run the
|
|
/// current batch keeps its order; the new order applies from the next run.
|
|
void downloadNext(String id) {
|
|
final items = state.items;
|
|
final index = items.indexWhere((item) => item.id == id);
|
|
if (index == -1) return;
|
|
final item = items[index];
|
|
if (item.status != DownloadStatus.queued) return;
|
|
final firstQueuedIndex = items.indexWhere(
|
|
(candidate) => candidate.status == DownloadStatus.queued,
|
|
);
|
|
if (firstQueuedIndex == index) return;
|
|
final reordered = List<DownloadItem>.from(items)
|
|
..removeAt(index)
|
|
..insert(firstQueuedIndex, item);
|
|
state = state.copyWith(items: reordered);
|
|
_saveQueueToStorage();
|
|
}
|
|
|
|
void removeItem(String id) {
|
|
final removedItem = state.items.where((item) => item.id == id).firstOrNull;
|
|
_locallyCancelledItemIds.remove(id);
|
|
final items = state.items.where((item) => item.id != id).toList();
|
|
state = state.copyWith(items: items);
|
|
_saveQueueToStorage();
|
|
|
|
// Clean stale album RG entries when a track is removed from the queue.
|
|
// Only purge for items that were NOT completed — completed items' RG data
|
|
// must survive removal because album gain is computed after the last track
|
|
// finishes, by which time earlier completed tracks have been removed.
|
|
if (removedItem != null && removedItem.status != DownloadStatus.completed) {
|
|
_purgeAlbumRgEntry(removedItem.track);
|
|
// Removing a failed/skipped item may unblock album RG for the album.
|
|
_retriggerAlbumRgChecks();
|
|
}
|
|
}
|
|
|
|
Future<String?> exportFailedDownloads() async {
|
|
final failedItems = state.items
|
|
.where((item) => item.status == DownloadStatus.failed)
|
|
.toList();
|
|
|
|
if (failedItems.isEmpty) {
|
|
_log.d('No failed downloads to export');
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
String baseDir = state.outputDir;
|
|
if (baseDir.isEmpty) {
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
baseDir = dir.path;
|
|
}
|
|
|
|
final failedDownloadsDir = '$baseDir/failed_downloads';
|
|
final failedDir = Directory(failedDownloadsDir);
|
|
if (!await failedDir.exists()) {
|
|
await failedDir.create(recursive: true);
|
|
}
|
|
|
|
// Use date-only format for daily grouping (YYYY-MM-DD)
|
|
final now = DateTime.now();
|
|
final dateStr =
|
|
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
|
final fileName = 'failed_downloads_$dateStr.txt';
|
|
final filePath = '$failedDownloadsDir/$fileName';
|
|
|
|
final file = File(filePath);
|
|
final bool fileExists = await file.exists();
|
|
|
|
final buffer = StringBuffer();
|
|
|
|
if (!fileExists) {
|
|
buffer.writeln('# SpotiFLAC Failed Downloads');
|
|
buffer.writeln('# Date: $dateStr');
|
|
buffer.writeln('#');
|
|
buffer.writeln('# Format: [Time] Track - Artist | URL | Error');
|
|
buffer.writeln('');
|
|
}
|
|
|
|
final timeStr =
|
|
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}:${now.second.toString().padLeft(2, '0')}';
|
|
|
|
for (final item in failedItems) {
|
|
final track = item.track;
|
|
final spotifyUrl = track.id.startsWith('deezer:')
|
|
? 'https://www.deezer.com/track/${track.id.substring(7)}'
|
|
: 'https://open.spotify.com/track/${track.id}';
|
|
final error = item.error ?? 'Unknown error';
|
|
buffer.writeln(
|
|
'[$timeStr] ${track.name} - ${track.artistName} | $spotifyUrl | $error',
|
|
);
|
|
}
|
|
|
|
if (fileExists) {
|
|
await file.writeAsString(buffer.toString(), mode: FileMode.append);
|
|
_log.i('Appended ${failedItems.length} failed downloads to: $filePath');
|
|
} else {
|
|
await file.writeAsString(buffer.toString());
|
|
_log.i('Created new failed downloads file: $filePath');
|
|
}
|
|
|
|
return filePath;
|
|
} catch (e) {
|
|
_log.e('Failed to export failed downloads: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
DownloadErrorType _downloadErrorTypeFromBackend(String? errorType) {
|
|
switch (errorType) {
|
|
case 'not_found':
|
|
return DownloadErrorType.notFound;
|
|
case 'rate_limit':
|
|
return DownloadErrorType.rateLimit;
|
|
case 'network':
|
|
return DownloadErrorType.network;
|
|
case 'permission':
|
|
return DownloadErrorType.permission;
|
|
case 'verification_required':
|
|
return DownloadErrorType.verificationRequired;
|
|
default:
|
|
return DownloadErrorType.unknown;
|
|
}
|
|
}
|
|
|
|
DownloadErrorType _downloadErrorTypeFromMessage(String errorMsg) {
|
|
final lowerMsg = errorMsg.toLowerCase();
|
|
if (isExtensionVerificationRequired(errorMsg)) {
|
|
return DownloadErrorType.verificationRequired;
|
|
}
|
|
if (errorMsg.contains('429') ||
|
|
lowerMsg.contains('rate limit') ||
|
|
lowerMsg.contains('too many requests')) {
|
|
return DownloadErrorType.rateLimit;
|
|
}
|
|
if (lowerMsg.contains('not found') ||
|
|
lowerMsg.contains('not available') ||
|
|
lowerMsg.contains('no results')) {
|
|
return DownloadErrorType.notFound;
|
|
}
|
|
if (lowerMsg.contains('permission') ||
|
|
lowerMsg.contains('operation not permitted') ||
|
|
lowerMsg.contains('access denied')) {
|
|
return DownloadErrorType.permission;
|
|
}
|
|
if (lowerMsg.contains('network') ||
|
|
lowerMsg.contains('connection') ||
|
|
lowerMsg.contains('timeout') ||
|
|
lowerMsg.contains('dial')) {
|
|
return DownloadErrorType.network;
|
|
}
|
|
return DownloadErrorType.unknown;
|
|
}
|
|
|
|
Future<void> _processQueue() async {
|
|
if (state.isProcessing) return;
|
|
|
|
final settings = ref.read(settingsProvider);
|
|
updateSettings(settings);
|
|
final isSafMode = _isSafMode(settings);
|
|
var iosDownloadBookmarkActive = false;
|
|
if (settings.downloadNetworkMode == 'wifi_only') {
|
|
final connectivityResult = await Connectivity().checkConnectivity();
|
|
final hasWifi = connectivityResult.contains(ConnectivityResult.wifi);
|
|
if (!hasWifi) {
|
|
_log.w('WiFi-only mode enabled but no WiFi connection. Queue paused.');
|
|
_networkPausedByWifiOnly = true;
|
|
_startConnectivityMonitoring();
|
|
state = state.copyWith(isProcessing: false, isPaused: true);
|
|
return;
|
|
}
|
|
}
|
|
// Monitor in every mode so idle connections are recycled on a network
|
|
// switch, even though only wifi_only pauses the queue.
|
|
_networkPausedByWifiOnly = false;
|
|
_startConnectivityMonitoring();
|
|
|
|
if (await _tryProcessQueueWithAndroidNativeWorker(settings)) {
|
|
return;
|
|
}
|
|
|
|
state = state.copyWith(isProcessing: true);
|
|
_log.i('Starting queue processing...');
|
|
|
|
_totalQueuedAtStart = state.items
|
|
.where((i) => i.status == DownloadStatus.queued)
|
|
.length;
|
|
_completedInSession = 0;
|
|
_failedInSession = 0;
|
|
|
|
if (Platform.isAndroid && _totalQueuedAtStart > 0) {
|
|
final firstItem = state.items.firstWhere(
|
|
(item) => item.status == DownloadStatus.queued,
|
|
orElse: () => state.items.first,
|
|
);
|
|
try {
|
|
await _notificationService.cancelDownloadNotification();
|
|
await PlatformBridge.startDownloadService(
|
|
trackName: firstItem.track.name,
|
|
artistName: firstItem.track.artistName,
|
|
queueCount: _totalQueuedAtStart,
|
|
);
|
|
_log.d('Foreground service started');
|
|
} catch (e) {
|
|
_log.e('Failed to start foreground service: $e');
|
|
}
|
|
}
|
|
|
|
// iOS: request a background execution window (no foreground service).
|
|
if (Platform.isIOS && _totalQueuedAtStart > 0) {
|
|
await PlatformBridge.beginBackgroundDownloadTask();
|
|
}
|
|
|
|
if (!isSafMode && state.outputDir.isEmpty) {
|
|
_log.d('Output dir empty, initializing...');
|
|
await _initOutputDir();
|
|
}
|
|
|
|
// iOS: Validate that outputDir is writable (not iCloud Drive which Go can't access)
|
|
if (!isSafMode && Platform.isIOS && state.outputDir.isNotEmpty) {
|
|
final isICloudPath =
|
|
state.outputDir.contains('Mobile Documents') ||
|
|
state.outputDir.contains('CloudDocs') ||
|
|
state.outputDir.contains('com~apple~CloudDocs');
|
|
if (isICloudPath) {
|
|
_log.w(
|
|
'iOS: iCloud Drive path detected, falling back to app Documents folder',
|
|
);
|
|
_log.w('Go backend cannot write to iCloud Drive due to iOS sandboxing');
|
|
final musicDir = await _ensureDefaultDocumentsOutputDir();
|
|
state = state.copyWith(outputDir: musicDir.path);
|
|
ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path);
|
|
} else if (!isValidIosWritablePath(state.outputDir)) {
|
|
_log.w(
|
|
'iOS: Invalid output path detected (container root?), falling back to app Documents folder',
|
|
);
|
|
_log.w('Original path: ${state.outputDir}');
|
|
final correctedPath = await validateOrFixIosPath(state.outputDir);
|
|
_log.i('Corrected path: $correctedPath');
|
|
state = state.copyWith(outputDir: correctedPath);
|
|
ref.read(settingsProvider.notifier).setDownloadDirectory(correctedPath);
|
|
}
|
|
}
|
|
|
|
if (!isSafMode && state.outputDir.isEmpty) {
|
|
_log.d('Using fallback directory...');
|
|
final musicDir = await _ensureDefaultDocumentsOutputDir();
|
|
state = state.copyWith(outputDir: musicDir.path);
|
|
}
|
|
|
|
if (!isSafMode) {
|
|
_log.d('Output directory: ${state.outputDir}');
|
|
} else {
|
|
_log.d('Output directory: SAF (tree_uri=${settings.downloadTreeUri})');
|
|
try {
|
|
final accessible = await PlatformBridge.validateSafTreeAccess(
|
|
settings.downloadTreeUri,
|
|
);
|
|
if (!accessible) {
|
|
throw const FileSystemException(
|
|
'Persisted SAF tree grant is missing or no longer writable',
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_log.e('SAF permission validation failed: $e');
|
|
_log.w('SAF tree URI may be invalid or permission revoked');
|
|
for (final item in state.items) {
|
|
if (item.status == DownloadStatus.queued) {
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.failed,
|
|
error: safPermissionLostErrorMessage,
|
|
);
|
|
}
|
|
}
|
|
state = state.copyWith(isProcessing: false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!isSafMode &&
|
|
Platform.isIOS &&
|
|
settings.downloadDirectoryBookmark.isNotEmpty) {
|
|
final resolvedPath = await PlatformBridge.startAccessingIosBookmark(
|
|
settings.downloadDirectoryBookmark,
|
|
);
|
|
if (resolvedPath != null && resolvedPath.isNotEmpty) {
|
|
iosDownloadBookmarkActive = true;
|
|
if (resolvedPath != state.outputDir) {
|
|
_log.i('Resolved iOS download bookmark path: $resolvedPath');
|
|
state = state.copyWith(outputDir: resolvedPath);
|
|
}
|
|
} else {
|
|
_log.e('Failed to access iOS download folder bookmark');
|
|
_log.w(
|
|
'The saved download folder may have been moved, deleted, or its access grant lost',
|
|
);
|
|
for (final item in state.items) {
|
|
if (item.status == DownloadStatus.queued) {
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.failed,
|
|
error: downloadFolderAccessLostErrorMessage,
|
|
);
|
|
}
|
|
}
|
|
state = state.copyWith(isProcessing: false);
|
|
await PlatformBridge.endBackgroundDownloadTask();
|
|
return;
|
|
}
|
|
}
|
|
|
|
try {
|
|
await _runQueueLoop();
|
|
} finally {
|
|
if (iosDownloadBookmarkActive) {
|
|
await PlatformBridge.stopAccessingIosBookmark();
|
|
iosDownloadBookmarkActive = false;
|
|
}
|
|
}
|
|
final stoppedWhilePaused = state.isPaused;
|
|
final keepConnectivityMonitoring =
|
|
stoppedWhilePaused && _networkPausedByWifiOnly;
|
|
|
|
_stopProgressPolling();
|
|
_clearEmbedCoverCache();
|
|
if (!keepConnectivityMonitoring) {
|
|
_stopConnectivityMonitoring();
|
|
}
|
|
|
|
if (Platform.isAndroid) {
|
|
try {
|
|
await PlatformBridge.stopDownloadService();
|
|
_log.d('Foreground service stopped');
|
|
} catch (e) {
|
|
_log.e('Failed to stop foreground service: $e');
|
|
}
|
|
}
|
|
|
|
if (Platform.isIOS) {
|
|
await PlatformBridge.endBackgroundDownloadTask();
|
|
}
|
|
|
|
if (_downloadCount > 0) {
|
|
_log.d('Final connection cleanup...');
|
|
try {
|
|
await PlatformBridge.cleanupConnections();
|
|
} catch (e) {
|
|
_log.e('Final cleanup failed: $e');
|
|
}
|
|
_downloadCount = 0;
|
|
}
|
|
|
|
_log.i(
|
|
'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart',
|
|
);
|
|
final hasSessionResults = _completedInSession > 0 || _failedInSession > 0;
|
|
if (!stoppedWhilePaused && _totalQueuedAtStart > 0 && hasSessionResults) {
|
|
await _notificationService.showQueueComplete(
|
|
completedCount: _completedInSession,
|
|
failedCount: _failedInSession,
|
|
);
|
|
|
|
final settings = ref.read(settingsProvider);
|
|
if (settings.autoExportFailedDownloads && _failedInSession > 0) {
|
|
final exportPath = await exportFailedDownloads();
|
|
if (exportPath != null) {
|
|
_log.i('Auto-exported failed downloads to: $exportPath');
|
|
}
|
|
}
|
|
} else if (!stoppedWhilePaused && _totalQueuedAtStart > 0) {
|
|
await _notificationService.showQueueCanceled(
|
|
canceledCount: _totalQueuedAtStart,
|
|
);
|
|
}
|
|
|
|
if (stoppedWhilePaused) {
|
|
_log.i('Queue processing paused');
|
|
} else {
|
|
_log.i('Queue processing finished');
|
|
}
|
|
state = state.copyWith(isProcessing: false, currentDownload: null);
|
|
|
|
final hasQueuedItems = state.items.any(
|
|
(item) => item.status == DownloadStatus.queued,
|
|
);
|
|
if (hasQueuedItems && !state.isPaused) {
|
|
_log.i(
|
|
'Found queued items after processing finished, restarting queue...',
|
|
);
|
|
Future.microtask(() => _processQueue());
|
|
}
|
|
}
|
|
|
|
Future<void> _runQueueLoop() async {
|
|
final activeDownloads = <String, Future<void>>{};
|
|
|
|
_startMultiProgressPolling();
|
|
|
|
while (true) {
|
|
if (state.isPaused) {
|
|
if (activeDownloads.isEmpty) {
|
|
_log.d('Queue is paused and no active download remains');
|
|
break;
|
|
}
|
|
_log.d('Queue is paused, waiting for active download...');
|
|
await Future.any([
|
|
Future.wait(activeDownloads.values),
|
|
Future<void>.delayed(_queueSchedulingInterval),
|
|
]);
|
|
continue;
|
|
}
|
|
|
|
final queuedItems = state.items
|
|
.where(
|
|
(item) =>
|
|
item.status == DownloadStatus.queued &&
|
|
!_pausePendingItemIds.contains(item.id),
|
|
)
|
|
.toList();
|
|
|
|
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
|
|
_log.d('No more items to process');
|
|
break;
|
|
}
|
|
|
|
final maxConcurrent = ref
|
|
.read(settingsProvider)
|
|
.concurrentDownloads
|
|
.clamp(1, 3);
|
|
while (activeDownloads.length < maxConcurrent &&
|
|
queuedItems.isNotEmpty &&
|
|
!state.isPaused) {
|
|
final item = queuedItems.removeAt(0);
|
|
|
|
updateItemStatus(item.id, DownloadStatus.downloading);
|
|
|
|
final future = _downloadSingleItem(item).whenComplete(() {
|
|
activeDownloads.remove(item.id);
|
|
PlatformBridge.clearItemProgress(item.id).catchError((_) {});
|
|
});
|
|
|
|
activeDownloads[item.id] = future;
|
|
_log.d('Started download: ${item.track.name}');
|
|
}
|
|
|
|
if (activeDownloads.isNotEmpty) {
|
|
await Future.any([
|
|
Future.any(activeDownloads.values),
|
|
Future<void>.delayed(_queueSchedulingInterval),
|
|
]);
|
|
} else {
|
|
await Future<void>.delayed(_queueSchedulingInterval);
|
|
}
|
|
}
|
|
|
|
if (activeDownloads.isNotEmpty) {
|
|
await Future.wait(activeDownloads.values);
|
|
}
|
|
|
|
_stopProgressPolling();
|
|
final remainingIds = state.items.map((item) => item.id).toSet();
|
|
_locallyCancelledItemIds.removeWhere((id) => !remainingIds.contains(id));
|
|
_pausePendingItemIds.removeWhere((id) => !remainingIds.contains(id));
|
|
_verificationRetryGuard.retainItems(remainingIds);
|
|
_rateLimitRetriedItemIds.removeWhere((id) => !remainingIds.contains(id));
|
|
}
|
|
|
|
}
|
|
|
|
final downloadQueueProvider =
|
|
NotifierProvider<DownloadQueueNotifier, DownloadQueueState>(
|
|
DownloadQueueNotifier.new,
|
|
);
|
|
|
|
final downloadQueueLookupProvider = Provider<DownloadQueueLookup>((ref) {
|
|
return ref.watch(downloadQueueProvider.select((s) => s.lookup));
|
|
});
|