import 'dart:io'; 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 zuVerdraengen( List eintraege, int maxBytes, { Set geschuetzt = const {}, }) { final grenze = maxBytes < 0 ? 0 : maxBytes; var belegt = eintraege.fold(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 = []; 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; Future init() async { final appDir = await getApplicationCacheDirectory(); _cacheFolder = Directory('${appDir.path}/$_cacheDir'); if (!await _cacheFolder.exists()) { await _cacheFolder.create(recursive: true); } } Future getCacheSize() async { int size = 0; final files = _cacheFolder.listSync(recursive: true); for (final file in files) { if (file is File) { size += await file.length(); } } return size; } Future clearCache() async { try { if (await _cacheFolder.exists()) { await _cacheFolder.delete(recursive: true); } await _cacheFolder.create(recursive: true); debugPrint('Cache gelöscht'); } catch (e) { debugPrint('Fehler beim Löschen des Cache: $e'); } } Future getCachedFile(String streamUrl) async { try { final hash = md5.convert(streamUrl.codeUnits).toString(); final file = File('${_cacheFolder.path}/$hash.mp3'); if (await file.exists()) return file; return null; } catch (e) { debugPrint('Fehler beim Abrufen der Cache-Datei: $e'); return null; } } /// 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 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> eintraege() async { if (!await _cacheFolder.exists()) return const []; final gefunden = []; 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 begrenzeAuf(int maxBytes, {Set 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 getCacheFile(String streamUrl) async { final hash = md5.convert(streamUrl.codeUnits).toString(); return File('${_cacheFolder.path}/$hash.mp3'); } Stream streamAndCache(Stream> sourceStream, String streamUrl) async* { final cacheFile = await getCacheFile(streamUrl); final sink = cacheFile.openWrite(); await for (final chunk in sourceStream) { sink.add(chunk); yield cacheFile; } await sink.close(); } }