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:
+155
-58
@@ -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
@@ -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 {
|
||||||
|
|||||||
Vendored
+17
-1
@@ -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
@@ -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
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user