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:
co-authored by
Claude Sonnet 5
parent
852c17cd48
commit
2a1cf9cdc2
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 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
|
## 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 0–8192 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 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,
|
- 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user