diff --git a/CHANGELOG.md b/CHANGELOG.md index 739cd5f..e10a133 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,55 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/). ## [Unreleased] +### đŸ’Ÿâ€ïž 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. 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. +- đŸ—‚ïž **Playlisten werden einseitig am Server gesichert** (kein RĂŒck-Merge, + Umbenennungen propagieren nicht). +- 📰 **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. + ### đŸ“ș🔑 YouTube-Tab: Server-User brauchen keine eigene Baka-Anmeldung mehr (2026-08-26) - 🔑 **Server-User (Dustin, Baka, Tinker) sehen den Baka-Anmelde-Schritt nicht diff --git a/SYNC_PLAN.md b/SYNC_PLAN.md deleted file mode 100644 index 9cec544..0000000 --- a/SYNC_PLAN.md +++ /dev/null @@ -1,88 +0,0 @@ -# Sync-Ausbau — Kontext-Sicherung (2026-08-27) - -Diese Datei ist eine Übergabe fĂŒr eine neue Session. Sie ersetzt nicht die -Spec/den Plan, sondern sagt: wo stehen wir, was ist als NĂ€chstes dran. - -**Wenn diese Datei erledigt ist (Implementierung fertig oder Plan ĂŒberholt): -löschen, nicht liegen lassen — sie ist kein Dauerdokument.** - -## Die zwei maßgeblichen Dokumente - -1. **Spec:** `docs/superpowers/specs/2026-08-27-sync-ausbau-design.md` - Status: **Freigegeben, umsetzungsbereit.** Durch ein 5-Reviewer-Panel - (agent-review-panel) geprĂŒft, nachgeschĂ€rft, gegengelesen. -2. **Implementierungsplan:** `docs/superpowers/plans/2026-08-27-sync-ausbau.md` - 13 Tasks nach TDD (echter Code in jedem Schritt, kein Platzhalter), - geprĂŒft und korrigiert. - -Beide sind committed und gepusht auf Branch `fix/p0-vollwertigkeit` -(bis Commit `1940e2b7909`). - -## Was das Feature ist (kurz) - -Sync-Ausbau der Melo-App: Favoriten-Merge-Fix (Datenverlust-Bug), -gezielter Song-Upload im Auswahl-Modus, Einzel-Song-Offline, -Playlist-Sicherung (einseitig), „Was ist neu"-Bericht. SSE, persistente -Notification und beidseitiger Playlist-Merge sind bewusst spĂ€tere Stufen, -nicht Teil dieser Runde. - -## Aktueller Stand — was schon passiert ist - -1. Bestandsaufnahme (6 parallele Leser ĂŒber App/Server/Navidrome/Cross-Platform/Melo-v2). -2. Brainstorming + Spec geschrieben, mit Dustin abgestimmt. -3. **agent-review-panel** (5 Reviewer, 2 Debattenrunden, Audit, Verifikation, - Richterurteil): Score 6/10, Verdikt „nachschĂ€rfen dann freigeben". Bericht: - `docs/reviews/2026-08-27-sync-ausbau/review_panel_report.md`. -4. Spec nach den 17 Aktionspunkten des Urteils ĂŒberarbeitet, gegengelesen, - freigegeben. -5. Implementierungsplan geschrieben, geprĂŒft (fand 3 echte - Server-Vertragsfehler in Task 11 — korrigiert), Korrekturrunde committed. -6. **Der einzige echte Blocker ist behoben:** Ein irreversibler Löschpfad in - `melo_cloud.py` (verschwindet eine lokale Datei, wurde sie serverseitig - endgĂŒltig gelöscht, erneutes Hochladen reparierte es nicht). Hermes hat - den Fix eingebaut (Datei-Wiederherstellung im Dedup-Zweig von `upload()`), - ich habe ihn end-to-end getestet (eigene Testdatei, Fake-Nutzer, danach - restlos aufgerĂ€umt — Registry vor/nach Test bei 325 Titeln): Upload → - Löschung (Bug reproduziert, Download 404) → erneuter Upload → **beide - Dateien bitgenau wiederhergestellt.** Spec und Plan sind entsprechend - aktualisiert (§ERLEDIGT in der Spec, ENTBLOCKT-Marker im Plan). - -## NĂ€chster Schritt - -**Implementierungsplan, Task 1: „Reine Merge-Funktionen (`sync_merge.dart`)“** -(Zeile ~104 im Plan). Tasks 1–9 sind vollstĂ€ndig entblockt und können sofort -starten, in der im Plan festgelegten Reihenfolge (Favoriten-Fix zuerst). - -Empfohlener Einstieg fĂŒr die neue Session: die Skill -`superpowers:subagent-driven-development` (Pflicht-Sub-Skill laut Plankopf) -fĂŒr die task-weise Umsetzung nutzen, wie schon beim vorherigen Feature -(YouTube-Gast-Zugang) erfolgreich gemacht. - -## Was noch offen ist (kein Blocker, aber zu klĂ€ren) - -- **Tasks 10–12 (Playlist-Sicherung, Feature 2):** bauen oder streichen? - Heute 0 Playlisten auf dem Server. Plan empfiehlt: vertagen, bis eine - echte Playlist existiert. Steht im Plan unter „Offene Punkte, die vor - bzw. wĂ€hrend der Umsetzung an Dustin gehen". -- **Server-Hygiene (an Hermes, eilt nicht):** `_link_user()` im Dedup-Zweig - von `upload()` legt einen dritten Hardlink unter `users//.mp3` - an, den `_entferne_datei_wenn_verwaist()` beim Löschen nicht mit abrĂ€umt. - Bisher folgenlos, aber seit dem Wiederherstellungs-Fix hinterlĂ€sst jeder - Dedup-Upload einen verwaisten Hardlink. -- **App-Seite, bewusst unangetastet:** `markMissing` tombstoned großzĂŒgig, - die Lösch-Bremse greift erst ab 109 von 325 Titeln. Der Schaden ist jetzt - reparabel (siehe oben), also kein Blocker mehr — ob die App trotzdem - vorsichtiger werden soll, ist eine eigene, spĂ€tere Entscheidung. - -## Regeln, die fĂŒr die neue Session gelten (aus Erfahrung dieser Session) - -- Worktree-Lock beachten: `.claude-worktree-lock` im Repo-Root prĂŒfen/setzen/ - entfernen (siehe `melo-app-workflow`-Skill). -- `flutter test` im Hintergrund laufen lassen (600+ Tests, mehrere Minuten, - kein kurzer Foreground-Timeout). -- Vor jedem Commit: QualitĂ€ts-Gate (analyze + test) lĂ€uft automatisch als - Pre-Commit-Hook — das ist schon eingerichtet, nichts zu tun. -- CHANGELOG.md nach jeder Aufgabe aktualisieren (Projektregel). -- Bei fertigem Arbeitspaket: melden und stoppen, nicht autonom weitermachen - (Dustin startet dann bewusst eine frische Session — siehe - Memory `feedback_melden-und-stoppen`). diff --git a/lib/downloads/server_titel_screen.dart b/lib/downloads/server_titel_screen.dart index 84f52eb..4ad7808 100644 --- a/lib/downloads/server_titel_screen.dart +++ b/lib/downloads/server_titel_screen.dart @@ -5,6 +5,7 @@ import 'package:provider/provider.dart'; import '../library/database.dart'; import '../library/song_media.dart'; import '../player/audio_handler.dart'; +import '../services/download_service.dart'; import '../services/navidrome_service.dart'; import '../shared/cover.dart'; import '../shared/lauf_balken.dart'; @@ -69,6 +70,45 @@ class _ServerTitelScreenState extends State { } } + /// Nach Laden oder Entfernen ist der Einmal-Schnappschuss [_geladen] veraltet + /// — hier wird er nachgezogen. + Future _aktualisiereGeladen() async { + final geladen = await context.read().downloadIds(); + if (mounted) setState(() => _geladen = geladen); + } + + Future _offlineNehmen(SubsonicSong song) async { + final dienst = context.read(); + final messenger = ScaffoldMessenger.of(context); + if (dienst.laeuft) { + messenger.showSnackBar( + const SnackBar(content: Text('Es lĂ€uft schon ein Download')), + ); + return; + } + final neu = await dienst.ladeEinzelnenTitel(song); + await _aktualisiereGeladen(); + if (!mounted) return; + messenger.showSnackBar(SnackBar( + content: Text(neu + ? 'Offline: ${song.titel}' + : (dienst.fehler ?? 'War schon heruntergeladen')), + )); + } + + Future _offlineEntfernen(SubsonicSong song) async { + final dienst = context.read(); + final messenger = ScaffoldMessenger.of(context); + final weg = await dienst.entferne(song.id); + await _aktualisiereGeladen(); + if (!mounted) return; + messenger.showSnackBar(SnackBar( + content: Text(weg + ? 'Vom GerĂ€t entfernt: ${song.titel}' + : (dienst.fehler ?? 'Ließ sich nicht entfernen')), + )); + } + Future _spiele(List songs, int ab) async { final handler = context.read(); final messenger = ScaffoldMessenger.of(context); @@ -130,6 +170,8 @@ class _ServerTitelScreenState extends State { nummer: i, geladen: _geladen.contains(song.id), onTap: () => _spiele(songs, i - 1), + onOffline: () => _offlineNehmen(song), + onEntfernen: () => _offlineEntfernen(song), ); }, ), @@ -230,12 +272,16 @@ class _Zeile extends StatelessWidget { 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; static String _dauer(int sekunden) { final m = (sekunden ~/ 60).toString(); @@ -286,12 +332,21 @@ class _Zeile extends StatelessWidget { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - if (geladen) - const Padding( - padding: EdgeInsets.only(right: MeloSpace.sm), - child: Icon(Icons.download_done, - size: 16, color: MeloTheme.text3), - ), + // 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)), ], diff --git a/lib/library/database.dart b/lib/library/database.dart index f3fc010..dc50242 100644 --- a/lib/library/database.dart +++ b/lib/library/database.dart @@ -72,6 +72,10 @@ class Playlists extends Table { IntColumn get updatedAtMs => integer()(); BoolColumn get deleted => boolean().withDefault(const Constant(false))(); + /// ID derselben Playlist am Melo-Server, sobald sie einmal gesichert + /// wurde. `null` heißt: nur auf diesem GerĂ€t. + TextColumn get cloudId => text().nullable()(); + @override Set get primaryKey => {id}; } @@ -148,7 +152,7 @@ class MeloDb extends _$MeloDb { MeloDb([QueryExecutor? executor]) : super(executor ?? _open()); @override - int get schemaVersion => 10; + int get schemaVersion => 11; @override MigrationStrategy get migration => MigrationStrategy( @@ -190,6 +194,9 @@ class MeloDb extends _$MeloDb { if (from < 10) { await m.createTable(downloads); } + if (from < 11) { + await m.addColumn(playlists, playlists.cloudId); + } }, ); @@ -284,6 +291,26 @@ class MeloDb extends _$MeloDb { ); } + Future playlistById(String id) => + (select(playlists)..where((p) => p.id.equals(id))).getSingleOrNull(); + + Future setPlaylistCloudId(String id, String cloudId) async { + await (update(playlists)..where((p) => p.id.equals(id))) + .write(PlaylistsCompanion(cloudId: Value(cloudId))); + } + + /// Wie viele Playlisten es hier gibt — **inklusive Grabsteinen**. + /// + /// Grundlage der Wiederherstellung: nur eine wirklich leere Tabelle gilt + /// als Neuinstallation. Wer alle Playlisten selbst gelöscht hat, soll sie + /// nicht vom Server zurĂŒckbekommen. + Future countPlaylists() async { + final zaehler = playlists.id.count(); + final zeile = await (selectOnly(playlists)..addColumns([zaehler])) + .getSingle(); + return zeile.read(zaehler) ?? 0; + } + // === PlaylistSongs === Stream> watchPlaylistSongs(String playlistId) { final query = select(songs).join([ @@ -519,6 +546,14 @@ class MeloDb extends _$MeloDb { .write(SongsCompanion(cloudId: Value(cloudId))); } + Future songByCloudId(String cloudId) async { + final treffer = await (select(songs) + ..where((s) => s.cloudId.equals(cloudId)) + ..limit(1)) + .get(); + return treffer.isEmpty ? null : treffer.single; + } + /// Titel, die am Server gelöscht wurden, auch auf dem GerĂ€t als gelöscht /// markieren. Grabstein statt echtem Löschen — sonst legt der nĂ€chste Scan /// sie wieder an. diff --git a/lib/library/database.g.dart b/lib/library/database.g.dart index f3edabd..3880634 100644 --- a/lib/library/database.g.dart +++ b/lib/library/database.g.dart @@ -1322,6 +1322,17 @@ class $PlaylistsTable extends Playlists ), defaultValue: const Constant(false), ); + static const VerificationMeta _cloudIdMeta = const VerificationMeta( + 'cloudId', + ); + @override + late final GeneratedColumn cloudId = GeneratedColumn( + 'cloud_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override List get $columns => [ id, @@ -1330,6 +1341,7 @@ class $PlaylistsTable extends Playlists createdAtMs, updatedAtMs, deleted, + cloudId, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -1393,6 +1405,12 @@ class $PlaylistsTable extends Playlists deleted.isAcceptableOrUnknown(data['deleted']!, _deletedMeta), ); } + if (data.containsKey('cloud_id')) { + context.handle( + _cloudIdMeta, + cloudId.isAcceptableOrUnknown(data['cloud_id']!, _cloudIdMeta), + ); + } return context; } @@ -1426,6 +1444,10 @@ class $PlaylistsTable extends Playlists DriftSqlType.bool, data['${effectivePrefix}deleted'], )!, + cloudId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}cloud_id'], + ), ); } @@ -1442,6 +1464,10 @@ class Playlist extends DataClass implements Insertable { final int createdAtMs; final int updatedAtMs; final bool deleted; + + /// ID derselben Playlist am Melo-Server, sobald sie einmal gesichert + /// wurde. `null` heißt: nur auf diesem GerĂ€t. + final String? cloudId; const Playlist({ required this.id, required this.name, @@ -1449,6 +1475,7 @@ class Playlist extends DataClass implements Insertable { required this.createdAtMs, required this.updatedAtMs, required this.deleted, + this.cloudId, }); @override Map toColumns(bool nullToAbsent) { @@ -1461,6 +1488,9 @@ class Playlist extends DataClass implements Insertable { map['created_at_ms'] = Variable(createdAtMs); map['updated_at_ms'] = Variable(updatedAtMs); map['deleted'] = Variable(deleted); + if (!nullToAbsent || cloudId != null) { + map['cloud_id'] = Variable(cloudId); + } return map; } @@ -1474,6 +1504,9 @@ class Playlist extends DataClass implements Insertable { createdAtMs: Value(createdAtMs), updatedAtMs: Value(updatedAtMs), deleted: Value(deleted), + cloudId: cloudId == null && nullToAbsent + ? const Value.absent() + : Value(cloudId), ); } @@ -1489,6 +1522,7 @@ class Playlist extends DataClass implements Insertable { createdAtMs: serializer.fromJson(json['createdAtMs']), updatedAtMs: serializer.fromJson(json['updatedAtMs']), deleted: serializer.fromJson(json['deleted']), + cloudId: serializer.fromJson(json['cloudId']), ); } @override @@ -1501,6 +1535,7 @@ class Playlist extends DataClass implements Insertable { 'createdAtMs': serializer.toJson(createdAtMs), 'updatedAtMs': serializer.toJson(updatedAtMs), 'deleted': serializer.toJson(deleted), + 'cloudId': serializer.toJson(cloudId), }; } @@ -1511,6 +1546,7 @@ class Playlist extends DataClass implements Insertable { int? createdAtMs, int? updatedAtMs, bool? deleted, + Value cloudId = const Value.absent(), }) => Playlist( id: id ?? this.id, name: name ?? this.name, @@ -1518,6 +1554,7 @@ class Playlist extends DataClass implements Insertable { createdAtMs: createdAtMs ?? this.createdAtMs, updatedAtMs: updatedAtMs ?? this.updatedAtMs, deleted: deleted ?? this.deleted, + cloudId: cloudId.present ? cloudId.value : this.cloudId, ); Playlist copyWithCompanion(PlaylistsCompanion data) { return Playlist( @@ -1533,6 +1570,7 @@ class Playlist extends DataClass implements Insertable { ? data.updatedAtMs.value : this.updatedAtMs, deleted: data.deleted.present ? data.deleted.value : this.deleted, + cloudId: data.cloudId.present ? data.cloudId.value : this.cloudId, ); } @@ -1544,14 +1582,22 @@ class Playlist extends DataClass implements Insertable { ..write('description: $description, ') ..write('createdAtMs: $createdAtMs, ') ..write('updatedAtMs: $updatedAtMs, ') - ..write('deleted: $deleted') + ..write('deleted: $deleted, ') + ..write('cloudId: $cloudId') ..write(')')) .toString(); } @override - int get hashCode => - Object.hash(id, name, description, createdAtMs, updatedAtMs, deleted); + int get hashCode => Object.hash( + id, + name, + description, + createdAtMs, + updatedAtMs, + deleted, + cloudId, + ); @override bool operator ==(Object other) => identical(this, other) || @@ -1561,7 +1607,8 @@ class Playlist extends DataClass implements Insertable { other.description == this.description && other.createdAtMs == this.createdAtMs && other.updatedAtMs == this.updatedAtMs && - other.deleted == this.deleted); + other.deleted == this.deleted && + other.cloudId == this.cloudId); } class PlaylistsCompanion extends UpdateCompanion { @@ -1571,6 +1618,7 @@ class PlaylistsCompanion extends UpdateCompanion { final Value createdAtMs; final Value updatedAtMs; final Value deleted; + final Value cloudId; final Value rowid; const PlaylistsCompanion({ this.id = const Value.absent(), @@ -1579,6 +1627,7 @@ class PlaylistsCompanion extends UpdateCompanion { this.createdAtMs = const Value.absent(), this.updatedAtMs = const Value.absent(), this.deleted = const Value.absent(), + this.cloudId = const Value.absent(), this.rowid = const Value.absent(), }); PlaylistsCompanion.insert({ @@ -1588,6 +1637,7 @@ class PlaylistsCompanion extends UpdateCompanion { required int createdAtMs, required int updatedAtMs, this.deleted = const Value.absent(), + this.cloudId = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), name = Value(name), @@ -1600,6 +1650,7 @@ class PlaylistsCompanion extends UpdateCompanion { Expression? createdAtMs, Expression? updatedAtMs, Expression? deleted, + Expression? cloudId, Expression? rowid, }) { return RawValuesInsertable({ @@ -1609,6 +1660,7 @@ class PlaylistsCompanion extends UpdateCompanion { if (createdAtMs != null) 'created_at_ms': createdAtMs, if (updatedAtMs != null) 'updated_at_ms': updatedAtMs, if (deleted != null) 'deleted': deleted, + if (cloudId != null) 'cloud_id': cloudId, if (rowid != null) 'rowid': rowid, }); } @@ -1620,6 +1672,7 @@ class PlaylistsCompanion extends UpdateCompanion { Value? createdAtMs, Value? updatedAtMs, Value? deleted, + Value? cloudId, Value? rowid, }) { return PlaylistsCompanion( @@ -1629,6 +1682,7 @@ class PlaylistsCompanion extends UpdateCompanion { createdAtMs: createdAtMs ?? this.createdAtMs, updatedAtMs: updatedAtMs ?? this.updatedAtMs, deleted: deleted ?? this.deleted, + cloudId: cloudId ?? this.cloudId, rowid: rowid ?? this.rowid, ); } @@ -1654,6 +1708,9 @@ class PlaylistsCompanion extends UpdateCompanion { if (deleted.present) { map['deleted'] = Variable(deleted.value); } + if (cloudId.present) { + map['cloud_id'] = Variable(cloudId.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -1669,6 +1726,7 @@ class PlaylistsCompanion extends UpdateCompanion { ..write('createdAtMs: $createdAtMs, ') ..write('updatedAtMs: $updatedAtMs, ') ..write('deleted: $deleted, ') + ..write('cloudId: $cloudId, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -4216,6 +4274,7 @@ typedef $$PlaylistsTableCreateCompanionBuilder = required int createdAtMs, required int updatedAtMs, Value deleted, + Value cloudId, Value rowid, }); typedef $$PlaylistsTableUpdateCompanionBuilder = @@ -4226,6 +4285,7 @@ typedef $$PlaylistsTableUpdateCompanionBuilder = Value createdAtMs, Value updatedAtMs, Value deleted, + Value cloudId, Value rowid, }); @@ -4291,6 +4351,11 @@ class $$PlaylistsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get cloudId => $composableBuilder( + column: $table.cloudId, + builder: (column) => ColumnFilters(column), + ); + Expression playlistSongsRefs( Expression Function($$PlaylistSongsTableFilterComposer f) f, ) { @@ -4355,6 +4420,11 @@ class $$PlaylistsTableOrderingComposer column: $table.deleted, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get cloudId => $composableBuilder( + column: $table.cloudId, + builder: (column) => ColumnOrderings(column), + ); } class $$PlaylistsTableAnnotationComposer @@ -4390,6 +4460,9 @@ class $$PlaylistsTableAnnotationComposer GeneratedColumn get deleted => $composableBuilder(column: $table.deleted, builder: (column) => column); + GeneratedColumn get cloudId => + $composableBuilder(column: $table.cloudId, builder: (column) => column); + Expression playlistSongsRefs( Expression Function($$PlaylistSongsTableAnnotationComposer a) f, ) { @@ -4450,6 +4523,7 @@ class $$PlaylistsTableTableManager Value createdAtMs = const Value.absent(), Value updatedAtMs = const Value.absent(), Value deleted = const Value.absent(), + Value cloudId = const Value.absent(), Value rowid = const Value.absent(), }) => PlaylistsCompanion( id: id, @@ -4458,6 +4532,7 @@ class $$PlaylistsTableTableManager createdAtMs: createdAtMs, updatedAtMs: updatedAtMs, deleted: deleted, + cloudId: cloudId, rowid: rowid, ), createCompanionCallback: @@ -4468,6 +4543,7 @@ class $$PlaylistsTableTableManager required int createdAtMs, required int updatedAtMs, Value deleted = const Value.absent(), + Value cloudId = const Value.absent(), Value rowid = const Value.absent(), }) => PlaylistsCompanion.insert( id: id, @@ -4476,6 +4552,7 @@ class $$PlaylistsTableTableManager createdAtMs: createdAtMs, updatedAtMs: updatedAtMs, deleted: deleted, + cloudId: cloudId, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/library/my_music_screen.dart b/lib/library/my_music_screen.dart index 684b4bb..9864d9b 100644 --- a/lib/library/my_music_screen.dart +++ b/lib/library/my_music_screen.dart @@ -120,6 +120,7 @@ class _MyMusicScreenState extends State { return SortableSongList( songs: songs, storeKey: SortStore.meineMusik, + serverUpload: true, empty: lib.scanning ? const SizedBox.shrink() : const _Empty(), ); diff --git a/lib/library/playlist_service.dart b/lib/library/playlist_service.dart index 4e2bcca..2b0ba5f 100644 --- a/lib/library/playlist_service.dart +++ b/lib/library/playlist_service.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import '../services/melo_cloud_service.dart'; import '../services/navidrome_service.dart'; import 'database.dart'; @@ -6,14 +7,22 @@ import 'database.dart'; /// 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}) - : _navidrome = navidrome ?? NavidromeService(); + // `this._cloud` wĂŒrde den öffentlichen Parameternamen `cloud` zu `_cloud` + // (privat) Ă€ndern und die API brechen. + PlaylistService(this.db, {NavidromeService? navidrome, MeloCloudService? cloud}) + : _navidrome = navidrome ?? NavidromeService(), + // ignore: prefer_initializing_formals + _cloud = cloud; final MeloDb db; final NavidromeService _navidrome; + /// Optional: ohne Cloud-Zugang entfĂ€llt der Sofort-Push stillschweigend. + final MeloCloudService? _cloud; + Future createPlaylist(String name, {String? description}) async { final id = await db.createPlaylist(name, description: description); notifyListeners(); + await _sichereNeuePlaylist(id, name); return id; } @@ -22,29 +31,64 @@ class PlaylistService extends ChangeNotifier { notifyListeners(); } - Future addSongToPlaylist(String playlistId, String songId, int position) async { + Future addSongToPlaylist( + String playlistId, String songId, int position) async { await db.addSongToPlaylist(playlistId, songId, position); notifyListeners(); + final cloudId = await _cloudIdDerPlaylist(playlistId); + final songCloudId = (await db.songById(songId))?.cloudId; + if (cloudId == null || songCloudId == null) return; + await _still(() => + _cloud!.fuegePlaylistSongsHinzu(cloudId, [songCloudId])); } - Future removeSongFromPlaylist(String playlistId, String songId) async { + Future removeSongFromPlaylist( + String playlistId, String songId) async { + final songCloudId = (await db.songById(songId))?.cloudId; await db.removeSongFromPlaylist(playlistId, songId); notifyListeners(); + final cloudId = await _cloudIdDerPlaylist(playlistId); + if (cloudId == null || songCloudId == null) return; + await _still(() => _cloud!.entfernePlaylistSong(cloudId, songCloudId)); } - Future reorderSong(String playlistId, String songId, int newPosition) async { + Future reorderSong( + String playlistId, String songId, int newPosition) async { await db.reorderPlaylistSong(playlistId, songId, newPosition); notifyListeners(); + await _sichereReihenfolge(playlistId); } - Future reorderAll(String playlistId, List orderedSongIds) async { + Future reorderAll( + String playlistId, List orderedSongIds) async { await db.reorderAllPlaylistSongs(playlistId, orderedSongIds); notifyListeners(); + await _sichereReihenfolge(playlistId); } Future toggleFavorite(String songId) async { await db.toggleFavorite(songId); notifyListeners(); + await _meldeFavorit(songId); + } + + /// Meldet den neuen Favoriten-Stand sofort an die Melo-Cloud. + /// + /// Deterministisch (`set:true/false`), nicht als Umschalten: ein + /// abweichender Server-Stand darf den Wunsch nicht invertieren. Nur fĂŒr + /// Titel mit cloudId, und Fehler werden still geschluckt — der nĂ€chste + /// Abgleich pusht additiv nach. + Future _meldeFavorit(String songId) async { + final cloud = _cloud; + if (cloud == null || !cloud.istAngemeldet) return; + final cloudId = (await db.songById(songId))?.cloudId; + if (cloudId == null) return; + final gesetzt = await db.watchIsFavorite(songId).first; + try { + await cloud.setzeFavorit(cloudId, gesetzt); + } catch (e) { + debugPrint('Favorit nicht gemeldet: $e'); + } } /// LĂ€dt alle Favoriten vom Navidrome-Server und importiert sie lokal. @@ -107,4 +151,75 @@ class PlaylistService extends ChangeNotifier { return 0; } } + + /// FĂŒhrt [aktion] aus und verwirft jeden Fehler. + /// + /// Die Sicherung ist einseitig und ohne Rollback: schlĂ€gt sie fehl, bleibt + /// der lokale Stand, wie er ist, und die nĂ€chste Änderung versucht es + /// erneut. + Future _still(Future Function() aktion) async { + try { + await aktion(); + } catch (e) { + debugPrint('Playlist-Sicherung ĂŒbersprungen: $e'); + } + } + + Future _cloudIdDerPlaylist(String playlistId) async { + if (_cloud == null || !_cloud.istAngemeldet) return null; + return (await db.playlistById(playlistId))?.cloudId; + } + + Future _sichereNeuePlaylist(String id, String name) async { + final cloud = _cloud; + if (cloud == null || !cloud.istAngemeldet) return; + await _still(() async { + final cloudId = await cloud.legePlaylistAn(name); + await db.setPlaylistCloudId(id, cloudId); + }); + } + + Future _sichereReihenfolge(String playlistId) async { + final cloudId = await _cloudIdDerPlaylist(playlistId); + if (cloudId == null) return; + final songs = await db.watchPlaylistSongs(playlistId).first; + final ids = [ + for (final s in songs) + if (s.cloudId != null) s.cloudId!, + ]; + if (ids.isEmpty) return; + await _still(() => _cloud!.setzePlaylistReihenfolge(cloudId, ids)); + } + + /// Holt die Playlisten des Servers **nur** auf ein GerĂ€t ohne eigene: + /// Neuinstallation oder Wiederherstellung. + /// + /// Es gibt bewusst keinen RĂŒck-Merge — Umbenennungen und Änderungen eines + /// zweiten GerĂ€ts erscheinen hier nicht. Der beidseitige Abgleich ist eine + /// eigene, spĂ€tere Spec. + Future stelleWiederHer() async { + final cloud = _cloud; + if (cloud == null || !cloud.istAngemeldet) return 0; + if (await db.countPlaylists() > 0) return 0; + + var angelegt = 0; + try { + for (final vomServer in await cloud.playlisten()) { + final id = await db.createPlaylist(vomServer.name); + await db.setPlaylistCloudId(id, vomServer.id); + final songCloudIds = await cloud.playlistSongs(vomServer.id); + var position = 0; + for (final songCloudId in songCloudIds) { + final song = await db.songByCloudId(songCloudId); + if (song == null) continue; + await db.addSongToPlaylist(id, song.id, position++); + } + angelegt++; + } + } catch (e) { + debugPrint('Playlist-Wiederherstellung abgebrochen: $e'); + } + notifyListeners(); + return angelegt; + } } diff --git a/lib/main.dart b/lib/main.dart index e9fa0fc..d66b618 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,6 +30,7 @@ import 'services/sync_service.dart'; import 'services/yt_download_service.dart'; import 'services/yt_search_service.dart'; import 'settings/app_settings.dart'; +import 'shared/sync_bericht_dialog.dart'; import 'shared/theme.dart'; late final MeloAudioHandler _handler; @@ -51,7 +52,6 @@ Future main() async { await logger.init(); _db = MeloDb(); _library = LibraryService(_db); - _playlists = PlaylistService(_db); _offlineMode = OfflineMode(); await _offlineMode.init(); _categories = CategoryService(_db); @@ -83,6 +83,12 @@ Future main() async { cloud: MeloCloudService(auth: _bakaAuth), ); await _sync.laden(); + // Erst hier: der Sofort-Push braucht den angemeldeten Cloud-Zugang, und + // _bakaAuth entsteht weiter oben. + _playlists = PlaylistService( + _db, + cloud: MeloCloudService(auth: _bakaAuth), + ); final navidrome = NavidromeService(); await navidrome.ladeGespeicherteZugangsdaten(); _downloads = DownloadService(db: _db, navidrome: navidrome); @@ -172,7 +178,7 @@ class _HomeShellState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addObserver(this); // Beim Start einmal mit dem Server abgleichen — neue Titel von anderen // GerĂ€ten sind dann sofort da. - context.read().automatisch(); + unawaited(_gleicheAbUndZeigeBericht()); unawaited(context.read().ensureStartupPermissions()); unawaited(_pruefeGeteilteAdresse()); } @@ -200,13 +206,41 @@ class _HomeShellState extends State with WidgetsBindingObserver { // auf dem GerĂ€t wie am Server. if (state == AppLifecycleState.resumed) { if (Platform.isAndroid) _autoScan.pruefe(); - context.read().automatisch(); + unawaited(_gleicheAbUndZeigeBericht()); // Ein zweites Teilen startet die App nicht neu (singleTop), sondern // bringt sie nur nach vorn — deshalb auch hier nachsehen. unawaited(_pruefeGeteilteAdresse()); } } + /// Gleicht ab und zeigt danach höchstens einmal den „Was ist neu"-Bericht. + Future _gleicheAbUndZeigeBericht() async { + final sync = context.read(); + final playlists = context.read(); + await sync.automatisch(); + // Nur auf einem GerĂ€t ohne eigene Playlisten, und nur nach einem + // tatsĂ€chlich abgeschlossenen Sync-Lauf: lĂ€uft parallel schon ein + // echter Sync (z. B. weil ein Berechtigungsdialog wĂ€hrend des + // Erststarts einen App-Resume auslöst und diese Methode ein zweites + // Mal anstĂ¶ĂŸt), kehrt automatisch() oben wegen des _laeuft-Guards + // sofort zurĂŒck — dann liefe jede songByCloudId-Suche noch ins Leere, + // legte leere Playlisten an, und der countPlaylists()-Riegel wĂŒrde + // jeden weiteren Versuch fĂŒr immer blockieren. + if (!sync.laeuft) { + unawaited(playlists.stelleWiederHer()); + } + if (!mounted) return; + final bericht = sync.bericht; + if (bericht == null) return; + // Zuerst quittieren: ein zweites ZurĂŒckkehren in die App soll denselben + // Bericht nicht erneut zeigen. + sync.berichtGesehen(); + await showDialog( + context: context, + builder: (_) => SyncBerichtDialog(bericht: bericht), + ); + } + @override Widget build(BuildContext context) { final tabs = [ diff --git a/lib/services/download_service.dart b/lib/services/download_service.dart index c87b781..bdb9be6 100644 --- a/lib/services/download_service.dart +++ b/lib/services/download_service.dart @@ -110,6 +110,20 @@ class DownloadService extends ChangeNotifier { return neu; } + /// Nimmt einen einzelnen Server-Titel offline. + /// + /// Bewusst ĂŒber [lade]: das bringt Doppel-Lauf-Schutz, VerbindungsprĂŒfung + /// und Fortschritts-BuchfĂŒhrung mit. Gibt zurĂŒck, ob der Titel neu + /// dazugekommen ist. + /// + /// **Bekannte EinschrĂ€nkung:** Liegt der Titel schon im Abspiel-Zwischen- + /// speicher (weil er gerade gehört wurde), wird er trotzdem neu geladen — + /// bis die VerdrĂ€ngung greift, belegt er doppelten Platz. Der Gegenschutz + /// kostete einen eigenen Cache-Zugang in diesem Dienst; fĂŒr drei Nutzer ist + /// das der schlechtere Tausch. + Future ladeEinzelnenTitel(SubsonicSong song) async => + await lade([song]) > 0; + Future<_Ergebnis> _ladeEinen(SubsonicSong song) async { try { // Datei und BuchfĂŒhrung entstehen nacheinander. Bricht die App diff --git a/lib/services/melo_cloud_service.dart b/lib/services/melo_cloud_service.dart index ff851ed..6d22621 100644 --- a/lib/services/melo_cloud_service.dart +++ b/lib/services/melo_cloud_service.dart @@ -42,6 +42,14 @@ class CloudSong { ); } +/// 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; +} + /// Ein Wiedergabe-Ereignis, das zum Server gemeldet wird. class CloudVerlauf { const CloudVerlauf({ @@ -114,10 +122,22 @@ class MeloCloudService { } /// Liest die Favoriten-IDs aus einer Server-Antwort. + /// + /// Ein fehlender `favorites`-SchlĂŒssel ist ein **Fehler, keine leere + /// Menge**: der Router verdrahtet fĂŒr `GET /favorites` hart HTTP 200, und + /// mehrere Handler desselben Servers melden Fehler im 200er-Körper. Eine + /// fĂ€lschlich leere Antwort wĂ€re sonst von einer echten nicht zu + /// unterscheiden — genau wie bei [parseListe] und [parseUpload] wird + /// deshalb geworfen. @visibleForTesting static List parseFavoriten(String body) { final daten = jsonDecode(body) as Map; - final liste = daten['favorites'] as List? ?? const []; + final fehler = daten['error'] as String?; + if (fehler != null) throw CloudException(fehler); + final liste = daten['favorites']; + if (liste is! List) { + throw CloudException('Antwort ohne Favoritenliste'); + } return [ for (final j in liste) (j as Map)['id'] as String, ]; @@ -264,19 +284,145 @@ class MeloCloudService { return parseFavoriten(antwort.body); } - /// Ersetzt die Favoriten am Server durch [cloudIds]. - Future setzeFavoriten(List cloudIds) async { + /// Setzt einen einzelnen Favoriten am Server — additiv oder entfernend, + /// aber immer **deterministisch**. + /// + /// Bewusst kein Umschalten: hĂ€tte der Server einen abweichenden Stand, + /// kehrte ein Toggle den Wunsch des Nutzers um. + Future setzeFavorit(String cloudId, bool gesetzt) async { _pruefeAnmeldung(); final antwort = await _client .post( - Uri.parse('$basisUrl/favorites'), + Uri.parse('$basisUrl/favorites/toggle'), headers: {..._kopf, 'Content-Type': 'application/json'}, - body: jsonEncode({'song_ids': cloudIds}), + body: jsonEncode({'song_id': cloudId, 'set': gesetzt}), ) .timeout(const Duration(seconds: 30)); _pruefeStatus(antwort); } + /// Der Körper einer Playlisten-Antwort, oder `CloudException`. + /// + /// Der Server meldet Fehler im 200er-Körper unter `error`. Ein bloßes + /// `{"status":"not_found"}` **ohne** `error` (so antworten + /// `handle_playlist_remove_song` und `handle_playlist_update_positions`) + /// geht hier bewusst durch: die Sicherung ist einseitig und + /// fire-and-forget, sie verwirft jeden Fehler ohnehin. + Map _json(http.Response antwort) { + final daten = jsonDecode(antwort.body) as Map; + final fehler = daten['error'] as String?; + if (fehler != null) throw CloudException(fehler); + return daten; + } + + /// Legt eine Playlist am Server an und gibt deren ID zurĂŒck. + /// + /// Die IdentitĂ€t stammt **immer** von hier: `user_playlists.id` ist + /// AUTOINCREMENT und damit stabil. Eine Zuordnung ĂŒber den Namen gibt es + /// nicht — sie zerbrĂ€che beim ersten Umbenennen. + Future legePlaylistAn(String name) async { + _pruefeAnmeldung(); + final antwort = await _client + .post( + Uri.parse('$basisUrl/playlists'), + headers: {..._kopf, 'Content-Type': 'application/json'}, + body: jsonEncode({'name': name}), + ) + .timeout(const Duration(seconds: 30)); + _pruefeStatus(antwort); + // handle_playlist_create antwortet {"status":"ok","playlist":{"id":
}} — + // die ID liegt eine Ebene tiefer, nicht auf oberster Ebene. + final playlist = _json(antwort)['playlist']; + if (playlist is! Map) { + throw CloudException('Antwort ohne Playlist'); + } + return '${playlist['id']}'; + } + + Future fuegePlaylistSongsHinzu( + String playlistCloudId, List songCloudIds) async { + if (songCloudIds.isEmpty) return; + _pruefeAnmeldung(); + final antwort = await _client + .post( + Uri.parse('$basisUrl/playlists/$playlistCloudId/songs'), + headers: {..._kopf, 'Content-Type': 'application/json'}, + body: jsonEncode({'song_ids': songCloudIds}), + ) + .timeout(const Duration(seconds: 30)); + _pruefeStatus(antwort); + _json(antwort); + } + + Future entfernePlaylistSong( + String playlistCloudId, String songCloudId) async { + _pruefeAnmeldung(); + final antwort = await _client + .delete( + Uri.parse('$basisUrl/playlists/$playlistCloudId/songs/$songCloudId'), + headers: _kopf, + ) + .timeout(const Duration(seconds: 30)); + _pruefeStatus(antwort); + _json(antwort); + } + + /// Schreibt die Reihenfolge einer Playlist am Server fest. + /// + /// Der Körper heißt `positions` und trĂ€gt Paare aus `id` und `position`: + /// Der Router liest `d.get('positions',[])`, der Handler greift je Eintrag + /// auf beide SchlĂŒssel zu. Eine blanke ID-Liste unter `song_ids` kĂ€me als + /// leere Liste an — der Server antwortete stumm `{"status":"ok"}` und + /// Ă€nderte nichts. + Future setzePlaylistReihenfolge( + String playlistCloudId, List songCloudIds) async { + _pruefeAnmeldung(); + final antwort = await _client + .put( + Uri.parse('$basisUrl/playlists/$playlistCloudId/positions'), + headers: {..._kopf, 'Content-Type': 'application/json'}, + body: jsonEncode({ + 'positions': [ + for (var i = 0; i < songCloudIds.length; i++) + {'id': songCloudIds[i], 'position': i}, + ], + }), + ) + .timeout(const Duration(seconds: 30)); + _pruefeStatus(antwort); + _json(antwort); + } + + Future> playlisten() async { + _pruefeAnmeldung(); + final antwort = await _client + .get(Uri.parse('$basisUrl/playlists'), headers: _kopf) + .timeout(const Duration(seconds: 30)); + _pruefeStatus(antwort); + final liste = _json(antwort)['playlists']; + if (liste is! List) throw CloudException('Antwort ohne Playlisten'); + return [ + for (final j in liste) + CloudPlaylist( + id: '${(j as Map)['id']}', + name: j['name'] as String? ?? 'Ohne Namen', + ), + ]; + } + + Future> playlistSongs(String playlistCloudId) async { + _pruefeAnmeldung(); + final antwort = await _client + .get(Uri.parse('$basisUrl/playlists/$playlistCloudId'), headers: _kopf) + .timeout(const Duration(seconds: 30)); + _pruefeStatus(antwort); + final liste = _json(antwort)['songs']; + if (liste is! List) throw CloudException('Antwort ohne Titel'); + return [ + for (final j in liste) '${(j as Map)['id']}', + ]; + } + /// Meldet Wiedergaben. Der Server nimmt höchstens 100 je Aufruf an und /// verwirft Doppelmeldungen desselben Titels innerhalb einer Stunde. Future meldeVerlauf(List eintraege) async { diff --git a/lib/services/sync_merge.dart b/lib/services/sync_merge.dart new file mode 100644 index 0000000..bd89100 --- /dev/null +++ b/lib/services/sync_merge.dart @@ -0,0 +1,65 @@ +/// Die reinen Entscheidungsfunktionen des Abgleichs — ohne Netz, ohne +/// Datenbank, ohne Plattform-KanĂ€le. +/// +/// Sie liegen bewusst außerhalb von `SyncService`: was hier steht, lĂ€sst sich +/// mit einer Handvoll Mengen prĂŒfen statt mit einem halben Server. +library; + +/// Wie viele Favoriten je Lauf höchstens gepusht werden. +/// +/// Der Rest kommt im nĂ€chsten Lauf dran. Die Pushes sind idempotent, ein +/// Teilausfall heilt sich dadurch von selbst. +const int maxFavoritenPushes = 200; + +/// Die cloudIds, die zum Server gepusht werden mĂŒssen: `lokal \ server`. +/// +/// [lokaleFavoriten] sind lokale Song-IDs, [cloudIdVon] bildet sie auf ihre +/// cloudId ab. Titel ohne cloudId kennt der Server nicht — sie tauchen in +/// keiner Richtung im Abgleich auf. +List zuPushendeFavoriten({ + required Set lokaleFavoriten, + required Map cloudIdVon, + required Set amServer, + int deckel = maxFavoritenPushes, +}) { + final offen = []; + for (final songId in lokaleFavoriten) { + final cloudId = cloudIdVon[songId]; + if (cloudId == null) continue; + if (amServer.contains(cloudId)) continue; + offen.add(cloudId); + if (offen.length >= deckel) break; + } + return offen; +} + +/// Die lokalen Song-IDs, die aus dem Server-Stand als Favorit dazukommen: +/// `server \ lokal`. +/// +/// Eine cloudId ohne lokalen Titel (Download fehlgeschlagen, noch nicht +/// geladen) wird **ĂŒbersprungen, nicht gelöscht**: ein Favorit ohne Song wĂ€re +/// ĂŒber den Join unsichtbar, wĂŒrde aber weiter mitgeschleppt. +List lokalZuSetzendeFavoriten({ + required Set amServer, + required Map songIdVonCloudId, + required Set lokaleFavoriten, +}) { + final offen = []; + for (final cloudId in amServer) { + final songId = songIdVonCloudId[cloudId]; + if (songId == null) continue; + if (lokaleFavoriten.contains(songId)) continue; + offen.add(songId); + } + return offen; +} + +/// Ob der „Was ist neu"-Bericht fĂ€llig ist. +/// +/// `null` heißt Neuinstallation, Abmeldung oder gelöschte App-Daten — dann ist +/// er **nicht** fĂ€llig, sonst begrĂŒĂŸt ein frisch eingerichtetes GerĂ€t den +/// Nutzer mit „Willkommen zurĂŒck! 325 neue Songs". `sollAutoSync` entscheidet +/// bei `null` bewusst umgekehrt und ist hier **kein** Vorbild. +bool berichtFaellig(DateTime? letzterErfolg, DateTime jetzt) => + letzterErfolg != null && + jetzt.difference(letzterErfolg) >= const Duration(hours: 24); diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index 1db96f0..368050e 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:drift/drift.dart' show Value; @@ -9,6 +10,7 @@ import 'package:uuid/uuid.dart'; import '../library/database.dart'; import 'media_store.dart'; import 'melo_cloud_service.dart'; +import 'sync_merge.dart'; /// Was beim Abgleich mit welchen Titeln zu tun ist. /// @@ -120,6 +122,48 @@ const autoSyncAbstand = Duration(minutes: 15); bool sollAutoSync(DateTime? letzterLauf, DateTime jetzt) => letzterLauf == null || jetzt.difference(letzterLauf) >= autoSyncAbstand; +/// Was ein gezielter Upload erledigt hat — Grundlage der Meldung an den +/// Nutzer. Ein einzelner Fehlschlag darf den Erfolg der ĂŒbrigen nicht +/// verdecken, deshalb steht hier alles nebeneinander. +class UploadErgebnis { + const UploadErgebnis({ + this.hochgeladen = 0, + this.schonDa = 0, + this.fehler = const [], + this.abgebrochen = false, + }); + + final int hochgeladen; + final int schonDa; + final List fehler; + final bool abgebrochen; + + String get meldung { + final teile = []; + if (hochgeladen > 0) teile.add('$hochgeladen hochgeladen'); + if (schonDa > 0) teile.add('$schonDa waren schon da'); + if (fehler.isNotEmpty) teile.add('${fehler.length} fehlgeschlagen'); + if (abgebrochen) teile.add('abgebrochen'); + return teile.isEmpty ? 'Nichts zu tun' : teile.join(', '); + } +} + +/// 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; +} + /// HĂ€lt GerĂ€t und Server auf demselben Stand: lĂ€dt neue Server-Titel herunter, /// bringt eigene Dateien hoch, zieht Löschungen nach und meldet Favoriten und /// Wiedergaben. Alle GerĂ€te am selben Konto sehen dadurch dasselbe. @@ -132,6 +176,7 @@ class SyncService extends ChangeNotifier { }) : _musikOrdner = musikOrdner ?? _standardMusikOrdner; static const _letzterLaufKey = 'cloud_sync_letzter_lauf'; + static const _letzterErfolgKey = 'cloud_sync_letzter_erfolg'; static const _verlaufStandKey = 'cloud_sync_verlauf_stand'; static const _uuid = Uuid(); @@ -146,6 +191,9 @@ class SyncService extends ChangeNotifier { String? _fehler; String? _status; DateTime? _letzterLauf; + DateTime? _letzterErfolg; + SyncBericht? _bericht; + bool _abbruchGewuenscht = false; bool get laeuft => _laeuft; int get erledigt => _erledigt; @@ -154,10 +202,23 @@ class SyncService extends ChangeNotifier { String? get status => _status; DateTime? get letzterLauf => _letzterLauf; + /// 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(); + } + Future laden() async { final prefs = await SharedPreferences.getInstance(); final ms = prefs.getInt(_letzterLaufKey); if (ms != null) _letzterLauf = DateTime.fromMillisecondsSinceEpoch(ms); + final erfolg = prefs.getInt(_letzterErfolgKey); + if (erfolg != null) { + _letzterErfolg = DateTime.fromMillisecondsSinceEpoch(erfolg); + } notifyListeners(); } @@ -185,6 +246,14 @@ class SyncService extends ChangeNotifier { _gesamt = 0; _melde('Vergleiche mit dem Server 
'); + // 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( @@ -204,18 +273,38 @@ class SyncService extends ChangeNotifier { '$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); - await _ladeHerunter(plan.herunterladen); - await _ladeHoch(plan.hochladen); - await _gleicheFavoritenAb(); + 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 = DateTime.now(); + _letzterLauf = laufBeginn; final prefs = await SharedPreferences.getInstance(); - await prefs.setInt(_letzterLaufKey, _letzterLauf!.millisecondsSinceEpoch); + 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) { _fehler = e.message; } catch (e) { @@ -228,6 +317,104 @@ class SyncService extends ChangeNotifier { } } + /// Stoppt einen laufenden Auswahl-Upload zwischen zwei Titeln. + /// + /// Nach dem Muster von `DownloadService.abbrechen`: wer versehentlich 60 + /// statt 6 Titel markiert hat, soll nicht die App killen mĂŒssen. + void abbrechen() { + if (_laeuft) _abbruchGewuenscht = true; + } + + /// LĂ€dt genau [songs] zum Server — die ausdrĂŒckliche Nutzeraktion aus dem + /// Auswahl-Modus. + /// + /// Sequenziell, weil der Server jeden Upload komplett im RAM hĂ€lt. Titel mit + /// cloudId werden ĂŒbersprungen, Einzelfehler vermerkt und ĂŒbergangen. + /// Der Sync-Zeitstempel wird bewusst **nicht** geschrieben: ein Upload ist + /// kein Abgleich, und sonst unterdrĂŒckte er 15 Minuten den Auto-Sync und + /// verschöbe die 24-h-Uhr des Berichts. + /// + /// Den Offline-Modus-Schalter beachtet er nicht — er ist eine ausdrĂŒckliche + /// Nutzeraktion. + Future ladeAusgewaehlteHoch(List songs) async { + if (_laeuft) { + _fehler = 'Es lĂ€uft gerade ein Abgleich — bitte kurz warten'; + notifyListeners(); + return const UploadErgebnis(); + } + if (!cloud.istAngemeldet) { + _fehler = 'Bitte zuerst beim Baka-Konto anmelden'; + notifyListeners(); + return const UploadErgebnis(); + } + + _laeuft = true; + _abbruchGewuenscht = false; + _fehler = null; + _erledigt = 0; + _gesamt = songs.length; + notifyListeners(); + + var hochgeladen = 0; + var schonDa = 0; + final fehler = []; + var abgebrochen = false; + + try { + for (final song in songs) { + if (_abbruchGewuenscht) { + abgebrochen = true; + break; + } + if (song.cloudId != null) { + schonDa++; + _erledigt++; + notifyListeners(); + continue; + } + final datei = File(song.path); + if (!await datei.exists()) { + fehler.add('${song.title}: Datei nicht gefunden'); + _erledigt++; + notifyListeners(); + continue; + } + _melde('Sende „${song.title}" 
'); + try { + final cloudId = await cloud.hochladen( + datei, + dateiname: + '${_sichererDateiname(song.title)}${p.extension(song.path)}', + ); + if (cloudId != null) { + await db.setCloudId(song.id, cloudId); + hochgeladen++; + } else { + fehler.add('${song.title}: keine Server-ID erhalten'); + } + } on CloudException catch (e) { + fehler.add('${song.title}: ${e.message}'); + } on TimeoutException { + fehler.add('${song.title}: ZeitĂŒberschreitung'); + } + _erledigt++; + notifyListeners(); + } + } finally { + _laeuft = false; + _abbruchGewuenscht = false; + _status = null; + notifyListeners(); + } + + return UploadErgebnis( + hochgeladen: hochgeladen, + schonDa: schonDa, + fehler: fehler, + abgebrochen: abgebrochen, + ); + } + Future _ziehLoeschungenNach(List songs) async { if (songs.isEmpty) return; _melde('Entferne ${songs.length} am Server gelöschte Titel 
'); @@ -236,7 +423,13 @@ class SyncService extends ChangeNotifier { notifyListeners(); } - Future _meldeLoeschungen(List songs) async { + /// Meldet die hier getombsteten Titel am Server. + /// + /// Gibt `false` zurĂŒck, sobald eine Meldung geschluckt wurde: der Lauf geht + /// weiter, gilt aber nicht mehr als erfolgreich — sonst rĂŒckte der + /// 24-h-Zeitstempel des Berichts vor, obwohl eine Phase ausgefallen ist. + Future _meldeLoeschungen(List songs) async { + var vollstaendig = true; for (final song in songs) { _melde('Melde Löschung von „${song.title}“ 
'); try { @@ -244,14 +437,19 @@ class SyncService extends ChangeNotifier { } 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; } - Future _ladeHerunter(List songs) async { - if (songs.isEmpty) return; + /// Gibt zurĂŒck, wie viele Titel wirklich neu dazugekommen sind — die Zahl + /// im „Was ist neu"-Bericht. + Future _ladeHerunter(List songs) async { + if (songs.isEmpty) return 0; + var neu = 0; final ordner = await _musikOrdner(); for (final cloudSong in songs) { _melde('Lade „${cloudSong.titel}“ 
'); @@ -291,12 +489,21 @@ class SyncService extends ChangeNotifier { cloudId: Value(cloudSong.id), ), ]); + neu++; _erledigt++; notifyListeners(); } + return neu; } - Future _ladeHoch(List songs) async { + /// LĂ€dt alle Titel ohne cloudId hoch. + /// + /// Gibt `false` zurĂŒck, sobald ein Upload an einem Fehler oder einer + /// ZeitĂŒberschreitung hĂ€ngenblieb. Eine **fehlende Datei** zĂ€hlt bewusst + /// nicht dazu: dort ist nichts schiefgegangen, der Titel ist weg und der + /// nĂ€chste Scan tombstoned ihn. + Future _ladeHoch(List songs) async { + var vollstaendig = true; for (final song in songs) { final datei = File(song.path); if (!await datei.exists()) { @@ -309,29 +516,89 @@ class SyncService extends ChangeNotifier { datei, dateiname: '${_sichererDateiname(song.title)}${p.extension(song.path)}', ); - if (cloudId != null) await db.setCloudId(song.id, cloudId); + 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 — ohne diesen Zweig riss ein + // einziger hĂ€ngender Upload den ganzen Lauf ab. + debugPrint('Upload „${song.title}“: ZeitĂŒberschreitung'); + vollstaendig = false; } _erledigt++; notifyListeners(); } + return vollstaendig; } - Future _gleicheFavoritenAb() async { + /// Additiver Favoriten-Abgleich: gleicht in **beide** Richtungen an, + /// entfernt aber in **keiner**. + /// + /// Damit ist der alte Datenverlust-Bug strukturell unmöglich: es gibt + /// keinen Codepfad mehr, der den Server-Stand ersetzen könnte. Der Preis + /// ist bekannt und bewusst: ein Ent-Favorisieren propagiert nicht + /// gerĂ€teĂŒbergreifend — hĂ€lt ein zweites GerĂ€t den Favoriten noch, bringt + /// dessen nĂ€chster Abgleich ihn zurĂŒck. + /// + /// Gibt `true` zurĂŒck, wenn die Phase vollstĂ€ndig durchlief. + Future _gleicheFavoritenAb() async { _melde('Gleiche Favoriten ab 
'); + + final Set amServer; + try { + amServer = (await cloud.favoriten()).toSet(); + } catch (e) { + // Ohne Server-Stand ist nichts zu tun. Blindes Pushen wĂ€re harmlos, + // aber nutzlos — die Phase wird ĂŒbersprungen, der Lauf geht weiter. + debugPrint('Favoriten-Abgleich ĂŒbersprungen: $e'); + return false; + } + + // Grabsteine bleiben außen vor: favoriteSongIds() liefert auch Favoriten + // getombsteter Titel, und die gehören nicht zurĂŒck auf den Server. final lokal = await db.allSongs(); final cloudIdVon = { for (final s in lokal) - if (s.cloudId != null) s.id: s.cloudId!, + if (s.cloudId != null && !s.deleted) s.id: s.cloudId!, }; - final favoritenIds = await db.favoriteSongIds(); - final cloudFavoriten = [ - for (final id in favoritenIds) - if (cloudIdVon[id] != null) cloudIdVon[id]!, - ]; - await cloud.setzeFavoriten(cloudFavoriten); + 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; } Future _meldeVerlauf() async { diff --git a/lib/shared/auswahl_leiste.dart b/lib/shared/auswahl_leiste.dart index 09b2fd7..4c1ea5b 100644 --- a/lib/shared/auswahl_leiste.dart +++ b/lib/shared/auswahl_leiste.dart @@ -7,6 +7,7 @@ import '../library/playlist_service.dart'; import '../library/song_media.dart'; import '../player/audio_handler.dart'; import '../playlists/create_playlist_dialog.dart'; +import '../services/sync_service.dart'; import '../settings/app_settings.dart'; import 'auswahl.dart'; import 'theme.dart'; @@ -22,6 +23,7 @@ class AuswahlLeiste extends StatelessWidget { required this.onAbbrechen, required this.onWiedergabeliste, required this.onWarteschlange, + this.onServerLaden, }); final int anzahl; @@ -29,6 +31,10 @@ class AuswahlLeiste extends StatelessWidget { 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; + @override Widget build(BuildContext context) { return Padding( @@ -49,6 +55,12 @@ class AuswahlLeiste extends StatelessWidget { style: const TextStyle(fontWeight: FontWeight.w600), ), ), + if (onServerLaden != null) + IconButton( + tooltip: 'Auf den Server laden', + icon: const Icon(Icons.cloud_upload), + onPressed: onServerLaden, + ), IconButton( tooltip: 'Zur Warteschlange hinzufĂŒgen', icon: const Icon(Icons.queue), @@ -159,3 +171,18 @@ Future fuegeZuWarteschlangeHinzu( : '$angehaengt von ${gewaehlte.length} Titeln angehĂ€ngt'), )); } + +/// LĂ€dt [gewaehlte] zum Melo-Server und meldet das Ergebnis. +/// +/// Wohnt neben [fuegeZuWiedergabelisteHinzu]: dieselbe Bauart, dieselbe Art +/// RĂŒckmeldung. +Future ladeAufServer( + BuildContext context, List gewaehlte) async { + final sync = context.read(); + final messenger = ScaffoldMessenger.of(context); + final ergebnis = await sync.ladeAusgewaehlteHoch(gewaehlte); + if (!context.mounted) return; + messenger.showSnackBar( + SnackBar(content: Text(sync.fehler ?? ergebnis.meldung)), + ); +} diff --git a/lib/shared/sortable_song_list.dart b/lib/shared/sortable_song_list.dart index f764173..7ab5be2 100644 --- a/lib/shared/sortable_song_list.dart +++ b/lib/shared/sortable_song_list.dart @@ -23,6 +23,7 @@ class SortableSongList extends StatefulWidget { required this.songs, required this.storeKey, this.empty, + this.serverUpload = false, }); final List songs; @@ -31,6 +32,10 @@ class SortableSongList extends StatefulWidget { /// Wird statt der Liste gezeigt, wenn [songs] leer ist. final Widget? empty; + /// 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; + @override State createState() => _SortableSongListState(); } @@ -67,6 +72,11 @@ class _SortableSongListState extends State { if (mounted) _beendeAuswahl(); } + Future _aufServer(List gewaehlte) async { + await ladeAufServer(context, gewaehlte); + if (mounted) _beendeAuswahl(); + } + @override void dispose() { _scroll.dispose(); @@ -196,6 +206,9 @@ class _SortableSongListState extends State { onAbbrechen: _beendeAuswahl, onWiedergabeliste: () => _inWiedergabeliste(_gewaehlte(sorted)), onWarteschlange: () => _inWarteschlange(_gewaehlte(sorted)), + onServerLaden: widget.serverUpload + ? () => _aufServer(_gewaehlte(sorted)) + : null, ) else Padding( diff --git a/lib/shared/sync_bericht_dialog.dart b/lib/shared/sync_bericht_dialog.dart new file mode 100644 index 0000000..78bcc75 --- /dev/null +++ b/lib/shared/sync_bericht_dialog.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import '../services/sync_service.dart'; + +/// „Willkommen zurĂŒck" — was sich seit dem letzten erfolgreichen Abgleich +/// getan hat. Rein in-App, ohne Benachrichtigungs-Kanal und ohne neue +/// AbhĂ€ngigkeit. +class SyncBerichtDialog extends StatelessWidget { + const SyncBerichtDialog({super.key, required this.bericht}); + + final SyncBericht bericht; + + static String textFuer(SyncBericht b) { + if (b.istLeer) return 'Nichts Neues seit dem letzten Abgleich.'; + final teile = []; + if (b.neueSongs > 0) teile.add('${b.neueSongs} neue Titel'); + if (b.geloeschte > 0) teile.add('${b.geloeschte} entfernt'); + if (b.favoriten > 0) teile.add('${b.favoriten} Favoriten geĂ€ndert'); + return '${teile.join(' · ')}.'; + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Willkommen zurĂŒck!'), + content: Text(textFuer(bericht)), + actions: [ + FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Alles klar'), + ), + ], + ); + } +} diff --git a/test/downloads/einzel_song_offline_test.dart b/test/downloads/einzel_song_offline_test.dart new file mode 100644 index 0000000..a3aa429 --- /dev/null +++ b/test/downloads/einzel_song_offline_test.dart @@ -0,0 +1,129 @@ +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/downloads/server_titel_screen.dart'; +import 'package:melo/player/audio_handler.dart'; +import 'package:melo/services/download_service.dart'; +import 'package:melo/services/navidrome_service.dart'; + +/// Merkt sich nur, was verlangt wurde — echte Downloads brauchen einen +/// Server, und darum geht es hier nicht. +class _FakeDownloads extends DownloadService { + _FakeDownloads(MeloDb db) : super(db: db, navidrome: NavidromeService()); + + final geladen = []; + final entfernt = []; + + @override + bool get laeuft => false; + + @override + Future ladeEinzelnenTitel(SubsonicSong song) async { + geladen.add(song.id); + return true; + } + + @override + Future entferne(String navidromeId) async { + entfernt.add(navidromeId); + return true; + } +} + +void main() { + final titel = [ + const SubsonicSong( + id: 'nav-1', + titel: 'Nachtpuls', + kuenstler: 'Rotklang', + album: 'Nacht', + dauerSekunden: 200, + ), + ]; + + Future<({_FakeDownloads dienst, MeloAudioHandler handler})> 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); + // MeloAudioHandler startet einen periodischen Timer (Positions- + // BuchfĂŒhrung). Ohne handler.dispose() meldet der Testrahmen ihn als + // „pending" — dasselbe etablierte Muster wie in + // test/library/my_music_screen_test.dart: handler.dispose() explizit am + // Testende aufrufen (addTearDown greift zu spĂ€t, außerhalb der + // FakeAsync-Zone, in der der Timer geprĂŒft wird). + final handler = MeloAudioHandler(db: db); + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: db), + ChangeNotifierProvider.value(value: dienst), + Provider.value(value: handler), + ], + 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: dienst, handler: handler); + } + + testWidgets('ein einzelner Titel lĂ€sst sich offline nehmen', (tester) async { + final (:dienst, :handler) = 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); + + handler.dispose(); + }); + + testWidgets('ein schon geladener Titel bietet den Gegenweg an', + (tester) async { + final (:dienst, :handler) = + 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); + + handler.dispose(); + }); +} diff --git a/test/library/playlist_cloud_id_test.dart b/test/library/playlist_cloud_id_test.dart new file mode 100644 index 0000000..e067f7b --- /dev/null +++ b/test/library/playlist_cloud_id_test.dart @@ -0,0 +1,57 @@ +import 'package:drift/drift.dart' show Migrator; +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); + }); +} diff --git a/test/library/playlist_service_test.dart b/test/library/playlist_service_test.dart index 2bbb0dd..62bb629 100644 --- a/test/library/playlist_service_test.dart +++ b/test/library/playlist_service_test.dart @@ -1,7 +1,32 @@ +import 'dart:convert'; + import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:melo/library/database.dart'; import 'package:melo/library/playlist_service.dart'; +import 'package:melo/services/baka_auth.dart'; +import 'package:melo/services/melo_cloud_service.dart'; + +class _MemorySpeicher implements TokenSpeicher { + _MemorySpeicher(this.werte); + final Map werte; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + +Future _angemeldeteAuth() async { + final auth = BakaAuth( + speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}), + ); + await auth.laden(); + return auth; +} void main() { late MeloDb db; @@ -61,4 +86,110 @@ void main() { expect(songs.map((s) => s.id).toList(), ['song-3', 'song-1', 'song-2']); expect(notified, true); }); + + group('Sofort-Push der Favoriten', () { + Future legeSongAn(MeloDb db, {String? cloudId}) async { + await db.into(db.songs).insert(SongsCompanion.insert( + id: 'song-1', + path: '/a.mp3', + title: 'A', + dateAddedMs: 0, + updatedAtMs: 0, + )); + if (cloudId != null) await db.setCloudId('song-1', cloudId); + } + + test('setzt deterministisch true beim Favorisieren', () async { + final db2 = MeloDb(NativeDatabase.memory()); + addTearDown(db2.close); + await legeSongAn(db2, cloudId: 'c1'); + + final gesendet = >[]; + final dienst = PlaylistService( + db2, + cloud: MeloCloudService( + auth: await _angemeldeteAuth(), + client: MockClient((anfrage) async { + gesendet.add(jsonDecode(anfrage.body) as Map); + return http.Response(jsonEncode({'status': 'ok'}), 200); + }), + ), + ); + + await dienst.toggleFavorite('song-1'); + + expect(gesendet, [ + {'song_id': 'c1', 'set': true} + ]); + }); + + test('setzt deterministisch false beim Ent-Favorisieren', () async { + final db2 = MeloDb(NativeDatabase.memory()); + addTearDown(db2.close); + await legeSongAn(db2, cloudId: 'c1'); + await db2.setFavorite('song-1', true); + + final gesendet = >[]; + final dienst = PlaylistService( + db2, + cloud: MeloCloudService( + auth: await _angemeldeteAuth(), + client: MockClient((anfrage) async { + gesendet.add(jsonDecode(anfrage.body) as Map); + return http.Response(jsonEncode({'status': 'ok'}), 200); + }), + ), + ); + + await dienst.toggleFavorite('song-1'); + + expect(gesendet, [ + {'song_id': 'c1', 'set': false} + ]); + }); + + test('ohne cloudId wird nichts gemeldet', () async { + final db2 = MeloDb(NativeDatabase.memory()); + addTearDown(db2.close); + await legeSongAn(db2); + + var anfragen = 0; + final dienst = PlaylistService( + db2, + cloud: MeloCloudService( + auth: await _angemeldeteAuth(), + client: MockClient((_) async { + anfragen++; + return http.Response(jsonEncode({'status': 'ok'}), 200); + }), + ), + ); + + await dienst.toggleFavorite('song-1'); + + expect(anfragen, 0); + expect(await db2.watchIsFavorite('song-1').first, isTrue); + }); + + test('ein Fehler des Servers Ă€ndert lokal nichts und wirft nicht', + () async { + final db2 = MeloDb(NativeDatabase.memory()); + addTearDown(db2.close); + await legeSongAn(db2, cloudId: 'c1'); + + final dienst = PlaylistService( + db2, + cloud: MeloCloudService( + auth: await _angemeldeteAuth(), + client: MockClient( + (_) async => http.Response(jsonEncode({'error': 'weg'}), 500)), + ), + ); + + // Der nĂ€chste Voll-Abgleich holt den Push additiv nach. + await dienst.toggleFavorite('song-1'); + + expect(await db2.watchIsFavorite('song-1').first, isTrue); + }); + }); } diff --git a/test/library/playlist_sicherung_test.dart b/test/library/playlist_sicherung_test.dart new file mode 100644 index 0000000..40cff24 --- /dev/null +++ b/test/library/playlist_sicherung_test.dart @@ -0,0 +1,254 @@ +import 'dart:convert'; + +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/playlist_service.dart'; +import 'package:melo/services/baka_auth.dart'; +import 'package:melo/services/melo_cloud_service.dart'; + +class _MemorySpeicher implements TokenSpeicher { + _MemorySpeicher(this.werte); + final Map werte; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + +Future cloudMit( + Future Function(http.Request) antwort) async { + final auth = BakaAuth( + speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}), + ); + await auth.laden(); + return MeloCloudService(auth: auth, client: MockClient(antwort)); +} + +void main() { + test('eine neue Playlist wird gemeldet und ihre Server-ID gemerkt', + () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + final dienst = PlaylistService( + db, + cloud: await cloudMit((_) async => http.Response( + // Form von handle_playlist_create: die ID liegt unter „playlist". + jsonEncode({ + 'status': 'ok', + 'playlist': {'id': 7, 'name': 'Road Trip'} + }), + 200, + )), + ); + + final id = await dienst.createPlaylist('Road Trip'); + + expect((await db.playlistById(id))!.cloudId, '7'); + }); + + test('ein Song ohne cloudId wird nicht mitgemeldet', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + var songMeldungen = 0; + final dienst = PlaylistService( + db, + cloud: await cloudMit((anfrage) async { + if (anfrage.url.path.endsWith('/songs')) songMeldungen++; + return http.Response( + jsonEncode({ + 'status': 'ok', + 'playlist': {'id': 7, 'name': 'Mix'} + }), + 200, + ); + }), + ); + await db.into(db.songs).insert(SongsCompanion.insert( + id: 'song-1', path: '/a.mp3', title: 'A', + dateAddedMs: 0, updatedAtMs: 0, + )); + final id = await dienst.createPlaylist('Mix'); + + await dienst.addSongToPlaylist(id, 'song-1', 0); + + expect(songMeldungen, 0); + // Lokal ist er trotzdem drin — kein Fehler, nur nichts zu melden. + expect(await db.watchPlaylistSongs(id).first, hasLength(1)); + }); + + /// Legt einen Titel mit Server-ID an. Ohne den fĂ€llt jeder Push aus, und + /// die Negativtests allein hĂ€tten den ganzen Vertrag nie berĂŒhrt. + Future legeSongAn(MeloDb db, String id, String cloudId) async { + await db.into(db.songs).insert(SongsCompanion.insert( + id: id, path: '/$id.mp3', title: id, + dateAddedMs: 0, updatedAtMs: 0, + )); + await db.setCloudId(id, cloudId); + } + + test('ein Song mit cloudId wird an die Server-Playlist gemeldet', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + Object? koerper; + String? pfad; + final dienst = PlaylistService( + db, + cloud: await cloudMit((anfrage) async { + if (anfrage.method == 'POST' && anfrage.url.path.endsWith('/songs')) { + pfad = anfrage.url.path; + koerper = jsonDecode(anfrage.body); + } + return http.Response( + jsonEncode({ + 'status': 'ok', + 'playlist': {'id': 7, 'name': 'Mix'} + }), + 200, + ); + }), + ); + await legeSongAn(db, 'song-1', 'c1'); + final id = await dienst.createPlaylist('Mix'); + + await dienst.addSongToPlaylist(id, 'song-1', 0); + + expect(pfad, endsWith('/playlists/7/songs')); + expect(koerper, { + 'song_ids': ['c1'] + }); + }); + + test('das Entfernen geht als DELETE auf den Song-Pfad', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + String? geloeschterPfad; + final dienst = PlaylistService( + db, + cloud: await cloudMit((anfrage) async { + if (anfrage.method == 'DELETE') geloeschterPfad = anfrage.url.path; + return http.Response( + jsonEncode({ + 'status': 'ok', + 'playlist': {'id': 7, 'name': 'Mix'} + }), + 200, + ); + }), + ); + await legeSongAn(db, 'song-1', 'c1'); + final id = await dienst.createPlaylist('Mix'); + await dienst.addSongToPlaylist(id, 'song-1', 0); + + await dienst.removeSongFromPlaylist(id, 'song-1'); + + expect(geloeschterPfad, endsWith('/playlists/7/songs/c1')); + expect(await db.watchPlaylistSongs(id).first, isEmpty); + }); + + test('eine neue Reihenfolge geht als positions-Paare raus', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + Object? koerper; + String? methode; + final dienst = PlaylistService( + db, + cloud: await cloudMit((anfrage) async { + if (anfrage.url.path.endsWith('/positions')) { + methode = anfrage.method; + koerper = jsonDecode(anfrage.body); + } + return http.Response( + jsonEncode({ + 'status': 'ok', + 'playlist': {'id': 7, 'name': 'Mix'} + }), + 200, + ); + }), + ); + await legeSongAn(db, 'song-1', 'c1'); + await legeSongAn(db, 'song-2', 'c2'); + final id = await dienst.createPlaylist('Mix'); + await dienst.addSongToPlaylist(id, 'song-1', 0); + await dienst.addSongToPlaylist(id, 'song-2', 1); + + await dienst.reorderAll(id, ['song-2', 'song-1']); + + // Unter „song_ids" hĂ€tte der Server eine leere Liste gelesen und stumm + // „ok" geantwortet — dieser Test ist der einzige Ort, an dem das auffĂ€llt. + expect(methode, 'PUT'); + expect(koerper, { + 'positions': [ + {'id': 'c2', 'position': 0}, + {'id': 'c1', 'position': 1}, + ] + }); + }); + + test('ein Endpunkt-Fehler Ă€ndert lokal nichts', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + final dienst = PlaylistService( + db, + cloud: await cloudMit( + (_) async => http.Response(jsonEncode({'error': 'weg'}), 500)), + ); + + final id = await dienst.createPlaylist('Mix'); + + expect((await db.playlistById(id))!.cloudId, isNull); + expect(await db.watchPlaylists().first, hasLength(1)); + }); + + test('Wiederherstellung greift nur bei leerer Playlisten-Tabelle', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + final dienst = PlaylistService( + db, + cloud: await cloudMit((anfrage) async { + if (anfrage.url.path.endsWith('/playlists')) { + return http.Response( + jsonEncode({ + 'playlists': [ + {'id': 7, 'name': 'Vom Server'} + ] + }), + 200, + ); + } + return http.Response(jsonEncode({'songs': []}), 200); + }), + ); + + expect(await dienst.stelleWiederHer(), 1); + final angelegt = await db.watchPlaylists().first; + expect(angelegt.single.name, 'Vom Server'); + expect(angelegt.single.cloudId, '7'); + }); + + test('mit einer lokalen Playlist wird nichts angelegt (kein RĂŒck-Merge)', + () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + final dienst = PlaylistService( + db, + cloud: await cloudMit((_) async => http.Response( + jsonEncode({ + 'playlists': [ + {'id': 7, 'name': 'Vom Server'} + ] + }), + 200, + )), + ); + await db.createPlaylist('Meine eigene'); + + expect(await dienst.stelleWiederHer(), 0); + expect(await db.watchPlaylists().first, hasLength(1)); + }); +} diff --git a/test/services/download_service_test.dart b/test/services/download_service_test.dart index db09e72..566ce7f 100644 --- a/test/services/download_service_test.dart +++ b/test/services/download_service_test.dart @@ -1,6 +1,9 @@ +import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/library/database.dart'; import 'package:melo/services/download_service.dart'; import 'package:melo/services/download_speicher.dart'; +import 'package:melo/services/navidrome_service.dart'; void main() { group('brauchtRueckfrage', () { @@ -54,4 +57,25 @@ void main() { expect(DownloadSpeicher.dateiName('abc'), endsWith('.mp3')); }); }); + + 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); + }); + }); } diff --git a/test/services/melo_cloud_playlists_test.dart b/test/services/melo_cloud_playlists_test.dart new file mode 100644 index 0000000..b105fe8 --- /dev/null +++ b/test/services/melo_cloud_playlists_test.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:melo/services/baka_auth.dart'; +import 'package:melo/services/melo_cloud_service.dart'; + +class _MemorySpeicher implements TokenSpeicher { + _MemorySpeicher(this.werte); + final Map werte; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + +Future baue( + Future Function(http.Request) antwort) async { + final auth = BakaAuth( + speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}), + ); + await auth.laden(); + return MeloCloudService(auth: auth, client: MockClient(antwort)); +} + +void main() { + test('legePlaylistAn liefert die Server-ID', () async { + final dienst = await baue((anfrage) async { + expect(anfrage.method, 'POST'); + expect(anfrage.url.path, endsWith('/playlists')); + expect(jsonDecode(anfrage.body), {'name': 'Road Trip'}); + // handle_playlist_create verpackt die ID unter „playlist" — genau so + // antwortet der echte Server, und genau daran ist der Vertrag geknĂŒpft. + return http.Response( + jsonEncode({ + 'status': 'ok', + 'playlist': {'id': 7, 'name': 'Road Trip', 'song_count': 0} + }), + 200, + ); + }); + + expect(await dienst.legePlaylistAn('Road Trip'), '7'); + }); + + test('eine Antwort ohne playlist-Block ist ein Fehler', () async { + // Die ID auf oberster Ebene zu suchen wĂ€re der naheliegende Fehler; er + // fiele am echten Server als „null" auf und sonst nirgends. + final dienst = await baue( + (_) async => http.Response(jsonEncode({'status': 'ok'}), 200)); + + expect(() => dienst.legePlaylistAn('Road Trip'), + throwsA(isA())); + }); + + test('ein Fehler im 200er-Körper wird geworfen', () async { + final dienst = await baue((_) async => + http.Response(jsonEncode({'error': 'kein Name'}), 200)); + + expect(() => dienst.legePlaylistAn(''), + throwsA(isA())); + }); + + test('fuegePlaylistSongsHinzu meldet die Song-IDs', () async { + Map? gesendet; + final dienst = await baue((anfrage) async { + expect(anfrage.url.path, endsWith('/playlists/7/songs')); + gesendet = jsonDecode(anfrage.body) as Map; + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await dienst.fuegePlaylistSongsHinzu('7', ['c1', 'c2']); + + expect(gesendet, { + 'song_ids': ['c1', 'c2'] + }); + }); + + test('entfernePlaylistSong benutzt DELETE auf dem Song-Pfad', () async { + String? pfad; + String? methode; + final dienst = await baue((anfrage) async { + pfad = anfrage.url.path; + methode = anfrage.method; + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await dienst.entfernePlaylistSong('7', 'c1'); + + expect(methode, 'DELETE'); + expect(pfad, endsWith('/playlists/7/songs/c1')); + }); + + test('setzePlaylistReihenfolge benutzt PUT auf /positions', () async { + String? methode; + Map? gesendet; + final dienst = await baue((anfrage) async { + methode = anfrage.method; + expect(anfrage.url.path, endsWith('/playlists/7/positions')); + gesendet = jsonDecode(anfrage.body) as Map; + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await dienst.setzePlaylistReihenfolge('7', ['c2', 'c1']); + + expect(methode, 'PUT'); + // Der Router liest „positions", der Handler erwartet Paare. Unter + // „song_ids" bekĂ€me der Server eine leere Liste und antwortete stumm + // „ok" — die Reihenfolge kĂ€me nie an, ohne jede Fehlermeldung. + expect(gesendet, { + 'positions': [ + {'id': 'c2', 'position': 0}, + {'id': 'c1', 'position': 1}, + ] + }); + }); + + test('playlisten liest Name und ID', () async { + final dienst = await baue((_) async => http.Response( + jsonEncode({ + 'playlists': [ + {'id': 7, 'name': 'Road Trip'} + ] + }), + 200, + )); + + final listen = await dienst.playlisten(); + + expect(listen.single.id, '7'); + expect(listen.single.name, 'Road Trip'); + }); + + test('playlistSongs liefert die Song-IDs in Reihenfolge', () async { + final dienst = await baue((_) async => http.Response( + jsonEncode({ + 'songs': [ + {'id': 'c1'}, + {'id': 'c2'}, + ] + }), + 200, + )); + + expect(await dienst.playlistSongs('7'), ['c1', 'c2']); + }); +} diff --git a/test/services/melo_cloud_service_test.dart b/test/services/melo_cloud_service_test.dart index 603ede6..212d6cf 100644 --- a/test/services/melo_cloud_service_test.dart +++ b/test/services/melo_cloud_service_test.dart @@ -1,8 +1,30 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:melo/services/baka_auth.dart'; import 'package:melo/services/melo_cloud_service.dart'; +class _MemorySpeicher implements TokenSpeicher { + _MemorySpeicher(this.werte); + final Map werte; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + +Future _angemeldeteAuth() async { + final auth = BakaAuth( + speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}), + ); + await auth.laden(); + return auth; +} + void main() { group('parseListe', () { test('liest Titel inklusive Grabsteinen', () { @@ -95,9 +117,30 @@ void main() { expect(MeloCloudService.parseFavoriten(body), ['a', 'b']); }); - test('ohne Favoriten leere Liste', () { - expect(MeloCloudService.parseFavoriten(jsonEncode({'status': 'ok'})), - isEmpty); + test('eine leere Favoritenliste ist kein Fehler', () { + expect( + MeloCloudService.parseFavoriten(jsonEncode({'favorites': []})), + isEmpty, + ); + }); + + test('fehlender SchlĂŒssel ist ein Fehler, keine leere Menge', () { + // Der Router verdrahtet fĂŒr GET /favorites hart HTTP 200; sechs Handler + // desselben Servers melden Fehler im 200er-Körper. Ein fĂ€lschlich + // leeres Ergebnis wĂ€re von einer echten Leerantwort nicht zu + // unterscheiden — wie parseListe und parseUpload wird deshalb geworfen. + expect( + () => MeloCloudService.parseFavoriten(jsonEncode({'status': 'ok'})), + throwsA(isA()), + ); + }); + + test('Server-Fehler im 200er-Körper wird als CloudException gemeldet', () { + expect( + () => MeloCloudService.parseFavoriten( + jsonEncode({'status': 'error', 'error': 'Auth required'})), + throwsA(isA()), + ); }); }); @@ -134,4 +177,39 @@ void main() { }); }); }); + + group('setzeFavorit', () { + test('meldet den Wunsch deterministisch, nicht als Umschalten', () async { + Map? gesendet; + String? pfad; + final dienst = MeloCloudService( + auth: await _angemeldeteAuth(), + client: MockClient((anfrage) async { + pfad = anfrage.url.path; + gesendet = jsonDecode(anfrage.body) as Map; + return http.Response(jsonEncode({'status': 'ok'}), 200); + }), + ); + + await dienst.setzeFavorit('c5', true); + + expect(pfad, endsWith('/favorites/toggle')); + expect(gesendet, {'song_id': 'c5', 'set': true}); + }); + + test('kann einen Favoriten auch ausdrĂŒcklich entfernen', () async { + Map? gesendet; + final dienst = MeloCloudService( + auth: await _angemeldeteAuth(), + client: MockClient((anfrage) async { + gesendet = jsonDecode(anfrage.body) as Map; + return http.Response(jsonEncode({'status': 'ok'}), 200); + }), + ); + + await dienst.setzeFavorit('c5', false); + + expect(gesendet, {'song_id': 'c5', 'set': false}); + }); + }); } diff --git a/test/services/sync_merge_test.dart b/test/services/sync_merge_test.dart new file mode 100644 index 0000000..3f18805 --- /dev/null +++ b/test/services/sync_merge_test.dart @@ -0,0 +1,122 @@ +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); + }); + }); +} diff --git a/test/services/sync_service_test.dart b/test/services/sync_service_test.dart index f1a54b8..728c8b6 100644 --- a/test/services/sync_service_test.dart +++ b/test/services/sync_service_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -163,7 +164,7 @@ void main() { expect(await db.watchSongs().first, isEmpty); }); - test('Favoriten werden mit ihren Server-IDs gemeldet', () async { + test('lokale Favoriten werden additiv gepusht, nie als Voll-Ersatz', () async { await db.upsertSongs([ SongsCompanion.insert( id: 'lokal-1', @@ -176,7 +177,54 @@ void main() { await db.setCloudId('lokal-1', 'c5'); await db.setFavorite('lokal-1', true); - List? gemeldet; + final gepusht = >[]; + var vollErsatz = 0; + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response( + jsonEncode({ + 'songs': [ + {'id': 'c5', 'title': 'Lieblingslied'} + ] + }), + 200, + ); + } + if (pfad.endsWith('/favorites/toggle')) { + gepusht.add(jsonDecode(anfrage.body) as Map); + return http.Response(jsonEncode({'status': 'ok'}), 200); + } + if (pfad.endsWith('/favorites')) { + if (anfrage.method == 'POST') vollErsatz++; + return http.Response(jsonEncode({'favorites': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + expect(gepusht, [ + {'song_id': 'c5', 'set': true} + ]); + // Der Datenverlust-Bug ist strukturell weg: es gibt keinen Aufruf mehr, + // der den Server-Stand ersetzen könnte. + expect(vollErsatz, 0); + expect(sync.fehler, isNull); + }); + + test('ein Server-Favorit wird lokal nachgezogen', () async { + await db.upsertSongs([ + SongsCompanion.insert( + id: 'lokal-1', + path: '${tempDir.path}/fav.mp3', + title: 'Lieblingslied', + dateAddedMs: 0, + updatedAtMs: 0, + ), + ]); + await db.setCloudId('lokal-1', 'c5'); + final sync = await baue((anfrage) async { final pfad = anfrage.url.path; if (pfad.endsWith('/list')) { @@ -190,15 +238,146 @@ void main() { ); } if (pfad.endsWith('/favorites')) { - final d = jsonDecode(anfrage.body) as Map; - gemeldet = (d['song_ids'] as List).cast(); + return http.Response( + jsonEncode({ + 'favorites': [ + {'id': 'c5'} + ] + }), + 200, + ); } return http.Response(jsonEncode({'status': 'ok'}), 200); }); await sync.synchronisiere(); - expect(gemeldet, ['c5']); + 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); }); test('eine Löschwelle wird nicht zum Server durchgereicht', () async { @@ -306,4 +485,342 @@ void main() { expect(sync.fehler, contains('Anmeldung abgelaufen')); expect(sync.laeuft, isFalse); }); + + group('ladeAusgewaehlteHoch', () { + Future> dreiTitel(Directory ordner, MeloDb db) async { + for (var i = 0; i < 3; i++) { + final datei = File('${ordner.path}/auswahl$i.mp3'); + await datei.writeAsBytes([i]); + await db.upsertSongs([ + SongsCompanion.insert( + id: 'lokal-$i', + path: datei.path, + title: 'Titel $i', + dateAddedMs: i, + updatedAtMs: 0, + ), + ]); + } + return db.allSongs(); + } + + test('lĂ€dt nur, was noch keine cloudId hat', () async { + final songs = await dreiTitel(tempDir, db); + await db.setCloudId('lokal-1', 'schon-da'); + + var uploads = 0; + final sync = await baue((anfrage) async { + if (anfrage.url.path.endsWith('/upload')) { + uploads++; + return http.Response( + jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs()); + + expect(songs, hasLength(3)); + expect(uploads, 2); + expect(ergebnis.hochgeladen, 2); + expect(ergebnis.schonDa, 1); + expect(ergebnis.fehler, isEmpty); + }); + + test('ein abgelehnter Titel stoppt die ĂŒbrigen nicht', () async { + await dreiTitel(tempDir, db); + + var uploads = 0; + final sync = await baue((anfrage) async { + if (anfrage.url.path.endsWith('/upload')) { + uploads++; + // Der Server meldet „zu groß" im Körper — dieselbe Wirkung wie eine + // lokal abgelehnte 50-MB-Datei, ohne 50 MB schreiben zu mĂŒssen. + if (uploads == 2) { + return http.Response( + jsonEncode({'error': 'Datei zu groß (max 50 MB)'}), 200); + } + return http.Response( + jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs()); + + expect(ergebnis.hochgeladen, 2); + expect(ergebnis.fehler, hasLength(1)); + expect(ergebnis.fehler.single, contains('zu groß')); + }); + + test('eine ZeitĂŒberschreitung ist ein Einzelfehler, kein Laufabbruch', + () async { + await dreiTitel(tempDir, db); + + var uploads = 0; + final sync = await baue((anfrage) async { + if (anfrage.url.path.endsWith('/upload')) { + uploads++; + if (uploads == 1) throw TimeoutException('zu lang'); + return http.Response( + jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs()); + + expect(ergebnis.hochgeladen, 2); + expect(ergebnis.fehler, hasLength(1)); + }); + + test('abbrechen() stoppt zwischen zwei Titeln', () async { + await dreiTitel(tempDir, db); + + late SyncService sync; + var uploads = 0; + sync = await baue((anfrage) async { + if (anfrage.url.path.endsWith('/upload')) { + uploads++; + sync.abbrechen(); + return http.Response( + jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs()); + + expect(uploads, 1); + expect(ergebnis.abgebrochen, isTrue); + expect(sync.laeuft, isFalse); + }); + + test('schreibt den Sync-Zeitstempel nicht', () async { + await dreiTitel(tempDir, db); + + final sync = await baue((anfrage) async { + if (anfrage.url.path.endsWith('/upload')) { + return http.Response( + jsonEncode({'status': 'ok', 'song_id': 'neu'}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.ladeAusgewaehlteHoch(await db.allSongs()); + + // Ein Upload ist kein Abgleich: sonst unterdrĂŒckt er 15 Minuten den + // Auto-Sync und verschiebt die 24-h-Uhr des Berichts. + expect(sync.letzterLauf, isNull); + }); + + test('wĂ€hrend eines laufenden Abgleichs wird abgewiesen', () async { + await dreiTitel(tempDir, db); + + late SyncService sync; + UploadErgebnis? waehrendSync; + sync = await baue((anfrage) async { + if (anfrage.url.path.endsWith('/list')) { + waehrendSync = await sync.ladeAusgewaehlteHoch(await db.allSongs()); + return http.Response(jsonEncode({'songs': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + expect(waehrendSync, isNotNull); + expect(waehrendSync!.hochgeladen, 0); + expect(sync.fehler, contains('Abgleich')); + }); + }); + + group('Sync-Bericht', () { + test('beim allerersten Lauf gibt es keinen Bericht', () async { + final sync = await baue((anfrage) async { + if (anfrage.url.path.endsWith('/list')) { + return http.Response(jsonEncode({'songs': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + // letzterErfolg war null: „Willkommen zurĂŒck!" auf einem frisch + // eingerichteten GerĂ€t wĂ€re Unsinn. + expect(sync.bericht, isNull); + }); + + test('nach mehr als 24 Stunden kommt der Bericht mit ZĂ€hlern', () async { + final vorgestern = DateTime.now().subtract(const Duration(days: 2)); + SharedPreferences.setMockInitialValues({ + 'cloud_sync_letzter_erfolg': vorgestern.millisecondsSinceEpoch, + }); + + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response( + jsonEncode({ + 'songs': [ + {'id': 'c1', 'title': 'Neu', 'artist': 'X', 'duration': 100} + ] + }), + 200, + ); + } + if (pfad.contains('/download/')) { + return http.Response.bytes([1], 200, + headers: {'content-type': 'audio/mpeg'}); + } + if (pfad.endsWith('/favorites')) { + return http.Response(jsonEncode({'favorites': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + await sync.laden(); + + await sync.synchronisiere(); + + expect(sync.bericht, isNotNull); + expect(sync.bericht!.neueSongs, 1); + sync.berichtGesehen(); + expect(sync.bericht, isNull); + }); + + test('eine ausgefallene Phase verschiebt den Erfolgs-Zeitstempel nicht', + () async { + SharedPreferences.setMockInitialValues({}); + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response(jsonEncode({'songs': []}), 200); + } + if (pfad.endsWith('/favorites')) { + // Fehler im 200er-Körper: die Favoriten-Phase fĂ€llt aus. + return http.Response( + jsonEncode({'status': 'error', 'error': 'kaputt'}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getInt('cloud_sync_letzter_erfolg'), isNull); + // Die Drossel lĂ€uft trotzdem weiter — sonst rennt der Sync bei jedem + // Tab-Wechsel neu los. + expect(sync.letzterLauf, isNotNull); + }); + + test('eine gescheiterte Löschmeldung verschiebt den Erfolgs-Zeitstempel ' + 'nicht', () async { + SharedPreferences.setMockInitialValues({}); + await db.upsertSongs([ + SongsCompanion.insert( + id: 'lokal-1', + path: '${tempDir.path}/weg.mp3', + title: 'Weg', + dateAddedMs: 0, + updatedAtMs: 0, + deleted: const Value(true), + ), + ]); + await db.setCloudId('lokal-1', 'c5'); + + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response( + jsonEncode({ + 'songs': [ + {'id': 'c5', 'title': 'Weg'} + ] + }), + 200, + ); + } + if (pfad.endsWith('/delete')) { + // _meldeLoeschungen schluckt die CloudException — ohne RĂŒckgabe + // bis zum Flag hĂ€tte der Lauf trotzdem als erfolgreich gegolten. + return http.Response(jsonEncode({'error': 'kaputt'}), 500); + } + if (pfad.endsWith('/favorites')) { + return http.Response(jsonEncode({'favorites': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getInt('cloud_sync_letzter_erfolg'), isNull); + expect(sync.letzterLauf, isNotNull); + }); + + test('ein gescheiterter Upload verschiebt den Erfolgs-Zeitstempel nicht', + () async { + SharedPreferences.setMockInitialValues({}); + final datei = File('${tempDir.path}/zu-gross.mp3'); + await datei.writeAsBytes([1]); + await db.upsertSongs([ + SongsCompanion.insert( + id: 'lokal-1', + path: datei.path, + title: 'Zu groß', + dateAddedMs: 0, + updatedAtMs: 0, + ), + ]); + + final sync = await baue((anfrage) async { + final pfad = anfrage.url.path; + if (pfad.endsWith('/list')) { + return http.Response(jsonEncode({'songs': []}), 200); + } + if (pfad.endsWith('/upload')) { + return http.Response( + jsonEncode({'error': 'Datei zu groß (max 50 MB)'}), 200); + } + if (pfad.endsWith('/favorites')) { + return http.Response(jsonEncode({'favorites': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + await sync.synchronisiere(); + + final prefs = await SharedPreferences.getInstance(); + expect(prefs.getInt('cloud_sync_letzter_erfolg'), isNull); + }); + + test('der Zeitstempel ist der Stand VOR dem Listen', () async { + final sync = await baue((anfrage) async { + if (anfrage.url.path.endsWith('/list')) { + // WĂ€hrend des Laufs vergeht Zeit — der Zeitstempel darf nicht + // danach genommen werden, sonst fallen zwischenzeitliche + // Änderungen durchs Raster (Tombstone-Race, v2-Lektion). + await Future.delayed(const Duration(milliseconds: 50)); + return http.Response(jsonEncode({'songs': []}), 200); + } + return http.Response(jsonEncode({'status': 'ok'}), 200); + }); + + final vorher = DateTime.now(); + await sync.synchronisiere(); + final nachher = DateTime.now(); + + expect(sync.letzterLauf!.isBefore(nachher), isTrue); + expect( + sync.letzterLauf! + .isAfter(vorher.subtract(const Duration(milliseconds: 1))), + isTrue, + ); + expect( + nachher.difference(sync.letzterLauf!) >= + const Duration(milliseconds: 50), + isTrue, + ); + }); + }); } diff --git a/test/shared/server_upload_aktion_test.dart b/test/shared/server_upload_aktion_test.dart new file mode 100644 index 0000000..3635994 --- /dev/null +++ b/test/shared/server_upload_aktion_test.dart @@ -0,0 +1,173 @@ +import 'package:drift/drift.dart' show Value, driftRuntimeOptions; +import 'package:drift/native.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:melo/library/category_service.dart'; +import 'package:melo/library/database.dart'; +import 'package:melo/library/playlist_service.dart'; +import 'package:melo/services/baka_auth.dart'; +import 'package:melo/services/melo_cloud_service.dart'; +import 'package:melo/services/sync_service.dart'; +import 'package:melo/settings/app_settings.dart'; +import 'package:melo/shared/sort_store.dart'; +import 'package:melo/shared/sortable_song_list.dart'; + +class _MemorySpeicher implements TokenSpeicher { + _MemorySpeicher(this.werte); + final Map werte; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + +/// Merkt sich nur, was hochgeladen werden sollte. Der echte Upload braucht +/// Dateien und einen Server — hier geht es um den Weg vom Knopf zum Dienst. +class _FakeSync extends SyncService { + _FakeSync(MeloDb db) + : super( + db: db, + cloud: MeloCloudService( + auth: BakaAuth(speicher: _MemorySpeicher({})), + client: MockClient( + (_) async => http.Response('{"status":"ok"}', 200)), + ), + ); + + final hochgeladen = []; + + @override + Future ladeAusgewaehlteHoch(List songs) async { + hochgeladen.addAll([for (final s in songs) s.id]); + return const UploadErgebnis(hochgeladen: 1); + } +} + +/// „Auf den Server laden" gehört in „Meine Musik" — und **nur** dorthin. +/// [SortableSongList] wird in fĂŒnf Ansichten benutzt; ohne Scoping erschiene +/// die Aktion auch bei Favoriten, Wiedergabelisten und Titellisten. +/// +/// Aufbau bewusst im Testkörper, nicht in `setUp` (siehe auswahl_modus_test). +void main() { + final lieder = [ + for (var i = 0; i < 3; i++) + Song( + id: 'song-$i', + path: '/music/$i.mp3', + title: 'Titel $i', + artist: 'Neoni', + dateAddedMs: i, + updatedAtMs: 0, + deleted: false, + playCount: 0, + categoriesEdited: false, + metadataEdited: false, + ), + ]; + + Future beruhige(WidgetTester tester) => tester.pumpAndSettle( + const Duration(milliseconds: 100), + EnginePhase.sendSemanticsUpdate, + const Duration(seconds: 5), + ); + + /// RĂ€umt den Widget-Baum kontrolliert ab, statt es dem Testrahmen am + /// Testende zu ĂŒberlassen: sonst bleibt ein von drift/SnackBar beim + /// AufrĂ€umen geplanter Timer offen (siehe auswahl_modus_test.dart / + /// BACKLOG.md — derselbe HĂ€nger, dieselbe Ursache). + Future aufraeumen(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(milliseconds: 1)); + } + + Future<_FakeSync> pumpe(WidgetTester tester, + {required bool serverUpload}) async { + driftRuntimeOptions.dontWarnAboutMultipleDatabases = true; + SharedPreferences.setMockInitialValues({}); + final db = MeloDb(NativeDatabase.memory()); + // Kein db.close(): drift plant dabei eine AufrĂ€um-Aufgabe (Timer) ein, + // die der Testrahmen nicht mehr abarbeitet (siehe BACKLOG.md und + // auswahl_modus_test.dart — derselbe HĂ€nger, dieselbe Ursache). + final sync = _FakeSync(db); + final einstellungen = AppSettings(); + await einstellungen.init(); + for (final song in lieder) { + await db.into(db.songs).insert(SongsCompanion.insert( + id: song.id, + path: song.path, + title: song.title, + artist: Value(song.artist), + dateAddedMs: song.dateAddedMs, + updatedAtMs: 0, + )); + } + await tester.pumpWidget( + MultiProvider( + providers: [ + Provider.value(value: db), + ChangeNotifierProvider.value( + value: CategoryService(db)), + ChangeNotifierProvider.value( + value: PlaylistService(db)), + ChangeNotifierProvider.value(value: einstellungen), + // Ohne diesen Provider stĂŒrbe schon das erste Antippen der neuen + // Aktion in ladeAufServer mit ProviderNotFoundException. + ChangeNotifierProvider.value(value: sync), + ], + child: MaterialApp( + home: Scaffold( + body: SortableSongList( + songs: lieder, + storeKey: SortStore.meineMusik, + serverUpload: serverUpload, + ), + ), + ), + ), + ); + await beruhige(tester); + await tester.longPress(find.text('Titel 0')); + await beruhige(tester); + return sync; + } + + testWidgets('in „Meine Musik" erscheint die Server-Aktion', (tester) async { + await pumpe(tester, serverUpload: true); + + expect(find.byTooltip('Auf den Server laden'), findsOneWidget); + + await aufraeumen(tester); + }); + + 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); + + await aufraeumen(tester); + }); + + 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); + + await aufraeumen(tester); + }); +} diff --git a/test/shared/sync_bericht_dialog_test.dart b/test/shared/sync_bericht_dialog_test.dart new file mode 100644 index 0000000..07d71de --- /dev/null +++ b/test/shared/sync_bericht_dialog_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:melo/services/sync_service.dart'; +import 'package:melo/shared/sync_bericht_dialog.dart'; + +void main() { + Future zeige(WidgetTester tester, SyncBericht bericht) async { + await tester.pumpWidget(MaterialApp( + home: Scaffold(body: SyncBerichtDialog(bericht: bericht)), + )); + await tester.pump(); + } + + testWidgets('nennt neue und entfernte Titel', (tester) async { + await zeige( + tester, + const SyncBericht(neueSongs: 3, geloeschte: 1, favoriten: 0), + ); + + expect(find.text('Willkommen zurĂŒck!'), findsOneWidget); + expect(find.textContaining('3 neue Titel'), findsOneWidget); + expect(find.textContaining('1 entfernt'), findsOneWidget); + }); + + testWidgets('ohne Änderungen sagt er das auch', (tester) async { + await zeige( + tester, + const SyncBericht(neueSongs: 0, geloeschte: 0, favoriten: 0), + ); + + expect(find.textContaining('Nichts Neues'), findsOneWidget); + }); +}