Files
Melo/docs/superpowers/specs/2026-08-27-sync-ausbau-design.md
T
Hermes (Server)andClaude Sonnet 5 2a1cf9cdc2 Sync-Ausbau: Spec nachgeschärft + Implementierungsplan (13 Tasks)
Spec nach dem Review-Panel-Urteil überarbeitet (alle 17 Aktionspunkte).
Der Umfang schrumpft deutlich: 6→5 Ziele, 4→2 neue Dateien, neue
Dependencies 1→0. SSE, persistente Notification und der beidseitige
Playlist-Merge werden spätere Stufen; der Favoriten-Fix schreibt jetzt
additiv (POST /favorites gestrichen), womit der Datenverlust-Bug
strukturell unmöglich wird statt nur per Regel verhindert.

Implementierungsplan: 13 Tasks nach TDD, Favoriten-Fix zuerst (hängt an
keiner offenen Frage). Gegengelesen und geprüft; die Prüfung fand drei
echte Server-Vertragsfehler, die in den eigenen Tests grün geworden
wären (playlist.id statt id, positions statt song_ids, not_found ohne
error-Feld) — alle korrigiert und am Servercode belegt.

OFFEN: Rückfrage an Dustin zum irreversiblen Löschpfad (siehe Spec,
Abschnitt "OFFENE ENTSCHEIDUNG"). Tasks 5-7 und 10-12 sind bis dahin
als blockiert markiert; Tasks 1-4 können sofort starten.

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

35 KiB
Raw Blame History

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

Status: Nachgeschärft nach agent-review-panel (Urteil Phase 14, 2026-08-27: Score 6/10, Verdikt „Spec nachschärfen dann freigeben"). Alle 17 Aktionspunkte A1A17 sind eingearbeitet. Bedingung des Richters: diese Fassung wird einmal kurz gegengelesen, bevor der Implementierungsplan entsteht — A1, A5 und A7 ändern, was gebaut wird, nicht nur wie es beschrieben ist.

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).

OFFENE ENTSCHEIDUNG — Rückfrage an Dustin (noch nicht beantwortet)

Ist der irreversible Löschpfad gewollt? Verschwindet eine lokale Datei (SD-Karte nicht eingehängt, Berechtigung entzogen, Dateimanager), löscht der Sync sie auf dem Server — und damit auch aus der Navidrome-Bibliothek. Erneutes Hochladen repariert das nachweislich nicht. Die technische Kette steht vollständig belegt unter §Risiken → „Irreversibler Löschpfad".

  • Antwort „ja, gewollt“ → es ist eine dokumentierte Eigenschaft, nichts weiter zu tun.
  • Antwort „nein“ → ein Server-Auftrag (Dedup-Zweig stellt die Datei wieder her, wenn registry_pfad(sid) leer ist) gehört vor Feature 3.

Kein Reviewer kann das entscheiden — es ist eine Produktfrage. Stand: unbeantwortet. Die Reihenfolge in §Reihenfolge ist bewusst so gewählt, dass mit Stufe 1 begonnen werden kann, ohne dass die Antwort vorliegt.

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. Deshalb kann mit der Umsetzung begonnen werden, ohne dass die Antwort auf die A6-Rückfrage vorliegt. Sie behebt außerdem als einzige einen belegten Datenverlust-Bug — sie zuerst zu liefern ist auch inhaltlich richtig.
  • Der Auswahl-Upload (Feature 3) kommt bewusst NACH der Klärung der A6-Rückfrage: Er gibt mehr Titeln eine cloudId und vergrößert damit genau die Angriffsfläche des irreversiblen Löschpfads. Lautet die Antwort „nein, nicht gewollt“, gehört der Server-Auftrag (Datei-Wiederherstellung im Dedup-Zweig) davor.
  • Alles, was auf einen fremden Auftrag wartet (Server-Fix :505, 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. Für Stufe 2 gilt das ausdrücklich nicht: sie wartet auf die A6-Antwort (Absatz darüber), und Stufe 4 hängt zusätzlich am Server-Fix :505 (§Offene Abhängigkeiten, Zeile 1).

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.

Irreversibler Löschpfad (Bestand, nicht von dieser Spec verursacht — Backlog-P0)

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 noch offen — siehe §OFFENE ENTSCHEIDUNG ganz oben.

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() Voraussetzung, falls die Antwort auf die offene Entscheidung „nein" lautet Feature 3
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.