Gezielter Upload mit Abbrechen aus dem Auswahl-Modus
This commit is contained in:
@@ -122,6 +122,32 @@ const autoSyncAbstand = Duration(minutes: 15);
|
|||||||
bool sollAutoSync(DateTime? letzterLauf, DateTime jetzt) =>
|
bool sollAutoSync(DateTime? letzterLauf, DateTime jetzt) =>
|
||||||
letzterLauf == null || jetzt.difference(letzterLauf) >= autoSyncAbstand;
|
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<String> fehler;
|
||||||
|
final bool abgebrochen;
|
||||||
|
|
||||||
|
String get meldung {
|
||||||
|
final teile = <String>[];
|
||||||
|
if (hochgeladen > 0) teile.add('$hochgeladen hochgeladen');
|
||||||
|
if (schonDa > 0) teile.add('$schonDa waren schon da');
|
||||||
|
if (fehler.isNotEmpty) teile.add('${fehler.length} fehlgeschlagen');
|
||||||
|
if (abgebrochen) teile.add('abgebrochen');
|
||||||
|
return teile.isEmpty ? 'Nichts zu tun' : teile.join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Hält Gerät und Server auf demselben Stand: lädt neue Server-Titel herunter,
|
/// 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
|
/// bringt eigene Dateien hoch, zieht Löschungen nach und meldet Favoriten und
|
||||||
/// Wiedergaben. Alle Geräte am selben Konto sehen dadurch dasselbe.
|
/// Wiedergaben. Alle Geräte am selben Konto sehen dadurch dasselbe.
|
||||||
@@ -148,6 +174,7 @@ class SyncService extends ChangeNotifier {
|
|||||||
String? _fehler;
|
String? _fehler;
|
||||||
String? _status;
|
String? _status;
|
||||||
DateTime? _letzterLauf;
|
DateTime? _letzterLauf;
|
||||||
|
bool _abbruchGewuenscht = false;
|
||||||
|
|
||||||
bool get laeuft => _laeuft;
|
bool get laeuft => _laeuft;
|
||||||
int get erledigt => _erledigt;
|
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<UploadErgebnis> ladeAusgewaehlteHoch(List<Song> songs) async {
|
||||||
|
if (_laeuft) {
|
||||||
|
_fehler = 'Es läuft gerade ein Abgleich — bitte kurz warten';
|
||||||
|
notifyListeners();
|
||||||
|
return const UploadErgebnis();
|
||||||
|
}
|
||||||
|
if (!cloud.istAngemeldet) {
|
||||||
|
_fehler = 'Bitte zuerst beim Baka-Konto anmelden';
|
||||||
|
notifyListeners();
|
||||||
|
return const UploadErgebnis();
|
||||||
|
}
|
||||||
|
|
||||||
|
_laeuft = true;
|
||||||
|
_abbruchGewuenscht = false;
|
||||||
|
_fehler = null;
|
||||||
|
_erledigt = 0;
|
||||||
|
_gesamt = songs.length;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
var hochgeladen = 0;
|
||||||
|
var schonDa = 0;
|
||||||
|
final fehler = <String>[];
|
||||||
|
var abgebrochen = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (final song in songs) {
|
||||||
|
if (_abbruchGewuenscht) {
|
||||||
|
abgebrochen = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (song.cloudId != null) {
|
||||||
|
schonDa++;
|
||||||
|
_erledigt++;
|
||||||
|
notifyListeners();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final datei = File(song.path);
|
||||||
|
if (!await datei.exists()) {
|
||||||
|
fehler.add('${song.title}: Datei nicht gefunden');
|
||||||
|
_erledigt++;
|
||||||
|
notifyListeners();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_melde('Sende „${song.title}" …');
|
||||||
|
try {
|
||||||
|
final cloudId = await cloud.hochladen(
|
||||||
|
datei,
|
||||||
|
dateiname:
|
||||||
|
'${_sichererDateiname(song.title)}${p.extension(song.path)}',
|
||||||
|
);
|
||||||
|
if (cloudId != null) {
|
||||||
|
await db.setCloudId(song.id, cloudId);
|
||||||
|
hochgeladen++;
|
||||||
|
} else {
|
||||||
|
fehler.add('${song.title}: keine Server-ID erhalten');
|
||||||
|
}
|
||||||
|
} on CloudException catch (e) {
|
||||||
|
fehler.add('${song.title}: ${e.message}');
|
||||||
|
} on TimeoutException {
|
||||||
|
fehler.add('${song.title}: Zeitüberschreitung');
|
||||||
|
}
|
||||||
|
_erledigt++;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_laeuft = false;
|
||||||
|
_abbruchGewuenscht = false;
|
||||||
|
_status = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
return UploadErgebnis(
|
||||||
|
hochgeladen: hochgeladen,
|
||||||
|
schonDa: schonDa,
|
||||||
|
fehler: fehler,
|
||||||
|
abgebrochen: abgebrochen,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _ziehLoeschungenNach(List<Song> songs) async {
|
Future<void> _ziehLoeschungenNach(List<Song> songs) async {
|
||||||
if (songs.isEmpty) return;
|
if (songs.isEmpty) return;
|
||||||
_melde('Entferne ${songs.length} am Server gelöschte Titel …');
|
_melde('Entferne ${songs.length} am Server gelöschte Titel …');
|
||||||
|
|||||||
@@ -485,4 +485,153 @@ void main() {
|
|||||||
expect(sync.fehler, contains('Anmeldung abgelaufen'));
|
expect(sync.fehler, contains('Anmeldung abgelaufen'));
|
||||||
expect(sync.laeuft, isFalse);
|
expect(sync.laeuft, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('ladeAusgewaehlteHoch', () {
|
||||||
|
Future<List<Song>> dreiTitel(Directory ordner, MeloDb db) async {
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
final datei = File('${ordner.path}/auswahl$i.mp3');
|
||||||
|
await datei.writeAsBytes([i]);
|
||||||
|
await db.upsertSongs([
|
||||||
|
SongsCompanion.insert(
|
||||||
|
id: 'lokal-$i',
|
||||||
|
path: datei.path,
|
||||||
|
title: 'Titel $i',
|
||||||
|
dateAddedMs: i,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return db.allSongs();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('lädt nur, was noch keine cloudId hat', () async {
|
||||||
|
final songs = await dreiTitel(tempDir, db);
|
||||||
|
await db.setCloudId('lokal-1', 'schon-da');
|
||||||
|
|
||||||
|
var uploads = 0;
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
if (anfrage.url.path.endsWith('/upload')) {
|
||||||
|
uploads++;
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs());
|
||||||
|
|
||||||
|
expect(songs, hasLength(3));
|
||||||
|
expect(uploads, 2);
|
||||||
|
expect(ergebnis.hochgeladen, 2);
|
||||||
|
expect(ergebnis.schonDa, 1);
|
||||||
|
expect(ergebnis.fehler, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein abgelehnter Titel stoppt die übrigen nicht', () async {
|
||||||
|
await dreiTitel(tempDir, db);
|
||||||
|
|
||||||
|
var uploads = 0;
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
if (anfrage.url.path.endsWith('/upload')) {
|
||||||
|
uploads++;
|
||||||
|
// Der Server meldet „zu groß" im Körper — dieselbe Wirkung wie eine
|
||||||
|
// lokal abgelehnte 50-MB-Datei, ohne 50 MB schreiben zu müssen.
|
||||||
|
if (uploads == 2) {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'error': 'Datei zu groß (max 50 MB)'}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs());
|
||||||
|
|
||||||
|
expect(ergebnis.hochgeladen, 2);
|
||||||
|
expect(ergebnis.fehler, hasLength(1));
|
||||||
|
expect(ergebnis.fehler.single, contains('zu groß'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine Zeitüberschreitung ist ein Einzelfehler, kein Laufabbruch',
|
||||||
|
() async {
|
||||||
|
await dreiTitel(tempDir, db);
|
||||||
|
|
||||||
|
var uploads = 0;
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
if (anfrage.url.path.endsWith('/upload')) {
|
||||||
|
uploads++;
|
||||||
|
if (uploads == 1) throw TimeoutException('zu lang');
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs());
|
||||||
|
|
||||||
|
expect(ergebnis.hochgeladen, 2);
|
||||||
|
expect(ergebnis.fehler, hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('abbrechen() stoppt zwischen zwei Titeln', () async {
|
||||||
|
await dreiTitel(tempDir, db);
|
||||||
|
|
||||||
|
late SyncService sync;
|
||||||
|
var uploads = 0;
|
||||||
|
sync = await baue((anfrage) async {
|
||||||
|
if (anfrage.url.path.endsWith('/upload')) {
|
||||||
|
uploads++;
|
||||||
|
sync.abbrechen();
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs());
|
||||||
|
|
||||||
|
expect(uploads, 1);
|
||||||
|
expect(ergebnis.abgebrochen, isTrue);
|
||||||
|
expect(sync.laeuft, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('schreibt den Sync-Zeitstempel nicht', () async {
|
||||||
|
await dreiTitel(tempDir, db);
|
||||||
|
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
if (anfrage.url.path.endsWith('/upload')) {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'status': 'ok', 'song_id': 'neu'}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.ladeAusgewaehlteHoch(await db.allSongs());
|
||||||
|
|
||||||
|
// Ein Upload ist kein Abgleich: sonst unterdrückt er 15 Minuten den
|
||||||
|
// Auto-Sync und verschiebt die 24-h-Uhr des Berichts.
|
||||||
|
expect(sync.letzterLauf, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('während eines laufenden Abgleichs wird abgewiesen', () async {
|
||||||
|
await dreiTitel(tempDir, db);
|
||||||
|
|
||||||
|
late SyncService sync;
|
||||||
|
UploadErgebnis? waehrendSync;
|
||||||
|
sync = await baue((anfrage) async {
|
||||||
|
if (anfrage.url.path.endsWith('/list')) {
|
||||||
|
waehrendSync = await sync.ladeAusgewaehlteHoch(await db.allSongs());
|
||||||
|
return http.Response(jsonEncode({'songs': []}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.synchronisiere();
|
||||||
|
|
||||||
|
expect(waehrendSync, isNotNull);
|
||||||
|
expect(waehrendSync!.hochgeladen, 0);
|
||||||
|
expect(sync.fehler, contains('Abgleich'));
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user