mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-29 15:28:48 +02:00
4185 lines
147 KiB
Dart
4185 lines
147 KiB
Dart
import 'dart:async';
|
|
import 'dart:math';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
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/models/download_item.dart';
|
|
import 'package:spotiflac_android/models/settings.dart';
|
|
import 'package:spotiflac_android/models/track.dart';
|
|
import 'package:spotiflac_android/providers/settings_provider.dart';
|
|
import 'package:spotiflac_android/providers/extension_provider.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/services/history_database.dart'
|
|
show HistoryLookupRequest, HistoryBatchLookupRequest;
|
|
|
|
part 'download_queue_provider_paths.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';
|
|
|
|
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.';
|
|
|
|
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 DownloadQueueState {
|
|
static const Object _noChange = Object();
|
|
final List<DownloadItem> items;
|
|
final DownloadQueueLookup lookup;
|
|
final DownloadItem? currentDownload;
|
|
final bool isProcessing;
|
|
final bool isPaused;
|
|
final String outputDir;
|
|
final String filenameFormat;
|
|
final String singleFilenameFormat;
|
|
final String audioQuality;
|
|
final bool autoFallback;
|
|
|
|
const DownloadQueueState({
|
|
this.items = const [],
|
|
this.lookup = const DownloadQueueLookup.empty(),
|
|
this.currentDownload,
|
|
this.isProcessing = false,
|
|
this.isPaused = false,
|
|
this.outputDir = '',
|
|
this.filenameFormat = '{artist} - {title}',
|
|
this.singleFilenameFormat = '{title} - {artist}',
|
|
this.audioQuality = 'LOSSLESS',
|
|
this.autoFallback = true,
|
|
});
|
|
|
|
DownloadQueueState copyWith({
|
|
List<DownloadItem>? items,
|
|
DownloadQueueLookup? lookup,
|
|
Object? currentDownload = _noChange,
|
|
bool? isProcessing,
|
|
bool? isPaused,
|
|
String? outputDir,
|
|
String? filenameFormat,
|
|
String? singleFilenameFormat,
|
|
String? audioQuality,
|
|
bool? autoFallback,
|
|
}) {
|
|
final resolvedItems = items ?? this.items;
|
|
return DownloadQueueState(
|
|
items: resolvedItems,
|
|
lookup:
|
|
lookup ??
|
|
(items != null
|
|
? DownloadQueueLookup.fromItems(resolvedItems)
|
|
: this.lookup),
|
|
currentDownload: identical(currentDownload, _noChange)
|
|
? this.currentDownload
|
|
: currentDownload as DownloadItem?,
|
|
isProcessing: isProcessing ?? this.isProcessing,
|
|
isPaused: isPaused ?? this.isPaused,
|
|
outputDir: outputDir ?? this.outputDir,
|
|
filenameFormat: filenameFormat ?? this.filenameFormat,
|
|
singleFilenameFormat: singleFilenameFormat ?? this.singleFilenameFormat,
|
|
audioQuality: audioQuality ?? this.audioQuality,
|
|
autoFallback: autoFallback ?? this.autoFallback,
|
|
);
|
|
}
|
|
|
|
int get queuedCount => items.isEmpty ? 0 : lookup.queuedCount;
|
|
int get completedCount => items.isEmpty ? 0 : lookup.completedCount;
|
|
int get failedCount => items.isEmpty ? 0 : lookup.failedCount;
|
|
int get activeDownloadsCount =>
|
|
items.isEmpty ? 0 : lookup.activeDownloadsCount;
|
|
}
|
|
|
|
class _ProgressUpdate {
|
|
final DownloadStatus status;
|
|
final double progress;
|
|
final double? speedMBps;
|
|
final int? bytesReceived;
|
|
final int? bytesTotal;
|
|
|
|
const _ProgressUpdate({
|
|
required this.status,
|
|
required this.progress,
|
|
this.speedMBps,
|
|
this.bytesReceived,
|
|
this.bytesTotal,
|
|
});
|
|
}
|
|
|
|
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);
|
|
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 Set<String> _verificationRetriedItemIds = {};
|
|
final Set<String> _rateLimitRetriedItemIds = {};
|
|
String? _activeNativeWorkerRunId;
|
|
|
|
// 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 = {};
|
|
|
|
String _verificationRetryKey(String itemId, String service) =>
|
|
'$itemId::${service.trim().toLowerCase()}';
|
|
|
|
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<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');
|
|
}
|
|
}
|
|
|
|
/// 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<void> _waitForForeground() async {
|
|
if (WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed) {
|
|
return;
|
|
}
|
|
final completer = Completer<void>();
|
|
final listener = AppLifecycleListener(
|
|
onResume: () {
|
|
if (!completer.isCompleted) completer.complete();
|
|
},
|
|
);
|
|
try {
|
|
await completer.future;
|
|
} finally {
|
|
listener.dispose();
|
|
}
|
|
}
|
|
|
|
Future<bool> _openVerificationAndWait(String extensionId) {
|
|
return 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',
|
|
);
|
|
unawaited(_notificationService.showVerificationRequired());
|
|
await _waitForForeground();
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<bool> _handleVerificationRequiredDownload(
|
|
DownloadItem item,
|
|
String errorMsg,
|
|
String? verificationService,
|
|
) async {
|
|
final targetService = (verificationService ?? '').trim().isNotEmpty
|
|
? verificationService!.trim()
|
|
: item.service;
|
|
final verificationRetryKey = _verificationRetryKey(item.id, targetService);
|
|
if (_verificationRetriedItemIds.contains(verificationRetryKey)) {
|
|
_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;
|
|
}
|
|
_verificationRetriedItemIds.add(verificationRetryKey);
|
|
|
|
_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;
|
|
}
|
|
|
|
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<bool> _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<void>.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, () {
|
|
_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');
|
|
}
|
|
}
|
|
|
|
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<String, dynamic> allProgress) {
|
|
final rawItems = allProgress['items'];
|
|
final items = rawItems is Map
|
|
? rawItems.map((key, value) => MapEntry(key.toString(), value))
|
|
: const <String, dynamic>{};
|
|
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 = <String, _ProgressUpdate>{};
|
|
|
|
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<String, dynamic>.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] = const _ProgressUpdate(
|
|
status: DownloadStatus.downloading,
|
|
progress: 0.0,
|
|
speedMBps: 0,
|
|
bytesReceived: 0,
|
|
bytesTotal: 0,
|
|
);
|
|
|
|
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 = <int>[];
|
|
|
|
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,
|
|
);
|
|
if (current.status != next.status ||
|
|
current.progress != next.progress ||
|
|
current.speedMBps != next.speedMBps ||
|
|
current.bytesReceived != next.bytesReceived ||
|
|
current.bytesTotal != next.bytesTotal) {
|
|
if (!changed) {
|
|
updatedItems = List<DownloadItem>.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<String, dynamic>.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;
|
|
}
|
|
|
|
void setOutputDir(String dir) {
|
|
state = state.copyWith(outputDir: dir);
|
|
}
|
|
|
|
bool _isSafMode(AppSettings settings) {
|
|
return Platform.isAndroid &&
|
|
settings.storageMode == 'saf' &&
|
|
settings.downloadTreeUri.isNotEmpty;
|
|
}
|
|
|
|
bool _isSafWriteFailure(Map<String, dynamic> result) {
|
|
final error = (result['error'] ?? result['message'] ?? '')
|
|
.toString()
|
|
.toLowerCase();
|
|
if (error.isEmpty) return false;
|
|
return error.contains('saf') ||
|
|
error.contains('content uri') ||
|
|
error.contains('permission denied') ||
|
|
error.contains('documentfile');
|
|
}
|
|
|
|
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 clearCompleted() {
|
|
final removedItems = state.items.where(
|
|
(item) =>
|
|
item.status == DownloadStatus.completed ||
|
|
item.status == DownloadStatus.failed ||
|
|
item.status == DownloadStatus.skipped,
|
|
);
|
|
bool hadFailedOrSkipped = false;
|
|
for (final item in removedItems) {
|
|
if (item.status == DownloadStatus.failed ||
|
|
item.status == DownloadStatus.skipped) {
|
|
hadFailedOrSkipped = true;
|
|
_purgeAlbumRgEntry(item.track);
|
|
}
|
|
}
|
|
|
|
final items = state.items
|
|
.where(
|
|
(item) =>
|
|
item.status != DownloadStatus.completed &&
|
|
item.status != DownloadStatus.failed &&
|
|
item.status != DownloadStatus.skipped,
|
|
)
|
|
.toList();
|
|
|
|
state = state.copyWith(items: items);
|
|
_saveQueueToStorage();
|
|
|
|
if (hadFailedOrSkipped) {
|
|
_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 (Platform.isAndroid &&
|
|
ref.read(settingsProvider).nativeDownloadWorkerEnabled) {
|
|
PlatformBridge.cancelNativeDownloadWorker().catchError((_) {});
|
|
}
|
|
_notificationService.cancelDownloadNotification();
|
|
_saveQueueToStorage();
|
|
_albumRgData.clear();
|
|
if (!wasProcessing) {
|
|
_locallyCancelledItemIds.clear();
|
|
}
|
|
_pausePendingItemIds.clear();
|
|
}
|
|
|
|
void pauseQueue() {
|
|
if (state.isProcessing && !state.isPaused) {
|
|
if (Platform.isAndroid &&
|
|
ref.read(settingsProvider).nativeDownloadWorkerEnabled) {
|
|
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 (Platform.isAndroid &&
|
|
ref.read(settingsProvider).nativeDownloadWorkerEnabled) {
|
|
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);
|
|
_verificationRetriedItemIds.removeWhere(
|
|
(retryKey) => retryKey == id || retryKey.startsWith('$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() {
|
|
final failedIds = state.items
|
|
.where(
|
|
(item) =>
|
|
item.status == DownloadStatus.failed ||
|
|
item.status == DownloadStatus.skipped,
|
|
)
|
|
.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());
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
void clearFailedDownloads() {
|
|
final failedItems = state.items
|
|
.where((item) => item.status == DownloadStatus.failed)
|
|
.toList();
|
|
for (final item in failedItems) {
|
|
_purgeAlbumRgEntry(item.track);
|
|
}
|
|
|
|
final items = state.items
|
|
.where((item) => item.status != DownloadStatus.failed)
|
|
.toList();
|
|
state = state.copyWith(items: items);
|
|
_saveQueueToStorage();
|
|
_log.d('Cleared failed downloads from queue');
|
|
|
|
// Removing failed items may unblock album RG for affected albums.
|
|
if (failedItems.isNotEmpty) {
|
|
_retriggerAlbumRgChecks();
|
|
}
|
|
}
|
|
|
|
Future<String?> _runPostProcessingHooks(String filePath, Track track) async {
|
|
try {
|
|
final settings = ref.read(settingsProvider);
|
|
final extensionState = ref.read(extensionProvider);
|
|
final resolvedAlbumArtist = _resolveAlbumArtistForMetadata(
|
|
track,
|
|
settings,
|
|
);
|
|
|
|
if (!settings.useExtensionProviders) return null;
|
|
|
|
final hasPostProcessing = extensionState.extensions.any(
|
|
(e) => e.enabled && e.hasPostProcessing,
|
|
);
|
|
if (!hasPostProcessing) return null;
|
|
|
|
_log.d('Running post-processing hooks on: $filePath');
|
|
|
|
final metadata = <String, dynamic>{
|
|
'title': track.name,
|
|
'artist': track.artistName,
|
|
'album': track.albumName,
|
|
'track_number': track.trackNumber ?? 0,
|
|
'disc_number': track.discNumber ?? 0,
|
|
'isrc': track.isrc ?? '',
|
|
'release_date': track.releaseDate ?? '',
|
|
'duration_ms': track.duration * 1000,
|
|
'cover_url': track.coverUrl ?? '',
|
|
};
|
|
if (resolvedAlbumArtist != null) {
|
|
metadata['album_artist'] = resolvedAlbumArtist;
|
|
}
|
|
|
|
final result = await PlatformBridge.runPostProcessingV2(
|
|
filePath,
|
|
metadata: metadata,
|
|
);
|
|
|
|
if (result['success'] == true) {
|
|
final hooksRun = result['hooks_run'] as int? ?? 0;
|
|
final newPath = result['file_path'] as String?;
|
|
_log.i('Post-processing completed: $hooksRun hook(s) executed');
|
|
|
|
if (newPath != null && newPath != filePath) {
|
|
_log.d('File path changed by post-processing: $newPath');
|
|
return newPath;
|
|
}
|
|
return filePath;
|
|
} else {
|
|
final error = result['error'] as String? ?? 'Unknown error';
|
|
_log.w('Post-processing failed: $error');
|
|
}
|
|
} catch (e) {
|
|
_log.w('Post-processing error: $e');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
bool _hasWifiConnection(List<ConnectivityResult> 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}) {
|
|
_connectivitySub?.cancel();
|
|
_connectivitySub = null;
|
|
if (clearNetworkPause) {
|
|
_networkPausedByWifiOnly = false;
|
|
}
|
|
}
|
|
|
|
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<ConnectivityResult> results) {
|
|
final previous = _lastConnectivityResults;
|
|
final unchanged =
|
|
previous != null && _connectivitySetEquals(previous, results);
|
|
_lastConnectivityResults = List<ConnectivityResult>.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<ConnectivityResult> a,
|
|
List<ConnectivityResult> b,
|
|
) {
|
|
final setA = a.toSet();
|
|
final setB = b.toSet();
|
|
return setA.length == setB.length && setA.containsAll(setB);
|
|
}
|
|
|
|
void _handleConnectivityResults(List<ConnectivityResult> results) {
|
|
_maybeCleanupOnNetworkChange(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':
|
|
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 testResult = await PlatformBridge.createSafFileFromPath(
|
|
treeUri: settings.downloadTreeUri,
|
|
relativeDir: '',
|
|
fileName: '.spotiflac_test',
|
|
mimeType: 'application/octet-stream',
|
|
srcPath: '',
|
|
);
|
|
if (testResult != null) {
|
|
await PlatformBridge.safDelete(testResult);
|
|
}
|
|
} 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));
|
|
_verificationRetriedItemIds.removeWhere((retryKey) {
|
|
final itemId = retryKey.split('::').first;
|
|
return !remainingIds.contains(itemId);
|
|
});
|
|
_rateLimitRetriedItemIds.removeWhere((id) => !remainingIds.contains(id));
|
|
}
|
|
|
|
Future<void> _downloadSingleItem(DownloadItem item) async {
|
|
final normalizedService = _normalizeQueuedService(item.service);
|
|
if (normalizedService != item.service) {
|
|
item = item.copyWith(service: normalizedService);
|
|
state = state.copyWith(
|
|
items: [
|
|
for (final existing in state.items)
|
|
if (existing.id == item.id) item else existing,
|
|
],
|
|
currentDownload: state.currentDownload?.id == item.id
|
|
? item
|
|
: state.currentDownload,
|
|
);
|
|
_saveQueueToStorage();
|
|
}
|
|
|
|
if (!_hasActiveDownloadProvider(item.service)) {
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.failed,
|
|
error: 'Download provider is no longer available',
|
|
errorType: DownloadErrorType.notFound,
|
|
);
|
|
return;
|
|
}
|
|
|
|
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
|
|
_log.d('Cover URL: ${item.track.coverUrl}');
|
|
var pausedDuringThisRun = false;
|
|
|
|
final currentItem = _findItemById(item.id) ?? item;
|
|
if (_isLocallyCancelled(item.id, item: currentItem)) {
|
|
_log.i('Download was cancelled before start, skipping');
|
|
return;
|
|
}
|
|
|
|
if (_isPausePending(item.id)) {
|
|
pausedDuringThisRun = true;
|
|
_requeueItemForPause(item.id);
|
|
_log.i('Download is pause-pending before start, skipping');
|
|
return;
|
|
}
|
|
|
|
state = state.copyWith(currentDownload: item);
|
|
|
|
updateItemStatus(item.id, DownloadStatus.downloading);
|
|
|
|
// Shared guard for every checkpoint below: an item that vanished from the
|
|
// queue or was flagged locally-cancelled aborts immediately; a
|
|
// pause-pending item re-queues instead. [deleteFileOnAbort], when given,
|
|
// is removed before returning (used once a file has already been written).
|
|
Future<bool> shouldAbortWork(
|
|
String stage, {
|
|
String? deleteFileOnAbort,
|
|
}) async {
|
|
final current = _findItemById(item.id);
|
|
if (current == null || _isLocallyCancelled(item.id, item: current)) {
|
|
_log.i('Download was cancelled $stage, skipping');
|
|
if (deleteFileOnAbort != null) {
|
|
await deleteFile(deleteFileOnAbort);
|
|
_log.d('Deleted cancelled download file: $deleteFileOnAbort');
|
|
}
|
|
return true;
|
|
}
|
|
if (_isPausePending(item.id)) {
|
|
pausedDuringThisRun = true;
|
|
if (deleteFileOnAbort != null) {
|
|
await deleteFile(deleteFileOnAbort);
|
|
_log.d('Deleted paused download file: $deleteFileOnAbort');
|
|
}
|
|
_requeueItemForPause(item.id);
|
|
_log.i('Download pause requested $stage, re-queueing');
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
final settings = ref.read(settingsProvider);
|
|
final metadataEmbeddingEnabled = settings.embedMetadata;
|
|
|
|
Track trackToDownload = item.track;
|
|
final needsEnrichment =
|
|
trackToDownload.id.startsWith('deezer:') &&
|
|
(trackToDownload.isrc == null ||
|
|
trackToDownload.isrc!.isEmpty ||
|
|
trackToDownload.trackNumber == null ||
|
|
trackToDownload.trackNumber == 0 ||
|
|
trackToDownload.totalTracks == null ||
|
|
trackToDownload.totalTracks == 0 ||
|
|
(trackToDownload.composer == null ||
|
|
trackToDownload.composer!.isEmpty));
|
|
|
|
if (needsEnrichment) {
|
|
try {
|
|
_log.d(
|
|
'Enriching incomplete metadata for Deezer track: ${trackToDownload.name}',
|
|
);
|
|
_log.d(
|
|
'Current ISRC: ${trackToDownload.isrc}, TrackNumber: ${trackToDownload.trackNumber}',
|
|
);
|
|
final rawId = trackToDownload.id.split(':')[1];
|
|
_log.d('Fetching full metadata for Deezer ID: $rawId');
|
|
final fullData = await PlatformBridge.getProviderMetadata(
|
|
'deezer',
|
|
'track',
|
|
rawId,
|
|
);
|
|
_log.d('Got response keys: ${fullData.keys.toList()}');
|
|
|
|
if (fullData.containsKey('track')) {
|
|
final trackData = fullData['track'];
|
|
_log.d('Track data type: ${trackData.runtimeType}');
|
|
if (trackData is Map<String, dynamic>) {
|
|
final data = trackData;
|
|
_log.d('Track data keys: ${data.keys.toList()}');
|
|
_log.d('ISRC from API: ${data['isrc']}');
|
|
_log.d('album_type from API: ${data['album_type']}');
|
|
final enrichedTotalTracks = readPositiveInt(data['total_tracks']);
|
|
final enrichedTotalDiscs = readPositiveInt(data['total_discs']);
|
|
final enrichedComposer = normalizeOptionalString(
|
|
data['composer']?.toString(),
|
|
);
|
|
trackToDownload = Track(
|
|
id: (data['spotify_id'] as String?) ?? trackToDownload.id,
|
|
name: (data['name'] as String?) ?? trackToDownload.name,
|
|
artistName:
|
|
(data['artists'] as String?) ?? trackToDownload.artistName,
|
|
albumName:
|
|
(data['album_name'] as String?) ??
|
|
trackToDownload.albumName,
|
|
albumArtist: data['album_artist'] as String?,
|
|
artistId:
|
|
(data['artist_id'] ?? data['artistId'])?.toString() ??
|
|
trackToDownload.artistId,
|
|
albumId:
|
|
data['album_id']?.toString() ?? trackToDownload.albumId,
|
|
coverUrl: data['images'] as String?,
|
|
duration:
|
|
((data['duration_ms'] as int?) ??
|
|
(trackToDownload.duration * 1000)) ~/
|
|
1000,
|
|
isrc: (data['isrc'] as String?) ?? trackToDownload.isrc,
|
|
trackNumber: data['track_number'] as int?,
|
|
discNumber: data['disc_number'] as int?,
|
|
totalDiscs: enrichedTotalDiscs ?? trackToDownload.totalDiscs,
|
|
releaseDate: data['release_date'] as String?,
|
|
deezerId: rawId,
|
|
availability: trackToDownload.availability,
|
|
albumType:
|
|
(data['album_type'] as String?) ??
|
|
trackToDownload.albumType,
|
|
totalTracks: enrichedTotalTracks ?? trackToDownload.totalTracks,
|
|
composer: enrichedComposer ?? trackToDownload.composer,
|
|
source: trackToDownload.source,
|
|
);
|
|
_log.d(
|
|
'Metadata enriched: Track ${trackToDownload.trackNumber}, Disc ${trackToDownload.discNumber}, ISRC ${trackToDownload.isrc}, AlbumType ${trackToDownload.albumType}',
|
|
);
|
|
} else {
|
|
_log.w('Unexpected track data type: ${trackData.runtimeType}');
|
|
}
|
|
} else {
|
|
_log.w('Response does not contain track key');
|
|
}
|
|
} catch (e, stack) {
|
|
_log.w('Failed to enrich metadata: $e');
|
|
_log.w('Stack trace: $stack');
|
|
}
|
|
|
|
if (await shouldAbortWork('during metadata enrichment')) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
|
|
|
|
final resolvedAlbumArtist = _resolveAlbumArtistForMetadata(
|
|
trackToDownload,
|
|
settings,
|
|
);
|
|
|
|
var quality = item.qualityOverride ?? state.audioQuality;
|
|
if (quality == 'DEFAULT') quality = state.audioQuality;
|
|
final isSafMode = _isSafMode(settings);
|
|
final relativeOutputDir = isSafMode
|
|
? await _buildRelativeOutputDir(
|
|
trackToDownload,
|
|
settings.folderOrganization,
|
|
separateSingles: settings.separateSingles,
|
|
albumFolderStructure: settings.albumFolderStructure,
|
|
createPlaylistFolder: settings.createPlaylistFolder,
|
|
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
|
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
|
filterContributingArtistsInAlbumArtist:
|
|
settings.filterContributingArtistsInAlbumArtist,
|
|
playlistName: item.playlistName,
|
|
)
|
|
: '';
|
|
String? appOutputDir;
|
|
final initialOutputDir = isSafMode
|
|
? relativeOutputDir
|
|
: await _buildOutputDir(
|
|
trackToDownload,
|
|
settings.folderOrganization,
|
|
separateSingles: settings.separateSingles,
|
|
albumFolderStructure: settings.albumFolderStructure,
|
|
createPlaylistFolder: settings.createPlaylistFolder,
|
|
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
|
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
|
filterContributingArtistsInAlbumArtist:
|
|
settings.filterContributingArtistsInAlbumArtist,
|
|
playlistName: item.playlistName,
|
|
);
|
|
var effectiveOutputDir = isSafMode
|
|
? _sanitizeSafRelativeDir(initialOutputDir)
|
|
: initialOutputDir;
|
|
var effectiveSafMode = isSafMode;
|
|
|
|
String? safFileName;
|
|
String? safBaseName;
|
|
String safOutputExt = _determineOutputExt(quality, item.service);
|
|
final baseFilenameFormat = _shouldTreatAsSingleRelease(trackToDownload)
|
|
? state.singleFilenameFormat
|
|
: state.filenameFormat;
|
|
final effectiveFilenameFormat = _filenameFormatForItem(
|
|
item,
|
|
baseFilenameFormat,
|
|
);
|
|
if (isSafMode) {
|
|
final baseName = await PlatformBridge.buildFilename(
|
|
effectiveFilenameFormat,
|
|
_filenameMetadataForTrack(
|
|
trackToDownload,
|
|
quality: quality,
|
|
qualityVariant: item.preserveQualityVariant
|
|
? qualityVariantStagingLabel(item.id)
|
|
: '',
|
|
playlistPosition: _validPlaylistPosition(item),
|
|
),
|
|
);
|
|
safFileName = await _buildSafFileName(
|
|
baseName,
|
|
safOutputExt,
|
|
qualityVariant: item.preserveQualityVariant
|
|
? qualityVariantStagingLabel(item.id)
|
|
: '',
|
|
);
|
|
safBaseName = safFileName.replaceFirst(RegExp(r'\.[^.]+$'), '');
|
|
}
|
|
String? finalSafFileName = safFileName;
|
|
// Filled by the SAF embed op from the local temp so the final quality
|
|
// probe doesn't have to copy the published file back out of SAF.
|
|
Map<String, dynamic>? probedFinalMetadata;
|
|
|
|
String? genre;
|
|
String? label;
|
|
String? copyright;
|
|
final extensionState = ref.read(extensionProvider);
|
|
final selectedExtensionDownloadProvider =
|
|
settings.useExtensionProviders &&
|
|
extensionState.extensions.any(
|
|
(e) =>
|
|
e.enabled &&
|
|
e.hasDownloadProvider &&
|
|
e.id.toLowerCase() == item.service.toLowerCase(),
|
|
);
|
|
final trackSource = (trackToDownload.source ?? '').trim().toLowerCase();
|
|
final shouldSkipExtensionSongLinkPrelookup =
|
|
trackSource.isNotEmpty &&
|
|
extensionState.extensions.any(
|
|
(e) =>
|
|
e.enabled &&
|
|
e.hasMetadataProvider &&
|
|
e.id.toLowerCase() == trackSource,
|
|
);
|
|
|
|
String? deezerTrackId = await _resolveDeezerIdFromKnownOrIsrc(
|
|
trackToDownload,
|
|
item.id,
|
|
lookupContext: 'ISRC',
|
|
);
|
|
if (await shouldAbortWork('during Deezer ISRC lookup')) {
|
|
return;
|
|
}
|
|
|
|
// For tidal:/qobuz: tracks without ISRC, resolve ISRC from provider
|
|
// API directly (faster than SongLink and avoids rate limits).
|
|
final providerResolved = await _resolveDeezerIdViaProviderIfNeeded(
|
|
trackToDownload,
|
|
deezerTrackId,
|
|
item.id,
|
|
);
|
|
trackToDownload = providerResolved.track;
|
|
deezerTrackId = providerResolved.deezerTrackId;
|
|
if (await shouldAbortWork('during provider ISRC resolution')) {
|
|
return;
|
|
}
|
|
|
|
if (!selectedExtensionDownloadProvider &&
|
|
deezerTrackId == null &&
|
|
!shouldSkipExtensionSongLinkPrelookup &&
|
|
trackToDownload.id.isNotEmpty &&
|
|
!trackToDownload.id.startsWith('deezer:') &&
|
|
!trackToDownload.id.startsWith('extension:') &&
|
|
!trackToDownload.id.startsWith('tidal:') &&
|
|
!trackToDownload.id.startsWith('qobuz:')) {
|
|
final spotifyLookup = await _resolveSpotifyTrackViaDeezer(
|
|
trackToDownload,
|
|
);
|
|
trackToDownload = spotifyLookup.track;
|
|
deezerTrackId ??= spotifyLookup.deezerTrackId;
|
|
|
|
if (await shouldAbortWork('during SongLink availability lookup')) {
|
|
return;
|
|
}
|
|
} else if (selectedExtensionDownloadProvider && deezerTrackId == null) {
|
|
_log.d(
|
|
'Skipping Flutter SongLink Deezer prelookup for extension provider: ${item.service}',
|
|
);
|
|
} else if (shouldSkipExtensionSongLinkPrelookup &&
|
|
deezerTrackId == null) {
|
|
_log.d(
|
|
'Skipping Flutter SongLink Deezer prelookup for extension-sourced track; backend metadata enrichment will resolve identifiers first',
|
|
);
|
|
}
|
|
|
|
// Genre/label/copyright are only consumed at embed time (the payload
|
|
// copy is just echoed back in the response), so this lookup overlaps
|
|
// with the download instead of delaying its start; it is awaited right
|
|
// after the download returns.
|
|
final extendedMetadataFuture =
|
|
_loadExtendedMetadataForDeezerId(deezerTrackId).catchError((
|
|
Object e,
|
|
) {
|
|
_log.w('Extended metadata lookup failed: $e');
|
|
return null;
|
|
});
|
|
|
|
Map<String, dynamic> result;
|
|
|
|
final hasActiveExtensions = extensionState.extensions.any(
|
|
(e) => e.enabled,
|
|
);
|
|
final useExtensions =
|
|
settings.useExtensionProviders && hasActiveExtensions;
|
|
|
|
Future<Map<String, dynamic>> runDownload({
|
|
required bool useSaf,
|
|
required String outputDir,
|
|
}) async {
|
|
final outputExt = safOutputExt;
|
|
final shouldUseExtensions = useExtensions;
|
|
final shouldUseFallback = state.autoFallback;
|
|
|
|
if (shouldUseExtensions) {
|
|
_log.d('Using extension providers for download');
|
|
_log.d(
|
|
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
|
);
|
|
} else if (shouldUseFallback) {
|
|
_log.d('Using auto-fallback mode');
|
|
_log.d(
|
|
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}',
|
|
);
|
|
}
|
|
|
|
if (!useSaf) {
|
|
await _ensureDirExists(outputDir, label: 'Output folder');
|
|
}
|
|
|
|
_log.d('Output dir: $outputDir');
|
|
|
|
final payload = _buildDownloadRequestPayload(
|
|
track: trackToDownload,
|
|
item: item,
|
|
settings: settings,
|
|
extensionState: extensionState,
|
|
quality: quality,
|
|
filenameFormat: effectiveFilenameFormat,
|
|
outputDir: outputDir,
|
|
outputExt: outputExt,
|
|
useSaf: useSaf,
|
|
safFileName: safFileName,
|
|
deezerTrackId: deezerTrackId,
|
|
genre: genre,
|
|
label: label,
|
|
copyright: copyright,
|
|
);
|
|
|
|
return PlatformBridge.downloadByStrategy(
|
|
payload: payload,
|
|
useExtensions: shouldUseExtensions,
|
|
useFallback: shouldUseFallback,
|
|
);
|
|
}
|
|
|
|
if (await shouldAbortWork('before native download start')) {
|
|
return;
|
|
}
|
|
|
|
result = await runDownload(
|
|
useSaf: effectiveSafMode,
|
|
outputDir: effectiveOutputDir,
|
|
);
|
|
|
|
if (effectiveSafMode &&
|
|
result['success'] != true &&
|
|
_isSafWriteFailure(result)) {
|
|
if (_isLocallyCancelled(item.id)) {
|
|
_log.i('Download was cancelled before SAF fallback, skipping');
|
|
return;
|
|
}
|
|
_log.w('SAF write failed, retrying with app-private storage');
|
|
appOutputDir ??= await _buildOutputDir(
|
|
trackToDownload,
|
|
settings.folderOrganization,
|
|
separateSingles: settings.separateSingles,
|
|
albumFolderStructure: settings.albumFolderStructure,
|
|
createPlaylistFolder: settings.createPlaylistFolder,
|
|
useAlbumArtistForFolders: settings.useAlbumArtistForFolders,
|
|
usePrimaryArtistOnly: settings.usePrimaryArtistOnly,
|
|
filterContributingArtistsInAlbumArtist:
|
|
settings.filterContributingArtistsInAlbumArtist,
|
|
playlistName: item.playlistName,
|
|
);
|
|
final fallbackResult = await runDownload(
|
|
useSaf: false,
|
|
outputDir: appOutputDir,
|
|
);
|
|
if (fallbackResult['success'] == true) {
|
|
effectiveSafMode = false;
|
|
effectiveOutputDir = appOutputDir;
|
|
finalSafFileName = null;
|
|
result = fallbackResult;
|
|
}
|
|
}
|
|
|
|
_log.d('Result: $result');
|
|
|
|
final extendedMetadata = await extendedMetadataFuture;
|
|
if (extendedMetadata != null) {
|
|
genre = extendedMetadata.genre;
|
|
label = extendedMetadata.label;
|
|
copyright = extendedMetadata.copyright;
|
|
}
|
|
|
|
final resultFilePath = result['file_path'] as String?;
|
|
final resultFileToCleanup =
|
|
(resultFilePath != null && result['success'] == true)
|
|
? resultFilePath
|
|
: null;
|
|
if (await shouldAbortWork(
|
|
'after result',
|
|
deleteFileOnAbort: resultFileToCleanup,
|
|
)) {
|
|
return;
|
|
}
|
|
|
|
if (result['success'] == true) {
|
|
var filePath = result['file_path'] as String?;
|
|
final reportedFileName = result['file_name'] as String?;
|
|
if (effectiveSafMode &&
|
|
reportedFileName != null &&
|
|
reportedFileName.isNotEmpty) {
|
|
finalSafFileName = reportedFileName;
|
|
}
|
|
|
|
final wasExisting = result['already_exists'] == true;
|
|
if (wasExisting) {
|
|
_log.i('File already exists in library: $filePath');
|
|
}
|
|
|
|
_log.i('Download success, file: $filePath');
|
|
|
|
final actualBitDepth = result['actual_bit_depth'] as int?;
|
|
final actualSampleRate = result['actual_sample_rate'] as int?;
|
|
String actualQuality = quality;
|
|
|
|
if (actualBitDepth != null && actualBitDepth > 0) {
|
|
final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0
|
|
? (actualSampleRate / 1000).toStringAsFixed(
|
|
actualSampleRate % 1000 == 0 ? 0 : 1,
|
|
)
|
|
: '?';
|
|
actualQuality = '$actualBitDepth-bit/${sampleRateKHz}kHz';
|
|
_log.i('Actual quality: $actualQuality');
|
|
}
|
|
|
|
final actualService =
|
|
((result['service'] as String?)?.toLowerCase()) ??
|
|
item.service.toLowerCase();
|
|
final resultOutputExt = _downloadResultOutputExt(
|
|
result,
|
|
filePath: filePath,
|
|
);
|
|
final resultAudioFormat = normalizeAudioFormatValue(
|
|
result['audio_codec']?.toString() ??
|
|
result['actual_audio_codec']?.toString(),
|
|
);
|
|
final resultIsLossyAudio = isLossyAudioFormat(resultAudioFormat);
|
|
final requiresContainerConversion =
|
|
result['requires_container_conversion'] == true ||
|
|
result['requiresContainerConversion'] == true ||
|
|
(!resultIsLossyAudio &&
|
|
_shouldRequestContainerConversion(actualService, safOutputExt));
|
|
final preferredOutputExt = _extensionPreferredOutputExt(actualService);
|
|
final shouldPreserveNativeM4a =
|
|
!requiresContainerConversion &&
|
|
(resultOutputExt == '.m4a' ||
|
|
resultOutputExt == '.mp4' ||
|
|
preferredOutputExt == '.m4a' ||
|
|
preferredOutputExt == '.mp4' ||
|
|
_extensionPreservesNativeOutputExt(actualService, '.m4a') ||
|
|
_extensionPreservesNativeOutputExt(actualService, '.mp4'));
|
|
final decryptionDescriptor =
|
|
DownloadDecryptionDescriptor.fromDownloadResult(result);
|
|
trackToDownload = _buildTrackForMetadataEmbedding(
|
|
trackToDownload,
|
|
result,
|
|
resolvedAlbumArtist,
|
|
);
|
|
_log.d(
|
|
'Track coverUrl after download result: ${trackToDownload.coverUrl}',
|
|
);
|
|
|
|
if (!wasExisting && decryptionDescriptor != null && filePath != null) {
|
|
_log.i(
|
|
'Encrypted stream detected, decrypting via ${decryptionDescriptor.normalizedStrategy}...',
|
|
);
|
|
updateItemStatus(item.id, DownloadStatus.finalizing, progress: 0.9);
|
|
|
|
final isSafSource = effectiveSafMode && isContentUri(filePath);
|
|
final decryptOutcome = await _finalizeDecryption(
|
|
result: result,
|
|
filePath: filePath,
|
|
storageMode: effectiveSafMode ? 'saf' : 'app',
|
|
downloadTreeUri: settings.downloadTreeUri,
|
|
safRelativeDir: effectiveOutputDir,
|
|
baseName: safBaseName ?? 'track',
|
|
extFallback: '.flac',
|
|
repairAc4: true,
|
|
);
|
|
if (decryptOutcome.path == null) {
|
|
final String errorMsg;
|
|
switch (decryptOutcome.failStage) {
|
|
case _decryptStageSafAccess:
|
|
_log.e('Failed to copy encrypted SAF file to temp for decrypt');
|
|
errorMsg = 'Failed to access encrypted SAF file';
|
|
break;
|
|
case _decryptStageSafWrite:
|
|
_log.e('Failed to write decrypted stream back to SAF');
|
|
errorMsg = 'Failed to write decrypted file to storage';
|
|
break;
|
|
default:
|
|
_log.e(
|
|
isSafSource
|
|
? 'FFmpeg decrypt failed for SAF file'
|
|
: 'FFmpeg decrypt failed for local file',
|
|
);
|
|
errorMsg = 'Failed to decrypt encrypted stream';
|
|
break;
|
|
}
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.failed,
|
|
error: errorMsg,
|
|
errorType: DownloadErrorType.unknown,
|
|
);
|
|
return;
|
|
}
|
|
filePath = decryptOutcome.path;
|
|
if (decryptOutcome.newFileName != null) {
|
|
finalSafFileName = decryptOutcome.newFileName;
|
|
}
|
|
_log.i(
|
|
isSafSource
|
|
? 'SAF decryption completed'
|
|
: 'Local decryption completed',
|
|
);
|
|
}
|
|
|
|
final isContentUriPath = filePath != null && isContentUri(filePath);
|
|
final mimeType = isContentUriPath
|
|
? await _getSafMimeType(filePath)
|
|
: null;
|
|
final isM4aFile =
|
|
filePath != null &&
|
|
(filePath.endsWith('.m4a') ||
|
|
filePath.endsWith('.mp4') ||
|
|
resultOutputExt == '.m4a' ||
|
|
resultOutputExt == '.mp4' ||
|
|
(mimeType != null && mimeType.contains('mp4')));
|
|
final isFlacFile =
|
|
filePath != null &&
|
|
(filePath.endsWith('.flac') ||
|
|
resultOutputExt == '.flac' ||
|
|
(mimeType != null && mimeType.contains('flac')));
|
|
final shouldForceDashSafM4aHandling =
|
|
!wasExisting &&
|
|
isContentUriPath &&
|
|
effectiveSafMode &&
|
|
_downloadProviderReplacesLegacyProvider(actualService, 'tidal') &&
|
|
filePath.endsWith('.flac') &&
|
|
(mimeType == null || mimeType.contains('flac'));
|
|
|
|
if (shouldForceDashSafM4aHandling) {
|
|
_log.w(
|
|
'SAF file is labeled FLAC but backend returned DASH/M4A stream; converting it back to FLAC.',
|
|
);
|
|
}
|
|
|
|
if (isM4aFile || shouldForceDashSafM4aHandling) {
|
|
final currentFilePath = filePath;
|
|
|
|
if (isContentUriPath && effectiveSafMode) {
|
|
if (quality == 'HIGH') {
|
|
final tidalHighFormat = settings.tidalHighFormat;
|
|
_log.i(
|
|
'Lossy 320kbps quality (SAF), converting M4A to $tidalHighFormat...',
|
|
);
|
|
|
|
final format = lossyFormatForSetting(tidalHighFormat);
|
|
final displayFormat = displayFormatForLossyFormat(format);
|
|
final newExt = lossyExtensionForFormat(format);
|
|
final newFileName = '${safBaseName ?? 'track'}$newExt';
|
|
var opStarted = false;
|
|
var convertFailed = false;
|
|
try {
|
|
final newUri = await _replaceSafFileVia(
|
|
uri: currentFilePath,
|
|
treeUri: settings.downloadTreeUri,
|
|
relativeDir: effectiveOutputDir,
|
|
op: (tempPath, addCleanup) async {
|
|
opStarted = true;
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.finalizing,
|
|
progress: 0.95,
|
|
);
|
|
final convertedPath = await FFmpegService.convertM4aToLossy(
|
|
tempPath,
|
|
format: format,
|
|
bitrate: tidalHighFormat,
|
|
deleteOriginal: false,
|
|
);
|
|
if (convertedPath == null) {
|
|
convertFailed = true;
|
|
return null;
|
|
}
|
|
addCleanup(convertedPath);
|
|
_log.i(
|
|
'Successfully converted M4A to $format (temp): $convertedPath',
|
|
);
|
|
_log.i('Embedding metadata to $format...');
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.finalizing,
|
|
progress: 0.99,
|
|
);
|
|
|
|
final backendGenre = result['genre'] as String?;
|
|
final backendLabel = result['label'] as String?;
|
|
final backendCopyright = result['copyright'] as String?;
|
|
|
|
await _embedMetadataToFile(
|
|
convertedPath,
|
|
trackToDownload,
|
|
format: metadataFormatForLossyFormat(format),
|
|
genre: backendGenre ?? genre,
|
|
label: backendLabel ?? label,
|
|
copyright: backendCopyright,
|
|
downloadService: item.service,
|
|
);
|
|
|
|
return (convertedPath, newFileName);
|
|
},
|
|
);
|
|
|
|
if (newUri != null) {
|
|
filePath = newUri;
|
|
finalSafFileName = newFileName;
|
|
final bitrateDisplay = tidalHighFormat.contains('_')
|
|
? '${tidalHighFormat.split('_').last}kbps'
|
|
: '320kbps';
|
|
actualQuality = '$displayFormat $bitrateDisplay';
|
|
} else if (convertFailed) {
|
|
_log.w('M4A to $format conversion failed, keeping M4A file');
|
|
actualQuality = 'AAC 320kbps';
|
|
} else if (opStarted) {
|
|
_log.w(
|
|
'Failed to write converted $format to SAF, keeping M4A',
|
|
);
|
|
actualQuality = 'AAC 320kbps';
|
|
}
|
|
} catch (e) {
|
|
_log.w('SAF M4A conversion failed: $e');
|
|
actualQuality = 'AAC 320kbps';
|
|
}
|
|
} else if (shouldPreserveNativeM4a) {
|
|
// Decrypted streams are already in their final format.
|
|
// Converting e.g. eac3 M4A to FLAC would produce fake upscaled output.
|
|
_log.d(
|
|
'M4A/MP4 file detected (SAF), preserving native container...',
|
|
);
|
|
final preserveExt = currentFilePath.toLowerCase().endsWith('.mp4')
|
|
? '.mp4'
|
|
: '.m4a';
|
|
final newFileName = '${safBaseName ?? 'track'}$preserveExt';
|
|
var opStarted = false;
|
|
try {
|
|
final newUri = await _replaceSafFileVia(
|
|
uri: currentFilePath,
|
|
treeUri: settings.downloadTreeUri,
|
|
relativeDir: effectiveOutputDir,
|
|
op: (tempPath, _) async {
|
|
opStarted = true;
|
|
if (metadataEmbeddingEnabled) {
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.finalizing,
|
|
progress: 0.99,
|
|
);
|
|
final finalTrack = _buildTrackForMetadataEmbedding(
|
|
trackToDownload,
|
|
result,
|
|
resolvedAlbumArtist,
|
|
);
|
|
final backendGenre = result['genre'] as String?;
|
|
final backendLabel = result['label'] as String?;
|
|
final backendCopyright = result['copyright'] as String?;
|
|
|
|
await _embedMetadataToFile(
|
|
tempPath,
|
|
finalTrack,
|
|
format: 'm4a',
|
|
genre: backendGenre ?? genre,
|
|
label: backendLabel ?? label,
|
|
copyright: backendCopyright,
|
|
downloadService: item.service,
|
|
writeExternalLrc: false,
|
|
);
|
|
}
|
|
return (tempPath, newFileName);
|
|
},
|
|
);
|
|
|
|
if (newUri != null) {
|
|
filePath = newUri;
|
|
finalSafFileName = newFileName;
|
|
} else if (opStarted) {
|
|
_log.w('Failed to write M4A to SAF, keeping original');
|
|
}
|
|
} catch (e) {
|
|
_log.w('SAF native M4A handling failed: $e');
|
|
}
|
|
} else {
|
|
_log.d('M4A file detected (SAF), converting to FLAC...');
|
|
String? branch;
|
|
String? producedFileName;
|
|
try {
|
|
final newUri = await _replaceSafFileVia(
|
|
uri: currentFilePath,
|
|
treeUri: settings.downloadTreeUri,
|
|
relativeDir: effectiveOutputDir,
|
|
op: (tempPath, addCleanup) async {
|
|
final length = await File(tempPath).length();
|
|
if (length < 1024) {
|
|
_log.w(
|
|
'Temp M4A is too small (<1KB), skipping conversion',
|
|
);
|
|
branch = 'skip';
|
|
return null;
|
|
}
|
|
final codec = await FFmpegService.probePrimaryAudioCodec(
|
|
tempPath,
|
|
);
|
|
final isAlreadyNativeFlac =
|
|
codec == 'flac' &&
|
|
await FFmpegService.isNativeFlacFile(tempPath);
|
|
if (!FFmpegService.isLosslessAudioCodec(codec)) {
|
|
_log.d(
|
|
'Preserving native container; audio codec is ${codec ?? 'unknown'}, '
|
|
'no FLAC container conversion needed.',
|
|
);
|
|
branch = 'preserve';
|
|
final preserveExt = resultOutputExt == '.mp4'
|
|
? '.mp4'
|
|
: '.m4a';
|
|
final newFileName =
|
|
'${safBaseName ?? 'track'}$preserveExt';
|
|
producedFileName = newFileName;
|
|
return (tempPath, newFileName);
|
|
} else if (isAlreadyNativeFlac) {
|
|
_log.d(
|
|
'Native FLAC payload detected in SAF temp file; '
|
|
'publishing as FLAC and embedding metadata.',
|
|
);
|
|
branch = 'nativeFlac';
|
|
final finalTrack = _buildTrackForMetadataEmbedding(
|
|
trackToDownload,
|
|
result,
|
|
resolvedAlbumArtist,
|
|
);
|
|
|
|
final backendGenre = result['genre'] as String?;
|
|
final backendLabel = result['label'] as String?;
|
|
final backendCopyright = result['copyright'] as String?;
|
|
|
|
await _embedMetadataToFile(
|
|
tempPath,
|
|
finalTrack,
|
|
format: 'flac',
|
|
genre: backendGenre ?? genre,
|
|
label: backendLabel ?? label,
|
|
copyright: backendCopyright,
|
|
downloadService: item.service,
|
|
writeExternalLrc: false,
|
|
);
|
|
|
|
final newFileName = '${safBaseName ?? 'track'}.flac';
|
|
producedFileName = newFileName;
|
|
return (tempPath, newFileName);
|
|
} else {
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.finalizing,
|
|
progress: 0.95,
|
|
);
|
|
final flacPath = await FFmpegService.convertM4aToFlac(
|
|
tempPath,
|
|
);
|
|
if (flacPath == null) {
|
|
_log.w(
|
|
'FFmpeg conversion returned null, keeping M4A file',
|
|
);
|
|
branch = 'convertFailed';
|
|
return null;
|
|
}
|
|
addCleanup(flacPath);
|
|
_log.d('Converted to FLAC (temp): $flacPath');
|
|
_log.d(
|
|
'Embedding metadata and cover to converted FLAC...',
|
|
);
|
|
final finalTrack = _buildTrackForMetadataEmbedding(
|
|
trackToDownload,
|
|
result,
|
|
resolvedAlbumArtist,
|
|
);
|
|
|
|
final backendGenre = result['genre'] as String?;
|
|
final backendLabel = result['label'] as String?;
|
|
final backendCopyright = result['copyright'] as String?;
|
|
|
|
await _embedMetadataToFile(
|
|
flacPath,
|
|
finalTrack,
|
|
format: 'flac',
|
|
genre: backendGenre ?? genre,
|
|
label: backendLabel ?? label,
|
|
copyright: backendCopyright,
|
|
downloadService: item.service,
|
|
writeExternalLrc: false,
|
|
);
|
|
|
|
final newFileName = '${safBaseName ?? 'track'}.flac';
|
|
branch = 'convert';
|
|
producedFileName = newFileName;
|
|
return (flacPath, newFileName);
|
|
}
|
|
},
|
|
);
|
|
|
|
if (newUri != null) {
|
|
filePath = newUri;
|
|
finalSafFileName = producedFileName;
|
|
} else if (branch == 'nativeFlac') {
|
|
_log.w('Failed to write native FLAC to SAF');
|
|
} else if (branch == 'convert') {
|
|
_log.w('Failed to write FLAC to SAF, keeping M4A');
|
|
}
|
|
} catch (e) {
|
|
_log.w('SAF M4A->FLAC conversion failed: $e');
|
|
}
|
|
}
|
|
} else {
|
|
if (quality == 'HIGH') {
|
|
final tidalHighFormat = settings.tidalHighFormat;
|
|
_log.i(
|
|
'Lossy 320kbps quality download, converting M4A to $tidalHighFormat...',
|
|
);
|
|
|
|
try {
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.finalizing,
|
|
progress: 0.95,
|
|
);
|
|
|
|
final format = lossyFormatForSetting(tidalHighFormat);
|
|
final displayFormat = displayFormatForLossyFormat(format);
|
|
final convertedPath = await FFmpegService.convertM4aToLossy(
|
|
currentFilePath,
|
|
format: format,
|
|
bitrate: tidalHighFormat,
|
|
deleteOriginal: true,
|
|
);
|
|
|
|
if (convertedPath != null) {
|
|
filePath = convertedPath;
|
|
final bitrateDisplay = tidalHighFormat.contains('_')
|
|
? '${tidalHighFormat.split('_').last}kbps'
|
|
: '320kbps';
|
|
actualQuality = '$displayFormat $bitrateDisplay';
|
|
_log.i(
|
|
'Successfully converted M4A to $format: $convertedPath',
|
|
);
|
|
|
|
_log.i('Embedding metadata to $format...');
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.finalizing,
|
|
progress: 0.99,
|
|
);
|
|
|
|
final backendGenre = result['genre'] as String?;
|
|
final backendLabel = result['label'] as String?;
|
|
final backendCopyright = result['copyright'] as String?;
|
|
|
|
await _embedMetadataToFile(
|
|
convertedPath,
|
|
trackToDownload,
|
|
format: metadataFormatForLossyFormat(format),
|
|
genre: backendGenre ?? genre,
|
|
label: backendLabel ?? label,
|
|
copyright: backendCopyright,
|
|
downloadService: item.service,
|
|
);
|
|
_log.d('Metadata embedded successfully');
|
|
} else {
|
|
_log.w('M4A to $format conversion failed, keeping M4A file');
|
|
actualQuality = 'AAC 320kbps';
|
|
}
|
|
} catch (e) {
|
|
_log.w('M4A conversion process failed: $e, keeping M4A file');
|
|
actualQuality = 'AAC 320kbps';
|
|
}
|
|
} else if (shouldPreserveNativeM4a) {
|
|
_log.d('M4A/MP4 file detected, preserving native container...');
|
|
|
|
try {
|
|
var targetPath = currentFilePath;
|
|
final file = File(targetPath);
|
|
if (!await file.exists()) {
|
|
_log.e('File does not exist at path: $filePath');
|
|
} else {
|
|
if (!(targetPath.toLowerCase().endsWith('.m4a') ||
|
|
targetPath.toLowerCase().endsWith('.mp4'))) {
|
|
final renamedPath = targetPath.replaceAll(
|
|
RegExp(r'\.[^.]+$'),
|
|
'.m4a',
|
|
);
|
|
final finalRenamedPath = renamedPath == targetPath
|
|
? '$targetPath.m4a'
|
|
: renamedPath;
|
|
await file.rename(finalRenamedPath);
|
|
targetPath = finalRenamedPath;
|
|
filePath = finalRenamedPath;
|
|
} else {
|
|
filePath = targetPath;
|
|
}
|
|
|
|
if (metadataEmbeddingEnabled) {
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.finalizing,
|
|
progress: 0.99,
|
|
);
|
|
final finalTrack = _buildTrackForMetadataEmbedding(
|
|
trackToDownload,
|
|
result,
|
|
resolvedAlbumArtist,
|
|
);
|
|
|
|
final backendGenre = result['genre'] as String?;
|
|
final backendLabel = result['label'] as String?;
|
|
final backendCopyright = result['copyright'] as String?;
|
|
|
|
await _embedMetadataToFile(
|
|
targetPath,
|
|
finalTrack,
|
|
format: 'm4a',
|
|
genre: backendGenre ?? genre,
|
|
label: backendLabel ?? label,
|
|
copyright: backendCopyright,
|
|
downloadService: item.service,
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
_log.w('Native M4A handling failed: $e');
|
|
}
|
|
} else {
|
|
_log.d(
|
|
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
|
|
);
|
|
|
|
try {
|
|
final file = File(currentFilePath);
|
|
if (!await file.exists()) {
|
|
_log.e('File does not exist at path: $filePath');
|
|
} else {
|
|
final length = await file.length();
|
|
_log.i('File size before conversion: ${length / 1024} KB');
|
|
|
|
if (length < 1024) {
|
|
_log.w(
|
|
'File is too small (<1KB), skipping conversion. Download might be corrupt.',
|
|
);
|
|
} else {
|
|
final codec = await FFmpegService.probePrimaryAudioCodec(
|
|
currentFilePath,
|
|
);
|
|
final isAlreadyNativeFlac =
|
|
codec == 'flac' &&
|
|
await FFmpegService.isNativeFlacFile(currentFilePath);
|
|
if (!FFmpegService.isLosslessAudioCodec(codec)) {
|
|
_log.d(
|
|
'Preserving native container; audio codec is ${codec ?? 'unknown'}, '
|
|
'no FLAC container conversion needed.',
|
|
);
|
|
} else if (isAlreadyNativeFlac) {
|
|
_log.d(
|
|
'Native FLAC payload detected; ensuring .flac '
|
|
'extension and embedding metadata.',
|
|
);
|
|
var flacPath = currentFilePath;
|
|
if (!currentFilePath.toLowerCase().endsWith('.flac')) {
|
|
final renamedPath = currentFilePath.replaceAll(
|
|
RegExp(r'\.[^.]+$'),
|
|
'.flac',
|
|
);
|
|
final targetPath = renamedPath == currentFilePath
|
|
? '$currentFilePath.flac'
|
|
: renamedPath;
|
|
await File(currentFilePath).rename(targetPath);
|
|
flacPath = targetPath;
|
|
filePath = targetPath;
|
|
}
|
|
|
|
final finalTrack = _buildTrackForMetadataEmbedding(
|
|
trackToDownload,
|
|
result,
|
|
resolvedAlbumArtist,
|
|
);
|
|
|
|
final backendGenre = result['genre'] as String?;
|
|
final backendLabel = result['label'] as String?;
|
|
final backendCopyright = result['copyright'] as String?;
|
|
|
|
await _embedMetadataToFile(
|
|
flacPath,
|
|
finalTrack,
|
|
format: 'flac',
|
|
genre: backendGenre ?? genre,
|
|
label: backendLabel ?? label,
|
|
copyright: backendCopyright,
|
|
downloadService: item.service,
|
|
);
|
|
} else {
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.finalizing,
|
|
progress: 0.95,
|
|
);
|
|
final flacPath = await FFmpegService.convertM4aToFlac(
|
|
currentFilePath,
|
|
);
|
|
|
|
if (flacPath != null) {
|
|
filePath = flacPath;
|
|
_log.d('Converted to FLAC: $flacPath');
|
|
|
|
_log.d(
|
|
'Embedding metadata and cover to converted FLAC...',
|
|
);
|
|
try {
|
|
final finalTrack = _buildTrackForMetadataEmbedding(
|
|
trackToDownload,
|
|
result,
|
|
resolvedAlbumArtist,
|
|
);
|
|
|
|
final backendGenre = result['genre'] as String?;
|
|
final backendLabel = result['label'] as String?;
|
|
final backendCopyright =
|
|
result['copyright'] as String?;
|
|
|
|
if (backendGenre != null ||
|
|
backendLabel != null ||
|
|
backendCopyright != null) {
|
|
_log.d(
|
|
'Extended metadata from backend - Genre: $backendGenre, Label: $backendLabel, Copyright: $backendCopyright',
|
|
);
|
|
}
|
|
|
|
await _embedMetadataToFile(
|
|
flacPath,
|
|
finalTrack,
|
|
format: 'flac',
|
|
genre: backendGenre ?? genre,
|
|
label: backendLabel ?? label,
|
|
copyright: backendCopyright,
|
|
downloadService: item.service,
|
|
);
|
|
_log.d('Metadata and cover embedded successfully');
|
|
} catch (e) {
|
|
_log.w('Warning: Failed to embed metadata/cover: $e');
|
|
}
|
|
} else {
|
|
_log.w(
|
|
'FFmpeg conversion returned null, keeping M4A file',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
_log.w(
|
|
'FFmpeg conversion process failed: $e, keeping M4A file',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} else if (metadataEmbeddingEnabled &&
|
|
isContentUriPath &&
|
|
effectiveSafMode &&
|
|
!isM4aFile &&
|
|
!wasExisting) {
|
|
final currentFilePath = filePath;
|
|
final isOpusFile =
|
|
filePath.endsWith('.opus') ||
|
|
filePath.endsWith('.ogg') ||
|
|
resultOutputExt == '.opus' ||
|
|
resultOutputExt == '.ogg';
|
|
final isMp3File =
|
|
filePath.endsWith('.mp3') || resultOutputExt == '.mp3';
|
|
final ext = isOpusFile
|
|
? (resultOutputExt == '.ogg' ? '.ogg' : '.opus')
|
|
: isMp3File
|
|
? '.mp3'
|
|
: '.flac';
|
|
final formatName = isOpusFile
|
|
? 'Opus'
|
|
: isMp3File
|
|
? 'MP3'
|
|
: 'FLAC';
|
|
_log.d(
|
|
'SAF $formatName detected, embedding metadata and cover via temp file...',
|
|
);
|
|
final newFileName = '${safBaseName ?? 'track'}$ext';
|
|
var opStarted = false;
|
|
try {
|
|
final newUri = await _replaceSafFileVia(
|
|
uri: currentFilePath,
|
|
treeUri: settings.downloadTreeUri,
|
|
relativeDir: effectiveOutputDir,
|
|
op: (tempPath, _) async {
|
|
opStarted = true;
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.finalizing,
|
|
progress: 0.99,
|
|
);
|
|
|
|
final finalTrack = _buildTrackForMetadataEmbedding(
|
|
trackToDownload,
|
|
result,
|
|
resolvedAlbumArtist,
|
|
);
|
|
final backendGenre = result['genre'] as String?;
|
|
final backendLabel = result['label'] as String?;
|
|
final backendCopyright = result['copyright'] as String?;
|
|
|
|
// writeExternalLrc: false — tempPath lives in the cache dir,
|
|
// so a sidecar .lrc written next to it would be orphaned;
|
|
// the SAF .lrc is written by _saveExternalLrc after publish,
|
|
// reusing the LRC fetched here via result['lyrics_lrc'].
|
|
final embedFormat = isMp3File
|
|
? 'mp3'
|
|
: isOpusFile
|
|
? 'opus'
|
|
: 'flac';
|
|
final fetchedLrc = await _embedMetadataToFile(
|
|
tempPath,
|
|
finalTrack,
|
|
format: embedFormat,
|
|
genre: backendGenre ?? genre,
|
|
label: backendLabel ?? label,
|
|
copyright: backendCopyright,
|
|
downloadService: item.service,
|
|
writeExternalLrc: false,
|
|
);
|
|
final existingLrc = result['lyrics_lrc'] as String?;
|
|
if ((existingLrc == null || existingLrc.isEmpty) &&
|
|
fetchedLrc != null &&
|
|
fetchedLrc.isNotEmpty) {
|
|
result['lyrics_lrc'] = fetchedLrc;
|
|
}
|
|
|
|
// Probe quality from the local temp now: the same probe on
|
|
// the published content:// URI would copy the whole file
|
|
// out of SAF again just to read a few header bytes.
|
|
try {
|
|
probedFinalMetadata = await PlatformBridge.readFileMetadata(
|
|
tempPath,
|
|
);
|
|
} catch (_) {}
|
|
|
|
return (tempPath, newFileName);
|
|
},
|
|
);
|
|
|
|
if (newUri != null) {
|
|
filePath = newUri;
|
|
finalSafFileName = newFileName;
|
|
_log.d('SAF $formatName metadata embedding completed');
|
|
} else if (opStarted) {
|
|
_log.w(
|
|
'Failed to write metadata-updated $formatName back to SAF',
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_log.w('SAF $formatName metadata embedding failed: $e');
|
|
}
|
|
} else if (metadataEmbeddingEnabled &&
|
|
!isContentUriPath &&
|
|
!effectiveSafMode &&
|
|
isFlacFile &&
|
|
!wasExisting &&
|
|
decryptionDescriptor != null) {
|
|
_log.d(
|
|
'Local FLAC after decrypt detected, embedding metadata and cover...',
|
|
);
|
|
try {
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.finalizing,
|
|
progress: 0.99,
|
|
);
|
|
|
|
final finalTrack = _buildTrackForMetadataEmbedding(
|
|
trackToDownload,
|
|
result,
|
|
resolvedAlbumArtist,
|
|
);
|
|
final backendGenre = result['genre'] as String?;
|
|
final backendLabel = result['label'] as String?;
|
|
final backendCopyright = result['copyright'] as String?;
|
|
|
|
await _embedMetadataToFile(
|
|
filePath,
|
|
finalTrack,
|
|
format: 'flac',
|
|
genre: backendGenre ?? genre,
|
|
label: backendLabel ?? label,
|
|
copyright: backendCopyright,
|
|
downloadService: item.service,
|
|
);
|
|
_log.d('Local FLAC metadata embedding completed');
|
|
} catch (e) {
|
|
_log.w('Local FLAC metadata embedding failed: $e');
|
|
}
|
|
}
|
|
|
|
if (await shouldAbortWork(
|
|
'during finalization',
|
|
deleteFileOnAbort: filePath,
|
|
)) {
|
|
return;
|
|
}
|
|
|
|
if (effectiveSafMode &&
|
|
filePath != null &&
|
|
filePath.isNotEmpty &&
|
|
!isContentUri(filePath) &&
|
|
settings.downloadTreeUri.isNotEmpty) {
|
|
final fallbackName = (finalSafFileName ?? safFileName ?? '').trim();
|
|
if (fallbackName.isNotEmpty) {
|
|
try {
|
|
final resolved = await PlatformBridge.resolveSafFile(
|
|
treeUri: settings.downloadTreeUri,
|
|
relativeDir: effectiveOutputDir,
|
|
fileName: fallbackName,
|
|
);
|
|
final resolvedUri = (resolved['uri'] as String? ?? '').trim();
|
|
final resolvedRelativeDir =
|
|
(resolved['relative_dir'] as String? ?? '').trim();
|
|
if (resolvedUri.isNotEmpty && isContentUri(resolvedUri)) {
|
|
_log.w('Recovered SAF URI from transient path: $filePath');
|
|
filePath = resolvedUri;
|
|
finalSafFileName = fallbackName;
|
|
if (resolvedRelativeDir.isNotEmpty) {
|
|
effectiveOutputDir = resolvedRelativeDir;
|
|
}
|
|
} else {
|
|
_log.w(
|
|
'Failed to recover SAF URI (fileName=$fallbackName, dir=$effectiveOutputDir)',
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_log.w('SAF URI recovery failed: $e');
|
|
}
|
|
} else {
|
|
_log.w(
|
|
'SAF download returned non-URI path without filename metadata: $filePath',
|
|
);
|
|
}
|
|
}
|
|
|
|
if (filePath != null) {
|
|
final postProcessedPath = await _runPostProcessingHooks(
|
|
filePath,
|
|
trackToDownload,
|
|
);
|
|
if (postProcessedPath != null && postProcessedPath.isNotEmpty) {
|
|
filePath = postProcessedPath;
|
|
result['file_path'] = postProcessedPath;
|
|
}
|
|
}
|
|
|
|
if (filePath != null && item.preserveQualityVariant) {
|
|
final variantOutcome = await _finalizeQualityVariantFilename(
|
|
item: item,
|
|
result: result,
|
|
filePath: filePath,
|
|
storageMode: effectiveSafMode ? 'saf' : 'app',
|
|
downloadTreeUri: settings.downloadTreeUri,
|
|
safRelativeDir: effectiveOutputDir,
|
|
fileName: finalSafFileName ?? safFileName,
|
|
);
|
|
filePath = variantOutcome.filePath;
|
|
if (variantOutcome.fileName != null) {
|
|
finalSafFileName = variantOutcome.fileName;
|
|
}
|
|
if (variantOutcome.metadata != null) {
|
|
probedFinalMetadata = variantOutcome.metadata;
|
|
}
|
|
}
|
|
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.completed,
|
|
progress: 1.0,
|
|
filePath: filePath,
|
|
);
|
|
|
|
if (effectiveSafMode && filePath != null && isContentUri(filePath)) {
|
|
await _saveExternalLrc(
|
|
result: result,
|
|
settings: settings,
|
|
extensionState: extensionState,
|
|
track: trackToDownload,
|
|
service: item.service,
|
|
filePath: filePath,
|
|
storageMode: 'saf',
|
|
downloadTreeUri: settings.downloadTreeUri,
|
|
safRelativeDir: effectiveOutputDir,
|
|
resolveBaseName: () async {
|
|
final currentFinalName = finalSafFileName;
|
|
return currentFinalName != null
|
|
? currentFinalName.replaceFirst(RegExp(r'\.[^.]+$'), '')
|
|
: safBaseName ??
|
|
await PlatformBridge.sanitizeFilename(
|
|
'${trackToDownload.artistName} - ${trackToDownload.name}',
|
|
);
|
|
},
|
|
onFetchError: (e) =>
|
|
_log.w('Failed to fetch lyrics for external LRC: $e'),
|
|
);
|
|
}
|
|
|
|
// Album ReplayGain: update the accumulator path to the final file
|
|
// location. For SAF downloads the metadata was embedded on a temp
|
|
// copy, so the stored path still points there. Replace it with the
|
|
// actual output path (SAF content URI or local path) so the later
|
|
// album-gain writer targets the correct file.
|
|
if (filePath != null) {
|
|
_updateAlbumRgFilePath(trackToDownload, filePath);
|
|
}
|
|
|
|
// Album ReplayGain: check if all album tracks are now complete and,
|
|
// if so, compute and write album gain/peak to every track file.
|
|
try {
|
|
await _checkAndWriteAlbumReplayGain(trackToDownload);
|
|
} catch (e) {
|
|
_log.w('Album ReplayGain check failed: $e');
|
|
}
|
|
|
|
_completedInSession++;
|
|
|
|
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
|
final existingInHistory =
|
|
await historyNotifier.getBySpotifyIdAsync(trackToDownload.id) ??
|
|
(trackToDownload.isrc != null
|
|
? await historyNotifier.getByIsrcAsync(trackToDownload.isrc!)
|
|
: null);
|
|
|
|
if (wasExisting && existingInHistory != null) {
|
|
_log.i('Track already in library, skipping history update');
|
|
await _notificationService.showDownloadComplete(
|
|
trackName: item.track.name,
|
|
artistName: item.track.artistName,
|
|
completedCount: _completedInSession,
|
|
totalCount: _totalQueuedAtStart,
|
|
alreadyInLibrary: true,
|
|
);
|
|
removeItem(item.id);
|
|
return;
|
|
}
|
|
|
|
await _notificationService.showDownloadComplete(
|
|
trackName: item.track.name,
|
|
artistName: item.track.artistName,
|
|
completedCount: _completedInSession,
|
|
totalCount: _totalQueuedAtStart,
|
|
alreadyInLibrary: wasExisting,
|
|
);
|
|
|
|
if (filePath != null) {
|
|
final backendBitDepth = result['actual_bit_depth'] as int?;
|
|
final backendSampleRate = result['actual_sample_rate'] as int?;
|
|
final backendFormat =
|
|
normalizeAudioFormatValue(
|
|
result['audio_codec']?.toString() ??
|
|
result['format']?.toString(),
|
|
) ??
|
|
normalizeAudioFormatValue(audioFormatForPath(filePath));
|
|
final backendBitrateKbps = readPositiveBitrateKbps(
|
|
result['bitrate'] ?? result['actual_bitrate'],
|
|
);
|
|
final backendGenre = result['genre'] as String?;
|
|
final backendLabel = result['label'] as String?;
|
|
final backendCopyright = result['copyright'] as String?;
|
|
final effectiveGenre =
|
|
normalizeOptionalString(backendGenre) ??
|
|
normalizeOptionalString(genre) ??
|
|
normalizeOptionalString(existingInHistory?.genre);
|
|
final effectiveLabel =
|
|
normalizeOptionalString(backendLabel) ??
|
|
normalizeOptionalString(label) ??
|
|
normalizeOptionalString(existingInHistory?.label);
|
|
final effectiveCopyright =
|
|
normalizeOptionalString(backendCopyright) ??
|
|
normalizeOptionalString(copyright) ??
|
|
normalizeOptionalString(existingInHistory?.copyright);
|
|
|
|
int? finalBitDepth = backendBitDepth;
|
|
int? finalSampleRate = backendSampleRate;
|
|
String? finalFormat = backendFormat;
|
|
int? finalBitrateKbps = isLossyAudioFormat(finalFormat)
|
|
? backendBitrateKbps
|
|
: null;
|
|
final lowerFilePath = filePath.toLowerCase();
|
|
final canProbeFinalMetadata =
|
|
filePath.startsWith('content://') ||
|
|
lowerFilePath.endsWith('.flac') ||
|
|
lowerFilePath.endsWith('.m4a') ||
|
|
lowerFilePath.endsWith('.mp4') ||
|
|
lowerFilePath.endsWith('.aac') ||
|
|
lowerFilePath.endsWith('.mp3') ||
|
|
lowerFilePath.endsWith('.opus') ||
|
|
lowerFilePath.endsWith('.ogg');
|
|
|
|
if (canProbeFinalMetadata) {
|
|
try {
|
|
final probed = probedFinalMetadata;
|
|
final metadata = (probed != null && probed['error'] == null)
|
|
? probed
|
|
: await PlatformBridge.readFileMetadata(filePath);
|
|
if (metadata['error'] == null) {
|
|
final probedBitDepth = metadata['bit_depth'] is num
|
|
? (metadata['bit_depth'] as num).toInt()
|
|
: int.tryParse(metadata['bit_depth']?.toString() ?? '');
|
|
final probedSampleRate = metadata['sample_rate'] is num
|
|
? (metadata['sample_rate'] as num).toInt()
|
|
: int.tryParse(metadata['sample_rate']?.toString() ?? '');
|
|
|
|
if (probedBitDepth != null && probedBitDepth > 0) {
|
|
finalBitDepth = probedBitDepth;
|
|
}
|
|
if (probedSampleRate != null && probedSampleRate > 0) {
|
|
finalSampleRate = probedSampleRate;
|
|
}
|
|
final probedFormat = normalizeAudioFormatValue(
|
|
metadata['audio_codec']?.toString() ??
|
|
metadata['format']?.toString(),
|
|
);
|
|
if (probedFormat != null) {
|
|
finalFormat = probedFormat;
|
|
}
|
|
final probedBitrateKbps = readPositiveBitrateKbps(
|
|
metadata['bitrate'] ?? metadata['bit_rate'],
|
|
);
|
|
if (probedBitrateKbps != null &&
|
|
isLossyAudioFormat(finalFormat)) {
|
|
finalBitrateKbps = probedBitrateKbps;
|
|
}
|
|
|
|
final resolvedQuality = resolveDisplayQuality(
|
|
filePath: filePath,
|
|
fileName: finalSafFileName,
|
|
detectedFormat: finalFormat,
|
|
bitDepth: finalBitDepth,
|
|
sampleRate: finalSampleRate,
|
|
bitrateKbps: finalBitrateKbps,
|
|
storedQuality: actualQuality,
|
|
);
|
|
if (resolvedQuality != null) {
|
|
actualQuality = resolvedQuality;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
_log.d('Final audio metadata probe failed for $filePath: $e');
|
|
}
|
|
}
|
|
|
|
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
|
|
|
|
final isLossyOutput =
|
|
isLossyAudioFormat(finalFormat) ||
|
|
lowerFilePath.endsWith('.mp3') ||
|
|
lowerFilePath.endsWith('.opus') ||
|
|
lowerFilePath.endsWith('.ogg');
|
|
final historyBitDepth = isLossyOutput ? null : finalBitDepth;
|
|
final historySampleRate = isLossyOutput ? null : finalSampleRate;
|
|
final historyBitrate = isLossyOutput ? finalBitrateKbps : null;
|
|
|
|
if (settings.saveDownloadHistory) {
|
|
await ref
|
|
.read(downloadHistoryProvider.notifier)
|
|
.addToHistory(
|
|
_historyItemFromResult(
|
|
item: item,
|
|
trackToDownload: trackToDownload,
|
|
result: result,
|
|
filePath: filePath,
|
|
quality: actualQuality,
|
|
useSaf: effectiveSafMode,
|
|
downloadTreeUri: settings.downloadTreeUri,
|
|
safRelativeDir: effectiveOutputDir,
|
|
safFileName: finalSafFileName ?? safFileName,
|
|
bitDepth: historyBitDepth,
|
|
sampleRate: historySampleRate,
|
|
bitrate: historyBitrate,
|
|
format: finalFormat,
|
|
genre: effectiveGenre,
|
|
label: effectiveLabel,
|
|
copyright: effectiveCopyright,
|
|
),
|
|
preserveTrackVariant: item.preserveQualityVariant,
|
|
);
|
|
}
|
|
|
|
removeItem(item.id);
|
|
}
|
|
} else {
|
|
if (await shouldAbortWork('after backend failure')) {
|
|
return;
|
|
}
|
|
|
|
var errorMsg = result['error'] as String? ?? 'Download failed';
|
|
final errorTypeStr = result['error_type'] as String? ?? 'unknown';
|
|
final retryAfterSeconds = readPositiveInt(
|
|
result['retry_after_seconds'],
|
|
);
|
|
if (retryAfterSeconds != null && retryAfterSeconds > 0) {
|
|
errorMsg = '$errorMsg retry-after: $retryAfterSeconds';
|
|
}
|
|
if (errorTypeStr == 'cancelled') {
|
|
if (_isPausePending(item.id)) {
|
|
pausedDuringThisRun = true;
|
|
_requeueItemForPause(item.id);
|
|
_log.i('Download was paused by backend cancellation, re-queueing');
|
|
} else {
|
|
_log.i(
|
|
'Download was cancelled by backend, skipping error handling',
|
|
);
|
|
updateItemStatus(item.id, DownloadStatus.skipped);
|
|
}
|
|
return;
|
|
}
|
|
|
|
final backendErrorType = _downloadErrorTypeFromBackend(errorTypeStr);
|
|
final errorType = backendErrorType == DownloadErrorType.unknown
|
|
? _downloadErrorTypeFromMessage(errorMsg)
|
|
: backendErrorType;
|
|
|
|
if (errorType == DownloadErrorType.verificationRequired) {
|
|
await _handleVerificationRequiredDownload(
|
|
item,
|
|
errorMsg,
|
|
result['service'] as String?,
|
|
);
|
|
return;
|
|
}
|
|
if (errorType == DownloadErrorType.rateLimit &&
|
|
await _handleRateLimitedDownload(item, errorMsg)) {
|
|
return;
|
|
}
|
|
|
|
_log.e('Download failed: $errorMsg (type: $errorTypeStr)');
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.failed,
|
|
error: errorMsg,
|
|
errorType: errorType,
|
|
);
|
|
_failedInSession++;
|
|
|
|
try {
|
|
await PlatformBridge.cleanupConnections();
|
|
} catch (e) {
|
|
_log.e('Post-failure connection cleanup failed: $e');
|
|
}
|
|
}
|
|
|
|
_downloadCount++;
|
|
if (_downloadCount % _cleanupInterval == 0) {
|
|
_log.d(
|
|
'Cleaning up idle connections (after $_downloadCount downloads)...',
|
|
);
|
|
try {
|
|
await PlatformBridge.cleanupConnections();
|
|
} catch (e) {
|
|
_log.e('Connection cleanup failed: $e');
|
|
}
|
|
}
|
|
} catch (e, stackTrace) {
|
|
if (await shouldAbortWork('after exception')) {
|
|
return;
|
|
}
|
|
|
|
_log.e('Exception: $e', e, stackTrace);
|
|
|
|
String errorMsg = e.toString();
|
|
DownloadErrorType errorType = DownloadErrorType.unknown;
|
|
|
|
if (errorMsg.contains('could not find Deezer equivalent') ||
|
|
errorMsg.contains('track not found on Deezer')) {
|
|
errorMsg = 'Track not found on Deezer (Metadata Unavailable)';
|
|
errorType = DownloadErrorType.notFound;
|
|
} else {
|
|
errorType = _downloadErrorTypeFromMessage(errorMsg);
|
|
}
|
|
|
|
if (errorType == DownloadErrorType.verificationRequired) {
|
|
await _handleVerificationRequiredDownload(item, errorMsg, item.service);
|
|
return;
|
|
}
|
|
if (errorType == DownloadErrorType.rateLimit &&
|
|
await _handleRateLimitedDownload(item, errorMsg)) {
|
|
return;
|
|
}
|
|
|
|
updateItemStatus(
|
|
item.id,
|
|
DownloadStatus.failed,
|
|
error: errorMsg,
|
|
errorType: errorType,
|
|
);
|
|
_failedInSession++;
|
|
|
|
try {
|
|
await PlatformBridge.cleanupConnections();
|
|
} catch (cleanupErr) {
|
|
_log.e('Post-exception connection cleanup failed: $cleanupErr');
|
|
}
|
|
} finally {
|
|
if (pausedDuringThisRun) {
|
|
_pausePendingItemIds.remove(item.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
final downloadQueueProvider =
|
|
NotifierProvider<DownloadQueueNotifier, DownloadQueueState>(
|
|
DownloadQueueNotifier.new,
|
|
);
|
|
|
|
class DownloadQueueLookup {
|
|
final Map<String, DownloadItem> byTrackId;
|
|
final Map<String, DownloadItem> byItemId;
|
|
final Map<String, int> indexByItemId;
|
|
final List<String> itemIds;
|
|
final int queuedCount;
|
|
final int completedCount;
|
|
final int failedCount;
|
|
final int activeDownloadsCount;
|
|
final int finalizingCount;
|
|
|
|
const DownloadQueueLookup.empty()
|
|
: byTrackId = const {},
|
|
byItemId = const {},
|
|
indexByItemId = const {},
|
|
itemIds = const [],
|
|
queuedCount = 0,
|
|
completedCount = 0,
|
|
failedCount = 0,
|
|
activeDownloadsCount = 0,
|
|
finalizingCount = 0;
|
|
|
|
DownloadQueueLookup._({
|
|
required this.byTrackId,
|
|
required this.byItemId,
|
|
required this.indexByItemId,
|
|
required this.itemIds,
|
|
required this.queuedCount,
|
|
required this.completedCount,
|
|
required this.failedCount,
|
|
required this.activeDownloadsCount,
|
|
required this.finalizingCount,
|
|
});
|
|
|
|
factory DownloadQueueLookup.fromItems(List<DownloadItem> items) {
|
|
final byTrackId = <String, DownloadItem>{};
|
|
final byItemId = <String, DownloadItem>{};
|
|
final indexByItemId = <String, int>{};
|
|
final itemIds = <String>[];
|
|
var queuedCount = 0;
|
|
var completedCount = 0;
|
|
var failedCount = 0;
|
|
var activeDownloadsCount = 0;
|
|
var finalizingCount = 0;
|
|
for (var index = 0; index < items.length; index++) {
|
|
final item = items[index];
|
|
byTrackId.putIfAbsent(item.track.id, () => item);
|
|
byItemId[item.id] = item;
|
|
indexByItemId[item.id] = index;
|
|
itemIds.add(item.id);
|
|
if (_countsAsQueued(item.status)) queuedCount++;
|
|
if (item.status == DownloadStatus.completed) completedCount++;
|
|
if (item.status == DownloadStatus.failed) failedCount++;
|
|
if (item.status == DownloadStatus.downloading) activeDownloadsCount++;
|
|
if (item.status == DownloadStatus.finalizing) finalizingCount++;
|
|
}
|
|
return DownloadQueueLookup._(
|
|
byTrackId: Map.unmodifiable(byTrackId),
|
|
byItemId: Map.unmodifiable(byItemId),
|
|
indexByItemId: Map.unmodifiable(indexByItemId),
|
|
itemIds: List.unmodifiable(itemIds),
|
|
queuedCount: queuedCount,
|
|
completedCount: completedCount,
|
|
failedCount: failedCount,
|
|
activeDownloadsCount: activeDownloadsCount,
|
|
finalizingCount: finalizingCount,
|
|
);
|
|
}
|
|
|
|
static bool _countsAsQueued(DownloadStatus status) =>
|
|
status == DownloadStatus.queued ||
|
|
status == DownloadStatus.downloading ||
|
|
status == DownloadStatus.finalizing;
|
|
|
|
static int _deltaForStatus({
|
|
required DownloadStatus previous,
|
|
required DownloadStatus next,
|
|
required bool Function(DownloadStatus status) predicate,
|
|
}) {
|
|
final had = predicate(previous);
|
|
final has = predicate(next);
|
|
if (had == has) return 0;
|
|
return has ? 1 : -1;
|
|
}
|
|
|
|
DownloadQueueLookup updatedForIndices({
|
|
required List<DownloadItem> previousItems,
|
|
required List<DownloadItem> nextItems,
|
|
required Iterable<int> changedIndices,
|
|
}) {
|
|
if (previousItems.length != nextItems.length ||
|
|
itemIds.length != nextItems.length ||
|
|
indexByItemId.length != nextItems.length) {
|
|
return DownloadQueueLookup.fromItems(nextItems);
|
|
}
|
|
|
|
final normalizedChanged = <int>[];
|
|
for (final index in changedIndices) {
|
|
if (index < 0 || index >= nextItems.length) {
|
|
return DownloadQueueLookup.fromItems(nextItems);
|
|
}
|
|
normalizedChanged.add(index);
|
|
}
|
|
if (normalizedChanged.isEmpty) return this;
|
|
|
|
var nextQueuedCount = queuedCount;
|
|
var nextCompletedCount = completedCount;
|
|
var nextFailedCount = failedCount;
|
|
var nextActiveDownloadsCount = activeDownloadsCount;
|
|
var nextFinalizingCount = finalizingCount;
|
|
Map<String, DownloadItem>? nextByItemId;
|
|
Map<String, DownloadItem>? nextByTrackId;
|
|
|
|
for (final index in normalizedChanged) {
|
|
final previous = previousItems[index];
|
|
final next = nextItems[index];
|
|
if (previous.id != next.id || previous.track.id != next.track.id) {
|
|
return DownloadQueueLookup.fromItems(nextItems);
|
|
}
|
|
|
|
nextByItemId ??= Map<String, DownloadItem>.from(byItemId);
|
|
nextByItemId[next.id] = next;
|
|
if (byTrackId[next.track.id]?.id == previous.id) {
|
|
nextByTrackId ??= Map<String, DownloadItem>.from(byTrackId);
|
|
nextByTrackId[next.track.id] = next;
|
|
}
|
|
nextQueuedCount += _deltaForStatus(
|
|
previous: previous.status,
|
|
next: next.status,
|
|
predicate: _countsAsQueued,
|
|
);
|
|
nextCompletedCount += _deltaForStatus(
|
|
previous: previous.status,
|
|
next: next.status,
|
|
predicate: (status) => status == DownloadStatus.completed,
|
|
);
|
|
nextFailedCount += _deltaForStatus(
|
|
previous: previous.status,
|
|
next: next.status,
|
|
predicate: (status) => status == DownloadStatus.failed,
|
|
);
|
|
nextActiveDownloadsCount += _deltaForStatus(
|
|
previous: previous.status,
|
|
next: next.status,
|
|
predicate: (status) => status == DownloadStatus.downloading,
|
|
);
|
|
nextFinalizingCount += _deltaForStatus(
|
|
previous: previous.status,
|
|
next: next.status,
|
|
predicate: (status) => status == DownloadStatus.finalizing,
|
|
);
|
|
}
|
|
|
|
return DownloadQueueLookup._(
|
|
byTrackId: nextByTrackId == null
|
|
? byTrackId
|
|
: Map.unmodifiable(nextByTrackId),
|
|
byItemId: nextByItemId == null
|
|
? byItemId
|
|
: Map.unmodifiable(nextByItemId),
|
|
indexByItemId: indexByItemId,
|
|
itemIds: itemIds,
|
|
queuedCount: nextQueuedCount,
|
|
completedCount: nextCompletedCount,
|
|
failedCount: nextFailedCount,
|
|
activeDownloadsCount: nextActiveDownloadsCount,
|
|
finalizingCount: nextFinalizingCount,
|
|
);
|
|
}
|
|
}
|
|
|
|
final downloadQueueLookupProvider = Provider<DownloadQueueLookup>((ref) {
|
|
return ref.watch(downloadQueueProvider.select((s) => s.lookup));
|
|
});
|