Sync-Ausbau: Favoriten-Fix, Auswahl-Upload, Einzel-Offline, Playlist-Sicherung #5

Open
dustin wants to merge 18 commits from feature/sync-ausbau into fix/p0-vollwertigkeit
2 changed files with 288 additions and 11 deletions
Showing only changes of commit 9de61bbf94 - Show all commits
+99 -11
View File
@@ -148,6 +148,22 @@ class UploadErgebnis {
} }
} }
/// 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, /// 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.
@@ -160,6 +176,7 @@ class SyncService extends ChangeNotifier {
}) : _musikOrdner = musikOrdner ?? _standardMusikOrdner; }) : _musikOrdner = musikOrdner ?? _standardMusikOrdner;
static const _letzterLaufKey = 'cloud_sync_letzter_lauf'; static const _letzterLaufKey = 'cloud_sync_letzter_lauf';
static const _letzterErfolgKey = 'cloud_sync_letzter_erfolg';
static const _verlaufStandKey = 'cloud_sync_verlauf_stand'; static const _verlaufStandKey = 'cloud_sync_verlauf_stand';
static const _uuid = Uuid(); static const _uuid = Uuid();
@@ -174,6 +191,8 @@ class SyncService extends ChangeNotifier {
String? _fehler; String? _fehler;
String? _status; String? _status;
DateTime? _letzterLauf; DateTime? _letzterLauf;
DateTime? _letzterErfolg;
SyncBericht? _bericht;
bool _abbruchGewuenscht = false; bool _abbruchGewuenscht = false;
bool get laeuft => _laeuft; bool get laeuft => _laeuft;
@@ -183,10 +202,23 @@ class SyncService extends ChangeNotifier {
String? get status => _status; String? get status => _status;
DateTime? get letzterLauf => _letzterLauf; 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<void> laden() async { Future<void> laden() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final ms = prefs.getInt(_letzterLaufKey); final ms = prefs.getInt(_letzterLaufKey);
if (ms != null) _letzterLauf = DateTime.fromMillisecondsSinceEpoch(ms); if (ms != null) _letzterLauf = DateTime.fromMillisecondsSinceEpoch(ms);
final erfolg = prefs.getInt(_letzterErfolgKey);
if (erfolg != null) {
_letzterErfolg = DateTime.fromMillisecondsSinceEpoch(erfolg);
}
notifyListeners(); notifyListeners();
} }
@@ -214,6 +246,14 @@ class SyncService extends ChangeNotifier {
_gesamt = 0; _gesamt = 0;
_melde('Vergleiche mit dem Server …'); _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 { try {
final amServer = await cloud.liste(); final amServer = await cloud.liste();
final plan = planeSync( final plan = planeSync(
@@ -233,18 +273,38 @@ class SyncService extends ChangeNotifier {
'$bestand Titeln würden am Server gelöscht. Das sieht nach einem ' '$bestand Titeln würden am Server gelöscht. Das sieht nach einem '
'Fehler aus (z. B. Speicherkarte nicht eingehängt) — es wurde ' 'Fehler aus (z. B. Speicherkarte nicht eingehängt) — es wurde '
'nichts gelöscht.'; '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); await _ziehLoeschungenNach(plan.lokalLoeschen);
if (!bremse) await _meldeLoeschungen(plan.serverLoeschen); if (!bremse && !await _meldeLoeschungen(plan.serverLoeschen)) {
await _ladeHerunter(plan.herunterladen); vollstaendig = false;
await _ladeHoch(plan.hochladen); }
await _gleicheFavoritenAb(); neueSongs = await _ladeHerunter(plan.herunterladen);
if (!await _ladeHoch(plan.hochladen)) vollstaendig = false;
if (!await _gleicheFavoritenAb()) vollstaendig = false;
await _meldeVerlauf(); await _meldeVerlauf();
_letzterLauf = DateTime.now(); _letzterLauf = laufBeginn;
final prefs = await SharedPreferences.getInstance(); 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) { } on CloudException catch (e) {
_fehler = e.message; _fehler = e.message;
} catch (e) { } catch (e) {
@@ -363,7 +423,13 @@ class SyncService extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
Future<void> _meldeLoeschungen(List<Song> 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<bool> _meldeLoeschungen(List<Song> songs) async {
var vollstaendig = true;
for (final song in songs) { for (final song in songs) {
_melde('Melde Löschung von „${song.title}“ …'); _melde('Melde Löschung von „${song.title}“ …');
try { try {
@@ -371,14 +437,19 @@ class SyncService extends ChangeNotifier {
} on CloudException catch (e) { } on CloudException catch (e) {
// Eine abgelehnte Löschung darf den Lauf nicht beenden. // Eine abgelehnte Löschung darf den Lauf nicht beenden.
debugPrint('Löschung „${song.title}“ übersprungen: ${e.message}'); debugPrint('Löschung „${song.title}“ übersprungen: ${e.message}');
vollstaendig = false;
} }
_erledigt++; _erledigt++;
notifyListeners(); notifyListeners();
} }
return vollstaendig;
} }
Future<void> _ladeHerunter(List<CloudSong> songs) async { /// Gibt zurück, wie viele Titel wirklich neu dazugekommen sind — die Zahl
if (songs.isEmpty) return; /// im „Was ist neu"-Bericht.
Future<int> _ladeHerunter(List<CloudSong> songs) async {
if (songs.isEmpty) return 0;
var neu = 0;
final ordner = await _musikOrdner(); final ordner = await _musikOrdner();
for (final cloudSong in songs) { for (final cloudSong in songs) {
_melde('Lade „${cloudSong.titel}“ …'); _melde('Lade „${cloudSong.titel}“ …');
@@ -418,12 +489,21 @@ class SyncService extends ChangeNotifier {
cloudId: Value(cloudSong.id), cloudId: Value(cloudSong.id),
), ),
]); ]);
neu++;
_erledigt++; _erledigt++;
notifyListeners(); notifyListeners();
} }
return neu;
} }
Future<void> _ladeHoch(List<Song> 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<bool> _ladeHoch(List<Song> songs) async {
var vollstaendig = true;
for (final song in songs) { for (final song in songs) {
final datei = File(song.path); final datei = File(song.path);
if (!await datei.exists()) { if (!await datei.exists()) {
@@ -436,19 +516,27 @@ class SyncService extends ChangeNotifier {
datei, datei,
dateiname: '${_sichererDateiname(song.title)}${p.extension(song.path)}', 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) { } on CloudException catch (e) {
// Eine zu große oder abgelehnte Datei darf den Lauf nicht beenden. // Eine zu große oder abgelehnte Datei darf den Lauf nicht beenden.
debugPrint('Upload „${song.title}“ übersprungen: ${e.message}'); debugPrint('Upload „${song.title}“ übersprungen: ${e.message}');
vollstaendig = false;
} on TimeoutException { } on TimeoutException {
// Der 120-s-Timeout (melo_cloud_service.dart:180) wirft // Der 120-s-Timeout (melo_cloud_service.dart:180) wirft
// TimeoutException, nicht CloudException — ohne diesen Zweig riss ein // TimeoutException, nicht CloudException — ohne diesen Zweig riss ein
// einziger hängender Upload den ganzen Lauf ab. // einziger hängender Upload den ganzen Lauf ab.
debugPrint('Upload „${song.title}“: Zeitüberschreitung'); debugPrint('Upload „${song.title}“: Zeitüberschreitung');
vollstaendig = false;
} }
_erledigt++; _erledigt++;
notifyListeners(); notifyListeners();
} }
return vollstaendig;
} }
/// Additiver Favoriten-Abgleich: gleicht in **beide** Richtungen an, /// Additiver Favoriten-Abgleich: gleicht in **beide** Richtungen an,
+189
View File
@@ -634,4 +634,193 @@ void main() {
expect(sync.fehler, contains('Abgleich')); 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<void>.delayed(const Duration(milliseconds: 50));
return http.Response(jsonEncode({'songs': []}), 200);
}
return http.Response(jsonEncode({'status': 'ok'}), 200);
});
final vorher = DateTime.now();
await sync.synchronisiere();
final nachher = DateTime.now();
expect(sync.letzterLauf!.isBefore(nachher), isTrue);
expect(
sync.letzterLauf!
.isAfter(vorher.subtract(const Duration(milliseconds: 1))),
isTrue,
);
expect(
nachher.difference(sync.letzterLauf!) >=
const Duration(milliseconds: 50),
isTrue,
);
});
});
} }