From faabf28abce3dc907cf2951c03db37401c18e27d Mon Sep 17 00:00:00 2001 From: "Hermes (Server)" Date: Thu, 27 Aug 2026 13:43:20 +0200 Subject: [PATCH] Einseitige Playlist-Sicherung: melden, merken, bei leerer Tabelle holen Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012A2pmbnVNPiHdyf2GW8eLP --- lib/library/database.dart | 4 + lib/library/playlist_service.dart | 95 +++++++- lib/main.dart | 3 + test/library/playlist_sicherung_test.dart | 254 ++++++++++++++++++++++ 4 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 test/library/playlist_sicherung_test.dart diff --git a/lib/library/database.dart b/lib/library/database.dart index 0e788cd..766d16a 100644 --- a/lib/library/database.dart +++ b/lib/library/database.dart @@ -546,6 +546,10 @@ class MeloDb extends _$MeloDb { .write(SongsCompanion(cloudId: Value(cloudId))); } + Future songByCloudId(String cloudId) => + (select(songs)..where((s) => s.cloudId.equals(cloudId))) + .getSingleOrNull(); + /// Titel, die am Server gelöscht wurden, auch auf dem Gerät als gelöscht /// markieren. Grabstein statt echtem Löschen — sonst legt der nächste Scan /// sie wieder an. diff --git a/lib/library/playlist_service.dart b/lib/library/playlist_service.dart index c0c5b39..2b0ba5f 100644 --- a/lib/library/playlist_service.dart +++ b/lib/library/playlist_service.dart @@ -22,6 +22,7 @@ class PlaylistService extends ChangeNotifier { Future createPlaylist(String name, {String? description}) async { final id = await db.createPlaylist(name, description: description); notifyListeners(); + await _sichereNeuePlaylist(id, name); return id; } @@ -30,24 +31,39 @@ class PlaylistService extends ChangeNotifier { notifyListeners(); } - Future addSongToPlaylist(String playlistId, String songId, int position) async { + Future addSongToPlaylist( + String playlistId, String songId, int position) async { await db.addSongToPlaylist(playlistId, songId, position); notifyListeners(); + final cloudId = await _cloudIdDerPlaylist(playlistId); + final songCloudId = (await db.songById(songId))?.cloudId; + if (cloudId == null || songCloudId == null) return; + await _still(() => + _cloud!.fuegePlaylistSongsHinzu(cloudId, [songCloudId])); } - Future removeSongFromPlaylist(String playlistId, String songId) async { + Future removeSongFromPlaylist( + String playlistId, String songId) async { + final songCloudId = (await db.songById(songId))?.cloudId; await db.removeSongFromPlaylist(playlistId, songId); notifyListeners(); + final cloudId = await _cloudIdDerPlaylist(playlistId); + if (cloudId == null || songCloudId == null) return; + await _still(() => _cloud!.entfernePlaylistSong(cloudId, songCloudId)); } - Future reorderSong(String playlistId, String songId, int newPosition) async { + Future reorderSong( + String playlistId, String songId, int newPosition) async { await db.reorderPlaylistSong(playlistId, songId, newPosition); notifyListeners(); + await _sichereReihenfolge(playlistId); } - Future reorderAll(String playlistId, List orderedSongIds) async { + Future reorderAll( + String playlistId, List orderedSongIds) async { await db.reorderAllPlaylistSongs(playlistId, orderedSongIds); notifyListeners(); + await _sichereReihenfolge(playlistId); } Future toggleFavorite(String songId) async { @@ -135,4 +151,75 @@ class PlaylistService extends ChangeNotifier { return 0; } } + + /// Führt [aktion] aus und verwirft jeden Fehler. + /// + /// Die Sicherung ist einseitig und ohne Rollback: schlägt sie fehl, bleibt + /// der lokale Stand, wie er ist, und die nächste Änderung versucht es + /// erneut. + Future _still(Future Function() aktion) async { + try { + await aktion(); + } catch (e) { + debugPrint('Playlist-Sicherung übersprungen: $e'); + } + } + + Future _cloudIdDerPlaylist(String playlistId) async { + if (_cloud == null || !_cloud.istAngemeldet) return null; + return (await db.playlistById(playlistId))?.cloudId; + } + + Future _sichereNeuePlaylist(String id, String name) async { + final cloud = _cloud; + if (cloud == null || !cloud.istAngemeldet) return; + await _still(() async { + final cloudId = await cloud.legePlaylistAn(name); + await db.setPlaylistCloudId(id, cloudId); + }); + } + + Future _sichereReihenfolge(String playlistId) async { + final cloudId = await _cloudIdDerPlaylist(playlistId); + if (cloudId == null) return; + final songs = await db.watchPlaylistSongs(playlistId).first; + final ids = [ + for (final s in songs) + if (s.cloudId != null) s.cloudId!, + ]; + if (ids.isEmpty) return; + await _still(() => _cloud!.setzePlaylistReihenfolge(cloudId, ids)); + } + + /// Holt die Playlisten des Servers **nur** auf ein Gerät ohne eigene: + /// Neuinstallation oder Wiederherstellung. + /// + /// Es gibt bewusst keinen Rück-Merge — Umbenennungen und Änderungen eines + /// zweiten Geräts erscheinen hier nicht. Der beidseitige Abgleich ist eine + /// eigene, spätere Spec. + Future stelleWiederHer() async { + final cloud = _cloud; + if (cloud == null || !cloud.istAngemeldet) return 0; + if (await db.countPlaylists() > 0) return 0; + + var angelegt = 0; + try { + for (final vomServer in await cloud.playlisten()) { + final id = await db.createPlaylist(vomServer.name); + await db.setPlaylistCloudId(id, vomServer.id); + final songCloudIds = await cloud.playlistSongs(vomServer.id); + var position = 0; + for (final songCloudId in songCloudIds) { + final song = await db.songByCloudId(songCloudId); + if (song == null) continue; + await db.addSongToPlaylist(id, song.id, position++); + } + angelegt++; + } + } catch (e) { + debugPrint('Playlist-Wiederherstellung abgebrochen: $e'); + } + notifyListeners(); + return angelegt; + } } diff --git a/lib/main.dart b/lib/main.dart index 27eeae6..f98b10e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -89,6 +89,9 @@ Future main() async { _db, cloud: MeloCloudService(auth: _bakaAuth), ); + // Nur auf einem Gerät ohne eigene Playlisten: nach Neuinstallation oder + // Zurücksetzen holt das die gesicherten Listen zurück. + unawaited(_playlists.stelleWiederHer()); final navidrome = NavidromeService(); await navidrome.ladeGespeicherteZugangsdaten(); _downloads = DownloadService(db: _db, navidrome: navidrome); diff --git a/test/library/playlist_sicherung_test.dart b/test/library/playlist_sicherung_test.dart new file mode 100644 index 0000000..40cff24 --- /dev/null +++ b/test/library/playlist_sicherung_test.dart @@ -0,0 +1,254 @@ +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 cloudMit( + Future Function(http.Request) antwort) async { + final auth = BakaAuth( + speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}), + ); + await auth.laden(); + return MeloCloudService(auth: auth, client: MockClient(antwort)); +} + +void main() { + test('eine neue Playlist wird gemeldet und ihre Server-ID gemerkt', + () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + final dienst = PlaylistService( + db, + cloud: await cloudMit((_) async => http.Response( + // Form von handle_playlist_create: die ID liegt unter „playlist". + jsonEncode({ + 'status': 'ok', + 'playlist': {'id': 7, 'name': 'Road Trip'} + }), + 200, + )), + ); + + final id = await dienst.createPlaylist('Road Trip'); + + expect((await db.playlistById(id))!.cloudId, '7'); + }); + + test('ein Song ohne cloudId wird nicht mitgemeldet', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + var songMeldungen = 0; + final dienst = PlaylistService( + db, + cloud: await cloudMit((anfrage) async { + if (anfrage.url.path.endsWith('/songs')) songMeldungen++; + return http.Response( + jsonEncode({ + 'status': 'ok', + 'playlist': {'id': 7, 'name': 'Mix'} + }), + 200, + ); + }), + ); + await db.into(db.songs).insert(SongsCompanion.insert( + id: 'song-1', path: '/a.mp3', title: 'A', + dateAddedMs: 0, updatedAtMs: 0, + )); + final id = await dienst.createPlaylist('Mix'); + + await dienst.addSongToPlaylist(id, 'song-1', 0); + + expect(songMeldungen, 0); + // Lokal ist er trotzdem drin — kein Fehler, nur nichts zu melden. + expect(await db.watchPlaylistSongs(id).first, hasLength(1)); + }); + + /// Legt einen Titel mit Server-ID an. Ohne den fällt jeder Push aus, und + /// die Negativtests allein hätten den ganzen Vertrag nie berührt. + Future legeSongAn(MeloDb db, String id, String cloudId) async { + await db.into(db.songs).insert(SongsCompanion.insert( + id: id, path: '/$id.mp3', title: id, + dateAddedMs: 0, updatedAtMs: 0, + )); + await db.setCloudId(id, cloudId); + } + + test('ein Song mit cloudId wird an die Server-Playlist gemeldet', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + Object? koerper; + String? pfad; + final dienst = PlaylistService( + db, + cloud: await cloudMit((anfrage) async { + if (anfrage.method == 'POST' && anfrage.url.path.endsWith('/songs')) { + pfad = anfrage.url.path; + koerper = jsonDecode(anfrage.body); + } + return http.Response( + jsonEncode({ + 'status': 'ok', + 'playlist': {'id': 7, 'name': 'Mix'} + }), + 200, + ); + }), + ); + await legeSongAn(db, 'song-1', 'c1'); + final id = await dienst.createPlaylist('Mix'); + + await dienst.addSongToPlaylist(id, 'song-1', 0); + + expect(pfad, endsWith('/playlists/7/songs')); + expect(koerper, { + 'song_ids': ['c1'] + }); + }); + + test('das Entfernen geht als DELETE auf den Song-Pfad', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + String? geloeschterPfad; + final dienst = PlaylistService( + db, + cloud: await cloudMit((anfrage) async { + if (anfrage.method == 'DELETE') geloeschterPfad = anfrage.url.path; + return http.Response( + jsonEncode({ + 'status': 'ok', + 'playlist': {'id': 7, 'name': 'Mix'} + }), + 200, + ); + }), + ); + await legeSongAn(db, 'song-1', 'c1'); + final id = await dienst.createPlaylist('Mix'); + await dienst.addSongToPlaylist(id, 'song-1', 0); + + await dienst.removeSongFromPlaylist(id, 'song-1'); + + expect(geloeschterPfad, endsWith('/playlists/7/songs/c1')); + expect(await db.watchPlaylistSongs(id).first, isEmpty); + }); + + test('eine neue Reihenfolge geht als positions-Paare raus', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + Object? koerper; + String? methode; + final dienst = PlaylistService( + db, + cloud: await cloudMit((anfrage) async { + if (anfrage.url.path.endsWith('/positions')) { + methode = anfrage.method; + koerper = jsonDecode(anfrage.body); + } + return http.Response( + jsonEncode({ + 'status': 'ok', + 'playlist': {'id': 7, 'name': 'Mix'} + }), + 200, + ); + }), + ); + await legeSongAn(db, 'song-1', 'c1'); + await legeSongAn(db, 'song-2', 'c2'); + final id = await dienst.createPlaylist('Mix'); + await dienst.addSongToPlaylist(id, 'song-1', 0); + await dienst.addSongToPlaylist(id, 'song-2', 1); + + await dienst.reorderAll(id, ['song-2', 'song-1']); + + // Unter „song_ids" hätte der Server eine leere Liste gelesen und stumm + // „ok" geantwortet — dieser Test ist der einzige Ort, an dem das auffällt. + expect(methode, 'PUT'); + expect(koerper, { + 'positions': [ + {'id': 'c2', 'position': 0}, + {'id': 'c1', 'position': 1}, + ] + }); + }); + + test('ein Endpunkt-Fehler ändert lokal nichts', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + final dienst = PlaylistService( + db, + cloud: await cloudMit( + (_) async => http.Response(jsonEncode({'error': 'weg'}), 500)), + ); + + final id = await dienst.createPlaylist('Mix'); + + expect((await db.playlistById(id))!.cloudId, isNull); + expect(await db.watchPlaylists().first, hasLength(1)); + }); + + test('Wiederherstellung greift nur bei leerer Playlisten-Tabelle', () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + final dienst = PlaylistService( + db, + cloud: await cloudMit((anfrage) async { + if (anfrage.url.path.endsWith('/playlists')) { + return http.Response( + jsonEncode({ + 'playlists': [ + {'id': 7, 'name': 'Vom Server'} + ] + }), + 200, + ); + } + return http.Response(jsonEncode({'songs': []}), 200); + }), + ); + + expect(await dienst.stelleWiederHer(), 1); + final angelegt = await db.watchPlaylists().first; + expect(angelegt.single.name, 'Vom Server'); + expect(angelegt.single.cloudId, '7'); + }); + + test('mit einer lokalen Playlist wird nichts angelegt (kein Rück-Merge)', + () async { + final db = MeloDb(NativeDatabase.memory()); + addTearDown(db.close); + final dienst = PlaylistService( + db, + cloud: await cloudMit((_) async => http.Response( + jsonEncode({ + 'playlists': [ + {'id': 7, 'name': 'Vom Server'} + ] + }), + 200, + )), + ); + await db.createPlaylist('Meine eigene'); + + expect(await dienst.stelleWiederHer(), 0); + expect(await db.watchPlaylists().first, hasLength(1)); + }); +}