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