Files
Melo/lib/services/melo_cloud_service.dart
T

335 lines
11 KiB
Dart

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<String, dynamic> 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<String, dynamic> 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<CloudSong> parseListe(String body) {
final daten = jsonDecode(body) as Map<String, dynamic>;
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<String, dynamic>),
];
}
/// Liest die Song-ID aus der Antwort auf einen Upload.
@visibleForTesting
static String? parseUpload(String body) {
final daten = jsonDecode(body) as Map<String, dynamic>;
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<String> parseFavoriten(String body) {
final daten = jsonDecode(body) as Map<String, dynamic>;
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<String, dynamic>)['id'] as String,
];
}
Map<String, String> 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<bool> 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<List<CloudSong>> 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<String?> 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].<Endung>` 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<File?> 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<void> 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<List<String>> 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<void> 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);
}
/// Ersetzt die Favoriten am Server durch [cloudIds].
Future<void> setzeFavoriten(List<String> 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<void> meldeVerlauf(List<CloudVerlauf> 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})');
}
}
}