Files
Melo/lib/services/acr_cloud.dart
T
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

155 lines
5.0 KiB
Dart

import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'baka_auth.dart';
/// Ein von ACRCloud erkanntes Stück.
class AcrTreffer {
const AcrTreffer({
required this.titel,
required this.kuenstler,
required this.album,
});
final String titel;
final String kuenstler;
final String album;
}
/// Fehler, den die Erkennung selbst meldet (falscher Schlüssel, Limit
/// erreicht, kein Netz …) — mit einem Text, der dem Nutzer gezeigt werden darf.
class AcrCloudException implements Exception {
const AcrCloudException(this.nachricht);
final String nachricht;
@override
String toString() => nachricht;
}
/// Musikerkennung über ACRCloud: eine kurze Aufnahme hochladen und den
/// erkannten Titel zurückbekommen.
class AcrCloudService {
AcrCloudService({
required this.accessKey,
required this.secretKey,
this.host = standardHost,
http.Client? client,
}) : _client = client ?? http.Client();
static const standardHost = 'identify-eu-west-1.acrcloud.com';
final String accessKey;
final String secretKey;
final String host;
final http.Client _client;
/// HMAC-SHA1 über die von ACRCloud vorgeschriebene Zeichenkette,
/// base64-codiert. Muss zum mitgeschickten [timestamp] passen.
static String signatur({
required String accessKey,
required String secretKey,
required int timestamp,
}) {
final zeichenkette = 'POST\n/v1/identify\n$accessKey\naudio\n1\n$timestamp';
final hmac = Hmac(sha1, utf8.encode(secretKey));
return base64.encode(hmac.convert(utf8.encode(zeichenkette)).bytes);
}
/// Wertet die Antwort aus: Treffer, `null` (nichts erkannt) oder Ausnahme.
static AcrTreffer? parseAntwort(String body) {
final Map<String, dynamic> daten;
try {
daten = jsonDecode(body) as Map<String, dynamic>;
} catch (_) {
throw const AcrCloudException('Musikerkennung antwortet unverständlich');
}
final status = daten['status'] as Map<String, dynamic>?;
final code = status?['code'] as int?;
// 1001 = "No result" — das ist kein Fehler, nur kein Treffer.
if (code == 1001) return null;
if (code != 0) {
throw AcrCloudException(
status?['msg'] as String? ?? 'Musikerkennung fehlgeschlagen ($code)');
}
final metadaten = daten['metadata'] as Map<String, dynamic>?;
final musik = metadaten?['music'] as List<dynamic>?;
if (musik == null || musik.isEmpty) return null;
final erster = musik.first as Map<String, dynamic>;
final kuenstler = erster['artists'] as List<dynamic>?;
return AcrTreffer(
titel: erster['title'] as String? ?? '',
kuenstler: kuenstler == null || kuenstler.isEmpty
? ''
: (kuenstler.first as Map<String, dynamic>)['name'] as String? ?? '',
album: (erster['album'] as Map<String, dynamic>?)?['name'] as String? ??
'',
);
}
/// Schickt die Aufnahme [sample] (WAV) zur Erkennung.
/// Gibt bei Erfolg den Treffer zurück, `null` wenn nichts erkannt wurde.
Future<AcrTreffer?> erkenne(List<int> sample) async {
final timestamp = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final anfrage =
http.MultipartRequest('POST', Uri.https(host, '/v1/identify'))
..fields['access_key'] = accessKey
..fields['data_type'] = 'audio'
..fields['signature_version'] = '1'
..fields['timestamp'] = '$timestamp'
..fields['signature'] = signatur(
accessKey: accessKey, secretKey: secretKey, timestamp: timestamp)
..fields['sample_bytes'] = '${sample.length}'
..files.add(http.MultipartFile.fromBytes('sample', sample,
filename: 'sample.wav'));
final http.Response antwort;
try {
final gestreamt =
await _client.send(anfrage).timeout(const Duration(seconds: 30));
antwort = await http.Response.fromStream(gestreamt);
} catch (e) {
debugPrint('Musikerkennung nicht erreichbar: $e');
throw const AcrCloudException('Keine Verbindung zur Musikerkennung');
}
return parseAntwort(antwort.body);
}
}
/// Die ACRCloud-Zugangsdaten. Sie stehen nirgends im Code — der Nutzer gibt
/// sie einmalig ein, danach liegen sie im verschlüsselten Gerätespeicher.
class AcrZugang {
AcrZugang({TokenSpeicher? speicher})
: _speicher = speicher ?? const SicherenSpeicher();
static const _accessKeyKey = 'acr_access_key';
static const _secretKeyKey = 'acr_secret_key';
final TokenSpeicher _speicher;
String? accessKey;
String? secretKey;
bool get istKonfiguriert =>
(accessKey?.isNotEmpty ?? false) && (secretKey?.isNotEmpty ?? false);
Future<void> laden() async {
accessKey = await _speicher.lesen(_accessKeyKey);
secretKey = await _speicher.lesen(_secretKeyKey);
}
Future<void> speichern(String accessKey, String secretKey) async {
this.accessKey = accessKey;
this.secretKey = secretKey;
await _speicher.schreiben(_accessKeyKey, accessKey);
await _speicher.schreiben(_secretKeyKey, secretKey);
}
}