Sync-Ausbau: Favoriten-Fix, Auswahl-Upload, Einzel-Offline, Playlist-Sicherung #5
@@ -546,6 +546,10 @@ class MeloDb extends _$MeloDb {
|
||||
.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
|
||||
/// markieren. Grabstein statt echtem Löschen — sonst legt der nächste Scan
|
||||
/// sie wieder an.
|
||||
|
||||
@@ -22,6 +22,7 @@ class PlaylistService extends ChangeNotifier {
|
||||
Future<String> 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<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);
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
notifyListeners();
|
||||
await _sichereReihenfolge(playlistId);
|
||||
}
|
||||
|
||||
Future<void> 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<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,
|
||||
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);
|
||||
|
||||
@@ -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