## 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>
121 lines
4.2 KiB
Dart
121 lines
4.2 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:drift/drift.dart';
|
|
import 'package:drift/native.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
part 'database.g.dart';
|
|
|
|
/// Songs. Sync-fähig ab Tag 1: [id] ist eine stabile UUID, [updatedAtMs] und
|
|
/// [deleted] (Tombstone) ermöglichen späteren Cloud-Sync ohne Schema-Umbau.
|
|
class Songs extends Table {
|
|
TextColumn get id => text()(); // uuid
|
|
TextColumn get path => text().unique()();
|
|
TextColumn get title => text()();
|
|
TextColumn get artist => text().nullable()();
|
|
TextColumn get album => text().nullable()();
|
|
IntColumn get durationMs => integer().nullable()();
|
|
TextColumn get coverPath => text().nullable()();
|
|
IntColumn get dateAddedMs => integer()();
|
|
IntColumn get updatedAtMs => integer()();
|
|
BoolColumn get deleted => boolean().withDefault(const Constant(false))();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {id};
|
|
}
|
|
|
|
/// Vom Nutzer gewählte Musikordner, die gescannt werden.
|
|
class Folders extends Table {
|
|
TextColumn get id => text()(); // uuid
|
|
TextColumn get path => text().unique()();
|
|
IntColumn get updatedAtMs => integer()();
|
|
BoolColumn get deleted => boolean().withDefault(const Constant(false))();
|
|
|
|
@override
|
|
Set<Column> get primaryKey => {id};
|
|
}
|
|
|
|
@DriftDatabase(tables: [Songs, Folders])
|
|
class MeloDb extends _$MeloDb {
|
|
MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
|
|
|
|
@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))
|
|
..orderBy([(s) => OrderingTerm(expression: s.title)]))
|
|
.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))
|
|
..orderBy([
|
|
(s) => OrderingTerm(expression: s.dateAddedMs, mode: OrderingMode.desc)
|
|
])
|
|
..limit(limit))
|
|
.watch();
|
|
}
|
|
|
|
/// Sucht Songs nach Titel, Künstler oder Album. Wildcards werden escaped.
|
|
Stream<List<Song>> searchSongs(String query) {
|
|
final escaped = query
|
|
.toLowerCase()
|
|
.replaceAll('\\', '\\\\')
|
|
.replaceAll('%', '\\%')
|
|
.replaceAll('_', '\\_');
|
|
final like = '%$escaped%';
|
|
return (select(songs)
|
|
..where((s) =>
|
|
s.deleted.equals(false) &
|
|
(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.
|
|
/// 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() =>
|
|
(select(folders)..where((f) => f.deleted.equals(false))).watch();
|
|
|
|
Future<List<Folder>> activeFolders() =>
|
|
(select(folders)..where((f) => f.deleted.equals(false))).get();
|
|
|
|
Future<void> addFolder(FoldersCompanion folder) =>
|
|
into(folders).insert(folder, onConflict: DoUpdate((_) => folder, target: [folders.path]));
|
|
}
|
|
|
|
LazyDatabase _open() {
|
|
return LazyDatabase(() async {
|
|
final dir = await getApplicationSupportDirectory();
|
|
final file = File(p.join(dir.path, 'melo.sqlite'));
|
|
return NativeDatabase.createInBackground(file);
|
|
});
|
|
}
|