Favoriten additiv abgleichen statt ersetzen; Upload-Timeout ist Einzelfehler

This commit is contained in:
Hermes (Server)
2026-08-27 10:55:00 +02:00
parent 09d24abf4d
commit 5dc80931e2
3 changed files with 246 additions and 26 deletions
-13
View File
@@ -293,19 +293,6 @@ class MeloCloudService {
_pruefeStatus(antwort); _pruefeStatus(antwort);
} }
/// Ersetzt die Favoriten am Server durch [cloudIds].
Future<void> setzeFavoriten(List<String> 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 /// Meldet Wiedergaben. Der Server nimmt höchstens 100 je Aufruf an und
/// verwirft Doppelmeldungen desselben Titels innerhalb einer Stunde. /// verwirft Doppelmeldungen desselben Titels innerhalb einer Stunde.
Future<void> meldeVerlauf(List<CloudVerlauf> eintraege) async { Future<void> meldeVerlauf(List<CloudVerlauf> eintraege) async {
+62 -8
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:drift/drift.dart' show Value; import 'package:drift/drift.dart' show Value;
@@ -9,6 +10,7 @@ import 'package:uuid/uuid.dart';
import '../library/database.dart'; import '../library/database.dart';
import 'media_store.dart'; import 'media_store.dart';
import 'melo_cloud_service.dart'; import 'melo_cloud_service.dart';
import 'sync_merge.dart';
/// Was beim Abgleich mit welchen Titeln zu tun ist. /// Was beim Abgleich mit welchen Titeln zu tun ist.
/// ///
@@ -313,25 +315,77 @@ class SyncService extends ChangeNotifier {
} 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}');
} 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++; _erledigt++;
notifyListeners(); notifyListeners();
} }
} }
Future<void> _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<bool> _gleicheFavoritenAb() async {
_melde('Gleiche Favoriten ab …'); _melde('Gleiche Favoriten ab …');
final Set<String> 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 lokal = await db.allSongs();
final cloudIdVon = { final cloudIdVon = {
for (final s in lokal) 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 songIdVonCloudId = {
final cloudFavoriten = [ for (final s in lokal)
for (final id in favoritenIds) if (s.cloudId != null && !s.deleted) s.cloudId!: s.id,
if (cloudIdVon[id] != null) cloudIdVon[id]!, };
]; final favoriten = (await db.favoriteSongIds()).toSet();
await cloud.setzeFavoriten(cloudFavoriten);
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<void> _meldeVerlauf() async { Future<void> _meldeVerlauf() async {
+184 -5
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
@@ -163,7 +164,7 @@ void main() {
expect(await db.watchSongs().first, isEmpty); 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([ await db.upsertSongs([
SongsCompanion.insert( SongsCompanion.insert(
id: 'lokal-1', id: 'lokal-1',
@@ -176,7 +177,54 @@ void main() {
await db.setCloudId('lokal-1', 'c5'); await db.setCloudId('lokal-1', 'c5');
await db.setFavorite('lokal-1', true); await db.setFavorite('lokal-1', true);
List<String>? gemeldet; final gepusht = <Map<String, dynamic>>[];
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<String, dynamic>);
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 sync = await baue((anfrage) async {
final pfad = anfrage.url.path; final pfad = anfrage.url.path;
if (pfad.endsWith('/list')) { if (pfad.endsWith('/list')) {
@@ -190,15 +238,146 @@ void main() {
); );
} }
if (pfad.endsWith('/favorites')) { if (pfad.endsWith('/favorites')) {
final d = jsonDecode(anfrage.body) as Map<String, dynamic>; return http.Response(
gemeldet = (d['song_ids'] as List).cast<String>(); jsonEncode({
'favorites': [
{'id': 'c5'}
]
}),
200,
);
} }
return http.Response(jsonEncode({'status': 'ok'}), 200); return http.Response(jsonEncode({'status': 'ok'}), 200);
}); });
await sync.synchronisiere(); 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 { test('eine Löschwelle wird nicht zum Server durchgereicht', () async {