From 2a1cf9cdc2a708aec5c5b241832441291acd6a03 Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Thu, 27 Aug 2026 02:40:38 +0200 Subject: [PATCH] =?UTF-8?q?Sync-Ausbau:=20Spec=20nachgesch=C3=A4rft=20+=20?= =?UTF-8?q?Implementierungsplan=20(13=20Tasks)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX --- .../plans/2026-08-27-sync-ausbau.md | 4003 +++++++++++++++++ .../specs/2026-08-27-sync-ausbau-design.md | 700 ++- 2 files changed, 4540 insertions(+), 163 deletions(-) create mode 100644 docs/superpowers/plans/2026-08-27-sync-ausbau.md diff --git a/docs/superpowers/plans/2026-08-27-sync-ausbau.md b/docs/superpowers/plans/2026-08-27-sync-ausbau.md new file mode 100644 index 0000000..9ad7e88 --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-sync-ausbau.md @@ -0,0 +1,4003 @@ +# Sync-Ausbau Implementation Plan + +> **Für Umsetzende:** PFLICHT-SUB-SKILL: superpowers:subagent-driven-development +> (empfohlen) oder superpowers:executing-plans zur task-weisen Umsetzung. +> Schritte nutzen Checkbox-Syntax (`- [ ]`). + +**Ziel:** Die Favoriten-Synchronisation vom zerstörerischen Voll-Ersatz auf einen +additiven Abgleich umstellen und darauf aufbauend gezielten Upload, +Einzel-Song-Offline, einen „Was ist neu"-Bericht und eine einseitige +Playlist-Sicherung ergänzen. + +**Architektur:** Rückgrat bleiben `SyncService` und `MeloCloudService`; die +reinen Entscheidungsfunktionen wandern in die neue, I/O-freie Datei +`lib/services/sync_merge.dart`. Der Favoriten-Schreibweg wechselt von +`POST /favorites` (Voll-Ersatz) auf `POST /favorites/toggle` mit +deterministischem `set:true` — danach existiert kein Codepfad mehr, der den +Server-Stand ersetzen kann. UI-Änderungen bleiben in den bestehenden Screens. + +**Tech-Stack:** Flutter/Dart, Drift (SQLite), `package:http` + `MockClient` für +Tests, SharedPreferences, Provider. **Keine neue Dependency.** + +**Spec:** docs/superpowers/specs/2026-08-27-sync-ausbau-design.md + +--- + +## Globale Randbedingungen + +- Arbeitsverzeichnis ist **ausschließlich** `/home/dustin/mello-dev/app`. Nie in + `~/mello-dev/worktrees/claude-server` wechseln, dort nie committen. +- Eigener Feature-Branch: `git switch -c feature/sync-ausbau` (Basis: + `fix/p0-vollwertigkeit`). Nicht auf dem Basis-Branch committen. +- Flutter: `/home/dustin/development/flutter/bin/flutter`, + `ANDROID_HOME=/home/dustin/Android`. +- **Build ohne `--dart-define`:** `flutter build apk --target-platform + android-arm64` muss ohne zusätzliche Definitionen durchlaufen. Es kommt in + diesem Plan **keine** neue Dependency dazu — `flutter_local_notifications` + und der Gradle-Umbau sind ausdrücklich Stufe B und nicht Teil dieses Plans. +- `flutter analyze` muss **sauber** sein (0 Fehler, 0 Warnungen) — vor jedem + Commit. +- **Testläufe:** Einzelne Dateien laufen im Vordergrund + (`flutter test --no-pub test/services/sync_merge_test.dart`, wenige + Sekunden). Der **vollständige** Lauf (`flutter test --no-pub`) umfasst 600+ + Tests und dauert mehrere Minuten — er wird **im Hintergrund** gestartet + (`run_in_background`) und sein Ergebnis abgewartet. **Kein kurzer + Foreground-Timeout**, sonst wird ein grüner Lauf als Fehlschlag gemeldet. +- Ausgangslage: **602 Tests grün.** Nach jedem Task muss diese Zahl gehalten + oder erhöht sein. Zwei Bestandstests werden bewusst umgeschrieben (Task 2 + und Task 3) — das ist kein Regressionsfehler, sondern Teil des Auftrags. +- **CHANGELOG-Pflicht:** `CHANGELOG.md` unter `## [Unreleased]`, neuester + Eintrag oben, Stil der bestehenden Einträge (Emoji-Bullets, einfach erklärt). + Der Changelog wird **mit** dem Code committet. Sammel-Eintrag im letzten + Task; einzelne Tasks brauchen keinen eigenen Eintrag. +- **Server-Endpunkt-Vertrag** (nichts anderes darf benutzt werden): + - `GET {basis}/favorites` → `{"favorites":[{"id":…}]}` — Router verdrahtet + hart HTTP 200, Fehler kommen im 200er-Körper. + - `POST {basis}/favorites/toggle` mit `{"song_id":…, "set":true|false}` — + deterministisch (`melo_cloud.py:644-688`). + - `POST {basis}/favorites` (Voll-Ersatz) — **verboten.** Nach Task 3 + existiert die Client-Methode nicht mehr. + - Playlisten (nur Tasks 10–12) — **am Server nachgelesen, Stand + `melo_cloud.py` 2026-08-27; diese Formen gelten, es wird nicht mehr + „abgeglichen":** + - `GET {basis}/playlists` → `{"status":"ok","playlists":[{"id":…, + "name":…,"created_at":…,"updated_at":…,"song_count":…}]}` + (`handle_playlist_list:475`). `id` ist eine **Zahl**, keine + Zeichenkette — clientseitig interpoliert zu `'$id'`. + - `POST {basis}/playlists`, Körper `{"name":…}` (Router liest + `d.get('name','')`, `:1254`) → `{"status":"ok","playlist":{"id":pid,…}}` + (`handle_playlist_create:499`). **Die ID steckt unter `playlist`, nicht + auf oberster Ebene.** Leerer Name → `{"status":"error","error":"Name + erforderlich"}` mit HTTP 200. + - `GET {basis}/playlists/` → `{"status":"ok","playlist":{…}, + "songs":[{"id":…,"title":…,"position":…}],"count":…}`, nach `position` + sortiert (`handle_playlist_get:511`). + - `POST {basis}/playlists//songs`, Körper `{"song_ids":[…]}` (Router + `:1272`) → `{"status":"ok","added":n}`. + - `DELETE {basis}/playlists//songs/` — alles im Pfad, kein Körper + (Router `:1274-1282`) → `{"status":"ok"}`. + - `PUT {basis}/playlists//positions`, Körper + **`{"positions":[{"id":…,"position":n},…]}`** — der Router liest + `d.get('positions',[])` (`:1288`) und `handle_playlist_update_positions` + (`:585`) greift je Eintrag auf `sp.get("id")` und `sp.get("position")` + zu. Eine blanke ID-Liste unter `song_ids` käme als **leere Liste** an; + der Server antwortete stumm `{"status":"ok"}` und änderte nichts. + - **Kein `DELETE {basis}/playlists`** (siehe Task 11) und **keinen + Rename-Endpunkt** — es gibt kein `PUT`/`PATCH` auf die Playlist selbst. + - Zwei Handler melden „nicht gefunden" als `{"status":"not_found"}` + **ohne** `error`-Schlüssel (`handle_playlist_remove_song:569`, + `handle_playlist_update_positions:585`). In Stufe 1 ist das folgenlos — + die Sicherung ist fire-and-forget und verwirft ohnehin jeden Fehler. + - `basis` = `MeloCloudService.basisUrl`. +- **Keine Server-Änderungen aus diesem Plan heraus.** `/home/dustin/scripts/*.py` + wird nur gelesen. Server-Aufträge sind eigene Tickets. +- Ponytail-Prinzip: minimale, robuste Lösungen. Drei Nutzer, ~325 Songs, + 0 Favoriten und 0 Playlisten auf dem Server — nichts überbauen. +- **Reihenfolge ist bindend.** Tasks 1–4 (Favoriten-Fix) hängen an nichts und + kommen zuerst. Tasks 5–7 sind **blockiert bis Dustin A6 beantwortet hat** + (irreversibler Löschpfad). Tasks 10–12 sind **blockiert bis der Server-Fix + `melo_cloud.py:505` steht** bzw. bis Dustin entscheidet, ob Feature 2 + überhaupt gebaut wird. + +--- + +### Task 1: Reine Merge-Funktionen (`sync_merge.dart`) + +**Dateien:** +- Erstellen: `lib/services/sync_merge.dart` +- Test: `test/services/sync_merge_test.dart` + +**Schnittstellen:** +- Nutzt: nichts (keine Imports außer Dart-Core). +- Liefert: + - `List zuPushendeFavoriten({required Set lokaleFavoriten, required Map cloudIdVon, required Set amServer, int deckel = maxFavoritenPushes})` + - `List lokalZuSetzendeFavoriten({required Set amServer, required Map songIdVonCloudId, required Set lokaleFavoriten})` + - `bool berichtFaellig(DateTime? letzterErfolg, DateTime jetzt)` + - `const int maxFavoritenPushes = 200` + +**Bestehende Tests, die mitgeändert werden:** keine. + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +`test/services/sync_merge_test.dart`: +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/services/sync_merge.dart'; + +void main() { + group('zuPushendeFavoriten', () { + test('leer gegen leer ergibt nichts', () { + expect( + zuPushendeFavoriten( + lokaleFavoriten: const {}, + cloudIdVon: const {}, + amServer: const {}, + ), + isEmpty, + ); + }); + + test('was der Server noch nicht hat, geht hoch', () { + expect( + zuPushendeFavoriten( + lokaleFavoriten: const {'s1', 's2'}, + cloudIdVon: const {'s1': 'c1', 's2': 'c2'}, + amServer: const {'c1'}, + ), + ['c2'], + ); + }); + + test('Titel ohne cloudId kennt der Server nicht und bleiben liegen', () { + // Lokal-only: der Titel wurde nie hochgeladen, es gibt nichts zu melden. + expect( + zuPushendeFavoriten( + lokaleFavoriten: const {'s1'}, + cloudIdVon: const {}, + amServer: const {}, + ), + isEmpty, + ); + }); + + test('was beidseitig steht, wird nicht erneut gepusht', () { + expect( + zuPushendeFavoriten( + lokaleFavoriten: const {'s1'}, + cloudIdVon: const {'s1': 'c1'}, + amServer: const {'c1'}, + ), + isEmpty, + ); + }); + + test('der Deckel begrenzt einen Lauf', () { + final viele = {for (var i = 0; i < 250; i++) 's$i'}; + final zuordnung = {for (var i = 0; i < 250; i++) 's$i': 'c$i'}; + + final offen = zuPushendeFavoriten( + lokaleFavoriten: viele, + cloudIdVon: zuordnung, + amServer: const {}, + ); + + expect(offen, hasLength(maxFavoritenPushes)); + }); + }); + + group('lokalZuSetzendeFavoriten', () { + test('disjunkte Mengen: der Server-Favorit kommt lokal dazu', () { + expect( + lokalZuSetzendeFavoriten( + amServer: const {'c9'}, + songIdVonCloudId: const {'c9': 's9'}, + lokaleFavoriten: const {}, + ), + ['s9'], + ); + }); + + test('eine lokal unauflösbare cloudId wird übersprungen, nicht gelöscht', () { + // Der Titel ist hier (noch) nicht vorhanden. Ein Favorit ohne Song wäre + // unsichtbar, würde aber ewig mitgeschleppt. + expect( + lokalZuSetzendeFavoriten( + amServer: const {'c9'}, + songIdVonCloudId: const {}, + lokaleFavoriten: const {}, + ), + isEmpty, + ); + }); + + test('was lokal schon Favorit ist, wird nicht noch einmal gesetzt', () { + expect( + lokalZuSetzendeFavoriten( + amServer: const {'c1'}, + songIdVonCloudId: const {'c1': 's1'}, + lokaleFavoriten: const {'s1'}, + ), + isEmpty, + ); + }); + }); + + group('berichtFaellig', () { + final jetzt = DateTime(2026, 8, 27, 12); + + test('ohne vorherigen Erfolg nicht fällig', () { + // Neuinstallation: „Willkommen zurück! 325 neue Songs" wäre Unsinn. + expect(berichtFaellig(null, jetzt), isFalse); + }); + + test('unter 24 Stunden nicht fällig', () { + expect(berichtFaellig(jetzt.subtract(const Duration(hours: 23)), jetzt), + isFalse); + }); + + test('ab 24 Stunden fällig', () { + expect(berichtFaellig(jetzt.subtract(const Duration(hours: 24)), jetzt), + isTrue); + expect(berichtFaellig(jetzt.subtract(const Duration(days: 3)), jetzt), + isTrue); + }); + }); +} +``` + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/sync_merge_test.dart` +Erwartet: FAIL mit `Error: Error when reading 'lib/services/sync_merge.dart': No such file or directory` + +- [ ] **Schritt 3: Minimale Implementierung** + +`lib/services/sync_merge.dart` (neu): +```dart +/// Die reinen Entscheidungsfunktionen des Abgleichs — ohne Netz, ohne +/// Datenbank, ohne Plattform-Kanäle. +/// +/// Sie liegen bewusst außerhalb von `SyncService`: was hier steht, lässt sich +/// mit einer Handvoll Mengen prüfen statt mit einem halben Server. +library; + +/// Wie viele Favoriten je Lauf höchstens gepusht werden. +/// +/// Der Rest kommt im nächsten Lauf dran. Die Pushes sind idempotent, ein +/// Teilausfall heilt sich dadurch von selbst. +const int maxFavoritenPushes = 200; + +/// Die cloudIds, die zum Server gepusht werden müssen: `lokal \ server`. +/// +/// [lokaleFavoriten] sind lokale Song-IDs, [cloudIdVon] bildet sie auf ihre +/// cloudId ab. Titel ohne cloudId kennt der Server nicht — sie tauchen in +/// keiner Richtung im Abgleich auf. +List zuPushendeFavoriten({ + required Set lokaleFavoriten, + required Map cloudIdVon, + required Set amServer, + int deckel = maxFavoritenPushes, +}) { + final offen = []; + for (final songId in lokaleFavoriten) { + final cloudId = cloudIdVon[songId]; + if (cloudId == null) continue; + if (amServer.contains(cloudId)) continue; + offen.add(cloudId); + if (offen.length >= deckel) break; + } + return offen; +} + +/// Die lokalen Song-IDs, die aus dem Server-Stand als Favorit dazukommen: +/// `server \ lokal`. +/// +/// Eine cloudId ohne lokalen Titel (Download fehlgeschlagen, noch nicht +/// geladen) wird **übersprungen, nicht gelöscht**: ein Favorit ohne Song wäre +/// über den Join unsichtbar, würde aber weiter mitgeschleppt. +List lokalZuSetzendeFavoriten({ + required Set amServer, + required Map songIdVonCloudId, + required Set lokaleFavoriten, +}) { + final offen = []; + for (final cloudId in amServer) { + final songId = songIdVonCloudId[cloudId]; + if (songId == null) continue; + if (lokaleFavoriten.contains(songId)) continue; + offen.add(songId); + } + return offen; +} + +/// Ob der „Was ist neu"-Bericht fällig ist. +/// +/// `null` heißt Neuinstallation, Abmeldung oder gelöschte App-Daten — dann ist +/// er **nicht** fällig, sonst begrüßt ein frisch eingerichtetes Gerät den +/// Nutzer mit „Willkommen zurück! 325 neue Songs". `sollAutoSync` entscheidet +/// bei `null` bewusst umgekehrt und ist hier **kein** Vorbild. +bool berichtFaellig(DateTime? letzterErfolg, DateTime jetzt) => + letzterErfolg != null && + jetzt.difference(letzterErfolg) >= const Duration(hours: 24); +``` + +- [ ] **Schritt 4: Test laufen lassen, Erfolg bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/sync_merge_test.dart` +Erwartet: `All tests passed!` (12 Tests) +Danach: `/home/dustin/development/flutter/bin/flutter analyze` → keine Meldung. + +- [ ] **Schritt 5: Commit** +```bash +cd /home/dustin/mello-dev/app +git add lib/services/sync_merge.dart test/services/sync_merge_test.dart +git commit -m "Merge-Entscheidungen als reine Funktionen (sync_merge.dart)" +``` + +--- + +### Task 2: `parseFavoriten` härten + `setzeFavorit` (deterministisch) + +**Dateien:** +- Ändern: `lib/services/melo_cloud_service.dart:116-124` (`parseFavoriten`) +- Ändern: `lib/services/melo_cloud_service.dart:258-278` (neue Methode + `setzeFavorit` direkt hinter `favoriten()`; `setzeFavoriten` bleibt in + **diesem** Task noch stehen, weil `sync_service.dart:334` sie noch aufruft) +- Test: `test/services/melo_cloud_service_test.dart:86-102` (Gruppe + `parseFavoriten`) + +**Schnittstellen:** +- Nutzt: nichts aus früheren Tasks. +- Liefert: `Future MeloCloudService.setzeFavorit(String cloudId, bool gesetzt)` + (POST `{basis}/favorites/toggle`, Körper `{"song_id":…, "set":…}`); + `parseFavoriten` wirft ab jetzt `CloudException` bei Fehlerkörper **und** bei + fehlender/nicht-Listen-`favorites`. + +**Bestehende Tests, die mitgeändert werden müssen:** +- `test/services/melo_cloud_service_test.dart:98-101` — + `expect(MeloCloudService.parseFavoriten(jsonEncode({'status':'ok'})), isEmpty)` + zementiert heute genau das Gegenteil der neuen Regel. Dieser Test wird + **umgeschrieben**, nicht gelöscht: aus „ohne Favoriten leere Liste" wird + „fehlender Schlüssel ist ein Fehler" plus ein neuer Test „`favorites: []` + ist eine legitime leere Menge". Das ist Teil des Arbeitspakets. + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +In `test/services/melo_cloud_service_test.dart` die Gruppe `parseFavoriten` +vollständig ersetzen durch: +```dart + group('parseFavoriten', () { + test('liefert nur die IDs', () { + final body = jsonEncode({ + 'favorites': [ + {'id': 'a', 'title': 'A'}, + {'id': 'b', 'title': 'B'}, + ] + }); + + expect(MeloCloudService.parseFavoriten(body), ['a', 'b']); + }); + + test('eine leere Favoritenliste ist kein Fehler', () { + expect( + MeloCloudService.parseFavoriten(jsonEncode({'favorites': []})), + isEmpty, + ); + }); + + test('fehlender Schlüssel ist ein Fehler, keine leere Menge', () { + // Der Router verdrahtet für GET /favorites hart HTTP 200; sechs Handler + // desselben Servers melden Fehler im 200er-Körper. Ein fälschlich + // leeres Ergebnis wäre von einer echten Leerantwort nicht zu + // unterscheiden — wie parseListe und parseUpload wird deshalb geworfen. + expect( + () => MeloCloudService.parseFavoriten(jsonEncode({'status': 'ok'})), + throwsA(isA()), + ); + }); + + test('Server-Fehler im 200er-Körper wird als CloudException gemeldet', () { + expect( + () => MeloCloudService.parseFavoriten( + jsonEncode({'status': 'error', 'error': 'Auth required'})), + throwsA(isA()), + ); + }); + }); +``` + +Zusätzlich ans Ende derselben Datei (vor der schließenden `}` von `main`) +einfügen: +```dart + group('setzeFavorit', () { + test('meldet den Wunsch deterministisch, nicht als Umschalten', () async { + Map? gesendet; + String? pfad; + final dienst = MeloCloudService( + auth: await _angemeldeteAuth(), + client: MockClient((anfrage) async { + pfad = anfrage.url.path; + gesendet = jsonDecode(anfrage.body) as Map; + return http.Response(jsonEncode({'status': 'ok'}), 200); + }), + ); + + await dienst.setzeFavorit('c5', true); + + expect(pfad, endsWith('/favorites/toggle')); + expect(gesendet, {'song_id': 'c5', 'set': true}); + }); + + test('kann einen Favoriten auch ausdrücklich entfernen', () async { + Map? gesendet; + final dienst = MeloCloudService( + auth: await _angemeldeteAuth(), + client: MockClient((anfrage) async { + gesendet = jsonDecode(anfrage.body) as Map; + return http.Response(jsonEncode({'status': 'ok'}), 200); + }), + ); + + await dienst.setzeFavorit('c5', false); + + expect(gesendet, {'song_id': 'c5', 'set': false}); + }); + }); +``` + +Und am Kopf derselben Datei die nötigen Importe + den Auth-Helfer ergänzen +(die Datei hat heute nur `dart:convert`, `flutter_test` und +`melo_cloud_service`): +```dart +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:melo/services/baka_auth.dart'; + +class _MemorySpeicher implements TokenSpeicher { + _MemorySpeicher(this.werte); + final Map werte; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + +Future _angemeldeteAuth() async { + final auth = BakaAuth( + speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}), + ); + await auth.laden(); + return auth; +} +``` + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/melo_cloud_service_test.dart` +Erwartet: FAIL — „fehlender Schlüssel ist ein Fehler, keine leere Menge" +scheitert mit `Expected: throws Actual: <[]>`, +und die `setzeFavorit`-Tests scheitern mit +`The method 'setzeFavorit' isn't defined for the class 'MeloCloudService'`. + +- [ ] **Schritt 3: Minimale Implementierung** + +`lib/services/melo_cloud_service.dart` — `parseFavoriten` ersetzen: +```dart + /// Liest die Favoriten-IDs aus einer Server-Antwort. + /// + /// Ein fehlender `favorites`-Schlüssel ist ein **Fehler, keine leere + /// Menge**: der Router verdrahtet für `GET /favorites` hart HTTP 200, und + /// mehrere Handler desselben Servers melden Fehler im 200er-Körper. Eine + /// fälschlich leere Antwort wäre sonst von einer echten nicht zu + /// unterscheiden — genau wie bei [parseListe] und [parseUpload] wird + /// deshalb geworfen. + @visibleForTesting + static List parseFavoriten(String body) { + final daten = jsonDecode(body) as Map; + final fehler = daten['error'] as String?; + if (fehler != null) throw CloudException(fehler); + final liste = daten['favorites']; + if (liste is! List) { + throw CloudException('Antwort ohne Favoritenliste'); + } + return [ + for (final j in liste) (j as Map)['id'] as String, + ]; + } +``` + +Direkt hinter `favoriten()` (heute `:258-265`) einfügen: +```dart + /// Setzt einen einzelnen Favoriten am Server — additiv oder entfernend, + /// aber immer **deterministisch**. + /// + /// Bewusst kein Umschalten: hätte der Server einen abweichenden Stand, + /// kehrte ein Toggle den Wunsch des Nutzers um. + Future setzeFavorit(String cloudId, bool gesetzt) async { + _pruefeAnmeldung(); + final antwort = await _client + .post( + Uri.parse('$basisUrl/favorites/toggle'), + headers: {..._kopf, 'Content-Type': 'application/json'}, + body: jsonEncode({'song_id': cloudId, 'set': gesetzt}), + ) + .timeout(const Duration(seconds: 30)); + _pruefeStatus(antwort); + } +``` + +- [ ] **Schritt 4: Test laufen lassen, Erfolg bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/melo_cloud_service_test.dart` +Erwartet: `All tests passed!` +Danach `/home/dustin/development/flutter/bin/flutter analyze` → sauber. + +- [ ] **Schritt 5: Commit** +```bash +cd /home/dustin/mello-dev/app +git add lib/services/melo_cloud_service.dart test/services/melo_cloud_service_test.dart +git commit -m "Favoriten-GET gehaertet + deterministisches setzeFavorit" +``` + +--- + +### Task 3: Additiver Favoriten-Abgleich im `SyncService` + +**Dateien:** +- Ändern: `lib/services/sync_service.dart:1-11` (Importe `dart:async` und + `sync_merge.dart`) +- Ändern: `lib/services/sync_service.dart:213` (Aufruf in `synchronisiere()`) +- Ändern: `lib/services/sync_service.dart:299-320` (`_ladeHoch`: Zeitüber- + schreitung wie einen Einzelfehler behandeln) +- Ändern: `lib/services/sync_service.dart:322-335` (`_gleicheFavoritenAb` + vollständig ersetzen) +- Ändern: `lib/services/melo_cloud_service.dart` — die Methode `setzeFavoriten` + ersatzlos **löschen** (nach dieser Änderung ohne Aufrufer). Bewusst **ohne + Zeilenangabe**: Task 2 fügt davor `setzeFavorit` ein und schiebt + `setzeFavoriten` um rund 17 Zeilen nach unten — zur Ausführungszeit ist + jede hier notierte Zeilennummer falsch. Nach dem Symbolnamen suchen. +- Test: `test/services/sync_service_test.dart:166-202` (umschreiben) + fünf + neue Tests + +> **Warum der Timeout-Fix hier steht und nicht in Task 5:** Er gehörte in der +> Vorfassung zu Task 5 und war damit an die offene A6-Entscheidung gekettet — +> ohne jeden Grund. `_ladeHoch` läuft in **jedem** Abgleich, vergibt keine +> neue cloudId über das hinaus, was der bestehende Auto-Upload ohnehin tut, +> und hat mit dem irreversiblen Löschpfad nichts zu tun. Er kommt deshalb in +> den frühesten unblockierten Task, der `sync_service.dart` ohnehin anfasst. +> Task 5 setzt ihn voraus und ändert `_ladeHoch` selbst nicht mehr. + +**Schnittstellen:** +- Nutzt aus Task 1: `zuPushendeFavoriten(...)`, `lokalZuSetzendeFavoriten(...)`. +- Nutzt aus Task 2: `cloud.setzeFavorit(cloudId, true)`, gehärtetes + `cloud.favoriten()`. +- Liefert: `Future SyncService._gleicheFavoritenAb()` — `true`, wenn die + Phase vollständig durchlief (Grundlage des Erfolgs-Flags in Task 8). +- Liefert: `_ladeHoch` fängt zusätzlich `TimeoutException`. Die Signatur + bleibt `Future` — den Rückgabewert für das Erfolgs-Flag ergänzt + Task 8, wo er auch ausgewertet wird. + +**Bestehende Tests, die mitgeändert werden müssen:** +- `test/services/sync_service_test.dart:166-202` („Favoriten werden mit ihren + Server-IDs gemeldet") prüft heute exakt das Full-Replace-Verhalten, das + dieser Task beseitigt. Er wird auf den additiven Delta-Push umgeschrieben + und bekommt eine **explizite** `/favorites`-Antwort. + **Der Catch-All-Mock (`:196`) der übrigen sieben Tests wird NICHT + aufgeweicht**, um die neue Sicherheitsregel zu umgehen — dort liefert + `GET /favorites` weiterhin `{'status':'ok'}`, die Phase wird korrekt + übersprungen, und genau das ist das gewünschte Verhalten. Diese sieben + Tests bleiben unverändert und müssen grün bleiben. + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +Am Kopf von `test/services/sync_service_test.dart` `import 'dart:async';` +ergänzen (für `TimeoutException`; `dart:io` ist schon da). + +In `test/services/sync_service_test.dart` den Test in Zeile 166–202 +vollständig ersetzen durch die folgenden fünf Tests: +```dart + test('lokale Favoriten werden additiv gepusht, nie als Voll-Ersatz', () async { + await db.upsertSongs([ + SongsCompanion.insert( + id: 'lokal-1', + path: '${tempDir.path}/fav.mp3', + title: 'Lieblingslied', + dateAddedMs: 0, + updatedAtMs: 0, + ), + ]); + await db.setCloudId('lokal-1', 'c5'); + await db.setFavorite('lokal-1', true); + + final gepusht = >[]; + var vollErsatz = 0; + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response( + jsonEncode({ + 'songs': [ + {'id': 'c5', 'title': 'Lieblingslied'} + ] + }), + 200, + ); + } + if (pfad.endsWith('/favorites/toggle')) { + gepusht.add(jsonDecode(anfrage.body) as Map); + return http.Response(jsonEncode({'status': 'ok'}), 200); + } + if (pfad.endsWith('/favorites')) { + if (anfrage.method == 'POST') vollErsatz++; + return http.Response(jsonEncode({'favorites': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + expect(gepusht, [ + {'song_id': 'c5', 'set': true} + ]); + // Der Datenverlust-Bug ist strukturell weg: es gibt keinen Aufruf mehr, + // der den Server-Stand ersetzen könnte. + expect(vollErsatz, 0); + expect(sync.fehler, isNull); + }); + + test('ein Server-Favorit wird lokal nachgezogen', () async { + await db.upsertSongs([ + SongsCompanion.insert( + id: 'lokal-1', + path: '${tempDir.path}/fav.mp3', + title: 'Lieblingslied', + dateAddedMs: 0, + updatedAtMs: 0, + ), + ]); + await db.setCloudId('lokal-1', 'c5'); + + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response( + jsonEncode({ + 'songs': [ + {'id': 'c5', 'title': 'Lieblingslied'} + ] + }), + 200, + ); + } + if (pfad.endsWith('/favorites')) { + return http.Response( + jsonEncode({ + 'favorites': [ + {'id': 'c5'} + ] + }), + 200, + ); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + expect(await db.favoriteSongIds(), ['lokal-1']); + }); + + test('200 mit Fehlerkörper überspringt die Favoriten-Phase ohne Push', + () async { + await db.upsertSongs([ + SongsCompanion.insert( + id: 'lokal-1', + path: '${tempDir.path}/fav.mp3', + title: 'Lieblingslied', + dateAddedMs: 0, + updatedAtMs: 0, + ), + ]); + await db.setCloudId('lokal-1', 'c5'); + await db.setFavorite('lokal-1', true); + + var pushes = 0; + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response( + jsonEncode({ + 'songs': [ + {'id': 'c5', 'title': 'Lieblingslied'} + ] + }), + 200, + ); + } + if (pfad.endsWith('/favorites/toggle')) { + pushes++; + return http.Response(jsonEncode({'status': 'ok'}), 200); + } + if (pfad.endsWith('/favorites')) { + return http.Response( + jsonEncode({'status': 'error', 'error': 'kaputt'}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + expect(pushes, 0); + // Nur diese Phase fällt aus, der Lauf geht weiter. + expect(sync.fehler, isNull); + }); + + test('200 mit leerer Favoritenliste läuft normal durch', () async { + // Gegenprobe zum Test darüber: eine echte Leerantwort darf NICHT als + // Fehler gelten, sonst wäre die Sicherheitsregel trivial erfüllt. + await db.upsertSongs([ + SongsCompanion.insert( + id: 'lokal-1', + path: '${tempDir.path}/fav.mp3', + title: 'Lieblingslied', + dateAddedMs: 0, + updatedAtMs: 0, + ), + ]); + await db.setCloudId('lokal-1', 'c5'); + await db.setFavorite('lokal-1', true); + + var pushes = 0; + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response( + jsonEncode({ + 'songs': [ + {'id': 'c5', 'title': 'Lieblingslied'} + ] + }), + 200, + ); + } + if (pfad.endsWith('/favorites/toggle')) { + pushes++; + return http.Response(jsonEncode({'status': 'ok'}), 200); + } + if (pfad.endsWith('/favorites')) { + return http.Response(jsonEncode({'favorites': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + expect(pushes, 1); + }); + + test('eine Zeitüberschreitung beim Hochladen reißt den Lauf nicht ab', + () async { + final datei = File('${tempDir.path}/haengt.mp3'); + await datei.writeAsBytes([1]); + await db.upsertSongs([ + SongsCompanion.insert( + id: 'lokal-1', + path: datei.path, + title: 'Hängt', + dateAddedMs: 0, + updatedAtMs: 0, + ), + ]); + + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response(jsonEncode({'songs': []}), 200); + } + if (pfad.endsWith('/upload')) { + // Der 120-s-Timeout in melo_cloud_service wirft TimeoutException, + // nicht CloudException — ohne eigenen Zweig riss ein einziger + // hängender Upload den ganzen Lauf ab. + throw TimeoutException('zu lang'); + } + if (pfad.endsWith('/favorites')) { + return http.Response(jsonEncode({'favorites': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + expect(sync.fehler, isNull); + expect((await db.songById('lokal-1'))!.cloudId, isNull); + }); +``` + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/sync_service_test.dart` +Erwartet: FAIL — „lokale Favoriten werden additiv gepusht" scheitert mit +`Expected: [{'song_id':'c5','set':true}] Actual: []` (heute geht ein +`POST /favorites` raus, kein Toggle), „ein Server-Favorit wird lokal +nachgezogen" scheitert mit `Expected: ['lokal-1'] Actual: []`, und „eine +Zeitüberschreitung beim Hochladen reißt den Lauf nicht ab" scheitert mit +`Expected: Actual: 'Abgleich fehlgeschlagen: TimeoutException: zu lang'`. + +- [ ] **Schritt 3: Minimale Implementierung** + +`lib/services/sync_service.dart` — Importe ergänzen (`dart:async` zu den +Dart-Importen ganz oben, `sync_merge.dart` hinter +`import 'melo_cloud_service.dart';`): +```dart +import 'dart:async'; +``` +```dart +import 'sync_merge.dart'; +``` + +`_ladeHoch` (heute `:299-320`) — den `catch`-Block um die Zeitüberschreitung +erweitern: +```dart + } on CloudException catch (e) { + // Eine zu große oder abgelehnte Datei darf den Lauf nicht beenden. + debugPrint('Upload „${song.title}" übersprungen: ${e.message}'); + } on TimeoutException { + // Der 120-s-Timeout (melo_cloud_service.dart:180) wirft + // TimeoutException, nicht CloudException — ohne diesen Zweig riss ein + // einziger hängender Upload den ganzen Lauf ab. + debugPrint('Upload „${song.title}": Zeitüberschreitung'); + } +``` + +`_gleicheFavoritenAb` (heute `:322-335`) vollständig ersetzen: +```dart + /// Additiver Favoriten-Abgleich: gleicht in **beide** Richtungen an, + /// entfernt aber in **keiner**. + /// + /// Damit ist der alte Datenverlust-Bug strukturell unmöglich: es gibt + /// keinen Codepfad mehr, der den Server-Stand ersetzen könnte. Der Preis + /// ist bekannt und bewusst: ein Ent-Favorisieren propagiert nicht + /// geräteübergreifend — hält ein zweites Gerät den Favoriten noch, bringt + /// dessen nächster Abgleich ihn zurück. + /// + /// Gibt `true` zurück, wenn die Phase vollständig durchlief. + Future _gleicheFavoritenAb() async { + _melde('Gleiche Favoriten ab …'); + + final Set amServer; + try { + amServer = (await cloud.favoriten()).toSet(); + } catch (e) { + // Ohne Server-Stand ist nichts zu tun. Blindes Pushen wäre harmlos, + // aber nutzlos — die Phase wird übersprungen, der Lauf geht weiter. + debugPrint('Favoriten-Abgleich übersprungen: $e'); + return false; + } + + // Grabsteine bleiben außen vor: favoriteSongIds() liefert auch Favoriten + // getombsteter Titel, und die gehören nicht zurück auf den Server. + final lokal = await db.allSongs(); + final cloudIdVon = { + for (final s in lokal) + if (s.cloudId != null && !s.deleted) s.id: s.cloudId!, + }; + final songIdVonCloudId = { + for (final s in lokal) + if (s.cloudId != null && !s.deleted) s.cloudId!: s.id, + }; + final favoriten = (await db.favoriteSongIds()).toSet(); + + var vollstaendig = true; + + for (final cloudId in zuPushendeFavoriten( + lokaleFavoriten: favoriten, + cloudIdVon: cloudIdVon, + amServer: amServer, + )) { + try { + await cloud.setzeFavorit(cloudId, true); + } catch (e) { + debugPrint('Favorit $cloudId nicht gemeldet: $e'); + vollstaendig = false; + } + } + + for (final songId in lokalZuSetzendeFavoriten( + amServer: amServer, + songIdVonCloudId: songIdVonCloudId, + lokaleFavoriten: favoriten, + )) { + await db.setFavorite(songId, true); + } + + return vollstaendig; + } +``` + +In `synchronisiere()` Zeile 213 bleibt der Aufruf `await _gleicheFavoritenAb();` +unverändert stehen (der Rückgabewert wird erst in Task 8 ausgewertet). + +`lib/services/melo_cloud_service.dart` — die Methode `setzeFavoriten` +**ersatzlos löschen**, inklusive Doc-Kommentar. Sie hat danach keinen Aufrufer +mehr, und ihr Weiterbestehen wäre die einzige verbliebene Möglichkeit, den +Server-Stand zu ersetzen. +**Keine Zeilenangabe:** Task 2 fügt davor `setzeFavorit` ein und schiebt +`setzeFavoriten` um rund 17 Zeilen nach unten. Die Stelle über den Symbolnamen +suchen (`grep -n "setzeFavoriten" lib/services/melo_cloud_service.dart`), +nicht über eine notierte Zeilennummer. + +- [ ] **Schritt 4: Test laufen lassen, Erfolg bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/sync_service_test.dart` +Erwartet: `All tests passed!` — insbesondere laufen die sieben unveränderten +Tests mit ihrem Catch-All-Mock weiter grün durch (die Favoriten-Phase wird +dort korrekt übersprungen). +Gegenprobe, dass der Voll-Ersatz wirklich weg ist: +`grep -rn "setzeFavoriten" lib/ test/` → **kein Treffer**. +Danach `/home/dustin/development/flutter/bin/flutter analyze` → sauber. + +- [ ] **Schritt 5: Commit** +```bash +cd /home/dustin/mello-dev/app +git add lib/services/sync_service.dart lib/services/melo_cloud_service.dart test/services/sync_service_test.dart +git commit -m "Favoriten additiv abgleichen statt ersetzen; Upload-Timeout ist Einzelfehler" +``` + +--- + +### Task 4: Sofort-Push beim Antippen des Herzens + +**Dateien:** +- Ändern: `lib/library/playlist_service.dart:1-12` (Import + Konstruktor) +- Ändern: `lib/library/playlist_service.dart:45-48` (`toggleFavorite`) +- Ändern: `lib/main.dart:54` (Konstruktionsreihenfolge: `PlaylistService` + entsteht heute **vor** `_bakaAuth`/`MeloCloudService`; er muss nach unten + wandern, hinter die `_sync`-Konstruktion in `:81-85`) +- Test: `test/library/playlist_service_test.dart` (neue Gruppe) + +**Schnittstellen:** +- Nutzt aus Task 2: `MeloCloudService.setzeFavorit(cloudId, gesetzt)`. +- Liefert: `PlaylistService(db, {NavidromeService? navidrome, MeloCloudService? cloud})` + — `cloud` optional, damit alle bestehenden Aufrufer und Tests unverändert + bleiben. + +**Bestehende Tests, die mitgeändert werden müssen:** keine. +`test/library/playlist_service_test.dart:29-40` konstruiert `PlaylistService(db)` +ohne Cloud — durch den optionalen Parameter bleibt der Test gültig und muss +grün bleiben (Beweis, dass der Push ohne Cloud stillschweigend entfällt). + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +In `test/library/playlist_service_test.dart` am Kopf ergänzen: +```dart +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/playlist_service.dart'; +import 'package:melo/services/baka_auth.dart'; +import 'package:melo/services/melo_cloud_service.dart'; + +class _MemorySpeicher implements TokenSpeicher { + _MemorySpeicher(this.werte); + final Map werte; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + +Future _angemeldeteAuth() async { + final auth = BakaAuth( + speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}), + ); + await auth.laden(); + return auth; +} +``` +(die vorhandenen Importe der Datei nicht doppeln) + +Und am Ende von `main()` diese Gruppe anfügen: +```dart + group('Sofort-Push der Favoriten', () { + Future legeSongAn(MeloDb db, {String? cloudId}) async { + await db.into(db.songs).insert(SongsCompanion.insert( + id: 'song-1', + path: '/a.mp3', + title: 'A', + dateAddedMs: 0, + updatedAtMs: 0, + )); + if (cloudId != null) await db.setCloudId('song-1', cloudId); + } + + test('setzt deterministisch true beim Favorisieren', () async { + final db2 = MeloDb(NativeDatabase.memory()); + addTearDown(db2.close); + await legeSongAn(db2, cloudId: 'c1'); + + final gesendet = >[]; + final dienst = PlaylistService( + db2, + cloud: MeloCloudService( + auth: await _angemeldeteAuth(), + client: MockClient((anfrage) async { + gesendet.add(jsonDecode(anfrage.body) as Map); + return http.Response(jsonEncode({'status': 'ok'}), 200); + }), + ), + ); + + await dienst.toggleFavorite('song-1'); + + expect(gesendet, [ + {'song_id': 'c1', 'set': true} + ]); + }); + + test('setzt deterministisch false beim Ent-Favorisieren', () async { + final db2 = MeloDb(NativeDatabase.memory()); + addTearDown(db2.close); + await legeSongAn(db2, cloudId: 'c1'); + await db2.setFavorite('song-1', true); + + final gesendet = >[]; + final dienst = PlaylistService( + db2, + cloud: MeloCloudService( + auth: await _angemeldeteAuth(), + client: MockClient((anfrage) async { + gesendet.add(jsonDecode(anfrage.body) as Map); + return http.Response(jsonEncode({'status': 'ok'}), 200); + }), + ), + ); + + await dienst.toggleFavorite('song-1'); + + expect(gesendet, [ + {'song_id': 'c1', 'set': false} + ]); + }); + + test('ohne cloudId wird nichts gemeldet', () async { + final db2 = MeloDb(NativeDatabase.memory()); + addTearDown(db2.close); + await legeSongAn(db2); + + var anfragen = 0; + final dienst = PlaylistService( + db2, + cloud: MeloCloudService( + auth: await _angemeldeteAuth(), + client: MockClient((_) async { + anfragen++; + return http.Response(jsonEncode({'status': 'ok'}), 200); + }), + ), + ); + + await dienst.toggleFavorite('song-1'); + + expect(anfragen, 0); + expect(await db2.watchIsFavorite('song-1').first, isTrue); + }); + + test('ein Fehler des Servers ändert lokal nichts und wirft nicht', + () async { + final db2 = MeloDb(NativeDatabase.memory()); + addTearDown(db2.close); + await legeSongAn(db2, cloudId: 'c1'); + + final dienst = PlaylistService( + db2, + cloud: MeloCloudService( + auth: await _angemeldeteAuth(), + client: MockClient( + (_) async => http.Response(jsonEncode({'error': 'weg'}), 500)), + ), + ); + + // Der nächste Voll-Abgleich holt den Push additiv nach. + await dienst.toggleFavorite('song-1'); + + expect(await db2.watchIsFavorite('song-1').first, isTrue); + }); + }); +``` + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/library/playlist_service_test.dart` +Erwartet: FAIL mit +`No named parameter with the name 'cloud'` in `PlaylistService(...)`. + +- [ ] **Schritt 3: Minimale Implementierung** + +`lib/library/playlist_service.dart` — Kopf ersetzen: +```dart +import 'package:flutter/foundation.dart'; +import '../services/melo_cloud_service.dart'; +import '../services/navidrome_service.dart'; +import 'database.dart'; + +/// Koordiniert Playlisten- und Favoriten-Operationen; benachrichtigt Listener +/// nach jeder Mutation (für Feedback wie SnackBars — die Listen selbst +/// beobachten UIs direkt über die watch()-Streams von [MeloDb]). +class PlaylistService extends ChangeNotifier { + PlaylistService(this.db, {NavidromeService? navidrome, MeloCloudService? cloud}) + : _navidrome = navidrome ?? NavidromeService(), + _cloud = cloud; + final MeloDb db; + final NavidromeService _navidrome; + + /// Optional: ohne Cloud-Zugang entfällt der Sofort-Push stillschweigend. + final MeloCloudService? _cloud; +``` + +`toggleFavorite` (heute `:45-48`) ersetzen: +```dart + Future toggleFavorite(String songId) async { + await db.toggleFavorite(songId); + notifyListeners(); + await _meldeFavorit(songId); + } + + /// Meldet den neuen Favoriten-Stand sofort an die Melo-Cloud. + /// + /// Deterministisch (`set:true/false`), nicht als Umschalten: ein + /// abweichender Server-Stand darf den Wunsch nicht invertieren. Nur für + /// Titel mit cloudId, und Fehler werden still geschluckt — der nächste + /// Abgleich pusht additiv nach. + Future _meldeFavorit(String songId) async { + final cloud = _cloud; + if (cloud == null || !cloud.istAngemeldet) return; + final cloudId = (await db.songById(songId))?.cloudId; + if (cloudId == null) return; + final gesetzt = await db.watchIsFavorite(songId).first; + try { + await cloud.setzeFavorit(cloudId, gesetzt); + } catch (e) { + debugPrint('Favorit nicht gemeldet: $e'); + } + } +``` + +`lib/main.dart` — die Zeile `_playlists = PlaylistService(_db);` (heute `:54`) +dort entfernen und **nach** der `_sync`-Konstruktion (heute `:81-85`) neu +einsetzen: +```dart + // Erst hier: der Sofort-Push braucht den angemeldeten Cloud-Zugang, und + // _bakaAuth entsteht weiter oben. + _playlists = PlaylistService( + _db, + cloud: MeloCloudService(auth: _bakaAuth), + ); +``` + +- [ ] **Schritt 4: Test laufen lassen, Erfolg bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/library/playlist_service_test.dart` +Erwartet: `All tests passed!` +Prüfen, dass `_playlists` in `main.dart` vor seiner ersten Verwendung gesetzt +wird: `grep -n "_playlists" lib/main.dart` — die Zuweisung muss vor +`ChangeNotifierProvider.value` (`:125`) stehen. +Danach `/home/dustin/development/flutter/bin/flutter analyze` → sauber. + +**Vollständigen Testlauf hier zum ersten Mal im Hintergrund starten:** +`/home/dustin/development/flutter/bin/flutter test --no-pub` +(600+ Tests, mehrere Minuten — `run_in_background`, Ergebnis abwarten.) +Erwartet: alle Tests grün. + +- [ ] **Schritt 5: Commit** +```bash +cd /home/dustin/mello-dev/app +git add lib/library/playlist_service.dart lib/main.dart test/library/playlist_service_test.dart +git commit -m "Herz-Tipp meldet den Favoriten sofort deterministisch an die Cloud" +``` + +--- + +### Task 5: `ladeAusgewaehlteHoch` + `abbrechen()` im `SyncService` + +> **BLOCKIERT bis Dustin A6 beantwortet hat.** +> A6 = „Ist der irreversible Löschpfad gewollt?" (Spec, §OFFENE ENTSCHEIDUNG). +> Dieser Task gibt mehr Titeln eine cloudId und vergrößert damit genau die +> Angriffsfläche des Löschpfads. Lautet die Antwort **„nein"**, gehört der +> Server-Auftrag „Datei-Wiederherstellung im Dedup-Zweig von `upload()`" +> **davor**. Lautet sie **„ja"**, darf sofort begonnen werden. +> **Nicht ohne Antwort anfangen.** + +**Dateien:** +- Ändern: `lib/services/sync_service.dart:143-155` (Feld `_abbruchGewuenscht`) +- Ändern: `lib/services/sync_service.dart` (neue öffentliche Methoden + `abbrechen()` und `ladeAusgewaehlteHoch(...)` hinter `synchronisiere()`) +- Test: `test/services/sync_service_test.dart` (neue Gruppe) + +`_ladeHoch` und der Import `dart:async` werden hier **nicht** mehr angefasst — +beides steht seit Task 3 (siehe dortigen Kasten „Warum der Timeout-Fix hier +steht"). `import 'dart:async';` ist in Datei und Test damit schon vorhanden +und darf nicht ein zweites Mal ergänzt werden. + +**Schnittstellen:** +- Nutzt: den bestehenden `cloud.hochladen(datei, dateiname: …)`-Pfad und + `db.setCloudId(songId, cloudId)`. +- Nutzt aus Task 3: `import 'dart:async';` in `sync_service.dart` und im Test + (für `TimeoutException`). +- Liefert: + - `class UploadErgebnis { final int hochgeladen; final int schonDa; final List fehler; final bool abgebrochen; String get meldung; }` + - `Future SyncService.ladeAusgewaehlteHoch(List songs)` + - `void SyncService.abbrechen()` + +**Bestehende Tests, die mitgeändert werden müssen:** keine. + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +Am Ende von `test/services/sync_service_test.dart` (vor der schließenden `}` +von `main`) einfügen: +```dart + group('ladeAusgewaehlteHoch', () { + Future> dreiTitel(Directory ordner, MeloDb db) async { + for (var i = 0; i < 3; i++) { + final datei = File('${ordner.path}/auswahl$i.mp3'); + await datei.writeAsBytes([i]); + await db.upsertSongs([ + SongsCompanion.insert( + id: 'lokal-$i', + path: datei.path, + title: 'Titel $i', + dateAddedMs: i, + updatedAtMs: 0, + ), + ]); + } + return db.allSongs(); + } + + test('lädt nur, was noch keine cloudId hat', () async { + final songs = await dreiTitel(tempDir, db); + await db.setCloudId('lokal-1', 'schon-da'); + + var uploads = 0; + final sync = await baue((anfrage) async { + if (anfrage.url.path.endsWith('/upload')) { + uploads++; + return http.Response( + jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs()); + + expect(songs, hasLength(3)); + expect(uploads, 2); + expect(ergebnis.hochgeladen, 2); + expect(ergebnis.schonDa, 1); + expect(ergebnis.fehler, isEmpty); + }); + + test('ein abgelehnter Titel stoppt die übrigen nicht', () async { + await dreiTitel(tempDir, db); + + var uploads = 0; + final sync = await baue((anfrage) async { + if (anfrage.url.path.endsWith('/upload')) { + uploads++; + // Der Server meldet „zu groß" im Körper — dieselbe Wirkung wie eine + // lokal abgelehnte 50-MB-Datei, ohne 50 MB schreiben zu müssen. + if (uploads == 2) { + return http.Response( + jsonEncode({'error': 'Datei zu groß (max 50 MB)'}), 200); + } + return http.Response( + jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs()); + + expect(ergebnis.hochgeladen, 2); + expect(ergebnis.fehler, hasLength(1)); + expect(ergebnis.fehler.single, contains('zu groß')); + }); + + test('eine Zeitüberschreitung ist ein Einzelfehler, kein Laufabbruch', + () async { + await dreiTitel(tempDir, db); + + var uploads = 0; + final sync = await baue((anfrage) async { + if (anfrage.url.path.endsWith('/upload')) { + uploads++; + if (uploads == 1) throw TimeoutException('zu lang'); + return http.Response( + jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs()); + + expect(ergebnis.hochgeladen, 2); + expect(ergebnis.fehler, hasLength(1)); + }); + + test('abbrechen() stoppt zwischen zwei Titeln', () async { + await dreiTitel(tempDir, db); + + late SyncService sync; + var uploads = 0; + sync = await baue((anfrage) async { + if (anfrage.url.path.endsWith('/upload')) { + uploads++; + sync.abbrechen(); + return http.Response( + jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs()); + + expect(uploads, 1); + expect(ergebnis.abgebrochen, isTrue); + expect(sync.laeuft, isFalse); + }); + + test('schreibt den Sync-Zeitstempel nicht', () async { + await dreiTitel(tempDir, db); + + final sync = await baue((anfrage) async { + if (anfrage.url.path.endsWith('/upload')) { + return http.Response( + jsonEncode({'status': 'ok', 'song_id': 'neu'}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.ladeAusgewaehlteHoch(await db.allSongs()); + + // Ein Upload ist kein Abgleich: sonst unterdrückt er 15 Minuten den + // Auto-Sync und verschiebt die 24-h-Uhr des Berichts. + expect(sync.letzterLauf, isNull); + }); + + test('während eines laufenden Abgleichs wird abgewiesen', () async { + await dreiTitel(tempDir, db); + + late SyncService sync; + UploadErgebnis? waehrendSync; + sync = await baue((anfrage) async { + if (anfrage.url.path.endsWith('/list')) { + waehrendSync = await sync.ladeAusgewaehlteHoch(await db.allSongs()); + return http.Response(jsonEncode({'songs': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + expect(waehrendSync, isNotNull); + expect(waehrendSync!.hochgeladen, 0); + expect(sync.fehler, contains('Abgleich')); + }); + }); +``` + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/sync_service_test.dart` +Erwartet: FAIL mit +`The method 'ladeAusgewaehlteHoch' isn't defined for the class 'SyncService'` +und `Undefined name 'UploadErgebnis'`. + +- [ ] **Schritt 3: Minimale Implementierung** + +Vor der Klasse `SyncService` (z. B. hinter `sollAutoSync`) einfügen: +```dart +/// Was ein gezielter Upload erledigt hat — Grundlage der Meldung an den +/// Nutzer. Ein einzelner Fehlschlag darf den Erfolg der übrigen nicht +/// verdecken, deshalb steht hier alles nebeneinander. +class UploadErgebnis { + const UploadErgebnis({ + this.hochgeladen = 0, + this.schonDa = 0, + this.fehler = const [], + this.abgebrochen = false, + }); + + final int hochgeladen; + final int schonDa; + final List fehler; + final bool abgebrochen; + + String get meldung { + final teile = []; + if (hochgeladen > 0) teile.add('$hochgeladen hochgeladen'); + if (schonDa > 0) teile.add('$schonDa waren schon da'); + if (fehler.isNotEmpty) teile.add('${fehler.length} fehlgeschlagen'); + if (abgebrochen) teile.add('abgebrochen'); + return teile.isEmpty ? 'Nichts zu tun' : teile.join(', '); + } +} +``` + +Im Feld-Block der Klasse (hinter `DateTime? _letzterLauf;`, heute `:148`) +ergänzen: +```dart + bool _abbruchGewuenscht = false; +``` + +Hinter `synchronisiere()` einfügen: +```dart + /// Stoppt einen laufenden Auswahl-Upload zwischen zwei Titeln. + /// + /// Nach dem Muster von `DownloadService.abbrechen`: wer versehentlich 60 + /// statt 6 Titel markiert hat, soll nicht die App killen müssen. + void abbrechen() { + if (_laeuft) _abbruchGewuenscht = true; + } + + /// Lädt genau [songs] zum Server — die ausdrückliche Nutzeraktion aus dem + /// Auswahl-Modus. + /// + /// Sequenziell, weil der Server jeden Upload komplett im RAM hält. Titel mit + /// cloudId werden übersprungen, Einzelfehler vermerkt und übergangen. + /// Der Sync-Zeitstempel wird bewusst **nicht** geschrieben: ein Upload ist + /// kein Abgleich, und sonst unterdrückte er 15 Minuten den Auto-Sync und + /// verschöbe die 24-h-Uhr des Berichts. + /// + /// Den Offline-Modus-Schalter beachtet er nicht — er ist eine ausdrückliche + /// Nutzeraktion. + Future ladeAusgewaehlteHoch(List songs) async { + if (_laeuft) { + _fehler = 'Es läuft gerade ein Abgleich — bitte kurz warten'; + notifyListeners(); + return const UploadErgebnis(); + } + if (!cloud.istAngemeldet) { + _fehler = 'Bitte zuerst beim Baka-Konto anmelden'; + notifyListeners(); + return const UploadErgebnis(); + } + + _laeuft = true; + _abbruchGewuenscht = false; + _fehler = null; + _erledigt = 0; + _gesamt = songs.length; + notifyListeners(); + + var hochgeladen = 0; + var schonDa = 0; + final fehler = []; + var abgebrochen = false; + + try { + for (final song in songs) { + if (_abbruchGewuenscht) { + abgebrochen = true; + break; + } + if (song.cloudId != null) { + schonDa++; + _erledigt++; + notifyListeners(); + continue; + } + final datei = File(song.path); + if (!await datei.exists()) { + fehler.add('${song.title}: Datei nicht gefunden'); + _erledigt++; + notifyListeners(); + continue; + } + _melde('Sende „${song.title}" …'); + try { + final cloudId = await cloud.hochladen( + datei, + dateiname: + '${_sichererDateiname(song.title)}${p.extension(song.path)}', + ); + if (cloudId != null) { + await db.setCloudId(song.id, cloudId); + hochgeladen++; + } else { + fehler.add('${song.title}: keine Server-ID erhalten'); + } + } on CloudException catch (e) { + fehler.add('${song.title}: ${e.message}'); + } on TimeoutException { + fehler.add('${song.title}: Zeitüberschreitung'); + } + _erledigt++; + notifyListeners(); + } + } finally { + _laeuft = false; + _abbruchGewuenscht = false; + _status = null; + notifyListeners(); + } + + return UploadErgebnis( + hochgeladen: hochgeladen, + schonDa: schonDa, + fehler: fehler, + abgebrochen: abgebrochen, + ); + } +``` + +- [ ] **Schritt 4: Test laufen lassen, Erfolg bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/sync_service_test.dart` +Erwartet: `All tests passed!` +Danach `/home/dustin/development/flutter/bin/flutter analyze` → sauber. + +- [ ] **Schritt 5: Commit** +```bash +cd /home/dustin/mello-dev/app +git add lib/services/sync_service.dart test/services/sync_service_test.dart +git commit -m "Gezielter Upload mit Abbrechen aus dem Auswahl-Modus" +``` + +--- + +### Task 6: Auswahl-Modus-Aktion „Auf den Server laden" + +> **BLOCKIERT bis Dustin A6 beantwortet hat** — dieselbe Begründung wie +> Task 5: die Aktion ist die Oberfläche zu `ladeAusgewaehlteHoch`. + +**Dateien:** +- Ändern: `lib/shared/auswahl_leiste.dart:18-66` (`AuswahlLeiste` bekommt + `onServerLaden`) und Ende der Datei (neue Funktion `ladeAufServer`) +- Ändern: `lib/shared/sortable_song_list.dart:20-36` (neuer Parameter + `serverUpload`), `:60-68` (neue Methode), `:193-199` (`AuswahlLeiste`-Aufruf) +- Ändern: `lib/library/my_music_screen.dart:120-126` (`serverUpload: true`) +- Test: `test/shared/server_upload_aktion_test.dart` (neu) + +**Schnittstellen:** +- Nutzt aus Task 5: `SyncService.ladeAusgewaehlteHoch(List)`, + `UploadErgebnis.meldung`. +- Liefert: + - `AuswahlLeiste({… VoidCallback? onServerLaden})` — Knopf erscheint nur, + wenn gesetzt. + - `SortableSongList({… bool serverUpload = false})` — Standard `false`, damit + die Aktion **nicht** in den vier anderen Ansichten erscheint + (`favorites_screen.dart:28`, `playlists_screen.dart:166`, + `titel_listen_screen.dart:30`, und über `titel_listen_screen` die + Kategorie-/Künstler-Listen). + - `Future ladeAufServer(BuildContext context, List gewaehlte)` + +**Bestehende Tests, die mitgeändert werden müssen:** keine. +`test/shared/auswahl_modus_test.dart` konstruiert `SortableSongList` ohne den +neuen Parameter — durch den Standardwert `false` bleibt er gültig und muss +grün bleiben. + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +`test/shared/server_upload_aktion_test.dart` (neu): +```dart +import 'package:drift/drift.dart' show Value, driftRuntimeOptions; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:melo/library/category_service.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/playlist_service.dart'; +import 'package:melo/services/baka_auth.dart'; +import 'package:melo/services/melo_cloud_service.dart'; +import 'package:melo/services/sync_service.dart'; +import 'package:melo/settings/app_settings.dart'; +import 'package:melo/shared/sort_store.dart'; +import 'package:melo/shared/sortable_song_list.dart'; + +class _MemorySpeicher implements TokenSpeicher { + _MemorySpeicher(this.werte); + final Map werte; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + +/// Merkt sich nur, was hochgeladen werden sollte. Der echte Upload braucht +/// Dateien und einen Server — hier geht es um den Weg vom Knopf zum Dienst. +class _FakeSync extends SyncService { + _FakeSync(MeloDb db) + : super( + db: db, + cloud: MeloCloudService( + auth: BakaAuth(speicher: _MemorySpeicher({})), + client: MockClient( + (_) async => http.Response('{"status":"ok"}', 200)), + ), + ); + + final hochgeladen = []; + + @override + Future ladeAusgewaehlteHoch(List songs) async { + hochgeladen.addAll([for (final s in songs) s.id]); + return const UploadErgebnis(hochgeladen: 1); + } +} + +/// „Auf den Server laden" gehört in „Meine Musik" — und **nur** dorthin. +/// [SortableSongList] wird in fünf Ansichten benutzt; ohne Scoping erschiene +/// die Aktion auch bei Favoriten, Wiedergabelisten und Titellisten. +/// +/// Aufbau bewusst im Testkörper, nicht in `setUp` (siehe auswahl_modus_test). +void main() { + final lieder = [ + for (var i = 0; i < 3; i++) + Song( + id: 'song-$i', + path: '/music/$i.mp3', + title: 'Titel $i', + artist: 'Neoni', + dateAddedMs: i, + updatedAtMs: 0, + deleted: false, + playCount: 0, + categoriesEdited: false, + metadataEdited: false, + ), + ]; + + Future beruhige(WidgetTester tester) => tester.pumpAndSettle( + const Duration(milliseconds: 100), + EnginePhase.sendSemanticsUpdate, + const Duration(seconds: 5), + ); + + Future<_FakeSync> pumpe(WidgetTester tester, + {required bool serverUpload}) async { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + SharedPreferences.setMockInitialValues({}); + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + final sync = _FakeSync(db); + final einstellungen = AppSettings(); + await einstellungen.init(); + for (final song in lieder) { + await db.into(db.songs).insert(SongsCompanion.insert( + id: song.id, + path: song.path, + title: song.title, + artist: Value(song.artist), + dateAddedMs: song.dateAddedMs, + updatedAtMs: 0, + )); + } + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: db), + ChangeNotifierProvider.value( + value: CategoryService(db)), + ChangeNotifierProvider.value( + value: PlaylistService(db)), + ChangeNotifierProvider.value(value: einstellungen), + // Ohne diesen Provider stürbe schon das erste Antippen der neuen + // Aktion in ladeAufServer mit ProviderNotFoundException. + ChangeNotifierProvider.value(value: sync), + ], + child: MaterialApp( + home: Scaffold( + body: SortableSongList( + songs: lieder, + storeKey: SortStore.meineMusik, + serverUpload: serverUpload, + ), + ), + ), + ), + ); + await beruhige(tester); + await tester.longPress(find.text('Titel 0')); + await beruhige(tester); + return sync; + } + + testWidgets('in „Meine Musik" erscheint die Server-Aktion', (tester) async { + await pumpe(tester, serverUpload: true); + + expect(find.byTooltip('Auf den Server laden'), findsOneWidget); + }); + + testWidgets('in den übrigen Ansichten erscheint sie nicht', (tester) async { + await pumpe(tester, serverUpload: false); + + // Der Auswahl-Modus läuft, die beiden Bestands-Aktionen sind da … + expect(find.byTooltip('Zur Warteschlange hinzufügen'), findsOneWidget); + // … die neue nicht. + expect(find.byTooltip('Auf den Server laden'), findsNothing); + }); + + testWidgets('das Antippen reicht die Auswahl an den Upload weiter', + (tester) async { + final sync = await pumpe(tester, serverUpload: true); + + await tester.tap(find.byTooltip('Auf den Server laden')); + await beruhige(tester); + + // Ein sichtbarer Knopf ist noch keine Funktion: geprüft wird, dass genau + // der lang gedrückte Titel bei ladeAusgewaehlteHoch ankommt. + expect(sync.hochgeladen, ['song-0']); + expect(find.text('1 hochgeladen'), findsOneWidget); + }); +} +``` + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/shared/server_upload_aktion_test.dart` +Erwartet: FAIL mit +`No named parameter with the name 'serverUpload'`. + +- [ ] **Schritt 3: Minimale Implementierung** + +`lib/shared/auswahl_leiste.dart` — `AuswahlLeiste` erweitern: +```dart +class AuswahlLeiste extends StatelessWidget { + const AuswahlLeiste({ + super.key, + required this.anzahl, + required this.onAbbrechen, + required this.onWiedergabeliste, + required this.onWarteschlange, + this.onServerLaden, + }); + + final int anzahl; + final VoidCallback onAbbrechen; + final VoidCallback onWiedergabeliste; + final VoidCallback onWarteschlange; + + /// Nur gesetzt, wo der Upload hingehört („Meine Musik"). Sonst erschiene + /// die Aktion in allen fünf Ansichten, die diese Leiste benutzen. + final VoidCallback? onServerLaden; +``` +und im `Row`-`children`, hinter dem `Expanded(child: Text(...))` und vor dem +Warteschlangen-Knopf: +```dart + if (onServerLaden != null) + IconButton( + tooltip: 'Auf den Server laden', + icon: const Icon(Icons.cloud_upload), + onPressed: onServerLaden, + ), +``` + +Am Ende derselben Datei ergänzen: +```dart +/// Lädt [gewaehlte] zum Melo-Server und meldet das Ergebnis. +/// +/// Wohnt neben [fuegeZuWiedergabelisteHinzu]: dieselbe Bauart, dieselbe Art +/// Rückmeldung. +Future ladeAufServer( + BuildContext context, List gewaehlte) async { + final sync = context.read(); + final messenger = ScaffoldMessenger.of(context); + final ergebnis = await sync.ladeAusgewaehlteHoch(gewaehlte); + if (!context.mounted) return; + messenger.showSnackBar( + SnackBar(content: Text(sync.fehler ?? ergebnis.meldung)), + ); +} +``` +und dafür oben `import '../services/sync_service.dart';` ergänzen. + +`lib/shared/sortable_song_list.dart` — Konstruktor und Feld: +```dart + const SortableSongList({ + super.key, + required this.songs, + required this.storeKey, + this.empty, + this.serverUpload = false, + }); + + final List songs; + final String storeKey; + + /// Ob der Auswahl-Modus „Auf den Server laden" anbietet. Standard `false`: + /// dieses Widget steckt in fünf Ansichten, gemeint ist nur „Meine Musik". + final bool serverUpload; +``` +Neue Methode hinter `_inWarteschlange`: +```dart + Future _aufServer(List gewaehlte) async { + await ladeAufServer(context, gewaehlte); + if (mounted) _beendeAuswahl(); + } +``` +Und im `AuswahlLeiste`-Aufruf (heute `:194-199`): +```dart + AuswahlLeiste( + anzahl: _auswahl.length, + onAbbrechen: _beendeAuswahl, + onWiedergabeliste: () => _inWiedergabeliste(_gewaehlte(sorted)), + onWarteschlange: () => _inWarteschlange(_gewaehlte(sorted)), + onServerLaden: widget.serverUpload + ? () => _aufServer(_gewaehlte(sorted)) + : null, + ) +``` + +`lib/library/my_music_screen.dart:120-126` — den Aufruf ergänzen: +```dart + return SortableSongList( + songs: songs, + storeKey: SortStore.meineMusik, + serverUpload: true, + empty: + lib.scanning ? const SizedBox.shrink() : const _Empty(), + ); +``` + +- [ ] **Schritt 4: Test laufen lassen, Erfolg bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/shared/server_upload_aktion_test.dart test/shared/auswahl_modus_test.dart` +Erwartet: `All tests passed!` (beide Dateien). +Gegenprobe zum Scoping: `grep -rn "serverUpload" lib/` — **vier** Treffer: +drei in `sortable_song_list.dart` (Konstruktor-Parameter, Feld und der +`widget.serverUpload`-Aufruf im `AuswahlLeiste`-Block) und einer in +`my_music_screen.dart`. Kein Treffer in `favorites_screen.dart`, +`playlists_screen.dart`, `titel_listen_screen.dart`. +Danach `/home/dustin/development/flutter/bin/flutter analyze` → sauber. + +- [ ] **Schritt 5: Commit** +```bash +cd /home/dustin/mello-dev/app +git add lib/shared/auswahl_leiste.dart lib/shared/sortable_song_list.dart lib/library/my_music_screen.dart test/shared/server_upload_aktion_test.dart +git commit -m "Auswahl-Modus: Auf den Server laden (nur in Meine Musik)" +``` + +--- + +### Task 7: Einzel-Song-Offline + +> **BLOCKIERT bis Dustin A6 beantwortet hat** — nur, weil die Spec Stufe 2 +> (Feature 3 **und** 4) als Ganzes hinter die A6-Antwort stellt. +> **Ehrlicher Hinweis für Dustin:** Die inhaltliche Begründung der Spec +> („vergrößert die Angriffsfläche des Löschpfads, weil mehr Titel eine cloudId +> bekommen") trifft auf **Feature 4 nicht zu** — ein Download vergibt keine +> cloudId und meldet nichts zum Server. Dieser Task könnte also gefahrlos +> vorgezogen werden. Das zu entscheiden ist Dustins Sache, nicht die des +> Umsetzenden — bis dahin bleibt der Task blockiert. + +**Dateien:** +- Ändern: `lib/services/download_service.dart:70-111` (neue Methode + `ladeEinzelnenTitel` hinter `lade`) +- Ändern: `lib/downloads/server_titel_screen.dart:39-70` (zwei neue Methoden + im State), `:127-133` (Zeilen-Aufruf), `:227-305` (`_Zeile` bekommt die + Knöpfe) +- Test: `test/services/download_service_test.dart` (neue Gruppe), + `test/downloads/einzel_song_offline_test.dart` (neu) + +**Schnittstellen:** +- Nutzt: den bestehenden **öffentlichen** `DownloadService.lade(List)` + (Doppel-Lauf-Schutz `:73`, Verbindungsprüfung `:77-84`, Fortschritt + `:85-104`) und `DownloadService.entferne(navidromeId)`. + **Nicht** `_ladeEinen` direkt verdrahten. +- Liefert: `Future DownloadService.ladeEinzelnenTitel(SubsonicSong song)` + — `true`, wenn der Titel neu dazukam. + +**Bewusst nicht gebaut (dokumentierte Einschränkung, Spec erlaubt das +ausdrücklich):** Der Abgleich mit dem Abspiel-Zwischenspeicher entfällt. Ein +gerade gehörter Titel liegt danach doppelt (Cache + Download), bis die +LRU-Verdrängung die Cache-Kopie holt. Der Gegenschutz kostet einen +`CacheManager` samt `init()` in `DownloadService` — für drei Nutzer zu teuer. +Der Verzicht wird im Doc-Kommentar **und** im CHANGELOG genannt, nicht +verschwiegen. Ebenfalls bewusst: kein Platz-Check und keine 30er-Rückfrage — +der Knopf lädt genau einen Titel. Den Offline-Modus-Schalter beachtet er +nicht (ausdrückliche Nutzeraktion, wie beim Auswahl-Upload). + +**Bestehende Tests, die mitgeändert werden müssen:** keine. + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +An `test/services/download_service_test.dart` anfügen (und oben +`import 'package:drift/native.dart';`, `import 'package:melo/library/database.dart';`, +`import 'package:melo/services/navidrome_service.dart';` ergänzen): +```dart + group('ladeEinzelnenTitel', () { + test('ohne Serververbindung wird nichts geladen und der Grund steht da', + () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + final dienst = DownloadService(db: db, navidrome: NavidromeService()); + + final neu = await dienst.ladeEinzelnenTitel(const SubsonicSong( + id: 'nav-1', + titel: 'Nachtpuls', + kuenstler: 'Rotklang', + album: 'Nacht', + dauerSekunden: 200, + )); + + expect(neu, isFalse); + expect(dienst.fehler, contains('Musikserver')); + expect(await db.downloadIds(), isEmpty); + }); + }); +``` +> `SubsonicSong` (`lib/services/navidrome_service.dart:13-28`) verlangt genau +> zwei Argumente: `id` und `titel`. `kuenstler`, `album` und `dauerSekunden` +> haben Standardwerte, `coverId` ist optional. Die oben geschriebene Form ist +> also gültig; sie nennt die drei Standardfelder nur, damit die Zeile in der +> Oberfläche etwas anzuzeigen hat. + +`test/downloads/einzel_song_offline_test.dart` (neu): +```dart +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/downloads/server_titel_screen.dart'; +import 'package:melo/player/audio_handler.dart'; +import 'package:melo/services/download_service.dart'; +import 'package:melo/services/navidrome_service.dart'; + +/// Merkt sich nur, was verlangt wurde — echte Downloads brauchen einen +/// Server, und darum geht es hier nicht. +class _FakeDownloads extends DownloadService { + _FakeDownloads(MeloDb db) : super(db: db, navidrome: NavidromeService()); + + final geladen = []; + final entfernt = []; + + @override + bool get laeuft => false; + + @override + Future ladeEinzelnenTitel(SubsonicSong song) async { + geladen.add(song.id); + return true; + } + + @override + Future entferne(String navidromeId) async { + entfernt.add(navidromeId); + return true; + } +} + +void main() { + final titel = [ + const SubsonicSong( + id: 'nav-1', + titel: 'Nachtpuls', + kuenstler: 'Rotklang', + album: 'Nacht', + dauerSekunden: 200, + ), + ]; + + Future<_FakeDownloads> pumpe(WidgetTester tester, + {bool schonGeladen = false}) async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + if (schonGeladen) { + // Der Bildschirm liest den Zustand einmal per db.downloadIds(). + await db.merkeDownload(DownloadsCompanion.insert( + navidromeId: 'nav-1', + titel: 'Nachtpuls', + groesseBytes: 1, + geladenAmMs: 0, + )); + } + final dienst = _FakeDownloads(db); + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: db), + ChangeNotifierProvider.value(value: dienst), + Provider.value(value: MeloAudioHandler(db: db)), + ], + child: MaterialApp( + home: ServerTitelScreen( + titel: 'Nacht', + navidrome: NavidromeService(), + holeTitel: () async => titel, + ), + ), + ), + ); + await tester.pumpAndSettle( + const Duration(milliseconds: 100), + EnginePhase.sendSemanticsUpdate, + const Duration(seconds: 5), + ); + return dienst; + } + + testWidgets('ein einzelner Titel lässt sich offline nehmen', (tester) async { + final dienst = await pumpe(tester); + + await tester.tap(find.byTooltip('Offline nehmen')); + await tester.pumpAndSettle( + const Duration(milliseconds: 100), + EnginePhase.sendSemanticsUpdate, + const Duration(seconds: 5), + ); + + expect(dienst.geladen, ['nav-1']); + expect(dienst.entfernt, isEmpty); + }); + + testWidgets('ein schon geladener Titel bietet den Gegenweg an', + (tester) async { + final dienst = await pumpe(tester, schonGeladen: true); + + // Zustand „schon offline": statt „Offline nehmen" steht dort das + // Entfernen — ohne diesen Test wäre der halbe Knopf ungeprüft. + expect(find.byTooltip('Offline nehmen'), findsNothing); + + await tester.tap(find.byTooltip('Vom Gerät entfernen')); + await tester.pumpAndSettle( + const Duration(milliseconds: 100), + EnginePhase.sendSemanticsUpdate, + const Duration(seconds: 5), + ); + + expect(dienst.entfernt, ['nav-1']); + expect(dienst.geladen, isEmpty); + }); +} +``` +> `MeloAudioHandler(db: db)` ist genau die im Repo etablierte Form für solche +> Bildschirme — `test/library/my_music_screen_test.dart:23`, +> `test/home_shell_test.dart:40`, `test/library/my_music_tabs_test.dart:56` +> und `test/library/song_zeile_lauf_test.dart:90` konstruieren ihn alle so und +> reichen ihn als `Provider.value` durch; `_Zeile` liest ihn +> mit `context.read`. **Dieser Widget-Test wird gebaut, nicht abgewogen:** er +> ist der einzige Nachweis, dass Feature 4 an der Oberfläche ankommt. Hängt er +> wider Erwarten, gilt superpowers:systematic-debugging — nicht das Weglassen. + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/download_service_test.dart test/downloads/einzel_song_offline_test.dart` +Erwartet: FAIL mit +`The method 'ladeEinzelnenTitel' isn't defined for the class 'DownloadService'` +und `Could not find a widget with tooltip "Offline nehmen"`. + +- [ ] **Schritt 3: Minimale Implementierung** + +`lib/services/download_service.dart` — hinter `lade` (`:111`) einfügen: +```dart + /// Nimmt einen einzelnen Server-Titel offline. + /// + /// Bewusst über [lade]: das bringt Doppel-Lauf-Schutz, Verbindungsprüfung + /// und Fortschritts-Buchführung mit. Gibt zurück, ob der Titel neu + /// dazugekommen ist. + /// + /// **Bekannte Einschränkung:** Liegt der Titel schon im Abspiel-Zwischen- + /// speicher (weil er gerade gehört wurde), wird er trotzdem neu geladen — + /// bis die Verdrängung greift, belegt er doppelten Platz. Der Gegenschutz + /// kostete einen eigenen Cache-Zugang in diesem Dienst; für drei Nutzer ist + /// das der schlechtere Tausch. + Future ladeEinzelnenTitel(SubsonicSong song) async => + await lade([song]) > 0; +``` + +`lib/downloads/server_titel_screen.dart` — im State (`_ServerTitelScreenState`) +hinter `_laden()` einfügen: +```dart + /// Nach Laden oder Entfernen ist der Einmal-Schnappschuss [_geladen] veraltet + /// — hier wird er nachgezogen. + Future _aktualisiereGeladen() async { + final geladen = await context.read().downloadIds(); + if (mounted) setState(() => _geladen = geladen); + } + + Future _offlineNehmen(SubsonicSong song) async { + final dienst = context.read(); + final messenger = ScaffoldMessenger.of(context); + if (dienst.laeuft) { + messenger.showSnackBar( + const SnackBar(content: Text('Es läuft schon ein Download')), + ); + return; + } + final neu = await dienst.ladeEinzelnenTitel(song); + await _aktualisiereGeladen(); + if (!mounted) return; + messenger.showSnackBar(SnackBar( + content: Text(neu + ? 'Offline: ${song.titel}' + : (dienst.fehler ?? 'War schon heruntergeladen')), + )); + } + + Future _offlineEntfernen(SubsonicSong song) async { + final dienst = context.read(); + final messenger = ScaffoldMessenger.of(context); + final weg = await dienst.entferne(song.id); + await _aktualisiereGeladen(); + if (!mounted) return; + messenger.showSnackBar(SnackBar( + content: Text(weg + ? 'Vom Gerät entfernt: ${song.titel}' + : (dienst.fehler ?? 'Ließ sich nicht entfernen')), + )); + } +``` +Import ergänzen: `import '../services/download_service.dart';` + +Im `ListView.builder` (heute `:128-133`) die Zeile erweitern: +```dart + return _Zeile( + song: song, + nummer: i, + geladen: _geladen.contains(song.id), + onTap: () => _spiele(songs, i - 1), + onOffline: () => _offlineNehmen(song), + onEntfernen: () => _offlineEntfernen(song), + ); +``` + +`_Zeile` — Konstruktor und Felder erweitern: +```dart + const _Zeile({ + required this.song, + required this.nummer, + required this.geladen, + required this.onTap, + required this.onOffline, + required this.onEntfernen, + }); + + final SubsonicSong song; + final int nummer; + final bool geladen; + final VoidCallback onTap; + final VoidCallback onOffline; + final VoidCallback onEntfernen; +``` +und das `trailing` (heute `:286-298`) ersetzen: +```dart + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Bisher gab es den Lade-Knopf nur je Album — einen einzelnen + // Titel mitzunehmen ging gar nicht. + geladen + ? IconButton( + tooltip: 'Vom Gerät entfernen', + icon: const Icon(Icons.download_done, + size: 20, color: MeloTheme.red), + onPressed: onEntfernen, + ) + : IconButton( + tooltip: 'Offline nehmen', + icon: const Icon(Icons.download_outlined, + size: 20, color: MeloTheme.text3), + onPressed: onOffline, + ), + Text(_dauer(song.dauerSekunden), + style: const TextStyle(color: MeloTheme.text3)), + ], + ), +``` + +- [ ] **Schritt 4: Test laufen lassen, Erfolg bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/download_service_test.dart test/downloads/einzel_song_offline_test.dart` +Erwartet: `All tests passed!` +Danach `/home/dustin/development/flutter/bin/flutter analyze` → sauber. + +- [ ] **Schritt 5: Commit** +```bash +cd /home/dustin/mello-dev/app +git add lib/services/download_service.dart lib/downloads/server_titel_screen.dart test/services/download_service_test.dart test/downloads/einzel_song_offline_test.dart +git commit -m "Einzelne Server-Titel offline nehmen und wieder entfernen" +``` + +--- + +### Task 8: Erfolgs-Flag, Zeitstempel-Snapshot und Bericht-Zähler + +**Dateien:** +- Ändern: `lib/services/sync_service.dart:134-155` (neue Konstante + `_letzterErfolgKey`, Felder `_letzterErfolg`, `_bericht`) +- Ändern: `lib/services/sync_service.dart:157-162` (`laden()` lädt den zweiten + Zeitstempel mit) +- Ändern: `lib/services/sync_service.dart:188-228` (`synchronisiere()`: + Snapshot vor dem Listen, Phasen-Isolation, Erfolgs-Flag, Bericht) +- Ändern: `lib/services/sync_service.dart:239-251` (`_meldeLoeschungen` meldet + zurück, ob alle Löschungen durchgingen) +- Ändern: `lib/services/sync_service.dart:253-297` (`_ladeHerunter` zählt neue + Titel) +- Ändern: `lib/services/sync_service.dart:299-320` (`_ladeHoch` meldet zurück, + ob alle Uploads durchgingen — der `TimeoutException`-Zweig selbst steht + bereits seit Task 3) +- Test: `test/services/sync_service_test.dart` (neue Gruppe) + +`_ziehLoeschungenNach` (`:231-237`) bleibt **unverändert** und steht deshalb +nicht in der Liste: es schluckt keinen Fehler, und die Zahl der lokal +getombsteten Titel steht in `synchronisiere()` als `plan.lokalLoeschen.length` +ohnehin fest. + +**Schnittstellen:** +- Nutzt aus Task 1: `berichtFaellig(DateTime?, DateTime)`. +- Nutzt aus Task 3: `Future _gleicheFavoritenAb()`. +- Liefert: + - `class SyncBericht { final int neueSongs; final int geloeschte; final int favoriten; bool get istLeer; }` + - `SyncBericht? SyncService.bericht` + - `void SyncService.berichtGesehen()` + - `Future SyncService._meldeLoeschungen(List songs)` + - `Future SyncService._ladeHoch(List songs)` + - `Future SyncService._ladeHerunter(List songs)` + +> **Was „vollständig" absichtlich nicht heißt:** Eine Datei, die der Server +> dauerhaft ablehnt (>50 MB), lässt das Flag in **jedem** Lauf fallen — dann +> erscheint der 24-h-Bericht auf diesem Gerät nie. Das ist die Bedeutung, die +> die Spec dem Wort gibt („alle Phasen ohne geschluckten Fehler"), und der +> Preis dafür, dass der Erfolgs-Zeitstempel etwas wert ist. Eine Sonderregel +> dagegen wird hier **nicht** gebaut. Der Fall einer fehlenden Datei in +> `_ladeHoch` zählt dagegen nicht als Fehlschlag: dort ist nichts schiefge- +> gangen, der Titel ist schlicht weg und der nächste Scan tombstoned ihn. + +**Bestehende Tests, die mitgeändert werden müssen:** keine — alle acht Tests in +`sync_service_test.dart` prüfen weder `letzterLauf` noch `bericht`. Sie müssen +grün bleiben. + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +An `test/services/sync_service_test.dart` anfügen: +```dart + group('Sync-Bericht', () { + test('beim allerersten Lauf gibt es keinen Bericht', () async { + final sync = await baue((anfrage) async { + if (anfrage.url.path.endsWith('/list')) { + return http.Response(jsonEncode({'songs': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + // letzterErfolg war null: „Willkommen zurück!" auf einem frisch + // eingerichteten Gerät wäre Unsinn. + expect(sync.bericht, isNull); + }); + + test('nach mehr als 24 Stunden kommt der Bericht mit Zählern', () async { + final vorgestern = DateTime.now().subtract(const Duration(days: 2)); + SharedPreferences.setMockInitialValues({ + 'cloud_sync_letzter_erfolg': vorgestern.millisecondsSinceEpoch, + }); + + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response( + jsonEncode({ + 'songs': [ + {'id': 'c1', 'title': 'Neu', 'artist': 'X', 'duration': 100} + ] + }), + 200, + ); + } + if (pfad.contains('/download/')) { + return http.Response.bytes([1], 200, + headers: {'content-type': 'audio/mpeg'}); + } + if (pfad.endsWith('/favorites')) { + return http.Response(jsonEncode({'favorites': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + await sync.laden(); + + await sync.synchronisiere(); + + expect(sync.bericht, isNotNull); + expect(sync.bericht!.neueSongs, 1); + sync.berichtGesehen(); + expect(sync.bericht, isNull); + }); + + test('eine ausgefallene Phase verschiebt den Erfolgs-Zeitstempel nicht', + () async { + SharedPreferences.setMockInitialValues({}); + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response(jsonEncode({'songs': []}), 200); + } + if (pfad.endsWith('/favorites')) { + // Fehler im 200er-Körper: die Favoriten-Phase fällt aus. + return http.Response( + jsonEncode({'status': 'error', 'error': 'kaputt'}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getInt('cloud_sync_letzter_erfolg'), isNull); + // Die Drossel läuft trotzdem weiter — sonst rennt der Sync bei jedem + // Tab-Wechsel neu los. + expect(sync.letzterLauf, isNotNull); + }); + + test('eine gescheiterte Löschmeldung verschiebt den Erfolgs-Zeitstempel ' + 'nicht', () async { + SharedPreferences.setMockInitialValues({}); + await db.upsertSongs([ + SongsCompanion.insert( + id: 'lokal-1', + path: '${tempDir.path}/weg.mp3', + title: 'Weg', + dateAddedMs: 0, + updatedAtMs: 0, + deleted: const Value(true), + ), + ]); + await db.setCloudId('lokal-1', 'c5'); + + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response( + jsonEncode({ + 'songs': [ + {'id': 'c5', 'title': 'Weg'} + ] + }), + 200, + ); + } + if (pfad.endsWith('/delete')) { + // _meldeLoeschungen schluckt die CloudException — ohne Rückgabe + // bis zum Flag hätte der Lauf trotzdem als erfolgreich gegolten. + return http.Response(jsonEncode({'error': 'kaputt'}), 500); + } + if (pfad.endsWith('/favorites')) { + return http.Response(jsonEncode({'favorites': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getInt('cloud_sync_letzter_erfolg'), isNull); + expect(sync.letzterLauf, isNotNull); + }); + + test('ein gescheiterter Upload verschiebt den Erfolgs-Zeitstempel nicht', + () async { + SharedPreferences.setMockInitialValues({}); + final datei = File('${tempDir.path}/zu-gross.mp3'); + await datei.writeAsBytes([1]); + await db.upsertSongs([ + SongsCompanion.insert( + id: 'lokal-1', + path: datei.path, + title: 'Zu groß', + dateAddedMs: 0, + updatedAtMs: 0, + ), + ]); + + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response(jsonEncode({'songs': []}), 200); + } + if (pfad.endsWith('/upload')) { + return http.Response( + jsonEncode({'error': 'Datei zu groß (max 50 MB)'}), 200); + } + if (pfad.endsWith('/favorites')) { + return http.Response(jsonEncode({'favorites': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getInt('cloud_sync_letzter_erfolg'), isNull); + }); + + test('der Zeitstempel ist der Stand VOR dem Listen', () async { + final sync = await baue((anfrage) async { + if (anfrage.url.path.endsWith('/list')) { + // Während des Laufs vergeht Zeit — der Zeitstempel darf nicht + // danach genommen werden, sonst fallen zwischenzeitliche + // Änderungen durchs Raster (Tombstone-Race, v2-Lektion). + await Future.delayed(const Duration(milliseconds: 50)); + return http.Response(jsonEncode({'songs': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + final vorher = DateTime.now(); + await sync.synchronisiere(); + final nachher = DateTime.now(); + + expect(sync.letzterLauf!.isBefore(nachher), isTrue); + expect( + sync.letzterLauf! + .isAfter(vorher.subtract(const Duration(milliseconds: 1))), + isTrue, + ); + expect( + nachher.difference(sync.letzterLauf!) >= + const Duration(milliseconds: 50), + isTrue, + ); + }); + }); +``` + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/sync_service_test.dart` +Erwartet: FAIL mit +`The getter 'bericht' isn't defined for the class 'SyncService'`. + +- [ ] **Schritt 3: Minimale Implementierung** + +`lib/services/sync_service.dart` — vor der Klasse einfügen: +```dart +/// Was seit dem letzten erfolgreichen Abgleich passiert ist — der Inhalt des +/// „Willkommen zurück"-Dialogs. +class SyncBericht { + const SyncBericht({ + required this.neueSongs, + required this.geloeschte, + required this.favoriten, + }); + + final int neueSongs; + final int geloeschte; + final int favoriten; + + bool get istLeer => neueSongs == 0 && geloeschte == 0 && favoriten == 0; +} +``` + +Konstanten und Felder ergänzen (bei `_letzterLaufKey`, heute `:134`): +```dart + static const _letzterErfolgKey = 'cloud_sync_letzter_erfolg'; +``` +und bei den Feldern (heute `:148`): +```dart + DateTime? _letzterErfolg; + SyncBericht? _bericht; + + /// Der fällige Bericht, oder `null`. Wird von der Oberfläche genau einmal + /// abgeholt und dann mit [berichtGesehen] quittiert. + SyncBericht? get bericht => _bericht; + + void berichtGesehen() { + _bericht = null; + notifyListeners(); + } +``` + +`laden()` erweitern: +```dart + Future laden() async { + final prefs = await SharedPreferences.getInstance(); + final ms = prefs.getInt(_letzterLaufKey); + if (ms != null) _letzterLauf = DateTime.fromMillisecondsSinceEpoch(ms); + final erfolg = prefs.getInt(_letzterErfolgKey); + if (erfolg != null) { + _letzterErfolg = DateTime.fromMillisecondsSinceEpoch(erfolg); + } + notifyListeners(); + } +``` + +`synchronisiere()` — den `try`-Block (heute `:188-218`) ersetzen: +```dart + // Der Zeitstempel ist der Stand VOR dem Listen: was während des Laufs am + // Server passiert, muss beim nächsten Mal noch drankommen (v2-Lektion, + // Tombstone-Race). + final laufBeginn = DateTime.now(); + var vollstaendig = true; + var neueSongs = 0; + var geloeschte = 0; + + try { + final amServer = await cloud.liste(); + final plan = planeSync( + lokal: await db.allSongs(), + server: amServer, + ); + _gesamt = plan.gesamt; + notifyListeners(); + + final bestand = amServer.where((s) => !s.geloescht).length; + final bremse = loeschBremseGreift( + zuLoeschen: plan.serverLoeschen.length, + bestand: bestand, + ); + if (bremse) { + _fehler = 'Sicherheitsbremse: ${plan.serverLoeschen.length} von ' + '$bestand Titeln würden am Server gelöscht. Das sieht nach einem ' + 'Fehler aus (z. B. Speicherkarte nicht eingehängt) — es wurde ' + 'nichts gelöscht.'; + vollstaendig = false; + } + + // Jede Phase für sich: fällt eine aus, laufen die übrigen weiter, und + // der Erfolgs-Zeitstempel bleibt stehen. + geloeschte = plan.lokalLoeschen.length; + await _ziehLoeschungenNach(plan.lokalLoeschen); + if (!bremse && !await _meldeLoeschungen(plan.serverLoeschen)) { + vollstaendig = false; + } + neueSongs = await _ladeHerunter(plan.herunterladen); + if (!await _ladeHoch(plan.hochladen)) vollstaendig = false; + if (!await _gleicheFavoritenAb()) vollstaendig = false; + await _meldeVerlauf(); + + _letzterLauf = laufBeginn; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_letzterLaufKey, laufBeginn.millisecondsSinceEpoch); + + if (vollstaendig) { + // „Erfolgreich" heißt: keine Phase hat einen Fehler geschluckt. + if (berichtFaellig(_letzterErfolg, laufBeginn)) { + _bericht = SyncBericht( + neueSongs: neueSongs, + geloeschte: geloeschte, + favoriten: 0, + ); + } + _letzterErfolg = laufBeginn; + await prefs.setInt( + _letzterErfolgKey, laufBeginn.millisecondsSinceEpoch); + } + } on CloudException catch (e) { +``` +(der `on CloudException` / `catch` / `finally`-Rest bleibt unverändert) + +`_meldeLoeschungen` (heute `:239-251`) vollständig ersetzen: +```dart + /// Meldet die hier getombsteten Titel am Server. + /// + /// Gibt `false` zurück, sobald eine Meldung geschluckt wurde: der Lauf geht + /// weiter, gilt aber nicht mehr als erfolgreich — sonst rückte der + /// 24-h-Zeitstempel des Berichts vor, obwohl eine Phase ausgefallen ist. + Future _meldeLoeschungen(List songs) async { + var vollstaendig = true; + for (final song in songs) { + _melde('Melde Löschung von „${song.title}" …'); + try { + await cloud.loeschen(song.cloudId!); + } on CloudException catch (e) { + // Eine abgelehnte Löschung darf den Lauf nicht beenden. + debugPrint('Löschung „${song.title}" übersprungen: ${e.message}'); + vollstaendig = false; + } + _erledigt++; + notifyListeners(); + } + return vollstaendig; + } +``` + +`_ladeHerunter` (heute `:253-297`) vollständig ersetzen — Rückgabetyp +`Future`, sonst unverändert: +```dart + /// Gibt zurück, wie viele Titel wirklich neu dazugekommen sind — die Zahl + /// im „Was ist neu"-Bericht. + Future _ladeHerunter(List songs) async { + if (songs.isEmpty) return 0; + var neu = 0; + final ordner = await _musikOrdner(); + for (final cloudSong in songs) { + _melde('Lade „${cloudSong.titel}" …'); + // Die Endung bestimmt der Server anhand des echten Dateityps — die + // Bibliothek enthält nicht nur MP3. + final datei = await cloud.herunterladen( + cloudSong.id, + ordner, + '${_sichererDateiname(cloudSong.titel)}-${cloudSong.id}', + ); + if (datei == null) { + _erledigt++; + continue; + } + + // In den öffentlichen Musikordner eintragen: sonst kennt der + // MediaStore die Datei nicht und der nächste Scan tombstoned sie. + final pfad = await mediaStore.veroeffentliche( + quellPfad: datei.path, + titel: cloudSong.titel, + kuenstler: cloudSong.kuenstler, + ) ?? + datei.path; + + final now = DateTime.now().millisecondsSinceEpoch; + await db.upsertSongs([ + SongsCompanion.insert( + id: _uuid.v4(), + path: pfad, + title: cloudSong.titel, + artist: Value(cloudSong.kuenstler.isEmpty ? null : cloudSong.kuenstler), + durationMs: Value(cloudSong.dauerSekunden > 0 + ? cloudSong.dauerSekunden * 1000 + : null), + dateAddedMs: now, + updatedAtMs: now, + cloudId: Value(cloudSong.id), + ), + ]); + neu++; + _erledigt++; + notifyListeners(); + } + return neu; + } +``` + +`_ladeHoch` (heute `:299-320`, inklusive des `TimeoutException`-Zweigs aus +Task 3) vollständig ersetzen: +```dart + /// Lädt alle Titel ohne cloudId hoch. + /// + /// Gibt `false` zurück, sobald ein Upload an einem Fehler oder einer + /// Zeitüberschreitung hängenblieb. Eine **fehlende Datei** zählt bewusst + /// nicht dazu: dort ist nichts schiefgegangen, der Titel ist weg und der + /// nächste Scan tombstoned ihn. + Future _ladeHoch(List songs) async { + var vollstaendig = true; + for (final song in songs) { + final datei = File(song.path); + if (!await datei.exists()) { + _erledigt++; + continue; + } + _melde('Sende „${song.title}" …'); + try { + final cloudId = await cloud.hochladen( + datei, + dateiname: '${_sichererDateiname(song.title)}${p.extension(song.path)}', + ); + if (cloudId != null) { + await db.setCloudId(song.id, cloudId); + } else { + // 200 ohne Server-ID: der Titel ist oben nicht angekommen. + vollstaendig = false; + } + } on CloudException catch (e) { + // Eine zu große oder abgelehnte Datei darf den Lauf nicht beenden. + debugPrint('Upload „${song.title}" übersprungen: ${e.message}'); + vollstaendig = false; + } on TimeoutException { + // Der 120-s-Timeout (melo_cloud_service.dart:180) wirft + // TimeoutException, nicht CloudException. + debugPrint('Upload „${song.title}": Zeitüberschreitung'); + vollstaendig = false; + } + _erledigt++; + notifyListeners(); + } + return vollstaendig; + } +``` + +Der Zähler `favoriten` bleibt in dieser Stufe bewusst `0`: die Zahl der +geänderten Favoriten wäre nur mit einem zusätzlichen Rückgabewert aus der +Phase zu haben, und der Bericht ist auch ohne sie vollständig lesbar. Wird +Task 12 gebaut, kommt dort ein Playlisten-Zähler dazu. + +- [ ] **Schritt 4: Test laufen lassen, Erfolg bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/sync_service_test.dart` +Erwartet: `All tests passed!` +Danach `/home/dustin/development/flutter/bin/flutter analyze` → sauber. + +- [ ] **Schritt 5: Commit** +```bash +cd /home/dustin/mello-dev/app +git add lib/services/sync_service.dart test/services/sync_service_test.dart +git commit -m "Erfolgs-Flag, Zeitstempel-Snapshot und Zaehler fuer den Sync-Bericht" +``` + +--- + +### Task 9: „Willkommen zurück"-Dialog + +**Dateien:** +- Erstellen: `lib/shared/sync_bericht_dialog.dart` +- Ändern: `lib/main.dart:163-178` (`initState`) und `:198-208` + (`didChangeAppLifecycleState`) +- Test: `test/shared/sync_bericht_dialog_test.dart` (neu) + +**Schnittstellen:** +- Nutzt aus Task 8: `SyncBericht`, `SyncService.bericht`, + `SyncService.berichtGesehen()`. +- Liefert: `class SyncBerichtDialog extends StatelessWidget` mit + `const SyncBerichtDialog({super.key, required SyncBericht bericht})`. + +**Bestehende Tests, die mitgeändert werden müssen:** keine. +`test/home_shell_test.dart` und `test/hauptmenue_test.dart` berühren +`main.dart` nicht direkt — sie müssen grün bleiben. + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +`test/shared/sync_bericht_dialog_test.dart` (neu): +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/services/sync_service.dart'; +import 'package:melo/shared/sync_bericht_dialog.dart'; + +void main() { + Future zeige(WidgetTester tester, SyncBericht bericht) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: SyncBerichtDialog(bericht: bericht)), + )); + await tester.pump(); + } + + testWidgets('nennt neue und entfernte Titel', (tester) async { + await zeige( + tester, + const SyncBericht(neueSongs: 3, geloeschte: 1, favoriten: 0), + ); + + expect(find.text('Willkommen zurück!'), findsOneWidget); + expect(find.textContaining('3 neue Titel'), findsOneWidget); + expect(find.textContaining('1 entfernt'), findsOneWidget); + }); + + testWidgets('ohne Änderungen sagt er das auch', (tester) async { + await zeige( + tester, + const SyncBericht(neueSongs: 0, geloeschte: 0, favoriten: 0), + ); + + expect(find.textContaining('Nichts Neues'), findsOneWidget); + }); +} +``` + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/shared/sync_bericht_dialog_test.dart` +Erwartet: FAIL mit +`Error: Error when reading 'lib/shared/sync_bericht_dialog.dart': No such file or directory` + +- [ ] **Schritt 3: Minimale Implementierung** + +`lib/shared/sync_bericht_dialog.dart` (neu): +```dart +import 'package:flutter/material.dart'; + +import '../services/sync_service.dart'; + +/// „Willkommen zurück" — was sich seit dem letzten erfolgreichen Abgleich +/// getan hat. Rein in-App, ohne Benachrichtigungs-Kanal und ohne neue +/// Abhängigkeit. +class SyncBerichtDialog extends StatelessWidget { + const SyncBerichtDialog({super.key, required this.bericht}); + + final SyncBericht bericht; + + static String textFuer(SyncBericht b) { + if (b.istLeer) return 'Nichts Neues seit dem letzten Abgleich.'; + final teile = []; + if (b.neueSongs > 0) teile.add('${b.neueSongs} neue Titel'); + if (b.geloeschte > 0) teile.add('${b.geloeschte} entfernt'); + if (b.favoriten > 0) teile.add('${b.favoriten} Favoriten geändert'); + return '${teile.join(' · ')}.'; + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Willkommen zurück!'), + content: Text(textFuer(bericht)), + actions: [ + FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Alles klar'), + ), + ], + ); + } +} +``` + +`lib/main.dart` — Import ergänzen: +```dart +import 'shared/sync_bericht_dialog.dart'; +``` +In `_MeloHomeState` die beiden bestehenden Aufrufe +`context.read().automatisch();` (heute `:175` und `:203`) durch +`unawaited(_gleicheAbUndZeigeBericht());` ersetzen und die Methode ergänzen: +```dart + /// Gleicht ab und zeigt danach höchstens einmal den „Was ist neu"-Bericht. + Future _gleicheAbUndZeigeBericht() async { + final sync = context.read(); + await sync.automatisch(); + if (!mounted) return; + final bericht = sync.bericht; + if (bericht == null) return; + // Zuerst quittieren: ein zweites Zurückkehren in die App soll denselben + // Bericht nicht erneut zeigen. + sync.berichtGesehen(); + await showDialog( + context: context, + builder: (_) => SyncBerichtDialog(bericht: bericht), + ); + } +``` + +- [ ] **Schritt 4: Test laufen lassen, Erfolg bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/shared/sync_bericht_dialog_test.dart test/home_shell_test.dart test/hauptmenue_test.dart` +Erwartet: `All tests passed!` +Danach `/home/dustin/development/flutter/bin/flutter analyze` → sauber. + +- [ ] **Schritt 5: Commit** +```bash +cd /home/dustin/mello-dev/app +git add lib/shared/sync_bericht_dialog.dart lib/main.dart test/shared/sync_bericht_dialog_test.dart +git commit -m "Was-ist-neu-Bericht als In-App-Dialog nach dem Abgleich" +``` + +--- + +### Task 10: Drift-Migration `Playlists.cloudId` (Schema 10 → 11) + +> **BLOCKIERT — zwei Bedingungen, beide von Dustin zu klären:** +> 1. **Server-Fix `melo_cloud.py:505`** (`handle_playlist_delete` löscht +> `user_playlist_songs` **ohne** `user`-Bedingung — echte +> Fremddaten-Löschung). Die Spec führt ihn als blockierende Vorbedingung +> für Feature 2. +> **Ehrlicher Einwand:** Die Änderungsliste der Spec für Stufe 1 nennt nur +> *anlegen / Song hinzufügen / Song entfernen / Reihenfolge* — **kein +> Löschen einer Playlist.** Ruft die App `DELETE /playlists` nie auf, greift +> der Bug nie. Das ändert nichts daran, dass der Server-Fix richtig ist, +> aber es könnte die Blockade auflösen. Vor Beginn klären. +> 2. **Ob Feature 2 überhaupt gebaut wird.** Die Spec selbst nennt es +> „vertretbar und heute kostenlos, Feature 2 ganz herauszuschneiden" +> (0 Playlisten auf dem Server). Tasks 10–12 kosten spürbar Arbeit für +> einen Nutzen, den heute niemand hat. **Empfehlung: streichen oder +> vertagen, bis eine echte Playlist existiert.** + +**Dateien:** +- Ändern: `lib/library/database.dart:66-77` (Tabelle `Playlists`) +- Ändern: `lib/library/database.dart:151` (`schemaVersion => 11`) +- Ändern: `lib/library/database.dart:154-192` (`onUpgrade`-Zweig `from < 11`) +- Ändern: `lib/library/database.dart` (neue Methoden hinter `deletePlaylist`) +- Test: `test/library/playlist_cloud_id_test.dart` (neu) + +**Schnittstellen:** +- Nutzt: nichts aus früheren Tasks. +- Liefert: + - Spalte `Playlists.cloudId` (`TEXT NULL`) + - `Future MeloDb.setPlaylistCloudId(String id, String cloudId)` + - `Future MeloDb.playlistById(String id)` + - `Future MeloDb.countPlaylists()` (zählt inkl. Grabsteine — die + Wiederherstellung darf auch nach einer gelöschten Playlist nicht greifen) + +**Bestehende Tests, die mitgeändert werden müssen:** keine. +`test/library/database_playlists_test.dart` legt Playlisten ohne `cloudId` an — +die Spalte ist nullable, der Test muss unverändert grün bleiben. +Nach `flutter analyze` ist zusätzlich +`/home/dustin/development/flutter/bin/dart run build_runner build --delete-conflicting-outputs` +nötig, damit `database.g.dart` die neue Spalte kennt. + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +`test/library/playlist_cloud_id_test.dart` (neu): +```dart +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/database.dart'; + +void main() { + test('Bestandsdaten überleben die neue Spalte', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + + // Den Stand von Schema 10 nachbauen: Tabelle ohne cloud_id, mit Daten. + await db.customStatement('DROP TABLE playlist_songs'); + await db.customStatement('DROP TABLE playlists'); + await db.customStatement( + 'CREATE TABLE playlists (' + 'id TEXT NOT NULL, ' + 'name TEXT NOT NULL, ' + 'description TEXT NULL, ' + 'created_at_ms INTEGER NOT NULL, ' + 'updated_at_ms INTEGER NOT NULL, ' + 'deleted INTEGER NOT NULL DEFAULT 0, ' + 'PRIMARY KEY (id))', + ); + await db.customStatement( + "INSERT INTO playlists (id, name, created_at_ms, updated_at_ms) " + "VALUES ('alt-1', 'Road Trip', 0, 0)", + ); + + await Migrator(db).addColumn(db.playlists, db.playlists.cloudId); + + final rows = await db.select(db.playlists).get(); + expect(rows.single.name, 'Road Trip'); + expect(rows.single.cloudId, isNull); + }); + + test('setPlaylistCloudId merkt sich die Server-ID', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + + final id = await db.createPlaylist('Mix'); + await db.setPlaylistCloudId(id, '42'); + + expect((await db.playlistById(id))!.cloudId, '42'); + }); + + test('countPlaylists zählt auch Grabsteine', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + + final id = await db.createPlaylist('Mix'); + await db.deletePlaylist(id); + + // Sonst hielte die Wiederherstellung ein Gerät, auf dem der Nutzer alle + // Playlisten gelöscht hat, für eine Neuinstallation. + expect(await db.countPlaylists(), 1); + }); +} +``` + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/library/playlist_cloud_id_test.dart` +Erwartet: FAIL mit +`The getter 'cloudId' isn't defined for the class '$PlaylistsTable'` +und `The method 'setPlaylistCloudId' isn't defined for the class 'MeloDb'`. + +- [ ] **Schritt 3: Minimale Implementierung** + +`lib/library/database.dart` — Tabelle `Playlists` erweitern: +```dart +/// Nutzer-Playlisten. +class Playlists extends Table { + TextColumn get id => text()(); // uuid + TextColumn get name => text()(); + TextColumn get description => text().nullable()(); + IntColumn get createdAtMs => integer()(); + IntColumn get updatedAtMs => integer()(); + BoolColumn get deleted => boolean().withDefault(const Constant(false))(); + + /// ID derselben Playlist am Melo-Server, sobald sie einmal gesichert + /// wurde. `null` heißt: nur auf diesem Gerät. + TextColumn get cloudId => text().nullable()(); + + @override + Set get primaryKey => {id}; +} +``` + +Schema-Version und Migration: +```dart + @override + int get schemaVersion => 11; +``` +und im `onUpgrade`, hinter dem `from < 10`-Zweig: +```dart + if (from < 11) { + await m.addColumn(playlists, playlists.cloudId); + } +``` + +Hinter `deletePlaylist` (heute `:278-285`) einfügen: +```dart + Future playlistById(String id) => + (select(playlists)..where((p) => p.id.equals(id))).getSingleOrNull(); + + Future setPlaylistCloudId(String id, String cloudId) async { + await (update(playlists)..where((p) => p.id.equals(id))) + .write(PlaylistsCompanion(cloudId: Value(cloudId))); + } + + /// Wie viele Playlisten es hier gibt — **inklusive Grabsteinen**. + /// + /// Grundlage der Wiederherstellung: nur eine wirklich leere Tabelle gilt + /// als Neuinstallation. Wer alle Playlisten selbst gelöscht hat, soll sie + /// nicht vom Server zurückbekommen. + Future countPlaylists() async { + final zaehler = playlists.id.count(); + final zeile = await (selectOnly(playlists)..addColumns([zaehler])) + .getSingle(); + return zeile.read(zaehler) ?? 0; + } +``` + +Danach Code-Generierung: +```bash +cd /home/dustin/mello-dev/app +/home/dustin/development/flutter/bin/dart run build_runner build --delete-conflicting-outputs +``` + +- [ ] **Schritt 4: Test laufen lassen, Erfolg bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/library/playlist_cloud_id_test.dart test/library/database_playlists_test.dart` +Erwartet: `All tests passed!` +Danach `/home/dustin/development/flutter/bin/flutter analyze` → sauber. + +- [ ] **Schritt 5: Commit** +```bash +cd /home/dustin/mello-dev/app +git add lib/library/database.dart lib/library/database.g.dart test/library/playlist_cloud_id_test.dart +git commit -m "Schema 11: Playlists.cloudId fuer die einseitige Sicherung" +``` + +--- + +### Task 11: Playlisten-Endpunkte im `MeloCloudService` + +> **BLOCKIERT — dieselben zwei Bedingungen wie Task 10.** + +**Dateien:** +- Ändern: `lib/services/melo_cloud_service.dart` (neuer Block hinter + `setzeFavorit`) +- Test: `test/services/melo_cloud_playlists_test.dart` (neu) + +**Schnittstellen:** +- Nutzt aus Task 2: `_kopf`, `_pruefeAnmeldung`, `_pruefeStatus`, `basisUrl`. +- Liefert: + - `class CloudPlaylist { final String id; final String name; }` + - `Future MeloCloudService.legePlaylistAn(String name)` → Server-ID + - `Future MeloCloudService.fuegePlaylistSongsHinzu(String playlistCloudId, List songCloudIds)` + - `Future MeloCloudService.entfernePlaylistSong(String playlistCloudId, String songCloudId)` + - `Future MeloCloudService.setzePlaylistReihenfolge(String playlistCloudId, List songCloudIds)` + - `Future> MeloCloudService.playlisten()` + - `Future> MeloCloudService.playlistSongs(String playlistCloudId)` +- **Kein `DELETE /playlists`.** Playlist-Löschungen propagieren in Stufe 1 + nicht — damit wird der ungeflickte Server-Endpunkt nie berührt. + +**Bestehende Tests, die mitgeändert werden müssen:** keine. + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +`test/services/melo_cloud_playlists_test.dart` (neu): +```dart +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:melo/services/baka_auth.dart'; +import 'package:melo/services/melo_cloud_service.dart'; + +class _MemorySpeicher implements TokenSpeicher { + _MemorySpeicher(this.werte); + final Map werte; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + +Future baue( + Future Function(http.Request) antwort) async { + final auth = BakaAuth( + speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}), + ); + await auth.laden(); + return MeloCloudService(auth: auth, client: MockClient(antwort)); +} + +void main() { + test('legePlaylistAn liefert die Server-ID', () async { + final dienst = await baue((anfrage) async { + expect(anfrage.method, 'POST'); + expect(anfrage.url.path, endsWith('/playlists')); + expect(jsonDecode(anfrage.body), {'name': 'Road Trip'}); + // handle_playlist_create verpackt die ID unter „playlist" — genau so + // antwortet der echte Server, und genau daran ist der Vertrag geknüpft. + return http.Response( + jsonEncode({ + 'status': 'ok', + 'playlist': {'id': 7, 'name': 'Road Trip', 'song_count': 0} + }), + 200, + ); + }); + + expect(await dienst.legePlaylistAn('Road Trip'), '7'); + }); + + test('eine Antwort ohne playlist-Block ist ein Fehler', () async { + // Die ID auf oberster Ebene zu suchen wäre der naheliegende Fehler; er + // fiele am echten Server als „null" auf und sonst nirgends. + final dienst = await baue( + (_) async => http.Response(jsonEncode({'status': 'ok'}), 200)); + + expect(() => dienst.legePlaylistAn('Road Trip'), + throwsA(isA())); + }); + + test('ein Fehler im 200er-Körper wird geworfen', () async { + final dienst = await baue((_) async => + http.Response(jsonEncode({'error': 'kein Name'}), 200)); + + expect(() => dienst.legePlaylistAn(''), + throwsA(isA())); + }); + + test('fuegePlaylistSongsHinzu meldet die Song-IDs', () async { + Map? gesendet; + final dienst = await baue((anfrage) async { + expect(anfrage.url.path, endsWith('/playlists/7/songs')); + gesendet = jsonDecode(anfrage.body) as Map; + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await dienst.fuegePlaylistSongsHinzu('7', ['c1', 'c2']); + + expect(gesendet, { + 'song_ids': ['c1', 'c2'] + }); + }); + + test('entfernePlaylistSong benutzt DELETE auf dem Song-Pfad', () async { + String? pfad; + String? methode; + final dienst = await baue((anfrage) async { + pfad = anfrage.url.path; + methode = anfrage.method; + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await dienst.entfernePlaylistSong('7', 'c1'); + + expect(methode, 'DELETE'); + expect(pfad, endsWith('/playlists/7/songs/c1')); + }); + + test('setzePlaylistReihenfolge benutzt PUT auf /positions', () async { + String? methode; + Map? gesendet; + final dienst = await baue((anfrage) async { + methode = anfrage.method; + expect(anfrage.url.path, endsWith('/playlists/7/positions')); + gesendet = jsonDecode(anfrage.body) as Map; + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await dienst.setzePlaylistReihenfolge('7', ['c2', 'c1']); + + expect(methode, 'PUT'); + // Der Router liest „positions", der Handler erwartet Paare. Unter + // „song_ids" bekäme der Server eine leere Liste und antwortete stumm + // „ok" — die Reihenfolge käme nie an, ohne jede Fehlermeldung. + expect(gesendet, { + 'positions': [ + {'id': 'c2', 'position': 0}, + {'id': 'c1', 'position': 1}, + ] + }); + }); + + test('playlisten liest Name und ID', () async { + final dienst = await baue((_) async => http.Response( + jsonEncode({ + 'playlists': [ + {'id': 7, 'name': 'Road Trip'} + ] + }), + 200, + )); + + final listen = await dienst.playlisten(); + + expect(listen.single.id, '7'); + expect(listen.single.name, 'Road Trip'); + }); + + test('playlistSongs liefert die Song-IDs in Reihenfolge', () async { + final dienst = await baue((_) async => http.Response( + jsonEncode({ + 'songs': [ + {'id': 'c1'}, + {'id': 'c2'}, + ] + }), + 200, + )); + + expect(await dienst.playlistSongs('7'), ['c1', 'c2']); + }); +} +``` + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/melo_cloud_playlists_test.dart` +Erwartet: FAIL mit +`The method 'legePlaylistAn' isn't defined for the class 'MeloCloudService'`. + +- [ ] **Schritt 3: Minimale Implementierung** + +`lib/services/melo_cloud_service.dart` — neben `CloudSong` einfügen: +```dart +/// Eine Playlist, wie sie der Server kennt. Mehr als Name und ID braucht die +/// einseitige Sicherung nicht. +class CloudPlaylist { + const CloudPlaylist({required this.id, required this.name}); + final String id; + final String name; +} +``` + +Hinter `setzeFavorit` einfügen: +```dart + /// Der Körper einer Playlisten-Antwort, oder `CloudException`. + /// + /// Der Server meldet Fehler im 200er-Körper unter `error`. Ein bloßes + /// `{"status":"not_found"}` **ohne** `error` (so antworten + /// `handle_playlist_remove_song` und `handle_playlist_update_positions`) + /// geht hier bewusst durch: die Sicherung ist einseitig und + /// fire-and-forget, sie verwirft jeden Fehler ohnehin. + Map _json(http.Response antwort) { + final daten = jsonDecode(antwort.body) as Map; + final fehler = daten['error'] as String?; + if (fehler != null) throw CloudException(fehler); + return daten; + } + + /// Legt eine Playlist am Server an und gibt deren ID zurück. + /// + /// Die Identität stammt **immer** von hier: `user_playlists.id` ist + /// AUTOINCREMENT und damit stabil. Eine Zuordnung über den Namen gibt es + /// nicht — sie zerbräche beim ersten Umbenennen. + Future legePlaylistAn(String name) async { + _pruefeAnmeldung(); + final antwort = await _client + .post( + Uri.parse('$basisUrl/playlists'), + headers: {..._kopf, 'Content-Type': 'application/json'}, + body: jsonEncode({'name': name}), + ) + .timeout(const Duration(seconds: 30)); + _pruefeStatus(antwort); + // handle_playlist_create antwortet {"status":"ok","playlist":{"id":…}} — + // die ID liegt eine Ebene tiefer, nicht auf oberster Ebene. + final playlist = _json(antwort)['playlist']; + if (playlist is! Map) { + throw CloudException('Antwort ohne Playlist'); + } + return '${playlist['id']}'; + } + + Future fuegePlaylistSongsHinzu( + String playlistCloudId, List songCloudIds) async { + if (songCloudIds.isEmpty) return; + _pruefeAnmeldung(); + final antwort = await _client + .post( + Uri.parse('$basisUrl/playlists/$playlistCloudId/songs'), + headers: {..._kopf, 'Content-Type': 'application/json'}, + body: jsonEncode({'song_ids': songCloudIds}), + ) + .timeout(const Duration(seconds: 30)); + _pruefeStatus(antwort); + _json(antwort); + } + + Future entfernePlaylistSong( + String playlistCloudId, String songCloudId) async { + _pruefeAnmeldung(); + final antwort = await _client + .delete( + Uri.parse('$basisUrl/playlists/$playlistCloudId/songs/$songCloudId'), + headers: _kopf, + ) + .timeout(const Duration(seconds: 30)); + _pruefeStatus(antwort); + _json(antwort); + } + + /// Schreibt die Reihenfolge einer Playlist am Server fest. + /// + /// Der Körper heißt `positions` und trägt Paare aus `id` und `position`: + /// Der Router liest `d.get('positions',[])`, der Handler greift je Eintrag + /// auf beide Schlüssel zu. Eine blanke ID-Liste unter `song_ids` käme als + /// leere Liste an — der Server antwortete stumm `{"status":"ok"}` und + /// änderte nichts. + Future setzePlaylistReihenfolge( + String playlistCloudId, List songCloudIds) async { + _pruefeAnmeldung(); + final antwort = await _client + .put( + Uri.parse('$basisUrl/playlists/$playlistCloudId/positions'), + headers: {..._kopf, 'Content-Type': 'application/json'}, + body: jsonEncode({ + 'positions': [ + for (var i = 0; i < songCloudIds.length; i++) + {'id': songCloudIds[i], 'position': i}, + ], + }), + ) + .timeout(const Duration(seconds: 30)); + _pruefeStatus(antwort); + _json(antwort); + } + + Future> playlisten() async { + _pruefeAnmeldung(); + final antwort = await _client + .get(Uri.parse('$basisUrl/playlists'), headers: _kopf) + .timeout(const Duration(seconds: 30)); + _pruefeStatus(antwort); + final liste = _json(antwort)['playlists']; + if (liste is! List) throw CloudException('Antwort ohne Playlisten'); + return [ + for (final j in liste) + CloudPlaylist( + id: '${(j as Map)['id']}', + name: j['name'] as String? ?? 'Ohne Namen', + ), + ]; + } + + Future> playlistSongs(String playlistCloudId) async { + _pruefeAnmeldung(); + final antwort = await _client + .get(Uri.parse('$basisUrl/playlists/$playlistCloudId'), headers: _kopf) + .timeout(const Duration(seconds: 30)); + _pruefeStatus(antwort); + final liste = _json(antwort)['songs']; + if (liste is! List) throw CloudException('Antwort ohne Titel'); + return [ + for (final j in liste) '${(j as Map)['id']}', + ]; + } +``` + +- [ ] **Schritt 4: Test laufen lassen, Erfolg bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/services/melo_cloud_playlists_test.dart test/services/melo_cloud_service_test.dart` +Erwartet: `All tests passed!` +Gegenprobe: `grep -n "playlists'" lib/services/melo_cloud_service.dart` — es +darf **kein** `.delete(Uri.parse('$basisUrl/playlists')` ohne Song-Pfad +existieren. +Danach `/home/dustin/development/flutter/bin/flutter analyze` → sauber. + +- [ ] **Schritt 5: Commit** +```bash +cd /home/dustin/mello-dev/app +git add lib/services/melo_cloud_service.dart test/services/melo_cloud_playlists_test.dart +git commit -m "Playlisten-Endpunkte fuer die einseitige Sicherung (ohne DELETE)" +``` + +--- + +### Task 12: Einseitige Playlist-Sicherung im `PlaylistService` + +> **BLOCKIERT — dieselben zwei Bedingungen wie Task 10.** + +**Dateien:** +- Ändern: `lib/library/playlist_service.dart:14-43` (die fünf Mutationen + melden ihre Änderung) +- Ändern: `lib/library/playlist_service.dart` (neue Methode + `stelleWiederHer()`) +- Ändern: `lib/library/database.dart` (neue Methode `songByCloudId` hinter + `setCloudId`) — sie wird von `stelleWiederHer()` gebraucht und in Schritt 5 + mitcommittet +- Ändern: `lib/main.dart` (Aufruf von `stelleWiederHer()` direkt hinter der + `_playlists`-Konstruktion aus Task 4) +- Test: `test/library/playlist_sicherung_test.dart` (neu) + +**Schnittstellen:** +- Nutzt aus Task 10: `db.setPlaylistCloudId`, `db.playlistById`, + `db.countPlaylists`. +- Nutzt aus Task 11: `legePlaylistAn`, `fuegePlaylistSongsHinzu`, + `entfernePlaylistSong`, `setzePlaylistReihenfolge`, `playlisten`, + `playlistSongs`. +- Liefert: `Future PlaylistService.stelleWiederHer()` — Anzahl der lokal + angelegten Playlisten (0, wenn lokal schon welche existieren). + +**Nicht abgedeckt, ausdrücklich dokumentiert:** Umbenennungen propagieren +nicht (es gibt keinen Rename-Endpunkt). Playlist-Löschungen propagieren nicht. +Änderungen auf einem zweiten Gerät erscheinen auf dem ersten nicht. Es gibt +keinen Rück-Merge. + +**Bestehende Tests, die mitgeändert werden müssen:** keine — +`test/library/playlist_service_test.dart` konstruiert weiterhin ohne Cloud; +alle Sofort-Pushes entfallen dann stillschweigend. + +- [ ] **Schritt 1: Fehlschlagenden Test schreiben** + +`test/library/playlist_sicherung_test.dart` (neu): +```dart +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/playlist_service.dart'; +import 'package:melo/services/baka_auth.dart'; +import 'package:melo/services/melo_cloud_service.dart'; + +class _MemorySpeicher implements TokenSpeicher { + _MemorySpeicher(this.werte); + final Map werte; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + +Future cloudMit( + Future Function(http.Request) antwort) async { + final auth = BakaAuth( + speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}), + ); + await auth.laden(); + return MeloCloudService(auth: auth, client: MockClient(antwort)); +} + +void main() { + test('eine neue Playlist wird gemeldet und ihre Server-ID gemerkt', + () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + final dienst = PlaylistService( + db, + cloud: await cloudMit((_) async => http.Response( + // Form von handle_playlist_create: die ID liegt unter „playlist". + jsonEncode({ + 'status': 'ok', + 'playlist': {'id': 7, 'name': 'Road Trip'} + }), + 200, + )), + ); + + final id = await dienst.createPlaylist('Road Trip'); + + expect((await db.playlistById(id))!.cloudId, '7'); + }); + + test('ein Song ohne cloudId wird nicht mitgemeldet', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + var songMeldungen = 0; + final dienst = PlaylistService( + db, + cloud: await cloudMit((anfrage) async { + if (anfrage.url.path.endsWith('/songs')) songMeldungen++; + return http.Response( + jsonEncode({ + 'status': 'ok', + 'playlist': {'id': 7, 'name': 'Mix'} + }), + 200, + ); + }), + ); + await db.into(db.songs).insert(SongsCompanion.insert( + id: 'song-1', path: '/a.mp3', title: 'A', + dateAddedMs: 0, updatedAtMs: 0, + )); + final id = await dienst.createPlaylist('Mix'); + + await dienst.addSongToPlaylist(id, 'song-1', 0); + + expect(songMeldungen, 0); + // Lokal ist er trotzdem drin — kein Fehler, nur nichts zu melden. + expect(await db.watchPlaylistSongs(id).first, hasLength(1)); + }); + + /// Legt einen Titel mit Server-ID an. Ohne den fällt jeder Push aus, und + /// die Negativtests allein hätten den ganzen Vertrag nie berührt. + Future legeSongAn(MeloDb db, String id, String cloudId) async { + await db.into(db.songs).insert(SongsCompanion.insert( + id: id, path: '/$id.mp3', title: id, + dateAddedMs: 0, updatedAtMs: 0, + )); + await db.setCloudId(id, cloudId); + } + + test('ein Song mit cloudId wird an die Server-Playlist gemeldet', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + Object? koerper; + String? pfad; + final dienst = PlaylistService( + db, + cloud: await cloudMit((anfrage) async { + if (anfrage.method == 'POST' && anfrage.url.path.endsWith('/songs')) { + pfad = anfrage.url.path; + koerper = jsonDecode(anfrage.body); + } + return http.Response( + jsonEncode({ + 'status': 'ok', + 'playlist': {'id': 7, 'name': 'Mix'} + }), + 200, + ); + }), + ); + await legeSongAn(db, 'song-1', 'c1'); + final id = await dienst.createPlaylist('Mix'); + + await dienst.addSongToPlaylist(id, 'song-1', 0); + + expect(pfad, endsWith('/playlists/7/songs')); + expect(koerper, { + 'song_ids': ['c1'] + }); + }); + + test('das Entfernen geht als DELETE auf den Song-Pfad', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + String? geloeschterPfad; + final dienst = PlaylistService( + db, + cloud: await cloudMit((anfrage) async { + if (anfrage.method == 'DELETE') geloeschterPfad = anfrage.url.path; + return http.Response( + jsonEncode({ + 'status': 'ok', + 'playlist': {'id': 7, 'name': 'Mix'} + }), + 200, + ); + }), + ); + await legeSongAn(db, 'song-1', 'c1'); + final id = await dienst.createPlaylist('Mix'); + await dienst.addSongToPlaylist(id, 'song-1', 0); + + await dienst.removeSongFromPlaylist(id, 'song-1'); + + expect(geloeschterPfad, endsWith('/playlists/7/songs/c1')); + expect(await db.watchPlaylistSongs(id).first, isEmpty); + }); + + test('eine neue Reihenfolge geht als positions-Paare raus', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + Object? koerper; + String? methode; + final dienst = PlaylistService( + db, + cloud: await cloudMit((anfrage) async { + if (anfrage.url.path.endsWith('/positions')) { + methode = anfrage.method; + koerper = jsonDecode(anfrage.body); + } + return http.Response( + jsonEncode({ + 'status': 'ok', + 'playlist': {'id': 7, 'name': 'Mix'} + }), + 200, + ); + }), + ); + await legeSongAn(db, 'song-1', 'c1'); + await legeSongAn(db, 'song-2', 'c2'); + final id = await dienst.createPlaylist('Mix'); + await dienst.addSongToPlaylist(id, 'song-1', 0); + await dienst.addSongToPlaylist(id, 'song-2', 1); + + await dienst.reorderAll(id, ['song-2', 'song-1']); + + // Unter „song_ids" hätte der Server eine leere Liste gelesen und stumm + // „ok" geantwortet — dieser Test ist der einzige Ort, an dem das auffällt. + expect(methode, 'PUT'); + expect(koerper, { + 'positions': [ + {'id': 'c2', 'position': 0}, + {'id': 'c1', 'position': 1}, + ] + }); + }); + + test('ein Endpunkt-Fehler ändert lokal nichts', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + final dienst = PlaylistService( + db, + cloud: await cloudMit( + (_) async => http.Response(jsonEncode({'error': 'weg'}), 500)), + ); + + final id = await dienst.createPlaylist('Mix'); + + expect((await db.playlistById(id))!.cloudId, isNull); + expect(await db.watchPlaylists().first, hasLength(1)); + }); + + test('Wiederherstellung greift nur bei leerer Playlisten-Tabelle', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + final dienst = PlaylistService( + db, + cloud: await cloudMit((anfrage) async { + if (anfrage.url.path.endsWith('/playlists')) { + return http.Response( + jsonEncode({ + 'playlists': [ + {'id': 7, 'name': 'Vom Server'} + ] + }), + 200, + ); + } + return http.Response(jsonEncode({'songs': []}), 200); + }), + ); + + expect(await dienst.stelleWiederHer(), 1); + final angelegt = await db.watchPlaylists().first; + expect(angelegt.single.name, 'Vom Server'); + expect(angelegt.single.cloudId, '7'); + }); + + test('mit einer lokalen Playlist wird nichts angelegt (kein Rück-Merge)', + () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + final dienst = PlaylistService( + db, + cloud: await cloudMit((_) async => http.Response( + jsonEncode({ + 'playlists': [ + {'id': 7, 'name': 'Vom Server'} + ] + }), + 200, + )), + ); + await db.createPlaylist('Meine eigene'); + + expect(await dienst.stelleWiederHer(), 0); + expect(await db.watchPlaylists().first, hasLength(1)); + }); +} +``` + +- [ ] **Schritt 2: Test laufen lassen, Fehlschlag bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/library/playlist_sicherung_test.dart` +Erwartet: FAIL — `expect((await db.playlistById(id))!.cloudId, '7')` schlägt +mit `Actual: ` fehl, und +`The method 'stelleWiederHer' isn't defined for the class 'PlaylistService'`. + +- [ ] **Schritt 3: Minimale Implementierung** + +`lib/library/playlist_service.dart` — die fünf Mutationen ersetzen: +```dart + Future createPlaylist(String name, {String? description}) async { + final id = await db.createPlaylist(name, description: description); + notifyListeners(); + await _sichereNeuePlaylist(id, name); + return id; + } + + Future addSongToPlaylist( + String playlistId, String songId, int position) async { + await db.addSongToPlaylist(playlistId, songId, position); + notifyListeners(); + final cloudId = await _cloudIdDerPlaylist(playlistId); + final songCloudId = (await db.songById(songId))?.cloudId; + if (cloudId == null || songCloudId == null) return; + await _still(() => + _cloud!.fuegePlaylistSongsHinzu(cloudId, [songCloudId])); + } + + Future removeSongFromPlaylist( + String playlistId, String songId) async { + final songCloudId = (await db.songById(songId))?.cloudId; + await db.removeSongFromPlaylist(playlistId, songId); + notifyListeners(); + final cloudId = await _cloudIdDerPlaylist(playlistId); + if (cloudId == null || songCloudId == null) return; + await _still(() => _cloud!.entfernePlaylistSong(cloudId, songCloudId)); + } + + Future reorderSong( + String playlistId, String songId, int newPosition) async { + await db.reorderPlaylistSong(playlistId, songId, newPosition); + notifyListeners(); + await _sichereReihenfolge(playlistId); + } + + Future reorderAll( + String playlistId, List orderedSongIds) async { + await db.reorderAllPlaylistSongs(playlistId, orderedSongIds); + notifyListeners(); + await _sichereReihenfolge(playlistId); + } +``` + +und die Helfer anfügen: +```dart + /// Führt [aktion] aus und verwirft jeden Fehler. + /// + /// Die Sicherung ist einseitig und ohne Rollback: schlägt sie fehl, bleibt + /// der lokale Stand, wie er ist, und die nächste Änderung versucht es + /// erneut. + Future _still(Future Function() aktion) async { + try { + await aktion(); + } catch (e) { + debugPrint('Playlist-Sicherung übersprungen: $e'); + } + } + + Future _cloudIdDerPlaylist(String playlistId) async { + if (_cloud == null || !_cloud.istAngemeldet) return null; + return (await db.playlistById(playlistId))?.cloudId; + } + + Future _sichereNeuePlaylist(String id, String name) async { + final cloud = _cloud; + if (cloud == null || !cloud.istAngemeldet) return; + await _still(() async { + final cloudId = await cloud.legePlaylistAn(name); + await db.setPlaylistCloudId(id, cloudId); + }); + } + + Future _sichereReihenfolge(String playlistId) async { + final cloudId = await _cloudIdDerPlaylist(playlistId); + if (cloudId == null) return; + final songs = await db.watchPlaylistSongs(playlistId).first; + final ids = [ + for (final s in songs) + if (s.cloudId != null) s.cloudId!, + ]; + if (ids.isEmpty) return; + await _still(() => _cloud!.setzePlaylistReihenfolge(cloudId, ids)); + } + + /// Holt die Playlisten des Servers **nur** auf ein Gerät ohne eigene: + /// Neuinstallation oder Wiederherstellung. + /// + /// Es gibt bewusst keinen Rück-Merge — Umbenennungen und Änderungen eines + /// zweiten Geräts erscheinen hier nicht. Der beidseitige Abgleich ist eine + /// eigene, spätere Spec. + Future stelleWiederHer() async { + final cloud = _cloud; + if (cloud == null || !cloud.istAngemeldet) return 0; + if (await db.countPlaylists() > 0) return 0; + + var angelegt = 0; + try { + for (final vomServer in await cloud.playlisten()) { + final id = await db.createPlaylist(vomServer.name); + await db.setPlaylistCloudId(id, vomServer.id); + final songCloudIds = await cloud.playlistSongs(vomServer.id); + var position = 0; + for (final songCloudId in songCloudIds) { + final song = await db.songByCloudId(songCloudId); + if (song == null) continue; + await db.addSongToPlaylist(id, song.id, position++); + } + angelegt++; + } + } catch (e) { + debugPrint('Playlist-Wiederherstellung abgebrochen: $e'); + } + notifyListeners(); + return angelegt; + } +``` + +Dafür in `lib/library/database.dart` hinter `setCloudId` (heute `:517`) +ergänzen: +```dart + Future songByCloudId(String cloudId) => + (select(songs)..where((s) => s.cloudId.equals(cloudId))) + .getSingleOrNull(); +``` + +`lib/main.dart` — unmittelbar **nach der `_playlists`-Konstruktion** ergänzen, +also hinter dem Block, den Task 4 aus `:54` hinter die `_sync`-Konstruktion +verschoben hat. **Nicht** einfach „nach `await _sync.laden();`": dort steht die +Zuweisung von `_playlists` noch nicht, und der Aufruf stürbe beim App-Start mit +`LateInitializationError`. +```dart + _playlists = PlaylistService( + _db, + cloud: MeloCloudService(auth: _bakaAuth), + ); + // Nur auf einem Gerät ohne eigene Playlisten: nach Neuinstallation oder + // Zurücksetzen holt das die gesicherten Listen zurück. + unawaited(_playlists.stelleWiederHer()); +``` +(die ersten vier Zeilen stehen seit Task 4 schon da — nur die beiden +Kommentarzeilen und der `unawaited`-Aufruf kommen dazu) + +- [ ] **Schritt 4: Test laufen lassen, Erfolg bestätigen** + +Befehl: `/home/dustin/development/flutter/bin/flutter test --no-pub test/library/playlist_sicherung_test.dart test/library/playlist_service_test.dart test/library/database_playlists_test.dart` +Erwartet: `All tests passed!` +Danach `/home/dustin/development/flutter/bin/flutter analyze` → sauber. + +- [ ] **Schritt 5: Commit** +```bash +cd /home/dustin/mello-dev/app +git add lib/library/playlist_service.dart lib/library/database.dart lib/main.dart test/library/playlist_sicherung_test.dart +git commit -m "Einseitige Playlist-Sicherung: melden, merken, bei leerer Tabelle holen" +``` + +--- + +### Task 13: Gesamtverifikation + CHANGELOG + +**Dateien:** +- Ändern: `CHANGELOG.md` (neuer Abschnitt unter `## [Unreleased]`, ganz oben) + +**Schnittstellen:** +- Nutzt: alle vorherigen Tasks. +- Liefert: den belegten Nachweis, dass der Stand baut, analysiert, testet — und + einen Changelog-Eintrag, der in einfachen Worten sagt, was sich für den + Nutzer ändert. + +**Bestehende Tests, die mitgeändert werden müssen:** keine. + +- [ ] **Schritt 1: Vollständigen Testlauf im Hintergrund starten** + +Befehl (im **Hintergrund**, `run_in_background`, kein kurzer Timeout — +600+ Tests, mehrere Minuten): +```bash +cd /home/dustin/mello-dev/app && /home/dustin/development/flutter/bin/flutter test --no-pub 2>&1 | tail -40 +``` +Erwartet: `All tests passed!` und eine Testzahl **≥ 602**. +Ein einziger roter Test ist ein Abbruchkriterium: erst +superpowers:systematic-debugging, dann weiter — **nicht** den Testlauf +schönreden. + +- [ ] **Schritt 2: Analyse und Build prüfen** + +```bash +cd /home/dustin/mello-dev/app +/home/dustin/development/flutter/bin/flutter analyze +``` +Erwartet: `No issues found!` + +```bash +cd /home/dustin/mello-dev/app +export ANDROID_HOME=/home/dustin/Android +/home/dustin/development/flutter/bin/flutter build apk --target-platform android-arm64 +``` +Erwartet: `✓ Built build/app/outputs/flutter-apk/app-release.apk` +**Ohne `--dart-define`.** Bricht Gradle mit OOM ab, ist das ein bekanntes +Umgebungsproblem (2-GB-Heap) und **kein** Grund, Code zu ändern — melden. + +- [ ] **Schritt 3: Sicherheitsregel gegenprüfen** + +```bash +cd /home/dustin/mello-dev/app +grep -rn "setzeFavoriten" lib/ test/ +grep -rn "song_ids" lib/services/melo_cloud_service.dart +``` +Erwartet: der erste Befehl liefert **keinen Treffer** (der Voll-Ersatz-Pfad +existiert nicht mehr). Der zweite liefert nur Treffer aus den +Playlisten-Methoden (Task 11), **keinen** aus einem `/favorites`-POST. + +- [ ] **Schritt 4: CHANGELOG schreiben** + +In `CHANGELOG.md` direkt unter `## [Unreleased]` einfügen (Datum anpassen): +```markdown +### 💾❤️ Sync-Ausbau: Favoriten gehen nicht mehr verloren (2026-08-27) + +- ❤️ **Der Favoriten-Datenverlust ist behoben.** Bisher hat jedes Gerät beim + Abgleich seine eigene Favoritenliste als Komplett-Ersatz zum Server + geschickt — ein frisch installiertes Handy löschte damit beim allerersten + Abgleich sämtliche Server-Favoriten. Neu wird nur noch **hinzugefügt**: + Was hier Favorit ist und dort fehlt, wird einzeln gemeldet; was dort + Favorit ist und hier fehlt, wird hier gesetzt. Entfernt wird in keiner + Richtung etwas. Der alte Weg (`POST /favorites`) existiert im Code nicht + mehr — der Fehler kann also nicht zurückkommen. +- 💔 **Bewusster Preis:** Ein entferntes Herz wirkt sofort auf diesem Gerät + und (online) auch am Server, ist aber **nicht geräteübergreifend + garantiert**: Hält ein zweites Gerät den Favoriten noch, bringt dessen + nächster Abgleich ihn zurück. Kein Datenverlust ist uns wichtiger als + verlässliches Löschen. +- ⚡ **Herz antippen meldet sofort.** Wer online ein Herz setzt oder entfernt, + schickt den Wunsch direkt zum Server — eindeutig als „setze auf ja/nein", + nicht als Umschalten. Geht das schief, passiert nichts Schlimmes: der + nächste Abgleich holt es nach. +- 🛡️ **Der Server darf sich nicht mehr missverständlich ausdrücken.** Kommt + auf die Favoriten-Abfrage eine Antwort ohne Favoritenliste, gilt das jetzt + als Fehler und die Favoriten-Runde wird übersprungen — vorher wurde daraus + stillschweigend „keine Favoriten". +- ☁️ **Neu: „Auf den Server laden".** Im Auswahl-Modus von „Meine Musik" + (langes Drücken) lassen sich einzelne Titel markieren und gezielt + hochladen — mit Fortschritt und **Abbrechen**. Titel, die schon oben sind, + werden übersprungen; einzelne Fehlschläge stoppen den Rest nicht. Die + Aktion erscheint bewusst nur dort und nicht bei Favoriten, + Wiedergabelisten oder Titellisten. +- ⬇️ **Neu: einzelne Server-Titel offline nehmen.** In der Album- und + Künstler-Ansicht hat jede Zeile jetzt einen Knopf — bisher ging nur „ganzes + Album". Schon geladene Titel lassen sich dort auch wieder entfernen. + *Bekannte Einschränkung:* Ein gerade gehörter Titel liegt danach kurzzeitig + doppelt (Zwischenspeicher + Download), bis der Zwischenspeicher aufräumt. +- 📰 **Neu: „Willkommen zurück".** War der letzte **erfolgreiche** Abgleich + mehr als 24 Stunden her, zeigt die App danach einmalig, was dazugekommen + und was verschwunden ist. Beim allerersten Start nach einer Neuinstallation + erscheint er absichtlich **nicht**. +- 🔧 **Unter der Haube:** Der Abgleichs-Zeitstempel wird jetzt **vor** dem + Abfragen der Serverliste genommen (sonst fallen Änderungen während des Laufs + durchs Raster); ein hängender Upload (Zeitüberschreitung) reißt nicht mehr + den ganzen Abgleich ab; ausgefallene Teilschritte verschieben die + 24-Stunden-Uhr des Berichts nicht mehr. +- 🧪 Neue Tests für die Merge-Regeln, den additiven Abgleich, den Sofort-Push, + den Auswahl-Upload samt Abbrechen und den Bericht. Zwei bestehende Tests + wurden bewusst umgeschrieben, weil sie das alte (fehlerhafte) Verhalten + festschrieben. +``` + +> Sind die Tasks 5–7 oder 10–12 **nicht** umgesetzt worden (weil noch +> blockiert), werden die zugehörigen Punkte aus dem Eintrag **gestrichen** — +> der Changelog beschreibt, was wirklich drin ist, nicht was geplant war. +> Wurden die Tasks 10–12 gebaut, kommt ein Punkt „🗂️ Playlisten werden +> einseitig am Server gesichert (kein Rück-Merge, Umbenennungen propagieren +> nicht)" dazu. + +- [ ] **Schritt 5: Commit + Push** +```bash +cd /home/dustin/mello-dev/app +git add CHANGELOG.md +git commit -m "CHANGELOG: Sync-Ausbau (Favoriten-Fix, Auswahl-Upload, Einzel-Offline, Bericht)" +git push -u origin feature/sync-ausbau +``` + +--- + +## Offene Punkte, die vor bzw. während der Umsetzung an Dustin gehen + +1. **A6 — irreversibler Löschpfad** (blockiert Tasks 5–7). + „Verschwindet eine lokale Datei, löscht der Abgleich sie am Server und aus + der Navidrome-Bibliothek; erneutes Hochladen repariert das nicht. Gewollt?" + *Nebenfrage:* Task 7 (Einzel-Song-Offline) vergibt keine cloudId und ist + von der Begründung sachlich nicht betroffen — darf er vorgezogen werden? +2. **Feature 2 (Playlist-Sicherung, Tasks 10–12).** Bauen oder streichen? + Heute 0 Playlisten am Server. Empfehlung: vertagen. + Falls bauen: hängt der Server-Fix `melo_cloud.py:505` wirklich davor, + obwohl Stufe 1 `DELETE /playlists` nie aufruft? +3. **Nicht Teil dieses Plans, nur zur Erinnerung:** Notification Stufe B, + SSE, beidseitiger Playlist-Merge und der Basis-Snapshot stehen unter + §Spätere Stufen der Spec und werden hier bewusst nicht angefasst. diff --git a/docs/superpowers/specs/2026-08-27-sync-ausbau-design.md b/docs/superpowers/specs/2026-08-27-sync-ausbau-design.md index ddd8c14..fb3c426 100644 --- a/docs/superpowers/specs/2026-08-27-sync-ausbau-design.md +++ b/docs/superpowers/specs/2026-08-27-sync-ausbau-design.md @@ -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 /`, +`POST //songs`, `DELETE //songs/`, `PUT //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 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/` +> und `navidrome/music/` (`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 //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.