498 lines
17 KiB
Dart
498 lines
17 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
|
||
}
|
||
}
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 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;
|
||
}
|
||
|
||
/// 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]);
|
||
}
|
||
|
||
/// 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();
|
||
}
|
||
|
||
/// Alle Songs MIT cloud_id (Server-Verknüpfung) — Grundlage für die
|
||
/// YT-Quell-URL-Suche (F2): Treffer werden auf den SERVER hochgeladen
|
||
/// (POST /api/v1/cloud/yt-url) statt lokal gespeichert.
|
||
Future<List<Song>> songsMitCloudId({int limit = 50}) async {
|
||
final d = await db;
|
||
final rows = await d.query('songs',
|
||
where: 'cloud_id IS NOT NULL AND cloud_id != ""',
|
||
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]);
|
||
}
|
||
|
||
// ─── 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 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 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');
|
||
});
|
||
}
|
||
|
||
/// Speicher-Info für die Einstellungen (F4): Anzahl lokaler Songs +
|
||
/// belegter Speicher in Bytes (Σ `groesse_bytes`).
|
||
Future<Map<String, int>> speicherInfo() async {
|
||
final d = await db;
|
||
final rows = await d.rawQuery(
|
||
'SELECT COUNT(*) AS anzahl, COALESCE(SUM(groesse_bytes), 0) AS bytes '
|
||
'FROM songs');
|
||
if (rows.isEmpty) return {'anzahl': 0, 'bytes': 0};
|
||
final erste = rows.first;
|
||
return {
|
||
'anzahl': (erste['anzahl'] as num?)?.toInt() ?? 0,
|
||
'bytes': (erste['bytes'] as num?)?.toInt() ?? 0,
|
||
};
|
||
}
|
||
}
|