v2.53 — Tombstone-Sync + Konflikt-Report (Sprint E): deleted_at, lokal nachgezogene Löschungen, Willkommen-zurück-Dialog
This commit is contained in:
@@ -22,6 +22,40 @@ class KonfliktErgebnis {
|
||||
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)
|
||||
@@ -107,6 +141,26 @@ class SyncService {
|
||||
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.0–1.0) für die Fortschritts-Ansicht im Cloud-Tab.
|
||||
@@ -134,6 +188,11 @@ class SyncService {
|
||||
/// 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;
|
||||
|
||||
@@ -156,18 +215,57 @@ class SyncService {
|
||||
onFortschritt?.call('Verbinde...', 0);
|
||||
onFortschrittZaehler?.call(0, 0);
|
||||
|
||||
// ── Phase 1: Songs ──
|
||||
// ── 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
|
||||
for (final song in serverSongs) {
|
||||
// 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);
|
||||
@@ -176,13 +274,13 @@ class SyncService {
|
||||
|
||||
// Downloade neue Songs + löse Konflikte (Titel ODER Künstler ≠ Server)
|
||||
int processed = 0;
|
||||
for (final song in serverSongs) {
|
||||
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, serverSongs.length);
|
||||
onFortschrittZaehler?.call(processed, serverSongs.length);
|
||||
_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) {
|
||||
@@ -249,6 +347,8 @@ class SyncService {
|
||||
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)
|
||||
}
|
||||
@@ -265,6 +365,17 @@ class SyncService {
|
||||
.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);
|
||||
@@ -275,7 +386,7 @@ class SyncService {
|
||||
}
|
||||
await _db.serverFavoritesSet(merged);
|
||||
onFavoritenAnzahl?.call(merged.length);
|
||||
onFortschrittZaehler?.call(downloaded + merged.length, serverSongs.length);
|
||||
onFortschrittZaehler?.call(downloaded + merged.length, aktiveSongs.length);
|
||||
|
||||
// ── Phase 3: Playlisten ──
|
||||
onFortschritt?.call('Sync Playlisten…', 0.7);
|
||||
@@ -309,9 +420,22 @@ class SyncService {
|
||||
_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);
|
||||
|
||||
Reference in New Issue
Block a user