Files
Melo/lib/library/database.dart
T
Hermes (Server)andClaude Haiku 4.5 d09a677315 feat(database): add Playlists, Favorites, PlaybackHistory tables
- Playlists + PlaylistSongs tables with position-ordered join query
- Favorites table with toggle + watch (reactive heart icon support)
- PlaybackHistory table for resume-position tracking
- Schema v2 with migration from v1
- Follows existing Songs/Folders sync-ready pattern (uuid id, updatedAtMs, deleted)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-18 15:13:44 +02:00

288 lines
9.7 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';
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))();
@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};
}
/// 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<Column> 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<Column> get primaryKey => {playlistId, songId};
}
/// Favorisierte Songs.
class Favorites extends Table {
TextColumn get songId => text().references(Songs, #id)();
IntColumn get createdAtMs => integer()();
@override
Set<Column> 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<Column> 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 => 2;
@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);
}
},
);
/// Ü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]));
static const _uuid = Uuid();
// === Playlists ===
Stream<List<Playlist>> watchPlaylists() =>
(select(playlists)
..where((p) => p.deleted.equals(false))
..orderBy([(p) => OrderingTerm(expression: p.createdAtMs, mode: OrderingMode.desc)]))
.watch();
Future<String> 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<void> 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<List<Song>> 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<void> 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<void> removeSongFromPlaylist(String playlistId, String songId) async {
await (delete(playlistSongs)
..where((ps) => ps.playlistId.equals(playlistId) & ps.songId.equals(songId)))
.go();
}
Future<void> 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)));
}
// === Favorites ===
Future<void> 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<bool> watchIsFavorite(String songId) {
return (select(favorites)..where((f) => f.songId.equals(songId)))
.watchSingleOrNull()
.map((row) => row != null);
}
Stream<List<Song>> 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 ===
Future<void> recordPlayback(String songId, int positionMs) async {
await into(playbackHistory).insert(PlaybackHistoryCompanion.insert(
songId: songId,
positionMs: positionMs,
playedAtMs: DateTime.now().millisecondsSinceEpoch,
));
}
Future<int?> 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);
});
}