Files
Melo/docs/superpowers/plans/2026-08-27-sync-ausbau.md
T
Hermes (Server)andClaude Sonnet 5 1940e2b790 Sync-Ausbau: Löschpfad gefixt — Spec + Plan entblockt
Hermes hat den irreversiblen Löschpfad in melo_cloud.py behoben: Der
Dedup-Zweig von upload() stellt die Datei jetzt aus den hochgeladenen
Bytes wieder her, wenn registry_pfad(sid) leer ist, statt sie zu
verwerfen.

End-to-end verifiziert (eigene Testdatei, Fake-Nutzer, Testdaten danach
restlos entfernt — Registry vor und nach dem Test bei 325 Titeln):
Upload → beide Kopien da · Löschung → beide weg, Download 404 (Bug
reproduziert) · erneuter Upload → beide Kopien zurück, Download
bitgenau identisch mit dem Original.

Damit ist die letzte offene Frage der Spec (A6) beantwortet und keine
Stufe mehr blockiert. Tasks 5-7 des Plans sind entblockt; Tasks 10-12
hängen nur noch an der Produktfrage "wird Feature 2 überhaupt gebaut?"
(0 Playlisten am Server).

Nebenbefund aus dem Test, als Backlog-Notiz festgehalten: _link_user()
legt im Dedup-Zweig einen dritten Hardlink unter users/<user>/ an, den
_entferne_datei_wenn_verwaist() nicht abräumt. Server-Hygiene für
Hermes, kein Datenverlust.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
2026-08-27 10:31:49 +02:00

4007 lines
142 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 1012) — **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/<id>` → `{"status":"ok","playlist":{…},
"songs":[{"id":…,"title":…,"position":…}],"count":…}`, nach `position`
sortiert (`handle_playlist_get:511`).
- `POST {basis}/playlists/<id>/songs`, Körper `{"song_ids":[…]}` (Router
`:1272`) → `{"status":"ok","added":n}`.
- `DELETE {basis}/playlists/<id>/songs/<sid>` — alles im Pfad, kein Körper
(Router `:1274-1282`) → `{"status":"ok"}`.
- `PUT {basis}/playlists/<id>/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 14 (Favoriten-Fix) hängen an nichts und
kommen zuerst. **Tasks 57 sind seit dem 2026-08-27 ENTBLOCKT** — der
Löschpfad ist serverseitig behoben und end-to-end verifiziert (siehe Spec,
§ERLEDIGT). Tasks 1012 bleiben blockiert, aber nur noch durch die
Produktfrage „wird Feature 2 überhaupt gebaut?" (0 Playlisten am Server) —
Details bei Task 10.
---
### 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<String> zuPushendeFavoriten({required Set<String> lokaleFavoriten, required Map<String, String> cloudIdVon, required Set<String> amServer, int deckel = maxFavoritenPushes})`
- `List<String> lokalZuSetzendeFavoriten({required Set<String> amServer, required Map<String, String> songIdVonCloudId, required Set<String> 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<String> zuPushendeFavoriten({
required Set<String> lokaleFavoriten,
required Map<String, String> cloudIdVon,
required Set<String> amServer,
int deckel = maxFavoritenPushes,
}) {
final offen = <String>[];
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<String> lokalZuSetzendeFavoriten({
required Set<String> amServer,
required Map<String, String> songIdVonCloudId,
required Set<String> lokaleFavoriten,
}) {
final offen = <String>[];
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<void> 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<CloudException>()),
);
});
test('Server-Fehler im 200er-Körper wird als CloudException gemeldet', () {
expect(
() => MeloCloudService.parseFavoriten(
jsonEncode({'status': 'error', 'error': 'Auth required'})),
throwsA(isA<CloudException>()),
);
});
});
```
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<String, dynamic>? gesendet;
String? pfad;
final dienst = MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient((anfrage) async {
pfad = anfrage.url.path;
gesendet = jsonDecode(anfrage.body) as Map<String, dynamic>;
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<String, dynamic>? gesendet;
final dienst = MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient((anfrage) async {
gesendet = jsonDecode(anfrage.body) as Map<String, dynamic>;
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<String, String> werte;
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
Future<BakaAuth> _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 <Instance of 'CloudException'> 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<String> parseFavoriten(String body) {
final daten = jsonDecode(body) as Map<String, dynamic>;
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<String, dynamic>)['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<void> 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<bool> 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<void>` — 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 166202
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 = <Map<String, dynamic>>[];
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<String, dynamic>);
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: <null> 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<bool> _gleicheFavoritenAb() async {
_melde('Gleiche Favoriten ab …');
final Set<String> 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<String, String> werte;
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
Future<BakaAuth> _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<void> 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 = <Map<String, dynamic>>[];
final dienst = PlaylistService(
db2,
cloud: MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient((anfrage) async {
gesendet.add(jsonDecode(anfrage.body) as Map<String, dynamic>);
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 = <Map<String, dynamic>>[];
final dienst = PlaylistService(
db2,
cloud: MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient((anfrage) async {
gesendet.add(jsonDecode(anfrage.body) as Map<String, dynamic>);
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<void> 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<void> _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<PlaylistService>.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`
> ✅ **ENTBLOCKT seit 2026-08-27.** Die frühere Blockade lautete: dieser Task
> gibt mehr Titeln eine cloudId und vergrößert damit die Angriffsfläche des
> irreversiblen Löschpfads. Dustins Antwort auf A6 war „nein, nicht gewollt";
> Hermes hat den Fix am selben Tag eingebaut (Datei-Wiederherstellung im
> Dedup-Zweig von `upload()`), end-to-end verifiziert. Ein versehentlicher
> Löschvorgang ist jetzt durch erneutes Hochladen reparabel.
> **Darf gebaut werden.**
**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<String> fehler; final bool abgebrochen; String get meldung; }`
- `Future<UploadErgebnis> SyncService.ladeAusgewaehlteHoch(List<Song> 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<List<Song>> 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<String> fehler;
final bool abgebrochen;
String get meldung {
final teile = <String>[];
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<UploadErgebnis> ladeAusgewaehlteHoch(List<Song> 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 = <String>[];
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"
> ✅ **ENTBLOCKT seit 2026-08-27** — dieselbe Begründung wie Task 5 (die
> Aktion ist die Oberfläche zu `ladeAusgewaehlteHoch`), und dieselbe
> Auflösung: Löschpfad serverseitig behoben. **Darf gebaut werden.**
**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<Song>)`,
`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<void> ladeAufServer(BuildContext context, List<Song> 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<String, String> werte;
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> 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 = <String>[];
@override
Future<UploadErgebnis> ladeAusgewaehlteHoch(List<Song> 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<void> 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<MeloDb>.value(value: db),
ChangeNotifierProvider<CategoryService>.value(
value: CategoryService(db)),
ChangeNotifierProvider<PlaylistService>.value(
value: PlaylistService(db)),
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
// Ohne diesen Provider stürbe schon das erste Antippen der neuen
// Aktion in ladeAufServer mit ProviderNotFoundException.
ChangeNotifierProvider<SyncService>.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<void> ladeAufServer(
BuildContext context, List<Song> gewaehlte) async {
final sync = context.read<SyncService>();
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<Song> 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<void> _aufServer(List<Song> 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
> ✅ **ENTBLOCKT seit 2026-08-27** (Löschpfad serverseitig behoben). Der Task
> war ohnehin nur mitblockiert, weil die Spec Stufe 2 als Ganzes hinter die
> A6-Antwort stellte — die inhaltliche Begründung traf auf Feature 4 nie zu:
> ein Download vergibt keine cloudId und meldet nichts zum Server.
> **Darf gebaut werden.**
**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<SubsonicSong>)`
(Doppel-Lauf-Schutz `:73`, Verbindungsprüfung `:77-84`, Fortschritt
`:85-104`) und `DownloadService.entferne(navidromeId)`.
**Nicht** `_ladeEinen` direkt verdrahten.
- Liefert: `Future<bool> 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 = <String>[];
final entfernt = <String>[];
@override
bool get laeuft => false;
@override
Future<bool> ladeEinzelnenTitel(SubsonicSong song) async {
geladen.add(song.id);
return true;
}
@override
Future<bool> 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<MeloDb>.value(value: db),
ChangeNotifierProvider<DownloadService>.value(value: dienst),
Provider<MeloAudioHandler>.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<MeloAudioHandler>.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<bool> 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<void> _aktualisiereGeladen() async {
final geladen = await context.read<MeloDb>().downloadIds();
if (mounted) setState(() => _geladen = geladen);
}
Future<void> _offlineNehmen(SubsonicSong song) async {
final dienst = context.read<DownloadService>();
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<void> _offlineEntfernen(SubsonicSong song) async {
final dienst = context.read<DownloadService>();
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<bool> _gleicheFavoritenAb()`.
- Liefert:
- `class SyncBericht { final int neueSongs; final int geloeschte; final int favoriten; bool get istLeer; }`
- `SyncBericht? SyncService.bericht`
- `void SyncService.berichtGesehen()`
- `Future<bool> SyncService._meldeLoeschungen(List<Song> songs)`
- `Future<bool> SyncService._ladeHoch(List<Song> songs)`
- `Future<int> SyncService._ladeHerunter(List<CloudSong> 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<void>.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<void> 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<bool> _meldeLoeschungen(List<Song> 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<int>`, sonst unverändert:
```dart
/// Gibt zurück, wie viele Titel wirklich neu dazugekommen sind — die Zahl
/// im „Was ist neu"-Bericht.
Future<int> _ladeHerunter(List<CloudSong> 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<bool> _ladeHoch(List<Song> 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<void> 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 = <String>[];
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<SyncService>().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<void> _gleicheAbUndZeigeBericht() async {
final sync = context.read<SyncService>();
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<void>(
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 1012 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<void> MeloDb.setPlaylistCloudId(String id, String cloudId)`
- `Future<Playlist?> MeloDb.playlistById(String id)`
- `Future<int> 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<Column> 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<Playlist?> playlistById(String id) =>
(select(playlists)..where((p) => p.id.equals(id))).getSingleOrNull();
Future<void> 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<int> 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<String> MeloCloudService.legePlaylistAn(String name)` → Server-ID
- `Future<void> MeloCloudService.fuegePlaylistSongsHinzu(String playlistCloudId, List<String> songCloudIds)`
- `Future<void> MeloCloudService.entfernePlaylistSong(String playlistCloudId, String songCloudId)`
- `Future<void> MeloCloudService.setzePlaylistReihenfolge(String playlistCloudId, List<String> songCloudIds)`
- `Future<List<CloudPlaylist>> MeloCloudService.playlisten()`
- `Future<List<String>> 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<String, String> werte;
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
Future<MeloCloudService> baue(
Future<http.Response> 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<CloudException>()));
});
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<CloudException>()));
});
test('fuegePlaylistSongsHinzu meldet die Song-IDs', () async {
Map<String, dynamic>? gesendet;
final dienst = await baue((anfrage) async {
expect(anfrage.url.path, endsWith('/playlists/7/songs'));
gesendet = jsonDecode(anfrage.body) as Map<String, dynamic>;
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<String, dynamic>? gesendet;
final dienst = await baue((anfrage) async {
methode = anfrage.method;
expect(anfrage.url.path, endsWith('/playlists/7/positions'));
gesendet = jsonDecode(anfrage.body) as Map<String, dynamic>;
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<String, dynamic> _json(http.Response antwort) {
final daten = jsonDecode(antwort.body) as Map<String, dynamic>;
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<String> 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<String, dynamic>) {
throw CloudException('Antwort ohne Playlist');
}
return '${playlist['id']}';
}
Future<void> fuegePlaylistSongsHinzu(
String playlistCloudId, List<String> 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<void> 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<void> setzePlaylistReihenfolge(
String playlistCloudId, List<String> 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<List<CloudPlaylist>> 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<String, dynamic>)['id']}',
name: j['name'] as String? ?? 'Ohne Namen',
),
];
}
Future<List<String>> 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<String, dynamic>)['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<int> 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<String, String> werte;
@override
Future<String?> lesen(String key) async => werte[key];
@override
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
@override
Future<void> loeschen(String key) async => werte.remove(key);
}
Future<MeloCloudService> cloudMit(
Future<http.Response> 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<void> 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: <null>` 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<String> createPlaylist(String name, {String? description}) async {
final id = await db.createPlaylist(name, description: description);
notifyListeners();
await _sichereNeuePlaylist(id, name);
return id;
}
Future<void> 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<void> 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<void> reorderSong(
String playlistId, String songId, int newPosition) async {
await db.reorderPlaylistSong(playlistId, songId, newPosition);
notifyListeners();
await _sichereReihenfolge(playlistId);
}
Future<void> reorderAll(
String playlistId, List<String> 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<void> _still(Future<void> Function() aktion) async {
try {
await aktion();
} catch (e) {
debugPrint('Playlist-Sicherung übersprungen: $e');
}
}
Future<String?> _cloudIdDerPlaylist(String playlistId) async {
if (_cloud == null || !_cloud.istAngemeldet) return null;
return (await db.playlistById(playlistId))?.cloudId;
}
Future<void> _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<void> _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<int> 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<Song?> 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 57 oder 1012 **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 1012 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**~~ ✅ **ERLEDIGT 2026-08-27.** Dustins
Antwort: nicht gewollt. Hermes hat die Datei-Wiederherstellung im
Dedup-Zweig von `upload()` eingebaut, end-to-end verifiziert (Upload →
Löschung → erneuter Upload stellt beide Kopien bitgenau wieder her).
Tasks 57 sind damit entblockt.
*Offen geblieben, aber kein Blocker:* Die App tombstoned weiterhin
großzügig (nicht eingehängte SD-Karte genügt) und die Lösch-Bremse greift
erst ab 109 von 325 Titeln — der Schaden ist jetzt nur reparabel, nicht
verhindert. Ob die App vorsichtiger werden soll, ist eine eigene Frage.
2. **Feature 2 (Playlist-Sicherung, Tasks 1012).** 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.