Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a5bbf6433 | ||
|
|
8126a16520 | ||
|
|
b7c8be6f0f | ||
|
|
b13ed6271d | ||
|
|
faabf28abc | ||
|
|
0519c68b26 | ||
|
|
3cd88565ec | ||
|
|
11a01d1a09 | ||
|
|
676f2c5f29 | ||
|
|
9de61bbf94 | ||
|
|
46ea9ef344 | ||
|
|
8e3c986d13 | ||
|
|
fcf53dcb6c | ||
|
|
78968fd6b8 | ||
|
|
8d84a88a9c | ||
|
|
5dc80931e2 | ||
|
|
09d24abf4d | ||
|
|
6250fb78c1 |
@@ -5,6 +5,55 @@ Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 💾❤️ Sync-Ausbau: Favoriten gehen nicht mehr verloren (2026-08-27)
|
||||||
|
|
||||||
|
- ❤️ **Der Favoriten-Datenverlust ist behoben.** Bisher hat jedes Gerät beim
|
||||||
|
Abgleich seine eigene Favoritenliste als Komplett-Ersatz zum Server
|
||||||
|
geschickt — ein frisch installiertes Handy löschte damit beim allerersten
|
||||||
|
Abgleich sämtliche Server-Favoriten. Neu wird nur noch **hinzugefügt**:
|
||||||
|
Was hier Favorit ist und dort fehlt, wird einzeln gemeldet; was dort
|
||||||
|
Favorit ist und hier fehlt, wird hier gesetzt. Entfernt wird in keiner
|
||||||
|
Richtung etwas. Der alte Weg (`POST /favorites`) existiert im Code nicht
|
||||||
|
mehr — der Fehler kann also nicht zurückkommen.
|
||||||
|
- 💔 **Bewusster Preis:** Ein entferntes Herz wirkt sofort auf diesem Gerät
|
||||||
|
und (online) auch am Server, ist aber **nicht geräteübergreifend
|
||||||
|
garantiert**: Hält ein zweites Gerät den Favoriten noch, bringt dessen
|
||||||
|
nächster Abgleich ihn zurück. Kein Datenverlust ist uns wichtiger als
|
||||||
|
verlässliches Löschen.
|
||||||
|
- ⚡ **Herz antippen meldet sofort.** Wer online ein Herz setzt oder entfernt,
|
||||||
|
schickt den Wunsch direkt zum Server — eindeutig als „setze auf ja/nein",
|
||||||
|
nicht als Umschalten. Geht das schief, passiert nichts Schlimmes: der
|
||||||
|
nächste Abgleich holt es nach.
|
||||||
|
- 🛡️ **Der Server darf sich nicht mehr missverständlich ausdrücken.** Kommt
|
||||||
|
auf die Favoriten-Abfrage eine Antwort ohne Favoritenliste, gilt das jetzt
|
||||||
|
als Fehler und die Favoriten-Runde wird übersprungen — vorher wurde daraus
|
||||||
|
stillschweigend „keine Favoriten".
|
||||||
|
- ☁️ **Neu: „Auf den Server laden".** Im Auswahl-Modus von „Meine Musik"
|
||||||
|
(langes Drücken) lassen sich einzelne Titel markieren und gezielt
|
||||||
|
hochladen. Titel, die schon oben sind, werden übersprungen; einzelne
|
||||||
|
Fehlschläge stoppen den Rest nicht. Die Aktion erscheint bewusst nur dort
|
||||||
|
und nicht bei Favoriten, Wiedergabelisten oder Titellisten.
|
||||||
|
- ⬇️ **Neu: einzelne Server-Titel offline nehmen.** In der Album- und
|
||||||
|
Künstler-Ansicht hat jede Zeile jetzt einen Knopf — bisher ging nur „ganzes
|
||||||
|
Album". Schon geladene Titel lassen sich dort auch wieder entfernen.
|
||||||
|
*Bekannte Einschränkung:* Ein gerade gehörter Titel liegt danach kurzzeitig
|
||||||
|
doppelt (Zwischenspeicher + Download), bis der Zwischenspeicher aufräumt.
|
||||||
|
- 🗂️ **Playlisten werden einseitig am Server gesichert** (kein Rück-Merge,
|
||||||
|
Umbenennungen propagieren nicht).
|
||||||
|
- 📰 **Neu: „Willkommen zurück".** War der letzte **erfolgreiche** Abgleich
|
||||||
|
mehr als 24 Stunden her, zeigt die App danach einmalig, was dazugekommen
|
||||||
|
und was verschwunden ist. Beim allerersten Start nach einer Neuinstallation
|
||||||
|
erscheint er absichtlich **nicht**.
|
||||||
|
- 🔧 **Unter der Haube:** Der Abgleichs-Zeitstempel wird jetzt **vor** dem
|
||||||
|
Abfragen der Serverliste genommen (sonst fallen Änderungen während des Laufs
|
||||||
|
durchs Raster); ein hängender Upload (Zeitüberschreitung) reißt nicht mehr
|
||||||
|
den ganzen Abgleich ab; ausgefallene Teilschritte verschieben die
|
||||||
|
24-Stunden-Uhr des Berichts nicht mehr.
|
||||||
|
- 🧪 Neue Tests für die Merge-Regeln, den additiven Abgleich, den Sofort-Push,
|
||||||
|
den Auswahl-Upload samt Abbrechen und den Bericht. Zwei bestehende Tests
|
||||||
|
wurden bewusst umgeschrieben, weil sie das alte (fehlerhafte) Verhalten
|
||||||
|
festschrieben.
|
||||||
|
|
||||||
### 📺🔑 YouTube-Tab: Server-User brauchen keine eigene Baka-Anmeldung mehr (2026-08-26)
|
### 📺🔑 YouTube-Tab: Server-User brauchen keine eigene Baka-Anmeldung mehr (2026-08-26)
|
||||||
|
|
||||||
- 🔑 **Server-User (Dustin, Baka, Tinker) sehen den Baka-Anmelde-Schritt nicht
|
- 🔑 **Server-User (Dustin, Baka, Tinker) sehen den Baka-Anmelde-Schritt nicht
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
# Sync-Ausbau — Kontext-Sicherung (2026-08-27)
|
|
||||||
|
|
||||||
Diese Datei ist eine Übergabe für eine neue Session. Sie ersetzt nicht die
|
|
||||||
Spec/den Plan, sondern sagt: wo stehen wir, was ist als Nächstes dran.
|
|
||||||
|
|
||||||
**Wenn diese Datei erledigt ist (Implementierung fertig oder Plan überholt):
|
|
||||||
löschen, nicht liegen lassen — sie ist kein Dauerdokument.**
|
|
||||||
|
|
||||||
## Die zwei maßgeblichen Dokumente
|
|
||||||
|
|
||||||
1. **Spec:** `docs/superpowers/specs/2026-08-27-sync-ausbau-design.md`
|
|
||||||
Status: **Freigegeben, umsetzungsbereit.** Durch ein 5-Reviewer-Panel
|
|
||||||
(agent-review-panel) geprüft, nachgeschärft, gegengelesen.
|
|
||||||
2. **Implementierungsplan:** `docs/superpowers/plans/2026-08-27-sync-ausbau.md`
|
|
||||||
13 Tasks nach TDD (echter Code in jedem Schritt, kein Platzhalter),
|
|
||||||
geprüft und korrigiert.
|
|
||||||
|
|
||||||
Beide sind committed und gepusht auf Branch `fix/p0-vollwertigkeit`
|
|
||||||
(bis Commit `1940e2b7909`).
|
|
||||||
|
|
||||||
## Was das Feature ist (kurz)
|
|
||||||
|
|
||||||
Sync-Ausbau der Melo-App: Favoriten-Merge-Fix (Datenverlust-Bug),
|
|
||||||
gezielter Song-Upload im Auswahl-Modus, Einzel-Song-Offline,
|
|
||||||
Playlist-Sicherung (einseitig), „Was ist neu"-Bericht. SSE, persistente
|
|
||||||
Notification und beidseitiger Playlist-Merge sind bewusst spätere Stufen,
|
|
||||||
nicht Teil dieser Runde.
|
|
||||||
|
|
||||||
## Aktueller Stand — was schon passiert ist
|
|
||||||
|
|
||||||
1. Bestandsaufnahme (6 parallele Leser über App/Server/Navidrome/Cross-Platform/Melo-v2).
|
|
||||||
2. Brainstorming + Spec geschrieben, mit Dustin abgestimmt.
|
|
||||||
3. **agent-review-panel** (5 Reviewer, 2 Debattenrunden, Audit, Verifikation,
|
|
||||||
Richterurteil): Score 6/10, Verdikt „nachschärfen dann freigeben". Bericht:
|
|
||||||
`docs/reviews/2026-08-27-sync-ausbau/review_panel_report.md`.
|
|
||||||
4. Spec nach den 17 Aktionspunkten des Urteils überarbeitet, gegengelesen,
|
|
||||||
freigegeben.
|
|
||||||
5. Implementierungsplan geschrieben, geprüft (fand 3 echte
|
|
||||||
Server-Vertragsfehler in Task 11 — korrigiert), Korrekturrunde committed.
|
|
||||||
6. **Der einzige echte Blocker ist behoben:** Ein irreversibler Löschpfad in
|
|
||||||
`melo_cloud.py` (verschwindet eine lokale Datei, wurde sie serverseitig
|
|
||||||
endgültig gelöscht, erneutes Hochladen reparierte es nicht). Hermes hat
|
|
||||||
den Fix eingebaut (Datei-Wiederherstellung im Dedup-Zweig von `upload()`),
|
|
||||||
ich habe ihn end-to-end getestet (eigene Testdatei, Fake-Nutzer, danach
|
|
||||||
restlos aufgeräumt — Registry vor/nach Test bei 325 Titeln): Upload →
|
|
||||||
Löschung (Bug reproduziert, Download 404) → erneuter Upload → **beide
|
|
||||||
Dateien bitgenau wiederhergestellt.** Spec und Plan sind entsprechend
|
|
||||||
aktualisiert (§ERLEDIGT in der Spec, ENTBLOCKT-Marker im Plan).
|
|
||||||
|
|
||||||
## Nächster Schritt
|
|
||||||
|
|
||||||
**Implementierungsplan, Task 1: „Reine Merge-Funktionen (`sync_merge.dart`)“**
|
|
||||||
(Zeile ~104 im Plan). Tasks 1–9 sind vollständig entblockt und können sofort
|
|
||||||
starten, in der im Plan festgelegten Reihenfolge (Favoriten-Fix zuerst).
|
|
||||||
|
|
||||||
Empfohlener Einstieg für die neue Session: die Skill
|
|
||||||
`superpowers:subagent-driven-development` (Pflicht-Sub-Skill laut Plankopf)
|
|
||||||
für die task-weise Umsetzung nutzen, wie schon beim vorherigen Feature
|
|
||||||
(YouTube-Gast-Zugang) erfolgreich gemacht.
|
|
||||||
|
|
||||||
## Was noch offen ist (kein Blocker, aber zu klären)
|
|
||||||
|
|
||||||
- **Tasks 10–12 (Playlist-Sicherung, Feature 2):** bauen oder streichen?
|
|
||||||
Heute 0 Playlisten auf dem Server. Plan empfiehlt: vertagen, bis eine
|
|
||||||
echte Playlist existiert. Steht im Plan unter „Offene Punkte, die vor
|
|
||||||
bzw. während der Umsetzung an Dustin gehen".
|
|
||||||
- **Server-Hygiene (an Hermes, eilt nicht):** `_link_user()` im Dedup-Zweig
|
|
||||||
von `upload()` legt einen dritten Hardlink unter `users/<user>/<sid>.mp3`
|
|
||||||
an, den `_entferne_datei_wenn_verwaist()` beim Löschen nicht mit abräumt.
|
|
||||||
Bisher folgenlos, aber seit dem Wiederherstellungs-Fix hinterlässt jeder
|
|
||||||
Dedup-Upload einen verwaisten Hardlink.
|
|
||||||
- **App-Seite, bewusst unangetastet:** `markMissing` tombstoned großzügig,
|
|
||||||
die Lösch-Bremse greift erst ab 109 von 325 Titeln. Der Schaden ist jetzt
|
|
||||||
reparabel (siehe oben), also kein Blocker mehr — ob die App trotzdem
|
|
||||||
vorsichtiger werden soll, ist eine eigene, spätere Entscheidung.
|
|
||||||
|
|
||||||
## Regeln, die für die neue Session gelten (aus Erfahrung dieser Session)
|
|
||||||
|
|
||||||
- Worktree-Lock beachten: `.claude-worktree-lock` im Repo-Root prüfen/setzen/
|
|
||||||
entfernen (siehe `melo-app-workflow`-Skill).
|
|
||||||
- `flutter test` im Hintergrund laufen lassen (600+ Tests, mehrere Minuten,
|
|
||||||
kein kurzer Foreground-Timeout).
|
|
||||||
- Vor jedem Commit: Qualitäts-Gate (analyze + test) läuft automatisch als
|
|
||||||
Pre-Commit-Hook — das ist schon eingerichtet, nichts zu tun.
|
|
||||||
- CHANGELOG.md nach jeder Aufgabe aktualisieren (Projektregel).
|
|
||||||
- Bei fertigem Arbeitspaket: melden und stoppen, nicht autonom weitermachen
|
|
||||||
(Dustin startet dann bewusst eine frische Session — siehe
|
|
||||||
Memory `feedback_melden-und-stoppen`).
|
|
||||||
@@ -5,6 +5,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../library/database.dart';
|
import '../library/database.dart';
|
||||||
import '../library/song_media.dart';
|
import '../library/song_media.dart';
|
||||||
import '../player/audio_handler.dart';
|
import '../player/audio_handler.dart';
|
||||||
|
import '../services/download_service.dart';
|
||||||
import '../services/navidrome_service.dart';
|
import '../services/navidrome_service.dart';
|
||||||
import '../shared/cover.dart';
|
import '../shared/cover.dart';
|
||||||
import '../shared/lauf_balken.dart';
|
import '../shared/lauf_balken.dart';
|
||||||
@@ -69,6 +70,45 @@ class _ServerTitelScreenState extends State<ServerTitelScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nach Laden oder Entfernen ist der Einmal-Schnappschuss [_geladen] veraltet
|
||||||
|
/// — hier wird er nachgezogen.
|
||||||
|
Future<void> _aktualisiereGeladen() async {
|
||||||
|
final geladen = await context.read<MeloDb>().downloadIds();
|
||||||
|
if (mounted) setState(() => _geladen = geladen);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _offlineNehmen(SubsonicSong song) async {
|
||||||
|
final dienst = context.read<DownloadService>();
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
if (dienst.laeuft) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(content: Text('Es läuft schon ein Download')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final neu = await dienst.ladeEinzelnenTitel(song);
|
||||||
|
await _aktualisiereGeladen();
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(
|
||||||
|
content: Text(neu
|
||||||
|
? 'Offline: ${song.titel}'
|
||||||
|
: (dienst.fehler ?? 'War schon heruntergeladen')),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _offlineEntfernen(SubsonicSong song) async {
|
||||||
|
final dienst = context.read<DownloadService>();
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final weg = await dienst.entferne(song.id);
|
||||||
|
await _aktualisiereGeladen();
|
||||||
|
if (!mounted) return;
|
||||||
|
messenger.showSnackBar(SnackBar(
|
||||||
|
content: Text(weg
|
||||||
|
? 'Vom Gerät entfernt: ${song.titel}'
|
||||||
|
: (dienst.fehler ?? 'Ließ sich nicht entfernen')),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _spiele(List<SubsonicSong> songs, int ab) async {
|
Future<void> _spiele(List<SubsonicSong> songs, int ab) async {
|
||||||
final handler = context.read<MeloAudioHandler>();
|
final handler = context.read<MeloAudioHandler>();
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
@@ -130,6 +170,8 @@ class _ServerTitelScreenState extends State<ServerTitelScreen> {
|
|||||||
nummer: i,
|
nummer: i,
|
||||||
geladen: _geladen.contains(song.id),
|
geladen: _geladen.contains(song.id),
|
||||||
onTap: () => _spiele(songs, i - 1),
|
onTap: () => _spiele(songs, i - 1),
|
||||||
|
onOffline: () => _offlineNehmen(song),
|
||||||
|
onEntfernen: () => _offlineEntfernen(song),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -230,12 +272,16 @@ class _Zeile extends StatelessWidget {
|
|||||||
required this.nummer,
|
required this.nummer,
|
||||||
required this.geladen,
|
required this.geladen,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
required this.onOffline,
|
||||||
|
required this.onEntfernen,
|
||||||
});
|
});
|
||||||
|
|
||||||
final SubsonicSong song;
|
final SubsonicSong song;
|
||||||
final int nummer;
|
final int nummer;
|
||||||
final bool geladen;
|
final bool geladen;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onOffline;
|
||||||
|
final VoidCallback onEntfernen;
|
||||||
|
|
||||||
static String _dauer(int sekunden) {
|
static String _dauer(int sekunden) {
|
||||||
final m = (sekunden ~/ 60).toString();
|
final m = (sekunden ~/ 60).toString();
|
||||||
@@ -286,11 +332,20 @@ class _Zeile extends StatelessWidget {
|
|||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
if (geladen)
|
// Bisher gab es den Lade-Knopf nur je Album — einen einzelnen
|
||||||
const Padding(
|
// Titel mitzunehmen ging gar nicht.
|
||||||
padding: EdgeInsets.only(right: MeloSpace.sm),
|
geladen
|
||||||
child: Icon(Icons.download_done,
|
? IconButton(
|
||||||
size: 16, color: MeloTheme.text3),
|
tooltip: 'Vom Gerät entfernen',
|
||||||
|
icon: const Icon(Icons.download_done,
|
||||||
|
size: 20, color: MeloTheme.red),
|
||||||
|
onPressed: onEntfernen,
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
tooltip: 'Offline nehmen',
|
||||||
|
icon: const Icon(Icons.download_outlined,
|
||||||
|
size: 20, color: MeloTheme.text3),
|
||||||
|
onPressed: onOffline,
|
||||||
),
|
),
|
||||||
Text(_dauer(song.dauerSekunden),
|
Text(_dauer(song.dauerSekunden),
|
||||||
style: const TextStyle(color: MeloTheme.text3)),
|
style: const TextStyle(color: MeloTheme.text3)),
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ class Playlists extends Table {
|
|||||||
IntColumn get updatedAtMs => integer()();
|
IntColumn get updatedAtMs => integer()();
|
||||||
BoolColumn get deleted => boolean().withDefault(const Constant(false))();
|
BoolColumn get deleted => boolean().withDefault(const Constant(false))();
|
||||||
|
|
||||||
|
/// ID derselben Playlist am Melo-Server, sobald sie einmal gesichert
|
||||||
|
/// wurde. `null` heißt: nur auf diesem Gerät.
|
||||||
|
TextColumn get cloudId => text().nullable()();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {id};
|
Set<Column> get primaryKey => {id};
|
||||||
}
|
}
|
||||||
@@ -148,7 +152,7 @@ class MeloDb extends _$MeloDb {
|
|||||||
MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
|
MeloDb([QueryExecutor? executor]) : super(executor ?? _open());
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 10;
|
int get schemaVersion => 11;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration => MigrationStrategy(
|
MigrationStrategy get migration => MigrationStrategy(
|
||||||
@@ -190,6 +194,9 @@ class MeloDb extends _$MeloDb {
|
|||||||
if (from < 10) {
|
if (from < 10) {
|
||||||
await m.createTable(downloads);
|
await m.createTable(downloads);
|
||||||
}
|
}
|
||||||
|
if (from < 11) {
|
||||||
|
await m.addColumn(playlists, playlists.cloudId);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -284,6 +291,26 @@ class MeloDb extends _$MeloDb {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Playlist?> playlistById(String id) =>
|
||||||
|
(select(playlists)..where((p) => p.id.equals(id))).getSingleOrNull();
|
||||||
|
|
||||||
|
Future<void> setPlaylistCloudId(String id, String cloudId) async {
|
||||||
|
await (update(playlists)..where((p) => p.id.equals(id)))
|
||||||
|
.write(PlaylistsCompanion(cloudId: Value(cloudId)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie viele Playlisten es hier gibt — **inklusive Grabsteinen**.
|
||||||
|
///
|
||||||
|
/// Grundlage der Wiederherstellung: nur eine wirklich leere Tabelle gilt
|
||||||
|
/// als Neuinstallation. Wer alle Playlisten selbst gelöscht hat, soll sie
|
||||||
|
/// nicht vom Server zurückbekommen.
|
||||||
|
Future<int> countPlaylists() async {
|
||||||
|
final zaehler = playlists.id.count();
|
||||||
|
final zeile = await (selectOnly(playlists)..addColumns([zaehler]))
|
||||||
|
.getSingle();
|
||||||
|
return zeile.read(zaehler) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
// === PlaylistSongs ===
|
// === PlaylistSongs ===
|
||||||
Stream<List<Song>> watchPlaylistSongs(String playlistId) {
|
Stream<List<Song>> watchPlaylistSongs(String playlistId) {
|
||||||
final query = select(songs).join([
|
final query = select(songs).join([
|
||||||
@@ -519,6 +546,14 @@ class MeloDb extends _$MeloDb {
|
|||||||
.write(SongsCompanion(cloudId: Value(cloudId)));
|
.write(SongsCompanion(cloudId: Value(cloudId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Song?> songByCloudId(String cloudId) async {
|
||||||
|
final treffer = await (select(songs)
|
||||||
|
..where((s) => s.cloudId.equals(cloudId))
|
||||||
|
..limit(1))
|
||||||
|
.get();
|
||||||
|
return treffer.isEmpty ? null : treffer.single;
|
||||||
|
}
|
||||||
|
|
||||||
/// 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.
|
||||||
|
|||||||
@@ -1322,6 +1322,17 @@ class $PlaylistsTable extends Playlists
|
|||||||
),
|
),
|
||||||
defaultValue: const Constant(false),
|
defaultValue: const Constant(false),
|
||||||
);
|
);
|
||||||
|
static const VerificationMeta _cloudIdMeta = const VerificationMeta(
|
||||||
|
'cloudId',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> cloudId = GeneratedColumn<String>(
|
||||||
|
'cloud_id',
|
||||||
|
aliasedName,
|
||||||
|
true,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
);
|
||||||
@override
|
@override
|
||||||
List<GeneratedColumn> get $columns => [
|
List<GeneratedColumn> get $columns => [
|
||||||
id,
|
id,
|
||||||
@@ -1330,6 +1341,7 @@ class $PlaylistsTable extends Playlists
|
|||||||
createdAtMs,
|
createdAtMs,
|
||||||
updatedAtMs,
|
updatedAtMs,
|
||||||
deleted,
|
deleted,
|
||||||
|
cloudId,
|
||||||
];
|
];
|
||||||
@override
|
@override
|
||||||
String get aliasedName => _alias ?? actualTableName;
|
String get aliasedName => _alias ?? actualTableName;
|
||||||
@@ -1393,6 +1405,12 @@ class $PlaylistsTable extends Playlists
|
|||||||
deleted.isAcceptableOrUnknown(data['deleted']!, _deletedMeta),
|
deleted.isAcceptableOrUnknown(data['deleted']!, _deletedMeta),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (data.containsKey('cloud_id')) {
|
||||||
|
context.handle(
|
||||||
|
_cloudIdMeta,
|
||||||
|
cloudId.isAcceptableOrUnknown(data['cloud_id']!, _cloudIdMeta),
|
||||||
|
);
|
||||||
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1426,6 +1444,10 @@ class $PlaylistsTable extends Playlists
|
|||||||
DriftSqlType.bool,
|
DriftSqlType.bool,
|
||||||
data['${effectivePrefix}deleted'],
|
data['${effectivePrefix}deleted'],
|
||||||
)!,
|
)!,
|
||||||
|
cloudId: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}cloud_id'],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1442,6 +1464,10 @@ class Playlist extends DataClass implements Insertable<Playlist> {
|
|||||||
final int createdAtMs;
|
final int createdAtMs;
|
||||||
final int updatedAtMs;
|
final int updatedAtMs;
|
||||||
final bool deleted;
|
final bool deleted;
|
||||||
|
|
||||||
|
/// ID derselben Playlist am Melo-Server, sobald sie einmal gesichert
|
||||||
|
/// wurde. `null` heißt: nur auf diesem Gerät.
|
||||||
|
final String? cloudId;
|
||||||
const Playlist({
|
const Playlist({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -1449,6 +1475,7 @@ class Playlist extends DataClass implements Insertable<Playlist> {
|
|||||||
required this.createdAtMs,
|
required this.createdAtMs,
|
||||||
required this.updatedAtMs,
|
required this.updatedAtMs,
|
||||||
required this.deleted,
|
required this.deleted,
|
||||||
|
this.cloudId,
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||||
@@ -1461,6 +1488,9 @@ class Playlist extends DataClass implements Insertable<Playlist> {
|
|||||||
map['created_at_ms'] = Variable<int>(createdAtMs);
|
map['created_at_ms'] = Variable<int>(createdAtMs);
|
||||||
map['updated_at_ms'] = Variable<int>(updatedAtMs);
|
map['updated_at_ms'] = Variable<int>(updatedAtMs);
|
||||||
map['deleted'] = Variable<bool>(deleted);
|
map['deleted'] = Variable<bool>(deleted);
|
||||||
|
if (!nullToAbsent || cloudId != null) {
|
||||||
|
map['cloud_id'] = Variable<String>(cloudId);
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1474,6 +1504,9 @@ class Playlist extends DataClass implements Insertable<Playlist> {
|
|||||||
createdAtMs: Value(createdAtMs),
|
createdAtMs: Value(createdAtMs),
|
||||||
updatedAtMs: Value(updatedAtMs),
|
updatedAtMs: Value(updatedAtMs),
|
||||||
deleted: Value(deleted),
|
deleted: Value(deleted),
|
||||||
|
cloudId: cloudId == null && nullToAbsent
|
||||||
|
? const Value.absent()
|
||||||
|
: Value(cloudId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1489,6 +1522,7 @@ class Playlist extends DataClass implements Insertable<Playlist> {
|
|||||||
createdAtMs: serializer.fromJson<int>(json['createdAtMs']),
|
createdAtMs: serializer.fromJson<int>(json['createdAtMs']),
|
||||||
updatedAtMs: serializer.fromJson<int>(json['updatedAtMs']),
|
updatedAtMs: serializer.fromJson<int>(json['updatedAtMs']),
|
||||||
deleted: serializer.fromJson<bool>(json['deleted']),
|
deleted: serializer.fromJson<bool>(json['deleted']),
|
||||||
|
cloudId: serializer.fromJson<String?>(json['cloudId']),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@override
|
@override
|
||||||
@@ -1501,6 +1535,7 @@ class Playlist extends DataClass implements Insertable<Playlist> {
|
|||||||
'createdAtMs': serializer.toJson<int>(createdAtMs),
|
'createdAtMs': serializer.toJson<int>(createdAtMs),
|
||||||
'updatedAtMs': serializer.toJson<int>(updatedAtMs),
|
'updatedAtMs': serializer.toJson<int>(updatedAtMs),
|
||||||
'deleted': serializer.toJson<bool>(deleted),
|
'deleted': serializer.toJson<bool>(deleted),
|
||||||
|
'cloudId': serializer.toJson<String?>(cloudId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1511,6 +1546,7 @@ class Playlist extends DataClass implements Insertable<Playlist> {
|
|||||||
int? createdAtMs,
|
int? createdAtMs,
|
||||||
int? updatedAtMs,
|
int? updatedAtMs,
|
||||||
bool? deleted,
|
bool? deleted,
|
||||||
|
Value<String?> cloudId = const Value.absent(),
|
||||||
}) => Playlist(
|
}) => Playlist(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
@@ -1518,6 +1554,7 @@ class Playlist extends DataClass implements Insertable<Playlist> {
|
|||||||
createdAtMs: createdAtMs ?? this.createdAtMs,
|
createdAtMs: createdAtMs ?? this.createdAtMs,
|
||||||
updatedAtMs: updatedAtMs ?? this.updatedAtMs,
|
updatedAtMs: updatedAtMs ?? this.updatedAtMs,
|
||||||
deleted: deleted ?? this.deleted,
|
deleted: deleted ?? this.deleted,
|
||||||
|
cloudId: cloudId.present ? cloudId.value : this.cloudId,
|
||||||
);
|
);
|
||||||
Playlist copyWithCompanion(PlaylistsCompanion data) {
|
Playlist copyWithCompanion(PlaylistsCompanion data) {
|
||||||
return Playlist(
|
return Playlist(
|
||||||
@@ -1533,6 +1570,7 @@ class Playlist extends DataClass implements Insertable<Playlist> {
|
|||||||
? data.updatedAtMs.value
|
? data.updatedAtMs.value
|
||||||
: this.updatedAtMs,
|
: this.updatedAtMs,
|
||||||
deleted: data.deleted.present ? data.deleted.value : this.deleted,
|
deleted: data.deleted.present ? data.deleted.value : this.deleted,
|
||||||
|
cloudId: data.cloudId.present ? data.cloudId.value : this.cloudId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1544,14 +1582,22 @@ class Playlist extends DataClass implements Insertable<Playlist> {
|
|||||||
..write('description: $description, ')
|
..write('description: $description, ')
|
||||||
..write('createdAtMs: $createdAtMs, ')
|
..write('createdAtMs: $createdAtMs, ')
|
||||||
..write('updatedAtMs: $updatedAtMs, ')
|
..write('updatedAtMs: $updatedAtMs, ')
|
||||||
..write('deleted: $deleted')
|
..write('deleted: $deleted, ')
|
||||||
|
..write('cloudId: $cloudId')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode => Object.hash(
|
||||||
Object.hash(id, name, description, createdAtMs, updatedAtMs, deleted);
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
createdAtMs,
|
||||||
|
updatedAtMs,
|
||||||
|
deleted,
|
||||||
|
cloudId,
|
||||||
|
);
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) =>
|
bool operator ==(Object other) =>
|
||||||
identical(this, other) ||
|
identical(this, other) ||
|
||||||
@@ -1561,7 +1607,8 @@ class Playlist extends DataClass implements Insertable<Playlist> {
|
|||||||
other.description == this.description &&
|
other.description == this.description &&
|
||||||
other.createdAtMs == this.createdAtMs &&
|
other.createdAtMs == this.createdAtMs &&
|
||||||
other.updatedAtMs == this.updatedAtMs &&
|
other.updatedAtMs == this.updatedAtMs &&
|
||||||
other.deleted == this.deleted);
|
other.deleted == this.deleted &&
|
||||||
|
other.cloudId == this.cloudId);
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlaylistsCompanion extends UpdateCompanion<Playlist> {
|
class PlaylistsCompanion extends UpdateCompanion<Playlist> {
|
||||||
@@ -1571,6 +1618,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
|
|||||||
final Value<int> createdAtMs;
|
final Value<int> createdAtMs;
|
||||||
final Value<int> updatedAtMs;
|
final Value<int> updatedAtMs;
|
||||||
final Value<bool> deleted;
|
final Value<bool> deleted;
|
||||||
|
final Value<String?> cloudId;
|
||||||
final Value<int> rowid;
|
final Value<int> rowid;
|
||||||
const PlaylistsCompanion({
|
const PlaylistsCompanion({
|
||||||
this.id = const Value.absent(),
|
this.id = const Value.absent(),
|
||||||
@@ -1579,6 +1627,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
|
|||||||
this.createdAtMs = const Value.absent(),
|
this.createdAtMs = const Value.absent(),
|
||||||
this.updatedAtMs = const Value.absent(),
|
this.updatedAtMs = const Value.absent(),
|
||||||
this.deleted = const Value.absent(),
|
this.deleted = const Value.absent(),
|
||||||
|
this.cloudId = const Value.absent(),
|
||||||
this.rowid = const Value.absent(),
|
this.rowid = const Value.absent(),
|
||||||
});
|
});
|
||||||
PlaylistsCompanion.insert({
|
PlaylistsCompanion.insert({
|
||||||
@@ -1588,6 +1637,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
|
|||||||
required int createdAtMs,
|
required int createdAtMs,
|
||||||
required int updatedAtMs,
|
required int updatedAtMs,
|
||||||
this.deleted = const Value.absent(),
|
this.deleted = const Value.absent(),
|
||||||
|
this.cloudId = const Value.absent(),
|
||||||
this.rowid = const Value.absent(),
|
this.rowid = const Value.absent(),
|
||||||
}) : id = Value(id),
|
}) : id = Value(id),
|
||||||
name = Value(name),
|
name = Value(name),
|
||||||
@@ -1600,6 +1650,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
|
|||||||
Expression<int>? createdAtMs,
|
Expression<int>? createdAtMs,
|
||||||
Expression<int>? updatedAtMs,
|
Expression<int>? updatedAtMs,
|
||||||
Expression<bool>? deleted,
|
Expression<bool>? deleted,
|
||||||
|
Expression<String>? cloudId,
|
||||||
Expression<int>? rowid,
|
Expression<int>? rowid,
|
||||||
}) {
|
}) {
|
||||||
return RawValuesInsertable({
|
return RawValuesInsertable({
|
||||||
@@ -1609,6 +1660,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
|
|||||||
if (createdAtMs != null) 'created_at_ms': createdAtMs,
|
if (createdAtMs != null) 'created_at_ms': createdAtMs,
|
||||||
if (updatedAtMs != null) 'updated_at_ms': updatedAtMs,
|
if (updatedAtMs != null) 'updated_at_ms': updatedAtMs,
|
||||||
if (deleted != null) 'deleted': deleted,
|
if (deleted != null) 'deleted': deleted,
|
||||||
|
if (cloudId != null) 'cloud_id': cloudId,
|
||||||
if (rowid != null) 'rowid': rowid,
|
if (rowid != null) 'rowid': rowid,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1620,6 +1672,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
|
|||||||
Value<int>? createdAtMs,
|
Value<int>? createdAtMs,
|
||||||
Value<int>? updatedAtMs,
|
Value<int>? updatedAtMs,
|
||||||
Value<bool>? deleted,
|
Value<bool>? deleted,
|
||||||
|
Value<String?>? cloudId,
|
||||||
Value<int>? rowid,
|
Value<int>? rowid,
|
||||||
}) {
|
}) {
|
||||||
return PlaylistsCompanion(
|
return PlaylistsCompanion(
|
||||||
@@ -1629,6 +1682,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
|
|||||||
createdAtMs: createdAtMs ?? this.createdAtMs,
|
createdAtMs: createdAtMs ?? this.createdAtMs,
|
||||||
updatedAtMs: updatedAtMs ?? this.updatedAtMs,
|
updatedAtMs: updatedAtMs ?? this.updatedAtMs,
|
||||||
deleted: deleted ?? this.deleted,
|
deleted: deleted ?? this.deleted,
|
||||||
|
cloudId: cloudId ?? this.cloudId,
|
||||||
rowid: rowid ?? this.rowid,
|
rowid: rowid ?? this.rowid,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1654,6 +1708,9 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
|
|||||||
if (deleted.present) {
|
if (deleted.present) {
|
||||||
map['deleted'] = Variable<bool>(deleted.value);
|
map['deleted'] = Variable<bool>(deleted.value);
|
||||||
}
|
}
|
||||||
|
if (cloudId.present) {
|
||||||
|
map['cloud_id'] = Variable<String>(cloudId.value);
|
||||||
|
}
|
||||||
if (rowid.present) {
|
if (rowid.present) {
|
||||||
map['rowid'] = Variable<int>(rowid.value);
|
map['rowid'] = Variable<int>(rowid.value);
|
||||||
}
|
}
|
||||||
@@ -1669,6 +1726,7 @@ class PlaylistsCompanion extends UpdateCompanion<Playlist> {
|
|||||||
..write('createdAtMs: $createdAtMs, ')
|
..write('createdAtMs: $createdAtMs, ')
|
||||||
..write('updatedAtMs: $updatedAtMs, ')
|
..write('updatedAtMs: $updatedAtMs, ')
|
||||||
..write('deleted: $deleted, ')
|
..write('deleted: $deleted, ')
|
||||||
|
..write('cloudId: $cloudId, ')
|
||||||
..write('rowid: $rowid')
|
..write('rowid: $rowid')
|
||||||
..write(')'))
|
..write(')'))
|
||||||
.toString();
|
.toString();
|
||||||
@@ -4216,6 +4274,7 @@ typedef $$PlaylistsTableCreateCompanionBuilder =
|
|||||||
required int createdAtMs,
|
required int createdAtMs,
|
||||||
required int updatedAtMs,
|
required int updatedAtMs,
|
||||||
Value<bool> deleted,
|
Value<bool> deleted,
|
||||||
|
Value<String?> cloudId,
|
||||||
Value<int> rowid,
|
Value<int> rowid,
|
||||||
});
|
});
|
||||||
typedef $$PlaylistsTableUpdateCompanionBuilder =
|
typedef $$PlaylistsTableUpdateCompanionBuilder =
|
||||||
@@ -4226,6 +4285,7 @@ typedef $$PlaylistsTableUpdateCompanionBuilder =
|
|||||||
Value<int> createdAtMs,
|
Value<int> createdAtMs,
|
||||||
Value<int> updatedAtMs,
|
Value<int> updatedAtMs,
|
||||||
Value<bool> deleted,
|
Value<bool> deleted,
|
||||||
|
Value<String?> cloudId,
|
||||||
Value<int> rowid,
|
Value<int> rowid,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4291,6 +4351,11 @@ class $$PlaylistsTableFilterComposer
|
|||||||
builder: (column) => ColumnFilters(column),
|
builder: (column) => ColumnFilters(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnFilters<String> get cloudId => $composableBuilder(
|
||||||
|
column: $table.cloudId,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
Expression<bool> playlistSongsRefs(
|
Expression<bool> playlistSongsRefs(
|
||||||
Expression<bool> Function($$PlaylistSongsTableFilterComposer f) f,
|
Expression<bool> Function($$PlaylistSongsTableFilterComposer f) f,
|
||||||
) {
|
) {
|
||||||
@@ -4355,6 +4420,11 @@ class $$PlaylistsTableOrderingComposer
|
|||||||
column: $table.deleted,
|
column: $table.deleted,
|
||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get cloudId => $composableBuilder(
|
||||||
|
column: $table.cloudId,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class $$PlaylistsTableAnnotationComposer
|
class $$PlaylistsTableAnnotationComposer
|
||||||
@@ -4390,6 +4460,9 @@ class $$PlaylistsTableAnnotationComposer
|
|||||||
GeneratedColumn<bool> get deleted =>
|
GeneratedColumn<bool> get deleted =>
|
||||||
$composableBuilder(column: $table.deleted, builder: (column) => column);
|
$composableBuilder(column: $table.deleted, builder: (column) => column);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get cloudId =>
|
||||||
|
$composableBuilder(column: $table.cloudId, builder: (column) => column);
|
||||||
|
|
||||||
Expression<T> playlistSongsRefs<T extends Object>(
|
Expression<T> playlistSongsRefs<T extends Object>(
|
||||||
Expression<T> Function($$PlaylistSongsTableAnnotationComposer a) f,
|
Expression<T> Function($$PlaylistSongsTableAnnotationComposer a) f,
|
||||||
) {
|
) {
|
||||||
@@ -4450,6 +4523,7 @@ class $$PlaylistsTableTableManager
|
|||||||
Value<int> createdAtMs = const Value.absent(),
|
Value<int> createdAtMs = const Value.absent(),
|
||||||
Value<int> updatedAtMs = const Value.absent(),
|
Value<int> updatedAtMs = const Value.absent(),
|
||||||
Value<bool> deleted = const Value.absent(),
|
Value<bool> deleted = const Value.absent(),
|
||||||
|
Value<String?> cloudId = const Value.absent(),
|
||||||
Value<int> rowid = const Value.absent(),
|
Value<int> rowid = const Value.absent(),
|
||||||
}) => PlaylistsCompanion(
|
}) => PlaylistsCompanion(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -4458,6 +4532,7 @@ class $$PlaylistsTableTableManager
|
|||||||
createdAtMs: createdAtMs,
|
createdAtMs: createdAtMs,
|
||||||
updatedAtMs: updatedAtMs,
|
updatedAtMs: updatedAtMs,
|
||||||
deleted: deleted,
|
deleted: deleted,
|
||||||
|
cloudId: cloudId,
|
||||||
rowid: rowid,
|
rowid: rowid,
|
||||||
),
|
),
|
||||||
createCompanionCallback:
|
createCompanionCallback:
|
||||||
@@ -4468,6 +4543,7 @@ class $$PlaylistsTableTableManager
|
|||||||
required int createdAtMs,
|
required int createdAtMs,
|
||||||
required int updatedAtMs,
|
required int updatedAtMs,
|
||||||
Value<bool> deleted = const Value.absent(),
|
Value<bool> deleted = const Value.absent(),
|
||||||
|
Value<String?> cloudId = const Value.absent(),
|
||||||
Value<int> rowid = const Value.absent(),
|
Value<int> rowid = const Value.absent(),
|
||||||
}) => PlaylistsCompanion.insert(
|
}) => PlaylistsCompanion.insert(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -4476,6 +4552,7 @@ class $$PlaylistsTableTableManager
|
|||||||
createdAtMs: createdAtMs,
|
createdAtMs: createdAtMs,
|
||||||
updatedAtMs: updatedAtMs,
|
updatedAtMs: updatedAtMs,
|
||||||
deleted: deleted,
|
deleted: deleted,
|
||||||
|
cloudId: cloudId,
|
||||||
rowid: rowid,
|
rowid: rowid,
|
||||||
),
|
),
|
||||||
withReferenceMapper: (p0) => p0
|
withReferenceMapper: (p0) => p0
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ class _MyMusicScreenState extends State<MyMusicScreen> {
|
|||||||
return SortableSongList(
|
return SortableSongList(
|
||||||
songs: songs,
|
songs: songs,
|
||||||
storeKey: SortStore.meineMusik,
|
storeKey: SortStore.meineMusik,
|
||||||
|
serverUpload: true,
|
||||||
empty:
|
empty:
|
||||||
lib.scanning ? const SizedBox.shrink() : const _Empty(),
|
lib.scanning ? const SizedBox.shrink() : const _Empty(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,14 +7,22 @@ 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();
|
||||||
|
await _sichereNeuePlaylist(id, name);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,29 +31,64 @@ 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 {
|
||||||
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.
|
||||||
@@ -107,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-3
@@ -30,6 +30,7 @@ import 'services/sync_service.dart';
|
|||||||
import 'services/yt_download_service.dart';
|
import 'services/yt_download_service.dart';
|
||||||
import 'services/yt_search_service.dart';
|
import 'services/yt_search_service.dart';
|
||||||
import 'settings/app_settings.dart';
|
import 'settings/app_settings.dart';
|
||||||
|
import 'shared/sync_bericht_dialog.dart';
|
||||||
import 'shared/theme.dart';
|
import 'shared/theme.dart';
|
||||||
|
|
||||||
late final MeloAudioHandler _handler;
|
late final MeloAudioHandler _handler;
|
||||||
@@ -51,7 +52,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 +83,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);
|
||||||
@@ -172,7 +178,7 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
|||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
// Beim Start einmal mit dem Server abgleichen — neue Titel von anderen
|
// Beim Start einmal mit dem Server abgleichen — neue Titel von anderen
|
||||||
// Geräten sind dann sofort da.
|
// Geräten sind dann sofort da.
|
||||||
context.read<SyncService>().automatisch();
|
unawaited(_gleicheAbUndZeigeBericht());
|
||||||
unawaited(context.read<LibraryService>().ensureStartupPermissions());
|
unawaited(context.read<LibraryService>().ensureStartupPermissions());
|
||||||
unawaited(_pruefeGeteilteAdresse());
|
unawaited(_pruefeGeteilteAdresse());
|
||||||
}
|
}
|
||||||
@@ -200,13 +206,41 @@ class _HomeShellState extends State<HomeShell> with WidgetsBindingObserver {
|
|||||||
// auf dem Gerät wie am Server.
|
// auf dem Gerät wie am Server.
|
||||||
if (state == AppLifecycleState.resumed) {
|
if (state == AppLifecycleState.resumed) {
|
||||||
if (Platform.isAndroid) _autoScan.pruefe();
|
if (Platform.isAndroid) _autoScan.pruefe();
|
||||||
context.read<SyncService>().automatisch();
|
unawaited(_gleicheAbUndZeigeBericht());
|
||||||
// Ein zweites Teilen startet die App nicht neu (singleTop), sondern
|
// Ein zweites Teilen startet die App nicht neu (singleTop), sondern
|
||||||
// bringt sie nur nach vorn — deshalb auch hier nachsehen.
|
// bringt sie nur nach vorn — deshalb auch hier nachsehen.
|
||||||
unawaited(_pruefeGeteilteAdresse());
|
unawaited(_pruefeGeteilteAdresse());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gleicht ab und zeigt danach höchstens einmal den „Was ist neu"-Bericht.
|
||||||
|
Future<void> _gleicheAbUndZeigeBericht() async {
|
||||||
|
final sync = context.read<SyncService>();
|
||||||
|
final playlists = context.read<PlaylistService>();
|
||||||
|
await sync.automatisch();
|
||||||
|
// Nur auf einem Gerät ohne eigene Playlisten, und nur nach einem
|
||||||
|
// tatsächlich abgeschlossenen Sync-Lauf: läuft parallel schon ein
|
||||||
|
// echter Sync (z. B. weil ein Berechtigungsdialog während des
|
||||||
|
// Erststarts einen App-Resume auslöst und diese Methode ein zweites
|
||||||
|
// Mal anstößt), kehrt automatisch() oben wegen des _laeuft-Guards
|
||||||
|
// sofort zurück — dann liefe jede songByCloudId-Suche noch ins Leere,
|
||||||
|
// legte leere Playlisten an, und der countPlaylists()-Riegel würde
|
||||||
|
// jeden weiteren Versuch für immer blockieren.
|
||||||
|
if (!sync.laeuft) {
|
||||||
|
unawaited(playlists.stelleWiederHer());
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
final bericht = sync.bericht;
|
||||||
|
if (bericht == null) return;
|
||||||
|
// Zuerst quittieren: ein zweites Zurückkehren in die App soll denselben
|
||||||
|
// Bericht nicht erneut zeigen.
|
||||||
|
sync.berichtGesehen();
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => SyncBerichtDialog(bericht: bericht),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final tabs = <Widget>[
|
final tabs = <Widget>[
|
||||||
|
|||||||
@@ -110,6 +110,20 @@ class DownloadService extends ChangeNotifier {
|
|||||||
return neu;
|
return neu;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Nimmt einen einzelnen Server-Titel offline.
|
||||||
|
///
|
||||||
|
/// Bewusst über [lade]: das bringt Doppel-Lauf-Schutz, Verbindungsprüfung
|
||||||
|
/// und Fortschritts-Buchführung mit. Gibt zurück, ob der Titel neu
|
||||||
|
/// dazugekommen ist.
|
||||||
|
///
|
||||||
|
/// **Bekannte Einschränkung:** Liegt der Titel schon im Abspiel-Zwischen-
|
||||||
|
/// speicher (weil er gerade gehört wurde), wird er trotzdem neu geladen —
|
||||||
|
/// bis die Verdrängung greift, belegt er doppelten Platz. Der Gegenschutz
|
||||||
|
/// kostete einen eigenen Cache-Zugang in diesem Dienst; für drei Nutzer ist
|
||||||
|
/// das der schlechtere Tausch.
|
||||||
|
Future<bool> ladeEinzelnenTitel(SubsonicSong song) async =>
|
||||||
|
await lade([song]) > 0;
|
||||||
|
|
||||||
Future<_Ergebnis> _ladeEinen(SubsonicSong song) async {
|
Future<_Ergebnis> _ladeEinen(SubsonicSong song) async {
|
||||||
try {
|
try {
|
||||||
// Datei und Buchführung entstehen nacheinander. Bricht die App
|
// Datei und Buchführung entstehen nacheinander. Bricht die App
|
||||||
|
|||||||
@@ -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({
|
||||||
@@ -114,10 +122,22 @@ class MeloCloudService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Liest die Favoriten-IDs aus einer Server-Antwort.
|
/// Liest die Favoriten-IDs aus einer Server-Antwort.
|
||||||
|
///
|
||||||
|
/// Ein fehlender `favorites`-Schlüssel ist ein **Fehler, keine leere
|
||||||
|
/// Menge**: der Router verdrahtet für `GET /favorites` hart HTTP 200, und
|
||||||
|
/// mehrere Handler desselben Servers melden Fehler im 200er-Körper. Eine
|
||||||
|
/// fälschlich leere Antwort wäre sonst von einer echten nicht zu
|
||||||
|
/// unterscheiden — genau wie bei [parseListe] und [parseUpload] wird
|
||||||
|
/// deshalb geworfen.
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
static List<String> parseFavoriten(String body) {
|
static List<String> parseFavoriten(String body) {
|
||||||
final daten = jsonDecode(body) as Map<String, dynamic>;
|
final daten = jsonDecode(body) as Map<String, dynamic>;
|
||||||
final liste = daten['favorites'] as List? ?? const [];
|
final fehler = daten['error'] as String?;
|
||||||
|
if (fehler != null) throw CloudException(fehler);
|
||||||
|
final liste = daten['favorites'];
|
||||||
|
if (liste is! List) {
|
||||||
|
throw CloudException('Antwort ohne Favoritenliste');
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
for (final j in liste) (j as Map<String, dynamic>)['id'] as String,
|
for (final j in liste) (j as Map<String, dynamic>)['id'] as String,
|
||||||
];
|
];
|
||||||
@@ -264,19 +284,145 @@ class MeloCloudService {
|
|||||||
return parseFavoriten(antwort.body);
|
return parseFavoriten(antwort.body);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ersetzt die Favoriten am Server durch [cloudIds].
|
/// Setzt einen einzelnen Favoriten am Server — additiv oder entfernend,
|
||||||
Future<void> setzeFavoriten(List<String> cloudIds) async {
|
/// aber immer **deterministisch**.
|
||||||
|
///
|
||||||
|
/// Bewusst kein Umschalten: hätte der Server einen abweichenden Stand,
|
||||||
|
/// kehrte ein Toggle den Wunsch des Nutzers um.
|
||||||
|
Future<void> setzeFavorit(String cloudId, bool gesetzt) async {
|
||||||
_pruefeAnmeldung();
|
_pruefeAnmeldung();
|
||||||
final antwort = await _client
|
final antwort = await _client
|
||||||
.post(
|
.post(
|
||||||
Uri.parse('$basisUrl/favorites'),
|
Uri.parse('$basisUrl/favorites/toggle'),
|
||||||
headers: {..._kopf, 'Content-Type': 'application/json'},
|
headers: {..._kopf, 'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({'song_ids': cloudIds}),
|
body: jsonEncode({'song_id': cloudId, 'set': gesetzt}),
|
||||||
)
|
)
|
||||||
.timeout(const Duration(seconds: 30));
|
.timeout(const Duration(seconds: 30));
|
||||||
_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,65 @@
|
|||||||
|
/// Die reinen Entscheidungsfunktionen des Abgleichs — ohne Netz, ohne
|
||||||
|
/// Datenbank, ohne Plattform-Kanäle.
|
||||||
|
///
|
||||||
|
/// Sie liegen bewusst außerhalb von `SyncService`: was hier steht, lässt sich
|
||||||
|
/// mit einer Handvoll Mengen prüfen statt mit einem halben Server.
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// Wie viele Favoriten je Lauf höchstens gepusht werden.
|
||||||
|
///
|
||||||
|
/// Der Rest kommt im nächsten Lauf dran. Die Pushes sind idempotent, ein
|
||||||
|
/// Teilausfall heilt sich dadurch von selbst.
|
||||||
|
const int maxFavoritenPushes = 200;
|
||||||
|
|
||||||
|
/// Die cloudIds, die zum Server gepusht werden müssen: `lokal \ server`.
|
||||||
|
///
|
||||||
|
/// [lokaleFavoriten] sind lokale Song-IDs, [cloudIdVon] bildet sie auf ihre
|
||||||
|
/// cloudId ab. Titel ohne cloudId kennt der Server nicht — sie tauchen in
|
||||||
|
/// keiner Richtung im Abgleich auf.
|
||||||
|
List<String> zuPushendeFavoriten({
|
||||||
|
required Set<String> lokaleFavoriten,
|
||||||
|
required Map<String, String> cloudIdVon,
|
||||||
|
required Set<String> amServer,
|
||||||
|
int deckel = maxFavoritenPushes,
|
||||||
|
}) {
|
||||||
|
final offen = <String>[];
|
||||||
|
for (final songId in lokaleFavoriten) {
|
||||||
|
final cloudId = cloudIdVon[songId];
|
||||||
|
if (cloudId == null) continue;
|
||||||
|
if (amServer.contains(cloudId)) continue;
|
||||||
|
offen.add(cloudId);
|
||||||
|
if (offen.length >= deckel) break;
|
||||||
|
}
|
||||||
|
return offen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Die lokalen Song-IDs, die aus dem Server-Stand als Favorit dazukommen:
|
||||||
|
/// `server \ lokal`.
|
||||||
|
///
|
||||||
|
/// Eine cloudId ohne lokalen Titel (Download fehlgeschlagen, noch nicht
|
||||||
|
/// geladen) wird **übersprungen, nicht gelöscht**: ein Favorit ohne Song wäre
|
||||||
|
/// über den Join unsichtbar, würde aber weiter mitgeschleppt.
|
||||||
|
List<String> lokalZuSetzendeFavoriten({
|
||||||
|
required Set<String> amServer,
|
||||||
|
required Map<String, String> songIdVonCloudId,
|
||||||
|
required Set<String> lokaleFavoriten,
|
||||||
|
}) {
|
||||||
|
final offen = <String>[];
|
||||||
|
for (final cloudId in amServer) {
|
||||||
|
final songId = songIdVonCloudId[cloudId];
|
||||||
|
if (songId == null) continue;
|
||||||
|
if (lokaleFavoriten.contains(songId)) continue;
|
||||||
|
offen.add(songId);
|
||||||
|
}
|
||||||
|
return offen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ob der „Was ist neu"-Bericht fällig ist.
|
||||||
|
///
|
||||||
|
/// `null` heißt Neuinstallation, Abmeldung oder gelöschte App-Daten — dann ist
|
||||||
|
/// er **nicht** fällig, sonst begrüßt ein frisch eingerichtetes Gerät den
|
||||||
|
/// Nutzer mit „Willkommen zurück! 325 neue Songs". `sollAutoSync` entscheidet
|
||||||
|
/// bei `null` bewusst umgekehrt und ist hier **kein** Vorbild.
|
||||||
|
bool berichtFaellig(DateTime? letzterErfolg, DateTime jetzt) =>
|
||||||
|
letzterErfolg != null &&
|
||||||
|
jetzt.difference(letzterErfolg) >= const Duration(hours: 24);
|
||||||
+286
-19
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:drift/drift.dart' show Value;
|
import 'package:drift/drift.dart' show Value;
|
||||||
@@ -9,6 +10,7 @@ import 'package:uuid/uuid.dart';
|
|||||||
import '../library/database.dart';
|
import '../library/database.dart';
|
||||||
import 'media_store.dart';
|
import 'media_store.dart';
|
||||||
import 'melo_cloud_service.dart';
|
import 'melo_cloud_service.dart';
|
||||||
|
import 'sync_merge.dart';
|
||||||
|
|
||||||
/// Was beim Abgleich mit welchen Titeln zu tun ist.
|
/// Was beim Abgleich mit welchen Titeln zu tun ist.
|
||||||
///
|
///
|
||||||
@@ -120,6 +122,48 @@ const autoSyncAbstand = Duration(minutes: 15);
|
|||||||
bool sollAutoSync(DateTime? letzterLauf, DateTime jetzt) =>
|
bool sollAutoSync(DateTime? letzterLauf, DateTime jetzt) =>
|
||||||
letzterLauf == null || jetzt.difference(letzterLauf) >= autoSyncAbstand;
|
letzterLauf == null || jetzt.difference(letzterLauf) >= autoSyncAbstand;
|
||||||
|
|
||||||
|
/// Was ein gezielter Upload erledigt hat — Grundlage der Meldung an den
|
||||||
|
/// Nutzer. Ein einzelner Fehlschlag darf den Erfolg der übrigen nicht
|
||||||
|
/// verdecken, deshalb steht hier alles nebeneinander.
|
||||||
|
class UploadErgebnis {
|
||||||
|
const UploadErgebnis({
|
||||||
|
this.hochgeladen = 0,
|
||||||
|
this.schonDa = 0,
|
||||||
|
this.fehler = const [],
|
||||||
|
this.abgebrochen = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int hochgeladen;
|
||||||
|
final int schonDa;
|
||||||
|
final List<String> fehler;
|
||||||
|
final bool abgebrochen;
|
||||||
|
|
||||||
|
String get meldung {
|
||||||
|
final teile = <String>[];
|
||||||
|
if (hochgeladen > 0) teile.add('$hochgeladen hochgeladen');
|
||||||
|
if (schonDa > 0) teile.add('$schonDa waren schon da');
|
||||||
|
if (fehler.isNotEmpty) teile.add('${fehler.length} fehlgeschlagen');
|
||||||
|
if (abgebrochen) teile.add('abgebrochen');
|
||||||
|
return teile.isEmpty ? 'Nichts zu tun' : teile.join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was seit dem letzten erfolgreichen Abgleich passiert ist — der Inhalt des
|
||||||
|
/// „Willkommen zurück"-Dialogs.
|
||||||
|
class SyncBericht {
|
||||||
|
const SyncBericht({
|
||||||
|
required this.neueSongs,
|
||||||
|
required this.geloeschte,
|
||||||
|
required this.favoriten,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int neueSongs;
|
||||||
|
final int geloeschte;
|
||||||
|
final int favoriten;
|
||||||
|
|
||||||
|
bool get istLeer => neueSongs == 0 && geloeschte == 0 && favoriten == 0;
|
||||||
|
}
|
||||||
|
|
||||||
/// Hält Gerät und Server auf demselben Stand: lädt neue Server-Titel herunter,
|
/// Hält Gerät und Server auf demselben Stand: lädt neue Server-Titel herunter,
|
||||||
/// bringt eigene Dateien hoch, zieht Löschungen nach und meldet Favoriten und
|
/// bringt eigene Dateien hoch, zieht Löschungen nach und meldet Favoriten und
|
||||||
/// Wiedergaben. Alle Geräte am selben Konto sehen dadurch dasselbe.
|
/// Wiedergaben. Alle Geräte am selben Konto sehen dadurch dasselbe.
|
||||||
@@ -132,6 +176,7 @@ class SyncService extends ChangeNotifier {
|
|||||||
}) : _musikOrdner = musikOrdner ?? _standardMusikOrdner;
|
}) : _musikOrdner = musikOrdner ?? _standardMusikOrdner;
|
||||||
|
|
||||||
static const _letzterLaufKey = 'cloud_sync_letzter_lauf';
|
static const _letzterLaufKey = 'cloud_sync_letzter_lauf';
|
||||||
|
static const _letzterErfolgKey = 'cloud_sync_letzter_erfolg';
|
||||||
static const _verlaufStandKey = 'cloud_sync_verlauf_stand';
|
static const _verlaufStandKey = 'cloud_sync_verlauf_stand';
|
||||||
static const _uuid = Uuid();
|
static const _uuid = Uuid();
|
||||||
|
|
||||||
@@ -146,6 +191,9 @@ class SyncService extends ChangeNotifier {
|
|||||||
String? _fehler;
|
String? _fehler;
|
||||||
String? _status;
|
String? _status;
|
||||||
DateTime? _letzterLauf;
|
DateTime? _letzterLauf;
|
||||||
|
DateTime? _letzterErfolg;
|
||||||
|
SyncBericht? _bericht;
|
||||||
|
bool _abbruchGewuenscht = false;
|
||||||
|
|
||||||
bool get laeuft => _laeuft;
|
bool get laeuft => _laeuft;
|
||||||
int get erledigt => _erledigt;
|
int get erledigt => _erledigt;
|
||||||
@@ -154,10 +202,23 @@ class SyncService extends ChangeNotifier {
|
|||||||
String? get status => _status;
|
String? get status => _status;
|
||||||
DateTime? get letzterLauf => _letzterLauf;
|
DateTime? get letzterLauf => _letzterLauf;
|
||||||
|
|
||||||
|
/// Der fällige Bericht, oder `null`. Wird von der Oberfläche genau einmal
|
||||||
|
/// abgeholt und dann mit [berichtGesehen] quittiert.
|
||||||
|
SyncBericht? get bericht => _bericht;
|
||||||
|
|
||||||
|
void berichtGesehen() {
|
||||||
|
_bericht = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> laden() async {
|
Future<void> laden() async {
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final ms = prefs.getInt(_letzterLaufKey);
|
final ms = prefs.getInt(_letzterLaufKey);
|
||||||
if (ms != null) _letzterLauf = DateTime.fromMillisecondsSinceEpoch(ms);
|
if (ms != null) _letzterLauf = DateTime.fromMillisecondsSinceEpoch(ms);
|
||||||
|
final erfolg = prefs.getInt(_letzterErfolgKey);
|
||||||
|
if (erfolg != null) {
|
||||||
|
_letzterErfolg = DateTime.fromMillisecondsSinceEpoch(erfolg);
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +246,14 @@ class SyncService extends ChangeNotifier {
|
|||||||
_gesamt = 0;
|
_gesamt = 0;
|
||||||
_melde('Vergleiche mit dem Server …');
|
_melde('Vergleiche mit dem Server …');
|
||||||
|
|
||||||
|
// Der Zeitstempel ist der Stand VOR dem Listen: was während des Laufs am
|
||||||
|
// Server passiert, muss beim nächsten Mal noch drankommen (v2-Lektion,
|
||||||
|
// Tombstone-Race).
|
||||||
|
final laufBeginn = DateTime.now();
|
||||||
|
var vollstaendig = true;
|
||||||
|
var neueSongs = 0;
|
||||||
|
var geloeschte = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final amServer = await cloud.liste();
|
final amServer = await cloud.liste();
|
||||||
final plan = planeSync(
|
final plan = planeSync(
|
||||||
@@ -204,18 +273,38 @@ class SyncService extends ChangeNotifier {
|
|||||||
'$bestand Titeln würden am Server gelöscht. Das sieht nach einem '
|
'$bestand Titeln würden am Server gelöscht. Das sieht nach einem '
|
||||||
'Fehler aus (z. B. Speicherkarte nicht eingehängt) — es wurde '
|
'Fehler aus (z. B. Speicherkarte nicht eingehängt) — es wurde '
|
||||||
'nichts gelöscht.';
|
'nichts gelöscht.';
|
||||||
|
vollstaendig = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jede Phase für sich: fällt eine aus, laufen die übrigen weiter, und
|
||||||
|
// der Erfolgs-Zeitstempel bleibt stehen.
|
||||||
|
geloeschte = plan.lokalLoeschen.length;
|
||||||
await _ziehLoeschungenNach(plan.lokalLoeschen);
|
await _ziehLoeschungenNach(plan.lokalLoeschen);
|
||||||
if (!bremse) await _meldeLoeschungen(plan.serverLoeschen);
|
if (!bremse && !await _meldeLoeschungen(plan.serverLoeschen)) {
|
||||||
await _ladeHerunter(plan.herunterladen);
|
vollstaendig = false;
|
||||||
await _ladeHoch(plan.hochladen);
|
}
|
||||||
await _gleicheFavoritenAb();
|
neueSongs = await _ladeHerunter(plan.herunterladen);
|
||||||
|
if (!await _ladeHoch(plan.hochladen)) vollstaendig = false;
|
||||||
|
if (!await _gleicheFavoritenAb()) vollstaendig = false;
|
||||||
await _meldeVerlauf();
|
await _meldeVerlauf();
|
||||||
|
|
||||||
_letzterLauf = DateTime.now();
|
_letzterLauf = laufBeginn;
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
await prefs.setInt(_letzterLaufKey, _letzterLauf!.millisecondsSinceEpoch);
|
await prefs.setInt(_letzterLaufKey, laufBeginn.millisecondsSinceEpoch);
|
||||||
|
|
||||||
|
if (vollstaendig) {
|
||||||
|
// „Erfolgreich" heißt: keine Phase hat einen Fehler geschluckt.
|
||||||
|
if (berichtFaellig(_letzterErfolg, laufBeginn)) {
|
||||||
|
_bericht = SyncBericht(
|
||||||
|
neueSongs: neueSongs,
|
||||||
|
geloeschte: geloeschte,
|
||||||
|
favoriten: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_letzterErfolg = laufBeginn;
|
||||||
|
await prefs.setInt(
|
||||||
|
_letzterErfolgKey, laufBeginn.millisecondsSinceEpoch);
|
||||||
|
}
|
||||||
} on CloudException catch (e) {
|
} on CloudException catch (e) {
|
||||||
_fehler = e.message;
|
_fehler = e.message;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -228,6 +317,104 @@ class SyncService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stoppt einen laufenden Auswahl-Upload zwischen zwei Titeln.
|
||||||
|
///
|
||||||
|
/// Nach dem Muster von `DownloadService.abbrechen`: wer versehentlich 60
|
||||||
|
/// statt 6 Titel markiert hat, soll nicht die App killen müssen.
|
||||||
|
void abbrechen() {
|
||||||
|
if (_laeuft) _abbruchGewuenscht = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lädt genau [songs] zum Server — die ausdrückliche Nutzeraktion aus dem
|
||||||
|
/// Auswahl-Modus.
|
||||||
|
///
|
||||||
|
/// Sequenziell, weil der Server jeden Upload komplett im RAM hält. Titel mit
|
||||||
|
/// cloudId werden übersprungen, Einzelfehler vermerkt und übergangen.
|
||||||
|
/// Der Sync-Zeitstempel wird bewusst **nicht** geschrieben: ein Upload ist
|
||||||
|
/// kein Abgleich, und sonst unterdrückte er 15 Minuten den Auto-Sync und
|
||||||
|
/// verschöbe die 24-h-Uhr des Berichts.
|
||||||
|
///
|
||||||
|
/// Den Offline-Modus-Schalter beachtet er nicht — er ist eine ausdrückliche
|
||||||
|
/// Nutzeraktion.
|
||||||
|
Future<UploadErgebnis> ladeAusgewaehlteHoch(List<Song> songs) async {
|
||||||
|
if (_laeuft) {
|
||||||
|
_fehler = 'Es läuft gerade ein Abgleich — bitte kurz warten';
|
||||||
|
notifyListeners();
|
||||||
|
return const UploadErgebnis();
|
||||||
|
}
|
||||||
|
if (!cloud.istAngemeldet) {
|
||||||
|
_fehler = 'Bitte zuerst beim Baka-Konto anmelden';
|
||||||
|
notifyListeners();
|
||||||
|
return const UploadErgebnis();
|
||||||
|
}
|
||||||
|
|
||||||
|
_laeuft = true;
|
||||||
|
_abbruchGewuenscht = false;
|
||||||
|
_fehler = null;
|
||||||
|
_erledigt = 0;
|
||||||
|
_gesamt = songs.length;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
var hochgeladen = 0;
|
||||||
|
var schonDa = 0;
|
||||||
|
final fehler = <String>[];
|
||||||
|
var abgebrochen = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (final song in songs) {
|
||||||
|
if (_abbruchGewuenscht) {
|
||||||
|
abgebrochen = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (song.cloudId != null) {
|
||||||
|
schonDa++;
|
||||||
|
_erledigt++;
|
||||||
|
notifyListeners();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final datei = File(song.path);
|
||||||
|
if (!await datei.exists()) {
|
||||||
|
fehler.add('${song.title}: Datei nicht gefunden');
|
||||||
|
_erledigt++;
|
||||||
|
notifyListeners();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_melde('Sende „${song.title}" …');
|
||||||
|
try {
|
||||||
|
final cloudId = await cloud.hochladen(
|
||||||
|
datei,
|
||||||
|
dateiname:
|
||||||
|
'${_sichererDateiname(song.title)}${p.extension(song.path)}',
|
||||||
|
);
|
||||||
|
if (cloudId != null) {
|
||||||
|
await db.setCloudId(song.id, cloudId);
|
||||||
|
hochgeladen++;
|
||||||
|
} else {
|
||||||
|
fehler.add('${song.title}: keine Server-ID erhalten');
|
||||||
|
}
|
||||||
|
} on CloudException catch (e) {
|
||||||
|
fehler.add('${song.title}: ${e.message}');
|
||||||
|
} on TimeoutException {
|
||||||
|
fehler.add('${song.title}: Zeitüberschreitung');
|
||||||
|
}
|
||||||
|
_erledigt++;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_laeuft = false;
|
||||||
|
_abbruchGewuenscht = false;
|
||||||
|
_status = null;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
return UploadErgebnis(
|
||||||
|
hochgeladen: hochgeladen,
|
||||||
|
schonDa: schonDa,
|
||||||
|
fehler: fehler,
|
||||||
|
abgebrochen: abgebrochen,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _ziehLoeschungenNach(List<Song> songs) async {
|
Future<void> _ziehLoeschungenNach(List<Song> songs) async {
|
||||||
if (songs.isEmpty) return;
|
if (songs.isEmpty) return;
|
||||||
_melde('Entferne ${songs.length} am Server gelöschte Titel …');
|
_melde('Entferne ${songs.length} am Server gelöschte Titel …');
|
||||||
@@ -236,7 +423,13 @@ class SyncService extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _meldeLoeschungen(List<Song> songs) async {
|
/// Meldet die hier getombsteten Titel am Server.
|
||||||
|
///
|
||||||
|
/// Gibt `false` zurück, sobald eine Meldung geschluckt wurde: der Lauf geht
|
||||||
|
/// weiter, gilt aber nicht mehr als erfolgreich — sonst rückte der
|
||||||
|
/// 24-h-Zeitstempel des Berichts vor, obwohl eine Phase ausgefallen ist.
|
||||||
|
Future<bool> _meldeLoeschungen(List<Song> songs) async {
|
||||||
|
var vollstaendig = true;
|
||||||
for (final song in songs) {
|
for (final song in songs) {
|
||||||
_melde('Melde Löschung von „${song.title}“ …');
|
_melde('Melde Löschung von „${song.title}“ …');
|
||||||
try {
|
try {
|
||||||
@@ -244,14 +437,19 @@ class SyncService extends ChangeNotifier {
|
|||||||
} on CloudException catch (e) {
|
} on CloudException catch (e) {
|
||||||
// Eine abgelehnte Löschung darf den Lauf nicht beenden.
|
// Eine abgelehnte Löschung darf den Lauf nicht beenden.
|
||||||
debugPrint('Löschung „${song.title}“ übersprungen: ${e.message}');
|
debugPrint('Löschung „${song.title}“ übersprungen: ${e.message}');
|
||||||
|
vollstaendig = false;
|
||||||
}
|
}
|
||||||
_erledigt++;
|
_erledigt++;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
return vollstaendig;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _ladeHerunter(List<CloudSong> songs) async {
|
/// Gibt zurück, wie viele Titel wirklich neu dazugekommen sind — die Zahl
|
||||||
if (songs.isEmpty) return;
|
/// im „Was ist neu"-Bericht.
|
||||||
|
Future<int> _ladeHerunter(List<CloudSong> songs) async {
|
||||||
|
if (songs.isEmpty) return 0;
|
||||||
|
var neu = 0;
|
||||||
final ordner = await _musikOrdner();
|
final ordner = await _musikOrdner();
|
||||||
for (final cloudSong in songs) {
|
for (final cloudSong in songs) {
|
||||||
_melde('Lade „${cloudSong.titel}“ …');
|
_melde('Lade „${cloudSong.titel}“ …');
|
||||||
@@ -291,12 +489,21 @@ class SyncService extends ChangeNotifier {
|
|||||||
cloudId: Value(cloudSong.id),
|
cloudId: Value(cloudSong.id),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
neu++;
|
||||||
_erledigt++;
|
_erledigt++;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
return neu;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _ladeHoch(List<Song> songs) async {
|
/// Lädt alle Titel ohne cloudId hoch.
|
||||||
|
///
|
||||||
|
/// Gibt `false` zurück, sobald ein Upload an einem Fehler oder einer
|
||||||
|
/// Zeitüberschreitung hängenblieb. Eine **fehlende Datei** zählt bewusst
|
||||||
|
/// nicht dazu: dort ist nichts schiefgegangen, der Titel ist weg und der
|
||||||
|
/// nächste Scan tombstoned ihn.
|
||||||
|
Future<bool> _ladeHoch(List<Song> songs) async {
|
||||||
|
var vollstaendig = true;
|
||||||
for (final song in songs) {
|
for (final song in songs) {
|
||||||
final datei = File(song.path);
|
final datei = File(song.path);
|
||||||
if (!await datei.exists()) {
|
if (!await datei.exists()) {
|
||||||
@@ -309,29 +516,89 @@ class SyncService extends ChangeNotifier {
|
|||||||
datei,
|
datei,
|
||||||
dateiname: '${_sichererDateiname(song.title)}${p.extension(song.path)}',
|
dateiname: '${_sichererDateiname(song.title)}${p.extension(song.path)}',
|
||||||
);
|
);
|
||||||
if (cloudId != null) await db.setCloudId(song.id, cloudId);
|
if (cloudId != null) {
|
||||||
|
await db.setCloudId(song.id, cloudId);
|
||||||
|
} else {
|
||||||
|
// 200 ohne Server-ID: der Titel ist oben nicht angekommen.
|
||||||
|
vollstaendig = false;
|
||||||
|
}
|
||||||
} on CloudException catch (e) {
|
} on CloudException catch (e) {
|
||||||
// Eine zu große oder abgelehnte Datei darf den Lauf nicht beenden.
|
// Eine zu große oder abgelehnte Datei darf den Lauf nicht beenden.
|
||||||
debugPrint('Upload „${song.title}“ übersprungen: ${e.message}');
|
debugPrint('Upload „${song.title}“ übersprungen: ${e.message}');
|
||||||
|
vollstaendig = false;
|
||||||
|
} on TimeoutException {
|
||||||
|
// Der 120-s-Timeout (melo_cloud_service.dart:180) wirft
|
||||||
|
// TimeoutException, nicht CloudException — ohne diesen Zweig riss ein
|
||||||
|
// einziger hängender Upload den ganzen Lauf ab.
|
||||||
|
debugPrint('Upload „${song.title}“: Zeitüberschreitung');
|
||||||
|
vollstaendig = false;
|
||||||
}
|
}
|
||||||
_erledigt++;
|
_erledigt++;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
return vollstaendig;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _gleicheFavoritenAb() async {
|
/// Additiver Favoriten-Abgleich: gleicht in **beide** Richtungen an,
|
||||||
|
/// entfernt aber in **keiner**.
|
||||||
|
///
|
||||||
|
/// Damit ist der alte Datenverlust-Bug strukturell unmöglich: es gibt
|
||||||
|
/// keinen Codepfad mehr, der den Server-Stand ersetzen könnte. Der Preis
|
||||||
|
/// ist bekannt und bewusst: ein Ent-Favorisieren propagiert nicht
|
||||||
|
/// geräteübergreifend — hält ein zweites Gerät den Favoriten noch, bringt
|
||||||
|
/// dessen nächster Abgleich ihn zurück.
|
||||||
|
///
|
||||||
|
/// Gibt `true` zurück, wenn die Phase vollständig durchlief.
|
||||||
|
Future<bool> _gleicheFavoritenAb() async {
|
||||||
_melde('Gleiche Favoriten ab …');
|
_melde('Gleiche Favoriten ab …');
|
||||||
|
|
||||||
|
final Set<String> amServer;
|
||||||
|
try {
|
||||||
|
amServer = (await cloud.favoriten()).toSet();
|
||||||
|
} catch (e) {
|
||||||
|
// Ohne Server-Stand ist nichts zu tun. Blindes Pushen wäre harmlos,
|
||||||
|
// aber nutzlos — die Phase wird übersprungen, der Lauf geht weiter.
|
||||||
|
debugPrint('Favoriten-Abgleich übersprungen: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grabsteine bleiben außen vor: favoriteSongIds() liefert auch Favoriten
|
||||||
|
// getombsteter Titel, und die gehören nicht zurück auf den Server.
|
||||||
final lokal = await db.allSongs();
|
final lokal = await db.allSongs();
|
||||||
final cloudIdVon = {
|
final cloudIdVon = {
|
||||||
for (final s in lokal)
|
for (final s in lokal)
|
||||||
if (s.cloudId != null) s.id: s.cloudId!,
|
if (s.cloudId != null && !s.deleted) s.id: s.cloudId!,
|
||||||
};
|
};
|
||||||
final favoritenIds = await db.favoriteSongIds();
|
final songIdVonCloudId = {
|
||||||
final cloudFavoriten = [
|
for (final s in lokal)
|
||||||
for (final id in favoritenIds)
|
if (s.cloudId != null && !s.deleted) s.cloudId!: s.id,
|
||||||
if (cloudIdVon[id] != null) cloudIdVon[id]!,
|
};
|
||||||
];
|
final favoriten = (await db.favoriteSongIds()).toSet();
|
||||||
await cloud.setzeFavoriten(cloudFavoriten);
|
|
||||||
|
var vollstaendig = true;
|
||||||
|
|
||||||
|
for (final cloudId in zuPushendeFavoriten(
|
||||||
|
lokaleFavoriten: favoriten,
|
||||||
|
cloudIdVon: cloudIdVon,
|
||||||
|
amServer: amServer,
|
||||||
|
)) {
|
||||||
|
try {
|
||||||
|
await cloud.setzeFavorit(cloudId, true);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Favorit $cloudId nicht gemeldet: $e');
|
||||||
|
vollstaendig = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final songId in lokalZuSetzendeFavoriten(
|
||||||
|
amServer: amServer,
|
||||||
|
songIdVonCloudId: songIdVonCloudId,
|
||||||
|
lokaleFavoriten: favoriten,
|
||||||
|
)) {
|
||||||
|
await db.setFavorite(songId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return vollstaendig;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _meldeVerlauf() async {
|
Future<void> _meldeVerlauf() async {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../library/playlist_service.dart';
|
|||||||
import '../library/song_media.dart';
|
import '../library/song_media.dart';
|
||||||
import '../player/audio_handler.dart';
|
import '../player/audio_handler.dart';
|
||||||
import '../playlists/create_playlist_dialog.dart';
|
import '../playlists/create_playlist_dialog.dart';
|
||||||
|
import '../services/sync_service.dart';
|
||||||
import '../settings/app_settings.dart';
|
import '../settings/app_settings.dart';
|
||||||
import 'auswahl.dart';
|
import 'auswahl.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
@@ -22,6 +23,7 @@ class AuswahlLeiste extends StatelessWidget {
|
|||||||
required this.onAbbrechen,
|
required this.onAbbrechen,
|
||||||
required this.onWiedergabeliste,
|
required this.onWiedergabeliste,
|
||||||
required this.onWarteschlange,
|
required this.onWarteschlange,
|
||||||
|
this.onServerLaden,
|
||||||
});
|
});
|
||||||
|
|
||||||
final int anzahl;
|
final int anzahl;
|
||||||
@@ -29,6 +31,10 @@ class AuswahlLeiste extends StatelessWidget {
|
|||||||
final VoidCallback onWiedergabeliste;
|
final VoidCallback onWiedergabeliste;
|
||||||
final VoidCallback onWarteschlange;
|
final VoidCallback onWarteschlange;
|
||||||
|
|
||||||
|
/// Nur gesetzt, wo der Upload hingehört („Meine Musik"). Sonst erschiene
|
||||||
|
/// die Aktion in allen fünf Ansichten, die diese Leiste benutzen.
|
||||||
|
final VoidCallback? onServerLaden;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
@@ -49,6 +55,12 @@ class AuswahlLeiste extends StatelessWidget {
|
|||||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (onServerLaden != null)
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Auf den Server laden',
|
||||||
|
icon: const Icon(Icons.cloud_upload),
|
||||||
|
onPressed: onServerLaden,
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'Zur Warteschlange hinzufügen',
|
tooltip: 'Zur Warteschlange hinzufügen',
|
||||||
icon: const Icon(Icons.queue),
|
icon: const Icon(Icons.queue),
|
||||||
@@ -159,3 +171,18 @@ Future<void> fuegeZuWarteschlangeHinzu(
|
|||||||
: '$angehaengt von ${gewaehlte.length} Titeln angehängt'),
|
: '$angehaengt von ${gewaehlte.length} Titeln angehängt'),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lädt [gewaehlte] zum Melo-Server und meldet das Ergebnis.
|
||||||
|
///
|
||||||
|
/// Wohnt neben [fuegeZuWiedergabelisteHinzu]: dieselbe Bauart, dieselbe Art
|
||||||
|
/// Rückmeldung.
|
||||||
|
Future<void> ladeAufServer(
|
||||||
|
BuildContext context, List<Song> gewaehlte) async {
|
||||||
|
final sync = context.read<SyncService>();
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
final ergebnis = await sync.ladeAusgewaehlteHoch(gewaehlte);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text(sync.fehler ?? ergebnis.meldung)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ class SortableSongList extends StatefulWidget {
|
|||||||
required this.songs,
|
required this.songs,
|
||||||
required this.storeKey,
|
required this.storeKey,
|
||||||
this.empty,
|
this.empty,
|
||||||
|
this.serverUpload = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<Song> songs;
|
final List<Song> songs;
|
||||||
@@ -31,6 +32,10 @@ class SortableSongList extends StatefulWidget {
|
|||||||
/// Wird statt der Liste gezeigt, wenn [songs] leer ist.
|
/// Wird statt der Liste gezeigt, wenn [songs] leer ist.
|
||||||
final Widget? empty;
|
final Widget? empty;
|
||||||
|
|
||||||
|
/// Ob der Auswahl-Modus „Auf den Server laden" anbietet. Standard `false`:
|
||||||
|
/// dieses Widget steckt in fünf Ansichten, gemeint ist nur „Meine Musik".
|
||||||
|
final bool serverUpload;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SortableSongList> createState() => _SortableSongListState();
|
State<SortableSongList> createState() => _SortableSongListState();
|
||||||
}
|
}
|
||||||
@@ -67,6 +72,11 @@ class _SortableSongListState extends State<SortableSongList> {
|
|||||||
if (mounted) _beendeAuswahl();
|
if (mounted) _beendeAuswahl();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _aufServer(List<Song> gewaehlte) async {
|
||||||
|
await ladeAufServer(context, gewaehlte);
|
||||||
|
if (mounted) _beendeAuswahl();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_scroll.dispose();
|
_scroll.dispose();
|
||||||
@@ -196,6 +206,9 @@ class _SortableSongListState extends State<SortableSongList> {
|
|||||||
onAbbrechen: _beendeAuswahl,
|
onAbbrechen: _beendeAuswahl,
|
||||||
onWiedergabeliste: () => _inWiedergabeliste(_gewaehlte(sorted)),
|
onWiedergabeliste: () => _inWiedergabeliste(_gewaehlte(sorted)),
|
||||||
onWarteschlange: () => _inWarteschlange(_gewaehlte(sorted)),
|
onWarteschlange: () => _inWarteschlange(_gewaehlte(sorted)),
|
||||||
|
onServerLaden: widget.serverUpload
|
||||||
|
? () => _aufServer(_gewaehlte(sorted))
|
||||||
|
: null,
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Padding(
|
Padding(
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../services/sync_service.dart';
|
||||||
|
|
||||||
|
/// „Willkommen zurück" — was sich seit dem letzten erfolgreichen Abgleich
|
||||||
|
/// getan hat. Rein in-App, ohne Benachrichtigungs-Kanal und ohne neue
|
||||||
|
/// Abhängigkeit.
|
||||||
|
class SyncBerichtDialog extends StatelessWidget {
|
||||||
|
const SyncBerichtDialog({super.key, required this.bericht});
|
||||||
|
|
||||||
|
final SyncBericht bericht;
|
||||||
|
|
||||||
|
static String textFuer(SyncBericht b) {
|
||||||
|
if (b.istLeer) return 'Nichts Neues seit dem letzten Abgleich.';
|
||||||
|
final teile = <String>[];
|
||||||
|
if (b.neueSongs > 0) teile.add('${b.neueSongs} neue Titel');
|
||||||
|
if (b.geloeschte > 0) teile.add('${b.geloeschte} entfernt');
|
||||||
|
if (b.favoriten > 0) teile.add('${b.favoriten} Favoriten geändert');
|
||||||
|
return '${teile.join(' · ')}.';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Willkommen zurück!'),
|
||||||
|
content: Text(textFuer(bericht)),
|
||||||
|
actions: [
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Alles klar'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:melo/library/database.dart';
|
||||||
|
import 'package:melo/downloads/server_titel_screen.dart';
|
||||||
|
import 'package:melo/player/audio_handler.dart';
|
||||||
|
import 'package:melo/services/download_service.dart';
|
||||||
|
import 'package:melo/services/navidrome_service.dart';
|
||||||
|
|
||||||
|
/// Merkt sich nur, was verlangt wurde — echte Downloads brauchen einen
|
||||||
|
/// Server, und darum geht es hier nicht.
|
||||||
|
class _FakeDownloads extends DownloadService {
|
||||||
|
_FakeDownloads(MeloDb db) : super(db: db, navidrome: NavidromeService());
|
||||||
|
|
||||||
|
final geladen = <String>[];
|
||||||
|
final entfernt = <String>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get laeuft => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> ladeEinzelnenTitel(SubsonicSong song) async {
|
||||||
|
geladen.add(song.id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> entferne(String navidromeId) async {
|
||||||
|
entfernt.add(navidromeId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final titel = [
|
||||||
|
const SubsonicSong(
|
||||||
|
id: 'nav-1',
|
||||||
|
titel: 'Nachtpuls',
|
||||||
|
kuenstler: 'Rotklang',
|
||||||
|
album: 'Nacht',
|
||||||
|
dauerSekunden: 200,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<({_FakeDownloads dienst, MeloAudioHandler handler})> pumpe(
|
||||||
|
WidgetTester tester,
|
||||||
|
{bool schonGeladen = false}) async {
|
||||||
|
final db = MeloDb(NativeDatabase.memory());
|
||||||
|
addTearDown(db.close);
|
||||||
|
if (schonGeladen) {
|
||||||
|
// Der Bildschirm liest den Zustand einmal per db.downloadIds().
|
||||||
|
await db.merkeDownload(DownloadsCompanion.insert(
|
||||||
|
navidromeId: 'nav-1',
|
||||||
|
titel: 'Nachtpuls',
|
||||||
|
groesseBytes: 1,
|
||||||
|
geladenAmMs: 0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
final dienst = _FakeDownloads(db);
|
||||||
|
// MeloAudioHandler startet einen periodischen Timer (Positions-
|
||||||
|
// Buchführung). Ohne handler.dispose() meldet der Testrahmen ihn als
|
||||||
|
// „pending" — dasselbe etablierte Muster wie in
|
||||||
|
// test/library/my_music_screen_test.dart: handler.dispose() explizit am
|
||||||
|
// Testende aufrufen (addTearDown greift zu spät, außerhalb der
|
||||||
|
// FakeAsync-Zone, in der der Timer geprüft wird).
|
||||||
|
final handler = MeloAudioHandler(db: db);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
Provider<MeloDb>.value(value: db),
|
||||||
|
ChangeNotifierProvider<DownloadService>.value(value: dienst),
|
||||||
|
Provider<MeloAudioHandler>.value(value: handler),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
home: ServerTitelScreen(
|
||||||
|
titel: 'Nacht',
|
||||||
|
navidrome: NavidromeService(),
|
||||||
|
holeTitel: () async => titel,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle(
|
||||||
|
const Duration(milliseconds: 100),
|
||||||
|
EnginePhase.sendSemanticsUpdate,
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
);
|
||||||
|
return (dienst: dienst, handler: handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('ein einzelner Titel lässt sich offline nehmen', (tester) async {
|
||||||
|
final (:dienst, :handler) = await pumpe(tester);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Offline nehmen'));
|
||||||
|
await tester.pumpAndSettle(
|
||||||
|
const Duration(milliseconds: 100),
|
||||||
|
EnginePhase.sendSemanticsUpdate,
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dienst.geladen, ['nav-1']);
|
||||||
|
expect(dienst.entfernt, isEmpty);
|
||||||
|
|
||||||
|
handler.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ein schon geladener Titel bietet den Gegenweg an',
|
||||||
|
(tester) async {
|
||||||
|
final (:dienst, :handler) =
|
||||||
|
await pumpe(tester, schonGeladen: true);
|
||||||
|
|
||||||
|
// Zustand „schon offline": statt „Offline nehmen" steht dort das
|
||||||
|
// Entfernen — ohne diesen Test wäre der halbe Knopf ungeprüft.
|
||||||
|
expect(find.byTooltip('Offline nehmen'), findsNothing);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Vom Gerät entfernen'));
|
||||||
|
await tester.pumpAndSettle(
|
||||||
|
const Duration(milliseconds: 100),
|
||||||
|
EnginePhase.sendSemanticsUpdate,
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(dienst.entfernt, ['nav-1']);
|
||||||
|
expect(dienst.geladen, isEmpty);
|
||||||
|
|
||||||
|
handler.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import 'package:drift/drift.dart' show Migrator;
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:melo/library/database.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('Bestandsdaten überleben die neue Spalte', () async {
|
||||||
|
final db = MeloDb(NativeDatabase.memory());
|
||||||
|
addTearDown(db.close);
|
||||||
|
|
||||||
|
// Den Stand von Schema 10 nachbauen: Tabelle ohne cloud_id, mit Daten.
|
||||||
|
await db.customStatement('DROP TABLE playlist_songs');
|
||||||
|
await db.customStatement('DROP TABLE playlists');
|
||||||
|
await db.customStatement(
|
||||||
|
'CREATE TABLE playlists ('
|
||||||
|
'id TEXT NOT NULL, '
|
||||||
|
'name TEXT NOT NULL, '
|
||||||
|
'description TEXT NULL, '
|
||||||
|
'created_at_ms INTEGER NOT NULL, '
|
||||||
|
'updated_at_ms INTEGER NOT NULL, '
|
||||||
|
'deleted INTEGER NOT NULL DEFAULT 0, '
|
||||||
|
'PRIMARY KEY (id))',
|
||||||
|
);
|
||||||
|
await db.customStatement(
|
||||||
|
"INSERT INTO playlists (id, name, created_at_ms, updated_at_ms) "
|
||||||
|
"VALUES ('alt-1', 'Road Trip', 0, 0)",
|
||||||
|
);
|
||||||
|
|
||||||
|
await Migrator(db).addColumn(db.playlists, db.playlists.cloudId);
|
||||||
|
|
||||||
|
final rows = await db.select(db.playlists).get();
|
||||||
|
expect(rows.single.name, 'Road Trip');
|
||||||
|
expect(rows.single.cloudId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setPlaylistCloudId merkt sich die Server-ID', () async {
|
||||||
|
final db = MeloDb(NativeDatabase.memory());
|
||||||
|
addTearDown(db.close);
|
||||||
|
|
||||||
|
final id = await db.createPlaylist('Mix');
|
||||||
|
await db.setPlaylistCloudId(id, '42');
|
||||||
|
|
||||||
|
expect((await db.playlistById(id))!.cloudId, '42');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('countPlaylists zählt auch Grabsteine', () async {
|
||||||
|
final db = MeloDb(NativeDatabase.memory());
|
||||||
|
addTearDown(db.close);
|
||||||
|
|
||||||
|
final id = await db.createPlaylist('Mix');
|
||||||
|
await db.deletePlaylist(id);
|
||||||
|
|
||||||
|
// Sonst hielte die Wiederherstellung ein Gerät, auf dem der Nutzer alle
|
||||||
|
// Playlisten gelöscht hat, für eine Neuinstallation.
|
||||||
|
expect(await db.countPlaylists(), 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:melo/library/database.dart';
|
||||||
import 'package:melo/services/download_service.dart';
|
import 'package:melo/services/download_service.dart';
|
||||||
import 'package:melo/services/download_speicher.dart';
|
import 'package:melo/services/download_speicher.dart';
|
||||||
|
import 'package:melo/services/navidrome_service.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
group('brauchtRueckfrage', () {
|
group('brauchtRueckfrage', () {
|
||||||
@@ -54,4 +57,25 @@ void main() {
|
|||||||
expect(DownloadSpeicher.dateiName('abc'), endsWith('.mp3'));
|
expect(DownloadSpeicher.dateiName('abc'), endsWith('.mp3'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('ladeEinzelnenTitel', () {
|
||||||
|
test('ohne Serververbindung wird nichts geladen und der Grund steht da',
|
||||||
|
() async {
|
||||||
|
final db = MeloDb(NativeDatabase.memory());
|
||||||
|
addTearDown(db.close);
|
||||||
|
final dienst = DownloadService(db: db, navidrome: NavidromeService());
|
||||||
|
|
||||||
|
final neu = await dienst.ladeEinzelnenTitel(const SubsonicSong(
|
||||||
|
id: 'nav-1',
|
||||||
|
titel: 'Nachtpuls',
|
||||||
|
kuenstler: 'Rotklang',
|
||||||
|
album: 'Nacht',
|
||||||
|
dauerSekunden: 200,
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(neu, isFalse);
|
||||||
|
expect(dienst.fehler, contains('Musikserver'));
|
||||||
|
expect(await db.downloadIds(), isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,8 +1,30 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
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/services/baka_auth.dart';
|
||||||
import 'package:melo/services/melo_cloud_service.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() {
|
||||||
group('parseListe', () {
|
group('parseListe', () {
|
||||||
test('liest Titel inklusive Grabsteinen', () {
|
test('liest Titel inklusive Grabsteinen', () {
|
||||||
@@ -95,9 +117,30 @@ void main() {
|
|||||||
expect(MeloCloudService.parseFavoriten(body), ['a', 'b']);
|
expect(MeloCloudService.parseFavoriten(body), ['a', 'b']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ohne Favoriten leere Liste', () {
|
test('eine leere Favoritenliste ist kein Fehler', () {
|
||||||
expect(MeloCloudService.parseFavoriten(jsonEncode({'status': 'ok'})),
|
expect(
|
||||||
isEmpty);
|
MeloCloudService.parseFavoriten(jsonEncode({'favorites': []})),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fehlender Schlüssel ist ein Fehler, keine leere Menge', () {
|
||||||
|
// Der Router verdrahtet für GET /favorites hart HTTP 200; sechs Handler
|
||||||
|
// desselben Servers melden Fehler im 200er-Körper. Ein fälschlich
|
||||||
|
// leeres Ergebnis wäre von einer echten Leerantwort nicht zu
|
||||||
|
// unterscheiden — wie parseListe und parseUpload wird deshalb geworfen.
|
||||||
|
expect(
|
||||||
|
() => MeloCloudService.parseFavoriten(jsonEncode({'status': 'ok'})),
|
||||||
|
throwsA(isA<CloudException>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Server-Fehler im 200er-Körper wird als CloudException gemeldet', () {
|
||||||
|
expect(
|
||||||
|
() => MeloCloudService.parseFavoriten(
|
||||||
|
jsonEncode({'status': 'error', 'error': 'Auth required'})),
|
||||||
|
throwsA(isA<CloudException>()),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,4 +177,39 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('setzeFavorit', () {
|
||||||
|
test('meldet den Wunsch deterministisch, nicht als Umschalten', () async {
|
||||||
|
Map<String, dynamic>? gesendet;
|
||||||
|
String? pfad;
|
||||||
|
final dienst = MeloCloudService(
|
||||||
|
auth: await _angemeldeteAuth(),
|
||||||
|
client: MockClient((anfrage) async {
|
||||||
|
pfad = anfrage.url.path;
|
||||||
|
gesendet = jsonDecode(anfrage.body) as Map<String, dynamic>;
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await dienst.setzeFavorit('c5', true);
|
||||||
|
|
||||||
|
expect(pfad, endsWith('/favorites/toggle'));
|
||||||
|
expect(gesendet, {'song_id': 'c5', 'set': true});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kann einen Favoriten auch ausdrücklich entfernen', () async {
|
||||||
|
Map<String, dynamic>? gesendet;
|
||||||
|
final dienst = MeloCloudService(
|
||||||
|
auth: await _angemeldeteAuth(),
|
||||||
|
client: MockClient((anfrage) async {
|
||||||
|
gesendet = jsonDecode(anfrage.body) as Map<String, dynamic>;
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await dienst.setzeFavorit('c5', false);
|
||||||
|
|
||||||
|
expect(gesendet, {'song_id': 'c5', 'set': false});
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:melo/services/sync_merge.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('zuPushendeFavoriten', () {
|
||||||
|
test('leer gegen leer ergibt nichts', () {
|
||||||
|
expect(
|
||||||
|
zuPushendeFavoriten(
|
||||||
|
lokaleFavoriten: const {},
|
||||||
|
cloudIdVon: const {},
|
||||||
|
amServer: const {},
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('was der Server noch nicht hat, geht hoch', () {
|
||||||
|
expect(
|
||||||
|
zuPushendeFavoriten(
|
||||||
|
lokaleFavoriten: const {'s1', 's2'},
|
||||||
|
cloudIdVon: const {'s1': 'c1', 's2': 'c2'},
|
||||||
|
amServer: const {'c1'},
|
||||||
|
),
|
||||||
|
['c2'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Titel ohne cloudId kennt der Server nicht und bleiben liegen', () {
|
||||||
|
// Lokal-only: der Titel wurde nie hochgeladen, es gibt nichts zu melden.
|
||||||
|
expect(
|
||||||
|
zuPushendeFavoriten(
|
||||||
|
lokaleFavoriten: const {'s1'},
|
||||||
|
cloudIdVon: const {},
|
||||||
|
amServer: const {},
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('was beidseitig steht, wird nicht erneut gepusht', () {
|
||||||
|
expect(
|
||||||
|
zuPushendeFavoriten(
|
||||||
|
lokaleFavoriten: const {'s1'},
|
||||||
|
cloudIdVon: const {'s1': 'c1'},
|
||||||
|
amServer: const {'c1'},
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('der Deckel begrenzt einen Lauf', () {
|
||||||
|
final viele = {for (var i = 0; i < 250; i++) 's$i'};
|
||||||
|
final zuordnung = {for (var i = 0; i < 250; i++) 's$i': 'c$i'};
|
||||||
|
|
||||||
|
final offen = zuPushendeFavoriten(
|
||||||
|
lokaleFavoriten: viele,
|
||||||
|
cloudIdVon: zuordnung,
|
||||||
|
amServer: const {},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(offen, hasLength(maxFavoritenPushes));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('lokalZuSetzendeFavoriten', () {
|
||||||
|
test('disjunkte Mengen: der Server-Favorit kommt lokal dazu', () {
|
||||||
|
expect(
|
||||||
|
lokalZuSetzendeFavoriten(
|
||||||
|
amServer: const {'c9'},
|
||||||
|
songIdVonCloudId: const {'c9': 's9'},
|
||||||
|
lokaleFavoriten: const {},
|
||||||
|
),
|
||||||
|
['s9'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine lokal unauflösbare cloudId wird übersprungen, nicht gelöscht', () {
|
||||||
|
// Der Titel ist hier (noch) nicht vorhanden. Ein Favorit ohne Song wäre
|
||||||
|
// unsichtbar, würde aber ewig mitgeschleppt.
|
||||||
|
expect(
|
||||||
|
lokalZuSetzendeFavoriten(
|
||||||
|
amServer: const {'c9'},
|
||||||
|
songIdVonCloudId: const {},
|
||||||
|
lokaleFavoriten: const {},
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('was lokal schon Favorit ist, wird nicht noch einmal gesetzt', () {
|
||||||
|
expect(
|
||||||
|
lokalZuSetzendeFavoriten(
|
||||||
|
amServer: const {'c1'},
|
||||||
|
songIdVonCloudId: const {'c1': 's1'},
|
||||||
|
lokaleFavoriten: const {'s1'},
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('berichtFaellig', () {
|
||||||
|
final jetzt = DateTime(2026, 8, 27, 12);
|
||||||
|
|
||||||
|
test('ohne vorherigen Erfolg nicht fällig', () {
|
||||||
|
// Neuinstallation: „Willkommen zurück! 325 neue Songs" wäre Unsinn.
|
||||||
|
expect(berichtFaellig(null, jetzt), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unter 24 Stunden nicht fällig', () {
|
||||||
|
expect(berichtFaellig(jetzt.subtract(const Duration(hours: 23)), jetzt),
|
||||||
|
isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ab 24 Stunden fällig', () {
|
||||||
|
expect(berichtFaellig(jetzt.subtract(const Duration(hours: 24)), jetzt),
|
||||||
|
isTrue);
|
||||||
|
expect(berichtFaellig(jetzt.subtract(const Duration(days: 3)), jetzt),
|
||||||
|
isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
@@ -163,7 +164,7 @@ void main() {
|
|||||||
expect(await db.watchSongs().first, isEmpty);
|
expect(await db.watchSongs().first, isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Favoriten werden mit ihren Server-IDs gemeldet', () async {
|
test('lokale Favoriten werden additiv gepusht, nie als Voll-Ersatz', () async {
|
||||||
await db.upsertSongs([
|
await db.upsertSongs([
|
||||||
SongsCompanion.insert(
|
SongsCompanion.insert(
|
||||||
id: 'lokal-1',
|
id: 'lokal-1',
|
||||||
@@ -176,7 +177,54 @@ void main() {
|
|||||||
await db.setCloudId('lokal-1', 'c5');
|
await db.setCloudId('lokal-1', 'c5');
|
||||||
await db.setFavorite('lokal-1', true);
|
await db.setFavorite('lokal-1', true);
|
||||||
|
|
||||||
List<String>? gemeldet;
|
final gepusht = <Map<String, dynamic>>[];
|
||||||
|
var vollErsatz = 0;
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
final pfad = anfrage.url.path;
|
||||||
|
if (pfad.endsWith('/list')) {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'songs': [
|
||||||
|
{'id': 'c5', 'title': 'Lieblingslied'}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/favorites/toggle')) {
|
||||||
|
gepusht.add(jsonDecode(anfrage.body) as Map<String, dynamic>);
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/favorites')) {
|
||||||
|
if (anfrage.method == 'POST') vollErsatz++;
|
||||||
|
return http.Response(jsonEncode({'favorites': []}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.synchronisiere();
|
||||||
|
|
||||||
|
expect(gepusht, [
|
||||||
|
{'song_id': 'c5', 'set': true}
|
||||||
|
]);
|
||||||
|
// Der Datenverlust-Bug ist strukturell weg: es gibt keinen Aufruf mehr,
|
||||||
|
// der den Server-Stand ersetzen könnte.
|
||||||
|
expect(vollErsatz, 0);
|
||||||
|
expect(sync.fehler, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein Server-Favorit wird lokal nachgezogen', () async {
|
||||||
|
await db.upsertSongs([
|
||||||
|
SongsCompanion.insert(
|
||||||
|
id: 'lokal-1',
|
||||||
|
path: '${tempDir.path}/fav.mp3',
|
||||||
|
title: 'Lieblingslied',
|
||||||
|
dateAddedMs: 0,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
await db.setCloudId('lokal-1', 'c5');
|
||||||
|
|
||||||
final sync = await baue((anfrage) async {
|
final sync = await baue((anfrage) async {
|
||||||
final pfad = anfrage.url.path;
|
final pfad = anfrage.url.path;
|
||||||
if (pfad.endsWith('/list')) {
|
if (pfad.endsWith('/list')) {
|
||||||
@@ -190,15 +238,146 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (pfad.endsWith('/favorites')) {
|
if (pfad.endsWith('/favorites')) {
|
||||||
final d = jsonDecode(anfrage.body) as Map<String, dynamic>;
|
return http.Response(
|
||||||
gemeldet = (d['song_ids'] as List).cast<String>();
|
jsonEncode({
|
||||||
|
'favorites': [
|
||||||
|
{'id': 'c5'}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
await sync.synchronisiere();
|
await sync.synchronisiere();
|
||||||
|
|
||||||
expect(gemeldet, ['c5']);
|
expect(await db.favoriteSongIds(), ['lokal-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('200 mit Fehlerkörper überspringt die Favoriten-Phase ohne Push',
|
||||||
|
() async {
|
||||||
|
await db.upsertSongs([
|
||||||
|
SongsCompanion.insert(
|
||||||
|
id: 'lokal-1',
|
||||||
|
path: '${tempDir.path}/fav.mp3',
|
||||||
|
title: 'Lieblingslied',
|
||||||
|
dateAddedMs: 0,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
await db.setCloudId('lokal-1', 'c5');
|
||||||
|
await db.setFavorite('lokal-1', true);
|
||||||
|
|
||||||
|
var pushes = 0;
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
final pfad = anfrage.url.path;
|
||||||
|
if (pfad.endsWith('/list')) {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'songs': [
|
||||||
|
{'id': 'c5', 'title': 'Lieblingslied'}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/favorites/toggle')) {
|
||||||
|
pushes++;
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/favorites')) {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'status': 'error', 'error': 'kaputt'}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.synchronisiere();
|
||||||
|
|
||||||
|
expect(pushes, 0);
|
||||||
|
// Nur diese Phase fällt aus, der Lauf geht weiter.
|
||||||
|
expect(sync.fehler, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('200 mit leerer Favoritenliste läuft normal durch', () async {
|
||||||
|
// Gegenprobe zum Test darüber: eine echte Leerantwort darf NICHT als
|
||||||
|
// Fehler gelten, sonst wäre die Sicherheitsregel trivial erfüllt.
|
||||||
|
await db.upsertSongs([
|
||||||
|
SongsCompanion.insert(
|
||||||
|
id: 'lokal-1',
|
||||||
|
path: '${tempDir.path}/fav.mp3',
|
||||||
|
title: 'Lieblingslied',
|
||||||
|
dateAddedMs: 0,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
await db.setCloudId('lokal-1', 'c5');
|
||||||
|
await db.setFavorite('lokal-1', true);
|
||||||
|
|
||||||
|
var pushes = 0;
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
final pfad = anfrage.url.path;
|
||||||
|
if (pfad.endsWith('/list')) {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'songs': [
|
||||||
|
{'id': 'c5', 'title': 'Lieblingslied'}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/favorites/toggle')) {
|
||||||
|
pushes++;
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/favorites')) {
|
||||||
|
return http.Response(jsonEncode({'favorites': []}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.synchronisiere();
|
||||||
|
|
||||||
|
expect(pushes, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine Zeitüberschreitung beim Hochladen reißt den Lauf nicht ab',
|
||||||
|
() async {
|
||||||
|
final datei = File('${tempDir.path}/haengt.mp3');
|
||||||
|
await datei.writeAsBytes([1]);
|
||||||
|
await db.upsertSongs([
|
||||||
|
SongsCompanion.insert(
|
||||||
|
id: 'lokal-1',
|
||||||
|
path: datei.path,
|
||||||
|
title: 'Hängt',
|
||||||
|
dateAddedMs: 0,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
final pfad = anfrage.url.path;
|
||||||
|
if (pfad.endsWith('/list')) {
|
||||||
|
return http.Response(jsonEncode({'songs': []}), 200);
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/upload')) {
|
||||||
|
// Der 120-s-Timeout in melo_cloud_service wirft TimeoutException,
|
||||||
|
// nicht CloudException — ohne eigenen Zweig riss ein einziger
|
||||||
|
// hängender Upload den ganzen Lauf ab.
|
||||||
|
throw TimeoutException('zu lang');
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/favorites')) {
|
||||||
|
return http.Response(jsonEncode({'favorites': []}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.synchronisiere();
|
||||||
|
|
||||||
|
expect(sync.fehler, isNull);
|
||||||
|
expect((await db.songById('lokal-1'))!.cloudId, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('eine Löschwelle wird nicht zum Server durchgereicht', () async {
|
test('eine Löschwelle wird nicht zum Server durchgereicht', () async {
|
||||||
@@ -306,4 +485,342 @@ void main() {
|
|||||||
expect(sync.fehler, contains('Anmeldung abgelaufen'));
|
expect(sync.fehler, contains('Anmeldung abgelaufen'));
|
||||||
expect(sync.laeuft, isFalse);
|
expect(sync.laeuft, isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('ladeAusgewaehlteHoch', () {
|
||||||
|
Future<List<Song>> dreiTitel(Directory ordner, MeloDb db) async {
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
final datei = File('${ordner.path}/auswahl$i.mp3');
|
||||||
|
await datei.writeAsBytes([i]);
|
||||||
|
await db.upsertSongs([
|
||||||
|
SongsCompanion.insert(
|
||||||
|
id: 'lokal-$i',
|
||||||
|
path: datei.path,
|
||||||
|
title: 'Titel $i',
|
||||||
|
dateAddedMs: i,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return db.allSongs();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('lädt nur, was noch keine cloudId hat', () async {
|
||||||
|
final songs = await dreiTitel(tempDir, db);
|
||||||
|
await db.setCloudId('lokal-1', 'schon-da');
|
||||||
|
|
||||||
|
var uploads = 0;
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
if (anfrage.url.path.endsWith('/upload')) {
|
||||||
|
uploads++;
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs());
|
||||||
|
|
||||||
|
expect(songs, hasLength(3));
|
||||||
|
expect(uploads, 2);
|
||||||
|
expect(ergebnis.hochgeladen, 2);
|
||||||
|
expect(ergebnis.schonDa, 1);
|
||||||
|
expect(ergebnis.fehler, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein abgelehnter Titel stoppt die übrigen nicht', () async {
|
||||||
|
await dreiTitel(tempDir, db);
|
||||||
|
|
||||||
|
var uploads = 0;
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
if (anfrage.url.path.endsWith('/upload')) {
|
||||||
|
uploads++;
|
||||||
|
// Der Server meldet „zu groß" im Körper — dieselbe Wirkung wie eine
|
||||||
|
// lokal abgelehnte 50-MB-Datei, ohne 50 MB schreiben zu müssen.
|
||||||
|
if (uploads == 2) {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'error': 'Datei zu groß (max 50 MB)'}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs());
|
||||||
|
|
||||||
|
expect(ergebnis.hochgeladen, 2);
|
||||||
|
expect(ergebnis.fehler, hasLength(1));
|
||||||
|
expect(ergebnis.fehler.single, contains('zu groß'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine Zeitüberschreitung ist ein Einzelfehler, kein Laufabbruch',
|
||||||
|
() async {
|
||||||
|
await dreiTitel(tempDir, db);
|
||||||
|
|
||||||
|
var uploads = 0;
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
if (anfrage.url.path.endsWith('/upload')) {
|
||||||
|
uploads++;
|
||||||
|
if (uploads == 1) throw TimeoutException('zu lang');
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs());
|
||||||
|
|
||||||
|
expect(ergebnis.hochgeladen, 2);
|
||||||
|
expect(ergebnis.fehler, hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('abbrechen() stoppt zwischen zwei Titeln', () async {
|
||||||
|
await dreiTitel(tempDir, db);
|
||||||
|
|
||||||
|
late SyncService sync;
|
||||||
|
var uploads = 0;
|
||||||
|
sync = await baue((anfrage) async {
|
||||||
|
if (anfrage.url.path.endsWith('/upload')) {
|
||||||
|
uploads++;
|
||||||
|
sync.abbrechen();
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'status': 'ok', 'song_id': 'neu-$uploads'}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final ergebnis = await sync.ladeAusgewaehlteHoch(await db.allSongs());
|
||||||
|
|
||||||
|
expect(uploads, 1);
|
||||||
|
expect(ergebnis.abgebrochen, isTrue);
|
||||||
|
expect(sync.laeuft, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('schreibt den Sync-Zeitstempel nicht', () async {
|
||||||
|
await dreiTitel(tempDir, db);
|
||||||
|
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
if (anfrage.url.path.endsWith('/upload')) {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'status': 'ok', 'song_id': 'neu'}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.ladeAusgewaehlteHoch(await db.allSongs());
|
||||||
|
|
||||||
|
// Ein Upload ist kein Abgleich: sonst unterdrückt er 15 Minuten den
|
||||||
|
// Auto-Sync und verschiebt die 24-h-Uhr des Berichts.
|
||||||
|
expect(sync.letzterLauf, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('während eines laufenden Abgleichs wird abgewiesen', () async {
|
||||||
|
await dreiTitel(tempDir, db);
|
||||||
|
|
||||||
|
late SyncService sync;
|
||||||
|
UploadErgebnis? waehrendSync;
|
||||||
|
sync = await baue((anfrage) async {
|
||||||
|
if (anfrage.url.path.endsWith('/list')) {
|
||||||
|
waehrendSync = await sync.ladeAusgewaehlteHoch(await db.allSongs());
|
||||||
|
return http.Response(jsonEncode({'songs': []}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.synchronisiere();
|
||||||
|
|
||||||
|
expect(waehrendSync, isNotNull);
|
||||||
|
expect(waehrendSync!.hochgeladen, 0);
|
||||||
|
expect(sync.fehler, contains('Abgleich'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Sync-Bericht', () {
|
||||||
|
test('beim allerersten Lauf gibt es keinen Bericht', () async {
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
if (anfrage.url.path.endsWith('/list')) {
|
||||||
|
return http.Response(jsonEncode({'songs': []}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.synchronisiere();
|
||||||
|
|
||||||
|
// letzterErfolg war null: „Willkommen zurück!" auf einem frisch
|
||||||
|
// eingerichteten Gerät wäre Unsinn.
|
||||||
|
expect(sync.bericht, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nach mehr als 24 Stunden kommt der Bericht mit Zählern', () async {
|
||||||
|
final vorgestern = DateTime.now().subtract(const Duration(days: 2));
|
||||||
|
SharedPreferences.setMockInitialValues({
|
||||||
|
'cloud_sync_letzter_erfolg': vorgestern.millisecondsSinceEpoch,
|
||||||
|
});
|
||||||
|
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
final pfad = anfrage.url.path;
|
||||||
|
if (pfad.endsWith('/list')) {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'songs': [
|
||||||
|
{'id': 'c1', 'title': 'Neu', 'artist': 'X', 'duration': 100}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pfad.contains('/download/')) {
|
||||||
|
return http.Response.bytes([1], 200,
|
||||||
|
headers: {'content-type': 'audio/mpeg'});
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/favorites')) {
|
||||||
|
return http.Response(jsonEncode({'favorites': []}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
await sync.laden();
|
||||||
|
|
||||||
|
await sync.synchronisiere();
|
||||||
|
|
||||||
|
expect(sync.bericht, isNotNull);
|
||||||
|
expect(sync.bericht!.neueSongs, 1);
|
||||||
|
sync.berichtGesehen();
|
||||||
|
expect(sync.bericht, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine ausgefallene Phase verschiebt den Erfolgs-Zeitstempel nicht',
|
||||||
|
() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
final pfad = anfrage.url.path;
|
||||||
|
if (pfad.endsWith('/list')) {
|
||||||
|
return http.Response(jsonEncode({'songs': []}), 200);
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/favorites')) {
|
||||||
|
// Fehler im 200er-Körper: die Favoriten-Phase fällt aus.
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'status': 'error', 'error': 'kaputt'}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.synchronisiere();
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(prefs.getInt('cloud_sync_letzter_erfolg'), isNull);
|
||||||
|
// Die Drossel läuft trotzdem weiter — sonst rennt der Sync bei jedem
|
||||||
|
// Tab-Wechsel neu los.
|
||||||
|
expect(sync.letzterLauf, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('eine gescheiterte Löschmeldung verschiebt den Erfolgs-Zeitstempel '
|
||||||
|
'nicht', () async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
await db.upsertSongs([
|
||||||
|
SongsCompanion.insert(
|
||||||
|
id: 'lokal-1',
|
||||||
|
path: '${tempDir.path}/weg.mp3',
|
||||||
|
title: 'Weg',
|
||||||
|
dateAddedMs: 0,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
deleted: const Value(true),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
await db.setCloudId('lokal-1', 'c5');
|
||||||
|
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
final pfad = anfrage.url.path;
|
||||||
|
if (pfad.endsWith('/list')) {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'songs': [
|
||||||
|
{'id': 'c5', 'title': 'Weg'}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/delete')) {
|
||||||
|
// _meldeLoeschungen schluckt die CloudException — ohne Rückgabe
|
||||||
|
// bis zum Flag hätte der Lauf trotzdem als erfolgreich gegolten.
|
||||||
|
return http.Response(jsonEncode({'error': 'kaputt'}), 500);
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/favorites')) {
|
||||||
|
return http.Response(jsonEncode({'favorites': []}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.synchronisiere();
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(prefs.getInt('cloud_sync_letzter_erfolg'), isNull);
|
||||||
|
expect(sync.letzterLauf, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ein gescheiterter Upload verschiebt den Erfolgs-Zeitstempel nicht',
|
||||||
|
() async {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final datei = File('${tempDir.path}/zu-gross.mp3');
|
||||||
|
await datei.writeAsBytes([1]);
|
||||||
|
await db.upsertSongs([
|
||||||
|
SongsCompanion.insert(
|
||||||
|
id: 'lokal-1',
|
||||||
|
path: datei.path,
|
||||||
|
title: 'Zu groß',
|
||||||
|
dateAddedMs: 0,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
final pfad = anfrage.url.path;
|
||||||
|
if (pfad.endsWith('/list')) {
|
||||||
|
return http.Response(jsonEncode({'songs': []}), 200);
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/upload')) {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({'error': 'Datei zu groß (max 50 MB)'}), 200);
|
||||||
|
}
|
||||||
|
if (pfad.endsWith('/favorites')) {
|
||||||
|
return http.Response(jsonEncode({'favorites': []}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
await sync.synchronisiere();
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
expect(prefs.getInt('cloud_sync_letzter_erfolg'), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('der Zeitstempel ist der Stand VOR dem Listen', () async {
|
||||||
|
final sync = await baue((anfrage) async {
|
||||||
|
if (anfrage.url.path.endsWith('/list')) {
|
||||||
|
// Während des Laufs vergeht Zeit — der Zeitstempel darf nicht
|
||||||
|
// danach genommen werden, sonst fallen zwischenzeitliche
|
||||||
|
// Änderungen durchs Raster (Tombstone-Race, v2-Lektion).
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||||
|
return http.Response(jsonEncode({'songs': []}), 200);
|
||||||
|
}
|
||||||
|
return http.Response(jsonEncode({'status': 'ok'}), 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
final vorher = DateTime.now();
|
||||||
|
await sync.synchronisiere();
|
||||||
|
final nachher = DateTime.now();
|
||||||
|
|
||||||
|
expect(sync.letzterLauf!.isBefore(nachher), isTrue);
|
||||||
|
expect(
|
||||||
|
sync.letzterLauf!
|
||||||
|
.isAfter(vorher.subtract(const Duration(milliseconds: 1))),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
nachher.difference(sync.letzterLauf!) >=
|
||||||
|
const Duration(milliseconds: 50),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import 'package:drift/drift.dart' show Value, driftRuntimeOptions;
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:http/testing.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:melo/library/category_service.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';
|
||||||
|
import 'package:melo/services/sync_service.dart';
|
||||||
|
import 'package:melo/settings/app_settings.dart';
|
||||||
|
import 'package:melo/shared/sort_store.dart';
|
||||||
|
import 'package:melo/shared/sortable_song_list.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Merkt sich nur, was hochgeladen werden sollte. Der echte Upload braucht
|
||||||
|
/// Dateien und einen Server — hier geht es um den Weg vom Knopf zum Dienst.
|
||||||
|
class _FakeSync extends SyncService {
|
||||||
|
_FakeSync(MeloDb db)
|
||||||
|
: super(
|
||||||
|
db: db,
|
||||||
|
cloud: MeloCloudService(
|
||||||
|
auth: BakaAuth(speicher: _MemorySpeicher({})),
|
||||||
|
client: MockClient(
|
||||||
|
(_) async => http.Response('{"status":"ok"}', 200)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final hochgeladen = <String>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<UploadErgebnis> ladeAusgewaehlteHoch(List<Song> songs) async {
|
||||||
|
hochgeladen.addAll([for (final s in songs) s.id]);
|
||||||
|
return const UploadErgebnis(hochgeladen: 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// „Auf den Server laden" gehört in „Meine Musik" — und **nur** dorthin.
|
||||||
|
/// [SortableSongList] wird in fünf Ansichten benutzt; ohne Scoping erschiene
|
||||||
|
/// die Aktion auch bei Favoriten, Wiedergabelisten und Titellisten.
|
||||||
|
///
|
||||||
|
/// Aufbau bewusst im Testkörper, nicht in `setUp` (siehe auswahl_modus_test).
|
||||||
|
void main() {
|
||||||
|
final lieder = [
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
Song(
|
||||||
|
id: 'song-$i',
|
||||||
|
path: '/music/$i.mp3',
|
||||||
|
title: 'Titel $i',
|
||||||
|
artist: 'Neoni',
|
||||||
|
dateAddedMs: i,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
deleted: false,
|
||||||
|
playCount: 0,
|
||||||
|
categoriesEdited: false,
|
||||||
|
metadataEdited: false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<void> beruhige(WidgetTester tester) => tester.pumpAndSettle(
|
||||||
|
const Duration(milliseconds: 100),
|
||||||
|
EnginePhase.sendSemanticsUpdate,
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Räumt den Widget-Baum kontrolliert ab, statt es dem Testrahmen am
|
||||||
|
/// Testende zu überlassen: sonst bleibt ein von drift/SnackBar beim
|
||||||
|
/// Aufräumen geplanter Timer offen (siehe auswahl_modus_test.dart /
|
||||||
|
/// BACKLOG.md — derselbe Hänger, dieselbe Ursache).
|
||||||
|
Future<void> aufraeumen(WidgetTester tester) async {
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
await tester.pump(const Duration(milliseconds: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_FakeSync> pumpe(WidgetTester tester,
|
||||||
|
{required bool serverUpload}) async {
|
||||||
|
driftRuntimeOptions.dontWarnAboutMultipleDatabases = true;
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
final db = MeloDb(NativeDatabase.memory());
|
||||||
|
// Kein db.close(): drift plant dabei eine Aufräum-Aufgabe (Timer) ein,
|
||||||
|
// die der Testrahmen nicht mehr abarbeitet (siehe BACKLOG.md und
|
||||||
|
// auswahl_modus_test.dart — derselbe Hänger, dieselbe Ursache).
|
||||||
|
final sync = _FakeSync(db);
|
||||||
|
final einstellungen = AppSettings();
|
||||||
|
await einstellungen.init();
|
||||||
|
for (final song in lieder) {
|
||||||
|
await db.into(db.songs).insert(SongsCompanion.insert(
|
||||||
|
id: song.id,
|
||||||
|
path: song.path,
|
||||||
|
title: song.title,
|
||||||
|
artist: Value(song.artist),
|
||||||
|
dateAddedMs: song.dateAddedMs,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
Provider<MeloDb>.value(value: db),
|
||||||
|
ChangeNotifierProvider<CategoryService>.value(
|
||||||
|
value: CategoryService(db)),
|
||||||
|
ChangeNotifierProvider<PlaylistService>.value(
|
||||||
|
value: PlaylistService(db)),
|
||||||
|
ChangeNotifierProvider<AppSettings>.value(value: einstellungen),
|
||||||
|
// Ohne diesen Provider stürbe schon das erste Antippen der neuen
|
||||||
|
// Aktion in ladeAufServer mit ProviderNotFoundException.
|
||||||
|
ChangeNotifierProvider<SyncService>.value(value: sync),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: SortableSongList(
|
||||||
|
songs: lieder,
|
||||||
|
storeKey: SortStore.meineMusik,
|
||||||
|
serverUpload: serverUpload,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await beruhige(tester);
|
||||||
|
await tester.longPress(find.text('Titel 0'));
|
||||||
|
await beruhige(tester);
|
||||||
|
return sync;
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('in „Meine Musik" erscheint die Server-Aktion', (tester) async {
|
||||||
|
await pumpe(tester, serverUpload: true);
|
||||||
|
|
||||||
|
expect(find.byTooltip('Auf den Server laden'), findsOneWidget);
|
||||||
|
|
||||||
|
await aufraeumen(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('in den übrigen Ansichten erscheint sie nicht', (tester) async {
|
||||||
|
await pumpe(tester, serverUpload: false);
|
||||||
|
|
||||||
|
// Der Auswahl-Modus läuft, die beiden Bestands-Aktionen sind da …
|
||||||
|
expect(find.byTooltip('Zur Warteschlange hinzufügen'), findsOneWidget);
|
||||||
|
// … die neue nicht.
|
||||||
|
expect(find.byTooltip('Auf den Server laden'), findsNothing);
|
||||||
|
|
||||||
|
await aufraeumen(tester);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('das Antippen reicht die Auswahl an den Upload weiter',
|
||||||
|
(tester) async {
|
||||||
|
final sync = await pumpe(tester, serverUpload: true);
|
||||||
|
|
||||||
|
await tester.tap(find.byTooltip('Auf den Server laden'));
|
||||||
|
await beruhige(tester);
|
||||||
|
|
||||||
|
// Ein sichtbarer Knopf ist noch keine Funktion: geprüft wird, dass genau
|
||||||
|
// der lang gedrückte Titel bei ladeAusgewaehlteHoch ankommt.
|
||||||
|
expect(sync.hochgeladen, ['song-0']);
|
||||||
|
expect(find.text('1 hochgeladen'), findsOneWidget);
|
||||||
|
|
||||||
|
await aufraeumen(tester);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:melo/services/sync_service.dart';
|
||||||
|
import 'package:melo/shared/sync_bericht_dialog.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
Future<void> zeige(WidgetTester tester, SyncBericht bericht) async {
|
||||||
|
await tester.pumpWidget(MaterialApp(
|
||||||
|
home: Scaffold(body: SyncBerichtDialog(bericht: bericht)),
|
||||||
|
));
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('nennt neue und entfernte Titel', (tester) async {
|
||||||
|
await zeige(
|
||||||
|
tester,
|
||||||
|
const SyncBericht(neueSongs: 3, geloeschte: 1, favoriten: 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('Willkommen zurück!'), findsOneWidget);
|
||||||
|
expect(find.textContaining('3 neue Titel'), findsOneWidget);
|
||||||
|
expect(find.textContaining('1 entfernt'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('ohne Änderungen sagt er das auch', (tester) async {
|
||||||
|
await zeige(
|
||||||
|
tester,
|
||||||
|
const SyncBericht(neueSongs: 0, geloeschte: 0, favoriten: 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.textContaining('Nichts Neues'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user