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
351 lines
11 KiB
Dart
351 lines
11 KiB
Dart
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;
|
|
}
|