Auf Wunsch: was vom Handy hochgeladen wird, soll auch in Navidrome auftauchen. Die Subsonic-API kennt keinen Upload — also erledigt es die Melo-Cloud selbst. Server (/home/dustin/scripts/melo_cloud.py, nicht versioniert — Backup als melo_cloud.py.bak-20260821-084506): - verknuepfe_navidrome(): Hardlink der Registry-Datei nach /home/dustin/navidrome/music (Bind-Mount des Containers). Gleiche Partition -> kein zusaetzlicher Speicher. Fallback: Kopie. - rescan_navidrome(): Subsonic startScan.view ueber Navidromes AuthProxyHeader (TrustedSources 127.0.0.1) — kein Passwort im Code. Nur zur Beschleunigung, der Watcher findet neue Dateien ohnehin. - upload() verknuepft neue UND bereits bekannte Titel (heilt Bestand). - handle_delete() entfernt die Datei aus Registry + Navidrome, sobald kein Konto sie mehr aktiv hat. Der Registry-EINTRAG bleibt stehen: handle_list haengt die Grabsteine daran (JOIN registry) — ohne ihn erfuehren die anderen Geraete nie von der Loeschung (Zombie-Song). - registry_pfad(): gemeinsame Dateisuche ueber alle Audio-Endungen, mit Rueckfall auf den Navidrome-Ordner. Nebenbei repariert: - Alle 325 Cloud-Dateien lagen nur noch im Navidrome-Ordner, REG war leer: jeder Download antwortete "File missing". Per Hardlink zurueckverknuepft (scripts/melo_cloud_migriere_registry.py). - upload() legte jede Datei als ".mp3" ab, auch m4a/flac. Jetzt echte Endung; _download liefert passenden Content-Type und einen Dateinamen ohne doppelte Endung. App: - MeloCloudService.herunterladen() gibt die geschriebene Datei zurueck und leitet die Endung aus dem Content-Type ab (endungFuer) — sonst landet eine M4A als .mp3 auf dem Handy und Android ordnet sie falsch ein. - loeschBremseGreift(): der Abgleich reicht keine Loeschwelle mehr zum Server durch (>10 Titel UND >1/3 des Serverbestands). Ohne die Bremse haette eine nicht eingehaengte Speicherkarte die Sammlung auf allen Geraeten geloescht. 276 Tests gruen (1 uebersprungen), flutter analyze ohne Befund, plus ein Durchlauf gegen den echten Server (scripts/test_melo_cloud_navidrome.py): Upload -> Registry + Navidrome (ein Hardlink) -> Navidrome liest ein -> Loeschen entfernt beides, Grabstein bleibt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FpPu4nuKjKKeX1RpdDeX81
377 lines
12 KiB
Dart
377 lines
12 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,
|
|
);
|
|
}
|
|
|
|
/// Ob eine Löschwelle gestoppt wird, statt sie zum Server durchzureichen.
|
|
///
|
|
/// Hintergrund: Der Bibliotheks-Scan markiert jeden Titel als gelöscht, dessen
|
|
/// Datei er nicht mehr findet. Ist die Speicherkarte einmal nicht eingehängt
|
|
/// oder die Berechtigung entzogen, betrifft das auf einen Schlag die halbe
|
|
/// Sammlung — ohne Bremse würde der Abgleich sie am Server und damit auf allen
|
|
/// Geräten löschen. Einzelne Löschungen laufen weiterhin ungehindert durch.
|
|
bool loeschBremseGreift({required int zuLoeschen, required int bestand}) =>
|
|
zuLoeschen > 10 && zuLoeschen * 3 > bestand;
|
|
|
|
/// 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 amServer = await cloud.liste();
|
|
final plan = planeSync(
|
|
lokal: await db.allSongs(),
|
|
server: amServer,
|
|
);
|
|
_gesamt = plan.gesamt;
|
|
notifyListeners();
|
|
|
|
final bestand = amServer.where((s) => !s.geloescht).length;
|
|
final bremse = loeschBremseGreift(
|
|
zuLoeschen: plan.serverLoeschen.length,
|
|
bestand: bestand,
|
|
);
|
|
if (bremse) {
|
|
_fehler = 'Sicherheitsbremse: ${plan.serverLoeschen.length} von '
|
|
'$bestand Titeln würden am Server gelöscht. Das sieht nach einem '
|
|
'Fehler aus (z. B. Speicherkarte nicht eingehängt) — es wurde '
|
|
'nichts gelöscht.';
|
|
}
|
|
|
|
await _ziehLoeschungenNach(plan.lokalLoeschen);
|
|
if (!bremse) 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}“ …');
|
|
// Die Endung bestimmt der Server anhand des echten Dateityps — die
|
|
// Bibliothek enthält nicht nur MP3.
|
|
final datei = await cloud.herunterladen(
|
|
cloudSong.id,
|
|
ordner,
|
|
'${_sichererDateiname(cloudSong.titel)}-${cloudSong.id}',
|
|
);
|
|
if (datei == null) {
|
|
_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;
|
|
}
|