Skip to content

Add Smart Cache: play-driven downloads with a size budget - #1244

Draft
skalthoff wants to merge 2 commits into
mainfrom
1154-smart-cache
Draft

Add Smart Cache: play-driven downloads with a size budget#1244
skalthoff wants to merge 2 commits into
mainfrom
1154-smart-cache

Conversation

@skalthoff

@skalthoff skalthoff commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

What is the change

This adds a smart cache: the user sets one size budget, tracks they actually listen to earn their way onto disk, and cold tracks make room for hot ones. Explicit downloads become pins that are never auto-evicted.

How it decides:

  • A track played past the existing 80% scrobble threshold is admitted into the cache and fetched (at download quality)
  • Each entry carries its recent play timestamps; hotness is an exponentially-decayed score (14-day half-life), so one formula covers both frequency and recency
  • When usage exceeds the budget, the coldest cached tracks are evicted down to a 90% watermark — never pins, never anything in the current play queue
  • Shrinking the budget below current usage is destructive, so it surfaces a confirmation ("removing N tracks frees X GB") instead of silently deleting

How it's built:

  • src/cache/core/ is a pure policy engine — no React/RN/nitro imports. One reducer, decide(ledger, event, now, context) → { ledger, effects }, returns commands (fetch/evict/confirm-eviction) instead of performing side effects, so every policy rule is unit-tested with plain objects
  • src/cache/service.ts is a small event pump that serializes events through the engine and executes effects against a storage adapter; the nitro DownloadManager sits behind that adapter interface
  • The ledger persists via the existing versioned-MMKV pattern (cache-ledger-storage, registered in STORAGE_SCHEMA_VERSIONS from day one)
  • On startup the ledger reconciles against disk truth: pre-existing downloads are adopted as pins, files that vanished are demoted, and fetching entries with no live task — the stuck-download state — are recovered

Download pipeline hardening that rode along:

  • onDownloadStateChange is now wired, so failures reach the ledger (with storage_full triggering an emergency eviction pass) instead of disappearing
  • onDownloadComplete upserts by track id instead of blind-appending (no more duplicate entries on re-download)
  • The old auto-download path (unbounded, no eviction, session-Set dedup) is removed; its setting migrates once into a default 4 GB budget so existing users keep the behavior they opted into, now bounded

UI: the Storage screen's download settings gain the Smart Cache toggle, budget presets (1–32 GB), a usage line (X of Y used · N cached · M downloaded), and the eviction confirmation card. Download quality settings are unchanged.

Testing: 5 new suites / 66 tests (jest/functional/Cache/) — scoring decay properties, eviction invariants (never evicts pins, never evicts the queue, deterministic plans, watermark hysteresis), reconciliation scenarios including stuck-state recovery, engine event scenarios, and the full service run against a fake adapter. Full app suite: 18 suites / 168 tests passing; tsc, ESLint, and Prettier clean.

Deliberate v1 scope (happy to adjust in review):

  • Budget presets only (no free-form slider); default on enable is 4 GB
  • Deleting a cached track allows re-admission on a future completed play ("not right now," not "never")
  • No unpin affordance yet — delete covers it; the engine already supports unpin when the UI wants it
  • Cached-vs-pinned badges on track rows are a follow-up

What does this address

Big library on the server, limited space on the device. Manually curating downloads doesn't scale past a few hundred tracks, and the previous auto-download grew without bound and never cleaned up after itself. This also fixes the class of stuck-download state that previously survived restarts, and replaces the "Unused in 30+ days" guess in storage cleanup with real per-track play history (lastPlayedAt is now actually tracked).

Issue number / link

Tag reviewers

@anultravioletaurora


CI note: run-jest-test-suite, build-bundle, and publish-ota-update are green. The maestro-tests failure is pre-existing — that workflow currently fails on every branch including main (instrumentation crash on the emulator; see the workflow's recent run history). Probably worth removing or repairing maestro-test.yml separately now that the Jest suite covers CI.

skalthoff and others added 2 commits June 9, 2026 23:27
Implements #1154. Tracks played past the scrobble threshold are admitted
into a user-budgeted cache; cold tracks are evicted to make room, and
explicit downloads become pins that are never auto-evicted.

- src/cache/core: pure policy engine (admission, recency-decayed scoring,
  watermark eviction, disk reconciliation) with no RN/native imports
- src/cache: ledger store (versioned MMKV), nitro storage adapter, and an
  event-pump service wiring player signals to the engine
- downloads pipeline: wire onDownloadStateChange, dedupe completion
  upserts, recover stuck 'fetching' state via startup reconciliation
- replace auto-download with cache admission; migrate the legacy setting
  to a default 4GB budget once
- Storage screen: smart cache toggle, budget presets, usage line, and a
  confirmation card before destructive budget shrinks
- tests: 5 new suites / 66 tests covering scoring, eviction invariants,
  reconciliation, engine scenarios, and the service against a fake adapter
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Smart cache instead of/along with manual download

2 participants