3f61079c02
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>
297 lines
9.8 KiB
Dart
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),
|
|
);
|
|
});
|