Spec nach dem Review-Panel-Urteil überarbeitet (alle 17 Aktionspunkte). Der Umfang schrumpft deutlich: 6→5 Ziele, 4→2 neue Dateien, neue Dependencies 1→0. SSE, persistente Notification und der beidseitige Playlist-Merge werden spätere Stufen; der Favoriten-Fix schreibt jetzt additiv (POST /favorites gestrichen), womit der Datenverlust-Bug strukturell unmöglich wird statt nur per Regel verhindert. Implementierungsplan: 13 Tasks nach TDD, Favoriten-Fix zuerst (hängt an keiner offenen Frage). Gegengelesen und geprüft; die Prüfung fand drei echte Server-Vertragsfehler, die in den eigenen Tests grün geworden wären (playlist.id statt id, positions statt song_ids, not_found ohne error-Feld) — alle korrigiert und am Servercode belegt. OFFEN: Rückfrage an Dustin zum irreversiblen Löschpfad (siehe Spec, Abschnitt "OFFENE ENTSCHEIDUNG"). Tasks 5-7 und 10-12 sind bis dahin als blockiert markiert; Tasks 1-4 können sofort starten. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CcDiyJdVRqh1TtJk5JiabX
4004 lines
142 KiB
Markdown
4004 lines
142 KiB
Markdown
# Sync-Ausbau Implementation Plan
|
||
|
||
> **Für Umsetzende:** PFLICHT-SUB-SKILL: superpowers:subagent-driven-development
|
||
> (empfohlen) oder superpowers:executing-plans zur task-weisen Umsetzung.
|
||
> Schritte nutzen Checkbox-Syntax (`- [ ]`).
|
||
|
||
**Ziel:** Die Favoriten-Synchronisation vom zerstörerischen Voll-Ersatz auf einen
|
||
additiven Abgleich umstellen und darauf aufbauend gezielten Upload,
|
||
Einzel-Song-Offline, einen „Was ist neu"-Bericht und eine einseitige
|
||
Playlist-Sicherung ergänzen.
|
||
|
||
**Architektur:** Rückgrat bleiben `SyncService` und `MeloCloudService`; die
|
||
reinen Entscheidungsfunktionen wandern in die neue, I/O-freie Datei
|
||
`lib/services/sync_merge.dart`. Der Favoriten-Schreibweg wechselt von
|
||
`POST /favorites` (Voll-Ersatz) auf `POST /favorites/toggle` mit
|
||
deterministischem `set:true` — danach existiert kein Codepfad mehr, der den
|
||
Server-Stand ersetzen kann. UI-Änderungen bleiben in den bestehenden Screens.
|
||
|
||
**Tech-Stack:** Flutter/Dart, Drift (SQLite), `package:http` + `MockClient` für
|
||
Tests, SharedPreferences, Provider. **Keine neue Dependency.**
|
||
|
||
**Spec:** docs/superpowers/specs/2026-08-27-sync-ausbau-design.md
|
||
|
||
---
|
||
|
||
## Globale Randbedingungen
|
||
|
||
- Arbeitsverzeichnis ist **ausschließlich** `/home/dustin/mello-dev/app`. Nie in
|
||
`~/mello-dev/worktrees/claude-server` wechseln, dort nie committen.
|
||
- Eigener Feature-Branch: `git switch -c feature/sync-ausbau` (Basis:
|
||
`fix/p0-vollwertigkeit`). Nicht auf dem Basis-Branch committen.
|
||
- Flutter: `/home/dustin/development/flutter/bin/flutter`,
|
||
`ANDROID_HOME=/home/dustin/Android`.
|
||
- **Build ohne `--dart-define`:** `flutter build apk --target-platform
|
||
android-arm64` muss ohne zusätzliche Definitionen durchlaufen. Es kommt in
|
||
diesem Plan **keine** neue Dependency dazu — `flutter_local_notifications`
|
||
und der Gradle-Umbau sind ausdrücklich Stufe B und nicht Teil dieses Plans.
|
||
- `flutter analyze` muss **sauber** sein (0 Fehler, 0 Warnungen) — vor jedem
|
||
Commit.
|
||
- **Testläufe:** Einzelne Dateien laufen im Vordergrund
|
||
(`flutter test --no-pub test/services/sync_merge_test.dart`, wenige
|
||
Sekunden). Der **vollständige** Lauf (`flutter test --no-pub`) umfasst 600+
|
||
Tests und dauert mehrere Minuten — er wird **im Hintergrund** gestartet
|
||
(`run_in_background`) und sein Ergebnis abgewartet. **Kein kurzer
|
||
Foreground-Timeout**, sonst wird ein grüner Lauf als Fehlschlag gemeldet.
|
||
- Ausgangslage: **602 Tests grün.** Nach jedem Task muss diese Zahl gehalten
|
||
oder erhöht sein. Zwei Bestandstests werden bewusst umgeschrieben (Task 2
|
||
und Task 3) — das ist kein Regressionsfehler, sondern Teil des Auftrags.
|
||
- **CHANGELOG-Pflicht:** `CHANGELOG.md` unter `## [Unreleased]`, neuester
|
||
Eintrag oben, Stil der bestehenden Einträge (Emoji-Bullets, einfach erklärt).
|
||
Der Changelog wird **mit** dem Code committet. Sammel-Eintrag im letzten
|
||
Task; einzelne Tasks brauchen keinen eigenen Eintrag.
|
||
- **Server-Endpunkt-Vertrag** (nichts anderes darf benutzt werden):
|
||
- `GET {basis}/favorites` → `{"favorites":[{"id":…}]}` — Router verdrahtet
|
||
hart HTTP 200, Fehler kommen im 200er-Körper.
|
||
- `POST {basis}/favorites/toggle` mit `{"song_id":…, "set":true|false}` —
|
||
deterministisch (`melo_cloud.py:644-688`).
|
||
- `POST {basis}/favorites` (Voll-Ersatz) — **verboten.** Nach Task 3
|
||
existiert die Client-Methode nicht mehr.
|
||
- Playlisten (nur Tasks 10–12) — **am Server nachgelesen, Stand
|
||
`melo_cloud.py` 2026-08-27; diese Formen gelten, es wird nicht mehr
|
||
„abgeglichen":**
|
||
- `GET {basis}/playlists` → `{"status":"ok","playlists":[{"id":…,
|
||
"name":…,"created_at":…,"updated_at":…,"song_count":…}]}`
|
||
(`handle_playlist_list:475`). `id` ist eine **Zahl**, keine
|
||
Zeichenkette — clientseitig interpoliert zu `'$id'`.
|
||
- `POST {basis}/playlists`, Körper `{"name":…}` (Router liest
|
||
`d.get('name','')`, `:1254`) → `{"status":"ok","playlist":{"id":pid,…}}`
|
||
(`handle_playlist_create:499`). **Die ID steckt unter `playlist`, nicht
|
||
auf oberster Ebene.** Leerer Name → `{"status":"error","error":"Name
|
||
erforderlich"}` mit HTTP 200.
|
||
- `GET {basis}/playlists/<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 1–4 (Favoriten-Fix) hängen an nichts und
|
||
kommen zuerst. Tasks 5–7 sind **blockiert bis Dustin A6 beantwortet hat**
|
||
(irreversibler Löschpfad). Tasks 10–12 sind **blockiert bis der Server-Fix
|
||
`melo_cloud.py:505` steht** bzw. bis Dustin entscheidet, ob Feature 2
|
||
überhaupt gebaut wird.
|
||
|
||
---
|
||
|
||
### Task 1: Reine Merge-Funktionen (`sync_merge.dart`)
|
||
|
||
**Dateien:**
|
||
- Erstellen: `lib/services/sync_merge.dart`
|
||
- Test: `test/services/sync_merge_test.dart`
|
||
|
||
**Schnittstellen:**
|
||
- Nutzt: nichts (keine Imports außer Dart-Core).
|
||
- Liefert:
|
||
- `List<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 166–202
|
||
vollständig ersetzen durch die folgenden fünf Tests:
|
||
```dart
|
||
test('lokale Favoriten werden additiv gepusht, nie als Voll-Ersatz', () async {
|
||
await db.upsertSongs([
|
||
SongsCompanion.insert(
|
||
id: 'lokal-1',
|
||
path: '${tempDir.path}/fav.mp3',
|
||
title: 'Lieblingslied',
|
||
dateAddedMs: 0,
|
||
updatedAtMs: 0,
|
||
),
|
||
]);
|
||
await db.setCloudId('lokal-1', 'c5');
|
||
await db.setFavorite('lokal-1', true);
|
||
|
||
final gepusht = <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`
|
||
|
||
> **BLOCKIERT bis Dustin A6 beantwortet hat.**
|
||
> A6 = „Ist der irreversible Löschpfad gewollt?" (Spec, §OFFENE ENTSCHEIDUNG).
|
||
> Dieser Task gibt mehr Titeln eine cloudId und vergrößert damit genau die
|
||
> Angriffsfläche des Löschpfads. Lautet die Antwort **„nein"**, gehört der
|
||
> Server-Auftrag „Datei-Wiederherstellung im Dedup-Zweig von `upload()`"
|
||
> **davor**. Lautet sie **„ja"**, darf sofort begonnen werden.
|
||
> **Nicht ohne Antwort anfangen.**
|
||
|
||
**Dateien:**
|
||
- Ändern: `lib/services/sync_service.dart:143-155` (Feld `_abbruchGewuenscht`)
|
||
- Ändern: `lib/services/sync_service.dart` (neue öffentliche Methoden
|
||
`abbrechen()` und `ladeAusgewaehlteHoch(...)` hinter `synchronisiere()`)
|
||
- Test: `test/services/sync_service_test.dart` (neue Gruppe)
|
||
|
||
`_ladeHoch` und der Import `dart:async` werden hier **nicht** mehr angefasst —
|
||
beides steht seit Task 3 (siehe dortigen Kasten „Warum der Timeout-Fix hier
|
||
steht"). `import 'dart:async';` ist in Datei und Test damit schon vorhanden
|
||
und darf nicht ein zweites Mal ergänzt werden.
|
||
|
||
**Schnittstellen:**
|
||
- Nutzt: den bestehenden `cloud.hochladen(datei, dateiname: …)`-Pfad und
|
||
`db.setCloudId(songId, cloudId)`.
|
||
- Nutzt aus Task 3: `import 'dart:async';` in `sync_service.dart` und im Test
|
||
(für `TimeoutException`).
|
||
- Liefert:
|
||
- `class UploadErgebnis { final int hochgeladen; final int schonDa; final List<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"
|
||
|
||
> **BLOCKIERT bis Dustin A6 beantwortet hat** — dieselbe Begründung wie
|
||
> Task 5: die Aktion ist die Oberfläche zu `ladeAusgewaehlteHoch`.
|
||
|
||
**Dateien:**
|
||
- Ändern: `lib/shared/auswahl_leiste.dart:18-66` (`AuswahlLeiste` bekommt
|
||
`onServerLaden`) und Ende der Datei (neue Funktion `ladeAufServer`)
|
||
- Ändern: `lib/shared/sortable_song_list.dart:20-36` (neuer Parameter
|
||
`serverUpload`), `:60-68` (neue Methode), `:193-199` (`AuswahlLeiste`-Aufruf)
|
||
- Ändern: `lib/library/my_music_screen.dart:120-126` (`serverUpload: true`)
|
||
- Test: `test/shared/server_upload_aktion_test.dart` (neu)
|
||
|
||
**Schnittstellen:**
|
||
- Nutzt aus Task 5: `SyncService.ladeAusgewaehlteHoch(List<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
|
||
|
||
> **BLOCKIERT bis Dustin A6 beantwortet hat** — nur, weil die Spec Stufe 2
|
||
> (Feature 3 **und** 4) als Ganzes hinter die A6-Antwort stellt.
|
||
> **Ehrlicher Hinweis für Dustin:** Die inhaltliche Begründung der Spec
|
||
> („vergrößert die Angriffsfläche des Löschpfads, weil mehr Titel eine cloudId
|
||
> bekommen") trifft auf **Feature 4 nicht zu** — ein Download vergibt keine
|
||
> cloudId und meldet nichts zum Server. Dieser Task könnte also gefahrlos
|
||
> vorgezogen werden. Das zu entscheiden ist Dustins Sache, nicht die des
|
||
> Umsetzenden — bis dahin bleibt der Task blockiert.
|
||
|
||
**Dateien:**
|
||
- Ändern: `lib/services/download_service.dart:70-111` (neue Methode
|
||
`ladeEinzelnenTitel` hinter `lade`)
|
||
- Ändern: `lib/downloads/server_titel_screen.dart:39-70` (zwei neue Methoden
|
||
im State), `:127-133` (Zeilen-Aufruf), `:227-305` (`_Zeile` bekommt die
|
||
Knöpfe)
|
||
- Test: `test/services/download_service_test.dart` (neue Gruppe),
|
||
`test/downloads/einzel_song_offline_test.dart` (neu)
|
||
|
||
**Schnittstellen:**
|
||
- Nutzt: den bestehenden **öffentlichen** `DownloadService.lade(List<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 10–12 kosten spürbar Arbeit für
|
||
> einen Nutzen, den heute niemand hat. **Empfehlung: streichen oder
|
||
> vertagen, bis eine echte Playlist existiert.**
|
||
|
||
**Dateien:**
|
||
- Ändern: `lib/library/database.dart:66-77` (Tabelle `Playlists`)
|
||
- Ändern: `lib/library/database.dart:151` (`schemaVersion => 11`)
|
||
- Ändern: `lib/library/database.dart:154-192` (`onUpgrade`-Zweig `from < 11`)
|
||
- Ändern: `lib/library/database.dart` (neue Methoden hinter `deletePlaylist`)
|
||
- Test: `test/library/playlist_cloud_id_test.dart` (neu)
|
||
|
||
**Schnittstellen:**
|
||
- Nutzt: nichts aus früheren Tasks.
|
||
- Liefert:
|
||
- Spalte `Playlists.cloudId` (`TEXT NULL`)
|
||
- `Future<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 5–7 oder 10–12 **nicht** umgesetzt worden (weil noch
|
||
> blockiert), werden die zugehörigen Punkte aus dem Eintrag **gestrichen** —
|
||
> der Changelog beschreibt, was wirklich drin ist, nicht was geplant war.
|
||
> Wurden die Tasks 10–12 gebaut, kommt ein Punkt „🗂️ Playlisten werden
|
||
> einseitig am Server gesichert (kein Rück-Merge, Umbenennungen propagieren
|
||
> nicht)" dazu.
|
||
|
||
- [ ] **Schritt 5: Commit + Push**
|
||
```bash
|
||
cd /home/dustin/mello-dev/app
|
||
git add CHANGELOG.md
|
||
git commit -m "CHANGELOG: Sync-Ausbau (Favoriten-Fix, Auswahl-Upload, Einzel-Offline, Bericht)"
|
||
git push -u origin feature/sync-ausbau
|
||
```
|
||
|
||
---
|
||
|
||
## Offene Punkte, die vor bzw. während der Umsetzung an Dustin gehen
|
||
|
||
1. **A6 — irreversibler Löschpfad** (blockiert Tasks 5–7).
|
||
„Verschwindet eine lokale Datei, löscht der Abgleich sie am Server und aus
|
||
der Navidrome-Bibliothek; erneutes Hochladen repariert das nicht. Gewollt?"
|
||
*Nebenfrage:* Task 7 (Einzel-Song-Offline) vergibt keine cloudId und ist
|
||
von der Begründung sachlich nicht betroffen — darf er vorgezogen werden?
|
||
2. **Feature 2 (Playlist-Sicherung, Tasks 10–12).** Bauen oder streichen?
|
||
Heute 0 Playlisten am Server. Empfehlung: vertagen.
|
||
Falls bauen: hängt der Server-Fix `melo_cloud.py:505` wirklich davor,
|
||
obwohl Stufe 1 `DELETE /playlists` nie aufruft?
|
||
3. **Nicht Teil dieses Plans, nur zur Erinnerung:** Notification Stufe B,
|
||
SSE, beidseitiger Playlist-Merge und der Basis-Snapshot stehen unter
|
||
§Spätere Stufen der Spec und werden hier bewusst nicht angefasst.
|