v2.35 — Vollständiges Sync-System: Playlisten, Favoriten, Auto-Sync, Persistent Login, Benutzerdefinierte Namen

## 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
This commit is contained in:
Dustin
2026-08-01 19:32:35 +02:00
parent 2b06a8e496
commit 15993b1d0a
5 changed files with 1551 additions and 463 deletions
+186 -49
View File
@@ -5,7 +5,7 @@ import '../config/app_config.dart';
import '../services/auth_service.dart';
import '../services/melo_logger.dart';
/// Cloud-Sync Service für Melo Registry
/// Cloud-Sync Service für Melo Registry (v3 — vollständiges Sync-System)
/// Authentifizierung via Baka-Auth JWT-Token
class CloudService {
static String get _base => AppConfig.cloudUrl;
@@ -33,15 +33,23 @@ class CloudService {
if (_user.isNotEmpty) {
headers['X-User'] = _user;
}
// Baka-Auth JWT Token mitsenden falls vorhanden
if (token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
return headers;
}
Map<String, String> get _jsonHeader => {
..._authHeader,
'Content-Type': 'application/json',
};
Future<Map?> status() => _get('/api/cloud/status');
Future<Map?> syncStatus() => _get('/api/cloud/sync-status');
Future<Map?> syncAll() => _post('/api/cloud/sync-all', {});
Future<List<Map>> listSongs() async {
final r = await _get('/api/cloud/list');
return List<Map>.from(r?['songs'] ?? []);
@@ -49,10 +57,12 @@ class CloudService {
Future<String?> upload(String filepath, String filename) async {
try {
final req = http.MultipartRequest('POST', Uri.parse('$_base/api/cloud/upload'));
final req =
http.MultipartRequest('POST', Uri.parse('$_base/api/cloud/upload'));
req.headers.addAll(_authHeader);
req.files.add(await http.MultipartFile.fromPath('file', filepath,
filename: filename));
req.files.add(
await http.MultipartFile.fromPath('file', filepath,
filename: filename));
final resp = await req.send().timeout(const Duration(seconds: 120));
final body = jsonDecode(await resp.stream.bytesToString());
return body['song_id'] as String?;
@@ -83,7 +93,7 @@ class CloudService {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/delete'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
headers: _jsonHeader,
body: jsonEncode({'song_id': songId}))
.timeout(const Duration(seconds: 10));
return r.statusCode == 200;
@@ -92,51 +102,12 @@ class CloudService {
}
}
Future<Map?> _get(String path) async {
try {
final r = await http
.get(Uri.parse('$_base$path'), headers: _authHeader)
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) return jsonDecode(r.body);
MeloLogger().fehler('cloud_get', '${r.statusCode} $path');
} catch (e) {
MeloLogger().fehler('cloud_get_error', '$path: $e');
}
return null;
}
Future<String?> share(List<String> songIds) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/share'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
body: jsonEncode({'song_ids': songIds}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) {
final d = jsonDecode(r.body);
return d['code'] as String?;
}
} catch (_) {}
return null;
}
Future<Map?> importCode(String code) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/import'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
body: jsonEncode({'code': code}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) return jsonDecode(r.body);
} catch (_) {}
return null;
}
/// Sync-Änderungen seit einem Zeitstempel abrufen
Future<List<Map>> syncChanges(String since) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/sync'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
headers: _jsonHeader,
body: jsonEncode({'since': since}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) {
@@ -147,6 +118,117 @@ class CloudService {
return [];
}
// ─── 📋 Playlisten ───
/// Alle Playlisten des Users auf dem Server
Future<List<Map>> getPlaylists() async {
final r = await _get('/api/cloud/playlists');
return List<Map>.from(r?['playlists'] ?? []);
}
/// Playlist erstellen
Future<Map?> createPlaylist(String name) async {
final r = await _post('/api/cloud/playlists', {'name': name});
return r?['playlist'];
}
/// Playlist löschen
Future<bool> deletePlaylist(int id) async {
final r = await _post('/api/cloud/playlists', {'id': id});
return r?['status'] == 'ok';
}
/// Playlist-Details mit Songs abrufen
Future<Map?> getPlaylist(int id) async {
return await _get('/api/cloud/playlists/$id');
}
/// Songs zu Playlist hinzufügen
Future<int> addSongsToPlaylist(int playlistId, List<String> songIds) async {
final r = await _post(
'/api/cloud/playlists/$playlistId/songs', {'song_ids': songIds});
return r?['added'] ?? 0;
}
/// Song aus Playlist entfernen
Future<bool> removeSongFromPlaylist(int playlistId, String songId) async {
try {
final r = await http
.delete(
Uri.parse(
'$_base/api/cloud/playlists/$playlistId/songs/$songId'),
headers: _authHeader)
.timeout(const Duration(seconds: 10));
return r.statusCode == 200;
} catch (_) {
return false;
}
}
/// Playlist-Positionen aktualisieren
Future<bool> updatePlaylistPositions(
int playlistId, List<Map<String, dynamic>> positions) async {
try {
final r = await http
.put(
Uri.parse(
'$_base/api/cloud/playlists/$playlistId/positions'),
headers: _jsonHeader,
body: jsonEncode({'positions': positions}))
.timeout(const Duration(seconds: 10));
return r.statusCode == 200;
} catch (_) {
return false;
}
}
// ─── ⭐ Favoriten ───
/// Favoriten vom Server abrufen
Future<List<Map>> getFavorites() async {
final r = await _get('/api/cloud/favorites');
return List<Map>.from(r?['favorites'] ?? []);
}
/// Favoriten synchronisieren (komplette Liste senden)
Future<bool> syncFavorites(List<String> songIds) async {
final r =
await _post('/api/cloud/favorites', {'song_ids': songIds});
return r?['status'] == 'ok';
}
/// Einzelnen Favoriten toggeln
Future<Map?> toggleFavorite(String songId) async {
return await _post('/api/cloud/favorites/toggle', {'song_id': songId});
}
// ─── ✏️ Umbenennen ───
/// Song umbenennen (benutzerdefinierter Titel/Artist)
Future<Map?> renameSong(String songId,
{String? title, String? artist}) async {
final body = <String, dynamic>{'song_id': songId};
if (title != null) body['title'] = title;
if (artist != null) body['artist'] = artist;
return await _post('/api/cloud/rename', body);
}
// ─── 🕐 Verlauf ───
/// Wiedergabe-Verlauf vom Server abrufen
Future<List<Map>> getHistory({int limit = 50}) async {
final r = await _get('/api/cloud/history?limit=$limit');
return List<Map>.from(r?['history'] ?? []);
}
/// Wiedergabe-Verlauf-Einträge hochladen
Future<int> addHistory(List<Map<String, dynamic>> entries) async {
final r = await _post('/api/cloud/history', {'entries': entries});
return r?['added'] ?? 0;
}
// ─── 🌐 Global / Shared ───
Future<List<Map>> globalList() async {
try {
final r = await http
@@ -163,7 +245,7 @@ class CloudService {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/toggle-global'),
headers: {..._authHeader, 'Content-Type': 'application/json'},
headers: _jsonHeader,
body: jsonEncode({'song_id': songId}))
.timeout(const Duration(seconds: 10));
return r.statusCode == 200;
@@ -172,7 +254,33 @@ class CloudService {
}
}
/// Ruft defekte/korrupte Songs vom Cloud-Server ab
Future<String?> share(List<String> songIds) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/share'),
headers: _jsonHeader,
body: jsonEncode({'song_ids': songIds}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) {
final d = jsonDecode(r.body);
return d['code'] as String?;
}
} catch (_) {}
return null;
}
Future<Map?> importCode(String code) async {
try {
final r = await http
.post(Uri.parse('$_base/api/cloud/import'),
headers: _jsonHeader,
body: jsonEncode({'code': code}))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) return jsonDecode(r.body);
} catch (_) {}
return null;
}
Future<List<Map>> getCorrupted() async {
try {
final r = await http
@@ -187,4 +295,33 @@ class CloudService {
}
return [];
}
// ─── Hilfsmethoden ───
Future<Map?> _get(String path) async {
try {
final r = await http
.get(Uri.parse('$_base$path'), headers: _authHeader)
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) return jsonDecode(r.body);
MeloLogger().fehler('cloud_get', '${r.statusCode} $path');
} catch (e) {
MeloLogger().fehler('cloud_get_error', '$path: $e');
}
return null;
}
Future<Map?> _post(String path, Map<String, dynamic> body) async {
try {
final r = await http
.post(Uri.parse('$_base$path'),
headers: _jsonHeader, body: jsonEncode(body))
.timeout(const Duration(seconds: 10));
if (r.statusCode == 200) return jsonDecode(r.body);
MeloLogger().fehler('cloud_post', '${r.statusCode} $path');
} catch (e) {
MeloLogger().fehler('cloud_post_error', '$path: $e');
}
return null;
}
}