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

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

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

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

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

142 KiB
Raw Permalink Blame History

Sync-Ausbau Implementation Plan

Für Umsetzende: PFLICHT-SUB-SKILL: superpowers:subagent-driven-development (empfohlen) oder superpowers:executing-plans zur task-weisen Umsetzung. Schritte nutzen Checkbox-Syntax (- [ ]).

Ziel: Die Favoriten-Synchronisation vom zerstörerischen Voll-Ersatz auf einen additiven Abgleich umstellen und darauf aufbauend gezielten Upload, Einzel-Song-Offline, einen „Was ist neu"-Bericht und eine einseitige Playlist-Sicherung ergänzen.

Architektur: Rückgrat bleiben SyncService und MeloCloudService; die reinen Entscheidungsfunktionen wandern in die neue, I/O-freie Datei lib/services/sync_merge.dart. Der Favoriten-Schreibweg wechselt von POST /favorites (Voll-Ersatz) auf POST /favorites/toggle mit deterministischem set:true — danach existiert kein Codepfad mehr, der den Server-Stand ersetzen kann. UI-Änderungen bleiben in den bestehenden Screens.

Tech-Stack: Flutter/Dart, Drift (SQLite), package:http + MockClient für Tests, SharedPreferences, Provider. Keine neue Dependency.

Spec: docs/superpowers/specs/2026-08-27-sync-ausbau-design.md


Globale Randbedingungen

  • Arbeitsverzeichnis ist ausschließlich /home/dustin/mello-dev/app. Nie in ~/mello-dev/worktrees/claude-server wechseln, dort nie committen.
  • Eigener Feature-Branch: git switch -c feature/sync-ausbau (Basis: fix/p0-vollwertigkeit). Nicht auf dem Basis-Branch committen.
  • Flutter: /home/dustin/development/flutter/bin/flutter, ANDROID_HOME=/home/dustin/Android.
  • Build ohne --dart-define: flutter build apk --target-platform android-arm64 muss ohne zusätzliche Definitionen durchlaufen. Es kommt in diesem Plan keine neue Dependency dazu — flutter_local_notifications und der Gradle-Umbau sind ausdrücklich Stufe B und nicht Teil dieses Plans.
  • flutter analyze muss sauber sein (0 Fehler, 0 Warnungen) — vor jedem Commit.
  • Testläufe: Einzelne Dateien laufen im Vordergrund (flutter test --no-pub test/services/sync_merge_test.dart, wenige Sekunden). Der vollständige Lauf (flutter test --no-pub) umfasst 600+ Tests und dauert mehrere Minuten — er wird im Hintergrund gestartet (run_in_background) und sein Ergebnis abgewartet. Kein kurzer Foreground-Timeout, sonst wird ein grüner Lauf als Fehlschlag gemeldet.
  • Ausgangslage: 602 Tests grün. Nach jedem Task muss diese Zahl gehalten oder erhöht sein. Zwei Bestandstests werden bewusst umgeschrieben (Task 2 und Task 3) — das ist kein Regressionsfehler, sondern Teil des Auftrags.
  • CHANGELOG-Pflicht: CHANGELOG.md unter ## [Unreleased], neuester Eintrag oben, Stil der bestehenden Einträge (Emoji-Bullets, einfach erklärt). Der Changelog wird mit dem Code committet. Sammel-Eintrag im letzten Task; einzelne Tasks brauchen keinen eigenen Eintrag.
  • Server-Endpunkt-Vertrag (nichts anderes darf benutzt werden):
    • GET {basis}/favorites{"favorites":[{"id":…}]} — Router verdrahtet hart HTTP 200, Fehler kommen im 200er-Körper.
    • POST {basis}/favorites/toggle mit {"song_id":…, "set":true|false} — deterministisch (melo_cloud.py:644-688).
    • POST {basis}/favorites (Voll-Ersatz) — verboten. Nach Task 3 existiert die Client-Methode nicht mehr.
    • Playlisten (nur Tasks 1012) — am Server nachgelesen, Stand melo_cloud.py 2026-08-27; diese Formen gelten, es wird nicht mehr „abgeglichen":
      • GET {basis}/playlists{"status":"ok","playlists":[{"id":…, "name":…,"created_at":…,"updated_at":…,"song_count":…}]} (handle_playlist_list:475). id ist eine Zahl, keine Zeichenkette — clientseitig interpoliert zu '$id'.
      • POST {basis}/playlists, Körper {"name":…} (Router liest d.get('name',''), :1254) → {"status":"ok","playlist":{"id":pid,…}} (handle_playlist_create:499). Die ID steckt unter playlist, nicht auf oberster Ebene. Leerer Name → {"status":"error","error":"Name erforderlich"} mit HTTP 200.
      • GET {basis}/playlists/<id>{"status":"ok","playlist":{…}, "songs":[{"id":…,"title":…,"position":…}],"count":…}, nach position sortiert (handle_playlist_get:511).
      • POST {basis}/playlists/<id>/songs, Körper {"song_ids":[…]} (Router :1272) → {"status":"ok","added":n}.
      • DELETE {basis}/playlists/<id>/songs/<sid> — alles im Pfad, kein Körper (Router :1274-1282) → {"status":"ok"}.
      • PUT {basis}/playlists/<id>/positions, Körper {"positions":[{"id":…,"position":n},…]} — der Router liest d.get('positions',[]) (:1288) und handle_playlist_update_positions (:585) greift je Eintrag auf sp.get("id") und sp.get("position") zu. Eine blanke ID-Liste unter song_ids käme als leere Liste an; der Server antwortete stumm {"status":"ok"} und änderte nichts.
      • Kein DELETE {basis}/playlists (siehe Task 11) und keinen Rename-Endpunkt — es gibt kein PUT/PATCH auf die Playlist selbst.
      • Zwei Handler melden „nicht gefunden" als {"status":"not_found"} ohne error-Schlüssel (handle_playlist_remove_song:569, handle_playlist_update_positions:585). In Stufe 1 ist das folgenlos — die Sicherung ist fire-and-forget und verwirft ohnehin jeden Fehler.
    • basis = MeloCloudService.basisUrl.
  • Keine Server-Änderungen aus diesem Plan heraus. /home/dustin/scripts/*.py wird nur gelesen. Server-Aufträge sind eigene Tickets.
  • Ponytail-Prinzip: minimale, robuste Lösungen. Drei Nutzer, ~325 Songs, 0 Favoriten und 0 Playlisten auf dem Server — nichts überbauen.
  • Reihenfolge ist bindend. Tasks 14 (Favoriten-Fix) hängen an nichts und kommen zuerst. Tasks 57 sind seit dem 2026-08-27 ENTBLOCKT — der Löschpfad ist serverseitig behoben und end-to-end verifiziert (siehe Spec, §ERLEDIGT). Tasks 1012 bleiben blockiert, aber nur noch durch die Produktfrage „wird Feature 2 überhaupt gebaut?" (0 Playlisten am Server) — Details bei Task 10.

Task 1: Reine Merge-Funktionen (sync_merge.dart)

Dateien:

  • Erstellen: lib/services/sync_merge.dart
  • Test: test/services/sync_merge_test.dart

Schnittstellen:

  • Nutzt: nichts (keine Imports außer Dart-Core).
  • Liefert:
    • List<String> zuPushendeFavoriten({required Set<String> lokaleFavoriten, required Map<String, String> cloudIdVon, required Set<String> amServer, int deckel = maxFavoritenPushes})
    • List<String> lokalZuSetzendeFavoriten({required Set<String> amServer, required Map<String, String> songIdVonCloudId, required Set<String> lokaleFavoriten})
    • bool berichtFaellig(DateTime? letzterErfolg, DateTime jetzt)
    • const int maxFavoritenPushes = 200

Bestehende Tests, die mitgeändert werden: keine.

  • Schritt 1: Fehlschlagenden Test schreiben

test/services/sync_merge_test.dart:

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):

/// 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
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-101expect(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:

  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:

  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):

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.dartparseFavoriten ersetzen:

  /// 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:

  /// 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
cd /home/dustin/mello-dev/app
git add lib/services/melo_cloud_service.dart test/services/melo_cloud_service_test.dart
git commit -m "Favoriten-GET gehaertet + deterministisches setzeFavorit"

Task 3: Additiver Favoriten-Abgleich im SyncService

Dateien:

  • Ändern: lib/services/sync_service.dart:1-11 (Importe dart:async und sync_merge.dart)
  • Ändern: lib/services/sync_service.dart:213 (Aufruf in synchronisiere())
  • Ändern: lib/services/sync_service.dart:299-320 (_ladeHoch: Zeitüber- schreitung wie einen Einzelfehler behandeln)
  • Ändern: lib/services/sync_service.dart:322-335 (_gleicheFavoritenAb vollständig ersetzen)
  • Ändern: lib/services/melo_cloud_service.dart — die Methode setzeFavoriten ersatzlos löschen (nach dieser Änderung ohne Aufrufer). Bewusst ohne Zeilenangabe: Task 2 fügt davor setzeFavorit ein und schiebt setzeFavoriten um rund 17 Zeilen nach unten — zur Ausführungszeit ist jede hier notierte Zeilennummer falsch. Nach dem Symbolnamen suchen.
  • Test: test/services/sync_service_test.dart:166-202 (umschreiben) + fünf neue Tests

Warum der Timeout-Fix hier steht und nicht in Task 5: Er gehörte in der Vorfassung zu Task 5 und war damit an die offene A6-Entscheidung gekettet — ohne jeden Grund. _ladeHoch läuft in jedem Abgleich, vergibt keine neue cloudId über das hinaus, was der bestehende Auto-Upload ohnehin tut, und hat mit dem irreversiblen Löschpfad nichts zu tun. Er kommt deshalb in den frühesten unblockierten Task, der sync_service.dart ohnehin anfasst. Task 5 setzt ihn voraus und ändert _ladeHoch selbst nicht mehr.

Schnittstellen:

  • Nutzt aus Task 1: zuPushendeFavoriten(...), lokalZuSetzendeFavoriten(...).
  • Nutzt aus Task 2: cloud.setzeFavorit(cloudId, true), gehärtetes cloud.favoriten().
  • Liefert: Future<bool> SyncService._gleicheFavoritenAb()true, wenn die Phase vollständig durchlief (Grundlage des Erfolgs-Flags in Task 8).
  • Liefert: _ladeHoch fängt zusätzlich TimeoutException. Die Signatur bleibt Future<void> — den Rückgabewert für das Erfolgs-Flag ergänzt Task 8, wo er auch ausgewertet wird.

Bestehende Tests, die mitgeändert werden müssen:

  • test/services/sync_service_test.dart:166-202 („Favoriten werden mit ihren Server-IDs gemeldet") prüft heute exakt das Full-Replace-Verhalten, das dieser Task beseitigt. Er wird auf den additiven Delta-Push umgeschrieben und bekommt eine explizite /favorites-Antwort. Der Catch-All-Mock (:196) der übrigen sieben Tests wird NICHT aufgeweicht, um die neue Sicherheitsregel zu umgehen — dort liefert GET /favorites weiterhin {'status':'ok'}, die Phase wird korrekt übersprungen, und genau das ist das gewünschte Verhalten. Diese sieben Tests bleiben unverändert und müssen grün bleiben.

  • Schritt 1: Fehlschlagenden Test schreiben

Am Kopf von test/services/sync_service_test.dart import 'dart:async'; ergänzen (für TimeoutException; dart:io ist schon da).

In test/services/sync_service_test.dart den Test in Zeile 166202 vollständig ersetzen durch die folgenden fünf Tests:

  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';):

import 'dart:async';
import 'sync_merge.dart';

_ladeHoch (heute :299-320) — den catch-Block um die Zeitüberschreitung erweitern:

      } 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:

  /// 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
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:

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:

  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:

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:

  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:

  // 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
cd /home/dustin/mello-dev/app
git add lib/library/playlist_service.dart lib/main.dart test/library/playlist_service_test.dart
git commit -m "Herz-Tipp meldet den Favoriten sofort deterministisch an die Cloud"

Task 5: ladeAusgewaehlteHoch + abbrechen() im SyncService

ENTBLOCKT seit 2026-08-27. Die frühere Blockade lautete: dieser Task gibt mehr Titeln eine cloudId und vergrößert damit die Angriffsfläche des irreversiblen Löschpfads. Dustins Antwort auf A6 war „nein, nicht gewollt"; Hermes hat den Fix am selben Tag eingebaut (Datei-Wiederherstellung im Dedup-Zweig von upload()), end-to-end verifiziert. Ein versehentlicher Löschvorgang ist jetzt durch erneutes Hochladen reparabel. Darf gebaut werden.

Dateien:

  • Ändern: lib/services/sync_service.dart:143-155 (Feld _abbruchGewuenscht)
  • Ändern: lib/services/sync_service.dart (neue öffentliche Methoden abbrechen() und ladeAusgewaehlteHoch(...) hinter synchronisiere())
  • Test: test/services/sync_service_test.dart (neue Gruppe)

_ladeHoch und der Import dart:async werden hier nicht mehr angefasst — beides steht seit Task 3 (siehe dortigen Kasten „Warum der Timeout-Fix hier steht"). import 'dart:async'; ist in Datei und Test damit schon vorhanden und darf nicht ein zweites Mal ergänzt werden.

Schnittstellen:

  • Nutzt: den bestehenden cloud.hochladen(datei, dateiname: …)-Pfad und db.setCloudId(songId, cloudId).
  • Nutzt aus Task 3: import 'dart:async'; in sync_service.dart und im Test (für TimeoutException).
  • Liefert:
    • class UploadErgebnis { final int hochgeladen; final int schonDa; final List<String> fehler; final bool abgebrochen; String get meldung; }
    • Future<UploadErgebnis> SyncService.ladeAusgewaehlteHoch(List<Song> songs)
    • void SyncService.abbrechen()

Bestehende Tests, die mitgeändert werden müssen: keine.

  • Schritt 1: Fehlschlagenden Test schreiben

Am Ende von test/services/sync_service_test.dart (vor der schließenden } von main) einfügen:

  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:

/// 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:

  bool _abbruchGewuenscht = false;

Hinter synchronisiere() einfügen:

  /// 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
cd /home/dustin/mello-dev/app
git add lib/services/sync_service.dart test/services/sync_service_test.dart
git commit -m "Gezielter Upload mit Abbrechen aus dem Auswahl-Modus"

Task 6: Auswahl-Modus-Aktion „Auf den Server laden"

ENTBLOCKT seit 2026-08-27 — dieselbe Begründung wie Task 5 (die Aktion ist die Oberfläche zu ladeAusgewaehlteHoch), und dieselbe Auflösung: Löschpfad serverseitig behoben. Darf gebaut werden.

Dateien:

  • Ändern: lib/shared/auswahl_leiste.dart:18-66 (AuswahlLeiste bekommt onServerLaden) und Ende der Datei (neue Funktion ladeAufServer)
  • Ändern: lib/shared/sortable_song_list.dart:20-36 (neuer Parameter serverUpload), :60-68 (neue Methode), :193-199 (AuswahlLeiste-Aufruf)
  • Ändern: lib/library/my_music_screen.dart:120-126 (serverUpload: true)
  • Test: test/shared/server_upload_aktion_test.dart (neu)

Schnittstellen:

  • Nutzt aus Task 5: SyncService.ladeAusgewaehlteHoch(List<Song>), UploadErgebnis.meldung.
  • Liefert:
    • AuswahlLeiste({… VoidCallback? onServerLaden}) — Knopf erscheint nur, wenn gesetzt.
    • SortableSongList({… bool serverUpload = false}) — Standard false, damit die Aktion nicht in den vier anderen Ansichten erscheint (favorites_screen.dart:28, playlists_screen.dart:166, titel_listen_screen.dart:30, und über titel_listen_screen die Kategorie-/Künstler-Listen).
    • Future<void> ladeAufServer(BuildContext context, List<Song> gewaehlte)

Bestehende Tests, die mitgeändert werden müssen: keine. test/shared/auswahl_modus_test.dart konstruiert SortableSongList ohne den neuen Parameter — durch den Standardwert false bleibt er gültig und muss grün bleiben.

  • Schritt 1: Fehlschlagenden Test schreiben

test/shared/server_upload_aktion_test.dart (neu):

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.dartAuswahlLeiste erweitern:

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:

          if (onServerLaden != null)
            IconButton(
              tooltip: 'Auf den Server laden',
              icon: const Icon(Icons.cloud_upload),
              onPressed: onServerLaden,
            ),

Am Ende derselben Datei ergänzen:

/// 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:

  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:

  Future<void> _aufServer(List<Song> gewaehlte) async {
    await ladeAufServer(context, gewaehlte);
    if (mounted) _beendeAuswahl();
  }

Und im AuswahlLeiste-Aufruf (heute :194-199):

          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:

                    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
cd /home/dustin/mello-dev/app
git add lib/shared/auswahl_leiste.dart lib/shared/sortable_song_list.dart lib/library/my_music_screen.dart test/shared/server_upload_aktion_test.dart
git commit -m "Auswahl-Modus: Auf den Server laden (nur in Meine Musik)"

Task 7: Einzel-Song-Offline

ENTBLOCKT seit 2026-08-27 (Löschpfad serverseitig behoben). Der Task war ohnehin nur mitblockiert, weil die Spec Stufe 2 als Ganzes hinter die A6-Antwort stellte — die inhaltliche Begründung traf auf Feature 4 nie zu: ein Download vergibt keine cloudId und meldet nichts zum Server. Darf gebaut werden.

Dateien:

  • Ändern: lib/services/download_service.dart:70-111 (neue Methode ladeEinzelnenTitel hinter lade)
  • Ändern: lib/downloads/server_titel_screen.dart:39-70 (zwei neue Methoden im State), :127-133 (Zeilen-Aufruf), :227-305 (_Zeile bekommt die Knöpfe)
  • Test: test/services/download_service_test.dart (neue Gruppe), test/downloads/einzel_song_offline_test.dart (neu)

Schnittstellen:

  • Nutzt: den bestehenden öffentlichen DownloadService.lade(List<SubsonicSong>) (Doppel-Lauf-Schutz :73, Verbindungsprüfung :77-84, Fortschritt :85-104) und DownloadService.entferne(navidromeId). Nicht _ladeEinen direkt verdrahten.
  • Liefert: Future<bool> DownloadService.ladeEinzelnenTitel(SubsonicSong song)true, wenn der Titel neu dazukam.

Bewusst nicht gebaut (dokumentierte Einschränkung, Spec erlaubt das ausdrücklich): Der Abgleich mit dem Abspiel-Zwischenspeicher entfällt. Ein gerade gehörter Titel liegt danach doppelt (Cache + Download), bis die LRU-Verdrängung die Cache-Kopie holt. Der Gegenschutz kostet einen CacheManager samt init() in DownloadService — für drei Nutzer zu teuer. Der Verzicht wird im Doc-Kommentar und im CHANGELOG genannt, nicht verschwiegen. Ebenfalls bewusst: kein Platz-Check und keine 30er-Rückfrage — der Knopf lädt genau einen Titel. Den Offline-Modus-Schalter beachtet er nicht (ausdrückliche Nutzeraktion, wie beim Auswahl-Upload).

Bestehende Tests, die mitgeändert werden müssen: keine.

  • Schritt 1: Fehlschlagenden Test schreiben

An test/services/download_service_test.dart anfügen (und oben import 'package:drift/native.dart';, import 'package:melo/library/database.dart';, import 'package:melo/services/navidrome_service.dart'; ergänzen):

  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):

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:

  /// 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:

  /// 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:

                        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:

  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:

            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
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:

  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:

/// 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):

  static const _letzterErfolgKey = 'cloud_sync_letzter_erfolg';

und bei den Feldern (heute :148):

  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:

  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:

    // 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:

  /// 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:

  /// 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:

  /// 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
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):

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):

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:

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:

  /// 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
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 / Reihenfolgekein Löschen einer Playlist. Ruft die App DELETE /playlists nie auf, greift der Bug nie. Das ändert nichts daran, dass der Server-Fix richtig ist, aber es könnte die Blockade auflösen. Vor Beginn klären.
  2. Ob Feature 2 überhaupt gebaut wird. Die Spec selbst nennt es „vertretbar und heute kostenlos, Feature 2 ganz herauszuschneiden" (0 Playlisten auf dem Server). Tasks 1012 kosten spürbar Arbeit für einen Nutzen, den heute niemand hat. Empfehlung: streichen oder vertagen, bis eine echte Playlist existiert.

Dateien:

  • Ändern: lib/library/database.dart:66-77 (Tabelle Playlists)
  • Ändern: lib/library/database.dart:151 (schemaVersion => 11)
  • Ändern: lib/library/database.dart:154-192 (onUpgrade-Zweig from < 11)
  • Ändern: lib/library/database.dart (neue Methoden hinter deletePlaylist)
  • Test: test/library/playlist_cloud_id_test.dart (neu)

Schnittstellen:

  • Nutzt: nichts aus früheren Tasks.
  • Liefert:
    • Spalte Playlists.cloudId (TEXT NULL)
    • Future<void> MeloDb.setPlaylistCloudId(String id, String cloudId)
    • Future<Playlist?> MeloDb.playlistById(String id)
    • Future<int> MeloDb.countPlaylists() (zählt inkl. Grabsteine — die Wiederherstellung darf auch nach einer gelöschten Playlist nicht greifen)

Bestehende Tests, die mitgeändert werden müssen: keine. test/library/database_playlists_test.dart legt Playlisten ohne cloudId an — die Spalte ist nullable, der Test muss unverändert grün bleiben. Nach flutter analyze ist zusätzlich /home/dustin/development/flutter/bin/dart run build_runner build --delete-conflicting-outputs nötig, damit database.g.dart die neue Spalte kennt.

  • Schritt 1: Fehlschlagenden Test schreiben

test/library/playlist_cloud_id_test.dart (neu):

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:

/// 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:

  @override
  int get schemaVersion => 11;

und im onUpgrade, hinter dem from < 10-Zweig:

          if (from < 11) {
            await m.addColumn(playlists, playlists.cloudId);
          }

Hinter deletePlaylist (heute :278-285) einfügen:

  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:

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
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):

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:

/// 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:

  /// 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
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):

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:

  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:

  /// 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:

  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.

  _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
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):

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
cd /home/dustin/mello-dev/app
/home/dustin/development/flutter/bin/flutter analyze

Erwartet: No issues found!

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
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):

### 💾❤️ Sync-Ausbau: Favoriten gehen nicht mehr verloren (2026-08-27)

- ❤️ **Der Favoriten-Datenverlust ist behoben.** Bisher hat jedes Gerät beim
  Abgleich seine eigene Favoritenliste als Komplett-Ersatz zum Server
  geschickt — ein frisch installiertes Handy löschte damit beim allerersten
  Abgleich sämtliche Server-Favoriten. Neu wird nur noch **hinzugefügt**:
  Was hier Favorit ist und dort fehlt, wird einzeln gemeldet; was dort
  Favorit ist und hier fehlt, wird hier gesetzt. Entfernt wird in keiner
  Richtung etwas. Der alte Weg (`POST /favorites`) existiert im Code nicht
  mehr — der Fehler kann also nicht zurückkommen.
- 💔 **Bewusster Preis:** Ein entferntes Herz wirkt sofort auf diesem Gerät
  und (online) auch am Server, ist aber **nicht geräteübergreifend
  garantiert**: Hält ein zweites Gerät den Favoriten noch, bringt dessen
  nächster Abgleich ihn zurück. Kein Datenverlust ist uns wichtiger als
  verlässliches Löschen.
-**Herz antippen meldet sofort.** Wer online ein Herz setzt oder entfernt,
  schickt den Wunsch direkt zum Server — eindeutig als „setze auf ja/nein",
  nicht als Umschalten. Geht das schief, passiert nichts Schlimmes: der
  nächste Abgleich holt es nach.
- 🛡️ **Der Server darf sich nicht mehr missverständlich ausdrücken.** Kommt
  auf die Favoriten-Abfrage eine Antwort ohne Favoritenliste, gilt das jetzt
  als Fehler und die Favoriten-Runde wird übersprungen — vorher wurde daraus
  stillschweigend „keine Favoriten".
- ☁️ **Neu: „Auf den Server laden".** Im Auswahl-Modus von „Meine Musik"
  (langes Drücken) lassen sich einzelne Titel markieren und gezielt
  hochladen — mit Fortschritt und **Abbrechen**. Titel, die schon oben sind,
  werden übersprungen; einzelne Fehlschläge stoppen den Rest nicht. Die
  Aktion erscheint bewusst nur dort und nicht bei Favoriten,
  Wiedergabelisten oder Titellisten.
- ⬇️ **Neu: einzelne Server-Titel offline nehmen.** In der Album- und
  Künstler-Ansicht hat jede Zeile jetzt einen Knopf — bisher ging nur „ganzes
  Album". Schon geladene Titel lassen sich dort auch wieder entfernen.
  *Bekannte Einschränkung:* Ein gerade gehörter Titel liegt danach kurzzeitig
  doppelt (Zwischenspeicher + Download), bis der Zwischenspeicher aufräumt.
- 📰 **Neu: „Willkommen zurück".** War der letzte **erfolgreiche** Abgleich
  mehr als 24 Stunden her, zeigt die App danach einmalig, was dazugekommen
  und was verschwunden ist. Beim allerersten Start nach einer Neuinstallation
  erscheint er absichtlich **nicht**.
- 🔧 **Unter der Haube:** Der Abgleichs-Zeitstempel wird jetzt **vor** dem
  Abfragen der Serverliste genommen (sonst fallen Änderungen während des Laufs
  durchs Raster); ein hängender Upload (Zeitüberschreitung) reißt nicht mehr
  den ganzen Abgleich ab; ausgefallene Teilschritte verschieben die
  24-Stunden-Uhr des Berichts nicht mehr.
- 🧪 Neue Tests für die Merge-Regeln, den additiven Abgleich, den Sofort-Push,
  den Auswahl-Upload samt Abbrechen und den Bericht. Zwei bestehende Tests
  wurden bewusst umgeschrieben, weil sie das alte (fehlerhafte) Verhalten
  festschrieben.

Sind die Tasks 57 oder 1012 nicht umgesetzt worden (weil noch blockiert), werden die zugehörigen Punkte aus dem Eintrag gestrichen — der Changelog beschreibt, was wirklich drin ist, nicht was geplant war. Wurden die Tasks 1012 gebaut, kommt ein Punkt „🗂️ Playlisten werden einseitig am Server gesichert (kein Rück-Merge, Umbenennungen propagieren nicht)" dazu.

  • Schritt 5: Commit + Push
cd /home/dustin/mello-dev/app
git add CHANGELOG.md
git commit -m "CHANGELOG: Sync-Ausbau (Favoriten-Fix, Auswahl-Upload, Einzel-Offline, Bericht)"
git push -u origin feature/sync-ausbau

Offene Punkte, die vor bzw. während der Umsetzung an Dustin gehen

  1. A6 — irreversibler Löschpfad ERLEDIGT 2026-08-27. Dustins Antwort: nicht gewollt. Hermes hat die Datei-Wiederherstellung im Dedup-Zweig von upload() eingebaut, end-to-end verifiziert (Upload → Löschung → erneuter Upload stellt beide Kopien bitgenau wieder her). Tasks 57 sind damit entblockt. Offen geblieben, aber kein Blocker: Die App tombstoned weiterhin großzügig (nicht eingehängte SD-Karte genügt) und die Lösch-Bremse greift erst ab 109 von 325 Titeln — der Schaden ist jetzt nur reparabel, nicht verhindert. Ob die App vorsichtiger werden soll, ist eine eigene Frage.
  2. Feature 2 (Playlist-Sicherung, Tasks 1012). Bauen oder streichen? Heute 0 Playlisten am Server. Empfehlung: vertagen. Falls bauen: hängt der Server-Fix melo_cloud.py:505 wirklich davor, obwohl Stufe 1 DELETE /playlists nie aufruft?
  3. Nicht Teil dieses Plans, nur zur Erinnerung: Notification Stufe B, SSE, beidseitiger Playlist-Merge und der Basis-Snapshot stehen unter §Spätere Stufen der Spec und werden hier bewusst nicht angefasst.