diff --git a/lib/services/sync_service.dart b/lib/services/sync_service.dart index 2781939..258903a 100644 --- a/lib/services/sync_service.dart +++ b/lib/services/sync_service.dart @@ -122,6 +122,32 @@ 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(', '); + } +} + /// 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. @@ -148,6 +174,7 @@ class SyncService extends ChangeNotifier { String? _fehler; String? _status; DateTime? _letzterLauf; + bool _abbruchGewuenscht = false; bool get laeuft => _laeuft; int get erledigt => _erledigt; @@ -230,6 +257,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 …'); diff --git a/test/services/sync_service_test.dart b/test/services/sync_service_test.dart index 38f839a..85d6838 100644 --- a/test/services/sync_service_test.dart +++ b/test/services/sync_service_test.dart @@ -485,4 +485,153 @@ 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')); + }); + }); }