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'; import 'package:uuid/uuid.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))(); /// Wie oft der Song vollständig gestartet wurde — Grundlage für die /// Sortierung "Wie oft abgespielt". IntColumn get playCount => integer().withDefault(const Constant(0))(); /// Sobald die Kategorien eines Songs von Hand geändert wurden, überschreibt /// ein erneuter Scan sie nicht mehr mit dem Genre-Tag der Datei. BoolColumn get categoriesEdited => boolean().withDefault(const Constant(false))(); /// Sobald Titel, Künstler oder Album von Hand korrigiert wurden, überschreibt /// ein erneuter Scan sie nicht mehr mit den Tags der Datei. BoolColumn get metadataEdited => boolean().withDefault(const Constant(false))(); /// Songtext aus dem Tag der Datei — Grundlage für den automatischen /// Songtext ohne Server. TextColumn get lyrics => text().nullable()(); /// ReplayGain des Titels in Dezibel, sofern die Datei den Tag mitbringt — /// Grundlage für "Gleiche Lautstärke". RealColumn get gainDb => real().nullable()(); /// ID desselben Titels in der Melo-Cloud. Verbindet den Titel auf dem Gerät /// mit dem am Server und ist die Grundlage des Abgleichs: ohne sie gilt ein /// Titel als nur lokal vorhanden und wird beim nächsten Sync hochgeladen. TextColumn get cloudId => text().nullable()(); @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}; } /// Nutzer-Playlisten. class Playlists extends Table { TextColumn get id => text()(); // uuid TextColumn get name => text()(); TextColumn get description => text().nullable()(); IntColumn get createdAtMs => integer()(); IntColumn get updatedAtMs => integer()(); BoolColumn get deleted => boolean().withDefault(const Constant(false))(); @override Set get primaryKey => {id}; } /// Zuordnung Song ↔ Playlist mit Reihenfolge. class PlaylistSongs extends Table { TextColumn get playlistId => text().references(Playlists, #id)(); TextColumn get songId => text().references(Songs, #id)(); IntColumn get position => integer()(); IntColumn get addedAtMs => integer()(); @override Set get primaryKey => {playlistId, songId}; } /// Kategorien eines Songs — ein Song kann mehreren angehören. [position] /// hält die Reihenfolge fest; die erste Kategorie bestimmt das Cover. class SongCategories extends Table { TextColumn get songId => text().references(Songs, #id)(); TextColumn get name => text()(); IntColumn get position => integer()(); @override Set get primaryKey => {songId, name}; } /// Favorisierte Songs. class Favorites extends Table { TextColumn get songId => text().references(Songs, #id)(); IntColumn get createdAtMs => integer()(); @override Set get primaryKey => {songId}; } /// Wiedergabe-Historie: letzte Position pro Song (für Resume) + Verlauf. class PlaybackHistory extends Table { TextColumn get songId => text().references(Songs, #id)(); IntColumn get positionMs => integer()(); IntColumn get playedAtMs => integer()(); @override Set get primaryKey => {songId, playedAtMs}; } @DriftDatabase(tables: [ Songs, Folders, Playlists, PlaylistSongs, Favorites, PlaybackHistory, SongCategories, ]) class MeloDb extends _$MeloDb { MeloDb([QueryExecutor? executor]) : super(executor ?? _open()); @override int get schemaVersion => 8; @override MigrationStrategy get migration => MigrationStrategy( onCreate: (m) => m.createAll(), onUpgrade: (m, from, to) async { if (from < 2) { await m.createTable(playlists); await m.createTable(playlistSongs); await m.createTable(favorites); await m.createTable(playbackHistory); } if (from < 3) { await m.addColumn(songs, songs.playCount); } if (from < 4) { await m.addColumn(songs, songs.categoriesEdited); await m.createTable(songCategories); } if (from < 5) { await m.addColumn(songs, songs.lyrics); } if (from < 6) { await m.addColumn(songs, songs.gainDb); } if (from < 7) { await m.addColumn(songs, songs.metadataEdited); } if (from < 8) { await m.addColumn(songs, songs.cloudId); } }, ); /// Ü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(); /// Wie viele Titel die Bibliothek kennt (ohne getombstonte). Future countSongs() async { final anzahl = songs.id.count(); final zeile = await (selectOnly(songs) ..addColumns([anzahl]) ..where(songs.deleted.equals(false))) .getSingle(); return zeile.read(anzahl) ?? 0; } /// 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])); static const _uuid = Uuid(); // === Playlists === Stream> watchPlaylists() => (select(playlists) ..where((p) => p.deleted.equals(false)) ..orderBy([(p) => OrderingTerm(expression: p.createdAtMs, mode: OrderingMode.desc)])) .watch(); Future createPlaylist(String name, {String? description}) async { final id = _uuid.v4(); final now = DateTime.now().millisecondsSinceEpoch; await into(playlists).insert(PlaylistsCompanion.insert( id: id, name: name, description: Value(description), createdAtMs: now, updatedAtMs: now, )); return id; } Future deletePlaylist(String id) async { await (update(playlists)..where((p) => p.id.equals(id))).write( PlaylistsCompanion( deleted: const Value(true), updatedAtMs: Value(DateTime.now().millisecondsSinceEpoch), ), ); } // === PlaylistSongs === Stream> watchPlaylistSongs(String playlistId) { final query = select(songs).join([ innerJoin(playlistSongs, playlistSongs.songId.equalsExp(songs.id)), ]) ..where(playlistSongs.playlistId.equals(playlistId)) ..orderBy([OrderingTerm(expression: playlistSongs.position)]); return query.watch().map((rows) => rows.map((r) => r.readTable(songs)).toList()); } Future addSongToPlaylist(String playlistId, String songId, int position) async { await into(playlistSongs).insert( PlaylistSongsCompanion.insert( playlistId: playlistId, songId: songId, position: position, addedAtMs: DateTime.now().millisecondsSinceEpoch, ), mode: InsertMode.insertOrReplace, ); } Future removeSongFromPlaylist(String playlistId, String songId) async { await (delete(playlistSongs) ..where((ps) => ps.playlistId.equals(playlistId) & ps.songId.equals(songId))) .go(); } Future reorderPlaylistSong(String playlistId, String songId, int newPosition) async { await (update(playlistSongs) ..where((ps) => ps.playlistId.equals(playlistId) & ps.songId.equals(songId))) .write(PlaylistSongsCompanion(position: Value(newPosition))); } /// Schreibt für die gesamte Playlist neue, lückenlose Positionen (0..N-1) /// gemäß [orderedSongIds] — vermeidet Mehrdeutigkeiten beim Umsortieren, /// da einzelne [reorderPlaylistSong]-Aufrufe nur einen Song verschieben. Future reorderAllPlaylistSongs(String playlistId, List orderedSongIds) async { await transaction(() async { for (var i = 0; i < orderedSongIds.length; i++) { await (update(playlistSongs) ..where((ps) => ps.playlistId.equals(playlistId) & ps.songId.equals(orderedSongIds[i]))) .write(PlaylistSongsCompanion(position: Value(i))); } }); } // === Favorites === Future songExists(String songId) async { final row = await (select(songs)..where((s) => s.id.equals(songId))).getSingleOrNull(); return row != null; } Future setFavorite(String songId, bool isFavorite) async { final existing = await (select(favorites)..where((f) => f.songId.equals(songId))).getSingleOrNull(); if (isFavorite && existing == null) { await into(favorites).insert(FavoritesCompanion.insert( songId: songId, createdAtMs: DateTime.now().millisecondsSinceEpoch, )); } else if (!isFavorite && existing != null) { await (delete(favorites)..where((f) => f.songId.equals(songId))).go(); } } Future toggleFavorite(String songId) async { final existing = await (select(favorites)..where((f) => f.songId.equals(songId))).getSingleOrNull(); if (existing == null) { await into(favorites).insert(FavoritesCompanion.insert( songId: songId, createdAtMs: DateTime.now().millisecondsSinceEpoch, )); } else { await (delete(favorites)..where((f) => f.songId.equals(songId))).go(); } } Stream watchIsFavorite(String songId) { return (select(favorites)..where((f) => f.songId.equals(songId))) .watchSingleOrNull() .map((row) => row != null); } Stream> watchFavorites() { final query = select(songs).join([ innerJoin(favorites, favorites.songId.equalsExp(songs.id)), ]) ..orderBy([OrderingTerm(expression: favorites.createdAtMs, mode: OrderingMode.desc)]); return query.watch().map((rows) => rows.map((r) => r.readTable(songs)).toList()); } /// Songtext eines Songs, sofern beim Scan einer im Tag gefunden wurde. Future lyricsOf(String songId) async { final row = await (select(songs)..where((s) => s.id.equals(songId))) .getSingleOrNull(); final text = row?.lyrics?.trim(); return (text == null || text.isEmpty) ? null : text; } // === Kategorien === /// Alle Kategorien-Zuordnungen, nach Song gebündelt und in gespeicherter /// Reihenfolge — die UI braucht sie immer für die ganze sichtbare Liste. Stream>> watchCategoriesBySong() { return (select(songCategories) ..orderBy([(c) => OrderingTerm(expression: c.position)])) .watch() .map((rows) { final grouped = >{}; for (final row in rows) { grouped.putIfAbsent(row.songId, () => []).add(row.name); } return grouped; }); } Future> categoriesOf(String songId) async { final rows = await (select(songCategories) ..where((c) => c.songId.equals(songId)) ..orderBy([(c) => OrderingTerm(expression: c.position)])) .get(); return rows.map((r) => r.name).toList(); } /// Ersetzt die Kategorien eines Songs. [byUser] markiert den Song als von /// Hand bearbeitet, sodass der nächste Scan ihn in Ruhe lässt. Future setCategories( String songId, List names, { bool byUser = false, }) async { await transaction(() async { await (delete(songCategories)..where((c) => c.songId.equals(songId))).go(); for (var i = 0; i < names.length; i++) { await into(songCategories).insert(SongCategoriesCompanion.insert( songId: songId, name: names[i], position: i, )); } if (byUser) { await (update(songs)..where((s) => s.id.equals(songId))) .write(const SongsCompanion(categoriesEdited: Value(true))); } }); } /// Alle vergebenen Kategorien, alphabetisch — für Vorschläge beim Bearbeiten. Future> allCategoryNames() async { final rows = await (selectOnly(songCategories, distinct: true) ..addColumns([songCategories.name])) .get(); final names = rows.map((r) => r.read(songCategories.name)!).toList() ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); return names; } // === Playback History === /// Zählt eine Wiedergabe. Wird beim Start eines Tracks aufgerufen, nicht beim /// periodischen Speichern der Position — sonst würde der Zähler hochlaufen, /// solange ein Song nur läuft. Future incrementPlayCount(String songId) async { await customUpdate( 'UPDATE songs SET play_count = play_count + 1 WHERE id = ?', variables: [Variable(songId)], updates: {songs}, ); } Future recordPlayback(String songId, int positionMs) async { await into(playbackHistory).insert(PlaybackHistoryCompanion.insert( songId: songId, positionMs: positionMs, playedAtMs: DateTime.now().millisecondsSinceEpoch, )); } // === Cloud-Sync === /// Verknüpft einen Titel des Geräts mit seinem Gegenstück in der Cloud. Future setCloudId(String songId, String cloudId) async { await (update(songs)..where((s) => s.id.equals(songId))) .write(SongsCompanion(cloudId: Value(cloudId))); } /// Titel, die am Server gelöscht wurden, auch auf dem Gerät als gelöscht /// markieren. Grabstein statt echtem Löschen — sonst legt der nächste Scan /// sie wieder an. Future tombstoneByCloudIds(List cloudIds) async { if (cloudIds.isEmpty) return; final now = DateTime.now().millisecondsSinceEpoch; await (update(songs)..where((s) => s.cloudId.isIn(cloudIds))).write( SongsCompanion(deleted: const Value(true), updatedAtMs: Value(now)), ); } /// Alle Favoriten-Song-IDs — Grundlage für den Favoriten-Abgleich. Future> favoriteSongIds() async { final rows = await select(favorites).get(); return [for (final r in rows) r.songId]; } /// Wiedergaben seit [sinceMs], neueste zuerst. Grundlage dafür, dem Server /// zu melden, was auf diesem Gerät gehört wurde. Future> historySince(int sinceMs, {int limit = 100}) async { return (select(playbackHistory) ..where((h) => h.playedAtMs.isBiggerThanValue(sinceMs)) ..orderBy([ (h) => OrderingTerm( expression: h.playedAtMs, mode: OrderingMode.desc) ]) ..limit(limit)) .get(); } Future lastPosition(String songId) async { final row = await (select(playbackHistory) ..where((h) => h.songId.equals(songId)) ..orderBy([(h) => OrderingTerm(expression: h.playedAtMs, mode: OrderingMode.desc)]) ..limit(1)) .getSingleOrNull(); return row?.positionMs; } } LazyDatabase _open() { return LazyDatabase(() async { final dir = await getApplicationSupportDirectory(); final file = File(p.join(dir.path, 'melo.sqlite')); return NativeDatabase.createInBackground(file); }); }