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'; /// 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; /// 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 _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; bool get laeuft => _laeuft; int get erledigt => _erledigt; int get gesamt => _gesamt; String? get fehler => _fehler; String? get status => _status; DateTime? get letzterLauf => _letzterLauf; Future laden() async { final prefs = await SharedPreferences.getInstance(); final ms = prefs.getInt(_letzterLaufKey); if (ms != null) _letzterLauf = DateTime.fromMillisecondsSinceEpoch(ms); 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 …'); 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.'; } await _ziehLoeschungenNach(plan.lokalLoeschen); if (!bremse) await _meldeLoeschungen(plan.serverLoeschen); await _ladeHerunter(plan.herunterladen); await _ladeHoch(plan.hochladen); await _gleicheFavoritenAb(); await _meldeVerlauf(); _letzterLauf = DateTime.now(); final prefs = await SharedPreferences.getInstance(); await prefs.setInt(_letzterLaufKey, _letzterLauf!.millisecondsSinceEpoch); } on CloudException catch (e) { _fehler = e.message; } catch (e) { debugPrint('Sync fehlgeschlagen: $e'); _fehler = 'Abgleich fehlgeschlagen: $e'; } finally { _laeuft = false; _status = null; notifyListeners(); } } 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(); } Future _meldeLoeschungen(List songs) async { 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}'); } _erledigt++; notifyListeners(); } } Future _ladeHerunter(List songs) async { if (songs.isEmpty) return; 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), ), ]); _erledigt++; notifyListeners(); } } Future _ladeHoch(List songs) async { 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); } on CloudException catch (e) { // Eine zu große oder abgelehnte Datei darf den Lauf nicht beenden. debugPrint('Upload „${song.title}“ übersprungen: ${e.message}'); } _erledigt++; notifyListeners(); } } Future _gleicheFavoritenAb() async { _melde('Gleiche Favoriten ab …'); final lokal = await db.allSongs(); final cloudIdVon = { for (final s in lokal) if (s.cloudId != null) s.id: s.cloudId!, }; final favoritenIds = await db.favoriteSongIds(); final cloudFavoriten = [ for (final id in favoritenIds) if (cloudIdVon[id] != null) cloudIdVon[id]!, ]; await cloud.setzeFavoriten(cloudFavoriten); } 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; }