import 'dart:async'; import 'dart:io'; import 'package:drift/drift.dart' show Value; import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; import '../library/database.dart'; import 'media_store.dart'; import 'melo_cloud_service.dart'; import 'sync_merge.dart'; /// Was beim Abgleich mit welchen Titeln zu tun ist. /// /// Ein Titel gilt über [Song.cloudId] als „am Server bekannt". Alles andere /// folgt daraus: kennt der Server ihn nicht, geht er hoch; kennt das Gerät /// einen Server-Titel nicht, kommt er herunter; hat der Server einen Grabstein /// gesetzt, verschwindet er auch hier. class SyncPlan { const SyncPlan({ required this.herunterladen, required this.hochladen, required this.lokalLoeschen, required this.serverLoeschen, }); /// Titel, die es nur am Server gibt. final List herunterladen; /// Titel, die es nur auf dem Gerät gibt. final List hochladen; /// Titel, die der Server als gelöscht meldet. final List lokalLoeschen; /// Titel, die hier gelöscht wurden und deren Grabstein der Server noch /// nicht kennt — sonst tauchen sie auf den anderen Geräten weiter auf. final List serverLoeschen; bool get istLeer => herunterladen.isEmpty && hochladen.isEmpty && lokalLoeschen.isEmpty && serverLoeschen.isEmpty; int get gesamt => herunterladen.length + hochladen.length + lokalLoeschen.length + serverLoeschen.length; } /// Stellt Gerät und Server gegenüber. Reine Funktion — der eigentliche /// Abgleich in [SyncService] führt nur noch aus, was hier entschieden wurde. SyncPlan planeSync({ required List lokal, required List server, }) { final serverNachId = {for (final s in server) s.id: s}; final bekannteCloudIds = {}; final hochladen = []; final lokalLoeschen = []; final serverLoeschen = []; for (final song in lokal) { final cloudId = song.cloudId; if (cloudId != null) bekannteCloudIds.add(cloudId); if (song.deleted) { // Hier gelöscht: der Server muss den Grabstein bekommen, sonst laden // ihn die anderen Geräte weiter herunter. final amServer = cloudId == null ? null : serverNachId[cloudId]; if (amServer != null && !amServer.geloescht) serverLoeschen.add(song); continue; } if (cloudId == null) { hochladen.add(song); continue; } final amServer = serverNachId[cloudId]; if (amServer == null) { // Der Server kennt die Verknüpfung nicht mehr (z. B. Konto gewechselt). // Erneut hochladen ist sicherer als den Titel stillschweigend zu verlieren. hochladen.add(song); } else if (amServer.geloescht) { lokalLoeschen.add(song); } } final herunterladen = [ for (final s in server) if (!s.geloescht && !bekannteCloudIds.contains(s.id)) s, ]; return SyncPlan( herunterladen: herunterladen, hochladen: hochladen, lokalLoeschen: lokalLoeschen, serverLoeschen: serverLoeschen, ); } /// Ob eine Löschwelle gestoppt wird, statt sie zum Server durchzureichen. /// /// Hintergrund: Der Bibliotheks-Scan markiert jeden Titel als gelöscht, dessen /// Datei er nicht mehr findet. Ist die Speicherkarte einmal nicht eingehängt /// oder die Berechtigung entzogen, betrifft das auf einen Schlag die halbe /// Sammlung — ohne Bremse würde der Abgleich sie am Server und damit auf allen /// Geräten löschen. Einzelne Löschungen laufen weiterhin ungehindert durch. bool loeschBremseGreift({required int zuLoeschen, required int bestand}) => zuLoeschen > 10 && zuLoeschen * 3 > bestand; /// Wie oft höchstens automatisch abgeglichen wird. Ein Abgleich beim /// Zurückkehren in die App darf nicht bei jedem Tab-Wechsel losrennen. const autoSyncAbstand = Duration(minutes: 15); /// Ob jetzt automatisch abgeglichen werden soll. bool sollAutoSync(DateTime? letzterLauf, DateTime jetzt) => 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 fehler; final bool abgebrochen; String get meldung { final teile = []; 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, /// bringt eigene Dateien hoch, zieht Löschungen nach und meldet Favoriten und /// Wiedergaben. Alle Geräte am selben Konto sehen dadurch dasselbe. class SyncService extends ChangeNotifier { SyncService({ required this.db, required this.cloud, this.mediaStore = const MediaStore(), Future Function()? musikOrdner, }) : _musikOrdner = musikOrdner ?? _standardMusikOrdner; static const _letzterLaufKey = 'cloud_sync_letzter_lauf'; static const _letzterErfolgKey = 'cloud_sync_letzter_erfolg'; static const _verlaufStandKey = 'cloud_sync_verlauf_stand'; static const _uuid = Uuid(); final MeloDb db; final MeloCloudService cloud; final MediaStore mediaStore; final Future Function() _musikOrdner; bool _laeuft = false; int _erledigt = 0; int _gesamt = 0; String? _fehler; String? _status; DateTime? _letzterLauf; DateTime? _letzterErfolg; SyncBericht? _bericht; bool _abbruchGewuenscht = false; bool get laeuft => _laeuft; int get erledigt => _erledigt; int get gesamt => _gesamt; String? get fehler => _fehler; String? get status => _status; 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 laden() async { final prefs = await SharedPreferences.getInstance(); final ms = prefs.getInt(_letzterLaufKey); if (ms != null) _letzterLauf = DateTime.fromMillisecondsSinceEpoch(ms); final erfolg = prefs.getInt(_letzterErfolgKey); if (erfolg != null) { _letzterErfolg = DateTime.fromMillisecondsSinceEpoch(erfolg); } notifyListeners(); } /// Gleicht ab, wenn seit dem letzten Lauf genug Zeit vergangen ist. /// Für den App-Start und die Rückkehr in die App. Future automatisch() async { if (_laeuft || !cloud.istAngemeldet) return; if (!sollAutoSync(_letzterLauf, DateTime.now())) return; await synchronisiere(); } /// Vollständiger Abgleich. Ein Fehler in einem Schritt bricht den ganzen /// Lauf nicht ab — was geht, wird erledigt, der Rest beim nächsten Mal. Future synchronisiere() async { if (_laeuft) return; if (!cloud.istAngemeldet) { _fehler = 'Bitte zuerst beim Baka-Konto anmelden'; notifyListeners(); return; } _laeuft = true; _fehler = null; _erledigt = 0; _gesamt = 0; _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 { final amServer = await cloud.liste(); final plan = planeSync( lokal: await db.allSongs(), server: amServer, ); _gesamt = plan.gesamt; notifyListeners(); final bestand = amServer.where((s) => !s.geloescht).length; final bremse = loeschBremseGreift( zuLoeschen: plan.serverLoeschen.length, bestand: bestand, ); if (bremse) { _fehler = 'Sicherheitsbremse: ${plan.serverLoeschen.length} von ' '$bestand Titeln würden am Server gelöscht. Das sieht nach einem ' 'Fehler aus (z. B. Speicherkarte nicht eingehängt) — es wurde ' '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); if (!bremse && !await _meldeLoeschungen(plan.serverLoeschen)) { vollstaendig = false; } neueSongs = await _ladeHerunter(plan.herunterladen); if (!await _ladeHoch(plan.hochladen)) vollstaendig = false; if (!await _gleicheFavoritenAb()) vollstaendig = false; await _meldeVerlauf(); _letzterLauf = laufBeginn; final prefs = await SharedPreferences.getInstance(); 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) { _fehler = e.message; } catch (e) { debugPrint('Sync fehlgeschlagen: $e'); _fehler = 'Abgleich fehlgeschlagen: $e'; } finally { _laeuft = false; _status = null; notifyListeners(); } } /// 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 ladeAusgewaehlteHoch(List 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 = []; 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 _ziehLoeschungenNach(List songs) async { if (songs.isEmpty) return; _melde('Entferne ${songs.length} am Server gelöschte Titel …'); await db.tombstoneByCloudIds([for (final s in songs) s.cloudId!]); _erledigt += songs.length; notifyListeners(); } /// 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 _meldeLoeschungen(List songs) async { var vollstaendig = true; for (final song in songs) { _melde('Melde Löschung von „${song.title}“ …'); try { await cloud.loeschen(song.cloudId!); } on CloudException catch (e) { // Eine abgelehnte Löschung darf den Lauf nicht beenden. debugPrint('Löschung „${song.title}“ übersprungen: ${e.message}'); vollstaendig = false; } _erledigt++; notifyListeners(); } return vollstaendig; } /// Gibt zurück, wie viele Titel wirklich neu dazugekommen sind — die Zahl /// im „Was ist neu"-Bericht. Future _ladeHerunter(List songs) async { if (songs.isEmpty) return 0; var neu = 0; final ordner = await _musikOrdner(); for (final cloudSong in songs) { _melde('Lade „${cloudSong.titel}“ …'); // Die Endung bestimmt der Server anhand des echten Dateityps — die // Bibliothek enthält nicht nur MP3. final datei = await cloud.herunterladen( cloudSong.id, ordner, '${_sichererDateiname(cloudSong.titel)}-${cloudSong.id}', ); if (datei == null) { _erledigt++; continue; } // In den öffentlichen Musikordner eintragen: sonst kennt der // MediaStore die Datei nicht und der nächste Scan tombstoned sie. final pfad = await mediaStore.veroeffentliche( quellPfad: datei.path, titel: cloudSong.titel, kuenstler: cloudSong.kuenstler, ) ?? datei.path; final now = DateTime.now().millisecondsSinceEpoch; await db.upsertSongs([ SongsCompanion.insert( id: _uuid.v4(), path: pfad, title: cloudSong.titel, artist: Value(cloudSong.kuenstler.isEmpty ? null : cloudSong.kuenstler), durationMs: Value(cloudSong.dauerSekunden > 0 ? cloudSong.dauerSekunden * 1000 : null), dateAddedMs: now, updatedAtMs: now, cloudId: Value(cloudSong.id), ), ]); neu++; _erledigt++; notifyListeners(); } return neu; } /// 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 _ladeHoch(List songs) async { var vollstaendig = true; for (final song in songs) { final datei = File(song.path); if (!await datei.exists()) { _erledigt++; 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); } else { // 200 ohne Server-ID: der Titel ist oben nicht angekommen. vollstaendig = false; } } on CloudException catch (e) { // Eine zu große oder abgelehnte Datei darf den Lauf nicht beenden. 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++; notifyListeners(); } return vollstaendig; } /// 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 _gleicheFavoritenAb() async { _melde('Gleiche Favoriten ab …'); final Set 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 cloudIdVon = { for (final s in lokal) if (s.cloudId != null && !s.deleted) s.id: s.cloudId!, }; final songIdVonCloudId = { for (final s in lokal) if (s.cloudId != null && !s.deleted) s.cloudId!: s.id, }; final favoriten = (await db.favoriteSongIds()).toSet(); 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 _meldeVerlauf() async { final prefs = await SharedPreferences.getInstance(); final stand = prefs.getInt(_verlaufStandKey) ?? 0; final eintraege = await db.historySince(stand); if (eintraege.isEmpty) return; _melde('Melde ${eintraege.length} Wiedergaben …'); final cloudIdVon = { for (final s in await db.allSongs()) if (s.cloudId != null) s.id: s.cloudId!, }; final zuMelden = [ for (final e in eintraege) if (cloudIdVon[e.songId] != null) CloudVerlauf( cloudId: cloudIdVon[e.songId]!, gespieltAm: DateTime.fromMillisecondsSinceEpoch(e.playedAtMs), positionSekunden: e.positionMs ~/ 1000, ), ]; await cloud.meldeVerlauf(zuMelden); await prefs.setInt(_verlaufStandKey, eintraege.first.playedAtMs); } void _melde(String text) { _status = text; notifyListeners(); } } String _sichererDateiname(String titel) => titel.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim(); Future _standardMusikOrdner() async { // Zwischenablage für den Download; danach wandert die Datei über den // MediaStore in den öffentlichen Musikordner. final dir = Directory(p.join(Directory.systemTemp.path, 'melo_cloud_dl')); await dir.create(recursive: true); return dir; }