import 'dart:io'; import 'package:dio/dio.dart'; import 'package:drift/drift.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import '../library/library_providers.dart' show dioProvider; import 'db.dart'; /// Owns the audio cache directory + drift index. /// API: /// - isCached(trackId) /// - pathFor(trackId) /// - pin(trackId, source) — downloads + indexes /// - unpin(trackId) /// - evict(targetBytes) — tiered LRU /// - usageBytes() /// - clearAll() class AudioCacheManager { AudioCacheManager({ required AppDb db, required Future Function() dioFactory, Future Function()? cacheDirFactory, }) : _db = db, _dioFactory = dioFactory, _cacheDirFactory = cacheDirFactory ?? getApplicationCacheDirectory; final AppDb _db; final Future Function() _dioFactory; final Future Function() _cacheDirFactory; Future _tracksDir() async { final base = await _cacheDirFactory(); final d = Directory('${base.path}/audio_cache'); if (!await d.exists()) await d.create(recursive: true); return d.path; } /// True if the trackId has a complete file on disk. Future isCached(String trackId) async { final row = await (_db.select(_db.audioCacheIndex) ..where((t) => t.trackId.equals(trackId))) .getSingleOrNull(); if (row == null) return false; return File(row.path).existsSync(); } /// Returns the local file path if cached, else null. Future pathFor(String trackId) async { final row = await (_db.select(_db.audioCacheIndex) ..where((t) => t.trackId.equals(trackId))) .getSingleOrNull(); if (row == null) return null; return File(row.path).existsSync() ? row.path : null; } /// Downloads the track's stream to disk and indexes it. Idempotent — /// returns the existing path if already cached. Future pin(String trackId, {required CacheSource source}) async { final existing = await pathFor(trackId); if (existing != null) return existing; final dir = await _tracksDir(); final path = '$dir/$trackId.mp3'; final dio = await _dioFactory(); try { await dio.download('/api/tracks/$trackId/stream', path); } catch (_) { final f = File(path); if (f.existsSync()) await f.delete(); return null; } final size = await File(path).length(); await _db.into(_db.audioCacheIndex).insertOnConflictUpdate( AudioCacheIndexCompanion.insert( trackId: trackId, path: path, sizeBytes: size, source: source, ), ); return path; } /// Registers a file already on disk in the cache index. Intended for /// the streaming path (LockCachingAudioSource) which writes the file /// itself; we need an index row so eviction can find and delete it /// when usage exceeds the cap. `source` defaults to incidental so /// stream-cached tracks are first to be evicted. Future registerStreamCache( String trackId, String path, int sizeBytes, { CacheSource source = CacheSource.incidental, }) async { await _db.into(_db.audioCacheIndex).insertOnConflictUpdate( AudioCacheIndexCompanion.insert( trackId: trackId, path: path, sizeBytes: sizeBytes, source: source, ), ); } /// Removes a track from the index AND deletes the file. Future unpin(String trackId) async { final row = await (_db.select(_db.audioCacheIndex) ..where((t) => t.trackId.equals(trackId))) .getSingleOrNull(); if (row == null) return; final f = File(row.path); if (f.existsSync()) await f.delete(); await (_db.delete(_db.audioCacheIndex) ..where((t) => t.trackId.equals(trackId))) .go(); } /// Total bytes used by the cache. Walks the cache directory directly /// instead of summing the index, because the streaming-as-you-play /// path (audio_handler's LockCachingAudioSource) writes files /// without registering an index row. The index sum would always /// understate (often to zero) for users who only stream. Future usageBytes() async { final dir = Directory(await _tracksDir()); if (!await dir.exists()) return 0; var total = 0; await for (final entity in dir.list(followLinks: false)) { if (entity is File) { try { total += await entity.length(); } catch (_) { // Race against concurrent writes / deletes — just skip. } } } return total; } /// Evicts files until usage ≤ targetBytes. Eviction order: /// incidental → autoPrefetch → autoPlaylist → autoLiked. /// `manual` never evicts via this path; only clearAll() removes them. Future evict({required int targetBytes}) async { final used = await usageBytes(); if (used <= targetBytes) return; final order = [ CacheSource.incidental, CacheSource.autoPrefetch, CacheSource.autoPlaylist, CacheSource.autoLiked, ]; int remaining = used - targetBytes; for (final src in order) { if (remaining <= 0) break; final rows = await (_db.select(_db.audioCacheIndex) ..where((t) => t.source.equalsValue(src)) ..orderBy([(t) => OrderingTerm.asc(t.cachedAt)])) .get(); for (final row in rows) { if (remaining <= 0) break; final f = File(row.path); if (f.existsSync()) await f.delete(); await (_db.delete(_db.audioCacheIndex) ..where((t) => t.trackId.equals(row.trackId))) .go(); remaining -= row.sizeBytes; } } } /// Clears EVERY row + EVERY file. Wired to the operator's "Clear cache" /// button — `manual` source rows are removed here but only here. Future clearAll() async { final dir = await _tracksDir(); final d = Directory(dir); if (d.existsSync()) { for (final entity in d.listSync()) { if (entity is File) await entity.delete(); } } await _db.delete(_db.audioCacheIndex).go(); } } /// AppDb singleton. One per app run; ref.onDispose closes it. final appDbProvider = Provider((ref) { final db = AppDb(); ref.onDispose(db.close); return db; }); final audioCacheManagerProvider = Provider((ref) { final db = ref.watch(appDbProvider); return AudioCacheManager( db: db, dioFactory: () async => ref.read(dioProvider.future), ); });