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

412 lines
16 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});
}
/// 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);
// ─── 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);
}
// ─── 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;
/// 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 ──
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);
int downloaded = 0;
int totalNew = 0;
// Zähle neue Songs
for (final song in serverSongs) {
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 serverSongs) {
final sid = song['id']?.toString() ?? '';
if (sid.isEmpty) continue;
processed++;
// Fortschritt je Song (auch für bereits vorhandene) — „12/25“
_zeigeSyncNotification('Synchronisiere…', processed, serverSongs.length);
onFortschrittZaehler?.call(processed, serverSongs.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++;
// 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);
// 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, serverSongs.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,
'favorites': merged.length,
'playlists': playlistCount,
});
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')}';
}
}