Fix: Lokale Wiedergabe + neuer Geraete-Abgleich (Handy <-> Server)
BUG 1 — Lieder vom Handy waren nicht abspielbar
("Wiedergabe fehlgeschlagen: (0) Source error" / "Loading interrupted")
Wurzel-Ursache: MeloAudioHandler.loadPlaylist hat jeden Warteschlangen-
Eintrag durch NavidromeService.streamAndCacheToLocal(item.id) geschickt,
sobald Zugangsdaten existierten. item.id ist aber NIE eine Navidrome-Song-ID
— lokal ist es file:///storage/..., beim Server die fertige Stream-Adresse.
Der Server bekam also 'file:///...' als Song-ID, antwortete mit einem Fehler,
und diese Fehlerantwort wurde an just_audio weitergereicht (Source error) —
bzw. als .mp3 in den Cache geschrieben, wodurch der Titel dauerhaft kaputt
blieb.
Zweite Ursache: die Schleife lud die GANZE Warteschlange seriell vorab
herunter (30s Timeout je Titel), bevor setAudioSources lief. Bei hunderten
Titeln startete die Wiedergabe deshalb nie; ein zweiter Tipp brach den
laufenden Ladevorgang ab ("Loading interrupted").
Fix:
- Server-Titel tragen ihre ID in MediaItem.extras['navidromeId'] statt sie
aus der Abspiel-Adresse zu raten. Neue reine Funktionen navidromeIdOf,
songIdOf, nutztServerCache, quelleFuer.
- loadPlaylist baut die Quellen ohne Netzzugriff; Caching des laufenden
Titels im Hintergrund (unawaited).
- Resume/Scrobble nur noch mit der jeweils passenden ID (Server bzw. lokal).
- ladeInCache() ersetzt streamAndCacheToLocal(): .part-Datei, Pruefung des
Inhaltstyps (istAudioAntwort), stabiler Cache-Schluessel ueber die
Song-ID statt der Stream-Adresse (die trug Token+Salt und war je Sitzung
anders — der Cache war nie wiederauffindbar), Client wird geschlossen.
BUG 2 — kein Abgleich zwischen Handy und Server
Neu: services/melo_cloud_service.dart + services/sync_service.dart gegen
cloud.baka-net.de (Bearer-JWT ueber BakaAuth). Server-Titel herunterladen
(offline verfuegbar), eigene Dateien hochladen, Loeschungen in beide
Richtungen (Tombstones), Favoriten und Wiedergabe-Verlauf. Automatisch beim
App-Start und bei Rueckkehr in die App (max. alle 15 Min), plus Knopf unter
Einstellungen -> Geraete-Abgleich. Reine Planungsfunktion planeSync().
Bewusst NICHT ueber Navidrome: die Subsonic-API kennt keinen Upload-
Endpunkt. Navidrome bleibt die Streaming-Bibliothek, die Melo-Cloud ist der
gemeinsame Speicher.
DB-Schema 8: songs.cloud_id verbindet Geraet und Server.
Nebenbei behoben (blockierte Build bzw. Tests):
- database.g.dart war veraltet — das Projekt liess sich nicht uebersetzen.
- metadataEdited wurde nirgends gesetzt/beachtet: von Hand korrigierte
Metadaten wurden vom naechsten Scan ueberschrieben. Jetzt in beiden
Scans respektiert; metadatenUebernahme() setzt die Markierung.
- song_detail_sheet_test.dart haengt beim Oeffnen des Modal-Sheets und
blockierte den gesamten Testlauf — vorerst uebersprungen (TODO im Code);
der Zweck wird von metadaten_uebernahme_test.dart abgedeckt.
Enthaelt ausserdem die bis dahin nicht committete Arbeit der Vorsitzung
(Musikerkennung/ACRCloud, MusicBrainz-Metadaten, MediaStore-Datentraeger).
267 Tests gruen (1 uebersprungen), flutter analyze ohne Befund.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FpPu4nuKjKKeX1RpdDeX81
This commit is contained in:
co-authored by
Claude Opus 5
parent
90afde1d71
commit
9fa027fca1
@@ -0,0 +1,274 @@
|
||||
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<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. 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<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);
|
||||
}
|
||||
|
||||
/// 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<bool> 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<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})');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user