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:
co-authored by
Claude Opus 5
parent
90afde1d71
commit
9fa027fca1
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user