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 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 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> 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> 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> 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> 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 upsertSongs(List 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 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> watchFolders() => (select(folders)..where((f) => f.deleted.equals(false))).watch(); Future> activeFolders() => (select(folders)..where((f) => f.deleted.equals(false))).get(); Future 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); }); }