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>
This commit is contained in:
co-authored by
Claude Haiku 4.5
parent
bc8f3bfe77
commit
d09a677315
+169
-2
@@ -4,6 +4,7 @@ import 'package:drift/drift.dart';
|
|||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
part 'database.g.dart';
|
part 'database.g.dart';
|
||||||
|
|
||||||
@@ -36,12 +37,68 @@ class Folders extends Table {
|
|||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
|
|
||||||
@DriftDatabase(tables: [Songs, Folders])
|
/// 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 {
|
class MeloDb extends _$MeloDb {
|
||||||
MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
|
MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 1;
|
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.
|
/// Überwacht alle Songs (nicht getombstonte). Nach Titel sortiert.
|
||||||
Stream<List<Song>> watchSongs() {
|
Stream<List<Song>> watchSongs() {
|
||||||
@@ -109,6 +166,116 @@ class MeloDb extends _$MeloDb {
|
|||||||
|
|
||||||
Future<void> addFolder(FoldersCompanion folder) =>
|
Future<void> addFolder(FoldersCompanion folder) =>
|
||||||
into(folders).insert(folder, onConflict: DoUpdate((_) => folder, target: [folders.path]));
|
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() {
|
LazyDatabase _open() {
|
||||||
|
|||||||
+2848
-7
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,110 @@
|
|||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:melo/library/database.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late MeloDb db;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
db = MeloDb(NativeDatabase.memory());
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Playlists', () {
|
||||||
|
test('createPlaylist inserts and watchPlaylists emits it', () async {
|
||||||
|
await db.createPlaylist('Workout');
|
||||||
|
final playlists = await db.watchPlaylists().first;
|
||||||
|
expect(playlists.length, 1);
|
||||||
|
expect(playlists.first.name, 'Workout');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletePlaylist removes it', () async {
|
||||||
|
final id = await db.createPlaylist('Temp');
|
||||||
|
await db.deletePlaylist(id);
|
||||||
|
final playlists = await db.watchPlaylists().first;
|
||||||
|
expect(playlists, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('PlaylistSongs', () {
|
||||||
|
test('addSongToPlaylist + watchPlaylistSongs returns song in order', () async {
|
||||||
|
final playlistId = await db.createPlaylist('My List');
|
||||||
|
await db.into(db.songs).insert(SongsCompanion.insert(
|
||||||
|
id: 'song-1',
|
||||||
|
path: '/a.mp3',
|
||||||
|
title: 'Song A',
|
||||||
|
dateAddedMs: 0,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
));
|
||||||
|
|
||||||
|
await db.addSongToPlaylist(playlistId, 'song-1', 0);
|
||||||
|
final songs = await db.watchPlaylistSongs(playlistId).first;
|
||||||
|
expect(songs.length, 1);
|
||||||
|
expect(songs.first.title, 'Song A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeSongFromPlaylist removes it', () async {
|
||||||
|
final playlistId = await db.createPlaylist('My List');
|
||||||
|
await db.into(db.songs).insert(SongsCompanion.insert(
|
||||||
|
id: 'song-1',
|
||||||
|
path: '/a.mp3',
|
||||||
|
title: 'Song A',
|
||||||
|
dateAddedMs: 0,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
));
|
||||||
|
await db.addSongToPlaylist(playlistId, 'song-1', 0);
|
||||||
|
await db.removeSongFromPlaylist(playlistId, 'song-1');
|
||||||
|
final songs = await db.watchPlaylistSongs(playlistId).first;
|
||||||
|
expect(songs, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Favorites', () {
|
||||||
|
test('toggleFavorite marks then unmarks a song', () async {
|
||||||
|
await db.into(db.songs).insert(SongsCompanion.insert(
|
||||||
|
id: 'song-1',
|
||||||
|
path: '/a.mp3',
|
||||||
|
title: 'Song A',
|
||||||
|
dateAddedMs: 0,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
));
|
||||||
|
|
||||||
|
await db.toggleFavorite('song-1');
|
||||||
|
expect(await db.watchIsFavorite('song-1').first, true);
|
||||||
|
|
||||||
|
await db.toggleFavorite('song-1');
|
||||||
|
expect(await db.watchIsFavorite('song-1').first, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('watchFavorites returns favorited songs only', () async {
|
||||||
|
await db.into(db.songs).insert(SongsCompanion.insert(
|
||||||
|
id: 'song-1', path: '/a.mp3', title: 'Fav', dateAddedMs: 0, updatedAtMs: 0,
|
||||||
|
));
|
||||||
|
await db.into(db.songs).insert(SongsCompanion.insert(
|
||||||
|
id: 'song-2', path: '/b.mp3', title: 'NotFav', dateAddedMs: 0, updatedAtMs: 0,
|
||||||
|
));
|
||||||
|
await db.toggleFavorite('song-1');
|
||||||
|
|
||||||
|
final favs = await db.watchFavorites().first;
|
||||||
|
expect(favs.length, 1);
|
||||||
|
expect(favs.first.title, 'Fav');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('PlaybackHistory', () {
|
||||||
|
test('recordPlayback + lastPosition roundtrip', () async {
|
||||||
|
await db.into(db.songs).insert(SongsCompanion.insert(
|
||||||
|
id: 'song-1', path: '/a.mp3', title: 'A', dateAddedMs: 0, updatedAtMs: 0,
|
||||||
|
));
|
||||||
|
await db.recordPlayback('song-1', 45000);
|
||||||
|
expect(await db.lastPosition('song-1'), 45000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lastPosition returns null when never played', () async {
|
||||||
|
expect(await db.lastPosition('unknown'), isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user