import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; 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, ); } /// Eine Playlist, wie sie der Server kennt. Mehr als Name und ID braucht die /// einseitige Sicherung nicht. class CloudPlaylist { const CloudPlaylist({required this.id, required this.name}); final String id; final String name; } /// 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. Die /// Melo-Cloud hängt hochgeladene Titel aber serverseitig in die /// Navidrome-Bibliothek ein — sie tauchen dort und im Web also trotzdem auf. 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. /// /// Ein fehlender `favorites`-Schlüssel ist ein **Fehler, keine leere /// Menge**: der Router verdrahtet für `GET /favorites` hart HTTP 200, und /// mehrere Handler desselben Servers melden Fehler im 200er-Körper. Eine /// fälschlich leere Antwort wäre sonst von einer echten nicht zu /// unterscheiden — genau wie bei [parseListe] und [parseUpload] wird /// deshalb geworfen. @visibleForTesting static List parseFavoriten(String body) { final daten = jsonDecode(body) as Map; final fehler = daten['error'] as String?; if (fehler != null) throw CloudException(fehler); final liste = daten['favorites']; if (liste is! List) { throw CloudException('Antwort ohne Favoritenliste'); } 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); } /// Dateiendung zum Inhaltstyp des Servers. Ohne bekannte Zuordnung `.mp3`. /// /// Nötig, weil die Bibliothek nicht nur MP3 enthält: eine als `.mp3` /// abgelegte M4A bekäme vom Android-MediaStore den falschen Typ. @visibleForTesting static String endungFuer(String? contentType) { final typ = (contentType ?? '').toLowerCase().split(';').first.trim(); return const { 'audio/mpeg': '.mp3', 'audio/mp4': '.m4a', 'audio/x-m4a': '.m4a', 'audio/aac': '.aac', 'audio/flac': '.flac', 'audio/x-flac': '.flac', 'audio/ogg': '.ogg', 'audio/opus': '.opus', 'audio/wav': '.wav', 'audio/x-wav': '.wav', }[typ] ?? '.mp3'; } /// Holt den Titel [cloudId] und legt ihn als `[basisName].` in /// [ordner] ab. Gibt die geschriebene Datei zurück, oder `null`. /// /// 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, Directory ordner, String basisName, ) async { _pruefeAnmeldung(); final teil = File(p.join(ordner.path, '$basisName.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 null; } await ordner.create(recursive: true); await antwort.stream.pipe(teil.openWrite()); final ziel = File(p.join(ordner.path, '$basisName${endungFuer(antwort.headers['content-type'])}')); await teil.rename(ziel.path); return ziel; } catch (e) { await logger.error('Cloud-Download $cloudId fehlgeschlagen: $e', e, StackTrace.current); if (await teil.exists()) await teil.delete(); return null; } } /// 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); } /// Setzt einen einzelnen Favoriten am Server — additiv oder entfernend, /// aber immer **deterministisch**. /// /// Bewusst kein Umschalten: hätte der Server einen abweichenden Stand, /// kehrte ein Toggle den Wunsch des Nutzers um. Future setzeFavorit(String cloudId, bool gesetzt) async { _pruefeAnmeldung(); final antwort = await _client .post( Uri.parse('$basisUrl/favorites/toggle'), headers: {..._kopf, 'Content-Type': 'application/json'}, body: jsonEncode({'song_id': cloudId, 'set': gesetzt}), ) .timeout(const Duration(seconds: 30)); _pruefeStatus(antwort); } /// Der Körper einer Playlisten-Antwort, oder `CloudException`. /// /// Der Server meldet Fehler im 200er-Körper unter `error`. Ein bloßes /// `{"status":"not_found"}` **ohne** `error` (so antworten /// `handle_playlist_remove_song` und `handle_playlist_update_positions`) /// geht hier bewusst durch: die Sicherung ist einseitig und /// fire-and-forget, sie verwirft jeden Fehler ohnehin. Map _json(http.Response antwort) { final daten = jsonDecode(antwort.body) as Map; final fehler = daten['error'] as String?; if (fehler != null) throw CloudException(fehler); return daten; } /// Legt eine Playlist am Server an und gibt deren ID zurück. /// /// Die Identität stammt **immer** von hier: `user_playlists.id` ist /// AUTOINCREMENT und damit stabil. Eine Zuordnung über den Namen gibt es /// nicht — sie zerbräche beim ersten Umbenennen. Future legePlaylistAn(String name) async { _pruefeAnmeldung(); final antwort = await _client .post( Uri.parse('$basisUrl/playlists'), headers: {..._kopf, 'Content-Type': 'application/json'}, body: jsonEncode({'name': name}), ) .timeout(const Duration(seconds: 30)); _pruefeStatus(antwort); // handle_playlist_create antwortet {"status":"ok","playlist":{"id":…}} — // die ID liegt eine Ebene tiefer, nicht auf oberster Ebene. final playlist = _json(antwort)['playlist']; if (playlist is! Map) { throw CloudException('Antwort ohne Playlist'); } return '${playlist['id']}'; } Future fuegePlaylistSongsHinzu( String playlistCloudId, List songCloudIds) async { if (songCloudIds.isEmpty) return; _pruefeAnmeldung(); final antwort = await _client .post( Uri.parse('$basisUrl/playlists/$playlistCloudId/songs'), headers: {..._kopf, 'Content-Type': 'application/json'}, body: jsonEncode({'song_ids': songCloudIds}), ) .timeout(const Duration(seconds: 30)); _pruefeStatus(antwort); _json(antwort); } Future entfernePlaylistSong( String playlistCloudId, String songCloudId) async { _pruefeAnmeldung(); final antwort = await _client .delete( Uri.parse('$basisUrl/playlists/$playlistCloudId/songs/$songCloudId'), headers: _kopf, ) .timeout(const Duration(seconds: 30)); _pruefeStatus(antwort); _json(antwort); } /// Schreibt die Reihenfolge einer Playlist am Server fest. /// /// Der Körper heißt `positions` und trägt Paare aus `id` und `position`: /// Der Router liest `d.get('positions',[])`, der Handler greift je Eintrag /// auf beide Schlüssel zu. Eine blanke ID-Liste unter `song_ids` käme als /// leere Liste an — der Server antwortete stumm `{"status":"ok"}` und /// änderte nichts. Future setzePlaylistReihenfolge( String playlistCloudId, List songCloudIds) async { _pruefeAnmeldung(); final antwort = await _client .put( Uri.parse('$basisUrl/playlists/$playlistCloudId/positions'), headers: {..._kopf, 'Content-Type': 'application/json'}, body: jsonEncode({ 'positions': [ for (var i = 0; i < songCloudIds.length; i++) {'id': songCloudIds[i], 'position': i}, ], }), ) .timeout(const Duration(seconds: 30)); _pruefeStatus(antwort); _json(antwort); } Future> playlisten() async { _pruefeAnmeldung(); final antwort = await _client .get(Uri.parse('$basisUrl/playlists'), headers: _kopf) .timeout(const Duration(seconds: 30)); _pruefeStatus(antwort); final liste = _json(antwort)['playlists']; if (liste is! List) throw CloudException('Antwort ohne Playlisten'); return [ for (final j in liste) CloudPlaylist( id: '${(j as Map)['id']}', name: j['name'] as String? ?? 'Ohne Namen', ), ]; } Future> playlistSongs(String playlistCloudId) async { _pruefeAnmeldung(); final antwort = await _client .get(Uri.parse('$basisUrl/playlists/$playlistCloudId'), headers: _kopf) .timeout(const Duration(seconds: 30)); _pruefeStatus(antwort); final liste = _json(antwort)['songs']; if (liste is! List) throw CloudException('Antwort ohne Titel'); return [ for (final j in liste) '${(j as Map)['id']}', ]; } /// 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})'); } } }