Auf Wunsch: was vom Handy hochgeladen wird, soll auch in Navidrome auftauchen. Die Subsonic-API kennt keinen Upload — also erledigt es die Melo-Cloud selbst. Server (/home/dustin/scripts/melo_cloud.py, nicht versioniert — Backup als melo_cloud.py.bak-20260821-084506): - verknuepfe_navidrome(): Hardlink der Registry-Datei nach /home/dustin/navidrome/music (Bind-Mount des Containers). Gleiche Partition -> kein zusaetzlicher Speicher. Fallback: Kopie. - rescan_navidrome(): Subsonic startScan.view ueber Navidromes AuthProxyHeader (TrustedSources 127.0.0.1) — kein Passwort im Code. Nur zur Beschleunigung, der Watcher findet neue Dateien ohnehin. - upload() verknuepft neue UND bereits bekannte Titel (heilt Bestand). - handle_delete() entfernt die Datei aus Registry + Navidrome, sobald kein Konto sie mehr aktiv hat. Der Registry-EINTRAG bleibt stehen: handle_list haengt die Grabsteine daran (JOIN registry) — ohne ihn erfuehren die anderen Geraete nie von der Loeschung (Zombie-Song). - registry_pfad(): gemeinsame Dateisuche ueber alle Audio-Endungen, mit Rueckfall auf den Navidrome-Ordner. Nebenbei repariert: - Alle 325 Cloud-Dateien lagen nur noch im Navidrome-Ordner, REG war leer: jeder Download antwortete "File missing". Per Hardlink zurueckverknuepft (scripts/melo_cloud_migriere_registry.py). - upload() legte jede Datei als ".mp3" ab, auch m4a/flac. Jetzt echte Endung; _download liefert passenden Content-Type und einen Dateinamen ohne doppelte Endung. App: - MeloCloudService.herunterladen() gibt die geschriebene Datei zurueck und leitet die Endung aus dem Content-Type ab (endungFuer) — sonst landet eine M4A als .mp3 auf dem Handy und Android ordnet sie falsch ein. - loeschBremseGreift(): der Abgleich reicht keine Loeschwelle mehr zum Server durch (>10 Titel UND >1/3 des Serverbestands). Ohne die Bremse haette eine nicht eingehaengte Speicherkarte die Sammlung auf allen Geraeten geloescht. 276 Tests gruen (1 uebersprungen), flutter analyze ohne Befund, plus ein Durchlauf gegen den echten Server (scripts/test_melo_cloud_navidrome.py): Upload -> Registry + Navidrome (ein Hardlink) -> Navidrome liest ein -> Loeschen entfernt beides, Grabstein bleibt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpPu4nuKjKKeX1RpdDeX81
306 lines
10 KiB
Dart
306 lines
10 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.
|
|
@visibleForTesting
|
|
static List<String> parseFavoriten(String body) {
|
|
final daten = jsonDecode(body) as Map<String, dynamic>;
|
|
final liste = daten['favorites'] as List? ?? const [];
|
|
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);
|
|
}
|
|
|
|
/// 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})');
|
|
}
|
|
}
|
|
}
|