Sync-Ausbau: Favoriten-Fix, Auswahl-Upload, Einzel-Offline, Playlist-Sicherung #5

Open
dustin wants to merge 18 commits from feature/sync-ausbau into fix/p0-vollwertigkeit
4 changed files with 352 additions and 4 deletions
Showing only changes of commit faabf28abc - Show all commits
+4
View File
@@ -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.
+91 -4
View File
@@ -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;
}
}
+3
View File
@@ -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);
+254
View File
@@ -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));
});
}