Kategorien (mehrere je Song), Metadaten-Ansicht mit Expertenmodus
- DB-Schema 4: Tabelle song_categories + Spalte categories_edited - Kategorien kommen aus dem Genre-Tag (Trennung an ; , | /), manuell bearbeitbar; von Hand gesetzte Kategorien ueberlebt der naechste Scan - Songzeile zeigt "Kuenstler | Kategorie1 - Kategorie2" - Einstellung "Gleiche Kategorie = gleiches Coverbild" (Standard an) - Metadaten-Sheet mit ausklappbarem Expertenmodus - Neu: categories.dart, category_service.dart, song_detail_sheet.dart, app_settings.dart Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011snPXPafPC5i87o68H14W7
This commit is contained in:
@@ -26,6 +26,10 @@ class Songs extends Table {
|
||||
/// 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))();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {id};
|
||||
}
|
||||
@@ -65,6 +69,17 @@ class PlaylistSongs extends Table {
|
||||
Set<Column> 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<Column> get primaryKey => {songId, name};
|
||||
}
|
||||
|
||||
/// Favorisierte Songs.
|
||||
class Favorites extends Table {
|
||||
TextColumn get songId => text().references(Songs, #id)();
|
||||
@@ -84,12 +99,20 @@ class PlaybackHistory extends Table {
|
||||
Set<Column> get primaryKey => {songId, playedAtMs};
|
||||
}
|
||||
|
||||
@DriftDatabase(tables: [Songs, Folders, Playlists, PlaylistSongs, Favorites, PlaybackHistory])
|
||||
@DriftDatabase(tables: [
|
||||
Songs,
|
||||
Folders,
|
||||
Playlists,
|
||||
PlaylistSongs,
|
||||
Favorites,
|
||||
PlaybackHistory,
|
||||
SongCategories,
|
||||
])
|
||||
class MeloDb extends _$MeloDb {
|
||||
MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 3;
|
||||
int get schemaVersion => 4;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration => MigrationStrategy(
|
||||
@@ -104,6 +127,10 @@ class MeloDb extends _$MeloDb {
|
||||
if (from < 3) {
|
||||
await m.addColumn(songs, songs.playCount);
|
||||
}
|
||||
if (from < 4) {
|
||||
await m.addColumn(songs, songs.categoriesEdited);
|
||||
await m.createTable(songCategories);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -297,6 +324,63 @@ class MeloDb extends _$MeloDb {
|
||||
return query.watch().map((rows) => rows.map((r) => r.readTable(songs)).toList());
|
||||
}
|
||||
|
||||
// === Kategorien ===
|
||||
/// Alle Kategorien-Zuordnungen, nach Song gebündelt und in gespeicherter
|
||||
/// Reihenfolge — die UI braucht sie immer für die ganze sichtbare Liste.
|
||||
Stream<Map<String, List<String>>> watchCategoriesBySong() {
|
||||
return (select(songCategories)
|
||||
..orderBy([(c) => OrderingTerm(expression: c.position)]))
|
||||
.watch()
|
||||
.map((rows) {
|
||||
final grouped = <String, List<String>>{};
|
||||
for (final row in rows) {
|
||||
grouped.putIfAbsent(row.songId, () => []).add(row.name);
|
||||
}
|
||||
return grouped;
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<String>> 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<void> setCategories(
|
||||
String songId,
|
||||
List<String> 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<List<String>> 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,
|
||||
|
||||
Reference in New Issue
Block a user