Alben und Kuenstler offline mitnehmen; Zwischenspeicher bekommt eine Grenze

Bisher landete Musik nur zufaellig auf dem Geraet: was man abspielte, blieb
nebenbei liegen. Gezielt "dieses Album fuer die Zugfahrt" gab es nicht. Und
dieser Zwischenspeicher wuchs unbegrenzt — es gab nur "alles loeschen".

Zwei getrennte Ablagen. Bewusste Downloads liegen NICHT im Cache-Ordner:
den darf Android bei Speichernot jederzeit selbst leeren, und genau das
darf einem mitgenommenen Album nicht passieren. Sie liegen deshalb neben
der Datenbank im Support-Ordner. Die Grenze gilt nur fuer den
Zufalls-Cache; Downloads sind unbegrenzt und werden nie verdraengt.

- Download-Pfeil je Album und Kuenstler, mit Fortschritt und Abbruch
- Rueckfrage ab 30 Titeln samt Groessenschaetzung
- Obergrenze wahlbar (aus / 512 MB / 1 / 2 / 4 / 8 GB), Standard 2 GB;
  am laengsten nicht Gehoertes weicht zuerst
- Liste "Heruntergeladen" mit Titel, Kuenstler, Groesse, einzeln oder alle
  entfernbar (Schema 10)
- Wiedergabe nimmt erst den Download, dann den Zwischenspeicher

Aus dem Code-Review nachgebessert, vier ernste Punkte: "aus" (0 MB)
schaltete das Aufraeumen ab statt das Zwischenspeichern und liess den
Cache unbegrenzt wachsen; wer sich erst waehrend der Sitzung am Server
anmeldete, konnte gar nichts laden; die Verdraengung konnte Dateien
loeschen, die die laufende Warteschlange schon als Quelle eingetragen
hatte (Abbruch mitten im Album, ohne Rueckfall aufs Streamen); und ein
heruntergeladener Titel wurde beim Abspielen ein zweites Mal in den Cache
geholt. Dazu sechs kleinere und der "zuletzt gehoert"-Stempel, der beim
Aufbau der Warteschlange statt beim Abspielen gesetzt wurde.

Nebenbei aufgeraeumt, weil die Dateien ohnehin offen waren: keine Emoji
als Knoepfe mehr, kein Colors.redAccent und kein grey.shade mehr im
ganzen Projekt, und die Einstellungen heissen nicht mehr "Settings".

433 Tests gruen (vorher 411), flutter analyze ohne Befund, Release-APK
gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013xAHJTJM6UUqmjUgk1PUEd
This commit is contained in:
Hermes (Server)
2026-08-23 21:31:53 +02:00
co-authored by Claude Opus 5
parent 80948f1ad5
commit b42e045e67
15 changed files with 1784 additions and 32 deletions
+117 -3
View File
@@ -3,6 +3,63 @@ import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
/// Ein Eintrag des Zwischenspeichers, wie ihn die Verdrängung sieht.
/// Ohne Datei-Zugriff, damit [zuVerdraengen] rein bleibt.
class CacheEintrag {
const CacheEintrag({
required this.pfad,
required this.groesse,
required this.geaendertMs,
});
final String pfad;
final int groesse;
/// Wann die Datei zuletzt angefasst wurde. Bewusst „geändert" und nicht
/// „zugegriffen": Android hängt Datenträger meist mit `noatime` ein, die
/// Zugriffszeit steht dort still. Der Cache-Treffer stempelt die Datei
/// deshalb selbst um (siehe [CacheManager.getCachedFile]).
final int geaendertMs;
}
/// Welche Dateien weichen müssen, damit der Zwischenspeicher [maxBytes] nicht
/// überschreitet — am längsten nicht gehört zuerst.
///
/// Rein und ohne Dateisystem: die Reihenfolge ist der Teil, der schiefgehen
/// kann, und der lässt sich so für sich prüfen.
/// [geschuetzt] sind Pfade, die gerade als Quelle in der Warteschlange
/// stehen. Sie bleiben liegen, auch wenn die Grenze dadurch überschritten
/// bleibt: die Wiedergabe hat die Pfade schon fest eingetragen, und eine
/// gelöschte Datei ließe sie mitten im Album abbrechen — ohne Rückfall aufs
/// Streamen. Die Grenze wird beim nächsten Mal wieder eingehalten.
List<String> zuVerdraengen(
List<CacheEintrag> eintraege,
int maxBytes, {
Set<String> geschuetzt = const {},
}) {
final grenze = maxBytes < 0 ? 0 : maxBytes;
var belegt = eintraege.fold<int>(0, (summe, e) => summe + e.groesse);
if (belegt <= grenze) return const [];
final sortiert = [
for (final e in eintraege)
if (!geschuetzt.contains(e.pfad)) e,
]..sort((a, b) {
final zeit = a.geaendertMs.compareTo(b.geaendertMs);
// Gleicher Zeitstempel: nach Pfad, damit dasselbe Eingabebild immer
// dieselbe Antwort ergibt.
return zeit != 0 ? zeit : a.pfad.compareTo(b.pfad);
});
final weg = <String>[];
for (final eintrag in sortiert) {
if (belegt <= grenze) break;
weg.add(eintrag.pfad);
belegt -= eintrag.groesse;
}
return weg;
}
class CacheManager {
static const _cacheDir = 'melo_cache';
late Directory _cacheFolder;
@@ -42,9 +99,7 @@ class CacheManager {
try {
final hash = md5.convert(streamUrl.codeUnits).toString();
final file = File('${_cacheFolder.path}/$hash.mp3');
if (await file.exists()) {
return file;
}
if (await file.exists()) return file;
return null;
} catch (e) {
debugPrint('Fehler beim Abrufen der Cache-Datei: $e');
@@ -52,6 +107,65 @@ class CacheManager {
}
}
/// Stempelt die Datei auf jetzt um — erst dadurch weiß die Verdrängung,
/// was zuletzt **gehört** wurde. Die Zugriffszeit des Dateisystems taugt
/// dafür nicht (siehe [CacheEintrag.geaendertMs]).
///
/// Bewusst getrennt vom Nachschlagen: beim Aufbau einer Warteschlange wird
/// jede Datei einmal nachgeschlagen. Würde dabei umgestempelt, hätten alle
/// fast denselben Zeitstempel und die Verdrängung wüsste nicht mehr, was
/// wirklich gehört wurde.
Future<void> beruehre(String streamUrl) async {
try {
final hash = md5.convert(streamUrl.codeUnits).toString();
final file = File('${_cacheFolder.path}/$hash.mp3');
if (await file.exists()) await file.setLastModified(DateTime.now());
} catch (e) {
debugPrint('Cache-Zeitstempel nicht setzbar: $e');
}
}
/// Alle Einträge mit Größe und Zeitstempel — Grundlage für [zuVerdraengen].
Future<List<CacheEintrag>> eintraege() async {
if (!await _cacheFolder.exists()) return const [];
final gefunden = <CacheEintrag>[];
for (final eintrag in _cacheFolder.listSync()) {
if (eintrag is! File) continue;
// Halbe Downloads zählen nicht mit; sie räumt der Downloader selbst weg.
if (eintrag.path.endsWith('.part')) continue;
try {
final status = eintrag.statSync();
gefunden.add(CacheEintrag(
pfad: eintrag.path,
groesse: status.size,
geaendertMs: status.modified.millisecondsSinceEpoch,
));
} catch (e) {
debugPrint('Cache-Eintrag nicht lesbar: $e');
}
}
return gefunden;
}
/// Hält den Zwischenspeicher unter [maxBytes] und meldet, wie viele Dateien
/// dafür weichen mussten. Die bewusst heruntergeladenen Titel liegen in
/// einem anderen Ordner und sind davon nie betroffen.
Future<int> begrenzeAuf(int maxBytes, {Set<String> geschuetzt = const {}}) async {
final weg =
zuVerdraengen(await eintraege(), maxBytes, geschuetzt: geschuetzt);
var geloescht = 0;
for (final pfad in weg) {
try {
await File(pfad).delete();
geloescht++;
} catch (e) {
debugPrint('Cache-Eintrag nicht löschbar: $e');
}
}
if (geloescht > 0) debugPrint('Zwischenspeicher: $geloescht Dateien verdrängt');
return geloescht;
}
Future<File> getCacheFile(String streamUrl) async {
final hash = md5.convert(streamUrl.codeUnits).toString();
return File('${_cacheFolder.path}/$hash.mp3');
+173
View File
@@ -0,0 +1,173 @@
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;
}
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();
}
}
+68
View File
@@ -0,0 +1,68 @@
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
/// Ablage für bewusst heruntergeladene Titel.
///
/// Bewusst **nicht** im Cache-Ordner: den darf Android bei Speichernot ohne
/// Vorwarnung leeren — das ist sein Zweck. Einem Album, das jemand für die
/// Zugfahrt mitgenommen hat, darf genau das nicht passieren. Deshalb liegt
/// es neben der Datenbank im Support-Ordner, den das System in Ruhe lässt.
///
/// Die Trennung ist auch die Grundlage der Speicher-Rechnung: die Grenze für
/// den Zwischenspeicher gilt nur dort, Downloads sind unbegrenzt.
class DownloadSpeicher {
static const ordnerName = 'melo_downloads';
Directory? _ordner;
Future<Directory> _ordnerHolen() async {
final vorhanden = _ordner;
if (vorhanden != null) return vorhanden;
final basis = await getApplicationSupportDirectory();
final ordner = Directory(p.join(basis.path, ordnerName));
if (!await ordner.exists()) await ordner.create(recursive: true);
_ordner = ordner;
return ordner;
}
/// Dateiname eines Titels. Wie im Zwischenspeicher über die Server-ID und
/// nicht über die Stream-Adresse: die trägt bei jedem App-Start ein neues
/// Token und wäre als Schlüssel wertlos.
static String dateiName(String navidromeId) =>
'${md5.convert('navidrome:$navidromeId'.codeUnits)}.mp3';
/// Wohin der Titel geschrieben wird — die Datei muss es noch nicht geben.
Future<File> ziel(String navidromeId) async =>
File(p.join((await _ordnerHolen()).path, dateiName(navidromeId)));
/// Die fertige Datei, oder `null`, wenn der Titel nicht heruntergeladen ist.
Future<File?> treffer(String navidromeId) async {
final datei = await ziel(navidromeId);
return await datei.exists() ? datei : null;
}
Future<void> loesche(String navidromeId) async {
try {
final datei = await ziel(navidromeId);
if (await datei.exists()) await datei.delete();
} catch (e) {
debugPrint('Download nicht löschbar: $e');
}
}
Future<void> loescheAlle() async {
try {
final ordner = await _ordnerHolen();
if (await ordner.exists()) await ordner.delete(recursive: true);
_ordner = null;
await _ordnerHolen();
} catch (e) {
debugPrint('Downloads nicht löschbar: $e');
}
}
}
+13 -3
View File
@@ -379,7 +379,18 @@ class NavidromeService {
Future<File?> ladeInCache(String songId, Uri streamUri, CacheManager cache) async {
final ziel = await cache.getCacheFile(cacheSchluessel(songId));
if (await ziel.exists()) return ziel;
return ladeDateiHerunter(streamUri, ziel);
}
/// Lädt [streamUri] vollständig nach [ziel] und gibt die fertige Datei
/// zurück, oder `null`, wenn dabei etwas schiefging.
///
/// Dieselbe Logik für den Zwischenspeicher wie für einen bewussten
/// Download — nur das Ziel unterscheidet sich. Geschrieben wird zuerst in
/// eine `.part`-Datei; erst der vollständige, als Audio bestätigte
/// Download wird umbenannt. Ein Abbruch hinterlässt damit keine halbe
/// Datei, die später als gültiger Treffer gälte.
Future<File?> ladeDateiHerunter(Uri streamUri, File ziel) async {
final teil = File('${ziel.path}.part');
final klient = http.Client();
try {
@@ -387,16 +398,15 @@ class NavidromeService {
.send(http.Request('GET', streamUri))
.timeout(const Duration(seconds: 30));
if (!istAudioAntwort(antwort.statusCode, antwort.headers['content-type'])) {
await logger.error('Cache abgebrochen: HTTP ${antwort.statusCode}, '
await logger.error('Download abgebrochen: HTTP ${antwort.statusCode}, '
'Typ ${antwort.headers['content-type'] ?? '-'}');
return null;
}
await antwort.stream.pipe(teil.openWrite());
await teil.rename(ziel.path);
debugPrint('Im Cache abgelegt: ${ziel.path}');
return ziel;
} catch (e) {
await logger.error('Cache-Download fehlgeschlagen: $e', e, StackTrace.current);
await logger.error('Download fehlgeschlagen: $e', e, StackTrace.current);
if (await teil.exists()) await teil.delete();
return null;
} finally {