From 5c54e04b69d14cb8c710ed084b79e1d276f17170 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 7 Feb 2026 13:20:00 +0700 Subject: [PATCH] feat: cleanup orphaned downloads from history --- lib/providers/download_queue_provider.dart | 53 +++++++++++++++++++ .../settings/options_settings_page.dart | 52 ++++++++++++++++++ lib/services/history_database.dart | 38 ++++++++++++- 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index b59fc812..b3f27dbb 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -408,6 +408,59 @@ class DownloadHistoryNotifier extends Notifier { return DownloadHistoryItem.fromJson(json); } + /// Remove history entries where the file no longer exists on disk + /// Returns the number of orphaned entries removed + Future cleanupOrphanedDownloads() async { + _historyLog.i('Starting orphaned downloads cleanup...'); + + final entries = await _db.getAllEntriesWithPaths(); + final orphanedIds = []; + + for (final entry in entries) { + final id = entry['id'] as String; + final filePath = entry['file_path'] as String?; + + if (filePath == null || filePath.isEmpty) continue; + + bool exists = false; + + if (filePath.startsWith('content://')) { + // SAF path - check via platform bridge + try { + exists = await PlatformBridge.safExists(filePath); + } catch (e) { + _historyLog.w('Error checking SAF file existence: $e'); + exists = false; + } + } else { + // Regular file path + exists = File(filePath).existsSync(); + } + + if (!exists) { + orphanedIds.add(id); + _historyLog.d('Found orphaned entry: $id ($filePath)'); + } + } + + if (orphanedIds.isEmpty) { + _historyLog.i('No orphaned entries found'); + return 0; + } + + // Delete from database + final deletedCount = await _db.deleteByIds(orphanedIds); + + // Update in-memory state + final orphanedSet = orphanedIds.toSet(); + state = state.copyWith( + items: state.items.where((item) => !orphanedSet.contains(item.id)).toList(), + ); + + _historyLog.i('Cleaned up $deletedCount orphaned entries'); + return deletedCount; + } + void clearHistory() { state = DownloadHistoryState(); _db.clearAll().catchError((e) { diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 7c786a9c..4c941095 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -222,6 +222,12 @@ class OptionsSettingsPage extends ConsumerWidget { SliverToBoxAdapter( child: SettingsGroup( children: [ + SettingsItem( + icon: Icons.cleaning_services_outlined, + title: context.l10n.cleanupOrphanedDownloads, + subtitle: context.l10n.cleanupOrphanedDownloadsSubtitle, + onTap: () => _cleanupOrphanedDownloads(context, ref), + ), SettingsItem( icon: Icons.delete_forever, title: context.l10n.optionsClearHistory, @@ -294,6 +300,52 @@ class OptionsSettingsPage extends ConsumerWidget { ); } + Future _cleanupOrphanedDownloads( + BuildContext context, + WidgetRef ref, + ) async { + // Show loading indicator + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + content: Row( + children: [ + const CircularProgressIndicator(), + const SizedBox(width: 16), + Text(context.l10n.cleanupOrphanedDownloads), + ], + ), + ), + ); + + try { + final removed = await ref + .read(downloadHistoryProvider.notifier) + .cleanupOrphanedDownloads(); + + if (context.mounted) { + Navigator.pop(context); // Close loading dialog + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + removed > 0 + ? context.l10n.cleanupOrphanedDownloadsResult(removed) + : context.l10n.cleanupOrphanedDownloadsNone, + ), + ), + ); + } + } catch (e) { + if (context.mounted) { + Navigator.pop(context); // Close loading dialog + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + } + } + void _showSpotifyCredentialsDialog( BuildContext context, WidgetRef ref, diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 04c291ef..7cb314d4 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -450,10 +450,46 @@ class HistoryDatabase { return null; } - /// Close database +/// Close database Future close() async { final db = await database; await db.close(); _database = null; } + + /// Get all file paths from download history + /// Used to exclude downloaded files from local library scan + Future> getAllFilePaths() async { + final db = await database; + final rows = await db.rawQuery( + 'SELECT file_path FROM history WHERE file_path IS NOT NULL AND file_path != ""' + ); + return rows.map((r) => r['file_path'] as String).toSet(); + } + + /// Get all entries with file paths for orphan detection + /// Returns list of (id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name) + Future>> getAllEntriesWithPaths() async { + final db = await database; + final rows = await db.rawQuery(''' + SELECT id, file_path, storage_mode, download_tree_uri, saf_relative_dir, saf_file_name + FROM history + WHERE file_path IS NOT NULL AND file_path != "" + '''); + return rows.map((r) => Map.from(r)).toList(); + } + + /// Delete multiple entries by IDs + Future deleteByIds(List ids) async { + if (ids.isEmpty) return 0; + + final db = await database; + final placeholders = List.filled(ids.length, '?').join(','); + final count = await db.rawDelete( + 'DELETE FROM history WHERE id IN ($placeholders)', + ids, + ); + _log.i('Deleted $count orphaned entries'); + return count; + } }