Files
minstrel/flutter_client/lib/cache/audio_cache_manager.dart
T
bvandeusen c08f4ace80 fix(flutter): cache usage display reflects actual disk, not just index
The Storage card has been showing "0 B" for users who only stream
(never explicitly pin or download an album). usageBytes() summed
SUM(size_bytes) from audio_cache_index — but the streaming path
through audio_handler writes files via LockCachingAudioSource without
ever inserting an index row, so the index undercounts (often to
zero) for normal use.

Walk the cache directory instead. Catches everything on disk:
manually pinned tracks (registered in the index), stream-cached
tracks (LockCaching), partial downloads. Falls back gracefully when
a file is racing against concurrent writes / deletes.

Eviction still operates on the index (it needs the source/recency
metadata to pick eviction order). Stream-cached files aren't subject
to eviction today — separate problem; addressed when we wire a
download-complete hook from LockCaching back into the index.
2026-05-11 17:41:07 -04:00

179 lines
5.7 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';
/// 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<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;
}
/// True if the trackId has a complete file on disk.
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();
}
/// Returns the local file path if cached, else null.
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 —
/// returns the existing path if already cached.
Future<String?> 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;
}
/// Removes a track from the index AND deletes the file.
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 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<int> 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<void> 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<void> 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<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),
);
});