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
|
||||
durchs agent-review-panel (Pflicht-Workflow).
|
||||
Status: Nachgeschärft nach agent-review-panel (Urteil Phase 14, 2026-08-27:
|
||||
Score 6/10, Verdikt „Spec nachschärfen dann freigeben"). Alle 17
|
||||
Aktionspunkte A1–A17 sind eingearbeitet. Bedingung des Richters: diese
|
||||
Fassung wird **einmal kurz gegengelesen**, bevor der Implementierungsplan
|
||||
entsteht — A1, A5 und A7 ändern, *was* gebaut wird, nicht nur wie es
|
||||
beschrieben ist.
|
||||
|
||||
## Kontext
|
||||
|
||||
@@ -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
|
||||
Einstellungen. Der Server hardlinkt jede hochgeladene Datei aktiv nach
|
||||
`/home/dustin/navidrome/music` und stößt einen Navidrome-Scan an — der
|
||||
Weg Handy→Server→Navidrome-Bibliothek existiert also bereits 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
|
||||
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,
|
||||
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** |
|
||||
| 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 |
|
||||
| Playlist-Konflikte | Vereinigung (Union) wie bei Favoriten; Reihenfolge: längerer Stand gewinnt |
|
||||
| Umfang | Einzel-Song-Offline + Playlist-Sync + Fortschritts-Notification/Sync-Bericht + Echtzeit-SSE; Favoriten-Merge-Fix immer dabei *(durch Review überholt — siehe unten)* |
|
||||
| Playlist-Konflikte | Vereinigung (Union) wie bei Favoriten; Reihenfolge: längerer Stand gewinnt *(durch Review überholt — siehe unten)* |
|
||||
| Bau-Ansatz | Ansatz 1: inkrementeller Ausbau des bestehenden `SyncService`, neue Logik in kleinen separaten Einheiten |
|
||||
|
||||
*Die Tabelle gibt den Stand des Brainstormings wieder — die Zeilen
|
||||
„Umfang" und „Playlist-Konflikte" sind durch das Review überholt, die
|
||||
Korrektur steht direkt darunter.*
|
||||
|
||||
**Was das Review daran geändert hat** (die Zerlegung, die Upload-UX und
|
||||
der Bau-Ansatz bleiben unangetastet):
|
||||
|
||||
- Der **Umfang** schrumpft: SSE und die persistente Notification werden
|
||||
eigene spätere Stufen (§Spätere Stufen), Playlist-Sync wird auf eine
|
||||
einseitige Sicherung reduziert.
|
||||
- Die **Union-Semantik bei Favoriten bleibt genau wie entschieden** —
|
||||
nur der Schreibweg wechselt vom Voll-Ersatz auf additive Pushes (A1).
|
||||
Das ist keine Überstimmung der Auftraggeber-Entscheidung, sondern ihre
|
||||
Präzisierung.
|
||||
- Die **Playlist-Konfliktregel** („längerer Stand gewinnt") entfällt
|
||||
ersatzlos, weil es ohne Rück-Merge keinen Konflikt mehr gibt (A5).
|
||||
|
||||
## OFFENE ENTSCHEIDUNG — Rückfrage an Dustin (noch nicht beantwortet)
|
||||
|
||||
> **Ist der irreversible Löschpfad gewollt?**
|
||||
> Verschwindet eine lokale Datei (SD-Karte nicht eingehängt, Berechtigung
|
||||
> entzogen, Dateimanager), löscht der Sync sie auf dem Server — und damit
|
||||
> auch aus der Navidrome-Bibliothek. Erneutes Hochladen repariert das
|
||||
> nachweislich **nicht**. Die technische Kette steht vollständig belegt
|
||||
> unter §Risiken → „Irreversibler Löschpfad".
|
||||
>
|
||||
> - **Antwort „ja, gewollt“** → es ist eine dokumentierte Eigenschaft,
|
||||
> nichts weiter zu tun.
|
||||
> - **Antwort „nein“** → ein Server-Auftrag (Dedup-Zweig stellt die Datei
|
||||
> wieder her, wenn `registry_pfad(sid)` leer ist) gehört **vor**
|
||||
> Feature 3.
|
||||
>
|
||||
> Kein Reviewer kann das entscheiden — es ist eine Produktfrage.
|
||||
> **Stand: unbeantwortet.** Die Reihenfolge in §Reihenfolge ist bewusst
|
||||
> so gewählt, dass mit Stufe 1 begonnen werden kann, ohne dass die
|
||||
> Antwort vorliegt.
|
||||
|
||||
## Ziele
|
||||
|
||||
1. **Favoriten-Merge-Fix** (Datenverlust-Bug): kein Gerät überschreibt
|
||||
@@ -38,10 +84,9 @@ im Brainstorming zerlegt und entschieden):
|
||||
hochladen.
|
||||
3. **Einzel-Song-Offline**: einzelne Server-Titel offline nehmen, nicht
|
||||
nur ganze Alben/Künstler.
|
||||
4. **Playlist-Sync**: Playlisten beidseitig mit der Melo-Cloud abgleichen.
|
||||
5. **Sichtbarkeit**: persistente Sync-Notification + „Was ist neu“-Bericht.
|
||||
6. **Echtzeit**: Änderungen anderer Geräte in Sekunden statt bis zu 15
|
||||
Minuten (SSE), Poll bleibt Fallback.
|
||||
4. **Playlist-Sicherung**: lokale Playlisten überleben ein
|
||||
zurückgesetztes Handy (einseitig, siehe Feature 2).
|
||||
5. **Sichtbarkeit**: „Was ist neu“-Bericht als In-App-Dialog.
|
||||
|
||||
## Nicht-Ziele
|
||||
|
||||
@@ -52,11 +97,70 @@ im Brainstorming zerlegt und entschieden):
|
||||
Server ist die Voll-Liste billig; der Delta-Endpunkt deckt zudem nur
|
||||
Songs ab, nicht Favoriten/Playlisten. YAGNI.
|
||||
- **Kein Hintergrund-Sync** (WorkManager o. ä.): Sync weiterhin nur bei
|
||||
geöffneter App (Start/Resume/SSE/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.
|
||||
geöffneter App (Start/Resume/manuell) — wie bisher und wie in v2.
|
||||
- **Kein Chunked/Resumable Upload**: 50-MB-Dateien am Stück wie bisher.
|
||||
- **Kein Echtzeit-Sync (SSE) in dieser Stufe.** Der beworbene
|
||||
Haupt-Nutzen („neuer Song erscheint in Sekunden auf dem anderen
|
||||
Gerät") hängt am `song_upload`-Event, das `melo_cloud.py` heute nicht
|
||||
feuert (`_emit_event` nur bei `:393`, `:410`, `:687`) und das diese
|
||||
Spec ausdrücklich auslagert — am Auslieferungstag könnte SSE also
|
||||
genau das nicht, wofür es gebaut wird. Die drei vorhandenen Kanäle
|
||||
(`song_delete`, `song_update`, `song_favorite`) haben heute keinen
|
||||
Adressaten (Live-Stand: ein Nutzer mit Songs, 0 gelöschte,
|
||||
0 Favoriten, 0 Playlisten). Dem stehen acht eigene Fehlerklassen
|
||||
gegenüber: eigener `http.Client` (der geteilte aus
|
||||
`melo_cloud_service.dart:82-83` würde beim `close()` laufende
|
||||
Sync-Requests mitreißen), Heartbeat-Watchdog gegen den halboffenen
|
||||
Socket nach WLAN↔LTE, dauerhafter 401-Ausstieg (`baka_auth.dart` hat
|
||||
kein Refresh), Selbst-Echo-Unterdrückung (`melo_realtime.py:56-70`
|
||||
broadcastet an alle Queues des Nutzers ohne Absenderkennung),
|
||||
Timer-Abbau bei `paused`, Debounce, Backoff, Nachhol-Anstoß.
|
||||
**Ehrlicher Preis:** Änderungen anderer Geräte erscheinen beim
|
||||
nächsten App-Start oder Zurückkehren — genau wie heute. Es gibt keinen
|
||||
billigeren Poll-Ersatz: `automatisch()` läuft nur aus `initState` und
|
||||
`resumed` (`main.dart:175`, `:203`), ein Timer existiert nicht.
|
||||
- **Keine persistente Fortschritts-Notification in dieser Stufe** — sie
|
||||
ist die einzige Quelle einer neuen Dependency und wird eigene Stufe
|
||||
(§Spätere Stufen, Stufe B).
|
||||
- **Kein beidseitiger Playlist-Merge** — eigene, spätere Spec (A5).
|
||||
- **Keine Playlist-Tombstones auf dem Server**: ein
|
||||
Server-Tombstone-System wäre ein eigener Auftrag.
|
||||
- **Kein Rollback auf eine ältere App-Version** nach der
|
||||
Schema-Migration: `database.dart:154-192` kennt nur `onCreate` und
|
||||
`onUpgrade`.
|
||||
|
||||
## Spätere Stufen (bewusst nach hinten geschoben, nicht verworfen)
|
||||
|
||||
| Stufe | Inhalt | Vorbedingung |
|
||||
|---|---|---|
|
||||
| Notification Stufe B | persistente Fortschritts-Notification via `flutter_local_notifications` | grüner Beweis-Build (siehe unten) |
|
||||
| SSE | Echtzeit-Sync, eigene Spec | `song_upload`-Event steht in `melo_cloud.py` |
|
||||
| Playlist-Merge | beidseitiger Abgleich, eigene Spec | Rename-Endpunkt + Tombstones serverseitig |
|
||||
| Basis-Snapshot | verlässliche Lösch-Propagation bei Favoriten | erst wenn die additive Semantik in der Praxis stört |
|
||||
|
||||
**Notification Stufe B, Details (eigenes Arbeitspaket):** Sie erzwingt
|
||||
`flutter_local_notifications` und damit
|
||||
`isCoreLibraryDesugaringEnabled = true` plus
|
||||
`coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")` in
|
||||
`android/app/build.gradle.kts` — die Datei hat heute überhaupt keinen
|
||||
`dependencies { }`-Block, der Gradle-Heap steht auf 2 GB
|
||||
(`gradle.properties:1`, dokumentierte OOM-Quelle), AGP ist 9.0.1
|
||||
(`settings.gradle.kts:22`) und android-37 ist ein Nachbau von 36.
|
||||
**Erstes Abnahmekriterium der Stufe B ist ein grüner
|
||||
`flutter build apk --target-platform android-arm64` nach dem `pub add` —
|
||||
vor jeder Zeile Notification-Logik.** `multiDexEnabled` entfällt aus der
|
||||
Forderung: bei `minSdkVersion = 24` (`FlutterExtension.kt:26`) ist es
|
||||
gegenstandslos. Mitzuentscheiden: Channel mit `Importance.low`,
|
||||
`onlyAlertOnce: true`, und ein `cancel()` beim Init gegen die nach
|
||||
App-Kill stehengebliebene Fortschritts-Notification.
|
||||
|
||||
**SSE später:** Die acht Härtungs-Bausteine gehören dann in **jene**
|
||||
Spec, nicht in einen Plan zu dieser. Vorbedingung außerdem: der additive
|
||||
Delta-Push (Feature 1) feuert je gepushtem Favoriten ein
|
||||
`song_favorite`-Event (`melo_cloud.py:687`) — beim ersten Lauf nach der
|
||||
Umstellung also so viele Events wie lokale Favoriten. Ohne SSE ist das
|
||||
folgenlos; kommt SSE, muss die Selbst-Echo-Unterdrückung **vorher**
|
||||
stehen.
|
||||
|
||||
## Architektur
|
||||
|
||||
@@ -65,92 +169,201 @@ Rückgrat bleibt `SyncService` (`lib/services/sync_service.dart`) +
|
||||
|
||||
| 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. |
|
||||
| Benachrichtigung | `lib/services/sync_benachrichtigung.dart` (neu) | Dünner Wrapper um `flutter_local_notifications` + reine Entscheidungslogik (wann zeigen/aktualisieren/Bericht fällig). |
|
||||
| Echtzeit | `lib/services/echtzeit_sync.dart` (neu) | SSE-Verbindung zu `GET /subscribe`, Event-Parser, Debounce, Reconnect-Backoff, Lifecycle-Anbindung. |
|
||||
| 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. |
|
||||
| Merge-Logik | `lib/services/sync_merge.dart` (neu) | Reine Funktionen ohne I/O: `fehlendeFavoriten` (Mengendifferenz beider Richtungen), `berichtFaellig`. Voll unit-testbar. |
|
||||
| Cloud-Erweiterung | `melo_cloud_service.dart` (erweitert) | `setzeFavorit(cloudId, set)` (neu, deterministisch), Playlisten-CRUD; `parseFavoriten` gehärtet. |
|
||||
| Sync-Erweiterung | `sync_service.dart` (erweitert) | Neue Phase (additiver Favoriten-Abgleich), `ladeAusgewaehlteHoch`, `abbrechen()`, Phasen-Isolation, Erfolgs-Flag. |
|
||||
| UI | bestehende Screens | Auswahl-Modus-Aktion, Einzel-Song-Offline-Knopf, Bericht-Dialog. |
|
||||
|
||||
Neue Abhängigkeit: **`flutter_local_notifications`** (die einzige neue
|
||||
Dependency dieser Spec).
|
||||
**Neue Abhängigkeit: keine.** Das war in der Vorfassung
|
||||
`flutter_local_notifications` und damit der einzige harte Build-Blocker
|
||||
der ganzen Planung; er wandert mit Stufe B nach hinten.
|
||||
|
||||
Ebenfalls entfallen gegenüber der Vorfassung: `echtzeit_sync.dart` (A7)
|
||||
und `sync_benachrichtigung.dart` (A8). Die einzige verbliebene reine
|
||||
Entscheidungsfunktion des Berichts (`berichtFaellig`) wohnt in
|
||||
`sync_merge.dart` — eine eigene Datei für eine Funktion wäre Abstraktion
|
||||
ohne zweiten Aufrufer.
|
||||
|
||||
## Feature-Details
|
||||
|
||||
### 1. Favoriten-Merge (Datenverlust-Fix)
|
||||
### 1. Favoriten-Abgleich (Datenverlust-Fix)
|
||||
|
||||
Heutiger Bug: `_gleicheFavoritenAb()` (sync_service.dart) POSTet die
|
||||
lokale Favoritenliste als Komplett-Ersatz — ein frisch installiertes
|
||||
Gerät löscht damit beim ersten Sync alle Server-Favoriten.
|
||||
|
||||
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
|
||||
online zusätzlich fire-and-forget ein deterministisches
|
||||
`set:true/false` an den Server (Endpunkt `POST /favorites/toggle` mit
|
||||
set-Parameter existiert in melo_cloud.py). Deterministisch statt
|
||||
Toggle, damit ein abweichender Server-Zustand den Wunsch nie
|
||||
invertiert. Nur für Songs mit cloudId; Fehler werden still geschluckt
|
||||
(der nächste Voll-Sync korrigiert).
|
||||
- **Vereinigung beim Sync:** neue Phase in `synchronisiere()`:
|
||||
1. `GET /favorites` (der bereits implementierte, bisher ungenutzte
|
||||
`MeloCloudService.favoriten()`).
|
||||
2. `favoritenVereinigung(lokal, server)` (sync_merge.dart): Union über
|
||||
cloudIds.
|
||||
3. Ergebnis als `POST /favorites` zum Server UND lokal übernehmen
|
||||
(fehlende Favoriten lokal setzen).
|
||||
- **Sicherheitsregel (hart):** Schlägt das GET fehl, wird die gesamte
|
||||
Phase übersprungen — es wird NIE ohne vorheriges erfolgreiches GET
|
||||
gePOSTet. Genau das ist der heutige Bug; er darf durch keinen
|
||||
Fehlerpfad wieder entstehen.
|
||||
`set:true/false` an den Server (`POST /favorites/toggle`).
|
||||
Deterministisch statt Toggle, damit ein abweichender Server-Zustand den
|
||||
Wunsch nie invertiert. Nur für Songs mit cloudId; Fehler werden still
|
||||
geschluckt (der nächste Voll-Sync korrigiert additiv).
|
||||
- **Additiver Abgleich beim Sync**, neue Phase in `synchronisiere()`:
|
||||
|
||||
> Die Sync-Phase benutzt **nie** `POST /favorites` (Voll-Ersatz). Sie
|
||||
> schreibt ausschließlich additiv:
|
||||
> 1. `GET /favorites` → `server` (Menge von cloudIds).
|
||||
> 2. Für jede cloudId in `lokal \ server`: `POST /favorites/toggle` mit
|
||||
> `{"song_id": …, "set": true}` (existiert und ist deterministisch:
|
||||
> `melo_cloud.py:1298-1302` → `handle_favorites_toggle:644-688`; die
|
||||
> Client-Methode ist neu).
|
||||
> 3. Für jede cloudId in `server \ lokal`: lokal Favorit setzen, sofern
|
||||
> der Song lokal auflösbar ist.
|
||||
> 4. **In keiner Richtung wird etwas entfernt.**
|
||||
>
|
||||
> `MeloCloudService.setzeFavoriten` (`melo_cloud_service.dart:268-280`)
|
||||
> wird nicht mehr benutzt und entfällt.
|
||||
>
|
||||
> **Wirkung:** Der Datenverlust-Bug aus Ziel 1 ist danach **strukturell**
|
||||
> unmöglich, nicht nur durch eine Regel verhindert — es existiert kein
|
||||
> Codepfad mehr, der den Server-Stand ersetzen kann. Deckel: höchstens
|
||||
> 200 Pushes je Lauf, der Rest im nächsten Lauf (die Pushes sind
|
||||
> idempotent, ein Teilausfall heilt sich beim nächsten Lauf selbst).
|
||||
|
||||
**GET-Härtung (`parseFavoriten`):**
|
||||
|
||||
> Ein GET gilt nur als erfolgreich, wenn HTTP 200 **und** kein
|
||||
> `error`-Schlüssel **und** `favorites` als Liste vorhanden ist. Ein
|
||||
> fehlender `favorites`-Schlüssel ist ein **Fehler, keine leere Menge**;
|
||||
> `parseFavoriten` wirft dann `CloudException`, genau wie `parseListe`
|
||||
> (`melo_cloud_service.dart:99-100`) und `parseUpload` (`:111-112`) es
|
||||
> bereits tun. Hintergrund: der Router verdrahtet für `GET /favorites`
|
||||
> hart 200 (`melo_cloud.py:1292-1293`), und sechs Handler desselben
|
||||
> Servers geben Fehler im 200er-Körper zurück (`:418, 491, 545, 766,
|
||||
> 1021, 1118`).
|
||||
>
|
||||
> **Bestandsänderung, Teil des Arbeitspakets:**
|
||||
> `test/services/melo_cloud_service_test.dart:98-101` prüft heute
|
||||
> ausdrücklich das Gegenteil
|
||||
> (`expect(parseFavoriten(jsonEncode({'status':'ok'})), isEmpty)`) und
|
||||
> wird mit umgeschrieben. Das ist kein Regressionsfehler.
|
||||
|
||||
Schlägt das GET fehl, wird die Phase übersprungen. Der Unterschied zur
|
||||
Vorfassung: das ist jetzt eine *Optimierung* (nichts zu tun ohne
|
||||
Server-Stand), keine *Sicherheitsregel* mehr — ein fälschlich leeres GET
|
||||
würde im schlimmsten Fall die lokalen Favoriten additiv hochpushen, also
|
||||
harmlos und idempotent.
|
||||
|
||||
**Pull-Richtung, unauflösbare cloudIds:**
|
||||
|
||||
> Server-Favoriten, deren cloudId sich lokal auf keinen Song abbilden
|
||||
> lässt (Download fehlgeschlagen, Titel noch nicht geladen), werden
|
||||
> **übersprungen, nicht gelöscht**: `Favorites.songId` verweist auf
|
||||
> `Songs.id` (`database.dart:102-107`); ein Favorit ohne Song wäre über
|
||||
> `watchFavorites()` (innerJoin, `:371-375`) unsichtbar, würde aber über
|
||||
> `favoriteSongIds()` (`:534-536`) weiter mitgeschleppt und wieder
|
||||
> hochgepusht. Der additive Abgleich schreibt sie deshalb weder lokal,
|
||||
> noch entfernt er sie serverseitig.
|
||||
> Hinweis am selben Ort: `favoriteSongIds()` liefert auch Favoriten
|
||||
> **getombsteter** Songs — es ist ein schlichtes `select(favorites).get()`
|
||||
> (`:534-537`) ohne Join auf `Songs.deleted`, und `markMissing`
|
||||
> (`:237-245`) setzt nur `Songs.deleted`, die Favorites-Zeile überlebt.
|
||||
> Unter dem additiven Abgleich harmlos, unter jedem Voll-Ersatz nicht.
|
||||
|
||||
- Songs ohne cloudId: bleiben lokal-only, tauchen in keiner Richtung im
|
||||
Abgleich auf.
|
||||
- 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`.
|
||||
(Schema-Version erhöhen, Migration schreiben + testen.)
|
||||
- **Zuordnung:** Playlisten mit cloudId sind eindeutig verbunden. Ohne
|
||||
cloudId: einmalige Zuordnung per Namensvergleich (case-insensitive,
|
||||
getrimmt); Treffer bekommt die Server-cloudId persistiert. Lokale
|
||||
Playlist ohne Server-Gegenstück → auf dem Server anlegen
|
||||
(`POST /playlists`), cloudId übernehmen. Server-Playlist ohne lokales
|
||||
Gegenstück → lokal anlegen.
|
||||
- **Song-Vereinigung:** `playlistVereinigung(lokal, server)` — Union der
|
||||
Songs per Song-cloudId. Reihenfolge: der längere Stand liefert die
|
||||
Grundreihenfolge, nur auf der jeweils anderen Seite vorhandene Songs
|
||||
werden hinten angehängt. Ergebnis geht an Server
|
||||
(Playlist-Songs-Endpunkt) und in die lokale DB.
|
||||
- **Sofort-Push online:** Playlist anlegen/umbenennen/löschen sowie
|
||||
Song hinzufügen/entfernen werden bei bestehender Verbindung
|
||||
fire-and-forget direkt zum Server durchgereicht (Endpunkte existieren:
|
||||
Playlists-CRUD + songs + positions).
|
||||
- **Songs ohne cloudId** in einer Playlist: bleiben lokal in der
|
||||
Playlist, werden zum Server einfach nicht mitgemeldet — kein Fehler.
|
||||
- **Bekannte Einschränkung (dokumentiert, akzeptiert):** Der Server hat
|
||||
keine Playlist-Tombstones (Löschen = hartes DELETE). Eine OFFLINE
|
||||
gelöschte Playlist kommt beim nächsten Sync vom Server zurück.
|
||||
Online-Löschungen greifen sofort und dauerhaft. Falls das in der
|
||||
Praxis stört: Server-Tombstones als separater Auftrag an
|
||||
Hermes/claude-server.
|
||||
> Der Abgleich ist rein additiv. Ein Ent-Favorisieren wirkt sofort lokal
|
||||
> und — online — auch auf dem Server, ist aber **nicht
|
||||
> geräteübergreifend garantiert**: Hält ein zweites Gerät den Favoriten
|
||||
> noch, bringt dessen nächster Sync ihn auf allen Geräten zurück. Das
|
||||
> gilt für offline **und für online** entfernte Herzen. Bewusster Tausch,
|
||||
> wie in v2: kein Datenverlust ist wichtiger als verlässliche
|
||||
> Lösch-Propagation.
|
||||
> Falls das in der Praxis stört: Folgeschritt „Basis-Snapshot" (Backlog).
|
||||
> Er lässt sich später **ohne Umbau** ergänzen, weil der Schreibweg dann
|
||||
> schon der Delta-Push ist — aus `set:true` wird zusätzlich `set:false`.
|
||||
|
||||
*(Die Vorfassung behauptete an dieser Stelle, Ent-Favorisierungen
|
||||
propagierten über den Sofort-Push und nur offline entfernte Herzen kämen
|
||||
zurück. Das war falsch und ist ersatzlos gestrichen.)*
|
||||
|
||||
### 2. Playlist-Sicherung (Stufe 1, einseitig)
|
||||
|
||||
> **Playlist-Sync Stufe 1 = einseitige Sicherung.** Die App meldet lokale
|
||||
> Playlisten-Änderungen (anlegen / Song hinzufügen / Song entfernen /
|
||||
> Reihenfolge) online fire-and-forget an den Server und persistiert die
|
||||
> vom Server vergebene cloudId (Drift-Migration
|
||||
> `Playlists.cloudId TEXT NULL` bleibt).
|
||||
> Es findet **kein Rück-Merge** statt: Server-Playlisten werden nur dann
|
||||
> lokal angelegt, wenn die lokale Playlisten-Tabelle **leer** ist
|
||||
> (Neuinstallation / Wiederherstellung).
|
||||
>
|
||||
> Damit entfallen ersatzlos: Namenszuordnung, Reihenfolge-Schiedsrichter
|
||||
> („längerer Stand gewinnt"), Gleichstands-Regel, Tombstone-Semantik und
|
||||
> die Abhängigkeit vom fehlenden Rename-Endpunkt. Die Identität stammt
|
||||
> **immer** aus dem eigenen `POST /playlists` und ist stabil
|
||||
> (`user_playlists.id` ist `INTEGER PRIMARY KEY AUTOINCREMENT`).
|
||||
>
|
||||
> **Nicht abgedeckt (dokumentiert):** Umbenennungen propagieren nicht.
|
||||
> Playlisten-Änderungen auf einem zweiten Gerät erscheinen auf dem ersten
|
||||
> nicht.
|
||||
> Der beidseitige Playlist-Merge ist eine eigene, spätere Spec.
|
||||
>
|
||||
> **Vorbedingung (Server-Auftrag, blockierend):** `melo_cloud.py:505`
|
||||
> löscht `user_playlist_songs` für jede `playlist_id` **ohne
|
||||
> `user`-Bedingung** — echte Fremddaten-Löschung. Muss geflickt sein,
|
||||
> bevor die App diesen Endpunkt regelmäßig benutzt.
|
||||
|
||||
Endpunkte: `GET/POST/DELETE /playlists`, `GET /<id>`,
|
||||
`POST /<id>/songs`, `DELETE /<id>/songs/<sid>`, `PUT /<id>/positions`
|
||||
(`melo_cloud.py:1251-1284`). **Einen Rename-Endpunkt gibt es nicht** —
|
||||
kein `PUT`/`PATCH` auf die Playlist selbst; `handle_rename:749` betrifft
|
||||
Songs. (Die Vorfassung behauptete das Gegenteil.)
|
||||
|
||||
Songs ohne cloudId in einer Playlist bleiben lokal in der Playlist und
|
||||
werden zum Server einfach nicht mitgemeldet — kein Fehler.
|
||||
|
||||
*Ebenfalls vertretbar und heute kostenlos (0 Playlisten auf dem Server):
|
||||
Feature 2 ganz herausschneiden. Entscheidung liegt bei Dustin; das
|
||||
Weiterlaufen im Zustand der Vorfassung (vier offene
|
||||
Semantik-Entscheidungen) ist es nicht.*
|
||||
|
||||
### 3. Upload-Auswahl („Auf den Server laden“)
|
||||
|
||||
- Auswahl-Modus in „Meine Musik“ (existiert, siehe
|
||||
`auswahl_modus_test.dart`) bekommt die Aktion **„Auf den Server
|
||||
laden“**.
|
||||
- Auswahl-Modus in „Meine Musik“ bekommt die Aktion **„Auf den Server
|
||||
laden“**. Er existiert (`my_music_screen.dart:120` →
|
||||
`SortableSongList`, `lib/shared/sortable_song_list.dart:46-54`,
|
||||
`:194-196`) — zu beachten ist nur das Scoping: `SortableSongList` wird
|
||||
in fünf Ansichten benutzt, die Aktion würde sonst überall erscheinen.
|
||||
- Neue Methode `SyncService.ladeAusgewaehlteHoch(List<Song> songs)`:
|
||||
nutzt den bestehenden `_ladeHoch`-Pfad pro Song, mit Fortschritt über
|
||||
die bestehenden Felder (`laeuft/erledigt/gesamt/status`) und der neuen
|
||||
Notification.
|
||||
die bestehenden Felder (`laeuft/erledigt/gesamt/status`).
|
||||
- Songs, die schon eine cloudId haben, werden übersprungen; zu große
|
||||
Dateien (>50 MB) einzeln als Fehler vermerkt, der Rest läuft weiter.
|
||||
Ergebnis-Meldung im Stil „3 hochgeladen, 2 waren schon da, 1 zu groß“.
|
||||
- **Abbrechen:** Der Auswahl-Upload bekommt `SyncService.abbrechen()`
|
||||
nach dem Muster von `DownloadService` (`:46`, `:66-68`, `:96`) — ein
|
||||
Flag, das die Schleife zwischen zwei Songs prüft. `SyncService` hat
|
||||
heute **keinerlei** Abbruchmöglichkeit (0 Treffer für `abbrech|cancel`
|
||||
in 376 Zeilen); ohne das ist ein versehentlich markierter
|
||||
60-Titel-Upload nur durch App-Kill zu stoppen.
|
||||
- **Zeitstempel:** `ladeAusgewaehlteHoch` schreibt `_letzterLauf`
|
||||
(`:215-217`) **nicht** — es ist kein Sync. Sonst unterdrückt ein
|
||||
manueller Upload 15 Minuten Auto-Sync (`sollAutoSync`, `:120-121`) und
|
||||
verschiebt die 24-h-Uhr des Berichts; wer die App täglich zum
|
||||
Hochladen öffnet, sähe den Bericht nie.
|
||||
- **Offline-Modus:** Der Schalter (`lib/services/offline_mode.dart`,
|
||||
`settings_screen.dart:218-231`) wirkt heute nur auf die Wiedergabe
|
||||
(`main.dart:106-110`). Entscheidung: der Auswahl-Upload **respektiert
|
||||
ihn nicht**, weil er eine ausdrückliche Nutzeraktion ist.
|
||||
- Läuft bereits ein Sync, wird die Aktion abgewiesen („Sync läuft
|
||||
gerade“) — der bestehende Doppel-Lauf-Schutz des SyncService gilt.
|
||||
- Der automatische Voll-Upload beim Sync (alles ohne cloudId) bleibt
|
||||
@@ -158,88 +371,156 @@ Neu, zwei Mechanismen (Vorbild Melo v2):
|
||||
|
||||
### 4. Einzel-Song-Offline
|
||||
|
||||
- `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,
|
||||
- `DownloadService` bekommt `ladeEinzelnenTitel(SubsonicSong song)`:
|
||||
|
||||
> `ladeEinzelnenTitel(song)` ruft den bestehenden **öffentlichen**
|
||||
> `DownloadService.lade([song])` (`download_service.dart:72-111`) auf —
|
||||
> der bringt Doppel-Lauf-Schutz (`:73`), Verbindungsprüfung (`:77-84`)
|
||||
> und Fortschritts-Buchführung (`:85-104`) mit. **Nicht** `_ladeEinen`
|
||||
> (`:113-139`) direkt verdrahten.
|
||||
|
||||
Gleiche Ablage (App-Speicher,
|
||||
Application-Support/melo_downloads), gleiche Buchführung
|
||||
(Downloads-Tabelle per navidromeId), gleiche Fehlerbehandlung.
|
||||
- **Doppelspeicherung:** Vor dem Netz-Download prüft
|
||||
`ladeEinzelnenTitel`, ob die Datei bereits im Abspiel-Cache liegt —
|
||||
sonst entsteht genau die Doppelspeicherung, die
|
||||
`audio_handler.dart:326-331` in der Gegenrichtung ausdrücklich
|
||||
verhindert („doppelter Platz und doppeltes Datenvolumen"). Falls zu
|
||||
teuer: als bekannte Einschränkung dokumentieren, nicht schweigen.
|
||||
- **Kein Platz-Check, keine Schwelle:** Die 30er-Rückfrage
|
||||
(`download_service.dart:13`, `:15`, `:17-21`, Aufrufer nur
|
||||
`downloads_screen.dart:460` und `:496`) ist bei einem Einzeltitel per
|
||||
Konstruktion wirkungslos, und einen Check auf freien Speicher gibt es
|
||||
in der ganzen App nicht. Bewusst akzeptiert: der Knopf lädt genau
|
||||
einen Titel.
|
||||
- **Offline-Modus:** wird ebenfalls nicht respektiert (ausdrückliche
|
||||
Nutzeraktion, siehe Feature 3).
|
||||
- UI: Songzeilen in der Server-Album-/Künstler-Ansicht
|
||||
(`server_titel_screen.dart`) bekommen den Lade-Knopf, den es heute nur
|
||||
pro Album gibt (Muster `_LadeKnopf`), inklusive Zustand
|
||||
„schon offline“ mit Entfernen-Option (bestehendes
|
||||
`DownloadService.entferne`).
|
||||
pro Album gibt, inklusive Zustand „schon offline“ mit Entfernen-Option
|
||||
(bestehendes `DownloadService.entferne`). **`_LadeKnopf` ist eine
|
||||
private Klasse in `lib/downloads/downloads_screen.dart:419-429`, kein
|
||||
wiederverwendbares Widget** — als Muster kopieren oder vorher
|
||||
extrahieren.
|
||||
|
||||
### 5. Fortschritts-Notification + Sync-Bericht
|
||||
### 5. Sync-Bericht („Was ist neu“)
|
||||
|
||||
- **`SyncBenachrichtigung`** (Wrapper um `flutter_local_notifications`,
|
||||
eigener Channel z. B. `de.baka.melo.sync`):
|
||||
- Während `SyncService.laeuft`: persistente (ongoing) Notification
|
||||
„Synchronisiere… X/Y“ mit Fortschrittsbalken, aktualisiert über die
|
||||
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
|
||||
- Rein in-App, **keine neue Dependency**: Ist der letzte erfolgreiche
|
||||
Sync >24 h her, sammelt der nächste Sync Zähler (neue Songs,
|
||||
gelöschte, Favoriten geändert, Playlisten geändert) und zeigt danach
|
||||
einmalig einen Dialog (v2-Parität „Willkommen zurück!“). Stand in
|
||||
SharedPreferences.
|
||||
- **Erstfall:** `letzterLauf == null` (Neuinstallation, nach Abmelden,
|
||||
nach App-Daten-Löschen) gilt **nicht** als fällig — sonst begrüßt der
|
||||
Bericht ein frisch installiertes Gerät mit „Willkommen zurück! 325
|
||||
neue Songs". Achtung: `sollAutoSync` (`sync_service.dart:120-121`)
|
||||
behandelt `null` genau umgekehrt und ist hier **kein** Vorbild.
|
||||
- **„Erfolgreich" heißt:** alle Phasen ohne geschluckten Fehler. Dafür
|
||||
führt `synchronisiere()` ein Flag, das jede Phase bei einem Fehlschlag
|
||||
löscht; der Bericht-Zeitstempel wird nur bei gesetztem Flag
|
||||
geschrieben. Heute schreibt `:215-217` denselben Zeitstempel, egal ob
|
||||
Phasen ausgefallen sind (`_meldeLoeschungen:243-252` schluckt Fehler).
|
||||
- Die Entscheidung „Bericht fällig?“ liegt als reine Funktion
|
||||
(`berichtFaellig`) in `sync_merge.dart` und ist ohne Plattform-Kanäle
|
||||
testbar.
|
||||
- Die In-App-Fortschrittsanzeige in den Einstellungen bleibt wie heute.
|
||||
|
||||
### 6. Echtzeit per SSE
|
||||
|
||||
- **`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)
|
||||
## Sync-Phasen nach Ausbau (Reihenfolge im Lauf)
|
||||
|
||||
1. Tombstones nachziehen (bestehend)
|
||||
2. Neue Server-Songs herunterladen (bestehend)
|
||||
3. Lokale Songs ohne cloudId hochladen (bestehend)
|
||||
4. **Favoriten-Vereinigung (neu)**
|
||||
5. **Playlist-Vereinigung (neu)**
|
||||
6. Verlauf melden (bestehend)
|
||||
7. Zeitstempel + ggf. Bericht (erweitert)
|
||||
4. **Additiver Favoriten-Abgleich (neu)**
|
||||
5. Verlauf melden (bestehend)
|
||||
6. 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
|
||||
POSTen), Sync läuft weiter.
|
||||
- Playlist-Endpunkt scheitert → Phase 5 überspringen, Sync läuft weiter.
|
||||
- Auswahl-Upload: Datei >50 MB oder Einzel-Upload-Fehler → im Ergebnis
|
||||
vermerken, mit nächstem Song fortfahren.
|
||||
Die Playlist-Sicherung (Feature 2) ist **keine Sync-Phase**: sie läuft
|
||||
als Sofort-Push bei der Änderung, und die Wiederherstellung greift nur
|
||||
bei leerer lokaler Tabelle.
|
||||
|
||||
## Reihenfolge (Auslieferung)
|
||||
|
||||
> 1. **Favoriten-Fix allein** (additiver Delta-Push, `parseFavoriten`,
|
||||
> Testanpassung). Wartet auf **nichts**: kein Server-Auftrag, keine
|
||||
> neue Dependency, keine Gradle-Änderung, kein neues UI-Muster.
|
||||
> Einzige Stufe mit belegtem Datenverlust-Bezug.
|
||||
> 2. Auswahl-Upload + Einzel-Song-Offline (UI, keine neue Dependency).
|
||||
> 3. Sync-Bericht (In-App-Dialog, kein Plugin).
|
||||
> 4. Playlist-Sicherung Stufe 1 — **nach** dem Server-Fix
|
||||
> `melo_cloud.py:505`.
|
||||
>
|
||||
> *Ab hier nicht mehr Inhalt dieser Spec — beides steht unter
|
||||
> §Nicht-Ziele bzw. §Spätere Stufen und ist nur der Vollständigkeit
|
||||
> halber einsortiert:*
|
||||
>
|
||||
> 5. Notification Stufe B — beginnt mit dem grünen Beweis-Build.
|
||||
> 6. SSE — eigene Spec, nach dem `song_upload`-Event.
|
||||
|
||||
**Warum genau diese Reihenfolge — Begründung, die mitgebaut werden
|
||||
muss:**
|
||||
|
||||
- Stufe 1 (A1/A2/A3) **berührt den Löschpfad nicht** und hängt an keiner
|
||||
offenen Frage. Deshalb kann mit der Umsetzung begonnen werden, **ohne
|
||||
dass die Antwort auf die A6-Rückfrage vorliegt**. Sie behebt außerdem
|
||||
als einzige einen belegten Datenverlust-Bug — sie zuerst zu liefern
|
||||
ist auch inhaltlich richtig.
|
||||
- Der **Auswahl-Upload (Feature 3) kommt bewusst NACH der Klärung** der
|
||||
A6-Rückfrage: Er gibt mehr Titeln eine cloudId und vergrößert damit
|
||||
genau die Angriffsfläche des irreversiblen Löschpfads. Lautet die
|
||||
Antwort „nein, nicht gewollt“, gehört der Server-Auftrag
|
||||
(Datei-Wiederherstellung im Dedup-Zweig) davor.
|
||||
- Alles, was auf einen fremden Auftrag wartet (Server-Fix `:505`,
|
||||
`song_upload`-Event) oder auf einen Build-Umbau
|
||||
(`flutter_local_notifications`), steht hinten — das betrifft die
|
||||
**Stufen 4–6**. Innerhalb dieser drei blockiert keine Stufe eine
|
||||
frühere. Für Stufe 2 gilt das ausdrücklich **nicht**: sie wartet auf
|
||||
die A6-Antwort (Absatz darüber), und Stufe 4 hängt zusätzlich am
|
||||
Server-Fix `:505` (§Offene Abhängigkeiten, Zeile 1).
|
||||
|
||||
## Fehlerfälle — zu bauende Arbeit, keine Zusagen
|
||||
|
||||
Fünf in der Vorfassung als „bestehend“ beschriebene Schutzmechanismen
|
||||
**existieren nicht** und sind Teil dieser Arbeit:
|
||||
|
||||
> - **Phasen-Isolation:** `synchronisiere()` hat heute **ein einziges**
|
||||
> try/catch um alle Phasen (`sync_service.dart:188-228`). Jede neue
|
||||
> Phase bekommt ihr eigenes try/catch mit definiertem Teil-Erfolg.
|
||||
> - **„verzögert" gibt es nicht:** `if (_laeuft) return;` (`:175`)
|
||||
> verwirft den Anstoß wortlos. Wer einen Nachlauf will, braucht ein
|
||||
> `_nachziehen`-Flag, das im `finally` genau **einen** weiteren Lauf
|
||||
> auslöst.
|
||||
> - **Der Upload überlebt keinen Timeout:** `_ladeHoch` fängt nur
|
||||
> `CloudException` (`:313`); der 120-s-Timeout
|
||||
> (`melo_cloud_service.dart:180`) wirft `TimeoutException` und reißt
|
||||
> den ganzen Lauf ab. Timeouts sind wie Einzelfehler zu behandeln.
|
||||
> - **Die Drossel sitzt in `automatisch()`** (`:166-170`), nicht in
|
||||
> `synchronisiere()` (`:174-180`). Ein Parameter `erzwinge` wäre
|
||||
> deshalb wirkungslos und entfällt ersatzlos (er stammte aus dem
|
||||
> gestrichenen SSE-Teil).
|
||||
> - **Der Löschweg zum Server** wird in den Fehlerfällen nirgends
|
||||
> beschrieben — siehe §Risiken, „Irreversibler Löschpfad".
|
||||
|
||||
Verhalten im Einzelnen:
|
||||
|
||||
- Favoriten-GET scheitert → Phase 4 überspringen, Sync läuft weiter
|
||||
(nichts zu tun ohne Server-Stand; ein blindes additives Pushen wäre
|
||||
harmlos, aber nutzlos).
|
||||
- Playlist-Endpunkt scheitert → Sofort-Push still verwerfen, nächste
|
||||
Änderung versucht es erneut.
|
||||
- Auswahl-Upload: Datei >50 MB, Einzel-Upload-Fehler oder Timeout → im
|
||||
Ergebnis vermerken, mit nächstem Song fortfahren; `abbrechen()`
|
||||
stoppt zwischen zwei Songs.
|
||||
- Einzel-Song-Offline: wie bestehender Album-Pfad (Fehler pro Titel,
|
||||
kein Abbruch des Rests).
|
||||
- SSE nicht erreichbar/abgerissen → Backoff-Reconnect, still; kein
|
||||
Nutzer-Fehler, Poll bleibt.
|
||||
- Notification-Berechtigung verweigert → In-App-Fortschritt wie heute.
|
||||
- Sync bereits aktiv → Auswahl-Upload/SSE-Anstoß werden abgewiesen bzw.
|
||||
verzögert (bestehender Schutz).
|
||||
- Sync bereits aktiv → Auswahl-Upload wird abgewiesen (bestehender
|
||||
Schutz, `:175`).
|
||||
|
||||
## Risiken (aus der Bestandsaufnahme, im Plan zu beachten)
|
||||
## Risiken
|
||||
|
||||
- Die Lösch-Bremse bremst nur Server-Löschungen; lokale Tombstones
|
||||
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
|
||||
(Tombstone-Race), neue Server-Songs sofort MIT cloudId in die DB
|
||||
(Doppel-Download-Falle), Dateinamen-Kollisionszähler.
|
||||
- `POST /favorites` bleibt technisch ein Voll-Ersatz — die Sicherheit
|
||||
liegt allein in der GET-vor-POST-Regel. Tests müssen genau diesen
|
||||
Pfad absichern.
|
||||
- Die Sicherheit der Favoriten liegt **nicht mehr in einer Regel**,
|
||||
sondern darin, dass kein Voll-Ersatz-Aufruf mehr existiert.
|
||||
- Der Navidrome-Playlist-Import ist nicht idempotent („🌐“-Duplikate) —
|
||||
Bestandsfehler, von Feature 2 nicht angefasst, aber beim Testen
|
||||
präsent.
|
||||
|
||||
### Irreversibler Löschpfad (Bestand, nicht von dieser Spec verursacht — Backlog-P0)
|
||||
|
||||
> `markMissing` (`database.dart:237-245`) tombstoned jeden Titel, dessen
|
||||
> Datei der Scan nicht findet — auch unbeabsichtigt (SD-Karte nicht
|
||||
> eingehängt, Berechtigung entzogen, Dateimanager). `planeSync`
|
||||
> (`sync_service.dart:68-76`) macht daraus eine Server-Löschung; die
|
||||
> Lösch-Bremse (`:112-113`) greift bei 325 Titeln erst ab 109 — **1 bis
|
||||
> 108 Löschungen laufen ungebremst**. Der Server löscht `registry/<sid>`
|
||||
> und `navidrome/music/<sid>` (`melo_cloud.py:356-372`, `:190-202`); für
|
||||
> die Datei gibt es keinen Grabstein. Erneutes Hochladen repariert es
|
||||
> **nicht**: der überlebende `registry.sha256` erzwingt den Dedup-Zweig
|
||||
> (`:265-267`), `shutil.move` steht nur im Neu-Zweig (`:289`),
|
||||
> `os.remove(tmp)` verwirft die Bytes (`:312`), `/download` antwortet
|
||||
> dauerhaft 404 (`:1400-1408`). Bisher nie ausgelöst (Live-DB:
|
||||
> `SUM(deleted)=0`).
|
||||
|
||||
Die zugehörige Rückfrage an Dustin ist **noch offen** — siehe §OFFENE
|
||||
ENTSCHEIDUNG ganz oben.
|
||||
|
||||
### Verworfene Alternativen
|
||||
|
||||
> **Verworfen: Drei-Wege-Merge gegen einen persistierten Basis-Snapshot**
|
||||
> (`neu = (lokal ∪ server) − (Basis \ lokal) − (Basis \ server)`). Er
|
||||
> löst zwar die Lösch-Propagation, führt aber den **einzigen neuen
|
||||
> Datenverlustpfad** der ganzen Planung ein: Liefert `GET /favorites`
|
||||
> fälschlich eine leere Liste, wird `Basis \ server = Basis`, die Formel
|
||||
> kollabiert zu `lokal − Basis` und löscht den gesamten bestätigten
|
||||
> Bestand — lokal **und** über den Delta-Push auf dem Server und damit
|
||||
> auf allen Geräten. Zusätzlich braucht er kontogebundenen Zustand mit
|
||||
> Aufräumpflicht beim Abmelden; `BakaAuth.abmelden`
|
||||
> (`baka_auth.dart:114-120`) räumt heute nichts ab und es gibt kein
|
||||
> Token-Refresh. Für drei Nutzer, 0 Favoriten und 0 Playlisten auf dem
|
||||
> Server ist der additive Abgleich der bessere Tausch. Der
|
||||
> Basis-Snapshot bleibt als Folgeschritt im Backlog.
|
||||
|
||||
*(Diese Alternative war die Haupt-Empfehlung des Review-Panels. Sie wird
|
||||
mit obiger Begründung abgelehnt — die separate Faktenprüfung hat
|
||||
nachgerechnet, dass ein fälschlich leeres GET, das `parseFavoriten` heute
|
||||
erzeugt, sie zur geräteübergreifenden Massenlöschung macht.)*
|
||||
|
||||
Ebenfalls verworfen: der beidseitige Playlist-Merge in dieser Stufe
|
||||
(vier offene Semantik-Entscheidungen, siehe A5) und SSE in dieser Stufe
|
||||
(eigene Vorbedingung nicht erfüllt, siehe §Nicht-Ziele).
|
||||
|
||||
## Tests
|
||||
|
||||
- `sync_merge.dart`: Unit-Tests für Favoriten-Union (leer×leer,
|
||||
einseitig, disjunkt, Songs ohne cloudId), Playlist-Union
|
||||
(Reihenfolge-Regel, Erst-Zuordnung per Name, Groß/Kleinschreibung,
|
||||
Namens-Kollision), deterministisch, ohne I/O.
|
||||
- `echtzeit_sync.dart`: SSE-Zeilen-Parser (event/data/Heartbeat/
|
||||
Fragmentierung), Debounce- und Backoff-Entscheidungen als reine
|
||||
Funktionen.
|
||||
- `sync_service.dart`: neue Phasen mit MockClient (inkl. GET-Fehler →
|
||||
kein POST; Auswahl-Upload mit Mischung aus ok/zu groß/schon da).
|
||||
- `sync_benachrichtigung.dart`: Entscheidungslogik (zeigen/aktualisieren/
|
||||
Bericht fällig) pur; Plugin-Aufrufe nicht getestet (dünner Wrapper).
|
||||
- Widget-Tests: Auswahl-Modus-Aktion sichtbar + ruft Upload auf;
|
||||
**Neu:**
|
||||
|
||||
- `sync_merge.dart`: Unit-Tests für die Mengendifferenz beider
|
||||
Richtungen (leer×leer, einseitig, disjunkt, Songs ohne cloudId,
|
||||
unauflösbare Server-cloudId → übersprungen) und für `berichtFaellig`
|
||||
(`null` → nicht fällig, <24 h → nicht fällig, >24 h → fällig).
|
||||
Deterministisch, ohne I/O.
|
||||
- `sync_service.dart`: neue Phase mit MockClient. **Zwei Pflichttests:**
|
||||
„200 mit Fehlerkörper → Phase übersprungen, kein Push" und „200 mit
|
||||
`favorites: []` → Phase läuft normal durch" (der zweite verhindert,
|
||||
dass der erste durch ein zu scharfes „wirf bei leer" trivial erfüllt
|
||||
wird). Dazu: Auswahl-Upload mit Mischung aus ok/zu groß/schon da,
|
||||
`abbrechen()` stoppt zwischen zwei Songs, `ladeAusgewaehlteHoch`
|
||||
schreibt `_letzterLauf` nicht.
|
||||
- **`PlaylistService`** — der Sofort-Push ist der einzige Online-Kanal
|
||||
der Spec und hatte in der Vorfassung keinen einzigen Testeintrag:
|
||||
`toggleFavorite` schickt deterministisches `set:true/false`, nur bei
|
||||
cloudId, Fehler werden geschluckt.
|
||||
- **`PlaylistService` — Feature 2 (einseitige Sicherung):** Sofort-Push
|
||||
je Änderungsart (Playlist anlegen → cloudId wird persistiert; Song
|
||||
hinzufügen/entfernen; Reihenfolge via `PUT /<id>/positions`), Songs
|
||||
ohne cloudId werden nicht mitgemeldet, Endpunkt-Fehler werden still
|
||||
verworfen (kein lokaler Rollback). **Pflichttest für die Kernregel:**
|
||||
Server-Playlisten werden nur angelegt, wenn die lokale
|
||||
Playlisten-Tabelle leer ist — Gegentest mit *einer* lokalen Playlist
|
||||
legt **nichts** an (kein Rück-Merge).
|
||||
- Widget-Tests: Auswahl-Modus-Aktion sichtbar + ruft Upload auf (und
|
||||
erscheint **nicht** in den vier anderen `SortableSongList`-Ansichten);
|
||||
Einzel-Song-Knopf lädt/entfernt; Bericht-Dialog erscheint nach
|
||||
>24h-Marke.
|
||||
>24h-Marke und nicht bei `letzterLauf == null`.
|
||||
- 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 /
|
||||
claude-server) — App funktioniert ohne, Echtzeit für neue Songs
|
||||
braucht es.
|
||||
2. Optional/nachrangig (nur falls Praxisproblem): Playlist-Tombstones
|
||||
serverseitig.
|
||||
> - `test/services/melo_cloud_service_test.dart:98-101` — zementiert das
|
||||
> `[]`-Verhalten, wird mit der GET-Härtung umgeschrieben.
|
||||
> - `test/services/sync_service_test.dart:166-202` („Favoriten werden mit
|
||||
> ihren Server-IDs gemeldet") prüft heute genau das
|
||||
> Full-Replace-Verhalten, das Ziel 1 beseitigt. Sein MockClient
|
||||
> beantwortet **jeden** Pfad außer `/list` mit `{'status':'ok'}`
|
||||
> (`:196`) — unter der neuen Regel liefert `GET /favorites` dort 200
|
||||
> ohne `favorites`-Schlüssel und die Phase wird übersprungen. Der Test
|
||||
> wird auf den additiven Delta-Push umgeschrieben und bekommt eine
|
||||
> **explizite** `/favorites`-Antwort.
|
||||
> **Der Catch-All-Mock darf nicht aufgeweicht werden, um die neue
|
||||
> Sicherheitsregel zu umgehen** — das ist der schnellste Weg zu Grün
|
||||
> und der falsche.
|
||||
> - Dasselbe gilt abgeschwächt für die übrigen sieben Tests derselben
|
||||
> Datei.
|
||||
|
||||
## Offene Abhängigkeiten (geschlossene Liste)
|
||||
|
||||
| # | Auftrag | Art | Blockiert |
|
||||
|---|---|---|---|
|
||||
| 1 | `melo_cloud.py:505` — Owner-Prüfung in `handle_playlist_delete` | **Voraussetzung** | Feature 2 |
|
||||
| 2 | Datei-Wiederherstellung im Dedup-Zweig von `upload()` | **Voraussetzung, falls die Antwort auf die offene Entscheidung „nein" lautet** | Feature 3 |
|
||||
| 3 | `song_upload`-SSE-Event | Voraussetzung für die spätere SSE-Stufe | nichts in dieser Spec |
|
||||
| 4 | Playlist-Rename-Endpunkt (`PUT`/`PATCH`) | optional | nichts (Feature 2 braucht ihn nicht mehr) |
|
||||
| 5 | Playlist-Tombstones serverseitig | optional / Backlog | nichts |
|
||||
| 6 | Schema-Divergenz `ist_korrupt` / `uq_user_song` (nur in der Live-DB, nicht im `CREATE TABLE` des Skripts) | optional / Backlog | nichts — trifft nur wiederhergestellte oder Test-Instanzen |
|
||||
|
||||
## Backlog-Notizen
|
||||
|
||||
- `handle_favorites_get` liefert bereits `favorited_at`
|
||||
(`melo_cloud.py:620`), lokal existiert `Favorites.createdAtMs`
|
||||
(`database.dart:103`) — die Zutat für einen späteren echten Merge ist
|
||||
beidseitig vorhanden, ohne dass der Server geändert werden müsste.
|
||||
- `server_titel_screen._geladen` ist ein Einmal-Snapshot
|
||||
(Bestandsfehler) — fällt beim Einbau des Einzel-Song-Knopfs auf.
|
||||
|
||||
Reference in New Issue
Block a user