import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../config/app_config.dart'; import '../services/auth_service.dart'; import '../services/melo_logger.dart'; /// Verbindungsstatus der Cloud enum CloudStatus { verbinde, verbunden, fehler } /// Cloud-Sync Service für Melo Registry (v3 — vollständiges Sync-System) /// Authentifizierung via Baka-Auth JWT-Token /// /// ## Konfliktauflösung: Last-Write-Wins (LWW) /// Bei Sync-Konflikten (Client und Server haben beide geändert) gewinnt der /// Eintrag mit dem neueren `updated_at`-Timestamp. Strategie: /// 1. `syncChanges(since)` lädt serverseitige Änderungen seit letztem Sync. /// 2. Client vergleicht `updated_at` mit lokalem Stand — Server gewinnt bei Gleichstand. /// 3. Lokale Änderungen werden danach per `syncAll()` hochgeladen. /// Merge-Strategie wird NICHT unterstützt (kein 3-Way-Merge) — es gewinnt immer /// der jüngste Timestamp. class CloudService extends ChangeNotifier { /// Injizierbarer HTTP-Client (Tests: package:http/testing.dart MockClient). CloudService({http.Client? client}) : _client = client ?? http.Client(); final http.Client _client; static String get _base => AppConfig.cloudUrl; CloudStatus _status = CloudStatus.verbinde; bool _verbindungGestartet = false; /// Aktueller Verbindungsstatus der Cloud CloudStatus get status => _status; /// Ob die Cloud-Verbindung steht bool get istVerbunden => _status == CloudStatus.verbunden; /// Ob bereits ein Verbindungsversuch gestartet wurde (auch wenn er läuft) bool get verbindungGestartet => _verbindungGestartet; /// Anzeigetext für den Verbindungsstatus String get statusText { switch (_status) { case CloudStatus.verbinde: return 'Verbinde...'; case CloudStatus.verbunden: return 'Verbunden'; case CloudStatus.fehler: return 'Keine Verbindung'; } } /// Stellt die Cloud-Verbindung her (Login + Status-Check) und benachrichtigt /// Listener über den neuen Status. Wird beim App-Start aufgerufen /// (feuern-und-vergessen — blockiert nicht). Future verbinde() async { _verbindungGestartet = true; final user = AuthService().benutzer; _status = CloudStatus.verbinde; notifyListeners(); if (user.isEmpty) { _status = CloudStatus.fehler; notifyListeners(); return; } final ok = await login(user); if (!ok) { _status = CloudStatus.fehler; notifyListeners(); return; } final st = await statusDaten(); _status = st != null ? CloudStatus.verbunden : CloudStatus.fehler; notifyListeners(); } /// Login mit Baka-Auth – Token wird aus AuthService bezogen Future login(String user) async { try { final r = await _client .get(Uri.parse('$_base/api/v1/cloud/status'), headers: _authHeader) .timeout(const Duration(seconds: 5)); return r.statusCode == 200; } catch (_) { return false; } } Map get _authHeader { final token = AuthService().token; final headers = {}; if (token != null && token.isNotEmpty) { headers['Authorization'] = 'Bearer $token'; } return headers; } Map get _jsonHeader => { ..._authHeader, 'Content-Type': 'application/json', }; /// Server-Status abrufen (Song-Zähler etc.) Future statusDaten() => _get('/api/v1/cloud/status'); Future syncStatus() => _get('/api/v1/cloud/sync-status'); Future syncAll() => _post('/api/v1/cloud/sync-all', {}); Future> listSongs() async { final r = await _get('/api/v1/cloud/list'); return List.from(r?['songs'] ?? []); } Future upload(String filepath, String filename) async { try { final req = http.MultipartRequest('POST', Uri.parse('$_base/api/v1/cloud/upload')); req.headers.addAll(_authHeader); req.files.add( await http.MultipartFile.fromPath('file', filepath, filename: filename)); final resp = await _client.send(req).timeout(const Duration(seconds: 120)); final body = jsonDecode(await resp.stream.bytesToString()); return body['song_id'] as String?; } catch (e) { MeloLogger().fehler('cloud_upload', e); return null; } } Future download(String songId, String destPath) async { try { final r = await _client .get(Uri.parse('$_base/api/v1/cloud/download/$songId'), headers: _authHeader) .timeout(const Duration(seconds: 120)); if (r.statusCode == 200) { await File(destPath).writeAsBytes(r.bodyBytes); return true; } return false; } catch (e) { MeloLogger().fehler('cloud_download', e); return false; } } Future delete(String songId) async { try { final r = await _client .post(Uri.parse('$_base/api/v1/cloud/delete'), headers: _jsonHeader, body: jsonEncode({'song_id': songId})) .timeout(const Duration(seconds: 10)); return r.statusCode == 200; } catch (_) { return false; } } /// Sync-Änderungen seit einem Zeitstempel abrufen Future> syncChanges(String since) async { try { final r = await _client .post(Uri.parse('$_base/api/v1/cloud/sync'), headers: _jsonHeader, body: jsonEncode({'since': since})) .timeout(const Duration(seconds: 10)); if (r.statusCode == 200) { final d = jsonDecode(r.body); return List.from(d['changes'] ?? []); } } catch (_) {} return []; } // ─── 📋 Playlisten ─── /// Alle Playlisten des Users auf dem Server Future> getPlaylists() async { final r = await _get('/api/v1/cloud/playlists'); return List.from(r?['playlists'] ?? []); } /// Playlist erstellen Future createPlaylist(String name) async { final r = await _post('/api/v1/cloud/playlists', {'name': name}); return r?['playlist']; } /// Playlist löschen Future deletePlaylist(int id) async { final r = await _post('/api/v1/cloud/playlists', {'id': id}); return r?['status'] == 'ok'; } /// Playlist-Details mit Songs abrufen Future getPlaylist(int id) async { return await _get('/api/v1/cloud/playlists/$id'); } /// Songs zu Playlist hinzufügen Future addSongsToPlaylist(int playlistId, List songIds) async { final r = await _post( '/api/v1/cloud/playlists/$playlistId/songs', {'song_ids': songIds}); return r?['added'] ?? 0; } /// Song aus Playlist entfernen Future removeSongFromPlaylist(int playlistId, String songId) async { try { final r = await _client .delete( Uri.parse( '$_base/api/v1/cloud/playlists/$playlistId/songs/$songId'), headers: _authHeader) .timeout(const Duration(seconds: 10)); return r.statusCode == 200; } catch (_) { return false; } } /// Playlist-Positionen aktualisieren Future updatePlaylistPositions( int playlistId, List> positions) async { try { final r = await _client .put( Uri.parse( '$_base/api/v1/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> getFavorites() async { final r = await _get('/api/v1/cloud/favorites'); return List.from(r?['favorites'] ?? []); } /// Favoriten synchronisieren (komplette Liste senden) Future syncFavorites(List songIds) async { final r = await _post('/api/v1/cloud/favorites', {'song_ids': songIds}); return r?['status'] == 'ok'; } /// Einzelnen Favoriten toggeln Future toggleFavorite(String songId) async { return await _post('/api/v1/cloud/favorites/toggle', {'song_id': songId}); } /// Favoriten-Status deterministisch setzen (KEIN Toggle): der Server /// erzwingt den gewünschten Zustand (`set`-Parameter). Verhindert /// Doppel-Toggle, wenn der Server-Zustand vom lokalen abweicht /// (Feuer-und-Vergessen-Push beim lokalen Favoriten-Toggle). Future setFavorite(String songId, {required bool favorit}) async { final r = await _post('/api/v1/cloud/favorites/toggle', { 'song_id': songId, 'set': favorit, }); return r?['status'] == 'ok'; } // ─── ✏️ Umbenennen ─── /// Song umbenennen (benutzerdefinierter Titel/Artist) Future renameSong(String songId, {String? title, String? artist}) async { final body = {'song_id': songId}; if (title != null) body['title'] = title; if (artist != null) body['artist'] = artist; return await _post('/api/v1/cloud/rename', body); } // ─── 🕐 Verlauf ─── /// Wiedergabe-Verlauf vom Server abrufen Future> getHistory({int limit = 50}) async { final r = await _get('/api/v1/cloud/history?limit=$limit'); return List.from(r?['history'] ?? []); } /// Wiedergabe-Verlauf-Einträge hochladen Future addHistory(List> entries) async { final r = await _post('/api/v1/cloud/history', {'entries': entries}); return r?['added'] ?? 0; } // ─── 🌐 Global / Shared ─── Future> globalList() async { try { final r = await _client .get(Uri.parse('$_base/api/v1/cloud/global'), headers: _authHeader) .timeout(const Duration(seconds: 10)); if (r.statusCode == 200) { return List.from(jsonDecode(r.body)['songs'] ?? []); } } catch (_) {} return []; } Future toggleGlobal(String songId) async { try { final r = await _client .post(Uri.parse('$_base/api/v1/cloud/toggle-global'), headers: _jsonHeader, body: jsonEncode({'song_id': songId})) .timeout(const Duration(seconds: 10)); return r.statusCode == 200; } catch (_) { return false; } } Future share(List songIds) async { try { final r = await _client .post(Uri.parse('$_base/api/v1/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 importCode(String code) async { try { final r = await _client .post(Uri.parse('$_base/api/v1/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> getCorrupted() async { try { final r = await _client .get(Uri.parse('$_base/api/v1/cloud/corrupted'), headers: _authHeader) .timeout(const Duration(seconds: 15)); if (r.statusCode == 200) { final d = jsonDecode(r.body); return List.from(d['corrupted'] ?? []); } } catch (e) { MeloLogger().fehler('cloud_corrupted', e); } return []; } // ─── Hilfsmethoden ─── Future _get(String path) async { try { final r = await _client .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 _post(String path, Map body) async { try { final r = await _client .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; } }