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 '../library/library_providers.dart' show dioProvider;
import 'db.dart'; 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. /// Owns the audio cache directory + drift index.
/// API: ///
/// - isCached(trackId) /// #427 S2: two storage buckets keyed by liked-ness, not by
/// - pathFor(trackId) /// CacheSource. A cached track currently in the user's liked set is
/// - pin(trackId, source) — downloads + indexes /// charged to (and evicted under) the Liked budget; everything else
/// - unpin(trackId) /// is Rolling. This dedup is storage/eviction-only — it never filters
/// - evict(targetBytes) — tiered LRU /// what the offline surfaces can play (S4). `CacheSource` is retained
/// - usageBytes() /// on the API for callers but is now informational.
/// - clearAll()
class AudioCacheManager { class AudioCacheManager {
AudioCacheManager({ AudioCacheManager({
required AppDb db, required AppDb db,
@@ -37,7 +42,6 @@ class AudioCacheManager {
return d.path; return d.path;
} }
/// True if the trackId has a complete file on disk.
Future<bool> isCached(String trackId) async { Future<bool> isCached(String trackId) async {
final row = await (_db.select(_db.audioCacheIndex) final row = await (_db.select(_db.audioCacheIndex)
..where((t) => t.trackId.equals(trackId))) ..where((t) => t.trackId.equals(trackId)))
@@ -46,7 +50,6 @@ class AudioCacheManager {
return File(row.path).existsSync(); return File(row.path).existsSync();
} }
/// Returns the local file path if cached, else null.
Future<String?> pathFor(String trackId) async { Future<String?> pathFor(String trackId) async {
final row = await (_db.select(_db.audioCacheIndex) final row = await (_db.select(_db.audioCacheIndex)
..where((t) => t.trackId.equals(trackId))) ..where((t) => t.trackId.equals(trackId)))
@@ -55,12 +58,14 @@ class AudioCacheManager {
return File(row.path).existsSync() ? row.path : null; return File(row.path).existsSync() ? row.path : null;
} }
/// Downloads the track's stream to disk and indexes it. Idempotent /// Downloads the track's stream to disk and indexes it. Idempotent.
/// returns the existing path if already cached. /// lastPlayedAt is set now — pinning is play-intent.
Future<String?> pin(String trackId, {required CacheSource source}) async { Future<String?> pin(String trackId, {required CacheSource source}) async {
final existing = await pathFor(trackId); final existing = await pathFor(trackId);
if (existing != null) return existing; if (existing != null) {
await touch(trackId);
return existing;
}
final dir = await _tracksDir(); final dir = await _tracksDir();
final path = '$dir/$trackId.mp3'; final path = '$dir/$trackId.mp3';
final dio = await _dioFactory(); final dio = await _dioFactory();
@@ -78,16 +83,15 @@ class AudioCacheManager {
path: path, path: path,
sizeBytes: size, sizeBytes: size,
source: source, source: source,
lastPlayedAt: Value(DateTime.now()),
), ),
); );
return path; return path;
} }
/// Registers a file already on disk in the cache index. Intended for /// Registers a file LockCachingAudioSource wrote itself. Called
/// the streaming path (LockCachingAudioSource) which writes the file /// once a track is fully buffered; it's being played, so stamp
/// itself; we need an index row so eviction can find and delete it /// lastPlayedAt.
/// when usage exceeds the cap. `source` defaults to incidental so
/// stream-cached tracks are first to be evicted.
Future<void> registerStreamCache( Future<void> registerStreamCache(
String trackId, String trackId,
String path, String path,
@@ -100,11 +104,20 @@ class AudioCacheManager {
path: path, path: path,
sizeBytes: sizeBytes, sizeBytes: sizeBytes,
source: source, 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 { Future<void> unpin(String trackId) async {
final row = await (_db.select(_db.audioCacheIndex) final row = await (_db.select(_db.audioCacheIndex)
..where((t) => t.trackId.equals(trackId))) ..where((t) => t.trackId.equals(trackId)))
@@ -117,66 +130,150 @@ class AudioCacheManager {
.go(); .go();
} }
/// Total bytes used by the cache. Walks the cache directory directly /// Total bytes on disk (directory walk — authoritative; catches
/// instead of summing the index, because the streaming-as-you-play /// orphan partials the index misses).
/// 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 { Future<int> usageBytes() async {
final dir = Directory(await _tracksDir()); final dir = Directory(await _tracksDir());
if (!await dir.exists()) return 0; if (!await dir.exists()) return 0;
var total = 0; var total = 0;
await for (final entity in dir.list(followLinks: false)) { await for (final e in dir.list(followLinks: false)) {
if (entity is File) { if (e is File) {
try { try {
total += await entity.length(); total += await e.length();
} catch (_) { } catch (_) {}
// Race against concurrent writes / deletes — just skip.
}
} }
} }
return total; return total;
} }
/// Evicts files until usage ≤ targetBytes. Eviction order: /// Bytes per bucket. Indexed rows split by liked-ness; on-disk
/// incidental → autoPrefetch → autoPlaylist → autoLiked. /// files with no index row (orphan partials) count as Rolling so
/// `manual` never evicts via this path; only clearAll() removes them. /// the rolling cap genuinely bounds disk.
Future<void> evict({required int targetBytes}) async { Future<BucketUsage> bucketUsage(Set<String> liked) async {
final used = await usageBytes(); final rows = await _db.select(_db.audioCacheIndex).get();
if (used <= targetBytes) return; final indexed = <String>{};
final order = [ var likedB = 0;
CacheSource.incidental, var rollingB = 0;
CacheSource.autoPrefetch, for (final r in rows) {
CacheSource.autoPlaylist, indexed.add(r.trackId);
CacheSource.autoLiked, if (liked.contains(r.trackId)) {
]; likedB += r.sizeBytes;
int remaining = used - targetBytes; } else {
for (final src in order) { rollingB += r.sizeBytes;
if (remaining <= 0) break; }
final rows = await (_db.select(_db.audioCacheIndex) }
..where((t) => t.source.equalsValue(src)) final dir = Directory(await _tracksDir());
..orderBy([(t) => OrderingTerm.asc(t.cachedAt)])) 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(); .get();
for (final row in rows) { for (final r in rolling) {
if (remaining <= 0) break; if (over <= 0) break;
final f = File(row.path); final f = File(r.path);
if (f.existsSync()) await f.delete(); if (f.existsSync()) await f.delete();
await (_db.delete(_db.audioCacheIndex) await (_db.delete(_db.audioCacheIndex)
..where((t) => t.trackId.equals(row.trackId))) ..where((t) => t.trackId.equals(r.trackId)))
.go(); .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" /// Deletes orphan files (on disk, no index row) oldest-mtime-first
/// button — `manual` source rows are removed here but only here. /// 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 { Future<void> clearAll() async {
final dir = await _tracksDir(); final dir = await _tracksDir();
final d = Directory(dir); final d = Directory(dir);
if (d.existsSync()) { if (d.existsSync()) {
for (final entity in d.listSync()) { for (final e in d.listSync()) {
if (entity is File) await entity.delete(); if (e is File) await e.delete();
} }
} }
await _db.delete(_db.audioCacheIndex).go(); 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; 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. /// 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 { class CacheSettings {
const CacheSettings({ const CacheSettings({
required this.capBytes, required this.likedCapBytes,
required this.rollingCapBytes,
required this.prefetchWindow, required this.prefetchWindow,
required this.cacheLikedTracks, required this.cacheLikedTracks,
}); });
/// 0 = unlimited. /// Liked-bucket budget. 0 = unlimited.
final int capBytes; final int likedCapBytes;
/// Rolling (recently-played) budget. 0 = unlimited.
final int rollingCapBytes;
/// 1..10. Number of next-tracks the prefetcher pre-downloads. /// 1..10. Number of next-tracks the prefetcher pre-downloads.
final int prefetchWindow; final int prefetchWindow;
@@ -22,25 +34,31 @@ class CacheSettings {
final bool cacheLikedTracks; final bool cacheLikedTracks;
CacheSettings copyWith({ CacheSettings copyWith({
int? capBytes, int? likedCapBytes,
int? rollingCapBytes,
int? prefetchWindow, int? prefetchWindow,
bool? cacheLikedTracks, bool? cacheLikedTracks,
}) => }) =>
CacheSettings( CacheSettings(
capBytes: capBytes ?? this.capBytes, likedCapBytes: likedCapBytes ?? this.likedCapBytes,
rollingCapBytes: rollingCapBytes ?? this.rollingCapBytes,
prefetchWindow: prefetchWindow ?? this.prefetchWindow, prefetchWindow: prefetchWindow ?? this.prefetchWindow,
cacheLikedTracks: cacheLikedTracks ?? this.cacheLikedTracks, cacheLikedTracks: cacheLikedTracks ?? this.cacheLikedTracks,
); );
static const _fiveGiB = 5 * 1024 * 1024 * 1024;
static const defaults = CacheSettings( static const defaults = CacheSettings(
capBytes: 5 * 1024 * 1024 * 1024, likedCapBytes: _fiveGiB,
rollingCapBytes: _fiveGiB,
prefetchWindow: 5, prefetchWindow: 5,
cacheLikedTracks: true, cacheLikedTracks: true,
); );
} }
class CacheSettingsController extends AsyncNotifier<CacheSettings> { 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 _kPrefetch = 'cache_prefetch_window';
static const _kCacheLiked = 'cache_liked_tracks'; static const _kCacheLiked = 'cache_liked_tracks';
@@ -49,13 +67,17 @@ class CacheSettingsController extends AsyncNotifier<CacheSettings> {
@override @override
Future<CacheSettings> build() async { Future<CacheSettings> build() async {
_storage = ref.read(secureStorageProvider); _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 pre = await _storage.read(key: _kPrefetch);
final liked = await _storage.read(key: _kCacheLiked); final liked = await _storage.read(key: _kCacheLiked);
return CacheSettings( return CacheSettings(
capBytes: cap == null likedCapBytes: likedCap == null
? CacheSettings.defaults.capBytes ? CacheSettings.defaults.likedCapBytes
: int.tryParse(cap) ?? CacheSettings.defaults.capBytes, : int.tryParse(likedCap) ?? CacheSettings.defaults.likedCapBytes,
rollingCapBytes: rollingCap == null
? CacheSettings.defaults.rollingCapBytes
: int.tryParse(rollingCap) ?? CacheSettings.defaults.rollingCapBytes,
prefetchWindow: pre == null prefetchWindow: pre == null
? CacheSettings.defaults.prefetchWindow ? CacheSettings.defaults.prefetchWindow
: (int.tryParse(pre) ?? 5).clamp(1, 10), : (int.tryParse(pre) ?? 5).clamp(1, 10),
@@ -65,9 +87,14 @@ class CacheSettingsController extends AsyncNotifier<CacheSettings> {
); );
} }
Future<void> setCapBytes(int bytes) async { Future<void> setLikedCapBytes(int bytes) async {
await _storage.write(key: _kCap, value: bytes.toString()); await _storage.write(key: _kLikedCap, value: bytes.toString());
state = AsyncData(state.value!.copyWith(capBytes: bytes)); 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 { Future<void> setPrefetchWindow(int n) async {
+17 -1
View File
@@ -101,6 +101,11 @@ class AudioCacheIndex extends Table {
TextColumn get path => text()(); TextColumn get path => text()();
IntColumn get sizeBytes => integer()(); IntColumn get sizeBytes => integer()();
DateTimeColumn get cachedAt => dateTime().withDefault(currentDateAndTime)(); 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>()(); TextColumn get source => textEnum<CacheSource>()();
@override @override
Set<Column> get primaryKey => {trackId}; Set<Column> get primaryKey => {trackId};
@@ -247,7 +252,7 @@ class AppDb extends _$AppDb {
AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache')); AppDb([QueryExecutor? e]) : super(e ?? driftDatabase(name: 'minstrel_cache'));
@override @override
int get schemaVersion => 8; int get schemaVersion => 9;
@override @override
MigrationStrategy get migration => MigrationStrategy( MigrationStrategy get migration => MigrationStrategy(
@@ -300,6 +305,17 @@ class AppDb extends _$AppDb {
// upgrade; populated by controllers when REST calls fail. // upgrade; populated by controllers when REST calls fail.
await m.createTable(cachedMutations); 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:audio_service/audio_service.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../likes/likes_provider.dart' show likedIdsProvider;
import '../player/album_color_extractor.dart'; import '../player/album_color_extractor.dart';
import '../player/player_provider.dart'; import '../player/player_provider.dart';
import 'audio_cache_manager.dart'; import 'audio_cache_manager.dart';
@@ -86,10 +87,17 @@ class Prefetcher {
} }
} }
// Eviction pass after pinning new files. // Eviction pass after pinning new files. Per-bucket (#427 S2):
if (settings.capBytes > 0) { // liked-ness decides the bucket, so a track that's both liked
await mgr.evict(targetBytes: settings.capBytes); // 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 '../api/endpoints/likes.dart';
import '../models/album.dart'; import '../models/album.dart';
import '../cache/audio_cache_manager.dart';
import '../cache/db.dart';
import '../likes/like_button.dart'; import '../likes/like_button.dart';
import '../player/player_provider.dart'; import '../player/player_provider.dart';
import '../shared/widgets/server_image.dart'; import '../shared/widgets/server_image.dart';
@@ -113,21 +111,6 @@ class AlbumDetailScreen extends ConsumerWidget {
Text(r.album.artistName, style: TextStyle(color: fs.ash)), 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( Container(
width: 48, height: 48, width: 48, height: 48,
decoration: BoxDecoration(color: fs.accent, shape: BoxShape.circle), 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:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../cache/audio_cache_manager.dart';
import '../cache/db.dart';
import '../models/playlist.dart'; import '../models/playlist.dart';
import '../models/track.dart'; import '../models/track.dart';
import '../player/player_provider.dart'; import '../player/player_provider.dart';
@@ -266,33 +264,15 @@ class _Header extends ConsumerWidget {
), ),
const Spacer(), const Spacer(),
if (playable.isNotEmpty) ...[ if (playable.isNotEmpty) ...[
if (p.refreshable) if (p.refreshable) ...[
OutlinedButton.icon( OutlinedButton.icon(
key: const Key('regenerate_playlist_button'), key: const Key('regenerate_playlist_button'),
onPressed: () => _regenerate(context, ref), onPressed: () => _regenerate(context, ref),
icon: const Icon(Icons.refresh, size: 16), icon: const Icon(Icons.refresh, size: 16),
label: const Text('Regenerate'), 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( FilledButton.icon(
onPressed: () { onPressed: () {
final refs = playable.map(_toTrackRef).toList(growable: false); 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/audio_cache_manager.dart';
import '../cache/cache_settings_provider.dart'; import '../cache/cache_settings_provider.dart';
import '../cache/sync_controller.dart'; import '../cache/sync_controller.dart';
import '../likes/likes_provider.dart' show likedIdsProvider;
import '../theme/theme_extension.dart'; import '../theme/theme_extension.dart';
/// Settings card: usage display, cap selector, prefetch window selector, /// Settings card: per-bucket usage, two cap selectors (Liked +
/// cache-liked toggle, Clear cache + Sync now buttons. /// Recently-played), prefetch window, cache-liked toggle, Clear
/// cache + Sync now. #427 S2/S3: two independent budgets.
class StorageSection extends ConsumerStatefulWidget { class StorageSection extends ConsumerStatefulWidget {
const StorageSection({super.key}); const StorageSection({super.key});
@@ -16,7 +18,7 @@ class StorageSection extends ConsumerStatefulWidget {
} }
class _StorageSectionState extends ConsumerState<StorageSection> { class _StorageSectionState extends ConsumerState<StorageSection> {
int? _usageBytes; BucketUsage? _usage;
bool _syncing = false; bool _syncing = false;
@override @override
@@ -25,10 +27,13 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
_refreshUsage(); _refreshUsage();
} }
Set<String> _likedSet() =>
ref.read(likedIdsProvider).value?.tracks ?? const <String>{};
Future<void> _refreshUsage() async { Future<void> _refreshUsage() async {
final mgr = ref.read(audioCacheManagerProvider); final mgr = ref.read(audioCacheManagerProvider);
final used = await mgr.usageBytes(); final u = await mgr.bucketUsage(_likedSet());
if (mounted) setState(() => _usageBytes = used); if (mounted) setState(() => _usage = u);
} }
String _fmtBytes(int? n) { String _fmtBytes(int? n) {
@@ -36,10 +41,14 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
if (n == 0) return '0 B'; if (n == 0) return '0 B';
if (n < 1024) return '$n B'; if (n < 1024) return '$n B';
if (n < 1024 * 1024) return '${(n / 1024).toStringAsFixed(1)} KB'; 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'; return '${(n / 1024 / 1024 / 1024).toStringAsFixed(2)} GB';
} }
String _cap(int bytes) => bytes == 0 ? 'unlimited' : _fmtBytes(bytes);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final fs = Theme.of(context).extension<FabledSwordTheme>()!; final fs = Theme.of(context).extension<FabledSwordTheme>()!;
@@ -65,17 +74,32 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w500)), fontWeight: FontWeight.w500)),
const SizedBox(height: 12), const SizedBox(height: 12),
Row(children: [ _usageRow('Liked', _usage?.liked, s.likedCapBytes, fs),
Text('Cache usage', style: TextStyle(color: fs.ash)), const SizedBox(height: 4),
const Spacer(), _usageRow('Recently played', _usage?.rolling,
Text( s.rollingCapBytes, fs),
'${_fmtBytes(_usageBytes)} / '
'${s.capBytes == 0 ? "unlimited" : _fmtBytes(s.capBytes)}',
style: TextStyle(color: fs.parchment),
),
]),
const SizedBox(height: 16), 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), const SizedBox(height: 8),
_prefetchSelector(s, fs), _prefetchSelector(s, fs),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -128,29 +152,52 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
); );
} }
Widget _capSelector(CacheSettings s, FabledSwordTheme fs) { Widget _usageRow(String label, int? used, int cap, 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'),
];
return Row(children: [ 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>( DropdownButton<int>(
key: const Key('cap_selector'), key: key,
value: options.any((o) => o.$1 == s.capBytes) value: _capOptions.any((o) => o.$1 == current)
? s.capBytes ? current
: 5 * 1024 * 1024 * 1024, : 5 * 1024 * 1024 * 1024,
items: options items: _capOptions
.map((o) => DropdownMenuItem(value: o.$1, child: Text(o.$2))) .map((o) => DropdownMenuItem(value: o.$1, child: Text(o.$2)))
.toList(), .toList(),
onChanged: (v) async { onChanged: (v) async {
if (v == null) return; if (v == null) return;
await ref.read(cacheSettingsProvider.notifier).setCapBytes(v); await setCap(v);
if (v > 0) { // Enforce immediately against the freshest settings.
await ref.read(audioCacheManagerProvider).evict(targetBytes: v); final fresh = ref.read(cacheSettingsProvider).value;
if (fresh != null) {
await ref.read(audioCacheManagerProvider).evictBuckets(
likedCap: fresh.likedCapBytes,
rollingCap: fresh.rollingCapBytes,
liked: _likedSet(),
);
} }
await _refreshUsage(); await _refreshUsage();
}, },
@@ -161,7 +208,8 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
Widget _prefetchSelector(CacheSettings s, FabledSwordTheme fs) { Widget _prefetchSelector(CacheSettings s, FabledSwordTheme fs) {
const options = [1, 3, 5, 7, 10]; const options = [1, 3, 5, 7, 10];
return Row(children: [ 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>( DropdownButton<int>(
key: const Key('prefetch_selector'), key: const Key('prefetch_selector'),
value: options.contains(s.prefetchWindow) ? s.prefetchWindow : 5, value: options.contains(s.prefetchWindow) ? s.prefetchWindow : 5,
@@ -183,8 +231,8 @@ class _StorageSectionState extends ConsumerState<StorageSection> {
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('Clear cache?'), title: const Text('Clear cache?'),
content: const Text( content: const Text(
'This deletes all cached audio files (including manually downloaded ones). ' 'This deletes all cached audio (liked and recently-played). '
'The next play of any track will re-download from the server.', 'The next play of any track re-downloads from the server.',
), ),
actions: [ actions: [
TextButton( TextButton(
+36 -17
View File
@@ -4,6 +4,7 @@ library;
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:drift/drift.dart' show Value;
import 'package:drift/native.dart' show NativeDatabase; import 'package:drift/native.dart' show NativeDatabase;
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -54,7 +55,8 @@ void main() {
expect(await mgr.usageBytes(), 350); 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(); final db = _testDb();
addTearDown(db.close); addTearDown(db.close);
final tmp = Directory.systemTemp.createTempSync(); final tmp = Directory.systemTemp.createTempSync();
@@ -63,30 +65,47 @@ void main() {
dioFactory: () async => Dio(), dioFactory: () async => Dio(),
cacheDirFactory: () async => tmp, cacheDirFactory: () async => tmp,
); );
final f1 = File('${tmp.path}/audio_cache/inc.mp3'); Future<void> mk(String id) async {
await f1.create(recursive: true); final f = File('${tmp.path}/audio_cache/$id.mp3');
await f1.writeAsBytes(List.filled(100, 0)); await f.create(recursive: true);
final f2 = File('${tmp.path}/audio_cache/man.mp3'); await f.writeAsBytes(List.filled(100, 0));
await f2.create(recursive: true); }
await f2.writeAsBytes(List.filled(100, 0)); await mk('old');
await mk('new');
await mk('lik');
await db.batch((b) { await db.batch((b) {
b.insertAll(db.audioCacheIndex, [ b.insertAll(db.audioCacheIndex, [
// 'old' has the older lastPlayedAt → evicted first.
AudioCacheIndexCompanion.insert( AudioCacheIndexCompanion.insert(
trackId: 'inc', trackId: 'old',
path: f1.path, path: '${tmp.path}/audio_cache/old.mp3',
sizeBytes: 100, sizeBytes: 100,
source: CacheSource.incidental), source: CacheSource.incidental,
lastPlayedAt: Value(DateTime(2020))),
AudioCacheIndexCompanion.insert( AudioCacheIndexCompanion.insert(
trackId: 'man', trackId: 'new',
path: f2.path, path: '${tmp.path}/audio_cache/new.mp3',
sizeBytes: 100, 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); final liked = {'lik'};
await mgr.evict(targetBytes: 100); final u = await mgr.bucketUsage(liked);
expect(await mgr.isCached('inc'), false); expect(u.liked, 100);
expect(await mgr.isCached('man'), true); 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 { test('clearAll removes everything including manual', skip: _skipDrift, () async {
@@ -27,7 +27,9 @@ void main() {
addTearDown(container.dispose); addTearDown(container.dispose);
final s = await container.read(cacheSettingsProvider.future); 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.prefetchWindow, 5);
expect(s.cacheLikedTracks, true); expect(s.cacheLikedTracks, true);
}); });
@@ -46,12 +46,14 @@ void main() {
await t.pumpAndSettle(); await t.pumpAndSettle();
expect(find.text('Storage'), findsOneWidget); 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.text('Pre-fetch ahead'), findsOneWidget);
expect(find.byKey(const Key('clear_cache_button')), findsOneWidget); expect(find.byKey(const Key('clear_cache_button')), findsOneWidget);
expect(find.byKey(const Key('sync_now_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('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); expect(find.byKey(const Key('prefetch_selector')), findsOneWidget);
}); });