644 lines
21 KiB
Dart
644 lines
21 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;
|
|
|
|
/// 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,
|
|
/// 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 _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<Directory> 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<void> 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<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 …');
|
|
|
|
// 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<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 {
|
|
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<bool> _meldeLoeschungen(List<Song> 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<int> _ladeHerunter(List<CloudSong> 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<bool> _ladeHoch(List<Song> 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<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;
|
|
}
|