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'; /// Per-bucket cache usage (#427 S2). Rolling includes orphan files — /// partials written by LockCachingAudioSource that were never indexed /// (skip-before-fully-buffered) — so the rolling cap actually bounds /// disk. typedef BucketUsage = ({int liked, int rolling}); /// Owns the audio cache directory + drift index. /// /// #427 S2: two storage buckets keyed by liked-ness, not by /// CacheSource. A cached track currently in the user's liked set is /// charged to (and evicted under) the Liked budget; everything else /// is Rolling. This dedup is storage/eviction-only — it never filters /// what the offline surfaces can play (S4). `CacheSource` is retained /// on the API for callers but is now informational. 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; } 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(); } 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. /// lastPlayedAt is set now — pinning is play-intent. Future pin(String trackId, {required CacheSource source}) async { final existing = await pathFor(trackId); if (existing != null) { await touch(trackId); 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, lastPlayedAt: Value(DateTime.now()), ), ); return path; } /// Registers a file LockCachingAudioSource wrote itself. Called /// once a track is fully buffered; it's being played, so stamp /// lastPlayedAt. 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, lastPlayedAt: Value(DateTime.now()), ), ); } /// Bumps lastPlayedAt for an already-indexed track so the rolling /// LRU + the offline "Recently played" view reflect real plays /// (not just download time). No-op if not indexed. Future touch(String trackId) async { await (_db.update(_db.audioCacheIndex) ..where((t) => t.trackId.equals(trackId))) .write(AudioCacheIndexCompanion(lastPlayedAt: Value(DateTime.now()))); } 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 on disk (directory walk — authoritative; catches /// orphan partials the index misses). Future usageBytes() async { final dir = Directory(await _tracksDir()); if (!await dir.exists()) return 0; var total = 0; await for (final e in dir.list(followLinks: false)) { if (e is File) { try { total += await e.length(); } catch (_) {} } } return total; } /// Bytes per bucket. Indexed rows split by liked-ness; on-disk /// files with no index row (orphan partials) count as Rolling so /// the rolling cap genuinely bounds disk. Future bucketUsage(Set liked) async { final rows = await _db.select(_db.audioCacheIndex).get(); final indexed = {}; var likedB = 0; var rollingB = 0; for (final r in rows) { indexed.add(r.trackId); if (liked.contains(r.trackId)) { likedB += r.sizeBytes; } else { rollingB += r.sizeBytes; } } final dir = Directory(await _tracksDir()); if (await dir.exists()) { await for (final e in dir.list(followLinks: false)) { if (e is! File) continue; final name = e.uri.pathSegments.last; final id = name.endsWith('.mp3') ? name.substring(0, name.length - 4) : name; if (indexed.contains(id)) continue; try { rollingB += await e.length(); } catch (_) {} } } return (liked: likedB, rolling: rollingB); } /// Enforces both budgets independently (0 = unlimited). /// /// Rolling: evict non-liked indexed rows LRU (oldest lastPlayedAt /// /cachedAt first), then sweep orphan files oldest-by-mtime, until /// rolling usage ≤ rollingCap. Liked: evict liked indexed rows LRU /// until ≤ likedCap. Liked is only ever touched by its own (large) /// cap, so normal use never evicts the user's liked library. Future evictBuckets({ required int likedCap, required int rollingCap, required Set liked, }) async { final usage = await bucketUsage(liked); if (rollingCap > 0 && usage.rolling > rollingCap) { var over = usage.rolling - rollingCap; final rolling = await (_db.select(_db.audioCacheIndex) ..where((t) => t.trackId.isNotIn(liked.toList())) ..orderBy([ (t) => OrderingTerm.asc(t.lastPlayedAt), (t) => OrderingTerm.asc(t.cachedAt), ])) .get(); for (final r in rolling) { if (over <= 0) break; final f = File(r.path); if (f.existsSync()) await f.delete(); await (_db.delete(_db.audioCacheIndex) ..where((t) => t.trackId.equals(r.trackId))) .go(); over -= r.sizeBytes; } if (over > 0) await _sweepOrphans(over); } if (likedCap > 0 && usage.liked > likedCap) { var over = usage.liked - likedCap; final likedRows = await (_db.select(_db.audioCacheIndex) ..where((t) => t.trackId.isIn(liked.toList())) ..orderBy([ (t) => OrderingTerm.asc(t.lastPlayedAt), (t) => OrderingTerm.asc(t.cachedAt), ])) .get(); for (final r in likedRows) { if (over <= 0) break; final f = File(r.path); if (f.existsSync()) await f.delete(); await (_db.delete(_db.audioCacheIndex) ..where((t) => t.trackId.equals(r.trackId))) .go(); over -= r.sizeBytes; } } } /// Deletes orphan files (on disk, no index row) oldest-mtime-first /// until `over` bytes are reclaimed. These are unindexed partials, /// always Rolling, always evict-first. Future _sweepOrphans(int over) async { final indexed = { for (final r in await _db.select(_db.audioCacheIndex).get()) r.trackId }; final dir = Directory(await _tracksDir()); if (!await dir.exists()) return; final orphans = <({File f, int size, DateTime mtime})>[]; await for (final e in dir.list(followLinks: false)) { if (e is! File) continue; final name = e.uri.pathSegments.last; final id = name.endsWith('.mp3') ? name.substring(0, name.length - 4) : name; if (indexed.contains(id)) continue; try { final st = e.statSync(); orphans.add((f: e, size: st.size, mtime: st.modified)); } catch (_) {} } orphans.sort((a, b) => a.mtime.compareTo(b.mtime)); var remaining = over; for (final o in orphans) { if (remaining <= 0) break; try { await o.f.delete(); remaining -= o.size; } catch (_) {} } } /// Clears EVERY row + EVERY file. Wired to "Clear cache". Future clearAll() async { final dir = await _tracksDir(); final d = Directory(dir); if (d.existsSync()) { for (final e in d.listSync()) { if (e is File) await e.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), ); });