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>
This commit is contained in:
2026-05-15 21:07:45 -04:00
parent a0aea00667
commit 3f61079c02
10 changed files with 355 additions and 173 deletions
+155 -58
View File
@@ -8,15 +8,20 @@ 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.
/// API:
/// - isCached(trackId)
/// - pathFor(trackId)
/// - pin(trackId, source) — downloads + indexes
/// - unpin(trackId)
/// - evict(targetBytes) — tiered LRU
/// - usageBytes()
/// - clearAll()
///
/// #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,
@@ -37,7 +42,6 @@ class AudioCacheManager {
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)))
@@ -46,7 +50,6 @@ class AudioCacheManager {
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)))
@@ -55,12 +58,14 @@ class AudioCacheManager {
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.
/// 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) return existing;
if (existing != null) {
await touch(trackId);
return existing;
}
final dir = await _tracksDir();
final path = '$dir/$trackId.mp3';
final dio = await _dioFactory();
@@ -78,16 +83,15 @@ class AudioCacheManager {
path: path,
sizeBytes: size,
source: source,
lastPlayedAt: Value(DateTime.now()),
),
);
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.
/// 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,
@@ -100,11 +104,20 @@ class AudioCacheManager {
path: path,
sizeBytes: sizeBytes,
source: source,
lastPlayedAt: Value(DateTime.now()),
),
);
}
/// Removes a track from the index AND deletes the file.
/// 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)))
@@ -117,66 +130,150 @@ class AudioCacheManager {
.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.
/// 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 entity in dir.list(followLinks: false)) {
if (entity is File) {
await for (final e in dir.list(followLinks: false)) {
if (e is File) {
try {
total += await entity.length();
} catch (_) {
// Race against concurrent writes / deletes — just skip.
}
total += await e.length();
} catch (_) {}
}
}
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)]))
/// 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 row in rows) {
if (remaining <= 0) break;
final f = File(row.path);
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(row.trackId)))
..where((t) => t.trackId.equals(r.trackId)))
.go();
remaining -= row.sizeBytes;
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;
}
}
}
/// Clears EVERY row + EVERY file. Wired to the operator's "Clear cache"
/// button — `manual` source rows are removed here but only here.
/// 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 entity in d.listSync()) {
if (entity is File) await entity.delete();
for (final e in d.listSync()) {
if (e is File) await e.delete();
}
}
await _db.delete(_db.audioCacheIndex).go();
+42 -15
View File
@@ -3,17 +3,29 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../auth/auth_provider.dart' show secureStorageProvider;
/// Operator-tunable cache settings (#357 plan B). Persisted via
/// Operator-tunable cache settings. Persisted via
/// flutter_secure_storage on the same device.
///
/// #427 S2: the single `capBytes` is replaced by two independent
/// budgets — Liked and Rolling-recent — each defaulting to 5GB.
/// Bucketing is by liked-ness (a cached track currently in the
/// user's liked set is charged to Liked; everything else to
/// Rolling), so the dedup is storage-only and never filters
/// playback. The old `cache_cap_bytes` key is intentionally not
/// migrated; defaults reapply (a one-time re-tune, not data loss).
class CacheSettings {
const CacheSettings({
required this.capBytes,
required this.likedCapBytes,
required this.rollingCapBytes,
required this.prefetchWindow,
required this.cacheLikedTracks,
});
/// 0 = unlimited.
final int capBytes;
/// Liked-bucket budget. 0 = unlimited.
final int likedCapBytes;
/// Rolling (recently-played) budget. 0 = unlimited.
final int rollingCapBytes;
/// 1..10. Number of next-tracks the prefetcher pre-downloads.
final int prefetchWindow;
@@ -22,25 +34,31 @@ class CacheSettings {
final bool cacheLikedTracks;
CacheSettings copyWith({
int? capBytes,
int? likedCapBytes,
int? rollingCapBytes,
int? prefetchWindow,
bool? cacheLikedTracks,
}) =>
CacheSettings(
capBytes: capBytes ?? this.capBytes,
likedCapBytes: likedCapBytes ?? this.likedCapBytes,
rollingCapBytes: rollingCapBytes ?? this.rollingCapBytes,
prefetchWindow: prefetchWindow ?? this.prefetchWindow,
cacheLikedTracks: cacheLikedTracks ?? this.cacheLikedTracks,
);
static const _fiveGiB = 5 * 1024 * 1024 * 1024;
static const defaults = CacheSettings(
capBytes: 5 * 1024 * 1024 * 1024,
likedCapBytes: _fiveGiB,
rollingCapBytes: _fiveGiB,
prefetchWindow: 5,
cacheLikedTracks: true,
);
}
class CacheSettingsController extends AsyncNotifier<CacheSettings> {
static const _kCap = 'cache_cap_bytes';
static const _kLikedCap = 'cache_liked_cap_bytes';
static const _kRollingCap = 'cache_rolling_cap_bytes';
static const _kPrefetch = 'cache_prefetch_window';
static const _kCacheLiked = 'cache_liked_tracks';
@@ -49,13 +67,17 @@ class CacheSettingsController extends AsyncNotifier<CacheSettings> {
@override
Future<CacheSettings> build() async {
_storage = ref.read(secureStorageProvider);
final cap = await _storage.read(key: _kCap);
final likedCap = await _storage.read(key: _kLikedCap);
final rollingCap = await _storage.read(key: _kRollingCap);
final pre = await _storage.read(key: _kPrefetch);
final liked = await _storage.read(key: _kCacheLiked);
return CacheSettings(
capBytes: cap == null
? CacheSettings.defaults.capBytes
: int.tryParse(cap) ?? CacheSettings.defaults.capBytes,
likedCapBytes: likedCap == null
? CacheSettings.defaults.likedCapBytes
: int.tryParse(likedCap) ?? CacheSettings.defaults.likedCapBytes,
rollingCapBytes: rollingCap == null
? CacheSettings.defaults.rollingCapBytes
: int.tryParse(rollingCap) ?? CacheSettings.defaults.rollingCapBytes,
prefetchWindow: pre == null
? CacheSettings.defaults.prefetchWindow
: (int.tryParse(pre) ?? 5).clamp(1, 10),
@@ -65,9 +87,14 @@ class CacheSettingsController extends AsyncNotifier<CacheSettings> {
);
}
Future<void> setCapBytes(int bytes) async {
await _storage.write(key: _kCap, value: bytes.toString());
state = AsyncData(state.value!.copyWith(capBytes: bytes));
Future<void> setLikedCapBytes(int bytes) async {
await _storage.write(key: _kLikedCap, value: bytes.toString());
state = AsyncData(state.value!.copyWith(likedCapBytes: bytes));
}
Future<void> setRollingCapBytes(int bytes) async {
await _storage.write(key: _kRollingCap, value: bytes.toString());
state = AsyncData(state.value!.copyWith(rollingCapBytes: bytes));
}
Future<void> setPrefetchWindow(int n) async {
+17 -1
View File
@@ -101,6 +101,11 @@ class AudioCacheIndex extends Table {
TextColumn get path => text()();
IntColumn get sizeBytes => integer()();
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)();
/// When the track was last played. Drives the offline "Recently
/// played" ordering and rolling-bucket LRU eviction. Distinct from
/// cachedAt (download time). Nullable for pre-schema-9 rows until
/// the next play touches them (migration backfills to cachedAt).
DateTimeColumn get lastPlayedAt => dateTime().nullable()();
TextColumn get source => textEnum<CacheSource>()();
@override
Set<Column> get primaryKey => {trackId};
@@ -247,7 +252,7 @@ class AppDb extends _$AppDb {
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
@override
int get schemaVersion => 8;
int get schemaVersion => 9;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -300,6 +305,17 @@ class AppDb extends _$AppDb {
// upgrade; populated by controllers when REST calls fail.
await m.createTable(cachedMutations);
}
if (from < 9) {
// Schema 9 (#427 S2): two-bucket cache. lastPlayedAt
// gives the offline "Recently played" view a real
// recency signal (cachedAt is download time, not play
// time). Nullable — backfilled to cachedAt so existing
// rows order sensibly until next play touches them.
await m.addColumn(audioCacheIndex, audioCacheIndex.lastPlayedAt);
await customStatement(
'UPDATE audio_cache_index SET last_played_at = cached_at',
);
}
},
);
}
+12 -4
View File
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../likes/likes_provider.dart' show likedIdsProvider;
import '../player/album_color_extractor.dart';
import '../player/player_provider.dart';
import 'audio_cache_manager.dart';
@@ -86,10 +87,17 @@ class Prefetcher {
}
}
// Eviction pass after pinning new files.
if (settings.capBytes > 0) {
await mgr.evict(targetBytes: settings.capBytes);
}
// Eviction pass after pinning new files. Per-bucket (#427 S2):
// liked-ness decides the bucket, so a track that's both liked
// and recently played is protected by the Liked cap, not the
// rolling LRU.
final liked =
_ref.read(likedIdsProvider).value?.tracks ?? const <String>{};
await mgr.evictBuckets(
likedCap: settings.likedCapBytes,
rollingCap: settings.rollingCapBytes,
liked: liked,
);
}
}
@@ -3,8 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/endpoints/likes.dart';
import '../models/album.dart';
import '../cache/audio_cache_manager.dart';
import '../cache/db.dart';
import '../likes/like_button.dart';
import '../player/player_provider.dart';
import '../shared/widgets/server_image.dart';
@@ -113,21 +111,6 @@ class AlbumDetailScreen extends ConsumerWidget {
Text(r.album.artistName, style: TextStyle(color: fs.ash)),
],
)),
IconButton(
key: const Key('download_album_button'),
icon: Icon(Icons.download, color: fs.ash),
tooltip: 'Download album',
onPressed: () {
final mgr = ref.read(audioCacheManagerProvider);
for (final t in r.tracks) {
// ignore: unawaited_futures
mgr.pin(t.id, source: CacheSource.autoPlaylist);
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Downloading ${r.tracks.length} tracks…')),
);
},
),
Container(
width: 48, height: 48,
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle),
@@ -2,8 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../cache/audio_cache_manager.dart';
import '../cache/db.dart';
import '../models/playlist.dart';
import '../models/track.dart';
import '../player/player_provider.dart';
@@ -266,33 +264,15 @@ class _Header extends ConsumerWidget {
),
const Spacer(),
if (playable.isNotEmpty) ...[
if (p.refreshable)
if (p.refreshable) ...[
OutlinedButton.icon(
key: const Key('regenerate_playlist_button'),
onPressed: () => _regenerate(context, ref),
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Regenerate'),
)
else
OutlinedButton.icon(
key: const Key('download_playlist_button'),
onPressed: () {
final mgr = ref.read(audioCacheManagerProvider);
for (final t in playable) {
final id = t.trackId ?? '';
if (id.isEmpty) continue;
// Fire-and-forget; downloads happen in background.
// ignore: unawaited_futures
mgr.pin(id, source: CacheSource.autoPlaylist);
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Downloading ${playable.length} tracks…')),
);
},
icon: const Icon(Icons.download, size: 16),
label: const Text('Download'),
),
const SizedBox(width: 8),
const SizedBox(width: 8),
],
FilledButton.icon(
onPressed: () {
final refs = playable.map(_toTrackRef).toList(growable: false);
@@ -4,10 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../cache/audio_cache_manager.dart';
import '../cache/cache_settings_provider.dart';
import '../cache/sync_controller.dart';
import '../likes/likes_provider.dart' show likedIdsProvider;
import '../theme/theme_extension.dart';
/// Settings card: usage display, cap selector, prefetch window selector,
/// cache-liked toggle, Clear cache + Sync now buttons.
/// Settings card: per-bucket usage, two cap selectors (Liked +
/// Recently-played), prefetch window, cache-liked toggle, Clear
/// cache + Sync now. #427 S2/S3: two independent budgets.
class StorageSection extends ConsumerStatefulWidget {
const StorageSection({super.key});
@@ -16,7 +18,7 @@ class StorageSection extends ConsumerStatefulWidget {
}
class _StorageSectionState extends ConsumerState<StorageSection> {
int? _usageBytes;
BucketUsage? _usage;
bool _syncing = false;
@override
@@ -25,10 +27,13 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
_refreshUsage();
}
Set<String> _likedSet() =>
ref.read(likedIdsProvider).value?.tracks ?? const <String>{};
Future<void> _refreshUsage() async {
final mgr = ref.read(audioCacheManagerProvider);
final used = await mgr.usageBytes();
if (mounted) setState(() => _usageBytes = used);
final u = await mgr.bucketUsage(_likedSet());
if (mounted) setState(() => _usage = u);
}
String _fmtBytes(int? n) {
@@ -36,10 +41,14 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
if (n == 0) return '0 B';
if (n < 1024) return '$n B';
if (n < 1024 * 1024) return '${(n / 1024).toStringAsFixed(1)} KB';
if (n < 1024 * 1024 * 1024) return '${(n / 1024 / 1024).toStringAsFixed(1)} MB';
if (n < 1024 * 1024 * 1024) {
return '${(n / 1024 / 1024).toStringAsFixed(1)} MB';
}
return '${(n / 1024 / 1024 / 1024).toStringAsFixed(2)} GB';
}
String _cap(int bytes) => bytes == 0 ? 'unlimited' : _fmtBytes(bytes);
@override
Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
@@ -65,17 +74,32 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
fontSize: 18,
fontWeight: FontWeight.w500)),
const SizedBox(height: 12),
Row(children: [
Text('Cache usage', style: TextStyle(color: fs.ash)),
const Spacer(),
Text(
'${_fmtBytes(_usageBytes)} / '
'${s.capBytes == 0 ? "unlimited" : _fmtBytes(s.capBytes)}',
style: TextStyle(color: fs.parchment),
),
]),
_usageRow('Liked', _usage?.liked, s.likedCapBytes, fs),
const SizedBox(height: 4),
_usageRow('Recently played', _usage?.rolling,
s.rollingCapBytes, fs),
const SizedBox(height: 16),
_capSelector(s, fs),
_capSelector(
'Liked cache limit',
const Key('liked_cap_selector'),
s.likedCapBytes,
(v) => ref
.read(cacheSettingsProvider.notifier)
.setLikedCapBytes(v),
s,
fs,
),
const SizedBox(height: 8),
_capSelector(
'Recently-played cache limit',
const Key('rolling_cap_selector'),
s.rollingCapBytes,
(v) => ref
.read(cacheSettingsProvider.notifier)
.setRollingCapBytes(v),
s,
fs,
),
const SizedBox(height: 8),
_prefetchSelector(s, fs),
const SizedBox(height: 8),
@@ -128,29 +152,52 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
);
}
Widget _capSelector(CacheSettings s, FabledSwordTheme fs) {
const options = [
(1024 * 1024 * 1024, '1 GB'),
(5 * 1024 * 1024 * 1024, '5 GB'),
(10 * 1024 * 1024 * 1024, '10 GB'),
(25 * 1024 * 1024 * 1024, '25 GB'),
(0, 'Unlimited'),
];
Widget _usageRow(String label, int? used, int cap, FabledSwordTheme fs) {
return Row(children: [
Expanded(child: Text('Cache size limit', style: TextStyle(color: fs.ash))),
Text(label, style: TextStyle(color: fs.ash)),
const Spacer(),
Text('${_fmtBytes(used)} / ${_cap(cap)}',
style: TextStyle(color: fs.parchment)),
]);
}
static const _capOptions = [
(1024 * 1024 * 1024, '1 GB'),
(5 * 1024 * 1024 * 1024, '5 GB'),
(10 * 1024 * 1024 * 1024, '10 GB'),
(25 * 1024 * 1024 * 1024, '25 GB'),
(0, 'Unlimited'),
];
Widget _capSelector(
String label,
Key key,
int current,
Future<void> Function(int) setCap,
CacheSettings s,
FabledSwordTheme fs,
) {
return Row(children: [
Expanded(child: Text(label, style: TextStyle(color: fs.ash))),
DropdownButton<int>(
key: const Key('cap_selector'),
value: options.any((o) => o.$1 == s.capBytes)
? s.capBytes
key: key,
value: _capOptions.any((o) => o.$1 == current)
? current
: 5 * 1024 * 1024 * 1024,
items: options
items: _capOptions
.map((o) => DropdownMenuItem(value: o.$1, child: Text(o.$2)))
.toList(),
onChanged: (v) async {
if (v == null) return;
await ref.read(cacheSettingsProvider.notifier).setCapBytes(v);
if (v > 0) {
await ref.read(audioCacheManagerProvider).evict(targetBytes: v);
await setCap(v);
// Enforce immediately against the freshest settings.
final fresh = ref.read(cacheSettingsProvider).value;
if (fresh != null) {
await ref.read(audioCacheManagerProvider).evictBuckets(
likedCap: fresh.likedCapBytes,
rollingCap: fresh.rollingCapBytes,
liked: _likedSet(),
);
}
await _refreshUsage();
},
@@ -161,7 +208,8 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
Widget _prefetchSelector(CacheSettings s, FabledSwordTheme fs) {
const options = [1, 3, 5, 7, 10];
return Row(children: [
Expanded(child: Text('Pre-fetch ahead', style: TextStyle(color: fs.ash))),
Expanded(
child: Text('Pre-fetch ahead', style: TextStyle(color: fs.ash))),
DropdownButton<int>(
key: const Key('prefetch_selector'),
value: options.contains(s.prefetchWindow) ? s.prefetchWindow : 5,
@@ -183,8 +231,8 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
builder: (ctx) => AlertDialog(
title: const Text('Clear cache?'),
content: const Text(
'This deletes all cached audio files (including manually downloaded ones). '
'The next play of any track will re-download from the server.',
'This deletes all cached audio (liked and recently-played). '
'The next play of any track re-downloads from the server.',
),
actions: [
TextButton(
+36 -17
View File
@@ -4,6 +4,7 @@ library;
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:drift/drift.dart' show Value;
import 'package:drift/native.dart' show NativeDatabase;
import 'package:flutter_test/flutter_test.dart';
@@ -54,7 +55,8 @@ void main() {
expect(await mgr.usageBytes(), 350);
});
test('eviction order: incidental first, manual never', skip: _skipDrift, () async {
test('rolling cap evicts non-liked LRU; liked protected',
skip: _skipDrift, () async {
final db = _testDb();
addTearDown(db.close);
final tmp = Directory.systemTemp.createTempSync();
@@ -63,30 +65,47 @@ void main() {
dioFactory: () async => Dio(),
cacheDirFactory: () async => tmp,
);
final f1 = File('${tmp.path}/audio_cache/inc.mp3');
await f1.create(recursive: true);
await f1.writeAsBytes(List.filled(100, 0));
final f2 = File('${tmp.path}/audio_cache/man.mp3');
await f2.create(recursive: true);
await f2.writeAsBytes(List.filled(100, 0));
Future<void> mk(String id) async {
final f = File('${tmp.path}/audio_cache/$id.mp3');
await f.create(recursive: true);
await f.writeAsBytes(List.filled(100, 0));
}
await mk('old');
await mk('new');
await mk('lik');
await db.batch((b) {
b.insertAll(db.audioCacheIndex, [
// 'old' has the older lastPlayedAt → evicted first.
AudioCacheIndexCompanion.insert(
trackId: 'inc',
path: f1.path,
trackId: 'old',
path: '${tmp.path}/audio_cache/old.mp3',
sizeBytes: 100,
source: CacheSource.incidental),
source: CacheSource.incidental,
lastPlayedAt: Value(DateTime(2020))),
AudioCacheIndexCompanion.insert(
trackId: 'man',
path: f2.path,
trackId: 'new',
path: '${tmp.path}/audio_cache/new.mp3',
sizeBytes: 100,
source: CacheSource.manual),
source: CacheSource.incidental,
lastPlayedAt: Value(DateTime(2024))),
AudioCacheIndexCompanion.insert(
trackId: 'lik',
path: '${tmp.path}/audio_cache/lik.mp3',
sizeBytes: 100,
source: CacheSource.incidental,
lastPlayedAt: Value(DateTime(2019))),
]);
});
expect(await mgr.usageBytes(), 200);
await mgr.evict(targetBytes: 100);
expect(await mgr.isCached('inc'), false);
expect(await mgr.isCached('man'), true);
final liked = {'lik'};
final u = await mgr.bucketUsage(liked);
expect(u.liked, 100);
expect(u.rolling, 200);
// Rolling cap fits one 100-byte file; Liked cap huge.
await mgr.evictBuckets(
likedCap: 1 << 30, rollingCap: 100, liked: liked);
expect(await mgr.isCached('old'), false); // oldest rolling, evicted
expect(await mgr.isCached('new'), true); // newer rolling, kept
expect(await mgr.isCached('lik'), true); // liked, protected
});
test('clearAll removes everything including manual', skip: _skipDrift, () async {
@@ -27,7 +27,9 @@ void main() {
addTearDown(container.dispose);
final s = await container.read(cacheSettingsProvider.future);
expect(s.capBytes, CacheSettings.defaults.capBytes);
expect(s.likedCapBytes, CacheSettings.defaults.likedCapBytes);
expect(s.rollingCapBytes, CacheSettings.defaults.rollingCapBytes);
expect(s.likedCapBytes, 5 * 1024 * 1024 * 1024);
expect(s.prefetchWindow, 5);
expect(s.cacheLikedTracks, true);
});
@@ -46,12 +46,14 @@ void main() {
await t.pumpAndSettle();
expect(find.text('Storage'), findsOneWidget);
expect(find.text('Cache size limit'), findsOneWidget);
expect(find.text('Liked cache limit'), findsOneWidget);
expect(find.text('Recently-played cache limit'), findsOneWidget);
expect(find.text('Pre-fetch ahead'), findsOneWidget);
expect(find.byKey(const Key('clear_cache_button')), findsOneWidget);
expect(find.byKey(const Key('sync_now_button')), findsOneWidget);
expect(find.byKey(const Key('cache_liked_toggle')), findsOneWidget);
expect(find.byKey(const Key('cap_selector')), findsOneWidget);
expect(find.byKey(const Key('liked_cap_selector')), findsOneWidget);
expect(find.byKey(const Key('rolling_cap_selector')), findsOneWidget);
expect(find.byKey(const Key('prefetch_selector')), findsOneWidget);
});