Files
SpotiFLAC-Mobile/lib/providers/download_queue_provider.dart
T

2379 lines
79 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.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/platform_bridge.dart';
import 'package:spotiflac_android/services/ffmpeg_service.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/history_database.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('DownloadQueue');
final _historyLog = AppLogger('DownloadHistory');
String? _normalizeOptionalString(String? value) {
if (value == null) return null;
final trimmed = value.trim();
if (trimmed.isEmpty) return null;
if (trimmed.toLowerCase() == 'null') return null;
return trimmed;
}
final _invalidFolderChars = RegExp(r'[<>:"/\\|?*]');
final _trailingDotsRegex = RegExp(r'\.+$');
final _yearRegex = RegExp(r'^(\d{4})');
class DownloadHistoryItem {
final String id;
final String trackName;
final String artistName;
final String albumName;
final String? albumArtist;
final String? coverUrl;
final String filePath;
final String service;
final DateTime downloadedAt;
final String? isrc;
final String? spotifyId;
final int? trackNumber;
final int? discNumber;
final int? duration;
final String? releaseDate;
final String? quality;
final int? bitDepth;
final int? sampleRate;
final String? genre;
final String? label;
final String? copyright;
const DownloadHistoryItem({
required this.id,
required this.trackName,
required this.artistName,
required this.albumName,
this.albumArtist,
this.coverUrl,
required this.filePath,
required this.service,
required this.downloadedAt,
this.isrc,
this.spotifyId,
this.trackNumber,
this.discNumber,
this.duration,
this.releaseDate,
this.quality,
this.bitDepth,
this.sampleRate,
this.genre,
this.label,
this.copyright,
});
Map<String, dynamic> toJson() => {
'id': id,
'trackName': trackName,
'artistName': artistName,
'albumName': albumName,
'albumArtist': albumArtist,
'coverUrl': coverUrl,
'filePath': filePath,
'service': service,
'downloadedAt': downloadedAt.toIso8601String(),
'isrc': isrc,
'spotifyId': spotifyId,
'trackNumber': trackNumber,
'discNumber': discNumber,
'duration': duration,
'releaseDate': releaseDate,
'quality': quality,
'bitDepth': bitDepth,
'sampleRate': sampleRate,
'genre': genre,
'label': label,
'copyright': copyright,
};
factory DownloadHistoryItem.fromJson(Map<String, dynamic> json) =>
DownloadHistoryItem(
id: json['id'] as String,
trackName: json['trackName'] as String,
artistName: json['artistName'] as String,
albumName: json['albumName'] as String,
albumArtist: _normalizeOptionalString(json['albumArtist'] as String?),
coverUrl: json['coverUrl'] as String?,
filePath: json['filePath'] as String,
service: json['service'] as String,
downloadedAt: DateTime.parse(json['downloadedAt'] as String),
isrc: json['isrc'] as String?,
spotifyId: json['spotifyId'] as String?,
trackNumber: json['trackNumber'] as int?,
discNumber: json['discNumber'] as int?,
duration: json['duration'] as int?,
releaseDate: json['releaseDate'] as String?,
quality: json['quality'] as String?,
bitDepth: json['bitDepth'] as int?,
sampleRate: json['sampleRate'] as int?,
genre: json['genre'] as String?,
label: json['label'] as String?,
copyright: json['copyright'] as String?,
);
}
class DownloadHistoryState {
final List<DownloadHistoryItem> items;
final Set<String> _downloadedSpotifyIds;
final Map<String, DownloadHistoryItem> _bySpotifyId;
final Map<String, DownloadHistoryItem> _byIsrc;
DownloadHistoryState({this.items = const []})
: _downloadedSpotifyIds = items
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
.map((item) => item.spotifyId!)
.toSet(),
_bySpotifyId = Map.fromEntries(
items
.where((item) => item.spotifyId != null && item.spotifyId!.isNotEmpty)
.map((item) => MapEntry(item.spotifyId!, item)),
),
_byIsrc = Map.fromEntries(
items
.where((item) => item.isrc != null && item.isrc!.isNotEmpty)
.map((item) => MapEntry(item.isrc!, item)),
);
/// O(1) check if spotify_id exists
bool isDownloaded(String spotifyId) =>
_downloadedSpotifyIds.contains(spotifyId);
/// O(1) lookup by spotify_id
DownloadHistoryItem? getBySpotifyId(String spotifyId) =>
_bySpotifyId[spotifyId];
/// O(1) lookup by ISRC
DownloadHistoryItem? getByIsrc(String isrc) =>
_byIsrc[isrc];
DownloadHistoryState copyWith({List<DownloadHistoryItem>? items}) {
return DownloadHistoryState(items: items ?? this.items);
}
}
class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
final HistoryDatabase _db = HistoryDatabase.instance;
bool _isLoaded = false;
@override
DownloadHistoryState build() {
_loadFromDatabaseSync();
return DownloadHistoryState();
}
/// Synchronously schedule load - ensures it runs before any UI renders
void _loadFromDatabaseSync() {
if (_isLoaded) return;
_isLoaded = true;
Future.microtask(() async {
await _loadFromDatabase();
});
}
Future<void> _loadFromDatabase() async {
try {
final migrated = await _db.migrateFromSharedPreferences();
if (migrated) {
_historyLog.i('Migrated history from SharedPreferences to SQLite');
}
// Migrate iOS paths if container UUID changed after app update
if (Platform.isIOS) {
final pathsMigrated = await _db.migrateIosContainerPaths();
if (pathsMigrated) {
_historyLog.i('Migrated iOS container paths after app update');
}
}
final jsonList = await _db.getAll();
final items = jsonList
.map((e) => DownloadHistoryItem.fromJson(e))
.toList();
state = state.copyWith(items: items);
_historyLog.i('Loaded ${items.length} items from SQLite database');
} catch (e, stack) {
_historyLog.e('Failed to load history from database: $e', e, stack);
}
}
Future<void> reloadFromStorage() async {
await _loadFromDatabase();
}
void addToHistory(DownloadHistoryItem item) {
DownloadHistoryItem? existing;
if (item.spotifyId != null && item.spotifyId!.isNotEmpty) {
existing = state.getBySpotifyId(item.spotifyId!);
}
if (existing == null && item.isrc != null && item.isrc!.isNotEmpty) {
existing = state.getByIsrc(item.isrc!);
}
if (existing != null) {
final updatedItems = state.items.where((i) => i.id != existing!.id).toList();
updatedItems.insert(0, item);
state = state.copyWith(items: updatedItems);
_historyLog.d('Updated existing history entry: ${item.trackName}');
} else {
state = state.copyWith(items: [item, ...state.items]);
_historyLog.d('Added new history entry: ${item.trackName}');
}
_db.upsert(item.toJson()).catchError((e) {
_historyLog.e('Failed to save to database: $e');
});
}
void removeFromHistory(String id) {
state = state.copyWith(
items: state.items.where((item) => item.id != id).toList(),
);
_db.deleteById(id).catchError((e) {
_historyLog.e('Failed to delete from database: $e');
});
}
void removeBySpotifyId(String spotifyId) {
state = state.copyWith(
items: state.items.where((item) => item.spotifyId != spotifyId).toList(),
);
_db.deleteBySpotifyId(spotifyId).catchError((e) {
_historyLog.e('Failed to delete from database: $e');
});
_historyLog.d('Removed item with spotifyId: $spotifyId');
}
DownloadHistoryItem? getBySpotifyId(String spotifyId) {
return state.getBySpotifyId(spotifyId);
}
/// O(1) lookup by ISRC
DownloadHistoryItem? getByIsrc(String isrc) {
return state.getByIsrc(isrc);
}
/// Async version with database lookup (for cases where in-memory might be stale)
Future<DownloadHistoryItem?> getBySpotifyIdAsync(String spotifyId) async {
final inMemory = state.getBySpotifyId(spotifyId);
if (inMemory != null) return inMemory;
final json = await _db.getBySpotifyId(spotifyId);
if (json == null) return null;
return DownloadHistoryItem.fromJson(json);
}
void clearHistory() {
state = DownloadHistoryState();
_db.clearAll().catchError((e) {
_historyLog.e('Failed to clear database: $e');
});
}
/// Get database stats for debugging
Future<int> getDatabaseCount() async {
return await _db.getCount();
}
}
final downloadHistoryProvider =
NotifierProvider<DownloadHistoryNotifier, DownloadHistoryState>(
DownloadHistoryNotifier.new,
);
class DownloadQueueState {
final List<DownloadItem> items;
final DownloadItem? currentDownload;
final bool isProcessing;
final bool isPaused;
final String outputDir;
final String filenameFormat;
final String audioQuality;
final bool autoFallback;
final int concurrentDownloads;
const DownloadQueueState({
this.items = const [],
this.currentDownload,
this.isProcessing = false,
this.isPaused = false,
this.outputDir = '',
this.filenameFormat = '{artist} - {title}',
this.audioQuality = 'LOSSLESS',
this.autoFallback = true,
this.concurrentDownloads = 1,
});
DownloadQueueState copyWith({
List<DownloadItem>? items,
DownloadItem? currentDownload,
bool? isProcessing,
bool? isPaused,
String? outputDir,
String? filenameFormat,
String? audioQuality,
bool? autoFallback,
int? concurrentDownloads,
}) {
return DownloadQueueState(
items: items ?? this.items,
currentDownload: currentDownload ?? this.currentDownload,
isProcessing: isProcessing ?? this.isProcessing,
isPaused: isPaused ?? this.isPaused,
outputDir: outputDir ?? this.outputDir,
filenameFormat: filenameFormat ?? this.filenameFormat,
audioQuality: audioQuality ?? this.audioQuality,
autoFallback: autoFallback ?? this.autoFallback,
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
);
}
int get queuedCount => items
.where(
(i) =>
i.status == DownloadStatus.queued ||
i.status == DownloadStatus.downloading,
)
.length;
int get completedCount =>
items.where((i) => i.status == DownloadStatus.completed).length;
int get failedCount =>
items.where((i) => i.status == DownloadStatus.failed).length;
int get activeDownloadsCount =>
items.where((i) => i.status == DownloadStatus.downloading).length;
}
class _ProgressUpdate {
final DownloadStatus status;
final double progress;
final double? speedMBps;
const _ProgressUpdate({
required this.status,
required this.progress,
this.speedMBps,
});
}
class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
Timer? _progressTimer;
int _downloadCount = 0;
static const _cleanupInterval = 50;
static const _queueStorageKey = 'download_queue';
final NotificationService _notificationService = NotificationService();
final Future<SharedPreferences> _prefs = SharedPreferences.getInstance();
int _totalQueuedAtStart = 0;
int _completedInSession = 0;
int _failedInSession = 0;
bool _isLoaded = false;
final Set<String> _ensuredDirs = {};
@override
DownloadQueueState build() {
ref.onDispose(() {
_progressTimer?.cancel();
_progressTimer = null;
});
Future.microtask(() async {
await _initOutputDir();
await _loadQueueFromStorage();
});
return const DownloadQueueState();
}
Future<void> _loadQueueFromStorage() async {
if (_isLoaded) return;
_isLoaded = true;
try {
final prefs = await _prefs;
final jsonStr = prefs.getString(_queueStorageKey);
if (jsonStr != null && jsonStr.isNotEmpty) {
final List<dynamic> jsonList = jsonDecode(jsonStr);
final items = jsonList
.map((e) => DownloadItem.fromJson(e as Map<String, dynamic>))
.toList();
final restoredItems = items.map((item) {
if (item.status == DownloadStatus.downloading) {
return item.copyWith(status: DownloadStatus.queued, progress: 0);
}
return item;
}).toList();
final pendingItems = restoredItems
.where((item) => item.status == DownloadStatus.queued)
.toList();
if (pendingItems.isNotEmpty) {
state = state.copyWith(items: pendingItems);
_log.i('Restored ${pendingItems.length} pending items from storage');
Future.microtask(() => _processQueue());
} else {
_log.d('No pending items to restore');
await prefs.remove(_queueStorageKey);
}
} else {
_log.d('No queue found in storage');
}
} catch (e) {
_log.e('Failed to load queue from storage: $e');
}
}
Future<void> _saveQueueToStorage() async {
try {
final prefs = await _prefs;
final pendingItems = state.items
.where(
(item) =>
item.status == DownloadStatus.queued ||
item.status == DownloadStatus.downloading,
)
.toList();
if (pendingItems.isEmpty) {
await prefs.remove(_queueStorageKey);
_log.d('Cleared queue storage (no pending items)');
} else {
final jsonList = pendingItems.map((e) => e.toJson()).toList();
await prefs.setString(_queueStorageKey, jsonEncode(jsonList));
_log.d('Saved ${pendingItems.length} pending items to storage');
}
} catch (e) {
_log.e('Failed to save queue to storage: $e');
}
}
void _startMultiProgressPolling() {
_progressTimer?.cancel();
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (
timer,
) async {
try {
final allProgress = await PlatformBridge.getAllDownloadProgress();
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
final currentItems = state.items;
final itemsById = <String, DownloadItem>{};
final itemIndexById = <String, int>{};
int queuedCount = 0;
int downloadingCount = 0;
DownloadItem? firstDownloading;
for (int i = 0; i < currentItems.length; i++) {
final item = currentItems[i];
itemsById[item.id] = item;
itemIndexById[item.id] = i;
if (item.status == DownloadStatus.downloading) {
downloadingCount++;
firstDownloading ??= item;
}
if (item.status == DownloadStatus.queued ||
item.status == DownloadStatus.downloading) {
queuedCount++;
}
}
final progressUpdates = <String, _ProgressUpdate>{};
bool hasFinalizingItem = false;
String? finalizingTrackName;
String? finalizingArtistName;
for (final entry in items.entries) {
final itemId = entry.key;
final localItem = itemsById[itemId];
if (localItem == null) {
continue;
}
if (localItem.status == DownloadStatus.skipped) {
PlatformBridge.clearItemProgress(itemId).catchError((_) {});
continue;
}
if (localItem.status == DownloadStatus.completed ||
localItem.status == DownloadStatus.failed) {
continue;
}
final itemProgress = entry.value as Map<String, dynamic>;
final bytesReceived = itemProgress['bytes_received'] as int? ?? 0;
final bytesTotal = itemProgress['bytes_total'] as int? ?? 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';
if (status == 'finalizing' && bytesTotal > 0) {
progressUpdates[itemId] = const _ProgressUpdate(
status: DownloadStatus.finalizing,
progress: 1.0,
);
hasFinalizingItem = true;
finalizingTrackName = localItem.track.name;
finalizingArtistName = localItem.track.artistName;
continue;
}
final progressFromBackend =
(itemProgress['progress'] as num?)?.toDouble() ?? 0.0;
if (isDownloading) {
double percentage = 0.0;
if (bytesTotal > 0) {
percentage = bytesReceived / bytesTotal;
} else {
percentage = progressFromBackend;
}
progressUpdates[itemId] = _ProgressUpdate(
status: DownloadStatus.downloading,
progress: percentage,
speedMBps: speedMBps,
);
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)}% (DASH segments/unknown size) @ ${speedMBps.toStringAsFixed(2)} MB/s',
);
}
}
}
if (progressUpdates.isNotEmpty) {
var updatedItems = currentItems;
bool changed = false;
for (final entry in progressUpdates.entries) {
final index = itemIndexById[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;
final next = current.copyWith(
status: update.status,
progress: update.progress,
speedMBps: update.speedMBps ?? current.speedMBps,
);
if (current.status != next.status ||
current.progress != next.progress ||
current.speedMBps != next.speedMBps) {
if (!changed) {
updatedItems = List<DownloadItem>.from(updatedItems);
changed = true;
}
updatedItems[index] = next;
}
}
if (changed) {
state = state.copyWith(items: updatedItems);
}
}
if (hasFinalizingItem && finalizingTrackName != null) {
_notificationService.showDownloadFinalizing(
trackName: finalizingTrackName,
artistName: finalizingArtistName ?? '',
);
return;
}
if (items.isNotEmpty) {
final firstEntry = items.entries.first;
final firstProgress = firstEntry.value as Map<String, dynamic>;
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
if (downloadingCount > 0 && firstDownloading != null) {
final trackName = downloadingCount == 1
? firstDownloading.track.name
: '$downloadingCount downloads';
final artistName = downloadingCount == 1
? firstDownloading.track.artistName
: 'Downloading...';
int notifProgress = bytesReceived;
int notifTotal = bytesTotal;
if (bytesTotal <= 0) {
final progressPercent =
(firstProgress['progress'] as num?)?.toDouble() ?? 0.0;
notifProgress = (progressPercent * 100).toInt();
notifTotal = 100;
}
_notificationService.showDownloadProgress(
trackName: trackName,
artistName: artistName,
progress: notifProgress,
total: notifTotal > 0 ? notifTotal : 1,
);
if (Platform.isAndroid) {
PlatformBridge.updateDownloadServiceProgress(
trackName: firstDownloading.track.name,
artistName: firstDownloading.track.artistName,
progress: notifProgress,
total: notifTotal > 0 ? notifTotal : 1,
queueCount: queuedCount,
).catchError((_) {});
}
}
}
} catch (_) {}
});
}
void _stopProgressPolling() {
_progressTimer?.cancel();
_progressTimer = null;
}
Future<void> _initOutputDir() async {
if (state.outputDir.isEmpty) {
try {
if (Platform.isIOS) {
final dir = await getApplicationDocumentsDirectory();
final musicDir = Directory('${dir.path}/SpotiFLAC');
if (!await musicDir.exists()) {
await musicDir.create(recursive: true);
}
state = state.copyWith(outputDir: musicDir.path);
} else {
final dir = await getExternalStorageDirectory();
if (dir != null) {
final musicDir = Directory(
'${dir.parent.parent.parent.parent.path}/Music/SpotiFLAC',
);
if (!await musicDir.exists()) {
await musicDir.create(recursive: true);
}
state = state.copyWith(outputDir: musicDir.path);
} else {
final docDir = await getApplicationDocumentsDirectory();
final musicDir = Directory('${docDir.path}/SpotiFLAC');
if (!await musicDir.exists()) {
await musicDir.create(recursive: true);
}
state = state.copyWith(outputDir: musicDir.path);
}
}
} catch (e) {
final dir = await getApplicationDocumentsDirectory();
final musicDir = Directory('${dir.path}/SpotiFLAC');
if (!await musicDir.exists()) {
await musicDir.create(recursive: true);
}
state = state.copyWith(outputDir: musicDir.path);
}
}
}
Future<void> _ensureDirExists(String path, {String? label}) async {
if (_ensuredDirs.contains(path)) return;
final dir = Directory(path);
if (!await dir.exists()) {
await dir.create(recursive: true);
if (label != null) {
_log.d('Created $label: $path');
} else {
_log.d('Created folder: $path');
}
}
_ensuredDirs.add(path);
}
void setOutputDir(String dir) {
state = state.copyWith(outputDir: dir);
}
Future<String> _buildOutputDir(Track track, String folderOrganization, {bool separateSingles = false, String albumFolderStructure = 'artist_album'}) async {
String baseDir = state.outputDir;
final albumArtist = _normalizeOptionalString(track.albumArtist) ?? track.artistName;
if (separateSingles) {
final isSingle = track.isSingle;
final artistName = _sanitizeFolderName(albumArtist);
// New option: Singles folder inside Artist folder
if (albumFolderStructure == 'artist_album_singles') {
if (isSingle) {
final singlesPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}Singles';
await _ensureDirExists(singlesPath, label: 'Artist Singles folder');
return singlesPath;
} else {
final albumName = _sanitizeFolderName(track.albumName);
final albumPath = '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
await _ensureDirExists(albumPath, label: 'Artist Album folder');
return albumPath;
}
}
// Existing behavior: Separate Albums/ and Singles/ at root
if (isSingle) {
final singlesPath = '$baseDir${Platform.pathSeparator}Singles';
await _ensureDirExists(singlesPath, label: 'Singles folder');
return singlesPath;
} else {
final albumName = _sanitizeFolderName(track.albumName);
final year = _extractYear(track.releaseDate);
String albumPath;
switch (albumFolderStructure) {
case 'album_only':
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$albumName';
break;
case 'artist_year_album':
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$yearAlbum';
break;
case 'year_album':
final yearAlbum = year != null ? '[$year] $albumName' : albumName;
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$yearAlbum';
break;
default:
albumPath = '$baseDir${Platform.pathSeparator}Albums${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName';
}
await _ensureDirExists(albumPath, label: 'Album folder');
return albumPath;
}
}
if (folderOrganization == 'none') {
return baseDir;
}
String subPath = '';
switch (folderOrganization) {
case 'artist':
final artistName = _sanitizeFolderName(albumArtist);
subPath = artistName;
break;
case 'album':
final albumName = _sanitizeFolderName(track.albumName);
subPath = albumName;
break;
case 'artist_album':
final artistName = _sanitizeFolderName(albumArtist);
final albumName = _sanitizeFolderName(track.albumName);
subPath = '$artistName${Platform.pathSeparator}$albumName';
break;
}
if (subPath.isNotEmpty) {
final fullPath = '$baseDir${Platform.pathSeparator}$subPath';
await _ensureDirExists(fullPath);
return fullPath;
}
return baseDir;
}
String _sanitizeFolderName(String name) {
return name
.replaceAll(_invalidFolderChars, '_')
.replaceAll(_trailingDotsRegex, '')
.trim();
}
/// Extract year from release date (format: "2005-06-13" or "2005")
String? _extractYear(String? releaseDate) {
if (releaseDate == null || releaseDate.isEmpty) return null;
final match = _yearRegex.firstMatch(releaseDate);
return match?.group(1);
}
void updateSettings(AppSettings settings) {
state = state.copyWith(
outputDir: settings.downloadDirectory.isNotEmpty
? settings.downloadDirectory
: state.outputDir,
filenameFormat: settings.filenameFormat,
audioQuality: settings.audioQuality,
autoFallback: settings.autoFallback,
concurrentDownloads: settings.concurrentDownloads,
);
}
String addToQueue(Track track, String service, {String? qualityOverride}) {
final settings = ref.read(settingsProvider);
updateSettings(settings);
final id =
'${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
final item = DownloadItem(
id: id,
track: track,
service: service,
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
);
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,
}) {
final settings = ref.read(settingsProvider);
updateSettings(settings);
final newItems = tracks.map((track) {
final id =
'${track.isrc ?? track.id}-${DateTime.now().millisecondsSinceEpoch}';
return DownloadItem(
id: id,
track: track,
service: service,
createdAt: DateTime.now(),
qualityOverride: qualityOverride,
);
}).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 = items.indexWhere((item) => item.id == id);
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);
if (status == DownloadStatus.completed ||
status == DownloadStatus.failed ||
status == DownloadStatus.skipped) {
_saveQueueToStorage();
}
}
void updateProgress(String id, double progress, {double? speedMBps}) {
final items = state.items;
final index = items.indexWhere((i) => i.id == id);
if (index == -1) return;
final item = items[index];
if (item.status == DownloadStatus.skipped ||
item.status == DownloadStatus.completed ||
item.status == DownloadStatus.failed) {
return;
}
updateItemStatus(
id,
DownloadStatus.downloading,
progress: progress,
speedMBps: speedMBps,
);
}
void cancelItem(String id) {
updateItemStatus(id, DownloadStatus.skipped);
PlatformBridge.cancelDownload(id).catchError((_) {});
PlatformBridge.clearItemProgress(id).catchError((_) {});
}
void clearCompleted() {
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();
}
void clearAll() {
state = state.copyWith(items: [], isPaused: false);
_saveQueueToStorage();
}
void pauseQueue() {
if (state.isProcessing && !state.isPaused) {
state = state.copyWith(isPaused: true);
_notificationService.cancelDownloadNotification();
_log.i('Queue paused');
}
}
void resumeQueue() {
if (state.isPaused) {
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)');
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 removeItem(String id) {
final items = state.items.where((item) => item.id != id).toList();
state = state.copyWith(items: items);
_saveQueueToStorage();
}
Future<void> _runPostProcessingHooks(String filePath, Track track) async {
try {
final settings = ref.read(settingsProvider);
final extensionState = ref.read(extensionProvider);
if (!settings.useExtensionProviders) return;
final hasPostProcessing = extensionState.extensions.any(
(e) => e.enabled && e.hasPostProcessing,
);
if (!hasPostProcessing) return;
_log.d('Running post-processing hooks on: $filePath');
final metadata = <String, dynamic>{
'title': track.name,
'artist': track.artistName,
'album': track.albumName,
'album_artist': _normalizeOptionalString(track.albumArtist) ?? track.artistName,
'track_number': track.trackNumber ?? 1,
'disc_number': track.discNumber ?? 1,
'isrc': track.isrc ?? '',
'release_date': track.releaseDate ?? '',
'duration_ms': track.duration * 1000,
'cover_url': track.coverUrl ?? '',
};
final result = await PlatformBridge.runPostProcessing(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');
}
} else {
final error = result['error'] as String? ?? 'Unknown error';
_log.w('Post-processing failed: $error');
}
} catch (e) {
_log.w('Post-processing error: $e');
}
}
/// Same logic as Go backend cover.go
String _upgradeToMaxQualityCover(String coverUrl) {
const spotifySize300 = 'ab67616d00001e02';
const spotifySize640 = 'ab67616d0000b273';
const spotifySizeMax = 'ab67616d000082c1';
var result = coverUrl;
if (result.contains(spotifySize300)) {
result = result.replaceFirst(spotifySize300, spotifySize640);
}
if (result.contains(spotifySize640)) {
result = result.replaceFirst(spotifySize640, spotifySizeMax);
}
return result;
}
Future<void> _embedMetadataAndCover(
String flacPath,
Track track, {
String? genre,
String? label,
String? copyright,
}) async {
final settings = ref.read(settingsProvider);
String? coverPath;
var coverUrl = track.coverUrl;
if (coverUrl != null && coverUrl.isNotEmpty) {
try {
if (settings.maxQualityCover) {
coverUrl = _upgradeToMaxQualityCover(coverUrl);
_log.d('Cover URL upgraded to max quality: $coverUrl');
}
final tempDir = await getTemporaryDirectory();
final uniqueId =
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
coverPath = '${tempDir.path}/cover_$uniqueId.jpg';
final httpClient = HttpClient();
final request = await httpClient.getUrl(Uri.parse(coverUrl));
final response = await request.close();
if (response.statusCode == 200) {
final file = File(coverPath);
final sink = file.openWrite();
await response.pipe(sink);
await sink.close();
_log.d('Cover downloaded to temp: $coverPath');
} else {
_log.w('Failed to download cover: HTTP ${response.statusCode}');
coverPath = null;
}
httpClient.close();
} catch (e) {
_log.e('Failed to download cover: $e');
coverPath = null;
}
}
try {
final metadata = <String, String>{
'TITLE': track.name,
'ARTIST': track.artistName,
'ALBUM': track.albumName,
};
final albumArtist = _normalizeOptionalString(track.albumArtist) ??
track.artistName;
metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) {
metadata['TRACKNUMBER'] = track.trackNumber.toString();
metadata['TRACK'] = track.trackNumber.toString();
}
if (track.discNumber != null) {
metadata['DISCNUMBER'] = track.discNumber.toString();
metadata['DISC'] = track.discNumber.toString();
}
if (track.releaseDate != null) {
metadata['DATE'] = track.releaseDate!;
metadata['YEAR'] = track.releaseDate!.split('-').first;
}
if (track.isrc != null) {
metadata['ISRC'] = track.isrc!;
}
if (genre != null && genre.isNotEmpty) {
metadata['GENRE'] = genre;
_log.d('Adding GENRE: $genre');
}
if (label != null && label.isNotEmpty) {
metadata['ORGANIZATION'] = label;
_log.d('Adding ORGANIZATION (label): $label');
}
if (copyright != null && copyright.isNotEmpty) {
metadata['COPYRIGHT'] = copyright;
_log.d('Adding COPYRIGHT: $copyright');
}
_log.d('Metadata map content: $metadata');
try {
final durationMs = track.duration * 1000;
final lrcContent = await PlatformBridge.getLyricsLRC(
track.id,
track.name,
track.artistName,
filePath: '',
durationMs: durationMs,
);
// Skip instrumental tracks (no lyrics to embed)
if (lrcContent.isNotEmpty && lrcContent != '[instrumental:true]') {
metadata['LYRICS'] = lrcContent;
metadata['UNSYNCEDLYRICS'] = lrcContent;
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
} else if (lrcContent == '[instrumental:true]') {
_log.d('Track is instrumental, skipping lyrics embedding');
}
} catch (e) {
_log.w('Failed to fetch lyrics for embedding: $e');
}
_log.d('Generating tags for FLAC: $metadata');
final result = await FFmpegService.embedMetadata(
flacPath: flacPath,
coverPath: coverPath != null && await File(coverPath).exists()
? coverPath
: null,
metadata: metadata,
);
if (result != null) {
_log.d('Metadata and cover embedded via FFmpeg');
} else {
_log.w('FFmpeg metadata/cover embed failed');
}
if (coverPath != null) {
try {
final coverFile = File(coverPath);
if (await coverFile.exists()) {
await coverFile.delete();
}
} catch (e) {
_log.w('Failed to cleanup cover file: $e');
}
}
} catch (e) {
_log.e('Failed to embed metadata: $e');
}
}
Future<void> _embedMetadataToMp3(
String mp3Path,
Track track, {
String? genre,
String? label,
String? copyright,
}) async {
final settings = ref.read(settingsProvider);
String? coverPath;
var coverUrl = track.coverUrl;
if (coverUrl != null && coverUrl.isNotEmpty) {
try {
if (settings.maxQualityCover) {
coverUrl = _upgradeToMaxQualityCover(coverUrl);
_log.d('Cover URL upgraded to max quality for MP3: $coverUrl');
}
final tempDir = await getTemporaryDirectory();
final uniqueId =
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
coverPath = '${tempDir.path}/cover_mp3_$uniqueId.jpg';
final httpClient = HttpClient();
final request = await httpClient.getUrl(Uri.parse(coverUrl));
final response = await request.close();
if (response.statusCode == 200) {
final file = File(coverPath);
final sink = file.openWrite();
await response.pipe(sink);
await sink.close();
_log.d('Cover downloaded for MP3: $coverPath');
} else {
_log.w('Failed to download cover for MP3: HTTP ${response.statusCode}');
coverPath = null;
}
httpClient.close();
} catch (e) {
_log.e('Failed to download cover for MP3: $e');
coverPath = null;
}
}
try {
final metadata = <String, String>{
'TITLE': track.name,
'ARTIST': track.artistName,
'ALBUM': track.albumName,
};
final albumArtist = _normalizeOptionalString(track.albumArtist) ??
track.artistName;
metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) {
metadata['TRACKNUMBER'] = track.trackNumber.toString();
metadata['TRACK'] = track.trackNumber.toString();
}
if (track.discNumber != null) {
metadata['DISCNUMBER'] = track.discNumber.toString();
metadata['DISC'] = track.discNumber.toString();
}
if (track.releaseDate != null) {
metadata['DATE'] = track.releaseDate!;
metadata['YEAR'] = track.releaseDate!.split('-').first;
}
if (track.isrc != null) {
metadata['ISRC'] = track.isrc!;
}
if (genre != null && genre.isNotEmpty) {
metadata['GENRE'] = genre;
_log.d('Adding GENRE to MP3: $genre');
}
if (label != null && label.isNotEmpty) {
metadata['ORGANIZATION'] = label;
_log.d('Adding ORGANIZATION (label) to MP3: $label');
}
if (copyright != null && copyright.isNotEmpty) {
metadata['COPYRIGHT'] = copyright;
_log.d('Adding COPYRIGHT to MP3: $copyright');
}
_log.d('MP3 Metadata map content: $metadata');
if (settings.embedLyrics) {
try {
final durationMs = track.duration * 1000;
final lrcContent = await PlatformBridge.getLyricsLRC(
track.id,
track.name,
track.artistName,
filePath: '',
durationMs: durationMs,
);
if (lrcContent.isNotEmpty) {
metadata['LYRICS'] = lrcContent;
metadata['UNSYNCEDLYRICS'] = lrcContent;
_log.d('Lyrics fetched for MP3 embedding (${lrcContent.length} chars)');
}
} catch (e) {
_log.w('Failed to fetch lyrics for MP3 embedding: $e');
}
}
_log.d('Embedding tags to MP3: $metadata');
final result = await FFmpegService.embedMetadataToMp3(
mp3Path: mp3Path,
coverPath: coverPath != null && await File(coverPath).exists()
? coverPath
: null,
metadata: metadata,
);
if (result != null) {
_log.d('Metadata, lyrics, and cover embedded to MP3 via FFmpeg');
} else {
_log.w('FFmpeg MP3 metadata/cover embed failed');
}
if (coverPath != null) {
try {
final coverFile = File(coverPath);
if (await coverFile.exists()) {
await coverFile.delete();
}
} catch (e) {
_log.w('Failed to cleanup MP3 cover file: $e');
}
}
} catch (e) {
_log.e('Failed to embed metadata to MP3: $e');
}
}
Future<void> _embedMetadataToOpus(
String opusPath,
Track track, {
String? genre,
String? label,
String? copyright,
}) async {
final settings = ref.read(settingsProvider);
String? coverPath;
var coverUrl = track.coverUrl;
if (coverUrl != null && coverUrl.isNotEmpty) {
try {
if (settings.maxQualityCover) {
coverUrl = _upgradeToMaxQualityCover(coverUrl);
_log.d('Cover URL upgraded to max quality for Opus: $coverUrl');
}
final tempDir = await getTemporaryDirectory();
final uniqueId =
'${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
coverPath = '${tempDir.path}/cover_opus_$uniqueId.jpg';
final httpClient = HttpClient();
final request = await httpClient.getUrl(Uri.parse(coverUrl));
final response = await request.close();
if (response.statusCode == 200) {
final file = File(coverPath);
final sink = file.openWrite();
await response.pipe(sink);
await sink.close();
_log.d('Cover downloaded for Opus: $coverPath');
} else {
_log.w('Failed to download cover for Opus: HTTP ${response.statusCode}');
coverPath = null;
}
httpClient.close();
} catch (e) {
_log.e('Failed to download cover for Opus: $e');
coverPath = null;
}
}
try {
final metadata = <String, String>{
'TITLE': track.name,
'ARTIST': track.artistName,
'ALBUM': track.albumName,
};
final albumArtist = _normalizeOptionalString(track.albumArtist) ??
track.artistName;
metadata['ALBUMARTIST'] = albumArtist;
if (track.trackNumber != null) {
metadata['TRACKNUMBER'] = track.trackNumber.toString();
}
if (track.discNumber != null) {
metadata['DISCNUMBER'] = track.discNumber.toString();
}
if (track.releaseDate != null) {
metadata['DATE'] = track.releaseDate!;
}
if (track.isrc != null) {
metadata['ISRC'] = track.isrc!;
}
if (genre != null && genre.isNotEmpty) {
metadata['GENRE'] = genre;
_log.d('Adding GENRE to Opus: $genre');
}
if (label != null && label.isNotEmpty) {
metadata['ORGANIZATION'] = label;
_log.d('Adding ORGANIZATION (label) to Opus: $label');
}
if (copyright != null && copyright.isNotEmpty) {
metadata['COPYRIGHT'] = copyright;
_log.d('Adding COPYRIGHT to Opus: $copyright');
}
_log.d('Opus Metadata map content: $metadata');
if (settings.embedLyrics) {
try {
final durationMs = track.duration * 1000;
final lrcContent = await PlatformBridge.getLyricsLRC(
track.id,
track.name,
track.artistName,
filePath: '',
durationMs: durationMs,
);
if (lrcContent.isNotEmpty) {
metadata['LYRICS'] = lrcContent;
_log.d('Lyrics fetched for Opus embedding (${lrcContent.length} chars)');
}
} catch (e) {
_log.w('Failed to fetch lyrics for Opus embedding: $e');
}
}
_log.d('Embedding tags to Opus: $metadata');
final result = await FFmpegService.embedMetadataToOpus(
opusPath: opusPath,
coverPath: coverPath != null && await File(coverPath).exists()
? coverPath
: null,
metadata: metadata,
);
if (result != null) {
_log.d('Metadata, lyrics, and cover embedded to Opus via FFmpeg');
} else {
_log.w('FFmpeg Opus metadata/cover embed failed');
}
if (coverPath != null) {
try {
final coverFile = File(coverPath);
if (await coverFile.exists()) {
await coverFile.delete();
}
} catch (e) {
_log.w('Failed to cleanup Opus cover file: $e');
}
}
} catch (e) {
_log.e('Failed to embed metadata to Opus: $e');
}
}
Future<void> _processQueue() async {
if (state.isProcessing) 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 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');
}
}
if (state.outputDir.isEmpty) {
_log.d('Output dir empty, initializing...');
await _initOutputDir();
}
if (state.outputDir.isEmpty) {
_log.d('Using fallback directory...');
final dir = await getApplicationDocumentsDirectory();
final musicDir = Directory('${dir.path}/SpotiFLAC');
if (!await musicDir.exists()) {
await musicDir.create(recursive: true);
}
state = state.copyWith(outputDir: musicDir.path);
}
_log.d('Output directory: ${state.outputDir}');
_log.d('Concurrent downloads: ${state.concurrentDownloads}');
if (state.concurrentDownloads > 1) {
await _processQueueParallel();
} else {
await _processQueueSequential();
}
_stopProgressPolling();
if (Platform.isAndroid) {
try {
await PlatformBridge.stopDownloadService();
_log.d('Foreground service stopped');
} catch (e) {
_log.e('Failed to stop foreground service: $e');
}
}
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',
);
if (_totalQueuedAtStart > 0) {
await _notificationService.showQueueComplete(
completedCount: _completedInSession,
failedCount: _failedInSession,
);
}
_log.i('Queue processing finished');
state = state.copyWith(isProcessing: false, currentDownload: null);
final hasQueuedItems = state.items.any(
(item) => item.status == DownloadStatus.queued,
);
if (hasQueuedItems) {
_log.i(
'Found queued items after processing finished, restarting queue...',
);
Future.microtask(() => _processQueue());
}
}
Future<void> _processQueueSequential() async {
_startMultiProgressPolling();
while (true) {
if (state.isPaused) {
_log.d('Queue is paused, waiting...');
await Future.delayed(const Duration(milliseconds: 500));
continue;
}
final currentItems = state.items;
final nextIndex = currentItems.indexWhere(
(item) => item.status == DownloadStatus.queued,
);
if (nextIndex == -1) {
_log.d(
'No more items to process (checked ${currentItems.length} items)',
);
break;
}
final nextItem = currentItems[nextIndex];
_log.d(
'Processing next item: ${nextItem.track.name} (id: ${nextItem.id})',
);
await _downloadSingleItem(nextItem);
PlatformBridge.clearItemProgress(nextItem.id).catchError((_) {});
}
_stopProgressPolling();
}
Future<void> _processQueueParallel() async {
final maxConcurrent = state.concurrentDownloads;
final activeDownloads = <String, Future<void>>{};
_startMultiProgressPolling();
while (true) {
if (state.isPaused) {
_log.d('Queue is paused, waiting for active downloads...');
if (activeDownloads.isNotEmpty) {
await Future.any(activeDownloads.values);
} else {
await Future.delayed(const Duration(milliseconds: 500));
}
continue;
}
final queuedItems = state.items
.where((item) => item.status == DownloadStatus.queued)
.toList();
if (queuedItems.isEmpty && activeDownloads.isEmpty) {
_log.d('No more items to process');
break;
}
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 parallel download: ${item.track.name} (${activeDownloads.length}/$maxConcurrent active)',
);
}
if (activeDownloads.isNotEmpty) {
await Future.any(activeDownloads.values);
}
}
if (activeDownloads.isNotEmpty) {
await Future.wait(activeDownloads.values);
}
_stopProgressPolling();
}
Future<void> _downloadSingleItem(DownloadItem item) async {
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
_log.d('Cover URL: ${item.track.coverUrl}');
final currentItem = state.items.firstWhere(
(i) => i.id == item.id,
orElse: () => item,
);
if (currentItem.status == DownloadStatus.skipped) {
_log.i('Download was cancelled before start, skipping');
return;
}
state = state.copyWith(currentDownload: item);
updateItemStatus(item.id, DownloadStatus.downloading);
try {
final settings = ref.read(settingsProvider);
Track trackToDownload = item.track;
final needsEnrichment =
trackToDownload.id.startsWith('deezer:') &&
(trackToDownload.isrc == null ||
trackToDownload.isrc!.isEmpty ||
trackToDownload.trackNumber == null ||
trackToDownload.trackNumber == 0);
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.getDeezerMetadata(
'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']}');
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?,
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?,
releaseDate: data['release_date'] as String?,
deezerId: rawId,
availability: trackToDownload.availability,
albumType: (data['album_type'] as String?) ?? trackToDownload.albumType,
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');
}
}
_log.d('Track coverUrl after enrichment: ${trackToDownload.coverUrl}');
final normalizedAlbumArtist =
_normalizeOptionalString(trackToDownload.albumArtist);
final outputDir = await _buildOutputDir(
trackToDownload,
settings.folderOrganization,
separateSingles: settings.separateSingles,
albumFolderStructure: settings.albumFolderStructure,
);
final quality = item.qualityOverride ?? state.audioQuality;
// For LOSSY, we need to download FLAC first then convert
// Servers don't support lossy quality directly
final downloadQuality = quality == 'LOSSY' ? 'LOSSLESS' : quality;
// Fetch extended metadata (genre, label) from Deezer if available
String? genre;
String? label;
String? deezerTrackId = trackToDownload.deezerId;
if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) {
deezerTrackId = trackToDownload.id.split(':')[1];
}
if (deezerTrackId == null && trackToDownload.availability?.deezerId != null) {
deezerTrackId = trackToDownload.availability!.deezerId;
}
if (deezerTrackId == null && trackToDownload.isrc != null && trackToDownload.isrc!.isNotEmpty) {
try {
_log.d('No Deezer ID, searching by ISRC: ${trackToDownload.isrc}');
final deezerResult = await PlatformBridge.searchDeezerByISRC(trackToDownload.isrc!);
if (deezerResult['success'] == true && deezerResult['track_id'] != null) {
deezerTrackId = deezerResult['track_id'].toString();
_log.d('Found Deezer track ID via ISRC: $deezerTrackId');
}
} catch (e) {
_log.w('Failed to search Deezer by ISRC: $e');
}
}
if (deezerTrackId != null && deezerTrackId.isNotEmpty) {
try {
final extendedMetadata = await PlatformBridge.getDeezerExtendedMetadata(deezerTrackId);
if (extendedMetadata != null) {
genre = extendedMetadata['genre'];
label = extendedMetadata['label'];
if (genre != null && genre.isNotEmpty) {
_log.d('Extended metadata - Genre: $genre, Label: $label');
}
}
} catch (e) {
_log.w('Failed to fetch extended metadata from Deezer: $e');
}
}
Map<String, dynamic> result;
final extensionState = ref.read(extensionProvider);
final hasActiveExtensions = extensionState.extensions.any((e) => e.enabled);
final useExtensions = settings.useExtensionProviders && hasActiveExtensions;
if (useExtensions) {
_log.d('Using extension providers for download');
_log.d(
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}${quality == 'LOSSY' ? ' (downloading as LOSSLESS for conversion)' : ''}',
);
_log.d('Output dir: $outputDir');
result = await PlatformBridge.downloadWithExtensions(
isrc: trackToDownload.isrc ?? '',
spotifyId: trackToDownload.id,
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: normalizedAlbumArtist,
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: downloadQuality,
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
releaseDate: trackToDownload.releaseDate,
itemId: item.id,
durationMs: trackToDownload.duration,
source: trackToDownload.source,
genre: genre,
label: label,
lyricsMode: settings.lyricsMode,
);
} else if (state.autoFallback) {
_log.d('Using auto-fallback mode');
_log.d(
'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}${quality == 'LOSSY' ? ' (downloading as LOSSLESS for conversion)' : ''}',
);
_log.d('Output dir: $outputDir');
result = await PlatformBridge.downloadWithFallback(
isrc: trackToDownload.isrc ?? '',
spotifyId: trackToDownload.id,
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: normalizedAlbumArtist,
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: downloadQuality,
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
releaseDate: trackToDownload.releaseDate,
preferredService: item.service,
itemId: item.id,
durationMs: trackToDownload.duration,
genre: genre,
label: label,
lyricsMode: settings.lyricsMode,
);
} else {
result = await PlatformBridge.downloadTrack(
isrc: trackToDownload.isrc ?? '',
service: item.service,
spotifyId: trackToDownload.id,
trackName: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: trackToDownload.albumName,
albumArtist: normalizedAlbumArtist,
coverUrl: trackToDownload.coverUrl,
outputDir: outputDir,
filenameFormat: state.filenameFormat,
quality: downloadQuality,
trackNumber: trackToDownload.trackNumber ?? 1,
discNumber: trackToDownload.discNumber ?? 1,
releaseDate: trackToDownload.releaseDate,
itemId: item.id,
durationMs: trackToDownload.duration,
);
}
_log.d('Result: $result');
final currentItem = state.items.firstWhere(
(i) => i.id == item.id,
orElse: () => item,
);
if (currentItem.status == DownloadStatus.skipped) {
_log.i('Download was cancelled, skipping result processing');
final filePath = result['file_path'] as String?;
if (filePath != null && result['success'] == true) {
try {
final file = File(filePath);
if (await file.exists()) {
await file.delete();
_log.d('Deleted cancelled download file: $filePath');
}
} catch (e) {
_log.w('Failed to delete cancelled file: $e');
}
}
return;
}
if (result['success'] == true) {
var filePath = result['file_path'] as String?;
final wasExisting = filePath != null && filePath.startsWith('EXISTS:');
if (wasExisting) {
filePath = filePath.substring(7); // Remove "EXISTS:" prefix
_log.i('Using existing file: $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) {
// Format: "24-bit/96kHz" or "16-bit/44.1kHz"
final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0
? (actualSampleRate / 1000).toStringAsFixed(
actualSampleRate % 1000 == 0 ? 0 : 1,
)
: '?';
actualQuality = '$actualBitDepth-bit/${sampleRateKHz}kHz';
_log.i('Actual quality: $actualQuality');
}
if (filePath != null && filePath.endsWith('.m4a')) {
_log.d(
'M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...',
);
try {
final file = File(filePath);
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 {
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.95,
);
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
if (flacPath != null) {
filePath = flacPath;
_log.d('Converted to FLAC: $flacPath');
_log.d('Embedding metadata and cover to converted FLAC...');
try {
Track finalTrack = trackToDownload;
if (result.containsKey('track_number') ||
result.containsKey('release_date')) {
_log.d(
'Using metadata from backend response for embedding',
);
final backendTrackNum = result['track_number'] as int?;
final backendDiscNum = result['disc_number'] as int?;
final backendYear = result['release_date'] as String?;
final backendAlbum = result['album'] as String?;
_log.d(
'Backend metadata - Track: $backendTrackNum, Disc: $backendDiscNum, Year: $backendYear',
);
final newTrackNumber =
(backendTrackNum != null && backendTrackNum > 0)
? backendTrackNum
: trackToDownload.trackNumber;
final newDiscNumber =
(backendDiscNum != null && backendDiscNum > 0)
? backendDiscNum
: trackToDownload.discNumber;
_log.d(
'Final metadata for embedding - Track: $newTrackNumber, Disc: $newDiscNumber',
);
finalTrack = Track(
id: trackToDownload.id,
name: trackToDownload.name,
artistName: trackToDownload.artistName,
albumName: backendAlbum ?? trackToDownload.albumName,
albumArtist: normalizedAlbumArtist,
coverUrl: trackToDownload.coverUrl,
duration: trackToDownload.duration,
isrc: trackToDownload.isrc,
trackNumber: newTrackNumber,
discNumber: newDiscNumber,
releaseDate: backendYear ?? trackToDownload.releaseDate,
deezerId: trackToDownload.deezerId,
availability: trackToDownload.availability,
albumType: trackToDownload.albumType,
source: trackToDownload.source,
);
}
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 _embedMetadataAndCover(
flacPath,
finalTrack,
genre: backendGenre ?? genre,
label: backendLabel ?? label,
copyright: backendCopyright,
);
_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');
}
}
final itemAfterDownload = state.items.firstWhere(
(i) => i.id == item.id,
orElse: () => item,
);
if (itemAfterDownload.status == DownloadStatus.skipped) {
_log.i('Download was cancelled during finalization, cleaning up');
if (filePath != null) {
try {
final file = File(filePath);
if (await file.exists()) {
await file.delete();
_log.d('Deleted cancelled download file: $filePath');
}
} catch (e) {
_log.w('Failed to delete cancelled file: $e');
}
}
return;
}
if (quality == 'LOSSY' && filePath != null && filePath.endsWith('.flac')) {
if (wasExisting) {
_log.i('Lossy requested but existing FLAC found - skipping conversion to preserve original file');
} else {
final lossyFormat = settings.lossyFormat;
final lossyBitrate = settings.lossyBitrate;
_log.i('Lossy quality selected, converting FLAC to $lossyFormat ($lossyBitrate)...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.97,
);
try {
final convertedPath = await FFmpegService.convertFlacToLossy(
filePath,
format: lossyFormat,
bitrate: lossyBitrate,
deleteOriginal: true,
);
if (convertedPath != null) {
filePath = convertedPath;
// Extract bitrate for display (e.g., 'mp3_320' -> '320kbps')
final bitrateDisplay = lossyBitrate.contains('_')
? '${lossyBitrate.split('_').last}kbps'
: (lossyFormat == 'opus' ? '128kbps' : '320kbps');
actualQuality = '${lossyFormat.toUpperCase()} $bitrateDisplay';
_log.i('Successfully converted to $lossyFormat ($bitrateDisplay): $convertedPath');
// Embed metadata and cover for both MP3 and Opus
_log.i('Embedding metadata to $lossyFormat...');
updateItemStatus(
item.id,
DownloadStatus.downloading,
progress: 0.99,
);
final lossyBackendGenre = result['genre'] as String?;
final lossyBackendLabel = result['label'] as String?;
final lossyBackendCopyright = result['copyright'] as String?;
if (lossyFormat == 'mp3') {
await _embedMetadataToMp3(
convertedPath,
trackToDownload,
genre: lossyBackendGenre ?? genre,
label: lossyBackendLabel ?? label,
copyright: lossyBackendCopyright,
);
} else if (lossyFormat == 'opus') {
await _embedMetadataToOpus(
convertedPath,
trackToDownload,
genre: lossyBackendGenre ?? genre,
label: lossyBackendLabel ?? label,
copyright: lossyBackendCopyright,
);
}
} else {
_log.w('$lossyFormat conversion failed, keeping FLAC file');
}
} catch (e) {
_log.e('Lossy conversion error: $e, keeping FLAC file');
}
}
}
updateItemStatus(
item.id,
DownloadStatus.completed,
progress: 1.0,
filePath: filePath,
);
if (filePath != null) {
await _runPostProcessingHooks(filePath, trackToDownload);
}
_completedInSession++;
await _notificationService.showDownloadComplete(
trackName: item.track.name,
artistName: item.track.artistName,
completedCount: _completedInSession,
totalCount: _totalQueuedAtStart,
);
if (filePath != null) {
final backendTitle = result['title'] as String?;
final backendArtist = result['artist'] as String?;
final backendAlbum = result['album'] as String?;
final backendYear = result['release_date'] as String?;
final backendTrackNum = result['track_number'] as int?;
final backendDiscNum = result['disc_number'] as int?;
final backendBitDepth = result['actual_bit_depth'] as int?;
final backendSampleRate = result['actual_sample_rate'] as int?;
final backendISRC = result['isrc'] as String?;
final backendGenre = result['genre'] as String?;
final backendLabel = result['label'] as String?;
final backendCopyright = result['copyright'] as String?;
_log.d('Saving to history - coverUrl: ${trackToDownload.coverUrl}');
final historyAlbumArtist =
(normalizedAlbumArtist != null &&
normalizedAlbumArtist != trackToDownload.artistName)
? normalizedAlbumArtist
: null;
final isMp3 = filePath.endsWith('.mp3');
final historyBitDepth = isMp3 ? null : backendBitDepth;
final historySampleRate = isMp3 ? null : backendSampleRate;
ref
.read(downloadHistoryProvider.notifier)
.addToHistory(
DownloadHistoryItem(
id: item.id,
trackName: (backendTitle != null && backendTitle.isNotEmpty)
? backendTitle
: trackToDownload.name,
artistName: (backendArtist != null && backendArtist.isNotEmpty)
? backendArtist
: trackToDownload.artistName,
albumName: (backendAlbum != null && backendAlbum.isNotEmpty)
? backendAlbum
: trackToDownload.albumName,
albumArtist: historyAlbumArtist,
coverUrl: trackToDownload.coverUrl,
filePath: filePath,
service: result['service'] as String? ?? item.service,
downloadedAt: DateTime.now(),
isrc: (backendISRC != null && backendISRC.isNotEmpty)
? backendISRC
: trackToDownload.isrc,
spotifyId: trackToDownload.id,
trackNumber: (backendTrackNum != null && backendTrackNum > 0)
? backendTrackNum
: trackToDownload.trackNumber,
discNumber: (backendDiscNum != null && backendDiscNum > 0)
? backendDiscNum
: trackToDownload.discNumber,
duration: trackToDownload.duration,
releaseDate: (backendYear != null && backendYear.isNotEmpty)
? backendYear
: trackToDownload.releaseDate,
quality: actualQuality,
bitDepth: historyBitDepth,
sampleRate: historySampleRate,
genre: backendGenre,
label: backendLabel,
copyright: backendCopyright,
),
);
removeItem(item.id);
}
} else {
final itemAfterFailure = state.items.firstWhere(
(i) => i.id == item.id,
orElse: () => item,
);
if (itemAfterFailure.status == DownloadStatus.skipped) {
_log.i('Download was cancelled, skipping error handling');
return;
}
final errorMsg = result['error'] as String? ?? 'Download failed';
final errorTypeStr = result['error_type'] as String? ?? 'unknown';
if (errorTypeStr == 'cancelled') {
_log.i('Download was cancelled by backend, skipping error handling');
updateItemStatus(item.id, DownloadStatus.skipped);
return;
}
DownloadErrorType errorType;
switch (errorTypeStr) {
case 'not_found':
errorType = DownloadErrorType.notFound;
break;
case 'rate_limit':
errorType = DownloadErrorType.rateLimit;
break;
case 'network':
errorType = DownloadErrorType.network;
break;
case 'permission':
errorType = DownloadErrorType.permission;
break;
default:
errorType = DownloadErrorType.unknown;
}
_log.e('Download failed: $errorMsg (type: $errorTypeStr)');
updateItemStatus(
item.id,
DownloadStatus.failed,
error: errorMsg,
errorType: errorType,
);
_failedInSession++;
}
_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) {
final itemAfterError = state.items.firstWhere(
(i) => i.id == item.id,
orElse: () => item,
);
if (itemAfterError.status == DownloadStatus.skipped) {
_log.i('Download was cancelled, skipping error handling');
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;
}
updateItemStatus(
item.id,
DownloadStatus.failed,
error: errorMsg,
errorType: errorType,
);
_failedInSession++;
}
}
}
final downloadQueueProvider =
NotifierProvider<DownloadQueueNotifier, DownloadQueueState>(
DownloadQueueNotifier.new,
);
class DownloadQueueLookup {
final Map<String, DownloadItem> byTrackId;
DownloadQueueLookup._(this.byTrackId);
factory DownloadQueueLookup.fromItems(List<DownloadItem> items) {
final map = <String, DownloadItem>{};
for (final item in items) {
map.putIfAbsent(item.track.id, () => item);
}
return DownloadQueueLookup._(map);
}
}
final downloadQueueLookupProvider = Provider<DownloadQueueLookup>((ref) {
final items = ref.watch(downloadQueueProvider.select((s) => s.items));
return DownloadQueueLookup.fromItems(items);
});