Files
minstrel/flutter_client/lib/cache/audio_cache_manager.dart
T
bvandeusen 3f61079c02 feat(offline): #427 S2(+S3) — two-bucket cache model
Replaces the single 5GB capBytes with independent Liked + Rolling
budgets (5GB each default). Bucket = liked-ness, NOT CacheSource:
a cached track currently in the liked set is charged to / evicted
under Liked; everything else is Rolling. Storage-only dedup — it
never filters playback (S4's offline lists query the whole index).

- db.dart: schema 8→9, AudioCacheIndex.lastPlayedAt (real play
  recency for S4 + rolling LRU; migration backfills to cachedAt).
  drift codegen run.
- cache_settings: likedCapBytes + rollingCapBytes (+ setters); old
  cache_cap_bytes key dropped, defaults reapply (not data loss).
- audio_cache_manager: touch(); bucketUsage() counts orphan
  partials (LockCaching files never indexed) as Rolling so the cap
  truly bounds disk; evictBuckets() drains non-liked LRU then
  sweeps orphans, Liked only by its own (large) cap — normal use
  never evicts the user's liked library.
- prefetcher → evictBuckets with the cached liked set.
- storage_section: two cap selectors + per-bucket usage (folds in
  S3 to avoid a broken intermediate).
- Explicit Download dropped: removed album + playlist Download
  buttons, autoPlaylist pins, now-unused imports.
- Tests updated/compiled (drift-cohort tests are CI-skipped).

High blast radius (eviction deletes files) — liked-protective by
design; needs operator device-check before "done".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:07:45 -04:00

297 lines
9.8 KiB
Dart

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<Dio> Function() dioFactory,
Future<Directory> Function()? cacheDirFactory,
}) : _db = db,
_dioFactory = dioFactory,
_cacheDirFactory = cacheDirFactory ?? getApplicationCacheDirectory;
final AppDb _db;
final Future<Dio> Function() _dioFactory;
final Future<Directory> Function() _cacheDirFactory;
Future<String> _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<bool> 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<String?> 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<String?> 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<void> 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<void> touch(String trackId) async {
await (_db.update(_db.audioCacheIndex)
..where((t) => t.trackId.equals(trackId)))
.write(AudioCacheIndexCompanion(lastPlayedAt: Value(DateTime.now())));
}
Future<void> 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<int> 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> bucketUsage(Set<String> liked) async {
final rows = await _db.select(_db.audioCacheIndex).get();
final indexed = <String>{};
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<void> evictBuckets({
required int likedCap,
required int rollingCap,
required Set<String> 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<void> _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<void> 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<AppDb>((ref) {
final db = AppDb();
ref.onDispose(db.close);
return db;
});
final audioCacheManagerProvider = Provider<AudioCacheManager>((ref) {
final db = ref.watch(appDbProvider);
return AudioCacheManager(
db: db,
dioFactory: () async => ref.read(dioProvider.future),
);
});