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
+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;
}