## YT-Quell-URL (Server ist die Quelle) - cloud_service.ytUrlSetzen(): POST /api/v1/cloud/yt-url (per-User UPDATE user_songs.yt_url) - musik_scanner._sucheYtUrls(): durchsucht NUR Songs mit cloud_id, die auf dem Server noch keine yt_url haben; Treffer werden auf den Server hochgeladen statt lokal gespeichert - db_helper: ytUrlAktualisieren/songsOhneYtUrl entfernt, songsMitCloudId() ergänzt - download_service: yt_url wird beim Download nicht mehr lokal persistiert ## Fallback-Kette reDownload (korrupte/fehlende Datei) - 1) Server-Download via cloud_id + Magic-Byte-Check (primäre Quelle) - 2) Server-ytUrl (aus listSongs) → yt-proxy als letzte Chance - 3) Legacy: lokal gespeicherte ytUrl (Bestandsdaten vor v2.52) - song_tile: „Neu laden“ erscheint bei istKorrupt && (cloudId != null || ytUrl != null) - 3 neue Tests (ytUrlSetzen: ok/error/401) → 111 Tests grün, analyze 0 Issues
413 lines
13 KiB
Dart
413 lines
13 KiB
Dart
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<void> 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<bool> 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<String, String> get _authHeader {
|
||
final token = AuthService().token;
|
||
final headers = <String, String>{};
|
||
if (token != null && token.isNotEmpty) {
|
||
headers['Authorization'] = 'Bearer $token';
|
||
}
|
||
return headers;
|
||
}
|
||
|
||
Map<String, String> get _jsonHeader => {
|
||
..._authHeader,
|
||
'Content-Type': 'application/json',
|
||
};
|
||
|
||
/// Server-Status abrufen (Song-Zähler etc.)
|
||
Future<Map?> statusDaten() => _get('/api/v1/cloud/status');
|
||
|
||
Future<Map?> syncStatus() => _get('/api/v1/cloud/sync-status');
|
||
|
||
Future<Map?> syncAll() => _post('/api/v1/cloud/sync-all', {});
|
||
|
||
Future<List<Map>> listSongs() async {
|
||
final r = await _get('/api/v1/cloud/list');
|
||
return List<Map>.from(r?['songs'] ?? []);
|
||
}
|
||
|
||
Future<String?> 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<bool> 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<bool> 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<List<Map>> 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<Map>.from(d['changes'] ?? []);
|
||
}
|
||
} catch (_) {}
|
||
return [];
|
||
}
|
||
|
||
// ─── 📋 Playlisten ───
|
||
|
||
/// Alle Playlisten des Users auf dem Server
|
||
Future<List<Map>> getPlaylists() async {
|
||
final r = await _get('/api/v1/cloud/playlists');
|
||
return List<Map>.from(r?['playlists'] ?? []);
|
||
}
|
||
|
||
/// Playlist erstellen
|
||
Future<Map?> createPlaylist(String name) async {
|
||
final r = await _post('/api/v1/cloud/playlists', {'name': name});
|
||
return r?['playlist'];
|
||
}
|
||
|
||
/// Playlist löschen
|
||
Future<bool> deletePlaylist(int id) async {
|
||
final r = await _post('/api/v1/cloud/playlists', {'id': id});
|
||
return r?['status'] == 'ok';
|
||
}
|
||
|
||
/// Playlist-Details mit Songs abrufen
|
||
Future<Map?> getPlaylist(int id) async {
|
||
return await _get('/api/v1/cloud/playlists/$id');
|
||
}
|
||
|
||
/// Songs zu Playlist hinzufügen
|
||
Future<int> addSongsToPlaylist(int playlistId, List<String> songIds) async {
|
||
final r = await _post(
|
||
'/api/v1/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 _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<bool> updatePlaylistPositions(
|
||
int playlistId, List<Map<String, dynamic>> 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<List<Map>> getFavorites() async {
|
||
final r = await _get('/api/v1/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/v1/cloud/favorites', {'song_ids': songIds});
|
||
return r?['status'] == 'ok';
|
||
}
|
||
|
||
/// Einzelnen Favoriten toggeln
|
||
Future<Map?> 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<bool> 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';
|
||
}
|
||
|
||
// ─── 🎬 YouTube-Quell-URL (Server-Fallback) ───
|
||
|
||
/// YT-Quell-URL eines Songs auf dem Server setzen (F2: Der Server ist die
|
||
/// Quelle für YT-Links — die App speichert `yt_url` nicht mehr lokal,
|
||
/// sondern lädt gefundene Treffer per Endpoint hoch).
|
||
Future<bool> ytUrlSetzen(String songId, String ytUrl) async {
|
||
final r = await _post('/api/v1/cloud/yt-url', {
|
||
'song_id': songId,
|
||
'yt_url': ytUrl,
|
||
});
|
||
return r?['status'] == 'ok';
|
||
}
|
||
|
||
// ─── ✏️ 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/v1/cloud/rename', body);
|
||
}
|
||
|
||
// ─── 🕐 Verlauf ───
|
||
|
||
/// Wiedergabe-Verlauf vom Server abrufen
|
||
Future<List<Map>> getHistory({int limit = 50}) async {
|
||
final r = await _get('/api/v1/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/v1/cloud/history', {'entries': entries});
|
||
return r?['added'] ?? 0;
|
||
}
|
||
|
||
// ─── 🌐 Global / Shared ───
|
||
|
||
Future<List<Map>> 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<Map>.from(jsonDecode(r.body)['songs'] ?? []);
|
||
}
|
||
} catch (_) {}
|
||
return [];
|
||
}
|
||
|
||
Future<bool> 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<String?> share(List<String> 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<Map?> 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<List<Map>> 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<Map>.from(d['corrupted'] ?? []);
|
||
}
|
||
} catch (e) {
|
||
MeloLogger().fehler('cloud_corrupted', e);
|
||
}
|
||
return [];
|
||
}
|
||
|
||
// ─── Hilfsmethoden ───
|
||
|
||
Future<Map?> _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<Map?> _post(String path, Map<String, dynamic> 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;
|
||
}
|
||
}
|