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))(); @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}; } /// 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]) class MeloDb extends _$MeloDb { MeloDb([QueryExecutor? executor]) : super(executor ?? _open()); @override int get schemaVersion => 3; @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); } }, ); /// Ü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])); 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()); } // === 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, )); } 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); }); }