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:
Hermes (Server)
2026-08-21 08:32:32 +02:00
co-authored by Claude Opus 5
parent 90afde1d71
commit 9fa027fca1
43 changed files with 3480 additions and 96 deletions
+154
View File
@@ -0,0 +1,154 @@
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);
}
}
+40 -1
View File
@@ -14,17 +14,21 @@ class MediaStore {
final MethodChannel channel;
/// Verschiebt [quellPfad] nach `Music/Melo` und meldet die Datei dem
/// MediaStore. Gibt den neuen Pfad zurück, oder `null` wenn es nicht klappt.
/// MediaStore. [volume] wählt den Datenträger (siehe [speicherOrte]);
/// ohne Angabe nimmt Android den internen Speicher.
/// Gibt den neuen Pfad zurück, oder `null` wenn es nicht klappt.
Future<String?> veroeffentliche({
required String quellPfad,
required String titel,
String? kuenstler,
String? volume,
}) async {
try {
return await channel.invokeMethod<String>('publishAudio', {
'sourcePath': quellPfad,
'title': titel,
'artist': kuenstler,
'volume': volume,
});
} on PlatformException catch (e) {
debugPrint('MediaStore-Eintrag fehlgeschlagen: ${e.message}');
@@ -34,4 +38,39 @@ class MediaStore {
return null;
}
}
/// Alle Datenträger, auf denen Musik landen kann — interner Speicher und,
/// falls eingelegt, SD-Karten.
Future<List<SpeicherOrt>> speicherOrte() async {
try {
final roh =
await channel.invokeListMethod<Map<Object?, Object?>>('listVolumes');
if (roh == null || roh.isEmpty) return const [_intern];
return [
for (final eintrag in roh)
SpeicherOrt(
name: eintrag['name'] as String,
beschreibung: eintrag['beschreibung'] as String,
),
];
} on PlatformException catch (e) {
debugPrint('Speicherorte nicht lesbar: ${e.message}');
return const [_intern];
} on MissingPluginException {
// Desktop/Tests: dort gibt es nur den einen Ordner.
return const [_intern];
}
}
}
/// Ein Datenträger für heruntergeladene Musik. [name] ist der MediaStore-Name
/// des Datenträgers, [beschreibung] das, was der Nutzer liest.
class SpeicherOrt {
const SpeicherOrt({required this.name, required this.beschreibung});
final String name;
final String beschreibung;
}
const _intern =
SpeicherOrt(name: 'external_primary', beschreibung: 'Interner Speicher');
+274
View File
@@ -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})');
}
}
}
+108
View File
@@ -0,0 +1,108 @@
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'\"');
}
+47 -27
View File
@@ -351,36 +351,56 @@ class NavidromeService {
}
}
Future<Uri?> streamAndCacheToLocal(String songId, CacheManager cache) async {
/// Nur eine vollständige Audio-Antwort darf in den Cache. Subsonic meldet
/// Fehler mit HTTP 200 und einem JSON-Rumpf — landet der als ".mp3" im
/// Cache, ist der Titel dauerhaft unabspielbar, weil jeder weitere Versuch
/// den Cache-Treffer nimmt.
@visibleForTesting
static bool istAudioAntwort(int statusCode, String? contentType) {
if (statusCode != 200) return false;
final typ = contentType?.toLowerCase() ?? '';
return typ.startsWith('audio/') || typ.startsWith('application/octet-stream');
}
/// Schlüssel, unter dem ein Server-Titel im Cache liegt.
///
/// Bewusst die Song-ID und nicht die Stream-Adresse: die trägt Token und
/// Salt, und beides wird bei jedem App-Start neu gewürfelt. Als Schlüssel
/// hätte damit derselbe Titel jedes Mal einen anderen — der Cache wäre nie
/// wieder auffindbar und würde nur wachsen.
static String cacheSchluessel(String songId) => 'navidrome:$songId';
/// Legt den Titel [songId] vollständig im Cache ab, damit er später ohne
/// Netz läuft. Läuft im Hintergrund — die Wiedergabe wartet nie darauf.
///
/// Geschrieben wird zuerst in eine `.part`-Datei; erst der vollständige,
/// als Audio bestätigte Download wird umbenannt. Ein Abbruch hinterlässt
/// damit keine halbe Datei, die als gültiger Cache-Treffer gälte.
Future<File?> ladeInCache(String songId, Uri streamUri, CacheManager cache) async {
final ziel = await cache.getCacheFile(cacheSchluessel(songId));
if (await ziel.exists()) return ziel;
final teil = File('${ziel.path}.part');
final klient = http.Client();
try {
final streamUri = streamUrl(songId);
final cacheFile = await cache.getCacheFile(streamUri.toString());
if (await cacheFile.exists()) {
debugPrint('Cache-Hit: ${cacheFile.path}');
return Uri.file(cacheFile.path);
final antwort = await klient
.send(http.Request('GET', streamUri))
.timeout(const Duration(seconds: 30));
if (!istAudioAntwort(antwort.statusCode, antwort.headers['content-type'])) {
await logger.error('Cache abgebrochen: HTTP ${antwort.statusCode}, '
'Typ ${antwort.headers['content-type'] ?? '-'}');
return null;
}
debugPrint('Cache-Miss: Starten download zu ${cacheFile.path}');
final request = http.Request('GET', streamUri);
final response = await http.Client().send(request).timeout(const Duration(seconds: 30));
if (response.statusCode != 200) {
await logger.error('Stream-Fehler: HTTP ${response.statusCode}');
return streamUri;
}
final sink = cacheFile.openWrite();
await for (final chunk in response.stream) {
sink.add(chunk);
}
await sink.close();
debugPrint('Cache-Speicherung erfolgreich: ${cacheFile.path}');
return Uri.file(cacheFile.path);
await antwort.stream.pipe(teil.openWrite());
await teil.rename(ziel.path);
debugPrint('Im Cache abgelegt: ${ziel.path}');
return ziel;
} catch (e) {
await logger.error('streamAndCacheToLocal Fehler: $e', e, StackTrace.current);
return streamUrl(songId);
await logger.error('Cache-Download fehlgeschlagen: $e', e, StackTrace.current);
if (await teil.exists()) await teil.delete();
return null;
} finally {
klient.close();
}
}
+350
View File
@@ -0,0 +1,350 @@
import 'dart:io';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
import '../library/database.dart';
import 'media_store.dart';
import 'melo_cloud_service.dart';
/// Was beim Abgleich mit welchen Titeln zu tun ist.
///
/// Ein Titel gilt über [Song.cloudId] als „am Server bekannt". Alles andere
/// folgt daraus: kennt der Server ihn nicht, geht er hoch; kennt das Gerät
/// einen Server-Titel nicht, kommt er herunter; hat der Server einen Grabstein
/// gesetzt, verschwindet er auch hier.
class SyncPlan {
const SyncPlan({
required this.herunterladen,
required this.hochladen,
required this.lokalLoeschen,
required this.serverLoeschen,
});
/// Titel, die es nur am Server gibt.
final List<CloudSong> herunterladen;
/// Titel, die es nur auf dem Gerät gibt.
final List<Song> hochladen;
/// Titel, die der Server als gelöscht meldet.
final List<Song> lokalLoeschen;
/// Titel, die hier gelöscht wurden und deren Grabstein der Server noch
/// nicht kennt — sonst tauchen sie auf den anderen Geräten weiter auf.
final List<Song> serverLoeschen;
bool get istLeer =>
herunterladen.isEmpty &&
hochladen.isEmpty &&
lokalLoeschen.isEmpty &&
serverLoeschen.isEmpty;
int get gesamt =>
herunterladen.length +
hochladen.length +
lokalLoeschen.length +
serverLoeschen.length;
}
/// Stellt Gerät und Server gegenüber. Reine Funktion — der eigentliche
/// Abgleich in [SyncService] führt nur noch aus, was hier entschieden wurde.
SyncPlan planeSync({
required List<Song> lokal,
required List<CloudSong> server,
}) {
final serverNachId = {for (final s in server) s.id: s};
final bekannteCloudIds = <String>{};
final hochladen = <Song>[];
final lokalLoeschen = <Song>[];
final serverLoeschen = <Song>[];
for (final song in lokal) {
final cloudId = song.cloudId;
if (cloudId != null) bekannteCloudIds.add(cloudId);
if (song.deleted) {
// Hier gelöscht: der Server muss den Grabstein bekommen, sonst laden
// ihn die anderen Geräte weiter herunter.
final amServer = cloudId == null ? null : serverNachId[cloudId];
if (amServer != null && !amServer.geloescht) serverLoeschen.add(song);
continue;
}
if (cloudId == null) {
hochladen.add(song);
continue;
}
final amServer = serverNachId[cloudId];
if (amServer == null) {
// Der Server kennt die Verknüpfung nicht mehr (z. B. Konto gewechselt).
// Erneut hochladen ist sicherer als den Titel stillschweigend zu verlieren.
hochladen.add(song);
} else if (amServer.geloescht) {
lokalLoeschen.add(song);
}
}
final herunterladen = [
for (final s in server)
if (!s.geloescht && !bekannteCloudIds.contains(s.id)) s,
];
return SyncPlan(
herunterladen: herunterladen,
hochladen: hochladen,
lokalLoeschen: lokalLoeschen,
serverLoeschen: serverLoeschen,
);
}
/// Wie oft höchstens automatisch abgeglichen wird. Ein Abgleich beim
/// Zurückkehren in die App darf nicht bei jedem Tab-Wechsel losrennen.
const autoSyncAbstand = Duration(minutes: 15);
/// Ob jetzt automatisch abgeglichen werden soll.
bool sollAutoSync(DateTime? letzterLauf, DateTime jetzt) =>
letzterLauf == null || jetzt.difference(letzterLauf) >= autoSyncAbstand;
/// Hält Gerät und Server auf demselben Stand: lädt neue Server-Titel herunter,
/// bringt eigene Dateien hoch, zieht Löschungen nach und meldet Favoriten und
/// Wiedergaben. Alle Geräte am selben Konto sehen dadurch dasselbe.
class SyncService extends ChangeNotifier {
SyncService({
required this.db,
required this.cloud,
this.mediaStore = const MediaStore(),
Future<Directory> Function()? musikOrdner,
}) : _musikOrdner = musikOrdner ?? _standardMusikOrdner;
static const _letzterLaufKey = 'cloud_sync_letzter_lauf';
static const _verlaufStandKey = 'cloud_sync_verlauf_stand';
static const _uuid = Uuid();
final MeloDb db;
final MeloCloudService cloud;
final MediaStore mediaStore;
final Future<Directory> Function() _musikOrdner;
bool _laeuft = false;
int _erledigt = 0;
int _gesamt = 0;
String? _fehler;
String? _status;
DateTime? _letzterLauf;
bool get laeuft => _laeuft;
int get erledigt => _erledigt;
int get gesamt => _gesamt;
String? get fehler => _fehler;
String? get status => _status;
DateTime? get letzterLauf => _letzterLauf;
Future<void> laden() async {
final prefs = await SharedPreferences.getInstance();
final ms = prefs.getInt(_letzterLaufKey);
if (ms != null) _letzterLauf = DateTime.fromMillisecondsSinceEpoch(ms);
notifyListeners();
}
/// Gleicht ab, wenn seit dem letzten Lauf genug Zeit vergangen ist.
/// Für den App-Start und die Rückkehr in die App.
Future<void> automatisch() async {
if (_laeuft || !cloud.istAngemeldet) return;
if (!sollAutoSync(_letzterLauf, DateTime.now())) return;
await synchronisiere();
}
/// Vollständiger Abgleich. Ein Fehler in einem Schritt bricht den ganzen
/// Lauf nicht ab — was geht, wird erledigt, der Rest beim nächsten Mal.
Future<void> synchronisiere() async {
if (_laeuft) return;
if (!cloud.istAngemeldet) {
_fehler = 'Bitte zuerst beim Baka-Konto anmelden';
notifyListeners();
return;
}
_laeuft = true;
_fehler = null;
_erledigt = 0;
_gesamt = 0;
_melde('Vergleiche mit dem Server …');
try {
final plan = planeSync(
lokal: await db.allSongs(),
server: await cloud.liste(),
);
_gesamt = plan.gesamt;
notifyListeners();
await _ziehLoeschungenNach(plan.lokalLoeschen);
await _meldeLoeschungen(plan.serverLoeschen);
await _ladeHerunter(plan.herunterladen);
await _ladeHoch(plan.hochladen);
await _gleicheFavoritenAb();
await _meldeVerlauf();
_letzterLauf = DateTime.now();
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_letzterLaufKey, _letzterLauf!.millisecondsSinceEpoch);
} on CloudException catch (e) {
_fehler = e.message;
} catch (e) {
debugPrint('Sync fehlgeschlagen: $e');
_fehler = 'Abgleich fehlgeschlagen: $e';
} finally {
_laeuft = false;
_status = null;
notifyListeners();
}
}
Future<void> _ziehLoeschungenNach(List<Song> songs) async {
if (songs.isEmpty) return;
_melde('Entferne ${songs.length} am Server gelöschte Titel …');
await db.tombstoneByCloudIds([for (final s in songs) s.cloudId!]);
_erledigt += songs.length;
notifyListeners();
}
Future<void> _meldeLoeschungen(List<Song> songs) async {
for (final song in songs) {
_melde('Melde Löschung von „${song.title}“ …');
try {
await cloud.loeschen(song.cloudId!);
} on CloudException catch (e) {
// Eine abgelehnte Löschung darf den Lauf nicht beenden.
debugPrint('Löschung „${song.title}“ übersprungen: ${e.message}');
}
_erledigt++;
notifyListeners();
}
}
Future<void> _ladeHerunter(List<CloudSong> songs) async {
if (songs.isEmpty) return;
final ordner = await _musikOrdner();
for (final cloudSong in songs) {
_melde('Lade „${cloudSong.titel}“ …');
final datei = File(p.join(
ordner.path,
'${_sichererDateiname(cloudSong.titel)}-${cloudSong.id}.mp3',
));
if (!await cloud.herunterladen(cloudSong.id, datei)) {
_erledigt++;
continue;
}
// In den öffentlichen Musikordner eintragen: sonst kennt der
// MediaStore die Datei nicht und der nächste Scan tombstoned sie.
final pfad = await mediaStore.veroeffentliche(
quellPfad: datei.path,
titel: cloudSong.titel,
kuenstler: cloudSong.kuenstler,
) ??
datei.path;
final now = DateTime.now().millisecondsSinceEpoch;
await db.upsertSongs([
SongsCompanion.insert(
id: _uuid.v4(),
path: pfad,
title: cloudSong.titel,
artist: Value(cloudSong.kuenstler.isEmpty ? null : cloudSong.kuenstler),
durationMs: Value(cloudSong.dauerSekunden > 0
? cloudSong.dauerSekunden * 1000
: null),
dateAddedMs: now,
updatedAtMs: now,
cloudId: Value(cloudSong.id),
),
]);
_erledigt++;
notifyListeners();
}
}
Future<void> _ladeHoch(List<Song> songs) async {
for (final song in songs) {
final datei = File(song.path);
if (!await datei.exists()) {
_erledigt++;
continue;
}
_melde('Sende „${song.title}“ …');
try {
final cloudId = await cloud.hochladen(
datei,
dateiname: '${_sichererDateiname(song.title)}${p.extension(song.path)}',
);
if (cloudId != null) await db.setCloudId(song.id, cloudId);
} on CloudException catch (e) {
// Eine zu große oder abgelehnte Datei darf den Lauf nicht beenden.
debugPrint('Upload „${song.title}“ übersprungen: ${e.message}');
}
_erledigt++;
notifyListeners();
}
}
Future<void> _gleicheFavoritenAb() async {
_melde('Gleiche Favoriten ab …');
final lokal = await db.allSongs();
final cloudIdVon = {
for (final s in lokal)
if (s.cloudId != null) s.id: s.cloudId!,
};
final favoritenIds = await db.favoriteSongIds();
final cloudFavoriten = [
for (final id in favoritenIds)
if (cloudIdVon[id] != null) cloudIdVon[id]!,
];
await cloud.setzeFavoriten(cloudFavoriten);
}
Future<void> _meldeVerlauf() async {
final prefs = await SharedPreferences.getInstance();
final stand = prefs.getInt(_verlaufStandKey) ?? 0;
final eintraege = await db.historySince(stand);
if (eintraege.isEmpty) return;
_melde('Melde ${eintraege.length} Wiedergaben …');
final cloudIdVon = {
for (final s in await db.allSongs())
if (s.cloudId != null) s.id: s.cloudId!,
};
final zuMelden = [
for (final e in eintraege)
if (cloudIdVon[e.songId] != null)
CloudVerlauf(
cloudId: cloudIdVon[e.songId]!,
gespieltAm: DateTime.fromMillisecondsSinceEpoch(e.playedAtMs),
positionSekunden: e.positionMs ~/ 1000,
),
];
await cloud.meldeVerlauf(zuMelden);
await prefs.setInt(_verlaufStandKey, eintraege.first.playedAtMs);
}
void _melde(String text) {
_status = text;
notifyListeners();
}
}
String _sichererDateiname(String titel) =>
titel.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim();
Future<Directory> _standardMusikOrdner() async {
// Zwischenablage für den Download; danach wandert die Datei über den
// MediaStore in den öffentlichen Musikordner.
final dir = Directory(p.join(Directory.systemTemp.path, 'melo_cloud_dl'));
await dir.create(recursive: true);
return dir;
}
+4 -2
View File
@@ -59,9 +59,11 @@ class YtDownloadService extends ChangeNotifier {
}
/// Lädt [url] herunter und legt die MP3 in [zielOrdner] ab.
/// Mit [cookies] nutzt der Proxy seine YouTube-Anmeldung — nötig für
/// altersbeschränkte Videos.
/// Gibt bei Erfolg das Ergebnis zurück, sonst `null` (siehe [fehler]).
Future<YtErgebnis?> herunterladen(String url,
{required String zielOrdner}) async {
{required String zielOrdner, bool cookies = true}) async {
_fehler = null;
_laeuft = true;
notifyListeners();
@@ -83,7 +85,7 @@ class YtDownloadService extends ChangeNotifier {
.post(
Uri.parse('$proxyUrl/api/yt-dl'),
headers: {...auth.authHeader, 'Content-Type': 'application/json'},
body: jsonEncode({'url': url}),
body: jsonEncode({'url': url, 'cookies': cookies}),
)
.timeout(const Duration(seconds: 180));
} catch (e) {