Herz-Tipp meldet den Favoriten sofort deterministisch an die Cloud

This commit is contained in:
Hermes (Server)
2026-08-27 11:23:38 +02:00
parent 5dc80931e2
commit 8d84a88a9c
3 changed files with 167 additions and 3 deletions
+30 -2
View File
@@ -1,4 +1,5 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../services/melo_cloud_service.dart';
import '../services/navidrome_service.dart'; import '../services/navidrome_service.dart';
import 'database.dart'; import 'database.dart';
@@ -6,11 +7,18 @@ import 'database.dart';
/// nach jeder Mutation (für Feedback wie SnackBars — die Listen selbst /// nach jeder Mutation (für Feedback wie SnackBars — die Listen selbst
/// beobachten UIs direkt über die watch()-Streams von [MeloDb]). /// beobachten UIs direkt über die watch()-Streams von [MeloDb]).
class PlaylistService extends ChangeNotifier { class PlaylistService extends ChangeNotifier {
PlaylistService(this.db, {NavidromeService? navidrome}) // `this._cloud` würde den öffentlichen Parameternamen `cloud` zu `_cloud`
: _navidrome = navidrome ?? NavidromeService(); // (privat) ändern und die API brechen.
PlaylistService(this.db, {NavidromeService? navidrome, MeloCloudService? cloud})
: _navidrome = navidrome ?? NavidromeService(),
// ignore: prefer_initializing_formals
_cloud = cloud;
final MeloDb db; final MeloDb db;
final NavidromeService _navidrome; final NavidromeService _navidrome;
/// Optional: ohne Cloud-Zugang entfällt der Sofort-Push stillschweigend.
final MeloCloudService? _cloud;
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();
@@ -45,6 +53,26 @@ class PlaylistService extends ChangeNotifier {
Future<void> toggleFavorite(String songId) async { Future<void> toggleFavorite(String songId) async {
await db.toggleFavorite(songId); await db.toggleFavorite(songId);
notifyListeners(); notifyListeners();
await _meldeFavorit(songId);
}
/// Meldet den neuen Favoriten-Stand sofort an die Melo-Cloud.
///
/// Deterministisch (`set:true/false`), nicht als Umschalten: ein
/// abweichender Server-Stand darf den Wunsch nicht invertieren. Nur für
/// Titel mit cloudId, und Fehler werden still geschluckt — der nächste
/// Abgleich pusht additiv nach.
Future<void> _meldeFavorit(String songId) async {
final cloud = _cloud;
if (cloud == null || !cloud.istAngemeldet) return;
final cloudId = (await db.songById(songId))?.cloudId;
if (cloudId == null) return;
final gesetzt = await db.watchIsFavorite(songId).first;
try {
await cloud.setzeFavorit(cloudId, gesetzt);
} catch (e) {
debugPrint('Favorit nicht gemeldet: $e');
}
} }
/// Lädt alle Favoriten vom Navidrome-Server und importiert sie lokal. /// Lädt alle Favoriten vom Navidrome-Server und importiert sie lokal.
+6 -1
View File
@@ -51,7 +51,6 @@ Future<void> main() async {
await logger.init(); await logger.init();
_db = MeloDb(); _db = MeloDb();
_library = LibraryService(_db); _library = LibraryService(_db);
_playlists = PlaylistService(_db);
_offlineMode = OfflineMode(); _offlineMode = OfflineMode();
await _offlineMode.init(); await _offlineMode.init();
_categories = CategoryService(_db); _categories = CategoryService(_db);
@@ -83,6 +82,12 @@ Future<void> main() async {
cloud: MeloCloudService(auth: _bakaAuth), cloud: MeloCloudService(auth: _bakaAuth),
); );
await _sync.laden(); await _sync.laden();
// Erst hier: der Sofort-Push braucht den angemeldeten Cloud-Zugang, und
// _bakaAuth entsteht weiter oben.
_playlists = PlaylistService(
_db,
cloud: MeloCloudService(auth: _bakaAuth),
);
final navidrome = NavidromeService(); final navidrome = NavidromeService();
await navidrome.ladeGespeicherteZugangsdaten(); await navidrome.ladeGespeicherteZugangsdaten();
_downloads = DownloadService(db: _db, navidrome: navidrome); _downloads = DownloadService(db: _db, navidrome: navidrome);
+131
View File
@@ -1,7 +1,32 @@
import 'dart:convert';
import 'package:drift/native.dart'; import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.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/database.dart';
import 'package:melo/library/playlist_service.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<BakaAuth> _angemeldeteAuth() async {
final auth = BakaAuth(
speicher: _MemorySpeicher({'baka_token': 'tok', 'baka_user': 'Baka'}),
);
await auth.laden();
return auth;
}
void main() { void main() {
late MeloDb db; late MeloDb db;
@@ -61,4 +86,110 @@ void main() {
expect(songs.map((s) => s.id).toList(), ['song-3', 'song-1', 'song-2']); expect(songs.map((s) => s.id).toList(), ['song-3', 'song-1', 'song-2']);
expect(notified, true); expect(notified, true);
}); });
group('Sofort-Push der Favoriten', () {
Future<void> legeSongAn(MeloDb db, {String? cloudId}) async {
await db.into(db.songs).insert(SongsCompanion.insert(
id: 'song-1',
path: '/a.mp3',
title: 'A',
dateAddedMs: 0,
updatedAtMs: 0,
));
if (cloudId != null) await db.setCloudId('song-1', cloudId);
}
test('setzt deterministisch true beim Favorisieren', () async {
final db2 = MeloDb(NativeDatabase.memory());
addTearDown(db2.close);
await legeSongAn(db2, cloudId: 'c1');
final gesendet = <Map<String, dynamic>>[];
final dienst = PlaylistService(
db2,
cloud: MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient((anfrage) async {
gesendet.add(jsonDecode(anfrage.body) as Map<String, dynamic>);
return http.Response(jsonEncode({'status': 'ok'}), 200);
}),
),
);
await dienst.toggleFavorite('song-1');
expect(gesendet, [
{'song_id': 'c1', 'set': true}
]);
});
test('setzt deterministisch false beim Ent-Favorisieren', () async {
final db2 = MeloDb(NativeDatabase.memory());
addTearDown(db2.close);
await legeSongAn(db2, cloudId: 'c1');
await db2.setFavorite('song-1', true);
final gesendet = <Map<String, dynamic>>[];
final dienst = PlaylistService(
db2,
cloud: MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient((anfrage) async {
gesendet.add(jsonDecode(anfrage.body) as Map<String, dynamic>);
return http.Response(jsonEncode({'status': 'ok'}), 200);
}),
),
);
await dienst.toggleFavorite('song-1');
expect(gesendet, [
{'song_id': 'c1', 'set': false}
]);
});
test('ohne cloudId wird nichts gemeldet', () async {
final db2 = MeloDb(NativeDatabase.memory());
addTearDown(db2.close);
await legeSongAn(db2);
var anfragen = 0;
final dienst = PlaylistService(
db2,
cloud: MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient((_) async {
anfragen++;
return http.Response(jsonEncode({'status': 'ok'}), 200);
}),
),
);
await dienst.toggleFavorite('song-1');
expect(anfragen, 0);
expect(await db2.watchIsFavorite('song-1').first, isTrue);
});
test('ein Fehler des Servers ändert lokal nichts und wirft nicht',
() async {
final db2 = MeloDb(NativeDatabase.memory());
addTearDown(db2.close);
await legeSongAn(db2, cloudId: 'c1');
final dienst = PlaylistService(
db2,
cloud: MeloCloudService(
auth: await _angemeldeteAuth(),
client: MockClient(
(_) async => http.Response(jsonEncode({'error': 'weg'}), 500)),
),
);
// Der nächste Voll-Abgleich holt den Push additiv nach.
await dienst.toggleFavorite('song-1');
expect(await db2.watchIsFavorite('song-1').first, isTrue);
});
});
} }