diff --git a/lib/services/melo_cloud_service.dart b/lib/services/melo_cloud_service.dart index 21dab35..4c158d4 100644 --- a/lib/services/melo_cloud_service.dart +++ b/lib/services/melo_cloud_service.dart @@ -293,19 +293,6 @@ class MeloCloudService { _pruefeStatus(antwort); } - /// Ersetzt die Favoriten am Server durch [cloudIds]. - Future setzeFavoriten(List cloudIds) async { - _pruefeAnmeldung(); - final antwort = await _client - .post( - Uri.parse('$basisUrl/favorites'), - headers: {..._kopf, 'Content-Type': 'application/json'}, - body: jsonEncode({'song_ids': cloudIds}), - ) - .timeout(const Duration(seconds: 30)); - _pruefeStatus(antwort); - } - /// 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_service.dart b/lib/services/sync_service.dart index 1db96f0..2781939 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. /// @@ -313,25 +315,77 @@ class SyncService extends ChangeNotifier { } on CloudException catch (e) { // Eine zu große oder abgelehnte Datei darf den Lauf nicht beenden. debugPrint('Upload „${song.title}“ übersprungen: ${e.message}'); + } on TimeoutException { + // Der 120-s-Timeout (melo_cloud_service.dart:180) wirft + // TimeoutException, nicht CloudException — ohne diesen Zweig riss ein + // einziger hängender Upload den ganzen Lauf ab. + debugPrint('Upload „${song.title}“: Zeitüberschreitung'); } _erledigt++; notifyListeners(); } } - 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/test/services/sync_service_test.dart b/test/services/sync_service_test.dart index f1a54b8..38f839a 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 {