v2.53 — Tombstone-Sync + Konflikt-Report (Sprint E): deleted_at, lokal nachgezogene Löschungen, Willkommen-zurück-Dialog

This commit is contained in:
Dustin
2026-08-05 11:14:21 +02:00
parent 22ce611537
commit 2d735464c0
3 changed files with 244 additions and 9 deletions
+9 -2
View File
@@ -149,12 +149,19 @@ class CloudService extends ChangeNotifier {
}
}
Future<bool> delete(String songId) async {
/// Song auf dem Server als gelöscht markieren (Tombstone).
/// [deletedAt] (ISO-Zeitstempel) dokumentiert den Löschzeitpunkt — Offline-
/// Geräte vergleichen ihn mit ihrem letzten Sync und löschen lokal nach
/// (keine Zombie-Songs). Ohne [deletedAt] stempelt der Server selbst.
Future<bool> delete(String songId, {String? deletedAt}) async {
try {
final r = await _client
.post(Uri.parse('$_base/api/v1/cloud/delete'),
headers: _jsonHeader,
body: jsonEncode({'song_id': songId}))
body: jsonEncode({
'song_id': songId,
if (deletedAt != null) 'deleted_at': deletedAt,
}))
.timeout(const Duration(seconds: 10));
return r.statusCode == 200;
} catch (_) {
+131 -7
View File
@@ -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.01.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);