Files
Melo/lib/services/musicbrainz_service.dart
Hermes (Server)andClaude Opus 5 9fa027fca1 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
2026-08-21 08:32:32 +02:00

109 lines
3.5 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
/// Ein Metadaten-Vorschlag aus der MusicBrainz-Datenbank.
class MbVorschlag {
const MbVorschlag({
required this.titel,
required this.kuenstler,
required this.album,
required this.releaseMbid,
required this.score,
});
final String titel;
final String kuenstler;
/// Titel der ersten Veröffentlichung — leer, wenn MusicBrainz keine kennt.
final String album;
/// Kennung der Veröffentlichung, mit der sich das Cover holen lässt.
final String releaseMbid;
/// Wie gut der Treffer zur Anfrage passt (0100).
final int score;
}
/// MusicBrainz verlangt eine erkennbare Kennung; anonyme Anfragen werden
/// gesperrt.
const _userAgent = 'Melo/1.0 (https://baka-net.de)';
/// Schlägt Titel, Künstler und Album eines Stücks online bei MusicBrainz nach.
class MusicBrainzService {
MusicBrainzService({http.Client? client}) : _client = client ?? http.Client();
final http.Client _client;
/// Liest die Vorschläge aus einer MusicBrainz-Antwort. Was sich nicht lesen
/// lässt, ergibt eine leere Liste — die Suche ist nur eine Hilfe.
static List<MbVorschlag> parseAntwort(String body) {
final Object? daten;
try {
daten = jsonDecode(body);
} catch (_) {
return const [];
}
if (daten is! Map<String, dynamic>) return const [];
final aufnahmen = daten['recordings'];
if (aufnahmen is! List) return const [];
final vorschlaege = <MbVorschlag>[];
for (final eintrag in aufnahmen) {
if (eintrag is! Map<String, dynamic>) continue;
final credits = eintrag['artist-credit'];
final erstesCredit = credits is List && credits.isNotEmpty
? credits.first as Map<String, dynamic>?
: null;
final releases = eintrag['releases'];
final erstesRelease = releases is List && releases.isNotEmpty
? releases.first as Map<String, dynamic>?
: null;
vorschlaege.add(MbVorschlag(
titel: eintrag['title'] as String? ?? '',
kuenstler: erstesCredit?['name'] as String? ?? '',
album: erstesRelease?['title'] as String? ?? '',
releaseMbid: erstesRelease?['id'] as String? ?? '',
score: eintrag['score'] as int? ?? 0,
));
}
return vorschlaege;
}
/// Adresse des Frontcovers einer Veröffentlichung im Cover Art Archive.
String coverUrl(String releaseMbid) =>
'https://coverartarchive.org/release/$releaseMbid/front-250';
/// Sucht zu [titel] (und wenn bekannt [kuenstler]) passende Aufnahmen.
Future<List<MbVorschlag>> suche({
required String titel,
String? kuenstler,
}) async {
final teile = ['recording:"${_maskiere(titel)}"'];
final name = kuenstler?.trim() ?? '';
if (name.isNotEmpty) teile.add('artist:"${_maskiere(name)}"');
final ziel = Uri.https('musicbrainz.org', '/ws/2/recording', {
'query': teile.join(' AND '),
'fmt': 'json',
'limit': '5',
});
final antwort = await _client
.get(ziel, headers: const {'User-Agent': _userAgent})
.timeout(const Duration(seconds: 15));
if (antwort.statusCode != 200) {
debugPrint('MusicBrainz antwortet mit ${antwort.statusCode}');
throw Exception('MusicBrainz antwortet mit ${antwort.statusCode}');
}
return parseAntwort(utf8.decode(antwort.bodyBytes));
}
/// Anführungszeichen und Backslashes würden die Lucene-Abfrage zerlegen.
static String _maskiere(String text) =>
text.replaceAll('\\', r'\\').replaceAll('"', r'\"');
}