335 lines
11 KiB
Dart
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})');
|
|
}
|
|
}
|
|
}
|