feat: cleanup orphaned downloads from history

This commit is contained in:
zarzet
2026-02-07 13:20:00 +07:00
parent bef07b1583
commit 5c54e04b69
3 changed files with 142 additions and 1 deletions
@@ -408,6 +408,59 @@ class DownloadHistoryNotifier extends Notifier<DownloadHistoryState> {
return DownloadHistoryItem.fromJson(json);
}
/// Remove history entries where the file no longer exists on disk
/// Returns the number of orphaned entries removed
Future<int> cleanupOrphanedDownloads() async {
_historyLog.i('Starting orphaned downloads cleanup...');
final entries = await _db.getAllEntriesWithPaths();
final orphanedIds = <String>[];
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) {
@@ -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<void> _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,
+37 -1
View File
@@ -450,10 +450,46 @@ class HistoryDatabase {
return null;
}
/// Close database
/// Close database
Future<void> 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<Set<String>> 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<List<Map<String, dynamic>>> 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<String, dynamic>.from(r)).toList();
}
/// Delete multiple entries by IDs
Future<int> deleteByIds(List<String> 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;
}
}