Sync-Ausbau: Favoriten-Fix, Auswahl-Upload, Einzel-Offline, Playlist-Sicherung #5
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user