Files
Melo/lib/services/download_service.dart

188 lines
6.4 KiB
Dart

import 'package:drift/drift.dart' show Value;
import 'package:flutter/foundation.dart';
import '../library/database.dart';
import 'download_speicher.dart';
import 'navidrome_service.dart';
/// Ab wie vielen Titeln vor dem Herunterladen nachgefragt wird.
///
/// Ein Album sind zehn bis zwanzig Titel — da wäre eine Rückfrage nur im Weg.
/// Ein Künstler können mehrere hundert und einige Gigabyte sein, und das
/// sollte niemand aus Versehen antippen.
const rueckfrageAb = 30;
bool brauchtRueckfrage(int anzahl) => anzahl >= rueckfrageAb;
/// Grobe Größenschätzung für die Rückfrage. Genau geht nicht — der Server
/// verrät die Dateigröße erst beim Abruf; diese Zahl beruht auf der Dauer
/// bei rund 256 kbit/s und ist als Hausnummer gedacht.
int geschaetzteBytes(Iterable<int> dauernSekunden) =>
dauernSekunden.fold<int>(0, (summe, s) => summe + s * 32000);
enum _Ergebnis { neu, schonDa, fehler }
/// Lädt Server-Titel bewusst auf das Gerät und führt Buch darüber.
///
/// Getrennt vom Zwischenspeicher, der beim Abspielen nebenbei entsteht: was
/// hier landet, hat der Nutzer gewollt und wird nie von selbst verdrängt.
class DownloadService extends ChangeNotifier {
DownloadService({
required this.db,
required this.navidrome,
DownloadSpeicher? speicher,
}) : speicher = speicher ?? DownloadSpeicher();
final MeloDb db;
final NavidromeService navidrome;
final DownloadSpeicher speicher;
bool _laeuft = false;
int _fertig = 0;
int _gesamt = 0;
int _fehlgeschlagen = 0;
String? _aktuell;
String? _fehler;
bool _abbruchGewuenscht = false;
bool get laeuft => _laeuft;
int get fertig => _fertig;
int get gesamt => _gesamt;
/// Titel, der gerade geladen wird.
String? get aktuell => _aktuell;
/// Meldung des letzten Fehlschlags. Sagt **nicht**, dass der ganze Lauf
/// gescheitert ist — siehe [fehlgeschlagen].
String? get fehler => _fehler;
/// Wie viele Titel im letzten Lauf nicht geladen werden konnten.
int get fehlgeschlagen => _fehlgeschlagen;
/// Fortschritt zwischen 0 und 1, oder `null`, solange nichts läuft.
double? get anteil =>
_laeuft && _gesamt > 0 ? (_fertig / _gesamt).clamp(0.0, 1.0) : null;
void abbrechen() {
if (_laeuft) _abbruchGewuenscht = true;
}
/// Lädt [songs] herunter. Bereits vorhandene Titel werden übersprungen.
/// Gibt zurück, wie viele neu dazugekommen sind.
Future<int> lade(List<SubsonicSong> songs) async {
if (_laeuft) return 0;
// Die Zugangsdaten können sich seit dem App-Start geändert haben: wer
// sich erst in den Einstellungen anmeldet, hätte hier sonst eine leere
// Verbindung und jeder Download liefe ins Leere.
if (!navidrome.istVerbunden) {
await navidrome.ladeGespeicherteZugangsdaten();
}
if (!navidrome.istVerbunden) {
_fehler = 'Nicht mit dem Musikserver verbunden';
notifyListeners();
return 0;
}
_laeuft = true;
_abbruchGewuenscht = false;
_fehler = null;
_fehlgeschlagen = 0;
_fertig = 0;
_gesamt = songs.length;
notifyListeners();
var neu = 0;
try {
for (final song in songs) {
if (_abbruchGewuenscht) break;
_aktuell = song.titel;
notifyListeners();
final ergebnis = await _ladeEinen(song);
if (ergebnis == _Ergebnis.neu) neu++;
if (ergebnis == _Ergebnis.fehler) _fehlgeschlagen++;
_fertig++;
notifyListeners();
}
} finally {
_laeuft = false;
_aktuell = null;
notifyListeners();
}
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 {
try {
// Datei und Buchführung entstehen nacheinander. Bricht die App
// dazwischen ab, liegt eine Datei ohne Zeile herum — sie tauchte dann
// in keiner Liste auf und ließe sich nie wieder loswerden. Deshalb hier
// beides prüfen und die fehlende Zeile nachtragen, statt nur „ist schon
// da" zu melden.
final vorhanden = await speicher.treffer(song.id);
if (vorhanden != null) {
await _buchen(song, await vorhanden.length());
return _Ergebnis.schonDa;
}
final ziel = await speicher.ziel(song.id);
final datei =
await navidrome.ladeDateiHerunter(navidrome.streamUrl(song.id), ziel);
if (datei == null) {
_fehler = 'Nicht ladbar: ${song.titel}';
return _Ergebnis.fehler;
}
await _buchen(song, await datei.length());
return _Ergebnis.neu;
} catch (e) {
_fehler = 'Fehler bei ${song.titel}: $e';
debugPrint('Download fehlgeschlagen: $e');
return _Ergebnis.fehler;
}
}
Future<void> _buchen(SubsonicSong song, int bytes) =>
db.merkeDownload(DownloadsCompanion.insert(
navidromeId: song.id,
titel: song.titel,
kuenstler: Value(song.kuenstler.isEmpty ? null : song.kuenstler),
album: Value(song.album.isEmpty ? null : song.album),
groesseBytes: bytes,
geladenAmMs: DateTime.now().millisecondsSinceEpoch,
));
/// Nimmt einen heruntergeladenen Titel wieder vom Gerät.
/// Nimmt einen heruntergeladenen Titel wieder vom Gerät.
///
/// Die Zeile fällt nur, wenn die Datei wirklich weg ist — sonst bliebe eine
/// Datei ohne Buchführung liegen, die in keiner Liste mehr auftaucht.
Future<bool> entferne(String navidromeId) async {
await speicher.loesche(navidromeId);
if (await speicher.treffer(navidromeId) != null) {
_fehler = 'Datei ließ sich nicht entfernen';
notifyListeners();
return false;
}
await db.vergissDownload(navidromeId);
notifyListeners();
return true;
}
Future<void> entferneAlle() async {
await speicher.loescheAlle();
await db.vergissAlleDownloads();
notifyListeners();
}
}