import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'baka_auth.dart'; import 'logger_service.dart'; /// Ein Titel, wie ihn die Melo-Cloud kennt. /// /// [geloescht] markiert einen Grabstein: der Server liefert gelöschte Titel /// bewusst weiter mit, damit die App die Löschung nachziehen kann, statt den /// Titel beim nächsten Abgleich wieder hochzuladen. class CloudSong { const CloudSong({ required this.id, required this.titel, this.kuenstler = '', this.dauerSekunden = 0, this.groesse = 0, this.geloescht = false, }); final String id; final String titel; final String kuenstler; final int dauerSekunden; final int groesse; final bool geloescht; factory CloudSong.fromJson(Map j) => CloudSong( id: j['id'] as String, titel: (j['title'] as String?)?.trim().isNotEmpty == true ? j['title'] as String : 'Unbekannt', kuenstler: j['artist'] as String? ?? '', dauerSekunden: (j['duration'] as num?)?.toInt() ?? 0, groesse: (j['size'] as num?)?.toInt() ?? 0, geloescht: j['deleted'] == true, ); } /// Ein Wiedergabe-Ereignis, das zum Server gemeldet wird. class CloudVerlauf { const CloudVerlauf({ required this.cloudId, required this.gespieltAm, this.positionSekunden = 0, }); final String cloudId; final DateTime gespieltAm; final int positionSekunden; Map toJson() => { 'song_id': cloudId, // Der Server erwartet ISO-Zeit ohne Zeitzone (UTC). 'played_at': gespieltAm.toUtc().toIso8601String().split('.').first, 'position': positionSekunden, }; } /// Fehler der Melo-Cloud, den die Oberfläche anzeigen darf. class CloudException implements Exception { CloudException(this.message); final String message; @override String toString() => message; } /// Zugriff auf die Melo-Cloud (`cloud.baka-net.de`) — die Gegenstelle für den /// Geräte-Abgleich: Titel hoch- und herunterladen, Löschungen, Favoriten und /// Wiedergabe-Verlauf. /// /// Bewusst nicht Navidrome: die Subsonic-API kennt keinen Upload-Endpunkt, /// eigene Dateien lassen sich darüber nicht zum Server bringen. Navidrome /// bleibt die Streaming-Bibliothek, die Melo-Cloud ist der Sync-Speicher. class MeloCloudService { MeloCloudService({required this.auth, http.Client? client}) : _client = client ?? http.Client(); static const basisUrl = 'https://cloud.baka-net.de/api/v1/cloud'; /// Der Server nimmt höchstens 50 MB je Datei an. static const maxUploadBytes = 50 * 1024 * 1024; final BakaAuth auth; final http.Client _client; bool get istAngemeldet => auth.istAngemeldet; /// Liest die Titelliste aus einer Server-Antwort — inklusive Grabsteinen. @visibleForTesting static List parseListe(String body) { final daten = jsonDecode(body) as Map; final fehler = daten['error'] as String?; if (fehler != null) throw CloudException(fehler); final liste = daten['songs'] as List? ?? const []; return [ for (final j in liste) CloudSong.fromJson(j as Map), ]; } /// Liest die Song-ID aus der Antwort auf einen Upload. @visibleForTesting static String? parseUpload(String body) { final daten = jsonDecode(body) as Map; final fehler = daten['error'] as String?; if (fehler != null) throw CloudException(fehler); return daten['song_id'] as String?; } /// Liest die Favoriten-IDs aus einer Server-Antwort. @visibleForTesting static List parseFavoriten(String body) { final daten = jsonDecode(body) as Map; final liste = daten['favorites'] as List? ?? const []; return [ for (final j in liste) (j as Map)['id'] as String, ]; } Map get _kopf => { ...auth.authHeader, 'Accept': 'application/json', }; void _pruefeAnmeldung() { if (!auth.istAngemeldet) { throw CloudException('Bitte zuerst beim Baka-Konto anmelden'); } } /// Ob der Server antwortet. Für die Statusanzeige in den Einstellungen. Future erreichbar() async { try { final antwort = await _client .get(Uri.parse('$basisUrl/health')) .timeout(const Duration(seconds: 10)); return antwort.statusCode == 200; } catch (e) { debugPrint('Melo-Cloud nicht erreichbar: $e'); return false; } } /// Alle Titel des angemeldeten Kontos, inklusive Grabsteinen. Future> liste() async { _pruefeAnmeldung(); final antwort = await _client .get(Uri.parse('$basisUrl/list'), headers: _kopf) .timeout(const Duration(seconds: 30)); _pruefeStatus(antwort); return parseListe(antwort.body); } /// Lädt [datei] hoch und gibt die Server-ID zurück. /// /// Der Server erkennt Dubletten selbst über die Prüfsumme und verknüpft /// sie mit dem bestehenden Titel — dieselbe Datei zweimal hochzuladen /// erzeugt also keine zweite Kopie. Future hochladen(File datei, {String? dateiname}) async { _pruefeAnmeldung(); final groesse = await datei.length(); if (groesse > maxUploadBytes) { throw CloudException( 'Datei zu groß (${(groesse / 1024 / 1024).round()} MB, max 50 MB)'); } final anfrage = http.MultipartRequest('POST', Uri.parse('$basisUrl/upload')) ..headers.addAll(_kopf) ..files.add(await http.MultipartFile.fromPath( 'file', datei.path, filename: dateiname ?? datei.uri.pathSegments.last, )); final gestreamt = await _client.send(anfrage).timeout(const Duration(seconds: 120)); final antwort = await http.Response.fromStream(gestreamt); _pruefeStatus(antwort); return parseUpload(antwort.body); } /// Holt den Titel [cloudId] und schreibt ihn nach [ziel]. /// /// Geschrieben wird erst in eine `.part`-Datei; nur der vollständige /// Download wird umbenannt. Ein Abbruch hinterlässt damit keine halbe /// Datei, die der Bibliotheks-Scan für Musik hielte. Future herunterladen(String cloudId, File ziel) async { _pruefeAnmeldung(); final teil = File('${ziel.path}.part'); try { final anfrage = http.Request('GET', Uri.parse('$basisUrl/download/$cloudId')) ..headers.addAll(_kopf); final antwort = await _client.send(anfrage).timeout(const Duration(seconds: 180)); if (antwort.statusCode != 200) { await logger.error('Cloud-Download $cloudId: HTTP ${antwort.statusCode}'); return false; } await ziel.parent.create(recursive: true); await antwort.stream.pipe(teil.openWrite()); await teil.rename(ziel.path); return true; } catch (e) { await logger.error('Cloud-Download $cloudId fehlgeschlagen: $e', e, StackTrace.current); if (await teil.exists()) await teil.delete(); return false; } } /// Meldet die Löschung eines Titels — der Server setzt einen Grabstein, /// damit auch die anderen Geräte ihn entfernen. Future loeschen(String cloudId) async { _pruefeAnmeldung(); final antwort = await _client .post( Uri.parse('$basisUrl/delete'), headers: {..._kopf, 'Content-Type': 'application/json'}, body: jsonEncode({'song_id': cloudId}), ) .timeout(const Duration(seconds: 30)); _pruefeStatus(antwort); } Future> favoriten() async { _pruefeAnmeldung(); final antwort = await _client .get(Uri.parse('$basisUrl/favorites'), headers: _kopf) .timeout(const Duration(seconds: 30)); _pruefeStatus(antwort); return parseFavoriten(antwort.body); } /// Ersetzt die Favoriten am Server durch [cloudIds]. Future setzeFavoriten(List cloudIds) async { _pruefeAnmeldung(); final antwort = await _client .post( Uri.parse('$basisUrl/favorites'), headers: {..._kopf, 'Content-Type': 'application/json'}, body: jsonEncode({'song_ids': cloudIds}), ) .timeout(const Duration(seconds: 30)); _pruefeStatus(antwort); } /// Meldet Wiedergaben. Der Server nimmt höchstens 100 je Aufruf an und /// verwirft Doppelmeldungen desselben Titels innerhalb einer Stunde. Future meldeVerlauf(List eintraege) async { if (eintraege.isEmpty) return; _pruefeAnmeldung(); final antwort = await _client .post( Uri.parse('$basisUrl/history'), headers: {..._kopf, 'Content-Type': 'application/json'}, body: jsonEncode({ 'entries': [for (final e in eintraege.take(100)) e.toJson()], }), ) .timeout(const Duration(seconds: 30)); _pruefeStatus(antwort); } void _pruefeStatus(http.Response antwort) { if (antwort.statusCode == 401) { throw CloudException('Anmeldung abgelaufen — bitte neu anmelden'); } if (antwort.statusCode != 200) { throw CloudException('Server-Fehler (${antwort.statusCode})'); } } }