v2.53.1 — MED-1: Tombstone-Race behoben (Snapshot vor listSongs + Server-Zeit)

## MED-1 (Review-Fix)
- sync_service.dart: letzterSync-Snapshot wird VOR listSongs gelesen und am
  Sync-Ende persistiert (Sync-BEGINN statt now-am-Ende) — Löschungen, die
  zwischen listSongs und Sync-Ende auf dem Server ankommen, werden im
  nächsten Sync korrekt angewendet statt als „bereits gesehen“ übersprungen
- cloud_screen.dart: _download schreibt cloud_last_sync_ts nicht mehr
  nachträglich mit now-am-Ende (redundant + Race-Wieder-Einführung);
  UI-Anzeige liest den von SyncService persistierten Snapshot
- melo_cloud.py: handle_delete stempelt deleted_at IMMER mit Server-Zeit
  (datetime('now')) — Client-Timestamps werden ignoriert (Uhr-Drift würde
  Löschungen dauerhaft überspringen lassen; konsistente Uhr für alle Geräte)
This commit is contained in:
Dustin
2026-08-05 11:22:36 +02:00
parent 2d735464c0
commit 3fe20e1c7b
2 changed files with 32 additions and 15 deletions
+14 -7
View File
@@ -790,15 +790,22 @@ class _CloudScreenState extends State<CloudScreen>
return;
}
final erfolgreich = await _syncAlles();
if (!erfolgreich) return; // cloud_last_sync nur bei Erfolg schreiben
if (!erfolgreich) return; // SyncService persistiert cloud_last_sync nur bei Erfolg
// UI: letzten Sync anzeigen. KEINE zweite cloud_last_sync_ts-Schreibung
// mit now-am-Ende hier — SyncService persistiert bereits den
// Sync-BEGINN-Snapshot (MED-1: Tombstone-Race). Eine spätere Schreibung
// würde die Löschungen zwischen listSongs und Sync-Ende wieder als
// „bereits gesehen“ markieren.
final p = await SharedPreferences.getInstance();
final now = DateTime.now();
final zeit = _formatZeit(now);
await p.setString('cloud_last_sync', zeit);
await p.setString('cloud_last_sync_ts', now.toIso8601String());
final letzterTs = p.getString('cloud_last_sync_ts');
if (mounted) {
setState(() => _letzterSync = zeit);
_berechneNaechstenSync();
setState(() {
_letzterSyncTs = letzterTs;
_letzterSync = letzterTs != null
? _formatZeit(DateTime.tryParse(letzterTs) ?? DateTime.now())
: _formatZeit(DateTime.now());
_berechneNaechstenSync();
});
}
}
+18 -8
View File
@@ -217,17 +217,24 @@ class SyncService {
// ── 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.
// MED-1 (Tombstone-Race): letzterSync-Snapshot VOR listSongs lesen.
// Der persistierte „letzter Sync“ darf nie später liegen als die
// letzte Beobachtung der Server-Liste — sonst gilt eine Löschung, die
// zwischen listSongs und Sync-Ende auf dem Server ankommt, im nächsten
// Sync als „bereits gesehen“ (deletedAt < letzterSync) und wird
// dauerhaft übersprungen (Zombie-Song). Der Snapshot (Sync-BEGINN,
// nicht now-am-Ende) wird am Ende persistiert.
final prefs = await SharedPreferences.getInstance();
final letzterSyncRaw = prefs.getString('cloud_last_sync_ts');
final letzterSync =
letzterSyncRaw != null ? DateTime.tryParse(letzterSyncRaw) : null;
final syncSnapshot = DateTime.now();
final serverSongs = await cloud.listSongs();
final dir =
Directory('${(await getApplicationDocumentsDirectory()).path}/music');
if (!await dir.exists()) await dir.create(recursive: true);
// ── Tombstones zuerst: serverseitig gelöschte Songs lokal nachziehen ──
// (keine Zombie-Songs: Offline-Geräte löschen nach dem Sync ebenfalls)
@@ -402,11 +409,14 @@ class SyncService {
// ── Phase 4: Sync-Metadaten + letzter Sync ──
onFortschritt?.call('Speichere Sync-Zeitpunkt...', 0.9);
await cloud.syncAll();
final now = DateTime.now().toIso8601String();
// Zeitstempel = Sync-BEGINN (Snapshot VOR listSongs), NICHT das
// Sync-Ende: Der Tombstone-Vergleich anderer Geräte hängt am
// „letzten Beobachtungszeitpunkt der Server-Liste“ (MED-1).
final now = syncSnapshot.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());
final zeit = _formatZeit(syncSnapshot);
await p.setString('cloud_last_sync', zeit);
await p.setString('cloud_last_sync_ts', now);