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
155 lines
5.0 KiB
Dart
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);
|
|
}
|
|
}
|