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 get db async { if (_db != null) return _db!; _db = await _init(); return _db!; } Future _init() async { final pfad = await getDatabasesPath(); return openDatabase( p.join(pfad, 'melo.db'), version: 5, 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 ) '''); 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 ) '''); }, 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 (_) {} } }, ); } /// Löscht fehlerhafte Einträge ohne Dateipfad Future alteDummiesLoeschen() async { final d = await db; await d.delete('songs', where: "datei_pfad = '' OR datei_pfad IS NULL"); } /// Einzelnen Song löschen Future loeschSong(int id) async { final d = await db; await d.delete('songs', where: 'id = ?', whereArgs: [id]); } // ─── Songs ────────────────────────────────────── Future songEinfuegen(Song song) async { final d = await db; return d.insert('songs', song.toMap(), conflictAlgorithm: ConflictAlgorithm.ignore); } Future songsEinfuegen(List 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> 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 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 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 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> 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(); } /// Markiert einen Song als korrupt Future 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 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> 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 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> 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 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> alleTags() async { final d = await db; final rows = await d.query('tags', orderBy: 'name'); return rows.map((r) => Tag.fromMap(r)).toList(); } Future 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> 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 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> 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> 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 playlistErstellen(String name) async { final d = await db; return d.insert('playlists', { 'name': name, 'erstellt_am': DateTime.now().toIso8601String(), }); } Future songZurPlaylist(int playlistId, int songId, int position) async { final d = await db; await d.insert('playlist_songs', { 'playlist_id': playlistId, 'song_id': songId, 'position': position, }); } Future>> allePlaylists() async { final d = await db; return d.query('playlists', orderBy: 'erstellt_am DESC'); } Future 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 playlistReihenfolgeAktualisieren(int playlistId, List 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 metadatenAktualisieren(int songId, {String? titel, String? kuenstler, String? album}) async { final d = await db; final update = {}; if (titel != null) update['titel'] = titel; if (kuenstler != null) update['kuenstler'] = kuenstler; if (album != null) update['album'] = album; if (update.isNotEmpty) { await d.update('songs', update, where: 'id = ?', whereArgs: [songId]); } } Future> 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 cloudIdSetzen(int songId, String cloudId, {String? cloudTitle, String? cloudArtist}) async { final d = await db; final update = {'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 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 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> 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 serverFavoritesSet(List 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 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 cloudMetadatenAktualisieren(int songId, {String? title, String? artist}) async { final d = await db; final update = {}; 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 loeschen() async { final d = await db; await d.transaction((txn) async { 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'); }); } }