CRIT: - Tag-Leiste: Toggle-Kopf 'Tags & Filter' eingebaut (war unerreichbar) + TagStats - Profil: Cloud-Count vor Dialog aufloesen (kein 'Instance of Future' mehr) HIGH: - CloudEinstellungen: toten Sync-Timer entfernt (echter Timer im ViewModel) - StatistikCard + RecentWidget jetzt eingebaut (gesamtMB/gesamtMin endlich genutzt) - CloudService als Singleton (konsistenter Token-Zustand in allen Services) MED/LOW: - Playlist-Erkennung nur noch via list= Parameter - Player: fehlende Quelle wird geloggt statt still - song_tile: null-ID-Guard vor Tag-Dialog - Scanner-Log mit Exception-Objekt - FavoritenService: anzahlFavoriten() fuer StatistikCard
316 lines
10 KiB
Dart
316 lines
10 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: 2,
|
||
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,
|
||
hinzugefuegt_am TEXT NOT NULL,
|
||
download_quelle TEXT DEFAULT 'local',
|
||
stream_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
|
||
}
|
||
}
|
||
// Weitere Migrationen: if (oldVersion < 3) { ... }
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 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();
|
||
}
|
||
|
||
// ─── 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;
|
||
await d.insert('playlist_songs', {
|
||
'playlist_id': playlistId,
|
||
'song_id': songId,
|
||
'position': position,
|
||
});
|
||
}
|
||
|
||
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}) 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 (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();
|
||
}
|
||
|
||
Future<void> 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');
|
||
});
|
||
}
|
||
}
|