# 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.