# 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 A1–A17 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.de` → `melo_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 0–8192 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 /favorites` → `server` (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-1302` → `handle_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 /`, `POST //songs`, `DELETE //songs/`, `PUT //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:120` → `SortableSongList`, `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 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:* > > 5. Notification Stufe B — beginnt mit dem grünen Beweis-Build. > 6. 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 4–6**. 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/` > und `navidrome/music/` (`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 //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.