This repository has been archived on 2026-08-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
melo-app/lib/database/db_helper.dart
T

554 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
/// 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]);
}
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');
});
}
/// 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,
};
}
}