## Server (melo_cloud.py v3.0.0) - Neue DB-Tabellen: user_playlists, user_playlist_songs, user_favorites, user_history, sync_meta - Playlist-Endpunkte: GET/POST /api/cloud/playlists, GET /api/cloud/playlists/<id>, POST songs, DELETE songs, PUT positions - Favoriten-Endpunkte: GET/POST /api/cloud/favorites, POST /api/cloud/favorites/toggle - History-Endpunkte: GET/POST /api/cloud/history (mit Dedup) - Rename-Endpunkt: POST /api/cloud/rename (custom_title/custom_artist) - Sync-Endpunkte: POST /api/cloud/sync-all, GET /api/cloud/sync-status - delete-all erweitert um Playlists + Favorites + History ## App - db_helper.dart: Migration v4→v5 (cloud_id, cloud_title, cloud_artist, server_favorites, sync_metadata) - auth_service.dart: Token-Validierung beim Start (online check + Offline-Fallback), Persistent Login - cloud_service.dart: Neue Methoden für Playlists (CRUD), Favorites (get/sync/toggle), Rename, History, syncAll/syncStatus - cloud_screen.dart: Komplett-Rewrite mit Sync-Modus (Manuell/Auto), Intervall-Auswahl, Sync-Fortschritt-Anzeige, Playlist-Verwaltung, Favoriten-Anzeige + Umbenennen, Sync-Info-Karte, nächster Sync - main.dart: Auto-Sync beim App-Start (nur wenn konfiguriert + fällig) ## Flutter Analyze - 0 neue Issues — nur 7 pre-existing
465 lines
15 KiB
Dart
465 lines
15 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: 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<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();
|
||
}
|
||
|
||
/// 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;
|
||
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();
|
||
}
|
||
|
||
/// 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('wiedergabe_verlauf');
|
||
await txn.delete('song_tags');
|
||
await txn.delete('playlist_songs');
|
||
await txn.delete('playlists');
|
||
await txn.delete('tags');
|
||
await txn.delete('songs');
|
||
});
|
||
}
|
||
}
|