Fix critical data loss bugs and improve error handling

## Critical Fixes
- Fix CRITICAL-1: Empty scans no longer delete entire library
  - Use timestamp-based tombstone logic instead of path-based
  - Only mark missing if livePaths is not empty

- Fix CRITICAL-2: Re-scanned files now return from deleted state
  - Add deleted=false to SongsCompanion.insert in both scanners

## High Priority Fixes
- HIGH-1: Fix SQLite variable limit crash on large libraries
  - Replace isNotIn() with timestamp comparison (O(1) not O(n))

- HIGH-2: Fix UI freezing during scan
  - Throttle progress updates (every 50 files or end of scan)

- HIGH-3: Add error handling for scan failures
  - Wrap scanFolders/scanAndroidMediaStore in try/catch
  - Display scanError banner in LibraryScreen

- HIGH-4: Add error handling for playback failures
  - Wrap loadPlaylist in try/catch

## Medium Priority Fixes
- MEDIUM-1: Replace ! with ?? to handle unknown ProcessingState
- MEDIUM-2: Escape LIKE wildcards in search queries
- MEDIUM-4: Display StreamBuilder errors instead of treating as empty
- MEDIUM-8: Add tooltips to all IconButtons for accessibility
- Add doc comments to public database APIs

## Testing
- Add regression tests for CRITICAL-1 and CRITICAL-2
- All 9 existing tests pass

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Dustin-Mike Jens Hähnel
2026-08-16 19:04:54 +02:00
co-authored by Claude Haiku 4.5
parent b5ab7bd9dd
commit 4fa8d12fa0
11 changed files with 156 additions and 30 deletions
+24 -12
View File
@@ -43,6 +43,7 @@ class MeloDb extends _$MeloDb {
@override
int get schemaVersion => 1;
/// Überwacht alle Songs (nicht getombstonte). Nach Titel sortiert.
Stream<List<Song>> watchSongs() {
return (select(songs)
..where((s) => s.deleted.equals(false))
@@ -50,6 +51,7 @@ class MeloDb extends _$MeloDb {
.watch();
}
/// Überwacht die zuletzt hinzugefügten Songs (nicht getombstonte).
Stream<List<Song>> watchRecent({int limit = 50}) {
return (select(songs)
..where((s) => s.deleted.equals(false))
@@ -60,33 +62,43 @@ class MeloDb extends _$MeloDb {
.watch();
}
/// Sucht Songs nach Titel, Künstler oder Album. Wildcards werden escaped.
Stream<List<Song>> searchSongs(String query) {
final like = '%${query.toLowerCase()}%';
final escaped = query
.toLowerCase()
.replaceAll('\\', '\\\\')
.replaceAll('%', '\\%')
.replaceAll('_', '\\_');
final like = '%$escaped%';
return (select(songs)
..where((s) =>
s.deleted.equals(false) &
(s.title.lower().like(like) |
s.artist.lower().like(like) |
s.album.lower().like(like)))
(s.title.lower().like(like, escapeChar: '\\') |
s.artist.lower().like(like, escapeChar: '\\') |
s.album.lower().like(like, escapeChar: '\\')))
..orderBy([(s) => OrderingTerm(expression: s.title)]))
.watch();
}
/// Alle Songs inkl. getombstonte. Intern für ID-Stabilität beim Scan.
Future<List<Song>> allSongs() => select(songs).get();
/// Upsert Songs nach Pfad. Neue Songs bekommen UUID; bekannte erhalten sie zurück.
/// Setzt [updatedAtMs] auf die Werte der Companions (normalerweise Scan-Startzeit).
Future<void> upsertSongs(List<SongsCompanion> items) async {
await batch((b) => b.insertAllOnConflictUpdate(songs, items));
}
/// Tombstone für Songs, deren Datei beim Scan nicht mehr gefunden wurde.
Future<void> markMissing(List<String> livePaths, int now) async {
final q = update(songs)..where((s) => s.deleted.equals(false));
if (livePaths.isNotEmpty) {
q.where((s) => s.path.isNotIn(livePaths));
}
await q.write(
SongsCompanion(deleted: const Value(true), updatedAtMs: Value(now)),
);
/// Löscht nur Songs, die in diesem Scan (Zeitstempel [now]) nicht aktualisiert wurden.
Future<void> markMissing(int now) async {
await (update(songs)
..where((s) =>
s.deleted.equals(false) &
s.updatedAtMs.isSmallerThanValue(now)))
.write(
SongsCompanion(deleted: const Value(true), updatedAtMs: Value(now)),
);
}
Stream<List<Folder>> watchFolders() =>