From 3b898e238e5deda655919667360f74b178a7768d Mon Sep 17 00:00:00 2001 From: Dustin Date: Wed, 5 Aug 2026 10:33:23 +0200 Subject: [PATCH] =?UTF-8?q?v2.52=20=E2=80=94=20Favoriten-Sync-Fix:=20bidir?= =?UTF-8?q?ektionaler=20Merge=20(lokal=E2=86=94Server),=20Toggle-Push=20so?= =?UTF-8?q?fort,=20cloud=5Fid-Verdrahtung?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/main.dart | 15 ++++ lib/models/song.dart | 6 +- lib/screens/cloud_screen.dart | 58 +++++++++---- lib/services/cloud_service.dart | 12 +++ lib/services/favoriten_service.dart | 49 ++++++++++- lib/services/favoriten_sync.dart | 39 +++++++++ test/favoriten_sync_test.dart | 124 ++++++++++++++++++++++++++++ 7 files changed, 286 insertions(+), 17 deletions(-) create mode 100644 lib/services/favoriten_sync.dart create mode 100644 test/favoriten_sync_test.dart diff --git a/lib/main.dart b/lib/main.dart index febe238..f11730e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -81,6 +81,21 @@ void main() async { // Auth initialisieren (Token aus SharedPreferences laden + online validieren) await AuthService().initialisieren(); + // Favoriten-Toggle → Server-Push (Feuer-und-Vergessen, deterministisch via + // `set` statt Toggle — verhindert Doppel-Toggle). Songs ohne cloud_id + // (nicht hochgeladen) werden übersprungen; ein Fehlschlag wird beim + // nächsten vollständigen Sync korrigiert (favoritenMerge-Vereinigung). + FavoritenService().onStatusGeaendert = (songId, neuerStatus) async { + try { + final song = await DbHelper().songNachId(songId); + final cid = song?.cloudId; + if (cid == null || cid.isEmpty) return; + await CloudService().setFavorite(cid, favorit: neuerStatus); + } catch (e) { + MeloLogger().fehler('favoriten_server_push', e); + } + }; + // Android-Berechtigungen beim Start abfragen (Issue #4) // Speicherzugriff für Musik-Scan (READ_MEDIA_AUDIO / Storage) await MusikScanner().frageSpeicherZugriff(); diff --git a/lib/models/song.dart b/lib/models/song.dart index ee8375d..cabb7fb 100644 --- a/lib/models/song.dart +++ b/lib/models/song.dart @@ -15,7 +15,8 @@ class Song { final String hinzugefuegtAm; final String downloadQuelle; // "local", "youtube", "server" final String? streamUrl; // Für Server-Streaming (Navidrome) – wird bewusst NICHT in der DB persistiert (Auth-Token-Schutz) - final String? ytUrl; // YouTube-Quell-URL für Auto-Redownload bei korrupten Songs + final String? ytUrl; // YouTube-Quell-URL – NUR Fallback (Datei korrupt + kein Server-Download möglich); wird nicht mehr lokal geschrieben + final String? cloudId; // Verknüpfung lokal ↔ Cloud (Server-Song-ID) für bidirektionalen Favoriten-Sync int? zuletztPosition; // Sekunden, für Wiederaufnahme Set? tagIds; // Cache für Tag-Filterung (nicht in DB gespeichert) @@ -37,6 +38,7 @@ class Song { this.downloadQuelle = 'local', this.streamUrl, this.ytUrl, + this.cloudId, this.zuletztPosition, }) : hinzugefuegtAm = hinzugefuegtAm ?? DateTime.now().toIso8601String(); @@ -58,6 +60,7 @@ class Song { 'download_quelle': downloadQuelle, 'stream_url': null, // Token-haltige Stream-URLs nie persistieren (Sicherheit) 'yt_url': ytUrl, + 'cloud_id': cloudId, 'zuletzt_position': zuletztPosition, }; @@ -79,6 +82,7 @@ class Song { downloadQuelle: m['download_quelle'] as String? ?? 'local', streamUrl: m['stream_url'] as String?, ytUrl: m['yt_url'] as String?, + cloudId: m['cloud_id'] as String?, zuletztPosition: m['zuletzt_position'] as int?, ); diff --git a/lib/screens/cloud_screen.dart b/lib/screens/cloud_screen.dart index c8fae14..1374451 100644 --- a/lib/screens/cloud_screen.dart +++ b/lib/screens/cloud_screen.dart @@ -8,6 +8,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import '../utils/farb_theme.dart'; import '../utils/sanitize.dart'; import '../services/cloud_service.dart'; +import '../services/favoriten_service.dart'; +import '../services/favoriten_sync.dart'; import '../database/db_helper.dart'; import '../services/melo_logger.dart'; import '../models/song.dart'; @@ -36,6 +38,9 @@ class _CloudScreenState extends State bool _statusOk = false; bool _serverDatenGeladen = false; + // ─── Favoriten (bidirektionaler Sync) ─── + final FavoritenService _favoriten = FavoritenService(); + // ─── Sync-Animation ─── late final AnimationController _syncAnimController; @@ -324,17 +329,29 @@ class _CloudScreenState extends State const Duration(milliseconds: 50)); // UI-Update erlauben } - // Phase 2: Favoriten syncen - _updateSync('Synchronisiere Favoriten...', 0.55); - final favs = await widget.cloud.getFavorites(); - final favIds = favs + // Phase 2: Favoriten bidirektional synchronisieren + // Root-Cause-Fix (Sprint D): Vorher wurden die Server-Favoriten nur + // zurückgespiegelt (Server → Server) — lokale ⭐-Toggles gingen + // verloren. Jetzt: Vereinigung lokal ∪ Server, Push + lokales Markieren. + _updateSync('Sync Favoriten…', 0.55); + final serverFavs = await widget.cloud.getFavorites(); + final serverIds = serverFavs .map((f) => f['id']?.toString() ?? '') .where((id) => id.isNotEmpty) - .toList(); - await widget.cloud.syncFavorites(favIds); - await db.serverFavoritesSet(favIds); - setState(() => _favServerCount = favIds.length); - _syncedItems += favIds.length; + .toSet(); + final lokalIds = await _favoriten.favoritenCloudIds(); + final merged = favoritenMerge(lokal: lokalIds, server: serverIds); + // Lokal → Server: lokale Toggles erreichen den Server, Server-Favoriten + // bleiben erhalten (kein Datenverlust in beide Richtungen) + await widget.cloud.syncFavorites(merged); + // Server → Lokal: Server-Favoriten lokal als ⭐ markieren (wenn Song + // lokal existiert); `server_favorites` spiegelt den Merge-Zustand + for (final cid in serverIds) { + await _favoriten.merkeCloudFavorit(cid); + } + await db.serverFavoritesSet(merged); + setState(() => _favServerCount = merged.length); + _syncedItems += merged.length; // Phase 3: Playlisten abgleichen _updateSync('Lade Playlisten...', 0.7); @@ -349,22 +366,22 @@ class _CloudScreenState extends State _updateSync('Fertig!', 1.0); if (mounted) { setState(() { - _syncedItems = downloaded + favIds.length; + _syncedItems = downloaded + merged.length; }); _setzeStatus( - '$downloaded Songs + ${favIds.length} Favoriten synchronisiert', + '$downloaded Songs + ${merged.length} Favoriten synchronisiert', ok: true); // Sync-Historie fürs Dashboard festhalten await _syncHistorieEintragen( dateien: downloaded, - favoriten: favIds.length, + favoriten: merged.length, playlists: _serverPlaylists.length, ); await _ladeStatus(); } MeloLogger().aktion('cloud_sync_all', { 'downloaded': downloaded, - 'favorites': favIds.length, + 'favorites': merged.length, 'playlists': _serverPlaylists.length, }); return true; @@ -714,6 +731,7 @@ class _CloudScreenState extends State setState(() => _ladt = true); _setzeStatus('Suche lokale Songs...'); try { + final db = DbHelper(); final dir = Directory( '${(await getApplicationDocumentsDirectory()).path}/music'); if (!await dir.exists()) { @@ -754,7 +772,19 @@ class _CloudScreenState extends State ); } final sid = await widget.cloud.upload(f.path, f.path.split('/').last); - if (sid != null) count++; + if (sid != null) { + count++; + // Lokalen Song mit dem Server verknüpfen (cloud_id) — Grundlage + // für den bidirektionalen Favoriten-Sync (lokal → Server). + try { + final lokalerSong = await db.songNachPfad(f.path); + if (lokalerSong?.id != null) { + await db.cloudIdSetzen(lokalerSong!.id!, sid); + } + } catch (_) { + // Verknüpfung ist nice-to-have — Upload selbst war erfolgreich + } + } } // Abschluss-Notification if (Platform.isAndroid) { diff --git a/lib/services/cloud_service.dart b/lib/services/cloud_service.dart index d2c4b08..625e0fa 100644 --- a/lib/services/cloud_service.dart +++ b/lib/services/cloud_service.dart @@ -262,6 +262,18 @@ class CloudService extends ChangeNotifier { return await _post('/api/v1/cloud/favorites/toggle', {'song_id': songId}); } + /// Favoriten-Status deterministisch setzen (KEIN Toggle): der Server + /// erzwingt den gewünschten Zustand (`set`-Parameter). Verhindert + /// Doppel-Toggle, wenn der Server-Zustand vom lokalen abweicht + /// (Feuer-und-Vergessen-Push beim lokalen Favoriten-Toggle). + Future setFavorite(String songId, {required bool favorit}) async { + final r = await _post('/api/v1/cloud/favorites/toggle', { + 'song_id': songId, + 'set': favorit, + }); + return r?['status'] == 'ok'; + } + // ─── ✏️ Umbenennen ─── /// Song umbenennen (benutzerdefinierter Titel/Artist) diff --git a/lib/services/favoriten_service.dart b/lib/services/favoriten_service.dart index 21bd805..0d58fde 100644 --- a/lib/services/favoriten_service.dart +++ b/lib/services/favoriten_service.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/foundation.dart'; import '../database/db_helper.dart'; import '../models/song.dart'; @@ -11,6 +12,11 @@ class FavoritenService { final DbHelper _db = DbHelper(); int? _favoritenPlaylistId; + /// Wird nach jedem erfolgreichen lokalen Toggle aufgerufen (Feuer-und- + /// Vergessen → Server-Push). Wird in `main()` verdrahtet; Fehler werden + /// beim nächsten vollständigen Sync korrigiert (favoritenMerge-Vereinigung). + Future Function(int songId, bool neuerStatus)? onStatusGeaendert; + int? get favoritenId => _favoritenPlaylistId; Future init() async { @@ -32,18 +38,32 @@ class FavoritenService { /// Schaltet den Favoriten-Status um und gibt den NEUEN Status zurück /// (true = jetzt Favorit). Bei Fehlern (z. B. DB-Race) wird der Zustand /// aus der DB neu geladen statt blind geflippt — kein unhandled Throw. + /// Nach dem lokalen Toggle wird [onStatusGeaendert] Feuer-und-Vergessen + /// aufgerufen (Server-Push; Retry = nächster vollständiger Sync). Future umschalten(int songId) async { if (_favoritenPlaylistId == null) return false; try { + bool neuerStatus; if (await istFavorit(songId)) { await _db.songAusPlaylistEntfernen(_favoritenPlaylistId!, songId); - return false; + neuerStatus = false; } else { final songs = await _db.songsDerPlaylist(_favoritenPlaylistId!); // INSERT OR IGNORE in songZurPlaylist fängt Doppel-Tap-Races ab await _db.songZurPlaylist(_favoritenPlaylistId!, songId, songs.length); - return true; + neuerStatus = true; } + final cb = onStatusGeaendert; + if (cb != null) { + unawaited(() async { + try { + await cb(songId, neuerStatus); + } catch (e) { + MeloLogger().fehler('favoriten_server_push', e); + } + }()); + } + return neuerStatus; } catch (e) { debugPrint('Favoriten-Toggle fehlgeschlagen: $e'); MeloLogger().fehler('favoriten_toggle', e); @@ -57,6 +77,31 @@ class FavoritenService { return _db.songsDerPlaylist(_favoritenPlaylistId!); } + /// Cloud-IDs aller lokalen ⭐-Favoriten (Push-Richtung lokal → Server). + /// Songs ohne cloud_id (nicht hochgeladen) werden übersprungen. + Future> favoritenCloudIds() async { + if (_favoritenPlaylistId == null) return {}; + final songs = await _db.songsDerPlaylist(_favoritenPlaylistId!); + return songs + .map((s) => s.cloudId) + .whereType() + .where((c) => c.isNotEmpty) + .toSet(); + } + + /// Markiert einen Server-Favoriten (cloud_id) lokal als ⭐-Favorit + /// (Richtung Server → lokal). Fehlt der Song lokal, passiert nichts — + /// der Favorit bleibt in `server_favorites` fürs Dashboard erhalten. + Future merkeCloudFavorit(String cloudId) async { + if (_favoritenPlaylistId == null) return; + final song = await _db.songNachCloudId(cloudId); + final id = song?.id; + if (id == null) return; + if (await istFavorit(id)) return; + final songs = await _db.songsDerPlaylist(_favoritenPlaylistId!); + await _db.songZurPlaylist(_favoritenPlaylistId!, id, songs.length); + } + Future anzahlFavoriten() async { if (_favoritenPlaylistId == null) await init(); if (_favoritenPlaylistId == null) return 0; diff --git a/lib/services/favoriten_sync.dart b/lib/services/favoriten_sync.dart new file mode 100644 index 0000000..b5817c4 --- /dev/null +++ b/lib/services/favoriten_sync.dart @@ -0,0 +1,39 @@ +import 'cloud_service.dart'; + +/// Bidirektionaler Favoriten-Sync (Sprint D, v2.52). +/// +/// Root-Cause des alten Bugs: `_syncAlles` las die Server-Favoriten und +/// schrieb sie per `syncFavorites` UNVERÄNDERT zurück auf den Server — +/// lokale ⭐-Toggles gingen verloren, `server_favorites` wurde nie genutzt. +/// +/// Neues Verhalten (Merge statt Server-Spiegel): +/// 1. **Lokal → Server:** Alle cloud_ids lokaler ⭐-Favoriten werden +/// zusammen mit den Server-Favoriten hochgeladen (Vereinigung). Lokale +/// Toggles erreichen den Server damit garantiert. +/// 2. **Server → Lokal:** Server-Favoriten werden lokal als ⭐ markiert, +/// sobald der Song lokal existiert (cloud_id-Verknüpfung). +/// 3. **Feuer-und-Vergessen beim Toggle:** `CloudService.setFavorite` +/// (deterministisch, kein Toggle) direkt beim lokalen Umschalten; ein +/// Fehlschlag wird durch den nächsten vollständigen Sync korrigiert. + +/// Vereinigt lokale und Server-Favoriten (cloud_ids) — keine Datenverluste +/// in beide Richtungen. Sortiert für deterministische Tests. +List favoritenMerge({ + required Set lokal, + required Set server, +}) { + final merged = {...lokal, ...server}.toList()..sort(); + return merged; +} + +/// Feuer-und-Vergessen-Push eines Favoriten-Toggles an den Server. +/// Deterministisch via `set` (kein Doppel-Toggle-Risiko). Fehler werden +/// still geschluckt — der nächste vollständige Sync korrigiert den Zustand +/// (favoritenMerge-Vereinigung). +Future pusheFavoritAnServer( + CloudService cloud, + String cloudId, + bool favorit, +) async { + await cloud.setFavorite(cloudId, favorit: favorit); +} diff --git a/test/favoriten_sync_test.dart b/test/favoriten_sync_test.dart new file mode 100644 index 0000000..597be91 --- /dev/null +++ b/test/favoriten_sync_test.dart @@ -0,0 +1,124 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:melo_app/services/cloud_service.dart'; +import 'package:melo_app/services/favoriten_sync.dart'; + +/// Tests für den bidirektionalen Favoriten-Sync (Sprint D v2.52). +/// +/// Root-Cause-Regression: `_syncAlles` spiegelte Server-Favoriten nur zurück +/// (Server → Server) — lokale ⭐-Toggles gingen verloren. Der Fix merge-t +/// lokal ∪ Server (favoritenMerge) und pusht Toggles deterministisch via +/// `set` (kein Doppel-Toggle-Risiko). +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + http.Response jsonOk(Map body) => + http.Response(jsonEncode(body), 200, + headers: {'content-type': 'application/json'}); + + group('favoritenMerge (Vereinigung lokal ∪ Server)', () { + test('lokale Favoriten werden dem Server-Push hinzugefügt', () { + final merged = favoritenMerge( + lokal: {'cloud-1', 'cloud-2'}, + server: {'cloud-9'}, + ); + expect(merged, containsAll(['cloud-1', 'cloud-2', 'cloud-9'])); + expect(merged, hasLength(3)); + }); + + test('Server-Favoriten bleiben erhalten (kein Datenverlust)', () { + final merged = favoritenMerge( + lokal: {}, + server: {'cloud-7', 'cloud-8'}, + ); + expect(merged, containsAll(['cloud-7', 'cloud-8'])); + }); + + test('Doppelte IDs (lokal == server) werden dedupliziert', () { + final merged = favoritenMerge( + lokal: {'cloud-1', 'cloud-2'}, + server: {'cloud-2', 'cloud-3'}, + ); + expect(merged, hasLength(3)); + expect(merged.where((id) => id == 'cloud-2'), hasLength(1)); + }); + + test('leere Mengen → leere Liste', () { + expect(favoritenMerge(lokal: {}, server: {}), isEmpty); + }); + + test('deterministisch sortiert (für Tests/Stabilität)', () { + final a = favoritenMerge(lokal: {'b', 'a'}, server: {'c'}); + final b = favoritenMerge(lokal: {'c'}, server: {'b', 'a'}); + expect(a, b); + expect(a, ['a', 'b', 'c']); + }); + }); + + group('CloudService.setFavorite (deterministischer Push)', () { + test('sendet set:true und wertet status ok aus', () async { + late Map gesendeterBody; + String? pfad; + final cloud = CloudService( + client: MockClient((req) async { + pfad = req.url.path; + gesendeterBody = jsonDecode(req.body) as Map; + return jsonOk({'status': 'ok', 'favorited': true}); + }), + ); + final ok = await cloud.setFavorite('cloud-1', favorit: true); + expect(ok, isTrue); + expect(pfad, '/api/v1/cloud/favorites/toggle'); + expect(gesendeterBody['song_id'], 'cloud-1'); + expect(gesendeterBody['set'], isTrue); + }); + + test('sendet set:false beim Entfernen', () async { + late Map gesendeterBody; + final cloud = CloudService( + client: MockClient((req) async { + gesendeterBody = jsonDecode(req.body) as Map; + return jsonOk({'status': 'ok', 'favorited': false}); + }), + ); + final ok = await cloud.setFavorite('cloud-2', favorit: false); + expect(ok, isTrue); + expect(gesendeterBody['set'], isFalse); + }); + + test('Fehlerantwort (kein status ok) → false', () async { + final cloud = CloudService( + client: MockClient((req) async => jsonOk({'status': 'error'})), + ); + expect(await cloud.setFavorite('cloud-3', favorit: true), isFalse); + }); + + test('Netzwerkfehler → false (kein Crash)', () async { + final cloud = CloudService( + client: MockClient( + (req) async => throw http.ClientException('offline')), + ); + expect(await cloud.setFavorite('cloud-4', favorit: true), isFalse); + }); + }); + + group('pusheFavoritAnServer (Feuer-und-Vergessen)', () { + test('leitet Toggle-Status an setFavorite weiter', () async { + final bodies = >[]; + final cloud = CloudService( + client: MockClient((req) async { + bodies.add(jsonDecode(req.body) as Map); + return jsonOk({'status': 'ok'}); + }), + ); + await pusheFavoritAnServer(cloud, 'cloud-5', true); + await pusheFavoritAnServer(cloud, 'cloud-5', false); + expect(bodies, hasLength(2)); + expect(bodies[0]['set'], isTrue); + expect(bodies[1]['set'], isFalse); + expect(bodies[0]['song_id'], 'cloud-5'); + }); + }); +}