feat: export failed downloads to TXT file

- Add Export button in queue when there are failed downloads
- Add auto-export setting in Download Settings
- Export includes track name, artist, Spotify/Deezer URL, and error message
- Clear Failed action in snackbar after export
This commit is contained in:
zarzet
2026-02-02 07:02:04 +07:00
parent 4633c7253a
commit 107d9ca007
22 changed files with 479 additions and 7 deletions
+5
View File
@@ -4,6 +4,11 @@
Same as 3.3.1 but fixes crash issues caused by FFmpeg.
### Added
- **Export Failed Downloads**: Export failed downloads to TXT file for easy lookup on other platforms
- **Auto-Export Setting**: Option to automatically export failed downloads when queue finishes
### Fixed
- **FFmpeg Crash**: Fixed crash issues during M4A to MP3/Opus conversion
+36
View File
@@ -3652,6 +3652,42 @@ abstract class AppLocalizations {
/// **'Are you sure you want to clear all downloads?'**
String get queueClearAllMessage;
/// Button - export failed downloads to TXT
///
/// In en, this message translates to:
/// **'Export'**
String get queueExportFailed;
/// Success message after exporting failed downloads
///
/// In en, this message translates to:
/// **'Failed downloads exported to TXT file'**
String get queueExportFailedSuccess;
/// Action to clear failed downloads after export
///
/// In en, this message translates to:
/// **'Clear Failed'**
String get queueExportFailedClear;
/// Error message when export fails
///
/// In en, this message translates to:
/// **'Failed to export downloads'**
String get queueExportFailedError;
/// Setting toggle for auto-export
///
/// In en, this message translates to:
/// **'Auto-export failed downloads'**
String get settingsAutoExportFailed;
/// Subtitle for auto-export setting
///
/// In en, this message translates to:
/// **'Save failed downloads to TXT file automatically'**
String get settingsAutoExportFailedSubtitle;
/// Empty queue state title
///
/// In en, this message translates to:
+20
View File
@@ -2005,6 +2005,26 @@ class AppLocalizationsDe extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get queueEmpty => 'No downloads in queue';
+20
View File
@@ -1990,6 +1990,26 @@ class AppLocalizationsEn extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get queueEmpty => 'No downloads in queue';
+20
View File
@@ -1990,6 +1990,26 @@ class AppLocalizationsEs extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get queueEmpty => 'No downloads in queue';
+20
View File
@@ -1990,6 +1990,26 @@ class AppLocalizationsFr extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get queueEmpty => 'No downloads in queue';
+20
View File
@@ -1990,6 +1990,26 @@ class AppLocalizationsHi extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get queueEmpty => 'No downloads in queue';
+20
View File
@@ -2003,6 +2003,26 @@ class AppLocalizationsId extends AppLocalizations {
String get queueClearAllMessage =>
'Apakah Anda yakin ingin menghapus semua unduhan?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get queueEmpty => 'Tidak ada unduhan dalam antrian';
+20
View File
@@ -1977,6 +1977,26 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get queueClearAllMessage => 'すべてのダウンロードを消去してもよろしいですか?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get queueEmpty => 'キューにダウンロードがありません';
+20
View File
@@ -1990,6 +1990,26 @@ class AppLocalizationsKo extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get queueEmpty => 'No downloads in queue';
+20
View File
@@ -1990,6 +1990,26 @@ class AppLocalizationsNl extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get queueEmpty => 'No downloads in queue';
+20
View File
@@ -1990,6 +1990,26 @@ class AppLocalizationsPt extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get queueEmpty => 'No downloads in queue';
+20
View File
@@ -2029,6 +2029,26 @@ class AppLocalizationsRu extends AppLocalizations {
String get queueClearAllMessage =>
'Вы уверены, что хотите очистить все загрузки?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get queueEmpty => 'Нет загрузок в очереди';
+20
View File
@@ -2005,6 +2005,26 @@ class AppLocalizationsTr extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get queueEmpty => 'No downloads in queue';
+20
View File
@@ -1990,6 +1990,26 @@ class AppLocalizationsZh extends AppLocalizations {
String get queueClearAllMessage =>
'Are you sure you want to clear all downloads?';
@override
String get queueExportFailed => 'Export';
@override
String get queueExportFailedSuccess =>
'Failed downloads exported to TXT file';
@override
String get queueExportFailedClear => 'Clear Failed';
@override
String get queueExportFailedError => 'Failed to export downloads';
@override
String get settingsAutoExportFailed => 'Auto-export failed downloads';
@override
String get settingsAutoExportFailedSubtitle =>
'Save failed downloads to TXT file automatically';
@override
String get queueEmpty => 'No downloads in queue';
+13 -1
View File
@@ -1460,10 +1460,22 @@
"queueTitle": "Download Queue",
"@queueTitle": {"description": "Queue screen title"},
"queueClearAll": "Clear All",
"queueClearAll": "Clear All",
"@queueClearAll": {"description": "Button - clear all queue items"},
"queueClearAllMessage": "Are you sure you want to clear all downloads?",
"@queueClearAllMessage": {"description": "Clear queue confirmation"},
"queueExportFailed": "Export",
"@queueExportFailed": {"description": "Button - export failed downloads to TXT"},
"queueExportFailedSuccess": "Failed downloads exported to TXT file",
"@queueExportFailedSuccess": {"description": "Success message after exporting failed downloads"},
"queueExportFailedClear": "Clear Failed",
"@queueExportFailedClear": {"description": "Action to clear failed downloads after export"},
"queueExportFailedError": "Failed to export downloads",
"@queueExportFailedError": {"description": "Error message when export fails"},
"settingsAutoExportFailed": "Auto-export failed downloads",
"@settingsAutoExportFailed": {"description": "Setting toggle for auto-export"},
"settingsAutoExportFailedSubtitle": "Save failed downloads to TXT file automatically",
"@settingsAutoExportFailedSubtitle": {"description": "Subtitle for auto-export setting"},
"queueEmpty": "No downloads in queue",
"@queueEmpty": {"description": "Empty queue state title"},
"queueEmptySubtitle": "Add tracks from the home screen",
+4
View File
@@ -34,6 +34,7 @@ class AppSettings {
final String lyricsMode;
final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320' or 'opus_128'
final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE
final bool autoExportFailedDownloads; // Auto export failed downloads to TXT file
const AppSettings({
this.defaultService = 'tidal',
@@ -66,6 +67,7 @@ class AppSettings {
this.lyricsMode = 'embed',
this.tidalHighFormat = 'mp3_320',
this.useAllFilesAccess = false,
this.autoExportFailedDownloads = false,
});
AppSettings copyWith({
@@ -100,6 +102,7 @@ class AppSettings {
String? lyricsMode,
String? tidalHighFormat,
bool? useAllFilesAccess,
bool? autoExportFailedDownloads,
}) {
return AppSettings(
defaultService: defaultService ?? this.defaultService,
@@ -132,6 +135,7 @@ class AppSettings {
lyricsMode: lyricsMode ?? this.lyricsMode,
tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat,
useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess,
autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads,
);
}
+3
View File
@@ -39,6 +39,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
lyricsMode: json['lyricsMode'] as String? ?? 'embed',
tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320',
useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false,
autoExportFailedDownloads:
json['autoExportFailedDownloads'] as bool? ?? false,
);
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
@@ -73,4 +75,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'lyricsMode': instance.lyricsMode,
'tidalHighFormat': instance.tidalHighFormat,
'useAllFilesAccess': instance.useAllFilesAccess,
'autoExportFailedDownloads': instance.autoExportFailedDownloads,
};
+73 -2
View File
@@ -1012,12 +1012,74 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
void removeItem(String id) {
void removeItem(String id) {
final items = state.items.where((item) => item.id != id).toList();
state = state.copyWith(items: items);
_saveQueueToStorage();
}
/// Export failed downloads to a TXT file
/// Returns the file path if successful, null otherwise
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 {
final buffer = StringBuffer();
buffer.writeln('# SpotiFLAC Failed Downloads');
buffer.writeln('# Exported: ${DateTime.now().toIso8601String()}');
buffer.writeln('# Total: ${failedItems.length} tracks');
buffer.writeln('#');
buffer.writeln('# Format: Track - Artist | Spotify URL | Error');
buffer.writeln('');
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('${track.name} - ${track.artistName} | $spotifyUrl | $error');
}
// Save to download directory
String exportDir = state.outputDir;
if (exportDir.isEmpty) {
final dir = await getApplicationDocumentsDirectory();
exportDir = dir.path;
}
final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-').split('.').first;
final fileName = 'failed_downloads_$timestamp.txt';
final filePath = '$exportDir/$fileName';
final file = File(filePath);
await file.writeAsString(buffer.toString());
_log.i('Exported ${failedItems.length} failed downloads to: $filePath');
return filePath;
} catch (e) {
_log.e('Failed to export failed downloads: $e');
return null;
}
}
/// Clear all failed downloads from queue
void clearFailedDownloads() {
final items = state.items
.where((item) => item.status != DownloadStatus.failed)
.toList();
state = state.copyWith(items: items);
_saveQueueToStorage();
_log.d('Cleared failed downloads from queue');
}
Future<void> _runPostProcessingHooks(String filePath, Track track) async {
try {
final settings = ref.read(settingsProvider);
@@ -1626,7 +1688,7 @@ if (state.outputDir.isEmpty) {
_downloadCount = 0;
}
_log.i(
_log.i(
'Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart',
);
if (_totalQueuedAtStart > 0) {
@@ -1634,6 +1696,15 @@ if (state.outputDir.isEmpty) {
completedCount: _completedInSession,
failedCount: _failedInSession,
);
// Auto-export failed downloads if enabled
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');
}
}
}
_log.i('Queue processing finished');
+6 -1
View File
@@ -236,10 +236,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setUseAllFilesAccess(bool enabled) {
void setUseAllFilesAccess(bool enabled) {
state = state.copyWith(useAllFilesAccess: enabled);
_saveSettings();
}
void setAutoExportFailedDownloads(bool enabled) {
state = state.copyWith(autoExportFailedDownloads: enabled);
_saveSettings();
}
}
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
+58 -2
View File
@@ -806,7 +806,9 @@ final queueItems = ref.watch(downloadQueueProvider.select((s) => s.items));
fontWeight: FontWeight.bold,
),
),
const Spacer(),
const Spacer(),
_buildExportFailedButton(context, ref, colorScheme),
const SizedBox(width: 4),
_buildPauseResumeButton(context, ref, colorScheme),
const SizedBox(width: 4),
_buildClearAllButton(context, ref, colorScheme),
@@ -1194,7 +1196,7 @@ if (queueItems.isEmpty &&
);
}
Widget _buildClearAllButton(
Widget _buildClearAllButton(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
@@ -1210,6 +1212,60 @@ if (queueItems.isEmpty &&
);
}
Widget _buildExportFailedButton(
BuildContext context,
WidgetRef ref,
ColorScheme colorScheme,
) {
final queueState = ref.watch(downloadQueueProvider);
final failedCount = queueState.failedCount;
if (failedCount == 0) {
return const SizedBox.shrink();
}
return TextButton.icon(
onPressed: () => _exportFailedDownloads(context, ref),
icon: const Icon(Icons.file_download, size: 18),
label: Text(context.l10n.queueExportFailed),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: colorScheme.tertiary,
),
);
}
Future<void> _exportFailedDownloads(
BuildContext context,
WidgetRef ref,
) async {
final filePath = await ref.read(downloadQueueProvider.notifier).exportFailedDownloads();
if (!context.mounted) return;
if (filePath != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.queueExportFailedSuccess),
action: SnackBarAction(
label: context.l10n.queueExportFailedClear,
onPressed: () {
ref.read(downloadQueueProvider.notifier).clearFailedDownloads();
},
),
duration: const Duration(seconds: 5),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.queueExportFailedError),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
}
Future<void> _showClearAllDialog(
BuildContext context,
WidgetRef ref,
@@ -385,7 +385,27 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
),
),
),
],
],
const SliverToBoxAdapter(child: SizedBox(height: 16)),
// Auto Export Failed Downloads
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.file_download_outlined,
title: context.l10n.settingsAutoExportFailed,
subtitle: context.l10n.settingsAutoExportFailedSubtitle,
value: settings.autoExportFailedDownloads,
onChanged: (value) {
ref.read(settingsProvider.notifier).setAutoExportFailedDownloads(value);
},
showDivider: false,
),
],
),
),
const SliverToBoxAdapter(child: SizedBox(height: 32)),
],