Einseitige Playlist-Sicherung: melden, merken, bei leerer Tabelle holen
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012A2pmbnVNPiHdyf2GW8eLP
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0519c68b26
commit
faabf28abc
@@ -546,6 +546,10 @@ class MeloDb extends _$MeloDb {
|
|||||||
.write(SongsCompanion(cloudId: Value(cloudId)));
|
.write(SongsCompanion(cloudId: Value(cloudId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Song?> 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
|
/// 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
|
/// markieren. Grabstein statt echtem Löschen — sonst legt der nächste Scan
|
||||||
/// sie wieder an.
|
/// sie wieder an.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class PlaylistService extends ChangeNotifier {
|
|||||||
Future<String> createPlaylist(String name, {String? description}) async {
|
Future<String> createPlaylist(String name, {String? description}) async {
|
||||||
final id = await db.createPlaylist(name, description: description);
|
final id = await db.createPlaylist(name, description: description);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
await _sichereNeuePlaylist(id, name);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,24 +31,39 @@ class PlaylistService extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> addSongToPlaylist(String playlistId, String songId, int position) async {
|
Future<void> addSongToPlaylist(
|
||||||
|
String playlistId, String songId, int position) async {
|
||||||
await db.addSongToPlaylist(playlistId, songId, position);
|
await db.addSongToPlaylist(playlistId, songId, position);
|
||||||
notifyListeners();
|
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<void> removeSongFromPlaylist(String playlistId, String songId) async {
|
Future<void> removeSongFromPlaylist(
|
||||||
|
String playlistId, String songId) async {
|
||||||
|
final songCloudId = (await db.songById(songId))?.cloudId;
|
||||||
await db.removeSongFromPlaylist(playlistId, songId);
|
await db.removeSongFromPlaylist(playlistId, songId);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
final cloudId = await _cloudIdDerPlaylist(playlistId);
|
||||||
|
if (cloudId == null || songCloudId == null) return;
|
||||||
|
await _still(() => _cloud!.entfernePlaylistSong(cloudId, songCloudId));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> reorderSong(String playlistId, String songId, int newPosition) async {
|
Future<void> reorderSong(
|
||||||
|
String playlistId, String songId, int newPosition) async {
|
||||||
await db.reorderPlaylistSong(playlistId, songId, newPosition);
|
await db.reorderPlaylistSong(playlistId, songId, newPosition);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
await _sichereReihenfolge(playlistId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> reorderAll(String playlistId, List<String> orderedSongIds) async {
|
Future<void> reorderAll(
|
||||||
|
String playlistId, List<String> orderedSongIds) async {
|
||||||
await db.reorderAllPlaylistSongs(playlistId, orderedSongIds);
|
await db.reorderAllPlaylistSongs(playlistId, orderedSongIds);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
await _sichereReihenfolge(playlistId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleFavorite(String songId) async {
|
Future<void> toggleFavorite(String songId) async {
|
||||||
@@ -135,4 +151,75 @@ class PlaylistService extends ChangeNotifier {
|
|||||||
return 0;
|
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<void> _still(Future<void> Function() aktion) async {
|
||||||
|
try {
|
||||||
|
await aktion();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Playlist-Sicherung übersprungen: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _cloudIdDerPlaylist(String playlistId) async {
|
||||||
|
if (_cloud == null || !_cloud.istAngemeldet) return null;
|
||||||
|
return (await db.playlistById(playlistId))?.cloudId;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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<int> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ Future<void> main() async {
|
|||||||
_db,
|
_db,
|
||||||
cloud: MeloCloudService(auth: _bakaAuth),
|
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();
|
final navidrome = NavidromeService();
|
||||||
await navidrome.ladeGespeicherteZugangsdaten();
|
await navidrome.ladeGespeicherteZugangsdaten();
|
||||||
_downloads = DownloadService(db: _db, navidrome: navidrome);
|
_downloads = DownloadService(db: _db, navidrome: navidrome);
|
||||||
|
|||||||
@@ -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<String, String> werte;
|
||||||
|
@override
|
||||||
|
Future<String?> lesen(String key) async => werte[key];
|
||||||
|
@override
|
||||||
|
Future<void> schreiben(String key, String wert) async => werte[key] = wert;
|
||||||
|
@override
|
||||||
|
Future<void> loeschen(String key) async => werte.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<MeloCloudService> cloudMit(
|
||||||
|
Future<http.Response> 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<void> 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user