## Fix (Code-Review Finding 2, HIGH) - DbHelper.songZurPlaylist: INSERT OR IGNORE (ConflictAlgorithm.ignore) — Doppel-Tap-Race verletzt PRIMARY KEY (playlist_id, song_id) nicht mehr - FavoritenService.umschalten: gibt neuen Status zurück, try/catch mit MeloLogger, Zustand nach Fehler aus DB neu geladen statt blind geflippt - NowPlayingScreen._toggleFavorit: In-Flight-Guard _toggleLaeuft gegen parallele Toggles + Statusübernahme aus DB (Quelle der Wahrheit)
544 lines
18 KiB
Dart
544 lines
18 KiB
Dart
import 'package:sqflite/sqflite.dart';
|
||
import 'package:path/path.dart' as p;
|
||
import '../models/song.dart';
|
||
import '../models/tag.dart';
|
||
|
||
class DbHelper {
|
||
static final DbHelper _instanz = DbHelper._();
|
||
factory DbHelper() => _instanz;
|
||
DbHelper._();
|
||
|
||
Database? _db;
|
||
|
||
Future<Database> get db async {
|
||
if (_db != null) return _db!;
|
||
_db = await _init();
|
||
return _db!;
|
||
}
|
||
|
||
Future<Database> _init() async {
|
||
final pfad = await getDatabasesPath();
|
||
return openDatabase(
|
||
p.join(pfad, 'melo.db'),
|
||
version: 7,
|
||
onCreate: (db, version) async {
|
||
await db.execute('''
|
||
CREATE TABLE songs (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
titel TEXT NOT NULL,
|
||
kuenstler TEXT NOT NULL,
|
||
album TEXT DEFAULT '',
|
||
dauer_sekunden INTEGER NOT NULL,
|
||
datei_pfad TEXT NOT NULL UNIQUE,
|
||
cover_pfad TEXT,
|
||
groesse_bytes INTEGER DEFAULT 0,
|
||
ist_heruntergeladen INTEGER DEFAULT 0,
|
||
ist_korrupt INTEGER DEFAULT 0,
|
||
hinzugefuegt_am TEXT NOT NULL,
|
||
download_quelle TEXT DEFAULT 'local',
|
||
stream_url TEXT,
|
||
yt_url TEXT,
|
||
zuletzt_position INTEGER,
|
||
jahr TEXT,
|
||
genre TEXT,
|
||
track TEXT
|
||
)
|
||
''');
|
||
await db.execute('''
|
||
CREATE TABLE tags (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL UNIQUE,
|
||
icon TEXT,
|
||
farbe_hex TEXT
|
||
)
|
||
''');
|
||
await db.execute('''
|
||
CREATE TABLE song_tags (
|
||
song_id INTEGER NOT NULL,
|
||
tag_id INTEGER NOT NULL,
|
||
PRIMARY KEY (song_id, tag_id),
|
||
FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||
)
|
||
''');
|
||
await db.execute('''
|
||
CREATE TABLE playlists (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
name TEXT NOT NULL,
|
||
erstellt_am TEXT NOT NULL
|
||
)
|
||
''');
|
||
await db.execute('''
|
||
CREATE TABLE playlist_songs (
|
||
playlist_id INTEGER NOT NULL,
|
||
song_id INTEGER NOT NULL,
|
||
position INTEGER NOT NULL,
|
||
PRIMARY KEY (playlist_id, song_id),
|
||
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE,
|
||
FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE
|
||
)
|
||
''');
|
||
await db.execute('''
|
||
CREATE TABLE wiedergabe_verlauf (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
song_id INTEGER NOT NULL,
|
||
position INTEGER DEFAULT 0,
|
||
zuletzt_abgespielt TEXT NOT NULL,
|
||
FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE
|
||
)
|
||
''');
|
||
await db.execute('''
|
||
CREATE TABLE abspiel_historie (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
song_id INTEGER NOT NULL,
|
||
played_at TEXT NOT NULL,
|
||
FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE
|
||
)
|
||
''');
|
||
await db.execute(
|
||
'CREATE INDEX IF NOT EXISTS i_hist_song ON abspiel_historie(song_id)');
|
||
await db.execute(
|
||
'CREATE INDEX IF NOT EXISTS i_hist_played ON abspiel_historie(played_at)');
|
||
},
|
||
onUpgrade: (db, oldVersion, newVersion) async {
|
||
if (oldVersion < 2) {
|
||
try {
|
||
await db.execute('ALTER TABLE songs ADD COLUMN stream_url TEXT');
|
||
} catch (_) {
|
||
// Spalte existiert bereits – ignorieren
|
||
}
|
||
}
|
||
if (oldVersion < 3) {
|
||
try {
|
||
await db.execute('ALTER TABLE songs ADD COLUMN ist_korrupt INTEGER DEFAULT 0');
|
||
} catch (_) {
|
||
// Spalte existiert bereits – ignorieren
|
||
}
|
||
}
|
||
if (oldVersion < 4) {
|
||
try {
|
||
await db.execute('ALTER TABLE songs ADD COLUMN yt_url TEXT');
|
||
} catch (_) {
|
||
// Spalte existiert bereits – ignorieren
|
||
}
|
||
}
|
||
if (oldVersion < 5) {
|
||
try {
|
||
await db.execute('ALTER TABLE songs ADD COLUMN cloud_id TEXT');
|
||
} catch (_) {}
|
||
try {
|
||
await db.execute('ALTER TABLE songs ADD COLUMN cloud_title TEXT');
|
||
} catch (_) {}
|
||
try {
|
||
await db.execute('ALTER TABLE songs ADD COLUMN cloud_artist TEXT');
|
||
} catch (_) {}
|
||
try {
|
||
await db.execute('''
|
||
CREATE TABLE IF NOT EXISTS server_favorites (
|
||
cloud_id TEXT PRIMARY KEY,
|
||
favorited_at TEXT
|
||
)
|
||
''');
|
||
} catch (_) {}
|
||
try {
|
||
await db.execute('''
|
||
CREATE TABLE IF NOT EXISTS sync_metadata (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT
|
||
)
|
||
''');
|
||
} catch (_) {}
|
||
}
|
||
if (oldVersion < 6) {
|
||
try { await db.execute('ALTER TABLE songs ADD COLUMN jahr TEXT'); } catch (_) {}
|
||
try { await db.execute('ALTER TABLE songs ADD COLUMN genre TEXT'); } catch (_) {}
|
||
try { await db.execute('ALTER TABLE songs ADD COLUMN track TEXT'); } catch (_) {}
|
||
}
|
||
if (oldVersion < 7) {
|
||
try {
|
||
await db.execute('''
|
||
CREATE TABLE IF NOT EXISTS abspiel_historie (
|
||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
song_id INTEGER NOT NULL,
|
||
played_at TEXT NOT NULL,
|
||
FOREIGN KEY (song_id) REFERENCES songs(id) ON DELETE CASCADE
|
||
)
|
||
''');
|
||
await db.execute(
|
||
'CREATE INDEX IF NOT EXISTS i_hist_song ON abspiel_historie(song_id)');
|
||
await db.execute(
|
||
'CREATE INDEX IF NOT EXISTS i_hist_played ON abspiel_historie(played_at)');
|
||
// Backfill: bisherige "zuletzt abgespielt"-Einträge als Historie übernehmen
|
||
await db.rawInsert('''
|
||
INSERT OR IGNORE INTO abspiel_historie (song_id, played_at)
|
||
SELECT song_id, zuletzt_abgespielt FROM wiedergabe_verlauf
|
||
WHERE zuletzt_abgespielt IS NOT NULL
|
||
''');
|
||
} catch (_) {
|
||
// Historie-Tabelle konnte nicht angelegt werden – ignorieren
|
||
}
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
/// Löscht fehlerhafte Einträge ohne Dateipfad
|
||
Future<void> alteDummiesLoeschen() async {
|
||
final d = await db;
|
||
await d.delete('songs', where: "datei_pfad = '' OR datei_pfad IS NULL");
|
||
}
|
||
|
||
/// Einzelnen Song löschen
|
||
Future<void> loeschSong(int id) async {
|
||
final d = await db;
|
||
await d.delete('songs', where: 'id = ?', whereArgs: [id]);
|
||
}
|
||
|
||
// ─── Songs ──────────────────────────────────────
|
||
|
||
Future<int> songEinfuegen(Song song) async {
|
||
final d = await db;
|
||
return d.insert('songs', song.toMap(),
|
||
conflictAlgorithm: ConflictAlgorithm.ignore);
|
||
}
|
||
|
||
Future<void> songsEinfuegen(List<Song> songs) async {
|
||
final d = await db;
|
||
final batch = d.batch();
|
||
for (final song in songs) {
|
||
batch.insert('songs', song.toMap(),
|
||
conflictAlgorithm: ConflictAlgorithm.ignore);
|
||
}
|
||
await batch.commit(noResult: true);
|
||
}
|
||
|
||
Future<List<Song>> alleSongs() async {
|
||
final d = await db;
|
||
final rows = await d.query('songs', orderBy: 'hinzugefuegt_am DESC');
|
||
return rows.map((r) => Song.fromMap(r)).toList();
|
||
}
|
||
|
||
Future<Song?> songNachId(int id) async {
|
||
final d = await db;
|
||
final rows = await d.query('songs', where: 'id = ?', whereArgs: [id]);
|
||
if (rows.isEmpty) return null;
|
||
return Song.fromMap(rows.first);
|
||
}
|
||
|
||
Future<Song?> songNachPfad(String pfad) async {
|
||
final d = await db;
|
||
final rows = await d.query('songs', where: 'datei_pfad = ?', whereArgs: [pfad]);
|
||
if (rows.isEmpty) return null;
|
||
return Song.fromMap(rows.first);
|
||
}
|
||
|
||
Future<void> positionAktualisieren(int songId, int position) async {
|
||
final d = await db;
|
||
await d.update('songs', {'zuletzt_position': position},
|
||
where: 'id = ?', whereArgs: [songId]);
|
||
final rows = await d.update('wiedergabe_verlauf',
|
||
{'position': position, 'zuletzt_abgespielt': DateTime.now().toIso8601String()},
|
||
where: 'song_id = ?', whereArgs: [songId]);
|
||
if (rows == 0) {
|
||
await d.insert('wiedergabe_verlauf', {
|
||
'song_id': songId,
|
||
'position': position,
|
||
'zuletzt_abgespielt': DateTime.now().toIso8601String(),
|
||
});
|
||
}
|
||
}
|
||
|
||
/// Die letzten [anzahl] abgespielten Songs
|
||
Future<List<Song>> letzteWiedergaben({int anzahl = 5}) async {
|
||
final d = await db;
|
||
final rows = await d.rawQuery('''
|
||
SELECT s.* FROM songs s
|
||
JOIN wiedergabe_verlauf w ON s.id = w.song_id
|
||
ORDER BY w.zuletzt_abgespielt DESC
|
||
LIMIT ?
|
||
''', [anzahl]);
|
||
return rows.map((r) => Song.fromMap(r)).toList();
|
||
}
|
||
|
||
// ─── Abspiel-Historie (für Jahres-Recap) ─────────
|
||
|
||
/// Registriert einen Abspielvorgang (Zeitpunkt wird jetzt gesetzt).
|
||
/// Dient als Datenbasis für den Jahres-Recap.
|
||
Future<void> abspielRegistrieren(int songId) async {
|
||
final d = await db;
|
||
await d.insert('abspiel_historie', {
|
||
'song_id': songId,
|
||
'played_at': DateTime.now().toIso8601String(),
|
||
});
|
||
}
|
||
|
||
/// Rohe Abspiel-Historie: song_id + played_at (für Recap-Aggregation)
|
||
Future<List<Map<String, dynamic>>> abspielHistorie() async {
|
||
final d = await db;
|
||
final rows = await d.rawQuery('''
|
||
SELECT h.song_id, h.played_at, s.titel, s.kuenstler, s.dauer_sekunden
|
||
FROM abspiel_historie h
|
||
JOIN songs s ON s.id = h.song_id
|
||
ORDER BY h.played_at ASC
|
||
''');
|
||
return rows;
|
||
}
|
||
|
||
/// Löscht die komplette Abspiel-Historie (für "Alle Daten löschen")
|
||
Future<void> abspielHistorieLoeschen() async {
|
||
final d = await db;
|
||
await d.delete('abspiel_historie');
|
||
}
|
||
|
||
/// Markiert einen Song als korrupt
|
||
Future<void> alsKorruptMarkieren(int songId) async {
|
||
final d = await db;
|
||
await d.update('songs', {'ist_korrupt': 1},
|
||
where: 'id = ?', whereArgs: [songId]);
|
||
}
|
||
|
||
/// Markiert einen Song als nicht-korrupt (Reparatur)
|
||
Future<void> korruptZuruecksetzen(int songId) async {
|
||
final d = await db;
|
||
await d.update('songs', {'ist_korrupt': 0},
|
||
where: 'id = ?', whereArgs: [songId]);
|
||
}
|
||
|
||
/// Alle als korrupt markierten Songs abrufen
|
||
Future<List<Song>> korrupteSongs() async {
|
||
final d = await db;
|
||
final rows = await d.query('songs',
|
||
where: 'ist_korrupt = 1',
|
||
orderBy: 'hinzugefuegt_am DESC');
|
||
return rows.map((r) => Song.fromMap(r)).toList();
|
||
}
|
||
|
||
/// YouTube-Quell-URL für einen Song speichern
|
||
Future<void> ytUrlAktualisieren(int songId, String ytUrl) async {
|
||
final d = await db;
|
||
await d.update('songs', {'yt_url': ytUrl},
|
||
where: 'id = ?', whereArgs: [songId]);
|
||
}
|
||
|
||
/// Alle Songs ohne YouTube-Quell-URL abrufen (für Scanner-Suche)
|
||
Future<List<Song>> songsOhneYtUrl({int limit = 50}) async {
|
||
final d = await db;
|
||
final rows = await d.query('songs',
|
||
where: 'yt_url IS NULL OR yt_url = ""',
|
||
orderBy: 'hinzugefuegt_am DESC',
|
||
limit: limit);
|
||
return rows.map((r) => Song.fromMap(r)).toList();
|
||
}
|
||
|
||
// ─── Tags ────────────────────────────────────────
|
||
|
||
Future<int> tagErstellen(String name, {String? icon, String? farbe}) async {
|
||
final d = await db;
|
||
return d.insert('tags', {
|
||
'name': name, 'icon': icon, 'farbe_hex': farbe,
|
||
}, conflictAlgorithm: ConflictAlgorithm.ignore);
|
||
}
|
||
|
||
Future<List<Tag>> alleTags() async {
|
||
final d = await db;
|
||
final rows = await d.query('tags', orderBy: 'name');
|
||
return rows.map((r) => Tag.fromMap(r)).toList();
|
||
}
|
||
|
||
Future<void> songTagHinzufuegen(int songId, int tagId) async {
|
||
final d = await db;
|
||
await d.insert('song_tags', {'song_id': songId, 'tag_id': tagId},
|
||
conflictAlgorithm: ConflictAlgorithm.ignore);
|
||
}
|
||
|
||
Future<List<Tag>> tagsFuerSong(int songId) async {
|
||
final d = await db;
|
||
final rows = await d.rawQuery('''
|
||
SELECT t.* FROM tags t
|
||
JOIN song_tags st ON t.id = st.tag_id
|
||
WHERE st.song_id = ?
|
||
''', [songId]);
|
||
return rows.map((r) => Tag.fromMap(r)).toList();
|
||
}
|
||
|
||
Future<void> songTagEntfernen(int songId, int tagId) async {
|
||
final d = await db;
|
||
await d.delete('song_tags',
|
||
where: 'song_id = ? AND tag_id = ?',
|
||
whereArgs: [songId, tagId]);
|
||
}
|
||
|
||
Future<List<int>> songIdsFuerTag(String tagName) async {
|
||
final d = await db;
|
||
final rows = await d.rawQuery('''
|
||
SELECT st.song_id FROM song_tags st
|
||
JOIN tags t ON t.id = st.tag_id
|
||
WHERE t.name = ?
|
||
''', [tagName]);
|
||
return rows.map((r) => r['song_id'] as int).toList();
|
||
}
|
||
|
||
Future<Map<String, int>> tagCountsBerechnen() async {
|
||
final d = await db;
|
||
final rows = await d.rawQuery('''
|
||
SELECT t.name, COUNT(st.song_id) as cnt
|
||
FROM tags t
|
||
LEFT JOIN song_tags st ON t.id = st.tag_id
|
||
GROUP BY t.name
|
||
''');
|
||
return {for (final r in rows) r['name'] as String: r['cnt'] as int};
|
||
}
|
||
|
||
// ─── Playlists ───────────────────────────────────
|
||
|
||
Future<int> playlistErstellen(String name) async {
|
||
final d = await db;
|
||
return d.insert('playlists', {
|
||
'name': name,
|
||
'erstellt_am': DateTime.now().toIso8601String(),
|
||
});
|
||
}
|
||
|
||
Future<void> songZurPlaylist(int playlistId, int songId, int position) async {
|
||
final d = await db;
|
||
// INSERT OR IGNORE: Song bereits in der Playlist (Race/Doppel-Tap) ist
|
||
// kein Fehler — der Eintrag bleibt einfach bestehen (PK-Konflikt abgefangen).
|
||
await d.insert('playlist_songs', {
|
||
'playlist_id': playlistId,
|
||
'song_id': songId,
|
||
'position': position,
|
||
}, conflictAlgorithm: ConflictAlgorithm.ignore);
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> allePlaylists() async {
|
||
final d = await db;
|
||
return d.query('playlists', orderBy: 'erstellt_am DESC');
|
||
}
|
||
|
||
Future<void> songAusPlaylistEntfernen(int playlistId, int songId) async {
|
||
final d = await db;
|
||
await d.delete('playlist_songs',
|
||
where: 'playlist_id = ? AND song_id = ?',
|
||
whereArgs: [playlistId, songId]);
|
||
}
|
||
|
||
/// Aktualisiert die Position aller Songs in einer Playlist (nach Drag & Drop)
|
||
Future<void> playlistReihenfolgeAktualisieren(int playlistId, List<int> songIds) async {
|
||
final d = await db;
|
||
await d.transaction((txn) async {
|
||
for (int i = 0; i < songIds.length; i++) {
|
||
await txn.update(
|
||
'playlist_songs',
|
||
{'position': i},
|
||
where: 'playlist_id = ? AND song_id = ?',
|
||
whereArgs: [playlistId, songIds[i]],
|
||
);
|
||
}
|
||
});
|
||
}
|
||
|
||
Future<void> metadatenAktualisieren(int songId, {String? titel, String? kuenstler, String? album, String? jahr, String? genre}) async {
|
||
final d = await db;
|
||
final update = <String, dynamic>{};
|
||
if (titel != null) update['titel'] = titel;
|
||
if (kuenstler != null) update['kuenstler'] = kuenstler;
|
||
if (album != null) update['album'] = album;
|
||
if (jahr != null) update['jahr'] = jahr;
|
||
if (genre != null) update['genre'] = genre;
|
||
if (update.isNotEmpty) {
|
||
await d.update('songs', update, where: 'id = ?', whereArgs: [songId]);
|
||
}
|
||
}
|
||
|
||
Future<List<Song>> songsDerPlaylist(int playlistId) async {
|
||
final d = await db;
|
||
final rows = await d.rawQuery('''
|
||
SELECT s.* FROM songs s
|
||
JOIN playlist_songs ps ON s.id = ps.song_id
|
||
WHERE ps.playlist_id = ?
|
||
ORDER BY ps.position
|
||
''', [playlistId]);
|
||
return rows.map((r) => Song.fromMap(r)).toList();
|
||
}
|
||
|
||
/// Cloud-ID für einen Song speichern (Verknüpfung lokal ↔ Cloud)
|
||
Future<void> cloudIdSetzen(int songId, String cloudId,
|
||
{String? cloudTitle, String? cloudArtist}) async {
|
||
final d = await db;
|
||
final update = <String, dynamic>{'cloud_id': cloudId};
|
||
if (cloudTitle != null) update['cloud_title'] = cloudTitle;
|
||
if (cloudArtist != null) update['cloud_artist'] = cloudArtist;
|
||
await d.update('songs', update, where: 'id = ?', whereArgs: [songId]);
|
||
}
|
||
|
||
/// Sync-Metadaten lesen
|
||
Future<String?> syncMetaGet(String key) async {
|
||
final d = await db;
|
||
final rows = await d.query('sync_metadata',
|
||
where: 'key = ?', whereArgs: [key]);
|
||
if (rows.isEmpty) return null;
|
||
return rows.first['value'] as String?;
|
||
}
|
||
|
||
/// Sync-Metadaten setzen
|
||
Future<void> syncMetaSet(String key, String value) async {
|
||
final d = await db;
|
||
await d.insert('sync_metadata', {'key': key, 'value': value},
|
||
conflictAlgorithm: ConflictAlgorithm.replace);
|
||
}
|
||
|
||
/// Server-Favoriten abrufen (alle cloud_ids)
|
||
Future<Set<String>> serverFavoritesGet() async {
|
||
final d = await db;
|
||
final rows = await d.query('server_favorites');
|
||
return rows.map((r) => r['cloud_id'] as String).toSet();
|
||
}
|
||
|
||
/// Server-Favoriten ersetzen (komplette Liste)
|
||
Future<void> serverFavoritesSet(List<String> cloudIds) async {
|
||
final d = await db;
|
||
await d.transaction((txn) async {
|
||
await txn.delete('server_favorites');
|
||
final now = DateTime.now().toIso8601String();
|
||
for (final cid in cloudIds) {
|
||
await txn.insert('server_favorites',
|
||
{'cloud_id': cid, 'favorited_at': now});
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Song per cloud_id finden
|
||
Future<Song?> songNachCloudId(String cloudId) async {
|
||
final d = await db;
|
||
final rows = await d.query('songs',
|
||
where: 'cloud_id = ?', whereArgs: [cloudId]);
|
||
if (rows.isEmpty) return null;
|
||
return Song.fromMap(rows.first);
|
||
}
|
||
|
||
/// Benutzerdefinierte Metadaten für Cloud-Song aktualisieren
|
||
Future<void> cloudMetadatenAktualisieren(int songId,
|
||
{String? title, String? artist}) async {
|
||
final d = await db;
|
||
final update = <String, dynamic>{};
|
||
if (title != null) update['cloud_title'] = title;
|
||
if (artist != null) update['cloud_artist'] = artist;
|
||
if (update.isNotEmpty) {
|
||
await d.update('songs', update, where: 'id = ?', whereArgs: [songId]);
|
||
}
|
||
}
|
||
|
||
Future<void> loeschen() async {
|
||
final d = await db;
|
||
await d.transaction((txn) async {
|
||
await txn.delete('abspiel_historie');
|
||
await txn.delete('wiedergabe_verlauf');
|
||
await txn.delete('song_tags');
|
||
await txn.delete('playlist_songs');
|
||
await txn.delete('playlists');
|
||
await txn.delete('tags');
|
||
await txn.delete('songs');
|
||
});
|
||
}
|
||
}
|