Files
Melo/docs/superpowers/specs/2026-08-27-sync-ausbau-design.md
Hermes (Server)andClaude Sonnet 5 1940e2b790 Sync-Ausbau: Löschpfad gefixt — Spec + Plan entblockt
Hermes hat den irreversiblen Löschpfad in melo_cloud.py behoben: Der
Dedup-Zweig von upload() stellt die Datei jetzt aus den hochgeladenen
Bytes wieder her, wenn registry_pfad(sid) leer ist, statt sie zu
verwerfen.

End-to-end verifiziert (eigene Testdatei, Fake-Nutzer, Testdaten danach
restlos entfernt — Registry vor und nach dem Test bei 325 Titeln):
Upload → beide Kopien da · Löschung → beide weg, Download 404 (Bug
reproduziert) · erneuter Upload → beide Kopien zurück, Download
bitgenau identisch mit dem Original.

Damit ist die letzte offene Frage der Spec (A6) beantwortet und keine
Stufe mehr blockiert. Tasks 5-7 des Plans sind entblockt; Tasks 10-12
hängen nur noch an der Produktfrage "wird Feature 2 überhaupt gebaut?"
(0 Playlisten am Server).

Nebenbefund aus dem Test, als Backlog-Notiz festgehalten: _link_user()
legt im Dedup-Zweig einen dritten Hardlink unter users/<user>/ an, den
_entferne_datei_wenn_verwaist() nicht abräumt. Server-Hygiene für
Hermes, kein Datenverlust.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
2026-08-27 10:31:49 +02:00

37 KiB
Raw Permalink Blame History

Sync-Ausbau: Favoriten-Fix, Auswahl-Upload, Einzel-Song-Offline, Playlist-Sicherung

Status: Freigegeben, umsetzungsbereit (2026-08-27). Nachgeschärft nach agent-review-panel (Urteil Phase 14: Score 6/10, Verdikt „Spec nachschärfen dann freigeben"), alle 17 Aktionspunkte A1A17 eingearbeitet, Gegenlesen durch einen frischen Prüfer bestanden (Bedingung des Richters erfüllt). Die einzige offene Frage (A6, Löschpfad) ist beantwortet und serverseitig behoben — siehe §ERLEDIGT. Keine Stufe ist blockiert. Implementierungsplan: docs/superpowers/plans/2026-08-27-sync-ausbau.md (13 Tasks).

Kontext

Die Melo-App synchronisiert heute schon Musikdateien UND Metadaten mit der Melo-Cloud (cloud.baka-net.demelo_cloud.py, Bearer-JWT via BakaAuth): SyncService lädt lokale Songs ohne Cloud-ID automatisch hoch (Multipart, 50 MB-Grenze, Server-Dedup per sha256 + Akustik-Fingerprint), lädt neue Server-Songs herunter, zieht Tombstones in beide Richtungen nach (mit Lösch-Bremse) und läuft automatisch bei App-Start/Resume (15-Minuten-Drossel) mit Fortschrittsanzeige in den Einstellungen. Der Server hardlinkt jede hochgeladene Datei aktiv nach /home/dustin/navidrome/music und stößt einen Navidrome-Scan an — der Weg Handy→Server→Navidrome-Bibliothek existiert also bereits und er läuft auch rückwärts: verschwindet die lokale Datei, meldet der Sync die Löschung, und der Server entfernt die Audiodatei aus Registry und Navidrome-Bibliothek (Details unter §Risiken, „Irreversibler Löschpfad"). DownloadService kann ganze Alben/Künstler in den App-Speicher offline nehmen (Fortschritt + Abbrechen), dazu gibt es einen automatischen Abspiel-Cache (Standard 2 GB, einstellbar 08192 MB; app_settings.dart:28, :31).

Diese Spec schließt die verbliebenen Lücken (Auftrag Dustin, 2026-08-26, im Brainstorming zerlegt und entschieden):

Entscheidung Ergebnis
Zerlegung Erst Sync-Ausbau (diese Spec, Android), Cross-Platform Mac+Windows als eigenes späteres Projekt
Upload-UX Bestehenden Auswahl-Modus in „Meine Musik“ um „Auf den Server laden“ erweitern; Auto-Upload beim Sync bleibt
Umfang Einzel-Song-Offline + Playlist-Sync + Fortschritts-Notification/Sync-Bericht + Echtzeit-SSE; Favoriten-Merge-Fix immer dabei (durch Review überholt — siehe unten)
Playlist-Konflikte Vereinigung (Union) wie bei Favoriten; Reihenfolge: längerer Stand gewinnt (durch Review überholt — siehe unten)
Bau-Ansatz Ansatz 1: inkrementeller Ausbau des bestehenden SyncService, neue Logik in kleinen separaten Einheiten

Die Tabelle gibt den Stand des Brainstormings wieder — die Zeilen „Umfang" und „Playlist-Konflikte" sind durch das Review überholt, die Korrektur steht direkt darunter.

Was das Review daran geändert hat (die Zerlegung, die Upload-UX und der Bau-Ansatz bleiben unangetastet):

  • Der Umfang schrumpft: SSE und die persistente Notification werden eigene spätere Stufen (§Spätere Stufen), Playlist-Sync wird auf eine einseitige Sicherung reduziert.
  • Die Union-Semantik bei Favoriten bleibt genau wie entschieden — nur der Schreibweg wechselt vom Voll-Ersatz auf additive Pushes (A1). Das ist keine Überstimmung der Auftraggeber-Entscheidung, sondern ihre Präzisierung.
  • Die Playlist-Konfliktregel („längerer Stand gewinnt") entfällt ersatzlos, weil es ohne Rück-Merge keinen Konflikt mehr gibt (A5).

ERLEDIGT — Löschpfad ist gefixt (2026-08-27)

Die Rückfrage an Dustin („Ist der irreversible Löschpfad gewollt?") ist beantwortet: nein. Hermes hat den Fix am selben Tag in melo_cloud.py eingebaut und den Dienst neu gestartet.

Was sich geändert hat: Im Dedup-Zweig von upload() prüft der Server jetzt registry_pfad(sid) is None und stellt in diesem Fall die Datei aus den hochgeladenen Bytes wieder her (shutil.move nach REG plus UPDATE registry SET path), statt sie über os.remove(tmp) zu verwerfen. Der bestehende verknuepfe_navidrome-Aufruf hängt sie danach automatisch zurück in die Navidrome-Bibliothek.

End-to-End verifiziert (2026-08-27, eigene Testdatei, Fake-Nutzer, Testdaten danach restlos entfernt — Registry vor und nach dem Test bei 325 Titeln): Upload → beide Kopien da, Download bitgenau · Löschung → beide Dateien weg, Download 404 (Bug reproduziert) · erneuter Upload → beide Dateien zurück, Download wieder bitgenau identisch.

Folge für diese Spec: keine Stufe ist mehr blockiert. Der ursprüngliche Grund, Feature 3 (Auswahl-Upload) hinter die Klärung zu stellen — es gibt mehr Titeln eine cloudId und vergrößert damit die Angriffsfläche —, ist entfallen: Ein versehentlicher Löschvorgang ist jetzt durch erneutes Hochladen reparabel. Die Reihenfolge in §Reihenfolge bleibt trotzdem so bestehen, weil sie auch aus anderen Gründen sinnvoll ist (Stufe 1 behebt den einzigen belegten Bug und hängt an nichts).

Nebenbefund aus dem Test, nicht blockierend: _link_user() wird nur im Dedup-Zweig aufgerufen (der Neu-Zweig kehrt vorher zurück) und legt einen dritten Hardlink unter users/<user>/<sid>.mp3 an, den _entferne_datei_wenn_verwaist() beim Löschen nicht mit abräumt. Bisher folgenlos (das Verzeichnis war leer, weil im Normalbetrieb fast alles über den Neu-Zweig läuft), aber jeder künftige Dedup-Upload hinterlässt einen verwaisten Hardlink. Server-Hygiene für Hermes, kein Datenverlust.

Ziele

  1. Favoriten-Merge-Fix (Datenverlust-Bug): kein Gerät überschreibt mehr die Server-Favoriten mit seinem lokalen Stand.
  2. Gezielter Upload: einzelne Songs im Auswahl-Modus markieren und hochladen.
  3. Einzel-Song-Offline: einzelne Server-Titel offline nehmen, nicht nur ganze Alben/Künstler.
  4. Playlist-Sicherung: lokale Playlisten überleben ein zurückgesetztes Handy (einseitig, siehe Feature 2).
  5. Sichtbarkeit: „Was ist neu“-Bericht als In-App-Dialog.

Nicht-Ziele

  • Kein Cross-Platform (Mac/Windows) — eigenes Folgeprojekt. windows//linux/ existieren nicht, just_audio hat kein Windows-Backend; das ist Plattform-Infrastruktur, keine Sync-Logik.
  • Kein Delta-Protokoll (/sync?since=…): bei ~330 Songs auf dem Server ist die Voll-Liste billig; der Delta-Endpunkt deckt zudem nur Songs ab, nicht Favoriten/Playlisten. YAGNI.
  • Kein Hintergrund-Sync (WorkManager o. ä.): Sync weiterhin nur bei geöffneter App (Start/Resume/manuell) — wie bisher und wie in v2.
  • Kein Chunked/Resumable Upload: 50-MB-Dateien am Stück wie bisher.
  • Kein Echtzeit-Sync (SSE) in dieser Stufe. Der beworbene Haupt-Nutzen („neuer Song erscheint in Sekunden auf dem anderen Gerät") hängt am song_upload-Event, das melo_cloud.py heute nicht feuert (_emit_event nur bei :393, :410, :687) und das diese Spec ausdrücklich auslagert — am Auslieferungstag könnte SSE also genau das nicht, wofür es gebaut wird. Die drei vorhandenen Kanäle (song_delete, song_update, song_favorite) haben heute keinen Adressaten (Live-Stand: ein Nutzer mit Songs, 0 gelöschte, 0 Favoriten, 0 Playlisten). Dem stehen acht eigene Fehlerklassen gegenüber: eigener http.Client (der geteilte aus melo_cloud_service.dart:82-83 würde beim close() laufende Sync-Requests mitreißen), Heartbeat-Watchdog gegen den halboffenen Socket nach WLAN↔LTE, dauerhafter 401-Ausstieg (baka_auth.dart hat kein Refresh), Selbst-Echo-Unterdrückung (melo_realtime.py:56-70 broadcastet an alle Queues des Nutzers ohne Absenderkennung), Timer-Abbau bei paused, Debounce, Backoff, Nachhol-Anstoß. Ehrlicher Preis: Änderungen anderer Geräte erscheinen beim nächsten App-Start oder Zurückkehren — genau wie heute. Es gibt keinen billigeren Poll-Ersatz: automatisch() läuft nur aus initState und resumed (main.dart:175, :203), ein Timer existiert nicht.
  • Keine persistente Fortschritts-Notification in dieser Stufe — sie ist die einzige Quelle einer neuen Dependency und wird eigene Stufe (§Spätere Stufen, Stufe B).
  • Kein beidseitiger Playlist-Merge — eigene, spätere Spec (A5).
  • Keine Playlist-Tombstones auf dem Server: ein Server-Tombstone-System wäre ein eigener Auftrag.
  • Kein Rollback auf eine ältere App-Version nach der Schema-Migration: database.dart:154-192 kennt nur onCreate und onUpgrade.

Spätere Stufen (bewusst nach hinten geschoben, nicht verworfen)

Stufe Inhalt Vorbedingung
Notification Stufe B persistente Fortschritts-Notification via flutter_local_notifications grüner Beweis-Build (siehe unten)
SSE Echtzeit-Sync, eigene Spec song_upload-Event steht in melo_cloud.py
Playlist-Merge beidseitiger Abgleich, eigene Spec Rename-Endpunkt + Tombstones serverseitig
Basis-Snapshot verlässliche Lösch-Propagation bei Favoriten erst wenn die additive Semantik in der Praxis stört

Notification Stufe B, Details (eigenes Arbeitspaket): Sie erzwingt flutter_local_notifications und damit isCoreLibraryDesugaringEnabled = true plus coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") in android/app/build.gradle.kts — die Datei hat heute überhaupt keinen dependencies { }-Block, der Gradle-Heap steht auf 2 GB (gradle.properties:1, dokumentierte OOM-Quelle), AGP ist 9.0.1 (settings.gradle.kts:22) und android-37 ist ein Nachbau von 36. Erstes Abnahmekriterium der Stufe B ist ein grüner flutter build apk --target-platform android-arm64 nach dem pub add — vor jeder Zeile Notification-Logik. multiDexEnabled entfällt aus der Forderung: bei minSdkVersion = 24 (FlutterExtension.kt:26) ist es gegenstandslos. Mitzuentscheiden: Channel mit Importance.low, onlyAlertOnce: true, und ein cancel() beim Init gegen die nach App-Kill stehengebliebene Fortschritts-Notification.

SSE später: Die acht Härtungs-Bausteine gehören dann in jene Spec, nicht in einen Plan zu dieser. Vorbedingung außerdem: der additive Delta-Push (Feature 1) feuert je gepushtem Favoriten ein song_favorite-Event (melo_cloud.py:687) — beim ersten Lauf nach der Umstellung also so viele Events wie lokale Favoriten. Ohne SSE ist das folgenlos; kommt SSE, muss die Selbst-Echo-Unterdrückung vorher stehen.

Architektur

Rückgrat bleibt SyncService (lib/services/sync_service.dart) + MeloCloudService (lib/services/melo_cloud_service.dart). Neu:

Einheit Datei Verantwortung
Merge-Logik lib/services/sync_merge.dart (neu) Reine Funktionen ohne I/O: fehlendeFavoriten (Mengendifferenz beider Richtungen), berichtFaellig. Voll unit-testbar.
Cloud-Erweiterung melo_cloud_service.dart (erweitert) setzeFavorit(cloudId, set) (neu, deterministisch), Playlisten-CRUD; parseFavoriten gehärtet.
Sync-Erweiterung sync_service.dart (erweitert) Neue Phase (additiver Favoriten-Abgleich), ladeAusgewaehlteHoch, abbrechen(), Phasen-Isolation, Erfolgs-Flag.
UI bestehende Screens Auswahl-Modus-Aktion, Einzel-Song-Offline-Knopf, Bericht-Dialog.

Neue Abhängigkeit: keine. Das war in der Vorfassung flutter_local_notifications und damit der einzige harte Build-Blocker der ganzen Planung; er wandert mit Stufe B nach hinten.

Ebenfalls entfallen gegenüber der Vorfassung: echtzeit_sync.dart (A7) und sync_benachrichtigung.dart (A8). Die einzige verbliebene reine Entscheidungsfunktion des Berichts (berichtFaellig) wohnt in sync_merge.dart — eine eigene Datei für eine Funktion wäre Abstraktion ohne zweiten Aufrufer.

Feature-Details

1. Favoriten-Abgleich (Datenverlust-Fix)

Heutiger Bug: _gleicheFavoritenAb() (sync_service.dart) POSTet die lokale Favoritenliste als Komplett-Ersatz — ein frisch installiertes Gerät löscht damit beim ersten Sync alle Server-Favoriten.

Geltungsbereich: Der Favoriten-Abgleich betrifft ausschließlich die lokale Favorites-Tabelle. Der Stern in der Server-Titel-Ansicht (ServerFavoriteButton, lib/shared/server_favorite_button.dart:42, benutzt in now_playing_screen.dart:279) ist ein anderes System — Navidrome-Starring über navidrome.setFavorite(navidromeId) — und wird von dieser Spec nicht angefasst. Beide erscheinen im Now-Playing-Screen als dasselbe Herz-Symbol; dass sie getrennt bleiben, ist eine Entscheidung, keine Auslassung. PlaylistService.syncFavoritesFromServer() (playlist_service.dart:52-71) ist ein dritter Kanal und heute wirkungslos (db.songExists(navidromeId) gegen lokale UUIDs) — er wird nicht Teil des Abgleichs.

Neu, zwei Mechanismen:

  • Sofort-Push beim Antippen: PlaylistService.toggleFavorite schickt online zusätzlich fire-and-forget ein deterministisches set:true/false an den Server (POST /favorites/toggle). Deterministisch statt Toggle, damit ein abweichender Server-Zustand den Wunsch nie invertiert. Nur für Songs mit cloudId; Fehler werden still geschluckt (der nächste Voll-Sync korrigiert additiv).
  • Additiver Abgleich beim Sync, neue Phase in synchronisiere():

Die Sync-Phase benutzt nie POST /favorites (Voll-Ersatz). Sie schreibt ausschließlich additiv:

  1. GET /favoritesserver (Menge von cloudIds).
  2. Für jede cloudId in lokal \ server: POST /favorites/toggle mit {"song_id": …, "set": true} (existiert und ist deterministisch: melo_cloud.py:1298-1302handle_favorites_toggle:644-688; die Client-Methode ist neu).
  3. Für jede cloudId in server \ lokal: lokal Favorit setzen, sofern der Song lokal auflösbar ist.
  4. In keiner Richtung wird etwas entfernt.

MeloCloudService.setzeFavoriten (melo_cloud_service.dart:268-280) wird nicht mehr benutzt und entfällt.

Wirkung: Der Datenverlust-Bug aus Ziel 1 ist danach strukturell unmöglich, nicht nur durch eine Regel verhindert — es existiert kein Codepfad mehr, der den Server-Stand ersetzen kann. Deckel: höchstens 200 Pushes je Lauf, der Rest im nächsten Lauf (die Pushes sind idempotent, ein Teilausfall heilt sich beim nächsten Lauf selbst).

GET-Härtung (parseFavoriten):

Ein GET gilt nur als erfolgreich, wenn HTTP 200 und kein error-Schlüssel und favorites als Liste vorhanden ist. Ein fehlender favorites-Schlüssel ist ein Fehler, keine leere Menge; parseFavoriten wirft dann CloudException, genau wie parseListe (melo_cloud_service.dart:99-100) und parseUpload (:111-112) es bereits tun. Hintergrund: der Router verdrahtet für GET /favorites hart 200 (melo_cloud.py:1292-1293), und sechs Handler desselben Servers geben Fehler im 200er-Körper zurück (:418, 491, 545, 766, 1021, 1118).

Bestandsänderung, Teil des Arbeitspakets: test/services/melo_cloud_service_test.dart:98-101 prüft heute ausdrücklich das Gegenteil (expect(parseFavoriten(jsonEncode({'status':'ok'})), isEmpty)) und wird mit umgeschrieben. Das ist kein Regressionsfehler.

Schlägt das GET fehl, wird die Phase übersprungen. Der Unterschied zur Vorfassung: das ist jetzt eine Optimierung (nichts zu tun ohne Server-Stand), keine Sicherheitsregel mehr — ein fälschlich leeres GET würde im schlimmsten Fall die lokalen Favoriten additiv hochpushen, also harmlos und idempotent.

Pull-Richtung, unauflösbare cloudIds:

Server-Favoriten, deren cloudId sich lokal auf keinen Song abbilden lässt (Download fehlgeschlagen, Titel noch nicht geladen), werden übersprungen, nicht gelöscht: Favorites.songId verweist auf Songs.id (database.dart:102-107); ein Favorit ohne Song wäre über watchFavorites() (innerJoin, :371-375) unsichtbar, würde aber über favoriteSongIds() (:534-536) weiter mitgeschleppt und wieder hochgepusht. Der additive Abgleich schreibt sie deshalb weder lokal, noch entfernt er sie serverseitig. Hinweis am selben Ort: favoriteSongIds() liefert auch Favoriten getombsteter Songs — es ist ein schlichtes select(favorites).get() (:534-537) ohne Join auf Songs.deleted, und markMissing (:237-245) setzt nur Songs.deleted, die Favorites-Zeile überlebt. Unter dem additiven Abgleich harmlos, unter jedem Voll-Ersatz nicht.

  • Songs ohne cloudId: bleiben lokal-only, tauchen in keiner Richtung im Abgleich auf.

Bekannte Einschränkung (bewusst, dokumentiert):

Der Abgleich ist rein additiv. Ein Ent-Favorisieren wirkt sofort lokal und — online — auch auf dem Server, ist aber nicht geräteübergreifend garantiert: Hält ein zweites Gerät den Favoriten noch, bringt dessen nächster Sync ihn auf allen Geräten zurück. Das gilt für offline und für online entfernte Herzen. Bewusster Tausch, wie in v2: kein Datenverlust ist wichtiger als verlässliche Lösch-Propagation. Falls das in der Praxis stört: Folgeschritt „Basis-Snapshot" (Backlog). Er lässt sich später ohne Umbau ergänzen, weil der Schreibweg dann schon der Delta-Push ist — aus set:true wird zusätzlich set:false.

(Die Vorfassung behauptete an dieser Stelle, Ent-Favorisierungen propagierten über den Sofort-Push und nur offline entfernte Herzen kämen zurück. Das war falsch und ist ersatzlos gestrichen.)

2. Playlist-Sicherung (Stufe 1, einseitig)

Playlist-Sync Stufe 1 = einseitige Sicherung. Die App meldet lokale Playlisten-Änderungen (anlegen / Song hinzufügen / Song entfernen / Reihenfolge) online fire-and-forget an den Server und persistiert die vom Server vergebene cloudId (Drift-Migration Playlists.cloudId TEXT NULL bleibt). Es findet kein Rück-Merge statt: Server-Playlisten werden nur dann lokal angelegt, wenn die lokale Playlisten-Tabelle leer ist (Neuinstallation / Wiederherstellung).

Damit entfallen ersatzlos: Namenszuordnung, Reihenfolge-Schiedsrichter („längerer Stand gewinnt"), Gleichstands-Regel, Tombstone-Semantik und die Abhängigkeit vom fehlenden Rename-Endpunkt. Die Identität stammt immer aus dem eigenen POST /playlists und ist stabil (user_playlists.id ist INTEGER PRIMARY KEY AUTOINCREMENT).

Nicht abgedeckt (dokumentiert): Umbenennungen propagieren nicht. Playlisten-Änderungen auf einem zweiten Gerät erscheinen auf dem ersten nicht. Der beidseitige Playlist-Merge ist eine eigene, spätere Spec.

Vorbedingung (Server-Auftrag, blockierend): melo_cloud.py:505 löscht user_playlist_songs für jede playlist_id ohne user-Bedingung — echte Fremddaten-Löschung. Muss geflickt sein, bevor die App diesen Endpunkt regelmäßig benutzt.

Endpunkte: GET/POST/DELETE /playlists, GET /<id>, POST /<id>/songs, DELETE /<id>/songs/<sid>, PUT /<id>/positions (melo_cloud.py:1251-1284). Einen Rename-Endpunkt gibt es nicht — kein PUT/PATCH auf die Playlist selbst; handle_rename:749 betrifft Songs. (Die Vorfassung behauptete das Gegenteil.)

Songs ohne cloudId in einer Playlist bleiben lokal in der Playlist und werden zum Server einfach nicht mitgemeldet — kein Fehler.

Ebenfalls vertretbar und heute kostenlos (0 Playlisten auf dem Server): Feature 2 ganz herausschneiden. Entscheidung liegt bei Dustin; das Weiterlaufen im Zustand der Vorfassung (vier offene Semantik-Entscheidungen) ist es nicht.

3. Upload-Auswahl („Auf den Server laden“)

  • Auswahl-Modus in „Meine Musik“ bekommt die Aktion „Auf den Server laden“. Er existiert (my_music_screen.dart:120SortableSongList, lib/shared/sortable_song_list.dart:46-54, :194-196) — zu beachten ist nur das Scoping: SortableSongList wird in fünf Ansichten benutzt, die Aktion würde sonst überall erscheinen.
  • Neue Methode SyncService.ladeAusgewaehlteHoch(List<Song> songs): nutzt den bestehenden _ladeHoch-Pfad pro Song, mit Fortschritt über die bestehenden Felder (laeuft/erledigt/gesamt/status).
  • Songs, die schon eine cloudId haben, werden übersprungen; zu große Dateien (>50 MB) einzeln als Fehler vermerkt, der Rest läuft weiter. Ergebnis-Meldung im Stil „3 hochgeladen, 2 waren schon da, 1 zu groß“.
  • Abbrechen: Der Auswahl-Upload bekommt SyncService.abbrechen() nach dem Muster von DownloadService (:46, :66-68, :96) — ein Flag, das die Schleife zwischen zwei Songs prüft. SyncService hat heute keinerlei Abbruchmöglichkeit (0 Treffer für abbrech|cancel in 376 Zeilen); ohne das ist ein versehentlich markierter 60-Titel-Upload nur durch App-Kill zu stoppen.
  • Zeitstempel: ladeAusgewaehlteHoch schreibt _letzterLauf (:215-217) nicht — es ist kein Sync. Sonst unterdrückt ein manueller Upload 15 Minuten Auto-Sync (sollAutoSync, :120-121) und verschiebt die 24-h-Uhr des Berichts; wer die App täglich zum Hochladen öffnet, sähe den Bericht nie.
  • Offline-Modus: Der Schalter (lib/services/offline_mode.dart, settings_screen.dart:218-231) wirkt heute nur auf die Wiedergabe (main.dart:106-110). Entscheidung: der Auswahl-Upload respektiert ihn nicht, weil er eine ausdrückliche Nutzeraktion ist.
  • Läuft bereits ein Sync, wird die Aktion abgewiesen („Sync läuft gerade“) — der bestehende Doppel-Lauf-Schutz des SyncService gilt.
  • Der automatische Voll-Upload beim Sync (alles ohne cloudId) bleibt unverändert bestehen.

4. Einzel-Song-Offline

  • DownloadService bekommt ladeEinzelnenTitel(SubsonicSong song):

ladeEinzelnenTitel(song) ruft den bestehenden öffentlichen DownloadService.lade([song]) (download_service.dart:72-111) auf — der bringt Doppel-Lauf-Schutz (:73), Verbindungsprüfung (:77-84) und Fortschritts-Buchführung (:85-104) mit. Nicht _ladeEinen (:113-139) direkt verdrahten.

Gleiche Ablage (App-Speicher, Application-Support/melo_downloads), gleiche Buchführung (Downloads-Tabelle per navidromeId), gleiche Fehlerbehandlung.

  • Doppelspeicherung: Vor dem Netz-Download prüft ladeEinzelnenTitel, ob die Datei bereits im Abspiel-Cache liegt — sonst entsteht genau die Doppelspeicherung, die audio_handler.dart:326-331 in der Gegenrichtung ausdrücklich verhindert („doppelter Platz und doppeltes Datenvolumen"). Falls zu teuer: als bekannte Einschränkung dokumentieren, nicht schweigen.
  • Kein Platz-Check, keine Schwelle: Die 30er-Rückfrage (download_service.dart:13, :15, :17-21, Aufrufer nur downloads_screen.dart:460 und :496) ist bei einem Einzeltitel per Konstruktion wirkungslos, und einen Check auf freien Speicher gibt es in der ganzen App nicht. Bewusst akzeptiert: der Knopf lädt genau einen Titel.
  • Offline-Modus: wird ebenfalls nicht respektiert (ausdrückliche Nutzeraktion, siehe Feature 3).
  • UI: Songzeilen in der Server-Album-/Künstler-Ansicht (server_titel_screen.dart) bekommen den Lade-Knopf, den es heute nur pro Album gibt, inklusive Zustand „schon offline“ mit Entfernen-Option (bestehendes DownloadService.entferne). _LadeKnopf ist eine private Klasse in lib/downloads/downloads_screen.dart:419-429, kein wiederverwendbares Widget — als Muster kopieren oder vorher extrahieren.

5. Sync-Bericht („Was ist neu“)

  • Rein in-App, keine neue Dependency: Ist der letzte erfolgreiche Sync >24 h her, sammelt der nächste Sync Zähler (neue Songs, gelöschte, Favoriten geändert, Playlisten geändert) und zeigt danach einmalig einen Dialog (v2-Parität „Willkommen zurück!“). Stand in SharedPreferences.
  • Erstfall: letzterLauf == null (Neuinstallation, nach Abmelden, nach App-Daten-Löschen) gilt nicht als fällig — sonst begrüßt der Bericht ein frisch installiertes Gerät mit „Willkommen zurück! 325 neue Songs". Achtung: sollAutoSync (sync_service.dart:120-121) behandelt null genau umgekehrt und ist hier kein Vorbild.
  • „Erfolgreich" heißt: alle Phasen ohne geschluckten Fehler. Dafür führt synchronisiere() ein Flag, das jede Phase bei einem Fehlschlag löscht; der Bericht-Zeitstempel wird nur bei gesetztem Flag geschrieben. Heute schreibt :215-217 denselben Zeitstempel, egal ob Phasen ausgefallen sind (_meldeLoeschungen:243-252 schluckt Fehler).
  • Die Entscheidung „Bericht fällig?“ liegt als reine Funktion (berichtFaellig) in sync_merge.dart und ist ohne Plattform-Kanäle testbar.
  • Die In-App-Fortschrittsanzeige in den Einstellungen bleibt wie heute.

Sync-Phasen nach Ausbau (Reihenfolge im Lauf)

  1. Tombstones nachziehen (bestehend)
  2. Neue Server-Songs herunterladen (bestehend)
  3. Lokale Songs ohne cloudId hochladen (bestehend)
  4. Additiver Favoriten-Abgleich (neu)
  5. Verlauf melden (bestehend)
  6. Zeitstempel + ggf. Bericht (erweitert)

Der Zeitstempel wird vor dem Listen genommen und nach allen Phasen geschrieben (heute: _letzterLauf = DateTime.now() in :215, nach allen Phasen — der Snapshot-vor-dem-Listen fehlt noch).

Die Playlist-Sicherung (Feature 2) ist keine Sync-Phase: sie läuft als Sofort-Push bei der Änderung, und die Wiederherstellung greift nur bei leerer lokaler Tabelle.

Reihenfolge (Auslieferung)

  1. Favoriten-Fix allein (additiver Delta-Push, parseFavoriten, Testanpassung). Wartet auf nichts: kein Server-Auftrag, keine neue Dependency, keine Gradle-Änderung, kein neues UI-Muster. Einzige Stufe mit belegtem Datenverlust-Bezug.
  2. Auswahl-Upload + Einzel-Song-Offline (UI, keine neue Dependency).
  3. Sync-Bericht (In-App-Dialog, kein Plugin).
  4. Playlist-Sicherung Stufe 1 — nach dem Server-Fix melo_cloud.py:505.

Ab hier nicht mehr Inhalt dieser Spec — beides steht unter §Nicht-Ziele bzw. §Spätere Stufen und ist nur der Vollständigkeit halber einsortiert:

  1. Notification Stufe B — beginnt mit dem grünen Beweis-Build.
  2. SSE — eigene Spec, nach dem song_upload-Event.

Warum genau diese Reihenfolge — Begründung, die mitgebaut werden muss:

  • Stufe 1 (A1/A2/A3) berührt den Löschpfad nicht und hängt an keiner offenen Frage. Sie behebt außerdem als einzige einen belegten Datenverlust-Bug — sie zuerst zu liefern ist auch inhaltlich richtig.
  • Der Auswahl-Upload (Feature 3) stand ursprünglich hinter der A6-Klärung, weil er mehr Titeln eine cloudId gibt und damit die Angriffsfläche des Löschpfads vergrößert. Dieser Grund ist seit dem 2026-08-27 entfallen (Löschpfad serverseitig entschärft, siehe §ERLEDIGT). Die Stufe bleibt an ihrer Position, weil Stufe 1 den einzigen belegten Bug behebt und deshalb vorne gehört — nicht mehr, weil Stufe 2 blockiert wäre.
  • Alles, was auf einen fremden Auftrag wartet (song_upload-Event) oder auf einen Build-Umbau (flutter_local_notifications), steht hinten — das betrifft die Stufen 46. Innerhalb dieser drei blockiert keine Stufe eine frühere.

Stand 2026-08-27: keine Stufe dieser Spec ist mehr blockiert. Die einzige offene Fremdabhängigkeit (song_upload-Event) betrifft ausschließlich die spätere SSE-Stufe, die nicht Teil dieser Spec ist.

Fehlerfälle — zu bauende Arbeit, keine Zusagen

Fünf in der Vorfassung als „bestehend“ beschriebene Schutzmechanismen existieren nicht und sind Teil dieser Arbeit:

  • Phasen-Isolation: synchronisiere() hat heute ein einziges try/catch um alle Phasen (sync_service.dart:188-228). Jede neue Phase bekommt ihr eigenes try/catch mit definiertem Teil-Erfolg.
  • „verzögert" gibt es nicht: if (_laeuft) return; (:175) verwirft den Anstoß wortlos. Wer einen Nachlauf will, braucht ein _nachziehen-Flag, das im finally genau einen weiteren Lauf auslöst.
  • Der Upload überlebt keinen Timeout: _ladeHoch fängt nur CloudException (:313); der 120-s-Timeout (melo_cloud_service.dart:180) wirft TimeoutException und reißt den ganzen Lauf ab. Timeouts sind wie Einzelfehler zu behandeln.
  • Die Drossel sitzt in automatisch() (:166-170), nicht in synchronisiere() (:174-180). Ein Parameter erzwinge wäre deshalb wirkungslos und entfällt ersatzlos (er stammte aus dem gestrichenen SSE-Teil).
  • Der Löschweg zum Server wird in den Fehlerfällen nirgends beschrieben — siehe §Risiken, „Irreversibler Löschpfad".

Verhalten im Einzelnen:

  • Favoriten-GET scheitert → Phase 4 überspringen, Sync läuft weiter (nichts zu tun ohne Server-Stand; ein blindes additives Pushen wäre harmlos, aber nutzlos).
  • Playlist-Endpunkt scheitert → Sofort-Push still verwerfen, nächste Änderung versucht es erneut.
  • Auswahl-Upload: Datei >50 MB, Einzel-Upload-Fehler oder Timeout → im Ergebnis vermerken, mit nächstem Song fortfahren; abbrechen() stoppt zwischen zwei Songs.
  • Einzel-Song-Offline: wie bestehender Album-Pfad (Fehler pro Titel, kein Abbruch des Rests).
  • Sync bereits aktiv → Auswahl-Upload wird abgewiesen (bestehender Schutz, :175).

Risiken

  • Die Lösch-Bremse bremst nur Server-Löschungen; lokale Tombstones laufen ungebremst — beim Ausbau nicht verschlimmern.
  • Uploads/Downloads liegen serverseitig komplett im RAM — der Auswahl-Upload bleibt sequenziell (kein Parallel-Upload), um den Server nicht aufzublähen.
  • v2-Lektionen übernehmen: Sync-Zeitstempel = Snapshot VOR dem Listen (Tombstone-Race), neue Server-Songs sofort MIT cloudId in die DB (Doppel-Download-Falle), Dateinamen-Kollisionszähler.
  • Die Sicherheit der Favoriten liegt nicht mehr in einer Regel, sondern darin, dass kein Voll-Ersatz-Aufruf mehr existiert.
  • Der Navidrome-Playlist-Import ist nicht idempotent („🌐“-Duplikate) — Bestandsfehler, von Feature 2 nicht angefasst, aber beim Testen präsent.

Löschpfad (war Backlog-P0 — am 2026-08-27 serverseitig entschärft)

Der folgende Abschnitt beschreibt den Stand VOR dem Fix. Die Kette selbst besteht unverändert — eine verschwundene lokale Datei führt weiterhin zur Server-Löschung. Was sich geändert hat: Sie ist nicht mehr irreversibel. Erneutes Hochladen derselben Datei stellt sie jetzt in Registry und Navidrome-Bibliothek wieder her (siehe §ERLEDIGT ganz oben). Der letzte Satz des Kastens („Erneutes Hochladen repariert es nicht") gilt daher nicht mehr.

markMissing (database.dart:237-245) tombstoned jeden Titel, dessen Datei der Scan nicht findet — auch unbeabsichtigt (SD-Karte nicht eingehängt, Berechtigung entzogen, Dateimanager). planeSync (sync_service.dart:68-76) macht daraus eine Server-Löschung; die Lösch-Bremse (:112-113) greift bei 325 Titeln erst ab 109 — 1 bis 108 Löschungen laufen ungebremst. Der Server löscht registry/<sid> und navidrome/music/<sid> (melo_cloud.py:356-372, :190-202); für die Datei gibt es keinen Grabstein. Erneutes Hochladen repariert es nicht: der überlebende registry.sha256 erzwingt den Dedup-Zweig (:265-267), shutil.move steht nur im Neu-Zweig (:289), os.remove(tmp) verwirft die Bytes (:312), /download antwortet dauerhaft 404 (:1400-1408). Bisher nie ausgelöst (Live-DB: SUM(deleted)=0).

Die zugehörige Rückfrage an Dustin ist beantwortet und behoben — siehe §ERLEDIGT ganz oben. Was bleibt: Die App tombstoned weiterhin großzügig (nicht eingehängte SD-Karte genügt), und die Lösch-Bremse greift weiterhin erst ab 109 von 325 Titeln. Beides ist App-Seite, bewusst unangetastet und kein Blocker — der Schaden ist jetzt reparabel. Ob die App zusätzlich vorsichtiger tombstonen soll, bleibt eine offene Backlog-Frage.

Verworfene Alternativen

Verworfen: Drei-Wege-Merge gegen einen persistierten Basis-Snapshot (neu = (lokal server) (Basis \ lokal) (Basis \ server)). Er löst zwar die Lösch-Propagation, führt aber den einzigen neuen Datenverlustpfad der ganzen Planung ein: Liefert GET /favorites fälschlich eine leere Liste, wird Basis \ server = Basis, die Formel kollabiert zu lokal Basis und löscht den gesamten bestätigten Bestand — lokal und über den Delta-Push auf dem Server und damit auf allen Geräten. Zusätzlich braucht er kontogebundenen Zustand mit Aufräumpflicht beim Abmelden; BakaAuth.abmelden (baka_auth.dart:114-120) räumt heute nichts ab und es gibt kein Token-Refresh. Für drei Nutzer, 0 Favoriten und 0 Playlisten auf dem Server ist der additive Abgleich der bessere Tausch. Der Basis-Snapshot bleibt als Folgeschritt im Backlog.

(Diese Alternative war die Haupt-Empfehlung des Review-Panels. Sie wird mit obiger Begründung abgelehnt — die separate Faktenprüfung hat nachgerechnet, dass ein fälschlich leeres GET, das parseFavoriten heute erzeugt, sie zur geräteübergreifenden Massenlöschung macht.)

Ebenfalls verworfen: der beidseitige Playlist-Merge in dieser Stufe (vier offene Semantik-Entscheidungen, siehe A5) und SSE in dieser Stufe (eigene Vorbedingung nicht erfüllt, siehe §Nicht-Ziele).

Tests

Neu:

  • sync_merge.dart: Unit-Tests für die Mengendifferenz beider Richtungen (leer×leer, einseitig, disjunkt, Songs ohne cloudId, unauflösbare Server-cloudId → übersprungen) und für berichtFaellig (null → nicht fällig, <24 h → nicht fällig, >24 h → fällig). Deterministisch, ohne I/O.
  • sync_service.dart: neue Phase mit MockClient. Zwei Pflichttests: „200 mit Fehlerkörper → Phase übersprungen, kein Push" und „200 mit favorites: [] → Phase läuft normal durch" (der zweite verhindert, dass der erste durch ein zu scharfes „wirf bei leer" trivial erfüllt wird). Dazu: Auswahl-Upload mit Mischung aus ok/zu groß/schon da, abbrechen() stoppt zwischen zwei Songs, ladeAusgewaehlteHoch schreibt _letzterLauf nicht.
  • PlaylistService — der Sofort-Push ist der einzige Online-Kanal der Spec und hatte in der Vorfassung keinen einzigen Testeintrag: toggleFavorite schickt deterministisches set:true/false, nur bei cloudId, Fehler werden geschluckt.
  • PlaylistService — Feature 2 (einseitige Sicherung): Sofort-Push je Änderungsart (Playlist anlegen → cloudId wird persistiert; Song hinzufügen/entfernen; Reihenfolge via PUT /<id>/positions), Songs ohne cloudId werden nicht mitgemeldet, Endpunkt-Fehler werden still verworfen (kein lokaler Rollback). Pflichttest für die Kernregel: Server-Playlisten werden nur angelegt, wenn die lokale Playlisten-Tabelle leer ist — Gegentest mit einer lokalen Playlist legt nichts an (kein Rück-Merge).
  • Widget-Tests: Auswahl-Modus-Aktion sichtbar + ruft Upload auf (und erscheint nicht in den vier anderen SortableSongList-Ansichten); Einzel-Song-Knopf lädt/entfernt; Bericht-Dialog erscheint nach

    24h-Marke und nicht bei letzterLauf == null.

  • Drift-Migration: Test, dass Bestandsdaten die neue Spalte überleben.

Anzupassender Bestand (die Vorfassung nannte ausschließlich neue Tests):

  • test/services/melo_cloud_service_test.dart:98-101 — zementiert das []-Verhalten, wird mit der GET-Härtung umgeschrieben.
  • test/services/sync_service_test.dart:166-202 („Favoriten werden mit ihren Server-IDs gemeldet") prüft heute genau das Full-Replace-Verhalten, das Ziel 1 beseitigt. Sein MockClient beantwortet jeden Pfad außer /list mit {'status':'ok'} (:196) — unter der neuen Regel liefert GET /favorites dort 200 ohne favorites-Schlüssel und die Phase wird übersprungen. Der Test wird auf den additiven Delta-Push umgeschrieben und bekommt eine explizite /favorites-Antwort. Der Catch-All-Mock darf nicht aufgeweicht werden, um die neue Sicherheitsregel zu umgehen — das ist der schnellste Weg zu Grün und der falsche.
  • Dasselbe gilt abgeschwächt für die übrigen sieben Tests derselben Datei.

Offene Abhängigkeiten (geschlossene Liste)

# Auftrag Art Blockiert
1 melo_cloud.py:505 — Owner-Prüfung in handle_playlist_delete Voraussetzung Feature 2
2 Datei-Wiederherstellung im Dedup-Zweig von upload() ERLEDIGT 2026-08-27 (Hermes; end-to-end verifiziert) nichts mehr
3 song_upload-SSE-Event Voraussetzung für die spätere SSE-Stufe nichts in dieser Spec
4 Playlist-Rename-Endpunkt (PUT/PATCH) optional nichts (Feature 2 braucht ihn nicht mehr)
5 Playlist-Tombstones serverseitig optional / Backlog nichts
6 Schema-Divergenz ist_korrupt / uq_user_song (nur in der Live-DB, nicht im CREATE TABLE des Skripts) optional / Backlog nichts — trifft nur wiederhergestellte oder Test-Instanzen

Backlog-Notizen

  • handle_favorites_get liefert bereits favorited_at (melo_cloud.py:620), lokal existiert Favorites.createdAtMs (database.dart:103) — die Zutat für einen späteren echten Merge ist beidseitig vorhanden, ohne dass der Server geändert werden müsste.
  • server_titel_screen._geladen ist ein Einmal-Snapshot (Bestandsfehler) — fällt beim Einbau des Einzel-Song-Knopfs auf.
  • Server-Hygiene (2026-08-27 beim Löschpfad-Test gefunden): _link_user() wird nur im Dedup-Zweig von upload() aufgerufen (der Neu-Zweig kehrt vorher zurück) und legt einen dritten Hardlink unter users/<user>/<sid>.mp3 an, den _entferne_datei_wenn_verwaist() beim Löschen nicht mit abräumt. Bisher folgenlos — das Verzeichnis war leer, weil im Normalbetrieb fast alles über den Neu-Zweig läuft. Seit dem Wiederherstellungs-Fix hinterlässt aber jeder Dedup-Upload einen verwaisten Hardlink. Auftrag an Hermes, kein Datenverlust, eilt nicht.
  • App-Seite, bewusst offen: markMissing tombstoned großzügig (eine nicht eingehängte SD-Karte genügt) und die Lösch-Bremse greift erst ab 109 von 325 Titeln. Seit dem Server-Fix ist der Schaden reparabel, also kein Blocker — ob die App trotzdem vorsichtiger tombstonen soll, ist eine eigene Entscheidung.