431 lines
14 KiB
Dart
431 lines
14 KiB
Dart
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<CloudSong> herunterladen;
|
|
|
|
/// Titel, die es nur auf dem Gerät gibt.
|
|
final List<Song> hochladen;
|
|
|
|
/// Titel, die der Server als gelöscht meldet.
|
|
final List<Song> 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<Song> 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<Song> lokal,
|
|
required List<CloudSong> server,
|
|
}) {
|
|
final serverNachId = {for (final s in server) s.id: s};
|
|
final bekannteCloudIds = <String>{};
|
|
|
|
final hochladen = <Song>[];
|
|
final lokalLoeschen = <Song>[];
|
|
final serverLoeschen = <Song>[];
|
|
|
|
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<Directory> 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<Directory> 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<void> 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<void> 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<void> 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<void> _ziehLoeschungenNach(List<Song> 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<void> _meldeLoeschungen(List<Song> 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<void> _ladeHerunter(List<CloudSong> 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<void> _ladeHoch(List<Song> 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}');
|
|
} 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');
|
|
}
|
|
_erledigt++;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// 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 …');
|
|
|
|
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 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<void> _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<Directory> _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;
|
|
}
|