import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; /// Legt Coverbilder vom Server auf der Platte ab. /// /// `Image.network` merkt sich Bilder nur im Arbeitsspeicher. Beim nächsten /// Kaltstart fragte deshalb jede sichtbare Zeile ihr Cover erneut beim Server /// an — und ohne Netz gab es gar keins, obwohl das Bild schon dreimal geladen /// worden war. /// /// Absichtlich im Cache-Ordner und nicht bei den Downloads: ein Cover ist /// ersetzbar. Räumt Android den Ordner, lädt es sich beim nächsten Mal neu. class CoverCache { static const ordnerName = 'melo_covers'; /// Höchstzahl gemerkter Abfragen. Ohne Deckel wüchse die Tabelle mit jedem /// je gesehenen Cover; die Dateien bleiben davon unberührt. static const _merkGrenze = 500; /// Laufende und fertige Abfragen je Adresse. /// /// Der **Future** wird gemerkt, nicht erst das Ergebnis: `CoverImage` ist /// zustandslos und fragt bei jedem Neuzeichnen. Ohne diese Tabelle liefe /// derselbe Download mehrfach parallel. static final Map> _abfragen = {}; static Directory? _ordner; /// Macht den Namen der Zwischendatei je Abruf eindeutig. static int _laufendeNummer = 0; static Future _ordnerHolen() async { final vorhanden = _ordner; if (vorhanden != null) return vorhanden; final basis = await getApplicationCacheDirectory(); final ordner = Directory(p.join(basis.path, ordnerName)); if (!await ordner.exists()) await ordner.create(recursive: true); _ordner = ordner; return ordner; } /// Dateiname einer Cover-Adresse. /// /// Ohne die Zugangsdaten: die Adresse trägt bei jedem App-Start ein neues /// Token und Salt. Als Schlüssel wäre dasselbe Bild sonst jedes Mal ein /// anderes, und der Ordner wüchse, ohne je einen Treffer zu liefern. static String dateiName(Uri adresse) { final id = adresse.queryParameters['id'] ?? adresse.path; return '${md5.convert(id.codeUnits)}.img'; } /// Die Bilddatei zu [adresse] — aus dem Ordner, sonst frisch geladen. /// `null`, wenn es sie nicht gibt und auch nicht zu holen ist. static Future hole(Uri adresse) { final schluessel = dateiName(adresse); final bekannt = _abfragen[schluessel]; if (bekannt != null) return bekannt; if (_abfragen.length >= _merkGrenze) _abfragen.clear(); // Einen Fehlschlag nicht dauerhaft merken: startet die App ohne Netz, // blieben sonst alle Cover bis zum Neustart grau, auch wenn die // Verbindung längst wieder steht. final abfrage = _hole(adresse, schluessel).then((datei) { if (datei == null) _abfragen.remove(schluessel); return datei; }); _abfragen[schluessel] = abfrage; return abfrage; } static Future _hole(Uri adresse, String schluessel) async { try { final datei = File(p.join((await _ordnerHolen()).path, schluessel)); if (await datei.exists()) return datei; final antwort = await http.get(adresse).timeout(const Duration(seconds: 15)); if (antwort.statusCode != 200 || antwort.bodyBytes.isEmpty) return null; // Subsonic meldet Fehler mit HTTP 200 und JSON — das wäre als Bild // unbrauchbar und läge dann dauerhaft im Ordner. final typ = antwort.headers['content-type'] ?? ''; if (!typ.startsWith('image/')) return null; // Erst vollständig schreiben, dann umbenennen: ein Abbruch hinterlässt // sonst eine halbe Datei, die als gültiger Treffer gälte. Der // Zwischenname ist je Abruf eindeutig — zwei gleichzeitige Abrufe // desselben Covers schrieben sonst in dieselbe Datei und könnten ein // zerschnittenes Bild hinterlassen, das dauerhaft liegen bliebe. final teil = File('${datei.path}.${_laufendeNummer++}.part'); await teil.writeAsBytes(antwort.bodyBytes, flush: true); await teil.rename(datei.path); return datei; } catch (e) { debugPrint('Cover nicht ladbar: $e'); return null; } } /// Nur für Tests: vergisst die gemerkten Abfragen. @visibleForTesting static void vergissAbfragen() => _abfragen.clear(); }