diff --git a/flutter_client/lib/cache/audio_cache_manager.dart b/flutter_client/lib/cache/audio_cache_manager.dart index 5c4afecb..a26aa665 100644 --- a/flutter_client/lib/cache/audio_cache_manager.dart +++ b/flutter_client/lib/cache/audio_cache_manager.dart @@ -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 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 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 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 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 touch(String trackId) async { + await (_db.update(_db.audioCacheIndex) + ..where((t) => t.trackId.equals(trackId))) + .write(AudioCacheIndexCompanion(lastPlayedAt: Value(DateTime.now()))); + } + Future 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 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 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(Set liked) async { + final rows = await _db.select(_db.audioCacheIndex).get(); + final indexed = {}; + 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 evictBuckets({ + required int likedCap, + required int rollingCap, + required Set 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 _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 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(); diff --git a/flutter_client/lib/cache/cache_settings_provider.dart b/flutter_client/lib/cache/cache_settings_provider.dart index 6a17e609..517ab195 100644 --- a/flutter_client/lib/cache/cache_settings_provider.dart +++ b/flutter_client/lib/cache/cache_settings_provider.dart @@ -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 { - 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 { @override Future 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 { ); } - Future setCapBytes(int bytes) async { - await _storage.write(key: _kCap, value: bytes.toString()); - state = AsyncData(state.value!.copyWith(capBytes: bytes)); + Future setLikedCapBytes(int bytes) async { + await _storage.write(key: _kLikedCap, value: bytes.toString()); + state = AsyncData(state.value!.copyWith(likedCapBytes: bytes)); + } + + Future setRollingCapBytes(int bytes) async { + await _storage.write(key: _kRollingCap, value: bytes.toString()); + state = AsyncData(state.value!.copyWith(rollingCapBytes: bytes)); } Future setPrefetchWindow(int n) async { diff --git a/flutter_client/lib/cache/db.dart b/flutter_client/lib/cache/db.dart index 26b79754..824adcb9 100644 --- a/flutter_client/lib/cache/db.dart +++ b/flutter_client/lib/cache/db.dart @@ -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()(); @override Set 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', + ); + } }, ); } diff --git a/flutter_client/lib/cache/prefetcher.dart b/flutter_client/lib/cache/prefetcher.dart index 785d05f6..2abeaf8e 100644 --- a/flutter_client/lib/cache/prefetcher.dart +++ b/flutter_client/lib/cache/prefetcher.dart @@ -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 {}; + await mgr.evictBuckets( + likedCap: settings.likedCapBytes, + rollingCap: settings.rollingCapBytes, + liked: liked, + ); } } diff --git a/flutter_client/lib/library/album_detail_screen.dart b/flutter_client/lib/library/album_detail_screen.dart index 8e11844e..9b628906 100644 --- a/flutter_client/lib/library/album_detail_screen.dart +++ b/flutter_client/lib/library/album_detail_screen.dart @@ -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), diff --git a/flutter_client/lib/playlists/playlist_detail_screen.dart b/flutter_client/lib/playlists/playlist_detail_screen.dart index b53cc7d2..ddb19d77 100644 --- a/flutter_client/lib/playlists/playlist_detail_screen.dart +++ b/flutter_client/lib/playlists/playlist_detail_screen.dart @@ -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); diff --git a/flutter_client/lib/settings/storage_section.dart b/flutter_client/lib/settings/storage_section.dart index f5488195..68e6b84c 100644 --- a/flutter_client/lib/settings/storage_section.dart +++ b/flutter_client/lib/settings/storage_section.dart @@ -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 { - int? _usageBytes; + BucketUsage? _usage; bool _syncing = false; @override @@ -25,10 +27,13 @@ class _StorageSectionState extends ConsumerState { _refreshUsage(); } + Set _likedSet() => + ref.read(likedIdsProvider).value?.tracks ?? const {}; + Future _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 { 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()!; @@ -65,17 +74,32 @@ class _StorageSectionState extends ConsumerState { 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 { ); } - 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 Function(int) setCap, + CacheSettings s, + FabledSwordTheme fs, + ) { + return Row(children: [ + Expanded(child: Text(label, style: TextStyle(color: fs.ash))), DropdownButton( - 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 { 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( key: const Key('prefetch_selector'), value: options.contains(s.prefetchWindow) ? s.prefetchWindow : 5, @@ -183,8 +231,8 @@ class _StorageSectionState extends ConsumerState { 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( diff --git a/flutter_client/test/cache/audio_cache_manager_test.dart b/flutter_client/test/cache/audio_cache_manager_test.dart index 669279f1..433f5ae1 100644 --- a/flutter_client/test/cache/audio_cache_manager_test.dart +++ b/flutter_client/test/cache/audio_cache_manager_test.dart @@ -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 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 { diff --git a/flutter_client/test/cache/cache_settings_provider_test.dart b/flutter_client/test/cache/cache_settings_provider_test.dart index 0df3681d..284800e4 100644 --- a/flutter_client/test/cache/cache_settings_provider_test.dart +++ b/flutter_client/test/cache/cache_settings_provider_test.dart @@ -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); }); diff --git a/flutter_client/test/settings/storage_section_test.dart b/flutter_client/test/settings/storage_section_test.dart index 816d2815..f4711bb9 100644 --- a/flutter_client/test/settings/storage_section_test.dart +++ b/flutter_client/test/settings/storage_section_test.dart @@ -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); });