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
This commit is contained in:
Hermes (Server)
2026-08-27 02:40:38 +02:00
co-authored by Claude Sonnet 5
parent 852c17cd48
commit 2a1cf9cdc2
2 changed files with 4540 additions and 163 deletions
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,11 @@
# Sync-Ausbau: Auswahl-Upload, Einzel-Song-Offline, Merge-Fixes, Echtzeit # Sync-Ausbau: Favoriten-Fix, Auswahl-Upload, Einzel-Song-Offline, Playlist-Sicherung
Status: Approved (Dustin, 2026-08-27) — vor dem Implementierungsplan noch Status: Nachgeschärft nach agent-review-panel (Urteil Phase 14, 2026-08-27:
durchs agent-review-panel (Pflicht-Workflow). 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 ## Kontext
@@ -14,10 +18,15 @@ in beide Richtungen nach (mit Lösch-Bremse) und läuft automatisch bei
App-Start/Resume (15-Minuten-Drossel) mit Fortschrittsanzeige in den App-Start/Resume (15-Minuten-Drossel) mit Fortschrittsanzeige in den
Einstellungen. Der Server hardlinkt jede hochgeladene Datei aktiv nach Einstellungen. Der Server hardlinkt jede hochgeladene Datei aktiv nach
`/home/dustin/navidrome/music` und stößt einen Navidrome-Scan an — der `/home/dustin/navidrome/music` und stößt einen Navidrome-Scan an — der
Weg Handy→Server→Navidrome-Bibliothek existiert also bereits vollständig. 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 `DownloadService` kann ganze Alben/Künstler in den App-Speicher offline
nehmen (Fortschritt + Abbrechen), dazu gibt es einen automatischen nehmen (Fortschritt + Abbrechen), dazu gibt es einen automatischen
2-GB-Abspiel-Cache. 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, Diese Spec schließt die verbliebenen Lücken (Auftrag Dustin, 2026-08-26,
im Brainstorming zerlegt und entschieden): im Brainstorming zerlegt und entschieden):
@@ -26,10 +35,47 @@ im Brainstorming zerlegt und entschieden):
|---|---| |---|---|
| Zerlegung | Erst Sync-Ausbau (diese Spec, Android), Cross-Platform Mac+Windows als **eigenes späteres Projekt** | | 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 | | 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 | | 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 | | 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 | | 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 ## Ziele
1. **Favoriten-Merge-Fix** (Datenverlust-Bug): kein Gerät überschreibt 1. **Favoriten-Merge-Fix** (Datenverlust-Bug): kein Gerät überschreibt
@@ -38,10 +84,9 @@ im Brainstorming zerlegt und entschieden):
hochladen. hochladen.
3. **Einzel-Song-Offline**: einzelne Server-Titel offline nehmen, nicht 3. **Einzel-Song-Offline**: einzelne Server-Titel offline nehmen, nicht
nur ganze Alben/Künstler. nur ganze Alben/Künstler.
4. **Playlist-Sync**: Playlisten beidseitig mit der Melo-Cloud abgleichen. 4. **Playlist-Sicherung**: lokale Playlisten überleben ein
5. **Sichtbarkeit**: persistente Sync-Notification + „Was ist neu“-Bericht. zurückgesetztes Handy (einseitig, siehe Feature 2).
6. **Echtzeit**: Änderungen anderer Geräte in Sekunden statt bis zu 15 5. **Sichtbarkeit**: „Was ist neu“-Bericht als In-App-Dialog.
Minuten (SSE), Poll bleibt Fallback.
## Nicht-Ziele ## Nicht-Ziele
@@ -52,11 +97,70 @@ im Brainstorming zerlegt und entschieden):
Server ist die Voll-Liste billig; der Delta-Endpunkt deckt zudem nur Server ist die Voll-Liste billig; der Delta-Endpunkt deckt zudem nur
Songs ab, nicht Favoriten/Playlisten. YAGNI. Songs ab, nicht Favoriten/Playlisten. YAGNI.
- **Kein Hintergrund-Sync** (WorkManager o. ä.): Sync weiterhin nur bei - **Kein Hintergrund-Sync** (WorkManager o. ä.): Sync weiterhin nur bei
geöffneter App (Start/Resume/SSE/manuell) — wie bisher und wie in v2. geöffneter App (Start/Resume/manuell) — wie bisher und wie in v2.
- **Keine Playlist-Tombstones auf dem Server**: offline gelöschte
Playlisten kommen beim nächsten Sync zurück (bekannte Einschränkung,
siehe unten) — ein Server-Tombstone-System wäre ein eigener Auftrag.
- **Kein Chunked/Resumable Upload**: 50-MB-Dateien am Stück wie bisher. - **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 ## Architektur
@@ -65,92 +169,201 @@ Rückgrat bleibt `SyncService` (`lib/services/sync_service.dart`) +
| Einheit | Datei | Verantwortung | | Einheit | Datei | Verantwortung |
|---|---|---| |---|---|---|
| Merge-Logik | `lib/services/sync_merge.dart` (neu) | Reine Funktionen ohne I/O: `favoritenVereinigung`, `playlistVereinigung`, Playlist-Zuordnung per Name. Voll unit-testbar. | | Merge-Logik | `lib/services/sync_merge.dart` (neu) | Reine Funktionen ohne I/O: `fehlendeFavoriten` (Mengendifferenz beider Richtungen), `berichtFaellig`. Voll unit-testbar. |
| Benachrichtigung | `lib/services/sync_benachrichtigung.dart` (neu) | Dünner Wrapper um `flutter_local_notifications` + reine Entscheidungslogik (wann zeigen/aktualisieren/Bericht fällig). | | Cloud-Erweiterung | `melo_cloud_service.dart` (erweitert) | `setzeFavorit(cloudId, set)` (neu, deterministisch), Playlisten-CRUD; `parseFavoriten` gehärtet. |
| Echtzeit | `lib/services/echtzeit_sync.dart` (neu) | SSE-Verbindung zu `GET /subscribe`, Event-Parser, Debounce, Reconnect-Backoff, Lifecycle-Anbindung. | | Sync-Erweiterung | `sync_service.dart` (erweitert) | Neue Phase (additiver Favoriten-Abgleich), `ladeAusgewaehlteHoch`, `abbrechen()`, Phasen-Isolation, Erfolgs-Flag. |
| Cloud-Erweiterung | `melo_cloud_service.dart` (erweitert) | Playlisten-CRUD, Favoriten-Toggle, SSE-Stream öffnen. |
| Sync-Erweiterung | `sync_service.dart` (erweitert) | Zwei neue Phasen (Favoriten-Union, Playlist-Union), `ladeAusgewaehlteHoch`, Drossel-Umgehung für SSE. |
| UI | bestehende Screens | Auswahl-Modus-Aktion, Einzel-Song-Offline-Knopf, Bericht-Dialog. | | UI | bestehende Screens | Auswahl-Modus-Aktion, Einzel-Song-Offline-Knopf, Bericht-Dialog. |
Neue Abhängigkeit: **`flutter_local_notifications`** (die einzige neue **Neue Abhängigkeit: keine.** Das war in der Vorfassung
Dependency dieser Spec). `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 ## Feature-Details
### 1. Favoriten-Merge (Datenverlust-Fix) ### 1. Favoriten-Abgleich (Datenverlust-Fix)
Heutiger Bug: `_gleicheFavoritenAb()` (sync_service.dart) POSTet die Heutiger Bug: `_gleicheFavoritenAb()` (sync_service.dart) POSTet die
lokale Favoritenliste als Komplett-Ersatz — ein frisch installiertes lokale Favoritenliste als Komplett-Ersatz — ein frisch installiertes
Gerät löscht damit beim ersten Sync alle Server-Favoriten. Gerät löscht damit beim ersten Sync alle Server-Favoriten.
Neu, zwei Mechanismen (Vorbild Melo v2): **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 - **Sofort-Push beim Antippen:** `PlaylistService.toggleFavorite` schickt
online zusätzlich fire-and-forget ein deterministisches online zusätzlich fire-and-forget ein deterministisches
`set:true/false` an den Server (Endpunkt `POST /favorites/toggle` mit `set:true/false` an den Server (`POST /favorites/toggle`).
set-Parameter existiert in melo_cloud.py). Deterministisch statt Deterministisch statt Toggle, damit ein abweichender Server-Zustand den
Toggle, damit ein abweichender Server-Zustand den Wunsch nie Wunsch nie invertiert. Nur für Songs mit cloudId; Fehler werden still
invertiert. Nur für Songs mit cloudId; Fehler werden still geschluckt geschluckt (der nächste Voll-Sync korrigiert additiv).
(der nächste Voll-Sync korrigiert). - **Additiver Abgleich beim Sync**, neue Phase in `synchronisiere()`:
- **Vereinigung beim Sync:** neue Phase in `synchronisiere()`:
1. `GET /favorites` (der bereits implementierte, bisher ungenutzte > Die Sync-Phase benutzt **nie** `POST /favorites` (Voll-Ersatz). Sie
`MeloCloudService.favoriten()`). > schreibt ausschließlich additiv:
2. `favoritenVereinigung(lokal, server)` (sync_merge.dart): Union über > 1. `GET /favorites` → `server` (Menge von cloudIds).
cloudIds. > 2. Für jede cloudId in `lokal \ server`: `POST /favorites/toggle` mit
3. Ergebnis als `POST /favorites` zum Server UND lokal übernehmen > `{"song_id": …, "set": true}` (existiert und ist deterministisch:
(fehlende Favoriten lokal setzen). > `melo_cloud.py:1298-1302` → `handle_favorites_toggle:644-688`; die
- **Sicherheitsregel (hart):** Schlägt das GET fehl, wird die gesamte > Client-Methode ist neu).
Phase übersprungen — es wird NIE ohne vorheriges erfolgreiches GET > 3. Für jede cloudId in `server \ lokal`: lokal Favorit setzen, sofern
gePOSTet. Genau das ist der heutige Bug; er darf durch keinen > der Song lokal auflösbar ist.
Fehlerpfad wieder entstehen. > 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 - Songs ohne cloudId: bleiben lokal-only, tauchen in keiner Richtung im
Abgleich auf. Abgleich auf.
- Semantik der Union: Ent-Favorisierungen propagieren über den
Sofort-Push (online) — die Sync-Union gleicht nur Hinzufügungen ab.
Offline entfernte Herzen kommen beim nächsten Sync zurück, wenn kein
Online-Push sie vorher gemeldet hat. Das ist dieselbe bewusste
v2-Semantik (kein Datenverlust > perfekte Lösch-Propagation).
### 2. Playlist-Sync **Bekannte Einschränkung (bewusst, dokumentiert):**
- **Drift-Migration:** Tabelle `Playlists` bekommt `cloudId TEXT NULL`. > Der Abgleich ist rein additiv. Ein Ent-Favorisieren wirkt sofort lokal
(Schema-Version erhöhen, Migration schreiben + testen.) > und — online — auch auf dem Server, ist aber **nicht
- **Zuordnung:** Playlisten mit cloudId sind eindeutig verbunden. Ohne > geräteübergreifend garantiert**: Hält ein zweites Gerät den Favoriten
cloudId: einmalige Zuordnung per Namensvergleich (case-insensitive, > noch, bringt dessen nächster Sync ihn auf allen Geräten zurück. Das
getrimmt); Treffer bekommt die Server-cloudId persistiert. Lokale > gilt für offline **und für online** entfernte Herzen. Bewusster Tausch,
Playlist ohne Server-Gegenstück → auf dem Server anlegen > wie in v2: kein Datenverlust ist wichtiger als verlässliche
(`POST /playlists`), cloudId übernehmen. Server-Playlist ohne lokales > Lösch-Propagation.
Gegenstück → lokal anlegen. > Falls das in der Praxis stört: Folgeschritt „Basis-Snapshot" (Backlog).
- **Song-Vereinigung:** `playlistVereinigung(lokal, server)` — Union der > Er lässt sich später **ohne Umbau** ergänzen, weil der Schreibweg dann
Songs per Song-cloudId. Reihenfolge: der längere Stand liefert die > schon der Delta-Push ist — aus `set:true` wird zusätzlich `set:false`.
Grundreihenfolge, nur auf der jeweils anderen Seite vorhandene Songs
werden hinten angehängt. Ergebnis geht an Server *(Die Vorfassung behauptete an dieser Stelle, Ent-Favorisierungen
(Playlist-Songs-Endpunkt) und in die lokale DB. propagierten über den Sofort-Push und nur offline entfernte Herzen kämen
- **Sofort-Push online:** Playlist anlegen/umbenennen/löschen sowie zurück. Das war falsch und ist ersatzlos gestrichen.)*
Song hinzufügen/entfernen werden bei bestehender Verbindung
fire-and-forget direkt zum Server durchgereicht (Endpunkte existieren: ### 2. Playlist-Sicherung (Stufe 1, einseitig)
Playlists-CRUD + songs + positions).
- **Songs ohne cloudId** in einer Playlist: bleiben lokal in der > **Playlist-Sync Stufe 1 = einseitige Sicherung.** Die App meldet lokale
Playlist, werden zum Server einfach nicht mitgemeldet — kein Fehler. > Playlisten-Änderungen (anlegen / Song hinzufügen / Song entfernen /
- **Bekannte Einschränkung (dokumentiert, akzeptiert):** Der Server hat > Reihenfolge) online fire-and-forget an den Server und persistiert die
keine Playlist-Tombstones (Löschen = hartes DELETE). Eine OFFLINE > vom Server vergebene cloudId (Drift-Migration
gelöschte Playlist kommt beim nächsten Sync vom Server zurück. > `Playlists.cloudId TEXT NULL` bleibt).
Online-Löschungen greifen sofort und dauerhaft. Falls das in der > Es findet **kein Rück-Merge** statt: Server-Playlisten werden nur dann
Praxis stört: Server-Tombstones als separater Auftrag an > lokal angelegt, wenn die lokale Playlisten-Tabelle **leer** ist
Hermes/claude-server. > (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“) ### 3. Upload-Auswahl („Auf den Server laden“)
- Auswahl-Modus in „Meine Musik“ (existiert, siehe - Auswahl-Modus in „Meine Musik“ bekommt die Aktion **„Auf den Server
`auswahl_modus_test.dart`) bekommt die Aktion **„Auf den Server laden“**. Er existiert (`my_music_screen.dart:120`
laden“**. `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)`: - Neue Methode `SyncService.ladeAusgewaehlteHoch(List<Song> songs)`:
nutzt den bestehenden `_ladeHoch`-Pfad pro Song, mit Fortschritt über nutzt den bestehenden `_ladeHoch`-Pfad pro Song, mit Fortschritt über
die bestehenden Felder (`laeuft/erledigt/gesamt/status`) und der neuen die bestehenden Felder (`laeuft/erledigt/gesamt/status`).
Notification.
- Songs, die schon eine cloudId haben, werden übersprungen; zu große - Songs, die schon eine cloudId haben, werden übersprungen; zu große
Dateien (>50 MB) einzeln als Fehler vermerkt, der Rest läuft weiter. 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ß“. 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 - Läuft bereits ein Sync, wird die Aktion abgewiesen („Sync läuft
gerade“) — der bestehende Doppel-Lauf-Schutz des SyncService gilt. gerade“) — der bestehende Doppel-Lauf-Schutz des SyncService gilt.
- Der automatische Voll-Upload beim Sync (alles ohne cloudId) bleibt - Der automatische Voll-Upload beim Sync (alles ohne cloudId) bleibt
@@ -158,88 +371,156 @@ Neu, zwei Mechanismen (Vorbild Melo v2):
### 4. Einzel-Song-Offline ### 4. Einzel-Song-Offline
- `DownloadService` bekommt `ladeEinzelnenTitel(SubsonicSong song)` - `DownloadService` bekommt `ladeEinzelnenTitel(SubsonicSong song)`:
der interne Pro-Song-Lade-Loop existiert bereits (Album-Pfad), wird
nur als Einzel-API zugänglich. Gleiche Ablage (App-Speicher, > `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 Application-Support/melo_downloads), gleiche Buchführung
(Downloads-Tabelle per navidromeId), gleiche Fehlerbehandlung. (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 - UI: Songzeilen in der Server-Album-/Künstler-Ansicht
(`server_titel_screen.dart`) bekommen den Lade-Knopf, den es heute nur (`server_titel_screen.dart`) bekommen den Lade-Knopf, den es heute nur
pro Album gibt (Muster `_LadeKnopf`), inklusive Zustand pro Album gibt, inklusive Zustand „schon offline“ mit Entfernen-Option
„schon offline“ mit Entfernen-Option (bestehendes (bestehendes `DownloadService.entferne`). **`_LadeKnopf` ist eine
`DownloadService.entferne`). private Klasse in `lib/downloads/downloads_screen.dart:419-429`, kein
wiederverwendbares Widget** — als Muster kopieren oder vorher
extrahieren.
### 5. Fortschritts-Notification + Sync-Bericht ### 5. Sync-Bericht („Was ist neu“)
- **`SyncBenachrichtigung`** (Wrapper um `flutter_local_notifications`, - Rein in-App, **keine neue Dependency**: Ist der letzte erfolgreiche
eigener Channel z. B. `de.baka.melo.sync`): Sync >24 h her, sammelt der nächste Sync Zähler (neue Songs,
- Während `SyncService.laeuft`: persistente (ongoing) Notification gelöschte, Favoriten geändert, Playlisten geändert) und zeigt danach
„Synchronisiere… X/Y“ mit Fortschrittsbalken, aktualisiert über die einmalig einen Dialog (v2-Parität „Willkommen zurück!“). Stand in
bestehenden ChangeNotifier-Felder.
- Bei Abschluss: kurze Erfolgs-Notification (bzw. Fehlertext), nicht
persistent, tippbar → App öffnen.
- Benachrichtigungs-Berechtigung wird seit dem Berechtigungs-Feature
beim App-Start angefragt; verweigert → stiller Verzicht, die
In-App-Anzeige in den Einstellungen bleibt wie heute.
- Die ENTSCHEIDUNGEN (wann zeigen, wann aktualisieren, wann Bericht
fällig) liegen als reine Funktionen in derselben Datei und sind ohne
Plattform-Kanäle testbar; der Plugin-Aufruf selbst bleibt dünn.
- **Sync-Bericht („Was ist neu“):** 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. 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.
### 6. Echtzeit per SSE ## Sync-Phasen nach Ausbau (Reihenfolge im Lauf)
- **`EchtzeitSync`**: öffnet im Vordergrund `GET /subscribe`
(Bearer-Header; `http`-Paket, `client.send()` → StreamedResponse,
zeilenweises SSE-Parsing — keine neue Dependency). Server schickt
Heartbeat alle 30 s.
- Events (`song_delete`, `song_update`, `song_favorite` + das neue
Upload-Event, s. u.): 3 s Debounce (Bursts bündeln), dann
`SyncService.synchronisiere()` mit neuem Parameter
`erzwinge: true`, der die 15-Minuten-Drossel (`sollAutoSync`) umgeht.
Der Event-INHALT wird bewusst nicht einzeln angewendet — ein
angestoßener Voll-Sync ist robuster als Event-Replays (Events sind
serverseitig lückenhaft, siehe Bestandsaufnahme).
- Lifecycle: verbinden bei `resumed`, trennen bei `paused` (kein
Hintergrund-Socket, kein Akku-Fresser).
- Verbindungsabriss → Reconnect mit exponentiellem Backoff (Start 5 s,
Deckel 5 min). SSE komplett tot → App verhält sich exakt wie heute
(Poll bei Start/Resume).
- **Server-Auftrag (separat, an Hermes/claude-server — nicht Teil des
App-Plans):** `melo_cloud.py` feuert beim Upload bisher KEIN
SSE-Event (`_emit_event` fehlt in `upload()`); ein `song_upload`-Event
ergänzen. Die App funktioniert auch ohne (Poll-Fallback), aber
„neuer Song erscheint in Sekunden auf dem anderen Gerät“ braucht es.
## Sync-Phasen nach Ausbau (Reihenfolge)
1. Tombstones nachziehen (bestehend) 1. Tombstones nachziehen (bestehend)
2. Neue Server-Songs herunterladen (bestehend) 2. Neue Server-Songs herunterladen (bestehend)
3. Lokale Songs ohne cloudId hochladen (bestehend) 3. Lokale Songs ohne cloudId hochladen (bestehend)
4. **Favoriten-Vereinigung (neu)** 4. **Additiver Favoriten-Abgleich (neu)**
5. **Playlist-Vereinigung (neu)** 5. Verlauf melden (bestehend)
6. Verlauf melden (bestehend) 6. Zeitstempel + ggf. Bericht (erweitert)
7. Zeitstempel + ggf. Bericht (erweitert)
## Fehlerfälle 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).
- Favoriten-GET scheitert → Phase 4 komplett überspringen (nie blind Die Playlist-Sicherung (Feature 2) ist **keine Sync-Phase**: sie läuft
POSTen), Sync läuft weiter. als Sofort-Push bei der Änderung, und die Wiederherstellung greift nur
- Playlist-Endpunkt scheitert → Phase 5 überspringen, Sync läuft weiter. bei leerer lokaler Tabelle.
- Auswahl-Upload: Datei >50 MB oder Einzel-Upload-Fehler → im Ergebnis
vermerken, mit nächstem Song fortfahren. ## 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 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, - Einzel-Song-Offline: wie bestehender Album-Pfad (Fehler pro Titel,
kein Abbruch des Rests). kein Abbruch des Rests).
- SSE nicht erreichbar/abgerissen → Backoff-Reconnect, still; kein - Sync bereits aktiv → Auswahl-Upload wird abgewiesen (bestehender
Nutzer-Fehler, Poll bleibt. Schutz, `:175`).
- Notification-Berechtigung verweigert → In-App-Fortschritt wie heute.
- Sync bereits aktiv → Auswahl-Upload/SSE-Anstoß werden abgewiesen bzw.
verzögert (bestehender Schutz).
## Risiken (aus der Bestandsaufnahme, im Plan zu beachten) ## Risiken
- Die Lösch-Bremse bremst nur Server-Löschungen; lokale Tombstones - Die Lösch-Bremse bremst nur Server-Löschungen; lokale Tombstones
laufen ungebremst — beim Ausbau nicht verschlimmern. laufen ungebremst — beim Ausbau nicht verschlimmern.
@@ -249,32 +530,125 @@ Neu, zwei Mechanismen (Vorbild Melo v2):
- v2-Lektionen übernehmen: Sync-Zeitstempel = Snapshot VOR dem Listen - v2-Lektionen übernehmen: Sync-Zeitstempel = Snapshot VOR dem Listen
(Tombstone-Race), neue Server-Songs sofort MIT cloudId in die DB (Tombstone-Race), neue Server-Songs sofort MIT cloudId in die DB
(Doppel-Download-Falle), Dateinamen-Kollisionszähler. (Doppel-Download-Falle), Dateinamen-Kollisionszähler.
- `POST /favorites` bleibt technisch ein Voll-Ersatz — die Sicherheit - Die Sicherheit der Favoriten liegt **nicht mehr in einer Regel**,
liegt allein in der GET-vor-POST-Regel. Tests müssen genau diesen sondern darin, dass kein Voll-Ersatz-Aufruf mehr existiert.
Pfad absichern. - 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 ## Tests
- `sync_merge.dart`: Unit-Tests für Favoriten-Union (leer×leer, **Neu:**
einseitig, disjunkt, Songs ohne cloudId), Playlist-Union
(Reihenfolge-Regel, Erst-Zuordnung per Name, Groß/Kleinschreibung, - `sync_merge.dart`: Unit-Tests für die Mengendifferenz beider
Namens-Kollision), deterministisch, ohne I/O. Richtungen (leer×leer, einseitig, disjunkt, Songs ohne cloudId,
- `echtzeit_sync.dart`: SSE-Zeilen-Parser (event/data/Heartbeat/ unauflösbare Server-cloudId → übersprungen) und für `berichtFaellig`
Fragmentierung), Debounce- und Backoff-Entscheidungen als reine (`null` → nicht fällig, <24 h → nicht fällig, >24 h → fällig).
Funktionen. Deterministisch, ohne I/O.
- `sync_service.dart`: neue Phasen mit MockClient (inkl. GET-Fehler → - `sync_service.dart`: neue Phase mit MockClient. **Zwei Pflichttests:**
kein POST; Auswahl-Upload mit Mischung aus ok/zu groß/schon da). „200 mit Fehlerkörper → Phase übersprungen, kein Push" und „200 mit
- `sync_benachrichtigung.dart`: Entscheidungslogik (zeigen/aktualisieren/ `favorites: []` → Phase läuft normal durch" (der zweite verhindert,
Bericht fällig) pur; Plugin-Aufrufe nicht getestet (dünner Wrapper). dass der erste durch ein zu scharfes „wirf bei leer" trivial erfüllt
- Widget-Tests: Auswahl-Modus-Aktion sichtbar + ruft Upload auf; 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 Einzel-Song-Knopf lädt/entfernt; Bericht-Dialog erscheint nach
>24h-Marke. >24h-Marke und nicht bei `letzterLauf == null`.
- Drift-Migration: Test, dass Bestandsdaten die neue Spalte überleben. - Drift-Migration: Test, dass Bestandsdaten die neue Spalte überleben.
## Offene Abhängigkeiten **Anzupassender Bestand** (die Vorfassung nannte ausschließlich neue
Tests):
1. **Server: `song_upload`-SSE-Event** in `melo_cloud.py` (Hermes / > - `test/services/melo_cloud_service_test.dart:98-101` — zementiert das
claude-server) — App funktioniert ohne, Echtzeit für neue Songs > `[]`-Verhalten, wird mit der GET-Härtung umgeschrieben.
braucht es. > - `test/services/sync_service_test.dart:166-202` („Favoriten werden mit
2. Optional/nachrangig (nur falls Praxisproblem): Playlist-Tombstones > ihren Server-IDs gemeldet") prüft heute genau das
serverseitig. > 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.