Files
Melo/docs/superpowers/specs/2026-08-27-sync-ausbau-design.md
T
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

700 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.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 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 /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 /<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: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<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:*
>
> 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. 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.