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
35 KiB
Sync-Ausbau: Favoriten-Fix, Auswahl-Upload, Einzel-Song-Offline, Playlist-Sicherung
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
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).
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
- Favoriten-Merge-Fix (Datenverlust-Bug): kein Gerät überschreibt mehr die Server-Favoriten mit seinem lokalen Stand.
- Gezielter Upload: einzelne Songs im Auswahl-Modus markieren und hochladen.
- Einzel-Song-Offline: einzelne Server-Titel offline nehmen, nicht nur ganze Alben/Künstler.
- Playlist-Sicherung: lokale Playlisten überleben ein zurückgesetztes Handy (einseitig, siehe Feature 2).
- Sichtbarkeit: „Was ist neu“-Bericht als In-App-Dialog.
Nicht-Ziele
- Kein Cross-Platform (Mac/Windows) — eigenes Folgeprojekt.
windows//linux/existieren nicht,just_audiohat 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, dasmelo_cloud.pyheute nicht feuert (_emit_eventnur 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: eigenerhttp.Client(der geteilte ausmelo_cloud_service.dart:82-83würde beimclose()laufende Sync-Requests mitreißen), Heartbeat-Watchdog gegen den halboffenen Socket nach WLAN↔LTE, dauerhafter 401-Ausstieg (baka_auth.darthat kein Refresh), Selbst-Echo-Unterdrückung (melo_realtime.py:56-70broadcastet an alle Queues des Nutzers ohne Absenderkennung), Timer-Abbau beipaused, 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 ausinitStateundresumed(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-192kennt nuronCreateundonUpgrade.
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.toggleFavoriteschickt online zusätzlich fire-and-forget ein deterministischesset:true/falsean 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:
GET /favorites→server(Menge von cloudIds).- Für jede cloudId in
lokal \ server:POST /favorites/togglemit{"song_id": …, "set": true}(existiert und ist deterministisch:melo_cloud.py:1298-1302→handle_favorites_toggle:644-688; die Client-Methode ist neu).- Für jede cloudId in
server \ lokal: lokal Favorit setzen, sofern der Song lokal auflösbar ist.- 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 undfavoritesals Liste vorhanden ist. Ein fehlenderfavorites-Schlüssel ist ein Fehler, keine leere Menge;parseFavoritenwirft dannCloudException, genau wieparseListe(melo_cloud_service.dart:99-100) undparseUpload(:111-112) es bereits tun. Hintergrund: der Router verdrahtet fürGET /favoriteshart 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-101prü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.songIdverweist aufSongs.id(database.dart:102-107); ein Favorit ohne Song wäre überwatchFavorites()(innerJoin,:371-375) unsichtbar, würde aber überfavoriteSongIds()(: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 schlichtesselect(favorites).get()(:534-537) ohne Join aufSongs.deleted, undmarkMissing(:237-245) setzt nurSongs.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:truewird zusätzlichset: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 NULLbleibt). 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 /playlistsund ist stabil (user_playlists.idistINTEGER 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:505löschtuser_playlist_songsfür jedeplaylist_idohneuser-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:SortableSongListwird 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 vonDownloadService(:46,:66-68,:96) — ein Flag, das die Schleife zwischen zwei Songs prüft.SyncServicehat heute keinerlei Abbruchmöglichkeit (0 Treffer fürabbrech|cancelin 376 Zeilen); ohne das ist ein versehentlich markierter 60-Titel-Upload nur durch App-Kill zu stoppen. - Zeitstempel:
ladeAusgewaehlteHochschreibt_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
DownloadServicebekommtladeEinzelnenTitel(SubsonicSong song):
ladeEinzelnenTitel(song)ruft den bestehenden öffentlichenDownloadService.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, dieaudio_handler.dart:326-331in 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 nurdownloads_screen.dart:460und: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 (bestehendesDownloadService.entferne)._LadeKnopfist eine private Klasse inlib/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) behandeltnullgenau 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-217denselben Zeitstempel, egal ob Phasen ausgefallen sind (_meldeLoeschungen:243-252schluckt Fehler). - Die Entscheidung „Bericht fällig?“ liegt als reine Funktion
(
berichtFaellig) insync_merge.dartund ist ohne Plattform-Kanäle testbar. - Die In-App-Fortschrittsanzeige in den Einstellungen bleibt wie heute.
Sync-Phasen nach Ausbau (Reihenfolge im Lauf)
- Tombstones nachziehen (bestehend)
- Neue Server-Songs herunterladen (bestehend)
- Lokale Songs ohne cloudId hochladen (bestehend)
- Additiver Favoriten-Abgleich (neu)
- Verlauf melden (bestehend)
- 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)
- 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.- Auswahl-Upload + Einzel-Song-Offline (UI, keine neue Dependency).
- Sync-Bericht (In-App-Dialog, kein Plugin).
- 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:
- Notification Stufe B — beginnt mit dem grünen Beweis-Build.
- 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 imfinallygenau einen weiteren Lauf auslöst.- Der Upload überlebt keinen Timeout:
_ladeHochfängt nurCloudException(:313); der 120-s-Timeout (melo_cloud_service.dart:180) wirftTimeoutExceptionund reißt den ganzen Lauf ab. Timeouts sind wie Einzelfehler zu behandeln.- Die Drossel sitzt in
automatisch()(:166-170), nicht insynchronisiere()(:174-180). Ein Parametererzwingewä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.
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öschtregistry/<sid>undnavidrome/music/<sid>(melo_cloud.py:356-372,:190-202); für die Datei gibt es keinen Grabstein. Erneutes Hochladen repariert es nicht: der überlebenderegistry.sha256erzwingt den Dedup-Zweig (:265-267),shutil.movesteht nur im Neu-Zweig (:289),os.remove(tmp)verwirft die Bytes (:312),/downloadantwortet 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: LiefertGET /favoritesfälschlich eine leere Liste, wirdBasis \ server = Basis, die Formel kollabiert zulokal − Basisund 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ürberichtFaellig(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 mitfavorites: []→ 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,ladeAusgewaehlteHochschreibt_letzterLaufnicht.PlaylistService— der Sofort-Push ist der einzige Online-Kanal der Spec und hatte in der Vorfassung keinen einzigen Testeintrag:toggleFavoriteschickt deterministischesset: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 viaPUT /<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 nach24h-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/listmit{'status':'ok'}(:196) — unter der neuen Regel liefertGET /favoritesdort 200 ohnefavorites-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_getliefert bereitsfavorited_at(melo_cloud.py:620), lokal existiertFavorites.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._geladenist ein Einmal-Snapshot (Bestandsfehler) — fällt beim Einbau des Einzel-Song-Knopfs auf.