Sync-Ausbau: Favoriten-Fix, Auswahl-Upload, Einzel-Offline, Playlist-Sicherung #5
@@ -42,6 +42,14 @@ class CloudSong {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Eine Playlist, wie sie der Server kennt. Mehr als Name und ID braucht die
|
||||||
|
/// einseitige Sicherung nicht.
|
||||||
|
class CloudPlaylist {
|
||||||
|
const CloudPlaylist({required this.id, required this.name});
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
}
|
||||||
|
|
||||||
/// Ein Wiedergabe-Ereignis, das zum Server gemeldet wird.
|
/// Ein Wiedergabe-Ereignis, das zum Server gemeldet wird.
|
||||||
class CloudVerlauf {
|
class CloudVerlauf {
|
||||||
const CloudVerlauf({
|
const CloudVerlauf({
|
||||||
@@ -293,6 +301,128 @@ class MeloCloudService {
|
|||||||
_pruefeStatus(antwort);
|
_pruefeStatus(antwort);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Der Körper einer Playlisten-Antwort, oder `CloudException`.
|
||||||
|
///
|
||||||
|
/// Der Server meldet Fehler im 200er-Körper unter `error`. Ein bloßes
|
||||||
|
/// `{"status":"not_found"}` **ohne** `error` (so antworten
|
||||||
|
/// `handle_playlist_remove_song` und `handle_playlist_update_positions`)
|
||||||
|
/// geht hier bewusst durch: die Sicherung ist einseitig und
|
||||||
|
/// fire-and-forget, sie verwirft jeden Fehler ohnehin.
|
||||||
|
Map<String, dynamic> _json(http.Response antwort) {
|
||||||
|
final daten = jsonDecode(antwort.body) as Map<String, dynamic>;
|
||||||
|
final fehler = daten['error'] as String?;
|
||||||
|
if (fehler != null) throw CloudException(fehler);
|
||||||
|
return daten;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legt eine Playlist am Server an und gibt deren ID zurück.
|
||||||
|
///
|
||||||
|
/// Die Identität stammt **immer** von hier: `user_playlists.id` ist
|
||||||
|
/// AUTOINCREMENT und damit stabil. Eine Zuordnung über den Namen gibt es
|
||||||
|
/// nicht — sie zerbräche beim ersten Umbenennen.
|
||||||
|
Future<String> legePlaylistAn(String name) async {
|
||||||
|
_pruefeAnmeldung();
|
||||||
|
final antwort = await _client
|
||||||
|
.post(
|
||||||
|
Uri.parse('$basisUrl/playlists'),
|
||||||
|
headers: {..._kopf, 'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'name': name}),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
_pruefeStatus(antwort);
|
||||||
|
// handle_playlist_create antwortet {"status":"ok","playlist":{"id":…}} —
|
||||||
|
// die ID liegt eine Ebene tiefer, nicht auf oberster Ebene.
|
||||||
|
final playlist = _json(antwort)['playlist'];
|
||||||
|
if (playlist is! Map<String, dynamic>) {
|
||||||
|
throw CloudException('Antwort ohne Playlist');
|
||||||
|
}
|
||||||
|
return '${playlist['id']}';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fuegePlaylistSongsHinzu(
|
||||||
|
String playlistCloudId, List<String> songCloudIds) async {
|
||||||
|
if (songCloudIds.isEmpty) return;
|
||||||
|
_pruefeAnmeldung();
|
||||||
|
final antwort = await _client
|
||||||
|
.post(
|
||||||
|
Uri.parse('$basisUrl/playlists/$playlistCloudId/songs'),
|
||||||
|
headers: {..._kopf, 'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({'song_ids': songCloudIds}),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
_pruefeStatus(antwort);
|
||||||
|
_json(antwort);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> entfernePlaylistSong(
|
||||||
|
String playlistCloudId, String songCloudId) async {
|
||||||
|
_pruefeAnmeldung();
|
||||||
|
final antwort = await _client
|
||||||
|
.delete(
|
||||||
|
Uri.parse('$basisUrl/playlists/$playlistCloudId/songs/$songCloudId'),
|
||||||
|
headers: _kopf,
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
_pruefeStatus(antwort);
|
||||||
|
_json(antwort);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Schreibt die Reihenfolge einer Playlist am Server fest.
|
||||||
|
///
|
||||||
|
/// Der Körper heißt `positions` und trägt Paare aus `id` und `position`:
|
||||||
|
/// Der Router liest `d.get('positions',[])`, der Handler greift je Eintrag
|
||||||
|
/// auf beide Schlüssel zu. Eine blanke ID-Liste unter `song_ids` käme als
|
||||||
|
/// leere Liste an — der Server antwortete stumm `{"status":"ok"}` und
|
||||||
|
/// änderte nichts.
|
||||||
|
Future<void> setzePlaylistReihenfolge(
|
||||||
|
String playlistCloudId, List<String> songCloudIds) async {
|
||||||
|
_pruefeAnmeldung();
|
||||||
|
final antwort = await _client
|
||||||
|
.put(
|
||||||
|
Uri.parse('$basisUrl/playlists/$playlistCloudId/positions'),
|
||||||
|
headers: {..._kopf, 'Content-Type': 'application/json'},
|
||||||
|
body: jsonEncode({
|
||||||
|
'positions': [
|
||||||
|
for (var i = 0; i < songCloudIds.length; i++)
|
||||||
|
{'id': songCloudIds[i], 'position': i},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
_pruefeStatus(antwort);
|
||||||
|
_json(antwort);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<CloudPlaylist>> playlisten() async {
|
||||||
|
_pruefeAnmeldung();
|
||||||
|
final antwort = await _client
|
||||||
|
.get(Uri.parse('$basisUrl/playlists'), headers: _kopf)
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
_pruefeStatus(antwort);
|
||||||
|
final liste = _json(antwort)['playlists'];
|
||||||
|
if (liste is! List) throw CloudException('Antwort ohne Playlisten');
|
||||||
|
return [
|
||||||
|
for (final j in liste)
|
||||||
|
CloudPlaylist(
|
||||||
|
id: '${(j as Map<String, dynamic>)['id']}',
|
||||||
|
name: j['name'] as String? ?? 'Ohne Namen',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> playlistSongs(String playlistCloudId) async {
|
||||||
|
_pruefeAnmeldung();
|
||||||
|
final antwort = await _client
|
||||||
|
.get(Uri.parse('$basisUrl/playlists/$playlistCloudId'), headers: _kopf)
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
_pruefeStatus(antwort);
|
||||||
|
final liste = _json(antwort)['songs'];
|
||||||
|
if (liste is! List) throw CloudException('Antwort ohne Titel');
|
||||||
|
return [
|
||||||
|
for (final j in liste) '${(j as Map<String, dynamic>)['id']}',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/// Meldet Wiedergaben. Der Server nimmt höchstens 100 je Aufruf an und
|
/// Meldet Wiedergaben. Der Server nimmt höchstens 100 je Aufruf an und
|
||||||
/// verwirft Doppelmeldungen desselben Titels innerhalb einer Stunde.
|
/// verwirft Doppelmeldungen desselben Titels innerhalb einer Stunde.
|
||||||
Future<void> meldeVerlauf(List<CloudVerlauf> eintraege) async {
|
Future<void> meldeVerlauf(List<CloudVerlauf> eintraege) async {
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.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> baue(
|
||||||
|
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('legePlaylistAn liefert die Server-ID', () async {
|
||||||
|
final dienst = await baue((anfrage) async {
|
||||||
|
expect(anfrage.method, 'POST');
|
||||||
|
expect(anfrage.url.path, endsWith('/playlists'));
|
||||||
|
expect(jsonDecode(anfrage.body), {'name': 'Road Trip'});
|
||||||
|
// handle_playlist_create verpackt die ID unter „playlist" — genau so
|
||||||
|
// antwortet der echte Server, und genau daran ist der Vertrag geknüpft.
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'status': 'ok',
|
||||||
|
'playlist': {'id': 7, 'name': 'Road Trip', 'song_count': 0}
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await dienst.legePlaylistAn('Road Trip'), '7');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine Antwort ohne playlist-Block ist ein Fehler', () async {
|
||||||
|
// Die ID auf oberster Ebene zu suchen wäre der naheliegende Fehler; er
|
||||||
|
// fiele am echten Server als „null" auf und sonst nirgends.
|
||||||
|
final dienst = await baue(
|
||||||
|
(_) async => http.Response(jsonEncode({'status': 'ok'}), 200));
|
||||||
|
|
||||||
|
expect(() => dienst.legePlaylistAn('Road Trip'),
|
||||||
|
throwsA(isA<CloudException>()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein Fehler im 200er-Körper wird geworfen', () async {
|
||||||
|
final dienst = await baue((_) async =>
|
||||||
|
http.Response(jsonEncode({'error': 'kein Name'}), 200));
|
||||||
|
|
||||||
|
expect(() => dienst.legePlaylistAn(''),
|
||||||
|
throwsA(isA<CloudException>()));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fuegePlaylistSongsHinzu meldet die Song-IDs', () async {
|
||||||
|
Map<String, dynamic>? gesendet;
|
||||||
|
final dienst = await baue((anfrage) async {
|
||||||
|
expect(anfrage.url.path, endsWith('/playlists/7/songs'));
|
||||||
|
gesendet = jsonDecode(anfrage.body) as Map<String, dynamic>;
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await dienst.fuegePlaylistSongsHinzu('7', ['c1', 'c2']);
|
||||||
|
|
||||||
|
expect(gesendet, {
|
||||||
|
'song_ids': ['c1', 'c2']
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('entfernePlaylistSong benutzt DELETE auf dem Song-Pfad', () async {
|
||||||
|
String? pfad;
|
||||||
|
String? methode;
|
||||||
|
final dienst = await baue((anfrage) async {
|
||||||
|
pfad = anfrage.url.path;
|
||||||
|
methode = anfrage.method;
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await dienst.entfernePlaylistSong('7', 'c1');
|
||||||
|
|
||||||
|
expect(methode, 'DELETE');
|
||||||
|
expect(pfad, endsWith('/playlists/7/songs/c1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setzePlaylistReihenfolge benutzt PUT auf /positions', () async {
|
||||||
|
String? methode;
|
||||||
|
Map<String, dynamic>? gesendet;
|
||||||
|
final dienst = await baue((anfrage) async {
|
||||||
|
methode = anfrage.method;
|
||||||
|
expect(anfrage.url.path, endsWith('/playlists/7/positions'));
|
||||||
|
gesendet = jsonDecode(anfrage.body) as Map<String, dynamic>;
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await dienst.setzePlaylistReihenfolge('7', ['c2', 'c1']);
|
||||||
|
|
||||||
|
expect(methode, 'PUT');
|
||||||
|
// Der Router liest „positions", der Handler erwartet Paare. Unter
|
||||||
|
// „song_ids" bekäme der Server eine leere Liste und antwortete stumm
|
||||||
|
// „ok" — die Reihenfolge käme nie an, ohne jede Fehlermeldung.
|
||||||
|
expect(gesendet, {
|
||||||
|
'positions': [
|
||||||
|
{'id': 'c2', 'position': 0},
|
||||||
|
{'id': 'c1', 'position': 1},
|
||||||
|
]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('playlisten liest Name und ID', () async {
|
||||||
|
final dienst = await baue((_) async => http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'playlists': [
|
||||||
|
{'id': 7, 'name': 'Road Trip'}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
));
|
||||||
|
|
||||||
|
final listen = await dienst.playlisten();
|
||||||
|
|
||||||
|
expect(listen.single.id, '7');
|
||||||
|
expect(listen.single.name, 'Road Trip');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('playlistSongs liefert die Song-IDs in Reihenfolge', () async {
|
||||||
|
final dienst = await baue((_) async => http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'songs': [
|
||||||
|
{'id': 'c1'},
|
||||||
|
{'id': 'c2'},
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(await dienst.playlistSongs('7'), ['c1', 'c2']);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user