diff --git a/lib/library/playlist_service.dart b/lib/library/playlist_service.dart index 4e2bcca..c0c5b39 100644 --- a/lib/library/playlist_service.dart +++ b/lib/library/playlist_service.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart'; +import '../services/melo_cloud_service.dart'; import '../services/navidrome_service.dart'; import 'database.dart'; @@ -6,11 +7,18 @@ import 'database.dart'; /// nach jeder Mutation (für Feedback wie SnackBars — die Listen selbst /// beobachten UIs direkt über die watch()-Streams von [MeloDb]). class PlaylistService extends ChangeNotifier { - PlaylistService(this.db, {NavidromeService? navidrome}) - : _navidrome = navidrome ?? NavidromeService(); + // `this._cloud` würde den öffentlichen Parameternamen `cloud` zu `_cloud` + // (privat) ändern und die API brechen. + PlaylistService(this.db, {NavidromeService? navidrome, MeloCloudService? cloud}) + : _navidrome = navidrome ?? NavidromeService(), + // ignore: prefer_initializing_formals + _cloud = cloud; final MeloDb db; final NavidromeService _navidrome; + /// Optional: ohne Cloud-Zugang entfällt der Sofort-Push stillschweigend. + final MeloCloudService? _cloud; + Future createPlaylist(String name, {String? description}) async { final id = await db.createPlaylist(name, description: description); notifyListeners(); @@ -45,6 +53,26 @@ class PlaylistService extends ChangeNotifier { Future toggleFavorite(String songId) async { await db.toggleFavorite(songId); notifyListeners(); + await _meldeFavorit(songId); + } + + /// Meldet den neuen Favoriten-Stand sofort an die Melo-Cloud. + /// + /// Deterministisch (`set:true/false`), nicht als Umschalten: ein + /// abweichender Server-Stand darf den Wunsch nicht invertieren. Nur für + /// Titel mit cloudId, und Fehler werden still geschluckt — der nächste + /// Abgleich pusht additiv nach. + Future _meldeFavorit(String songId) async { + final cloud = _cloud; + if (cloud == null || !cloud.istAngemeldet) return; + final cloudId = (await db.songById(songId))?.cloudId; + if (cloudId == null) return; + final gesetzt = await db.watchIsFavorite(songId).first; + try { + await cloud.setzeFavorit(cloudId, gesetzt); + } catch (e) { + debugPrint('Favorit nicht gemeldet: $e'); + } } /// Lädt alle Favoriten vom Navidrome-Server und importiert sie lokal. diff --git a/lib/main.dart b/lib/main.dart index e9fa0fc..696311e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -51,7 +51,6 @@ Future main() async { await logger.init(); _db = MeloDb(); _library = LibraryService(_db); - _playlists = PlaylistService(_db); _offlineMode = OfflineMode(); await _offlineMode.init(); _categories = CategoryService(_db); @@ -83,6 +82,12 @@ Future main() async { cloud: MeloCloudService(auth: _bakaAuth), ); await _sync.laden(); + // Erst hier: der Sofort-Push braucht den angemeldeten Cloud-Zugang, und + // _bakaAuth entsteht weiter oben. + _playlists = PlaylistService( + _db, + cloud: MeloCloudService(auth: _bakaAuth), + ); final navidrome = NavidromeService(); await navidrome.ladeGespeicherteZugangsdaten(); _downloads = DownloadService(db: _db, navidrome: navidrome); diff --git a/test/library/playlist_service_test.dart b/test/library/playlist_service_test.dart index 2bbb0dd..62bb629 100644 --- a/test/library/playlist_service_test.dart +++ b/test/library/playlist_service_test.dart @@ -1,7 +1,32 @@ +import 'dart:convert'; + import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; import 'package:melo/library/database.dart'; import 'package:melo/library/playlist_service.dart'; +import 'package:melo/services/baka_auth.dart'; +import 'package:melo/services/melo_cloud_service.dart'; + +class _MemorySpeicher implements TokenSpeicher { + _MemorySpeicher(this.werte); + final Map werte; + @override + Future lesen(String key) async => werte[key]; + @override + Future schreiben(String key, String wert) async => werte[key] = wert; + @override + Future loeschen(String key) async => werte.remove(key); +} + +Future _angemeldeteAuth() async { + final auth = BakaAuth( + speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}), + ); + await auth.laden(); + return auth; +} void main() { late MeloDb db; @@ -61,4 +86,110 @@ void main() { expect(songs.map((s) => s.id).toList(), ['song-3', 'song-1', 'song-2']); expect(notified, true); }); + + group('Sofort-Push der Favoriten', () { + Future legeSongAn(MeloDb db, {String? cloudId}) async { + await db.into(db.songs).insert(SongsCompanion.insert( + id: 'song-1', + path: '/a.mp3', + title: 'A', + dateAddedMs: 0, + updatedAtMs: 0, + )); + if (cloudId != null) await db.setCloudId('song-1', cloudId); + } + + test('setzt deterministisch true beim Favorisieren', () async { + final db2 = MeloDb(NativeDatabase.memory()); + addTearDown(db2.close); + await legeSongAn(db2, cloudId: 'c1'); + + final gesendet = >[]; + final dienst = PlaylistService( + db2, + cloud: MeloCloudService( + auth: await _angemeldeteAuth(), + client: MockClient((anfrage) async { + gesendet.add(jsonDecode(anfrage.body) as Map); + return http.Response(jsonEncode({'status': 'ok'}), 200); + }), + ), + ); + + await dienst.toggleFavorite('song-1'); + + expect(gesendet, [ + {'song_id': 'c1', 'set': true} + ]); + }); + + test('setzt deterministisch false beim Ent-Favorisieren', () async { + final db2 = MeloDb(NativeDatabase.memory()); + addTearDown(db2.close); + await legeSongAn(db2, cloudId: 'c1'); + await db2.setFavorite('song-1', true); + + final gesendet = >[]; + final dienst = PlaylistService( + db2, + cloud: MeloCloudService( + auth: await _angemeldeteAuth(), + client: MockClient((anfrage) async { + gesendet.add(jsonDecode(anfrage.body) as Map); + return http.Response(jsonEncode({'status': 'ok'}), 200); + }), + ), + ); + + await dienst.toggleFavorite('song-1'); + + expect(gesendet, [ + {'song_id': 'c1', 'set': false} + ]); + }); + + test('ohne cloudId wird nichts gemeldet', () async { + final db2 = MeloDb(NativeDatabase.memory()); + addTearDown(db2.close); + await legeSongAn(db2); + + var anfragen = 0; + final dienst = PlaylistService( + db2, + cloud: MeloCloudService( + auth: await _angemeldeteAuth(), + client: MockClient((_) async { + anfragen++; + return http.Response(jsonEncode({'status': 'ok'}), 200); + }), + ), + ); + + await dienst.toggleFavorite('song-1'); + + expect(anfragen, 0); + expect(await db2.watchIsFavorite('song-1').first, isTrue); + }); + + test('ein Fehler des Servers ändert lokal nichts und wirft nicht', + () async { + final db2 = MeloDb(NativeDatabase.memory()); + addTearDown(db2.close); + await legeSongAn(db2, cloudId: 'c1'); + + final dienst = PlaylistService( + db2, + cloud: MeloCloudService( + auth: await _angemeldeteAuth(), + client: MockClient( + (_) async => http.Response(jsonEncode({'error': 'weg'}), 500)), + ), + ); + + // Der nächste Voll-Abgleich holt den Push additiv nach. + await dienst.toggleFavorite('song-1'); + + expect(await db2.watchIsFavorite('song-1').first, isTrue); + }); + }); }