This repository has been archived on 2026-08-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
melo-app/lib/services/sync_service.dart
T

542 lines
22 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import '../database/db_helper.dart';
import '../models/song.dart';
import '../utils/sanitize.dart';
import 'cloud_service.dart';
import 'favoriten_service.dart';
import 'favoriten_sync.dart';
import 'melo_logger.dart';
import '../main.dart'; // notificationsPlugin
/// Ergebnis des Konflikt-Dialogs (UI-Callback aus dem Cloud-Tab):
/// [wahl] = 'lokal' | 'server' | 'beide' | 'ueberspringen' | null (Abbruch).
/// [fuerAlle] = Checkbox „Für alle weiteren Konflikte übernehmen“.
class KonfliktErgebnis {
final String? wahl;
final bool fuerAlle;
const KonfliktErgebnis(this.wahl, {this.fuerAlle = false});
}
/// Zusammenfassung der Änderungen eines Sync-Laufs (Konflikt-Report).
///
/// Der SyncService sammelt die Zähler + Detail-Zeilen; der CloudScreen zeigt
/// sie nach dem Sync als Dialog („Willkommen zurück! …“ mit [Übernehmen] /
/// [Details]) an — v.a. wenn der letzte Sync länger als 24h zurückliegt.
class SyncBericht {
final int neueSongs; // neu heruntergeladene Server-Songs
final int geloeschteSongs; // lokal nachgezogene Server-Löschungen (Tombstones)
final int favoritenGeaendert; // Merge-Differenz lokal ↔ Server
/// Menschlich lesbare Änderungszeilen („+ Neuer Song: …“, „− Gelöscht: …“).
final List<String> details;
const SyncBericht({
required this.neueSongs,
required this.geloeschteSongs,
required this.favoritenGeaendert,
this.details = const [],
});
bool get hatAenderungen =>
neueSongs > 0 || geloeschteSongs > 0 || favoritenGeaendert > 0;
/// Kurzfassung: „+8 neue Songs · 3 gelöscht · ⭐5 Favoriten geändert“.
String get zusammenfassung {
final teile = <String>[
if (neueSongs > 0) '+$neueSongs neue Songs',
if (geloeschteSongs > 0) '$geloeschteSongs gelöscht',
if (favoritenGeaendert > 0) '⭐$favoritenGeaendert Favoriten geändert',
];
return teile.join(' · ');
}
}
/// Zentraler Cloud-Sync (Sprint D, v2.52.2).
///
/// Der komplette Sync-Loop (Songs → Favoriten-Merge → Playlisten → Metadaten)
/// ist hier herausgelöst aus dem CloudScreen, damit er auch OHNE geöffneten
/// Cloud-Tab laufen kann (Auto-Sync-Timer, app-weit) und nach
/// „Im Hintergrund fortsetzen“ sichtbar bleibt:
///
/// - **Persistente Sync-Notification** („Synchronisiere… 12/25“) mit Fortschritt
/// - **Abschluss-Notification** („Sync fertig: X Songs, Y Favoriten“, tippbar)
/// - **☁️-Musikserver-Chip pulsiert** während des Syncs ([laeuftNotifier])
/// - **Echter Doppel-Sync-Guard** ([laeuftGlobal]) über alle Instanzen hinweg
/// - **Auto-Sync-Intervall** Aus/1h/3h/6h/12h ([starteAutoSyncTimer]) —
/// KEIN Sync beim App-Start, nur wenn der letzte Sync älter als das
/// Intervall ist ([istSyncFaellig]).
///
/// UI-Feedback läuft über Callbacks ([onFortschritt], [onStatus], …) — ohne
/// UI (Hintergrund/Auto-Sync) sind sie No-ops, die Notifications übernehmen.
class SyncService {
SyncService(this.cloud)
: _db = DbHelper(),
_favoriten = FavoritenService();
final CloudService cloud;
final DbHelper _db;
final FavoritenService _favoriten;
// ─── Globale Laufzeit-Signale ───
/// Echter Doppel-Sync-Guard: bleibt true, solange IRGENDEIN Sync-Loop läuft
/// (auch im Hintergrund nach „Im Hintergrund fortsetzen“ oder via
/// Auto-Sync-Timer). Statisch → alle SyncService-Instanzen teilen ihn.
static bool _laeuftGlobal = false;
static bool get laeuftGlobal => _laeuftGlobal;
/// UI-Signal für den ☁️-Musikserver-Chip (pulsiert während des Syncs).
/// ValueNotifier, damit der Home-Screen (ListenableBuilder) mitrebuildet.
static final ValueNotifier<bool> laeuftNotifier = ValueNotifier<bool>(false);
/// Wird inkrementiert, wenn die Sync-Abschluss-/Fehler-Notification getippt
/// wird — MeloHome hört darauf und öffnet den Cloud-Tab (main.dart
/// verdrahtet den Tipp via `onDidReceiveNotificationResponse`).
static final ValueNotifier<int> syncBenachrichtigungGetippt =
ValueNotifier<int>(0);
// ─── Auto-Sync-Timer (app-weit, F3) ───
static Timer? _autoSyncTimer;
/// Startet den Auto-Sync-Timer neu anhand der gespeicherten Einstellung
/// `cloud_interval` (0 = Aus). Erster Tick erst NACH dem Intervall —
/// kein Sync beim App-Start. Wird beim App-Start und bei jeder
/// Intervall-Änderung aufgerufen.
static Future<void> starteAutoSyncTimer() async {
_autoSyncTimer?.cancel();
_autoSyncTimer = null;
final p = await SharedPreferences.getInstance();
final intervall = p.getInt('cloud_interval') ?? 0;
if (intervall <= 0) return;
_autoSyncTimer = Timer.periodic(Duration(hours: intervall), (_) {
final service = SyncService(CloudService());
service._autoSyncTick(intervall);
});
}
/// F3c: Auto-Sync nur wenn der letzte Sync älter als das Intervall ist.
/// [letzterSync] = null (nie gesynct) → fällig.
static bool istSyncFaellig(DateTime? letzterSync, int intervallStunden,
{DateTime? jetzt}) {
if (intervallStunden <= 0) return false;
final j = jetzt ?? DateTime.now();
final l = letzterSync;
if (l == null) return true;
return j.difference(l).inHours >= intervallStunden;
}
Future<void> _autoSyncTick(int intervallStunden) async {
if (_laeuftGlobal) return;
final p = await SharedPreferences.getInstance();
final lastTs = p.getString('cloud_last_sync_ts');
final letzter = lastTs != null ? DateTime.tryParse(lastTs) : null;
if (!istSyncFaellig(letzter, intervallStunden)) return;
MeloLogger().aktion('auto_sync_timer', {'intervall': intervallStunden});
await syncAlles(automatisch: true);
}
/// Tombstone-Entscheidung (Sprint E): Soll eine serverseitige Löschung
/// ([deletedAt] = ISO-Zeitstempel) lokal angewendet werden?
///
/// Ja, wenn … kein letzter Sync bekannt ist (Erst-Sync), die Server-
/// Löschung keinen Zeitstempel hat (Alt-Tombstone) oder die Löschung
/// NEUER als der letzte Sync ist. Reine, statische Funktion → testbar.
@visibleForTesting
static bool tombstoneAnwenden({DateTime? letzterSync, String? deletedAt}) {
final d = deletedAt != null ? DateTime.tryParse(deletedAt) : null;
return letzterSync == null || d == null || d.isAfter(letzterSync);
}
/// Lokale Löschung eines Cloud-Songs an den Server melden (Tombstone).
/// Der Server speichert `deleted=1` + `deleted_at` → andere Geräte löschen
/// den Song nach ihrem nächsten Sync ebenfalls lokal (keine Zombie-Songs).
/// Aufrufen, sobald ein Song mit cloud_id lokal gelöscht wird.
Future<bool> meldeLoeschung(String cloudId) async {
return cloud.delete(cloudId, deletedAt: DateTime.now().toIso8601String());
}
// ─── UI-Callbacks (CloudScreen verdrahtet sie; ohne UI = No-op) ───
/// Phase + Fortschritt (0.01.0) für die Fortschritts-Ansicht im Cloud-Tab.
void Function(String phase, double progress)? onFortschritt;
/// „12/25“-Zähler (aktuelle Position / Gesamtanzahl).
void Function(int aktuell, int gesamt)? onFortschrittZaehler;
/// Transiente Status-Meldung (grün/rot).
void Function(String msg, {bool ok})? onStatus;
/// Sync-Historie-Eintrag (Dateien, Favoriten, Playlisten).
void Function(int dateien, int favoriten, int playlists)? onHistorie;
/// Konflikt-Dialog (nur bei manuellem Sync — Auto-Sync gewinnt still).
Future<KonfliktErgebnis?> Function(Song lokal, Map serverSong)? onKonflikt;
/// Server-Favoriten-Anzahl nach dem Merge.
void Function(int anzahl)? onFavoritenAnzahl;
/// Playlisten vom Server neu laden (für Sync-Phase 3 + Historie).
/// Rückgabe: Anzahl der geladenen Playlisten.
Future<int> Function()? onPlaylistenLaden;
/// Nach erfolgreichem Sync: Status/Playlisten/Favoriten/letzten Sync neu laden.
Future<void> Function()? onNachSync;
/// Konflikt-Report: wird nach dem Sync aufgerufen, wenn es Änderungen gab
/// (neue/gelöschte Songs, Favoriten-Merge). UI zeigt den Zusammenfassungs-
/// Dialog mit [Übernehmen]/[Details].
void Function(SyncBericht bericht)? onBericht;
/// Immer am Ende (auch bei Fehler): Animation stoppen, Sync-Ansicht schließen.
void Function()? onSyncEnde;
// ─── Notifications ───
static const int _syncNotifyId = 300;
static const String _syncChannelId = 'de.baka.melo.sync';
/// Komplett-Sync. [automatisch]=true (Auto-Sync-Timer): Konflikte werden
/// NICHT interaktiv gelöst — die Server-Metadaten gewinnen still.
/// Rückgabe: true bei Erfolg, false bei Fehler oder wenn bereits ein
/// Sync läuft (globaler Guard).
Future<bool> syncAlles({bool automatisch = false}) async {
if (_laeuftGlobal) return false;
_laeuftGlobal = true;
laeuftNotifier.value = true;
String? konfliktBatch;
try {
_zeigeSyncNotification('Verbinde…', 0, 0);
onFortschritt?.call('Verbinde...', 0);
onFortschrittZaehler?.call(0, 0);
// ── Phase 1: Songs (inkl. Tombstone-Sync, Sprint E) ──
onFortschritt?.call('Lade Songs…', 0.1);
final serverSongs = await cloud.listSongs();
final dir =
Directory('${(await getApplicationDocumentsDirectory()).path}/music');
if (!await dir.exists()) await dir.create(recursive: true);
// Letzter Sync-Zeitpunkt: Tombstones werden nur angewendet, wenn die
// Server-Löschung NEUER als der letzte Sync ist.
final prefs = await SharedPreferences.getInstance();
final letzterSyncRaw = prefs.getString('cloud_last_sync_ts');
final letzterSync =
letzterSyncRaw != null ? DateTime.tryParse(letzterSyncRaw) : null;
// ── Tombstones zuerst: serverseitig gelöschte Songs lokal nachziehen ──
// (keine Zombie-Songs: Offline-Geräte löschen nach dem Sync ebenfalls)
final tombstones =
serverSongs.where((s) => s['deleted'] == true).toList();
final aktiveSongs =
serverSongs.where((s) => s['deleted'] != true).toList();
var geloeschteSongs = 0;
final loeschDetails = <String>[];
for (final ts in tombstones) {
final sid = ts['id']?.toString() ?? '';
if (sid.isEmpty) continue;
if (!tombstoneAnwenden(
letzterSync: letzterSync,
deletedAt: ts['deletedAt']?.toString())) {
continue;
}
final lokal = await _db.songNachCloudId(sid);
if (lokal == null || lokal.id == null) continue;
// Datei entfernen (falls vorhanden), dann DB-Eintrag löschen
try {
final f = File(lokal.dateiPfad);
if (lokal.dateiPfad.isNotEmpty && await f.exists()) await f.delete();
} catch (_) {
// Datei-Fehler dürfen den Sync nicht abbrechen
}
await _db.loeschSong(lokal.id!);
geloeschteSongs++;
loeschDetails.add(' Gelöscht: ${lokal.titel}');
}
int downloaded = 0;
int neueSongs = 0;
final neueDetails = <String>[];
int totalNew = 0;
// Zähle neue Songs (nur aktive — Tombstones sind oben erledigt)
for (final song in aktiveSongs) {
final sid = song['id']?.toString() ?? '';
if (sid.isEmpty) continue;
final existing = await _db.songNachCloudId(sid);
if (existing == null) totalNew++;
}
// Downloade neue Songs + löse Konflikte (Titel ODER Künstler ≠ Server)
int processed = 0;
for (final song in aktiveSongs) {
final sid = song['id']?.toString() ?? '';
if (sid.isEmpty) continue;
processed++;
// Fortschritt je Song (auch für bereits vorhandene) — „12/25“
_zeigeSyncNotification('Synchronisiere…', processed, aktiveSongs.length);
onFortschrittZaehler?.call(processed, aktiveSongs.length);
final title = (song['title'] ?? 'unknown').toString();
final existing = await _db.songNachCloudId(sid);
if (existing != null) {
final serverTitel = title.trim();
final lokalerTitel = existing.titel.trim();
final serverKuenstler = (song['artist']?.toString() ?? '').trim();
final lokalerKuenstler = existing.kuenstler.trim();
final titelWeichtAb = serverTitel.isNotEmpty &&
lokalerTitel.toLowerCase() != serverTitel.toLowerCase();
final kuenstlerWeichtAb = serverKuenstler.isNotEmpty &&
lokalerKuenstler.toLowerCase() != serverKuenstler.toLowerCase();
if (titelWeichtAb || kuenstlerWeichtAb) {
// Batch: einmal gewählt → für alle weiteren Konflikte anwenden
String? wahl;
final batch = konfliktBatch;
if (batch != null) {
wahl = batch == 'ueberspringen' ? 'lokal' : batch;
} else if (automatisch) {
// Auto-Sync: keine Dialoge — Server-Metadaten gewinnen still
wahl = 'server';
} else {
final ergebnis =
onKonflikt != null ? await onKonflikt!(existing, song) : null;
if (ergebnis != null) {
wahl = ergebnis.wahl;
if (ergebnis.fuerAlle &&
konfliktBatch == null &&
wahl != null) {
konfliktBatch = wahl;
}
}
}
if (wahl == 'server') {
await _db.cloudMetadatenAktualisieren(existing.id!,
title: serverTitel, artist: serverKuenstler);
downloaded++;
} else if (wahl == 'beide') {
// Server-Kopie als eigenen lokalen Song ohne cloud_id anlegen
final safeTitle = sanitizeDateiname(serverTitel);
var dest = '${dir.path}/$safeTitle';
if (await File(dest).exists()) {
dest = '${dir.path}/$safeTitle (Server)';
}
if (await cloud.download(sid, dest)) {
await _db.songEinfuegen(Song(
titel: serverTitel,
kuenstler: serverKuenstler,
dauerSekunden: 0,
dateiPfad: dest,
downloadQuelle: 'cloud',
istHeruntergeladen: true,
));
downloaded++;
}
}
// 'lokal' → nichts tun (lokale Version behalten)
}
continue;
}
onFortschritt?.call('Download: $title…',
0.1 + (0.4 * processed / (totalNew > 0 ? totalNew : 1)));
final safeTitle = sanitizeDateiname(title);
final dest = '${dir.path}/$safeTitle';
if (await cloud.download(sid, dest)) {
downloaded++;
neueSongs++;
neueDetails.add('+ Neuer Song: $title');
// In DB eintragen mit cloud_id
// (vereinfacht: ID3-Reader würde Titel extrahieren)
}
await Future.delayed(
const Duration(milliseconds: 50)); // UI-Update erlauben
}
// ── Phase 2: Favoriten bidirektional (Merge lokal Server) ──
onFortschritt?.call('Sync Favoriten…', 0.55);
final serverFavs = await cloud.getFavorites();
final serverIds = serverFavs
.map((f) => f['id']?.toString() ?? '')
.where((id) => id.isNotEmpty)
.toSet();
final lokalIds = await _favoriten.favoritenCloudIds();
final merged = favoritenMerge(lokal: lokalIds, server: serverIds);
final mergedSet = merged.toSet();
// Merge-Differenz = Anzahl der Favoriten-Änderungen (Konflikt-Report):
// lokal neu (wurden zum Server gepusht) + vom Server neu (lokal markiert)
final favoritenGeaendert = mergedSet.difference(serverIds).length +
serverIds.difference(lokalIds).length;
final favoritenDetails = <String>[
for (final cid in mergedSet.difference(serverIds))
'⭐ Favorit lokal → Server: $cid',
for (final cid in serverIds.difference(lokalIds))
'⭐ Favorit Server → lokal: $cid',
];
// Lokal → Server: lokale Toggles erreichen den Server, Server-Favoriten
// bleiben erhalten (kein Datenverlust in beide Richtungen)
await cloud.syncFavorites(merged);
// Server → Lokal: Server-Favoriten lokal als ⭐ markieren (wenn Song
// lokal existiert); `server_favorites` spiegelt den Merge-Zustand
for (final cid in serverIds) {
await _favoriten.merkeCloudFavorit(cid);
}
await _db.serverFavoritesSet(merged);
onFavoritenAnzahl?.call(merged.length);
onFortschrittZaehler?.call(downloaded + merged.length, aktiveSongs.length);
// ── Phase 3: Playlisten ──
onFortschritt?.call('Sync Playlisten…', 0.7);
int playlistCount = 0;
if (onPlaylistenLaden != null) {
try {
playlistCount = await onPlaylistenLaden!();
} catch (e) {
MeloLogger().fehler('cloud_playlisten_sync', e);
}
}
// ── Phase 4: Sync-Metadaten + letzter Sync ──
onFortschritt?.call('Speichere Sync-Zeitpunkt...', 0.9);
await cloud.syncAll();
final now = DateTime.now().toIso8601String();
await _db.syncMetaSet('last_full_sync', now);
// Nur bei Erfolg persistieren (Auto-Sync-Fälligkeit hängt daran)
final p = await SharedPreferences.getInstance();
final zeit = _formatZeit(DateTime.now());
await p.setString('cloud_last_sync', zeit);
await p.setString('cloud_last_sync_ts', now);
onFortschritt?.call('Fertig!', 1.0);
onStatus?.call(
'$downloaded Songs + ${merged.length} Favoriten synchronisiert',
ok: true);
// UI-Nachladen (Status, Playlisten, Favoriten, letzter Sync)
await onNachSync?.call();
onHistorie?.call(downloaded, merged.length, playlistCount);
_zeigeSyncFertigNotification(downloaded, merged.length);
MeloLogger().aktion('cloud_sync_all', {
'downloaded': downloaded,
'neue': neueSongs,
'geloescht': geloeschteSongs,
'favorites': merged.length,
'playlists': playlistCount,
});
// Konflikt-Report (Sprint E): Zusammenfassung nur bei echten Änderungen.
// Der CloudScreen zeigt den Dialog („Willkommen zurück! …“).
final bericht = SyncBericht(
neueSongs: neueSongs,
geloeschteSongs: geloeschteSongs,
favoritenGeaendert: favoritenGeaendert,
details: [...neueDetails, ...loeschDetails, ...favoritenDetails],
);
if (bericht.hatAenderungen) {
onBericht?.call(bericht);
}
return true;
} catch (e) {
MeloLogger().fehler('cloud_sync_all', e);
onStatus?.call('Sync-Fehler: $e', ok: false);
_zeigeSyncFehlerNotification();
return false;
} finally {
// Guard IMMER freigeben — auch bei Fehler oder wenn der Screen während
// des Syncs verlassen wurde („Im Hintergrund fortsetzen“).
_laeuftGlobal = false;
laeuftNotifier.value = false;
try {
notificationsPlugin.cancel(id: _syncNotifyId);
} catch (_) {
// Plugin kann beim App-Exit bereits disposed sein
}
onSyncEnde?.call();
}
}
// ─── Notifications ───
/// Persistente Fortschritts-Notification (nicht wegwischbar, ongoing).
void _zeigeSyncNotification(String body, int aktuell, int gesamt) {
if (!Platform.isAndroid) return;
final maxP = gesamt > 0 ? gesamt : 1;
final p = aktuell > maxP ? maxP : aktuell;
notificationsPlugin.show(
id: _syncNotifyId,
title: 'Synchronisiere…',
body: gesamt > 0 ? '$aktuell / $gesamt Songs' : body,
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_syncChannelId,
'Melo Sync',
channelDescription: 'Cloud-Sync-Fortschritt',
importance: Importance.low,
priority: Priority.low,
onlyAlertOnce: true,
showProgress: true,
maxProgress: maxP,
progress: p,
ongoing: true,
autoCancel: false,
),
),
);
}
/// Abschluss-Notification — tippbar (öffnet den Cloud-Tab, payload
/// 'sync_fertig' wird in main.dart behandelt).
void _zeigeSyncFertigNotification(int songs, int favoriten) {
if (!Platform.isAndroid) return;
try {
notificationsPlugin.cancel(id: _syncNotifyId);
} catch (_) {}
notificationsPlugin.show(
id: _syncNotifyId + 1,
title: 'Sync fertig',
body: '$songs Songs, $favoriten Favoriten synchronisiert ✅',
payload: 'sync_fertig',
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_syncChannelId,
'Melo Sync',
channelDescription: 'Cloud-Sync-Abschluss',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
),
),
);
}
/// Fehler-Notification (tippbar, öffnet den Cloud-Tab).
void _zeigeSyncFehlerNotification() {
if (!Platform.isAndroid) return;
try {
notificationsPlugin.cancel(id: _syncNotifyId);
} catch (_) {}
notificationsPlugin.show(
id: _syncNotifyId + 1,
title: 'Sync fehlgeschlagen',
body: 'Bitte erneut versuchen ❌',
payload: 'sync_fehler',
notificationDetails: NotificationDetails(
android: AndroidNotificationDetails(
_syncChannelId,
'Melo Sync',
channelDescription: 'Cloud-Sync-Abschluss',
importance: Importance.defaultImportance,
priority: Priority.defaultPriority,
autoCancel: true,
),
),
);
}
String _formatZeit(DateTime? dt) {
if (dt == null) return 'Nie';
return '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
}