diff --git a/index.js b/index.js index 4ff25bf29..95120e8a2 100644 --- a/index.js +++ b/index.js @@ -10,6 +10,7 @@ import { GLITCHTIP_DSN } from './src/configs/config' import * as Sentry from '@sentry/react-native' import registerNitroPlayer from './src/services/player' import configureDownloadManager from './src/services/downloads' +import { cacheService } from './src/cache/service' enableScreens(true) enableFreeze(true) @@ -25,6 +26,10 @@ Sentry.init({ registerNitroPlayer() configureDownloadManager() +// Migrate legacy auto-download users and reconcile the cache ledger against +// disk truth (adopts pre-ledger downloads, recovers stuck download state) +void cacheService.initialize() + // Lazy require the CarPlayService on iOS so react-native-carplay's native // module is never accessed on Android, as it's only linked for iOS in react-native.config.js if (Platform.OS === 'ios') { diff --git a/jest/functional/Cache/engine.test.ts b/jest/functional/Cache/engine.test.ts new file mode 100644 index 000000000..5dbf4b2d8 --- /dev/null +++ b/jest/functional/Cache/engine.test.ts @@ -0,0 +1,434 @@ +import { decide } from '../../../src/cache/core/engine' +import { CacheLedger, EMPTY_CONTEXT, EvictionPlan } from '../../../src/cache/core/types' +import { DAY, MB, NOW, contextWith, makeEntry, makeLedger } from './helpers' + +const GB = 1024 * MB + +describe('engine: admission via completed plays', () => { + it('does nothing for unknown tracks when the cache is disabled', () => { + const { ledger, effects } = decide( + makeLedger(null), + { type: 'play-completed', trackId: 'a' }, + NOW, + EMPTY_CONTEXT, + ) + + expect(ledger.entries['a']).toBeUndefined() + expect(effects).toEqual([]) + }) + + it('still records plays on existing entries when the cache is disabled', () => { + const initial = makeLedger(null, [ + makeEntry({ trackId: 'pin', origin: 'pinned', state: 'present' }), + ]) + + const { ledger, effects } = decide( + initial, + { type: 'play-completed', trackId: 'pin' }, + NOW, + EMPTY_CONTEXT, + ) + + expect(ledger.entries['pin'].lastPlayedAt).toBe(NOW) + expect(ledger.entries['pin'].playEvents).toEqual([NOW]) + expect(effects).toEqual([]) + }) + + it('admits an unknown track on a completed play and fetches it', () => { + const { ledger, effects } = decide( + makeLedger(4 * GB), + { type: 'play-completed', trackId: 'new' }, + NOW, + EMPTY_CONTEXT, + ) + + expect(ledger.entries['new']).toMatchObject({ + origin: 'cached', + state: 'fetching', + lastPlayedAt: NOW, + playEvents: [NOW], + }) + expect(effects).toEqual([{ type: 'fetch', trackId: 'new' }]) + }) + + it('re-fetches a hot-but-evicted (wanted) track on a completed play', () => { + const initial = makeLedger(4 * GB, [ + makeEntry({ + trackId: 'evicted', + state: 'wanted', + sizeBytes: 0, + playEvents: [NOW - DAY], + }), + ]) + + const { ledger, effects } = decide( + initial, + { type: 'play-completed', trackId: 'evicted' }, + NOW, + EMPTY_CONTEXT, + ) + + expect(ledger.entries['evicted'].state).toBe('fetching') + expect(effects).toEqual([{ type: 'fetch', trackId: 'evicted' }]) + }) + + it('does not re-fetch tracks that are already present, fetching, or failed', () => { + for (const state of ['present', 'fetching', 'failed'] as const) { + const initial = makeLedger(4 * GB, [makeEntry({ trackId: 'a', state })]) + + const { ledger, effects } = decide( + initial, + { type: 'play-completed', trackId: 'a' }, + NOW, + EMPTY_CONTEXT, + ) + + expect(ledger.entries['a'].state).toBe(state) + expect(effects).toEqual([]) + } + }) +}) + +describe('engine: play-started (eager fetch at ~30%)', () => { + it('fetches a wanted track without recording a play', () => { + const initial = makeLedger(4 * GB, [ + makeEntry({ trackId: 'wanted', state: 'wanted', sizeBytes: 0 }), + ]) + + const { ledger, effects } = decide( + initial, + { type: 'play-started', trackId: 'wanted' }, + NOW, + EMPTY_CONTEXT, + ) + + expect(ledger.entries['wanted'].state).toBe('fetching') + expect(ledger.entries['wanted'].playEvents).toEqual([]) + expect(effects).toEqual([{ type: 'fetch', trackId: 'wanted' }]) + }) + + it('does not admit unknown tracks', () => { + const { ledger, effects } = decide( + makeLedger(4 * GB), + { type: 'play-started', trackId: 'unknown' }, + NOW, + EMPTY_CONTEXT, + ) + + expect(ledger.entries['unknown']).toBeUndefined() + expect(effects).toEqual([]) + }) +}) + +describe('engine: fetch lifecycle and steady-state eviction', () => { + it('marks a fetched track present and silently evicts the coldest cached entries', () => { + // Budget 100MB, 95MB present; the incoming 10MB fetch tips it to 105MB. + // Target after eviction is 90MB → the coldest 20MB entry goes. + const initial = makeLedger(100 * MB, [ + makeEntry({ trackId: 'coldest', sizeBytes: 20 * MB, playEvents: [NOW - 90 * DAY] }), + makeEntry({ trackId: 'warm', sizeBytes: 35 * MB, playEvents: [NOW - 2 * DAY] }), + makeEntry({ trackId: 'pin', origin: 'pinned', sizeBytes: 40 * MB }), + makeEntry({ trackId: 'incoming', state: 'fetching', sizeBytes: 0, playEvents: [NOW] }), + ]) + + const { ledger, effects } = decide( + initial, + { type: 'fetch-succeeded', trackId: 'incoming', sizeBytes: 10 * MB }, + NOW, + EMPTY_CONTEXT, + ) + + expect(ledger.entries['incoming']).toMatchObject({ state: 'present', sizeBytes: 10 * MB }) + // Evicted and fully decayed → pruned from the ledger, not just demoted + expect(ledger.entries['coldest']).toBeUndefined() + expect(ledger.entries['pin'].state).toBe('present') + expect(effects).toEqual([{ type: 'evict', trackId: 'coldest' }]) + }) + + it('never evicts the current queue to make room', () => { + const initial = makeLedger(10 * MB, [ + makeEntry({ trackId: 'queued', sizeBytes: 10 * MB, playEvents: [NOW - 90 * DAY] }), + makeEntry({ trackId: 'incoming', state: 'fetching', sizeBytes: 0 }), + ]) + + const { effects } = decide( + initial, + { type: 'fetch-succeeded', trackId: 'incoming', sizeBytes: 5 * MB }, + NOW, + contextWith('queued', 'incoming'), + ) + + expect(effects).toEqual([]) + }) + + it('adopts an unknown completed download as pinned', () => { + const { ledger } = decide( + makeLedger(4 * GB), + { type: 'fetch-succeeded', trackId: 'legacy', sizeBytes: 5 * MB }, + NOW, + EMPTY_CONTEXT, + ) + + expect(ledger.entries['legacy']).toMatchObject({ + origin: 'pinned', + state: 'present', + sizeBytes: 5 * MB, + }) + }) + + it('returns retryable failures to wanted and parks permanent failures at failed', () => { + const retryable = decide( + makeLedger(4 * GB, [makeEntry({ trackId: 'a', state: 'fetching', sizeBytes: 0 })]), + { type: 'fetch-failed', trackId: 'a', retryable: true }, + NOW, + EMPTY_CONTEXT, + ) + expect(retryable.ledger.entries['a'].state).toBe('wanted') + + const permanent = decide( + makeLedger(4 * GB, [makeEntry({ trackId: 'b', state: 'fetching', sizeBytes: 0 })]), + { type: 'fetch-failed', trackId: 'b', retryable: false }, + NOW, + EMPTY_CONTEXT, + ) + expect(permanent.ledger.entries['b'].state).toBe('failed') + }) + + it('runs an emergency eviction when a fetch fails with a full disk while over budget', () => { + const initial = makeLedger(10 * MB, [ + makeEntry({ trackId: 'cold', sizeBytes: 20 * MB, playEvents: [NOW - 90 * DAY] }), + makeEntry({ trackId: 'incoming', state: 'fetching', sizeBytes: 0, playEvents: [NOW] }), + ]) + + const { ledger, effects } = decide( + initial, + { type: 'fetch-failed', trackId: 'incoming', retryable: true, storageFull: true }, + NOW, + EMPTY_CONTEXT, + ) + + expect(ledger.entries['incoming'].state).toBe('wanted') + expect(effects).toEqual([{ type: 'evict', trackId: 'cold' }]) + }) +}) + +describe('engine: pin / unpin / remove', () => { + it('pins an unknown track and fetches it', () => { + const { ledger, effects } = decide( + makeLedger(null), + { type: 'pin', trackId: 'album-track' }, + NOW, + EMPTY_CONTEXT, + ) + + expect(ledger.entries['album-track']).toMatchObject({ origin: 'pinned', state: 'fetching' }) + expect(effects).toEqual([{ type: 'fetch', trackId: 'album-track' }]) + }) + + it('promotes a cached entry to pinned without re-fetching when present', () => { + const initial = makeLedger(4 * GB, [makeEntry({ trackId: 'a', state: 'present' })]) + + const { ledger, effects } = decide( + initial, + { type: 'pin', trackId: 'a' }, + NOW, + EMPTY_CONTEXT, + ) + + expect(ledger.entries['a']).toMatchObject({ origin: 'pinned', state: 'present' }) + expect(effects).toEqual([]) + }) + + it('retries failed entries when pinned', () => { + const initial = makeLedger(4 * GB, [ + makeEntry({ trackId: 'a', origin: 'pinned', state: 'failed', sizeBytes: 0 }), + ]) + + const { ledger, effects } = decide( + initial, + { type: 'pin', trackId: 'a' }, + NOW, + EMPTY_CONTEXT, + ) + + expect(ledger.entries['a'].state).toBe('fetching') + expect(effects).toEqual([{ type: 'fetch', trackId: 'a' }]) + }) + + it('unpinning demotes to cached and evicts immediately if over budget', () => { + const initial = makeLedger(10 * MB, [ + makeEntry({ trackId: 'big-pin', origin: 'pinned', sizeBytes: 30 * MB }), + ]) + + const { ledger, effects } = decide( + initial, + { type: 'unpin', trackId: 'big-pin' }, + NOW, + EMPTY_CONTEXT, + ) + + // Never played → demoted, evicted, and pruned in one pass + expect(ledger.entries['big-pin']).toBeUndefined() + expect(effects).toEqual([{ type: 'evict', trackId: 'big-pin' }]) + }) + + it('remove drops the entry and always emits an idempotent evict', () => { + const initial = makeLedger(4 * GB, [makeEntry({ trackId: 'a' })]) + + const removed = decide(initial, { type: 'remove', trackId: 'a' }, NOW, EMPTY_CONTEXT) + expect(removed.ledger.entries['a']).toBeUndefined() + expect(removed.effects).toEqual([{ type: 'evict', trackId: 'a' }]) + + const unknown = decide( + makeLedger(4 * GB), + { type: 'remove', trackId: 'b' }, + NOW, + EMPTY_CONTEXT, + ) + expect(unknown.effects).toEqual([{ type: 'evict', trackId: 'b' }]) + }) +}) + +describe('engine: budget changes require confirmation', () => { + const overBudgetAfterShrink = (): CacheLedger => + makeLedger(100 * MB, [ + makeEntry({ trackId: 'cold', sizeBytes: 30 * MB, playEvents: [NOW - 90 * DAY] }), + makeEntry({ trackId: 'hot', sizeBytes: 30 * MB, playEvents: [NOW] }), + ]) + + it('surfaces a confirm-eviction effect without touching files or the ledger entries', () => { + const { ledger, effects } = decide( + overBudgetAfterShrink(), + { type: 'budget-changed', budgetBytes: 40 * MB }, + NOW, + EMPTY_CONTEXT, + ) + + expect(ledger.budgetBytes).toBe(40 * MB) + expect(ledger.entries['cold'].state).toBe('present') + expect(effects).toHaveLength(1) + expect(effects[0]).toMatchObject({ + type: 'confirm-eviction', + plan: { trackIds: ['cold'], freedBytes: 30 * MB, reason: 'budget-shrink' }, + }) + }) + + it('applies a confirmed plan and evicts', () => { + const shrunk = decide( + overBudgetAfterShrink(), + { type: 'budget-changed', budgetBytes: 40 * MB }, + NOW, + EMPTY_CONTEXT, + ) + const plan = (shrunk.effects[0] as { type: 'confirm-eviction'; plan: EvictionPlan }).plan + + const { ledger, effects } = decide( + shrunk.ledger, + { type: 'eviction-confirmed', plan }, + NOW, + EMPTY_CONTEXT, + ) + + // 90 days stale → demoted then pruned by the same pass + expect(ledger.entries['cold']).toBeUndefined() + expect(effects).toEqual([{ type: 'evict', trackId: 'cold' }]) + }) + + it('re-validates a stale confirmed plan instead of trusting it', () => { + const shrunk = decide( + overBudgetAfterShrink(), + { type: 'budget-changed', budgetBytes: 40 * MB }, + NOW, + EMPTY_CONTEXT, + ) + const plan = (shrunk.effects[0] as { type: 'confirm-eviction'; plan: EvictionPlan }).plan + + // The user pinned the planned track while the prompt was up. + const pinnedMeanwhile = decide( + shrunk.ledger, + { type: 'pin', trackId: 'cold' }, + NOW, + EMPTY_CONTEXT, + ) + + const { ledger, effects } = decide( + pinnedMeanwhile.ledger, + { type: 'eviction-confirmed', plan }, + NOW, + EMPTY_CONTEXT, + ) + + expect(ledger.entries['cold'].state).toBe('present') + expect(effects).toEqual([]) + }) + + it('raising the budget or disabling the cache evicts nothing', () => { + const raised = decide( + overBudgetAfterShrink(), + { type: 'budget-changed', budgetBytes: 1 * GB }, + NOW, + EMPTY_CONTEXT, + ) + expect(raised.effects).toEqual([]) + + const disabled = decide( + overBudgetAfterShrink(), + { type: 'budget-changed', budgetBytes: null }, + NOW, + EMPTY_CONTEXT, + ) + expect(disabled.ledger.budgetBytes).toBeNull() + expect(disabled.effects).toEqual([]) + }) +}) + +describe('engine: disk truth', () => { + it('reconciles and surfaces (not silently applies) any resulting overage', () => { + const initial = makeLedger(10 * MB, [ + makeEntry({ trackId: 'known', sizeBytes: 5 * MB, playEvents: [NOW - DAY] }), + ]) + + const { ledger, effects } = decide( + initial, + { + type: 'disk-truth', + snapshot: { + present: [ + { trackId: 'known', sizeBytes: 5 * MB }, + { trackId: 'adopted', sizeBytes: 20 * MB }, + ], + fetching: [], + }, + }, + NOW, + EMPTY_CONTEXT, + ) + + // The adopted orphan is pinned, so the only candidate is `known`. + expect(ledger.entries['adopted'].origin).toBe('pinned') + expect(effects).toHaveLength(1) + expect(effects[0]).toMatchObject({ + type: 'confirm-eviction', + plan: { trackIds: ['known'], reason: 'reconcile' }, + }) + }) + + it('is a no-op for a ledger that matches disk', () => { + const initial = makeLedger(100 * MB, [ + makeEntry({ trackId: 'a', sizeBytes: 5 * MB, playEvents: [NOW - DAY] }), + ]) + + const { ledger, effects } = decide( + initial, + { + type: 'disk-truth', + snapshot: { present: [{ trackId: 'a', sizeBytes: 5 * MB }], fetching: [] }, + }, + NOW, + EMPTY_CONTEXT, + ) + + expect(ledger.entries['a']).toMatchObject({ state: 'present', sizeBytes: 5 * MB }) + expect(effects).toEqual([]) + }) +}) diff --git a/jest/functional/Cache/eviction.test.ts b/jest/functional/Cache/eviction.test.ts new file mode 100644 index 000000000..e4043cf69 --- /dev/null +++ b/jest/functional/Cache/eviction.test.ts @@ -0,0 +1,178 @@ +import { + applyEviction, + planEviction, + pruneStale, + usedBytes, +} from '../../../src/cache/core/eviction' +import { EMPTY_CONTEXT } from '../../../src/cache/core/types' +import { DAY, MB, NOW, contextWith, makeEntry, makeLedger } from './helpers' + +describe('usedBytes', () => { + it('sums only present entries', () => { + const ledger = makeLedger(100 * MB, [ + makeEntry({ trackId: 'a', state: 'present', sizeBytes: 10 * MB }), + makeEntry({ trackId: 'b', state: 'wanted', sizeBytes: 0 }), + makeEntry({ trackId: 'c', state: 'fetching', sizeBytes: 0 }), + makeEntry({ trackId: 'd', state: 'present', sizeBytes: 5 * MB, origin: 'pinned' }), + ]) + + expect(usedBytes(ledger)).toBe(15 * MB) + }) +}) + +describe('planEviction', () => { + it('returns null when the cache is disabled', () => { + const ledger = makeLedger(null, [makeEntry({ trackId: 'a', sizeBytes: 50 * MB })]) + + expect(planEviction(ledger, NOW, EMPTY_CONTEXT, 'over-budget')).toBeNull() + }) + + it('returns null when usage is within budget', () => { + const ledger = makeLedger(100 * MB, [makeEntry({ trackId: 'a', sizeBytes: 90 * MB })]) + + expect(planEviction(ledger, NOW, EMPTY_CONTEXT, 'over-budget')).toBeNull() + }) + + it('never selects pinned entries, even when they are the only way under budget', () => { + const ledger = makeLedger(10 * MB, [ + makeEntry({ trackId: 'pin', origin: 'pinned', sizeBytes: 50 * MB }), + makeEntry({ trackId: 'cached', sizeBytes: 5 * MB }), + ]) + + const plan = planEviction(ledger, NOW, EMPTY_CONTEXT, 'over-budget') + + expect(plan?.trackIds).toEqual(['cached']) + }) + + it('never selects protected (queued) tracks', () => { + const ledger = makeLedger(10 * MB, [ + makeEntry({ trackId: 'queued', sizeBytes: 20 * MB, playEvents: [NOW - 90 * DAY] }), + makeEntry({ trackId: 'free', sizeBytes: 20 * MB, playEvents: [NOW] }), + ]) + + const plan = planEviction(ledger, NOW, contextWith('queued'), 'over-budget') + + expect(plan?.trackIds).toEqual(['free']) + }) + + it('evicts coldest first and stops at the low watermark', () => { + // Budget 100MB, usage 120MB → target is 90MB, so 30MB must go. + const ledger = makeLedger(100 * MB, [ + makeEntry({ trackId: 'coldest', sizeBytes: 20 * MB, playEvents: [NOW - 90 * DAY] }), + makeEntry({ trackId: 'cool', sizeBytes: 20 * MB, playEvents: [NOW - 30 * DAY] }), + makeEntry({ trackId: 'warm', sizeBytes: 40 * MB, playEvents: [NOW - 2 * DAY] }), + makeEntry({ trackId: 'hot', sizeBytes: 40 * MB, playEvents: [NOW] }), + ]) + + const plan = planEviction(ledger, NOW, EMPTY_CONTEXT, 'over-budget') + + expect(plan?.trackIds).toEqual(['coldest', 'cool']) + expect(plan?.freedBytes).toBe(40 * MB) + }) + + it('is deterministic for a fixed ledger and clock', () => { + const ledger = makeLedger(10 * MB, [ + makeEntry({ trackId: 'b', sizeBytes: 10 * MB }), + makeEntry({ trackId: 'a', sizeBytes: 10 * MB }), + makeEntry({ trackId: 'c', sizeBytes: 10 * MB }), + ]) + + const first = planEviction(ledger, NOW, EMPTY_CONTEXT, 'over-budget') + const second = planEviction(ledger, NOW, EMPTY_CONTEXT, 'over-budget') + + expect(first).toEqual(second) + expect(first?.trackIds).toEqual(['a', 'b', 'c']) + }) + + it('returns a partial plan when candidates run out before the watermark', () => { + const ledger = makeLedger(10 * MB, [ + makeEntry({ trackId: 'pin', origin: 'pinned', sizeBytes: 40 * MB }), + makeEntry({ trackId: 'cached', sizeBytes: 10 * MB }), + ]) + + const plan = planEviction(ledger, NOW, EMPTY_CONTEXT, 'over-budget') + + expect(plan?.trackIds).toEqual(['cached']) + expect(plan?.freedBytes).toBe(10 * MB) + }) + + it('returns null when only pins exceed the budget', () => { + const ledger = makeLedger(10 * MB, [ + makeEntry({ trackId: 'pin', origin: 'pinned', sizeBytes: 40 * MB }), + ]) + + expect(planEviction(ledger, NOW, EMPTY_CONTEXT, 'over-budget')).toBeNull() + }) + + it('carries the supplied reason', () => { + const ledger = makeLedger(10 * MB, [makeEntry({ trackId: 'a', sizeBytes: 20 * MB })]) + + expect(planEviction(ledger, NOW, EMPTY_CONTEXT, 'budget-shrink')?.reason).toBe( + 'budget-shrink', + ) + }) +}) + +describe('applyEviction', () => { + it('demotes evicted entries to wanted, preserving play history', () => { + const ledger = makeLedger(10 * MB, [ + makeEntry({ trackId: 'a', sizeBytes: 20 * MB, playEvents: [NOW - DAY] }), + ]) + + const next = applyEviction( + ledger, + { trackIds: ['a'], freedBytes: 20 * MB, reason: 'over-budget' }, + NOW, + ) + + expect(next.entries['a'].state).toBe('wanted') + expect(next.entries['a'].sizeBytes).toBe(0) + expect(next.entries['a'].playEvents).toEqual([NOW - DAY]) + }) + + it('ignores plan entries that are no longer cached and present', () => { + const ledger = makeLedger(10 * MB, [ + makeEntry({ trackId: 'pinned-since', origin: 'pinned', sizeBytes: 20 * MB }), + ]) + + const next = applyEviction( + ledger, + { trackIds: ['pinned-since', 'ghost'], freedBytes: 20 * MB, reason: 'over-budget' }, + NOW, + ) + + expect(next.entries['pinned-since'].state).toBe('present') + }) +}) + +describe('pruneStale', () => { + it('drops decayed non-resident cached entries but keeps fresh, resident, and pinned ones', () => { + const ledger = makeLedger(100 * MB, [ + makeEntry({ + trackId: 'decayed', + state: 'wanted', + sizeBytes: 0, + playEvents: [NOW - 200 * DAY], + }), + makeEntry({ trackId: 'fresh', state: 'wanted', sizeBytes: 0, playEvents: [NOW - DAY] }), + makeEntry({ trackId: 'on-disk', state: 'present', playEvents: [NOW - 200 * DAY] }), + makeEntry({ trackId: 'in-flight', state: 'fetching', sizeBytes: 0 }), + makeEntry({ + trackId: 'pinned-failed', + origin: 'pinned', + state: 'failed', + sizeBytes: 0, + playEvents: [NOW - 200 * DAY], + }), + ]) + + const next = pruneStale(ledger, NOW) + + expect(Object.keys(next.entries).sort()).toEqual([ + 'fresh', + 'in-flight', + 'on-disk', + 'pinned-failed', + ]) + }) +}) diff --git a/jest/functional/Cache/helpers.ts b/jest/functional/Cache/helpers.ts new file mode 100644 index 000000000..94bee9e7f --- /dev/null +++ b/jest/functional/Cache/helpers.ts @@ -0,0 +1,27 @@ +import { CacheEntry, CacheLedger, DecideContext } from '../../../src/cache/core/types' + +export const NOW = 1_750_000_000_000 +export const DAY = 24 * 60 * 60 * 1000 +export const MB = 1024 * 1024 + +export const makeEntry = (overrides: Partial & { trackId: string }): CacheEntry => ({ + origin: 'cached', + state: 'present', + sizeBytes: 10 * MB, + addedAt: NOW - 30 * DAY, + lastPlayedAt: null, + playEvents: [], + ...overrides, +}) + +export const makeLedger = ( + budgetBytes: number | null, + entries: CacheEntry[] = [], +): CacheLedger => ({ + budgetBytes, + entries: Object.fromEntries(entries.map((entry) => [entry.trackId, entry])), +}) + +export const contextWith = (...protectedTrackIds: string[]): DecideContext => ({ + protectedTrackIds: new Set(protectedTrackIds), +}) diff --git a/jest/functional/Cache/reconcile.test.ts b/jest/functional/Cache/reconcile.test.ts new file mode 100644 index 000000000..703cb40b8 --- /dev/null +++ b/jest/functional/Cache/reconcile.test.ts @@ -0,0 +1,130 @@ +import { reconcile } from '../../../src/cache/core/reconcile' +import { DAY, MB, NOW, makeEntry, makeLedger } from './helpers' + +describe('reconcile', () => { + it('adopts unknown disk tracks as pinned and present', () => { + const ledger = makeLedger(100 * MB) + + const next = reconcile( + ledger, + { present: [{ trackId: 'orphan', sizeBytes: 8 * MB }], fetching: [] }, + NOW, + ) + + expect(next.entries['orphan']).toMatchObject({ + origin: 'pinned', + state: 'present', + sizeBytes: 8 * MB, + addedAt: NOW, + }) + }) + + it('demotes a cached entry whose file vanished to wanted', () => { + const ledger = makeLedger(100 * MB, [ + makeEntry({ + trackId: 'gone', + state: 'present', + sizeBytes: 5 * MB, + playEvents: [NOW - DAY], + }), + ]) + + const next = reconcile(ledger, { present: [], fetching: [] }, NOW) + + expect(next.entries['gone']).toMatchObject({ state: 'wanted', sizeBytes: 0 }) + expect(next.entries['gone'].playEvents).toEqual([NOW - DAY]) + }) + + it('marks a pinned entry whose file vanished as failed', () => { + const ledger = makeLedger(100 * MB, [ + makeEntry({ + trackId: 'gone-pin', + origin: 'pinned', + state: 'present', + sizeBytes: 5 * MB, + }), + ]) + + const next = reconcile(ledger, { present: [], fetching: [] }, NOW) + + expect(next.entries['gone-pin']).toMatchObject({ state: 'failed', sizeBytes: 0 }) + }) + + it('recovers stuck fetching entries with no active task (the #817 case)', () => { + const ledger = makeLedger(100 * MB, [ + makeEntry({ + trackId: 'stuck', + state: 'fetching', + sizeBytes: 0, + playEvents: [NOW - DAY], + }), + makeEntry({ + trackId: 'live', + state: 'fetching', + sizeBytes: 0, + playEvents: [NOW - DAY], + }), + ]) + + const next = reconcile(ledger, { present: [], fetching: ['live'] }, NOW) + + expect(next.entries['stuck'].state).toBe('wanted') + expect(next.entries['live'].state).toBe('fetching') + }) + + it('refreshes sizes from disk and promotes ledger entries found on disk', () => { + const ledger = makeLedger(100 * MB, [ + makeEntry({ trackId: 'resized', state: 'present', sizeBytes: 5 * MB }), + makeEntry({ trackId: 'landed', state: 'fetching', sizeBytes: 0 }), + ]) + + const next = reconcile( + ledger, + { + present: [ + { trackId: 'resized', sizeBytes: 7 * MB }, + { trackId: 'landed', sizeBytes: 3 * MB }, + ], + fetching: [], + }, + NOW, + ) + + expect(next.entries['resized']).toMatchObject({ state: 'present', sizeBytes: 7 * MB }) + expect(next.entries['landed']).toMatchObject({ state: 'present', sizeBytes: 3 * MB }) + }) + + it('prunes decayed non-resident cached entries during reconciliation', () => { + const ledger = makeLedger(100 * MB, [ + makeEntry({ + trackId: 'ancient', + state: 'wanted', + sizeBytes: 0, + playEvents: [NOW - 300 * DAY], + }), + ]) + + const next = reconcile(ledger, { present: [], fetching: [] }, NOW) + + expect(next.entries['ancient']).toBeUndefined() + }) + + it('preserves play history when adopting a known entry from disk', () => { + const ledger = makeLedger(100 * MB, [ + makeEntry({ + trackId: 'known', + state: 'present', + sizeBytes: 5 * MB, + playEvents: [NOW - DAY], + }), + ]) + + const next = reconcile( + ledger, + { present: [{ trackId: 'known', sizeBytes: 5 * MB }], fetching: [] }, + NOW, + ) + + expect(next.entries['known']).toBe(ledger.entries['known']) + }) +}) diff --git a/jest/functional/Cache/scoring.test.ts b/jest/functional/Cache/scoring.test.ts new file mode 100644 index 000000000..188487ce9 --- /dev/null +++ b/jest/functional/Cache/scoring.test.ts @@ -0,0 +1,116 @@ +import { compareByColdness, recordPlay, scoreEntry } from '../../../src/cache/core/scoring' +import { CACHE_SCORE_HALF_LIFE_MS } from '../../../src/cache/core/types' +import { DAY, NOW, makeEntry } from './helpers' + +describe('scoreEntry', () => { + it('returns 0 for an entry with no plays', () => { + expect(scoreEntry(makeEntry({ trackId: 'a' }), NOW)).toBe(0) + }) + + it('scores a just-played track at ~1 per play', () => { + const entry = makeEntry({ trackId: 'a', playEvents: [NOW] }) + + expect(scoreEntry(entry, NOW)).toBeCloseTo(1, 5) + }) + + it('halves a play contribution after one half-life', () => { + const entry = makeEntry({ trackId: 'a', playEvents: [NOW - CACHE_SCORE_HALF_LIFE_MS] }) + + expect(scoreEntry(entry, NOW)).toBeCloseTo(0.5, 5) + }) + + it('sums contributions across plays', () => { + const entry = makeEntry({ + trackId: 'a', + playEvents: [NOW, NOW - CACHE_SCORE_HALF_LIFE_MS], + }) + + expect(scoreEntry(entry, NOW)).toBeCloseTo(1.5, 5) + }) + + it('decays monotonically as time passes', () => { + const entry = makeEntry({ trackId: 'a', playEvents: [NOW - DAY, NOW - 2 * DAY] }) + + const early = scoreEntry(entry, NOW) + const later = scoreEntry(entry, NOW + 30 * DAY) + const muchLater = scoreEntry(entry, NOW + 90 * DAY) + + expect(early).toBeGreaterThan(later) + expect(later).toBeGreaterThan(muchLater) + }) + + it('ranks recent-but-few plays above frequent-but-stale plays', () => { + const playedTwiceThisWeek = makeEntry({ + trackId: 'fresh', + playEvents: [NOW - DAY, NOW - 3 * DAY], + }) + const playedTenTimesLastQuarter = makeEntry({ + trackId: 'stale', + playEvents: Array.from({ length: 10 }, (_, i) => NOW - (80 + i) * DAY), + }) + + expect(scoreEntry(playedTwiceThisWeek, NOW)).toBeGreaterThan( + scoreEntry(playedTenTimesLastQuarter, NOW), + ) + }) + + it('clamps future timestamps instead of inflating the score', () => { + const entry = makeEntry({ trackId: 'a', playEvents: [NOW + 5 * DAY] }) + + expect(scoreEntry(entry, NOW)).toBeCloseTo(1, 5) + }) +}) + +describe('compareByColdness', () => { + it('orders lower-scored entries first', () => { + const cold = makeEntry({ trackId: 'cold', playEvents: [NOW - 60 * DAY] }) + const hot = makeEntry({ trackId: 'hot', playEvents: [NOW] }) + + expect(compareByColdness(cold, hot, NOW)).toBeLessThan(0) + expect(compareByColdness(hot, cold, NOW)).toBeGreaterThan(0) + }) + + it('breaks score ties by least-recently-played', () => { + const playedLongAgo = makeEntry({ trackId: 'a', lastPlayedAt: NOW - 10 * DAY }) + const playedRecently = makeEntry({ trackId: 'b', lastPlayedAt: NOW - DAY }) + + expect(compareByColdness(playedLongAgo, playedRecently, NOW)).toBeLessThan(0) + }) + + it('breaks remaining ties by oldest entry, then track id, deterministically', () => { + const older = makeEntry({ trackId: 'b', addedAt: NOW - 50 * DAY }) + const newer = makeEntry({ trackId: 'a', addedAt: NOW - 5 * DAY }) + + expect(compareByColdness(older, newer, NOW)).toBeLessThan(0) + + const twinA = makeEntry({ trackId: 'a' }) + const twinB = makeEntry({ trackId: 'b' }) + + expect(compareByColdness(twinA, twinB, NOW)).toBeLessThan(0) + expect(compareByColdness(twinB, twinA, NOW)).toBeGreaterThan(0) + expect(compareByColdness(twinA, twinA, NOW)).toBe(0) + }) +}) + +describe('recordPlay', () => { + it('appends the play and updates lastPlayedAt without mutating', () => { + const entry = makeEntry({ trackId: 'a', playEvents: [NOW - DAY] }) + + const updated = recordPlay(entry, NOW, 20) + + expect(updated.playEvents).toEqual([NOW - DAY, NOW]) + expect(updated.lastPlayedAt).toBe(NOW) + expect(entry.playEvents).toEqual([NOW - DAY]) + }) + + it('caps stored plays, dropping the oldest first', () => { + const entry = makeEntry({ + trackId: 'a', + playEvents: Array.from({ length: 5 }, (_, i) => NOW - (5 - i) * DAY), + }) + + const updated = recordPlay(entry, NOW, 3) + + expect(updated.playEvents).toEqual([NOW - 2 * DAY, NOW - DAY, NOW]) + }) +}) diff --git a/jest/functional/Cache/service.test.ts b/jest/functional/Cache/service.test.ts new file mode 100644 index 000000000..a141aa5b7 --- /dev/null +++ b/jest/functional/Cache/service.test.ts @@ -0,0 +1,330 @@ +import { TrackItem } from 'react-native-nitro-player' +import { useCacheStore } from '../../../src/cache/adapters/ledger-store' +import { CacheStorageAdapter } from '../../../src/cache/adapters/storage-adapter' +import { DiskSnapshot, EMPTY_LEDGER } from '../../../src/cache/core/types' +import { DEFAULT_CACHE_BUDGET_BYTES, createCacheService } from '../../../src/cache/service' +import { queryClient } from '../../../src/constants/query-client' +import { useUsageSettingsStore } from '../../../src/stores/settings/usage' +import { MB } from './helpers' + +const makeTrack = (id: string): TrackItem => ({ + id, + title: `Track ${id}`, + artist: 'Artist', + album: 'Album', + duration: 180, + url: '', +}) + +type FakeAdapter = CacheStorageAdapter & { + fetched: string[] + evicted: string[] + failNextFetch: boolean +} + +const makeFakeAdapter = (snapshot: DiskSnapshot = { present: [], fetching: [] }): FakeAdapter => { + const adapter: FakeAdapter = { + fetched: [], + evicted: [], + failNextFetch: false, + + async fetch(track: TrackItem) { + if (adapter.failNextFetch) { + adapter.failNextFetch = false + throw new Error('network down') + } + adapter.fetched.push(track.id) + }, + async evict(trackId: string) { + adapter.evicted.push(trackId) + }, + async snapshot() { + return snapshot + }, + } + + return adapter +} + +// resolveTrackUrls (used only by the real nitro adapter) pulls in the API +// layer; the fake adapter keeps these tests entirely in-memory. +jest.mock('../../../src/utils/fetching/track-media-info', () => ({ + __esModule: true, + default: jest.fn(async (tracks: TrackItem[]) => tracks), +})) + +describe('cacheService', () => { + beforeEach(() => { + useCacheStore.setState({ + ledger: EMPTY_LEDGER, + pendingEvictionPlan: null, + legacyAutoDownloadMigrated: false, + }) + useUsageSettingsStore.setState({ autoDownload: false }) + }) + + afterAll(() => { + // pinTracks seeds the shared query client; drop the cached query so its + // gc timer doesn't hold the test process open + queryClient.clear() + }) + + it('admits a completed play, fetches it, and marks it present on completion', async () => { + const adapter = makeFakeAdapter() + const service = createCacheService(adapter) + + await service.setBudget(1024 * MB) + + service.notifyPlayCompleted(makeTrack('song')) + await service.flush() + + expect(adapter.fetched).toEqual(['song']) + expect(useCacheStore.getState().ledger.entries['song']).toMatchObject({ + origin: 'cached', + state: 'fetching', + }) + + service.notifyDownloadCompleted({ + trackId: 'song', + originalTrack: makeTrack('song'), + localPath: '/tmp/song.flac', + downloadedAt: Date.now(), + fileSize: 8 * MB, + storageLocation: 'private', + }) + await service.flush() + + expect(useCacheStore.getState().ledger.entries['song']).toMatchObject({ + state: 'present', + sizeBytes: 8 * MB, + }) + }) + + it('ignores plays entirely while the cache is disabled', async () => { + const adapter = makeFakeAdapter() + const service = createCacheService(adapter) + + service.notifyPlayCompleted(makeTrack('song')) + await service.flush() + + expect(adapter.fetched).toEqual([]) + expect(useCacheStore.getState().ledger.entries['song']).toBeUndefined() + }) + + it('returns a track to wanted when its fetch cannot be started', async () => { + const adapter = makeFakeAdapter() + const service = createCacheService(adapter) + + await service.setBudget(1024 * MB) + adapter.failNextFetch = true + + service.notifyPlayCompleted(makeTrack('song')) + await service.flush() + // The failure dispatches a follow-up event onto the pump + await service.flush() + + expect(useCacheStore.getState().ledger.entries['song'].state).toBe('wanted') + }) + + it('evicts the coldest cached track when a completed fetch tips the budget', async () => { + const adapter = makeFakeAdapter() + const service = createCacheService(adapter) + + await service.setBudget(10 * MB) + + // A cold cached resident occupying most of the budget + useCacheStore.setState((state) => ({ + ledger: { + ...state.ledger, + entries: { + cold: { + trackId: 'cold', + origin: 'cached', + state: 'present', + sizeBytes: 8 * MB, + addedAt: Date.now() - 1000, + lastPlayedAt: Date.now() - 1000, + playEvents: [], + }, + }, + }, + })) + + service.notifyPlayCompleted(makeTrack('hot')) + await service.flush() + service.notifyDownloadCompleted({ + trackId: 'hot', + originalTrack: makeTrack('hot'), + localPath: '/tmp/hot.flac', + downloadedAt: Date.now(), + fileSize: 8 * MB, + storageLocation: 'private', + }) + await service.flush() + + expect(adapter.evicted).toEqual(['cold']) + expect(useCacheStore.getState().ledger.entries['hot'].state).toBe('present') + }) + + it('pins tracks explicitly and never evicts them to make room', async () => { + const adapter = makeFakeAdapter() + const service = createCacheService(adapter) + + await service.pinTracks([{ Id: 'pinned-song', Name: 'Pinned' }]) + + expect(adapter.fetched).toEqual(['pinned-song']) + expect(useCacheStore.getState().ledger.entries['pinned-song']).toMatchObject({ + origin: 'pinned', + state: 'fetching', + }) + }) + + it('removes tracks from ledger and disk', async () => { + const adapter = makeFakeAdapter() + const service = createCacheService(adapter) + + await service.pinTracks([{ Id: 'song', Name: 'Song' }]) + await service.removeTracks(['song']) + + expect(adapter.evicted).toEqual(['song']) + expect(useCacheStore.getState().ledger.entries['song']).toBeUndefined() + }) + + it('surfaces a budget-shrink plan and only evicts after confirmation', async () => { + const adapter = makeFakeAdapter() + const service = createCacheService(adapter) + + await service.setBudget(100 * MB) + useCacheStore.setState((state) => ({ + ledger: { + ...state.ledger, + entries: { + resident: { + trackId: 'resident', + origin: 'cached', + state: 'present', + sizeBytes: 50 * MB, + addedAt: Date.now(), + lastPlayedAt: Date.now(), + playEvents: [Date.now()], + }, + }, + }, + })) + + await service.setBudget(10 * MB) + + const plan = useCacheStore.getState().pendingEvictionPlan + expect(plan).toMatchObject({ reason: 'budget-shrink', trackIds: ['resident'] }) + expect(adapter.evicted).toEqual([]) + + await service.confirmPendingEviction() + + expect(adapter.evicted).toEqual(['resident']) + expect(useCacheStore.getState().pendingEvictionPlan).toBeNull() + }) + + it('dismissing a surfaced plan keeps everything on disk', async () => { + const adapter = makeFakeAdapter() + const service = createCacheService(adapter) + + await service.setBudget(100 * MB) + useCacheStore.setState((state) => ({ + ledger: { + ...state.ledger, + entries: { + resident: { + trackId: 'resident', + origin: 'cached', + state: 'present', + sizeBytes: 50 * MB, + addedAt: Date.now(), + lastPlayedAt: Date.now(), + playEvents: [Date.now()], + }, + }, + }, + })) + await service.setBudget(10 * MB) + + service.dismissPendingEviction() + await service.flush() + + expect(adapter.evicted).toEqual([]) + expect(useCacheStore.getState().pendingEvictionPlan).toBeNull() + expect(useCacheStore.getState().ledger.entries['resident'].state).toBe('present') + }) + + it('initialize adopts pre-ledger downloads from disk as pins', async () => { + const adapter = makeFakeAdapter({ + present: [{ trackId: 'legacy-download', sizeBytes: 12 * MB }], + fetching: [], + }) + const service = createCacheService(adapter) + + await service.initialize() + await service.flush() + + expect(useCacheStore.getState().ledger.entries['legacy-download']).toMatchObject({ + origin: 'pinned', + state: 'present', + sizeBytes: 12 * MB, + }) + }) + + it('initialize migrates a legacy auto-download user to a default budget, once', async () => { + useUsageSettingsStore.setState({ autoDownload: true }) + + const adapter = makeFakeAdapter() + const service = createCacheService(adapter) + + await service.initialize() + await service.flush() + + expect(useCacheStore.getState().ledger.budgetBytes).toBe(DEFAULT_CACHE_BUDGET_BYTES) + expect(useCacheStore.getState().legacyAutoDownloadMigrated).toBe(true) + + // Disabling afterwards must stick across restarts + await service.setBudget(null) + await service.initialize() + await service.flush() + + expect(useCacheStore.getState().ledger.budgetBytes).toBeNull() + }) + + it('eagerly re-fetches a wanted track once playback passes the started threshold', async () => { + const adapter = makeFakeAdapter() + const service = createCacheService(adapter) + + await service.setBudget(1024 * MB) + useCacheStore.setState((state) => ({ + ledger: { + ...state.ledger, + entries: { + evicted: { + trackId: 'evicted', + origin: 'cached', + state: 'wanted', + sizeBytes: 0, + addedAt: Date.now(), + lastPlayedAt: Date.now(), + playEvents: [Date.now()], + }, + }, + }, + })) + + const track = makeTrack('evicted') + // Below the threshold: nothing happens + service.notifyPlaybackProgress(10, 180, track) + await service.flush() + expect(adapter.fetched).toEqual([]) + + // Past the threshold: fetched exactly once, even across repeated ticks + service.notifyPlaybackProgress(120, 180, track) + service.notifyPlaybackProgress(121, 180, track) + await service.flush() + + expect(adapter.fetched).toEqual(['evicted']) + }) +}) diff --git a/jest/functional/Player/track-media-info.test.ts b/jest/functional/Player/track-media-info.test.ts index ec1a9672f..4e03d3012 100644 --- a/jest/functional/Player/track-media-info.test.ts +++ b/jest/functional/Player/track-media-info.test.ts @@ -62,9 +62,11 @@ jest.mock('../../../src/utils/audio/normalization', () => ({ default: jest.fn().mockResolvedValue(undefined), resetPlayerVolume: jest.fn().mockResolvedValue(undefined), })) -jest.mock('../../../src/services/player/utils/auto-download', () => ({ - __esModule: true, - default: jest.fn().mockResolvedValue(undefined), +jest.mock('../../../src/cache/service', () => ({ + cacheService: { + notifyPlaybackProgress: jest.fn(), + notifyPlayCompleted: jest.fn(), + }, })) // ─── helpers ──────────────────────────────────────────────────────────────── diff --git a/jest/setup/nitro-player.ts b/jest/setup/nitro-player.ts index a7159504e..3e3046396 100644 --- a/jest/setup/nitro-player.ts +++ b/jest/setup/nitro-player.ts @@ -37,8 +37,10 @@ jest.mock('react-native-nitro-player', () => ({ getDownloadedTrack: jest.fn().mockResolvedValue(null), getDownloadedTracks: jest.fn().mockResolvedValue([]), getAllDownloadedTracks: jest.fn().mockResolvedValue([]), + getActiveDownloads: jest.fn().mockReturnValue([]), isTrackDownloaded: jest.fn().mockReturnValue(false), onDownloadComplete: jest.fn(), + onDownloadStateChange: jest.fn(), }, RepeatMode: { None: 'none', diff --git a/src/cache/adapters/ledger-store.ts b/src/cache/adapters/ledger-store.ts new file mode 100644 index 000000000..b91808f08 --- /dev/null +++ b/src/cache/adapters/ledger-store.ts @@ -0,0 +1,50 @@ +import { create } from 'zustand' +import { createJSONStorage, devtools, persist } from 'zustand/middleware' +import { createVersionedMmkvStorage } from '../../constants/versioned-storage' +import { CacheLedger, EMPTY_LEDGER, EvictionPlan } from '../core/types' + +type CacheStore = { + /** The smart cache ledger — the engine's entire persisted state */ + ledger: CacheLedger + setLedger: (ledger: CacheLedger) => void + + /** + * An eviction plan awaiting user confirmation (budget shrink or + * reconcile-discovered overage). Not persisted — a stale prompt after an + * app restart would describe a ledger that no longer exists. + */ + pendingEvictionPlan: EvictionPlan | null + setPendingEvictionPlan: (pendingEvictionPlan: EvictionPlan | null) => void + + /** One-shot migration marker for the legacy auto-download setting */ + legacyAutoDownloadMigrated: boolean + setLegacyAutoDownloadMigrated: (legacyAutoDownloadMigrated: boolean) => void +} + +export const useCacheStore = create()( + devtools( + persist( + (set) => ({ + ledger: EMPTY_LEDGER, + setLedger: (ledger) => set({ ledger }), + + pendingEvictionPlan: null, + setPendingEvictionPlan: (pendingEvictionPlan) => set({ pendingEvictionPlan }), + + legacyAutoDownloadMigrated: false, + setLegacyAutoDownloadMigrated: (legacyAutoDownloadMigrated) => + set({ legacyAutoDownloadMigrated }), + }), + { + name: 'cache-ledger-storage', + storage: createJSONStorage(() => + createVersionedMmkvStorage('cache-ledger-storage'), + ), + partialize: (state) => ({ + ledger: state.ledger, + legacyAutoDownloadMigrated: state.legacyAutoDownloadMigrated, + }), + }, + ), + ), +) diff --git a/src/cache/adapters/storage-adapter.ts b/src/cache/adapters/storage-adapter.ts new file mode 100644 index 000000000..dca46d5d3 --- /dev/null +++ b/src/cache/adapters/storage-adapter.ts @@ -0,0 +1,52 @@ +import { DownloadManager, DownloadedTrack, TrackItem } from 'react-native-nitro-player' +import { queryClient } from '../../constants/query-client' +import ALL_DOWNLOADS_KEY from '../../hooks/downloads/keys' +import resolveTrackUrls from '../../utils/fetching/track-media-info' +import { DiskSnapshot } from '../core/types' + +/** + * The storage boundary the cache engine's effects are executed against. + * Kept as an interface so service tests can run against a fake without + * mocking native modules. + */ +export type CacheStorageAdapter = { + /** Resolve a download-quality URL for the track and start the download */ + fetch: (track: TrackItem) => Promise + /** Delete a track's files; idempotent for tracks that aren't on disk */ + evict: (trackId: string) => Promise + /** Disk truth: everything downloaded plus everything in flight */ + snapshot: () => Promise +} + +/** In-flight download states reported by the nitro DownloadManager */ +const ACTIVE_DOWNLOAD_STATES = ['pending', 'downloading', 'paused'] + +export const nitroStorageAdapter: CacheStorageAdapter = { + async fetch(track: TrackItem) { + const [resolved] = await resolveTrackUrls([track], 'download') + await DownloadManager.downloadTrack(resolved) + }, + + async evict(trackId: string) { + await DownloadManager.deleteDownloadedTrack(trackId) + + queryClient.setQueryData(ALL_DOWNLOADS_KEY, (oldData: DownloadedTrack[] | undefined) => + oldData ? oldData.filter((download) => download.trackId !== trackId) : [], + ) + }, + + async snapshot() { + const downloaded = await DownloadManager.getAllDownloadedTracks() + const active = DownloadManager.getActiveDownloads() + + return { + present: downloaded.map((download) => ({ + trackId: download.trackId, + sizeBytes: download.fileSize ?? 0, + })), + fetching: active + .filter((task) => ACTIVE_DOWNLOAD_STATES.includes(task.state)) + .map((task) => task.trackId), + } + }, +} diff --git a/src/cache/core/admission.ts b/src/cache/core/admission.ts new file mode 100644 index 000000000..322cb0294 --- /dev/null +++ b/src/cache/core/admission.ts @@ -0,0 +1,27 @@ +import { CacheEntry, CacheLedger } from './types' + +/** + * Decides whether a completed play admits a track into the cache. + * + * Admission is deliberately generous — one completed play qualifies — because + * the budget and eviction policy are what keep the cache honest. A second + * knob ("play N times before caching") would add tuning burden without + * changing steady-state behavior. + * + * Returns a fresh `cached`/`wanted` entry, or `null` when the cache is + * disabled or the track is already tracked. + */ +export function admit(ledger: CacheLedger, trackId: string, now: number): CacheEntry | null { + if (ledger.budgetBytes === null) return null + if (ledger.entries[trackId]) return null + + return { + trackId, + origin: 'cached', + state: 'wanted', + sizeBytes: 0, + addedAt: now, + lastPlayedAt: null, + playEvents: [], + } +} diff --git a/src/cache/core/engine.ts b/src/cache/core/engine.ts new file mode 100644 index 000000000..dd4ebda87 --- /dev/null +++ b/src/cache/core/engine.ts @@ -0,0 +1,328 @@ +import { admit } from './admission' +import { applyEviction, planEviction } from './eviction' +import { reconcile } from './reconcile' +import { recordPlay } from './scoring' +import { + CacheEntry, + CacheEvent, + CacheLedger, + DecideContext, + Decision, + Effect, + EvictionPlan, + MAX_PLAY_EVENTS_PER_ENTRY, +} from './types' + +/** + * The smart cache policy engine: a pure reducer over the ledger. + * + * `decide` never performs side effects — it returns the next ledger plus a + * list of {@link Effect} commands (fetch this, evict that, ask the user to + * confirm this plan) for the service layer to interpret against the storage + * adapter. All policy decisions therefore live in deterministic, exhaustively + * testable code; anything the engine gets wrong self-heals on the next + * `disk-truth` reconciliation. + */ +export function decide( + ledger: CacheLedger, + event: CacheEvent, + now: number, + context: DecideContext, +): Decision { + switch (event.type) { + case 'play-completed': + return onPlayCompleted(ledger, event.trackId, now) + case 'play-started': + return onPlayStarted(ledger, event.trackId) + case 'pin': + return onPin(ledger, event.trackId, now) + case 'unpin': + return onUnpin(ledger, event.trackId, now, context) + case 'remove': + return onRemove(ledger, event.trackId) + case 'fetch-succeeded': + return onFetchSucceeded(ledger, event.trackId, event.sizeBytes, now, context) + case 'fetch-failed': + return onFetchFailed( + ledger, + event.trackId, + event.retryable, + event.storageFull, + now, + context, + ) + case 'budget-changed': + return onBudgetChanged(ledger, event.budgetBytes, now, context) + case 'eviction-confirmed': + return onEvictionConfirmed(ledger, event.plan, now, context) + case 'disk-truth': + return onDiskTruth(ledger, event, now, context) + } +} + +function withEntry(ledger: CacheLedger, entry: CacheEntry): CacheLedger { + return { ...ledger, entries: { ...ledger.entries, [entry.trackId]: entry } } +} + +/** + * A completed play (>80% listened — the same signal used for scrobbling) + * records history on the entry and admits unknown tracks when the cache is + * enabled. Newly admitted or previously evicted (`wanted`) tracks are fetched. + */ +function onPlayCompleted(ledger: CacheLedger, trackId: string, now: number): Decision { + const existing = ledger.entries[trackId] + + if (existing) { + let entry = recordPlay(existing, now, MAX_PLAY_EVENTS_PER_ENTRY) + const effects: Effect[] = [] + + if (entry.state === 'wanted') { + entry = { ...entry, state: 'fetching' } + effects.push({ type: 'fetch', trackId }) + } + + return { ledger: withEntry(ledger, entry), effects } + } + + const admitted = admit(ledger, trackId, now) + if (!admitted) return { ledger, effects: [] } + + const entry: CacheEntry = { + ...recordPlay(admitted, now, MAX_PLAY_EVENTS_PER_ENTRY), + state: 'fetching', + } + + return { ledger: withEntry(ledger, entry), effects: [{ type: 'fetch', trackId }] } +} + +/** + * Fired when a track passes the in-progress threshold (~30%). Not a completed + * play — no history is recorded — but a track the ledger already wants (hot + * but evicted, or recovered from a stuck download) is fetched eagerly while + * the user is clearly listening to it. + */ +function onPlayStarted(ledger: CacheLedger, trackId: string): Decision { + const entry = ledger.entries[trackId] + if (!entry || entry.state !== 'wanted') return { ledger, effects: [] } + + return { + ledger: withEntry(ledger, { ...entry, state: 'fetching' }), + effects: [{ type: 'fetch', trackId }], + } +} + +/** + * An explicit user download. Pins are sacred: they are never evicted and + * never pruned. Pinning an evicted/failed entry retries the fetch; any budget + * overage a pin causes is resolved by evicting cached entries when the fetch + * completes (never other pins). + */ +function onPin(ledger: CacheLedger, trackId: string, now: number): Decision { + const existing = ledger.entries[trackId] + + if (!existing) { + const entry: CacheEntry = { + trackId, + origin: 'pinned', + state: 'fetching', + sizeBytes: 0, + addedAt: now, + lastPlayedAt: null, + playEvents: [], + } + return { ledger: withEntry(ledger, entry), effects: [{ type: 'fetch', trackId }] } + } + + if (existing.state === 'wanted' || existing.state === 'failed') { + return { + ledger: withEntry(ledger, { ...existing, origin: 'pinned', state: 'fetching' }), + effects: [{ type: 'fetch', trackId }], + } + } + + return { ledger: withEntry(ledger, { ...existing, origin: 'pinned' }), effects: [] } +} + +/** + * Demotes a pin to a cached entry. The file stays, but it now competes for + * budget like everything else — so an over-budget ledger evicts immediately + * (silently: unpinning is an explicit statement the track is no longer + * protected). + */ +function onUnpin( + ledger: CacheLedger, + trackId: string, + now: number, + context: DecideContext, +): Decision { + const entry = ledger.entries[trackId] + if (!entry || entry.origin !== 'pinned') return { ledger, effects: [] } + + const next = withEntry(ledger, { ...entry, origin: 'cached' }) + + return evictSilently(next, now, context, 'over-budget') +} + +/** + * Removes a track from the cache entirely — ledger entry and file. The next + * completed play may re-admit it; deletion is "not right now," not "never." + */ +function onRemove(ledger: CacheLedger, trackId: string): Decision { + const entries = { ...ledger.entries } + delete entries[trackId] + + // The evict effect is emitted even for unknown tracks: deletion requests + // can target downloads that predate the ledger, and the delete is idempotent. + return { ledger: { ...ledger, entries }, effects: [{ type: 'evict', trackId }] } +} + +/** + * A download finished. Unknown completions (from flows that predate the + * ledger) are adopted as pins, mirroring reconciliation. New bytes on disk + * may tip the budget — resolved silently; this is the cache doing its job. + */ +function onFetchSucceeded( + ledger: CacheLedger, + trackId: string, + sizeBytes: number, + now: number, + context: DecideContext, +): Decision { + const existing = ledger.entries[trackId] + + const entry: CacheEntry = existing + ? { ...existing, state: 'present', sizeBytes } + : { + trackId, + origin: 'pinned', + state: 'present', + sizeBytes, + addedAt: now, + lastPlayedAt: null, + playEvents: [], + } + + return evictSilently(withEntry(ledger, entry), now, context, 'over-budget') +} + +/** + * A download failed. Retryable failures demote to `wanted` so the next play + * retries; non-retryable failures park at `failed` (a pin retries them). + * A full disk additionally triggers an emergency eviction pass when the + * ledger itself is over budget. + */ +function onFetchFailed( + ledger: CacheLedger, + trackId: string, + retryable: boolean, + storageFull: boolean | undefined, + now: number, + context: DecideContext, +): Decision { + const existing = ledger.entries[trackId] + if (!existing) return { ledger, effects: [] } + + const entry: CacheEntry = { + ...existing, + state: retryable || storageFull ? 'wanted' : 'failed', + sizeBytes: 0, + } + + const next = withEntry(ledger, entry) + + // If the disk is full because of other apps (we're under budget) there is + // nothing safe to free on their behalf; only act on our own overage. + if (storageFull) return evictSilently(next, now, context, 'storage-full') + + return { ledger: next, effects: [] } +} + +/** + * The user changed (or disabled) the budget. Shrinking below current usage is + * destructive, so the resulting plan is surfaced via `confirm-eviction` and + * only applied when an `eviction-confirmed` event comes back. + */ +function onBudgetChanged( + ledger: CacheLedger, + budgetBytes: number | null, + now: number, + context: DecideContext, +): Decision { + const next = { ...ledger, budgetBytes } + if (budgetBytes === null) return { ledger: next, effects: [] } + + const plan = planEviction(next, now, context, 'budget-shrink') + if (!plan) return { ledger: next, effects: [] } + + return { ledger: next, effects: [{ type: 'confirm-eviction', plan }] } +} + +/** + * The user approved a surfaced plan. The plan is re-validated against the + * current ledger (entries may have been played, pinned, or removed while the + * prompt was up) rather than trusted verbatim. + */ +function onEvictionConfirmed( + ledger: CacheLedger, + plan: EvictionPlan, + now: number, + context: DecideContext, +): Decision { + const validIds = plan.trackIds.filter((trackId) => { + const entry = ledger.entries[trackId] + return ( + entry !== undefined && + entry.origin === 'cached' && + entry.state === 'present' && + !context.protectedTrackIds.has(trackId) + ) + }) + + if (validIds.length === 0) return { ledger, effects: [] } + + const freedBytes = validIds.reduce( + (total, trackId) => total + ledger.entries[trackId].sizeBytes, + 0, + ) + const validated: EvictionPlan = { ...plan, trackIds: validIds, freedBytes } + + return { + ledger: applyEviction(ledger, validated, now), + effects: validIds.map((trackId) => ({ type: 'evict', trackId })), + } +} + +/** + * Reconciliation against disk truth (startup, post-bulk operations). Overage + * discovered here — adopted files, a budget shrunk on another device's + * schedule — is surfaced for confirmation rather than silently evicted. + */ +function onDiskTruth( + ledger: CacheLedger, + event: Extract, + now: number, + context: DecideContext, +): Decision { + const next = reconcile(ledger, event.snapshot, now) + + const plan = planEviction(next, now, context, 'reconcile') + if (!plan) return { ledger: next, effects: [] } + + return { ledger: next, effects: [{ type: 'confirm-eviction', plan }] } +} + +/** Plans and immediately applies an eviction (steady-state, no prompt). */ +function evictSilently( + ledger: CacheLedger, + now: number, + context: DecideContext, + reason: EvictionPlan['reason'], +): Decision { + const plan = planEviction(ledger, now, context, reason) + if (!plan) return { ledger, effects: [] } + + return { + ledger: applyEviction(ledger, plan, now), + effects: plan.trackIds.map((trackId) => ({ type: 'evict', trackId })), + } +} diff --git a/src/cache/core/eviction.ts b/src/cache/core/eviction.ts new file mode 100644 index 000000000..9953121d6 --- /dev/null +++ b/src/cache/core/eviction.ts @@ -0,0 +1,104 @@ +import { compareByColdness, scoreEntry } from './scoring' +import { + CACHE_LOW_WATERMARK_RATIO, + CacheLedger, + DecideContext, + EvictionPlan, + EvictionReason, + PRUNE_SCORE_THRESHOLD, +} from './types' + +/** Bytes currently occupied on disk according to the ledger */ +export function usedBytes(ledger: CacheLedger): number { + return Object.values(ledger.entries).reduce( + (total, entry) => (entry.state === 'present' ? total + entry.sizeBytes : total), + 0, + ) +} + +/** + * Builds a plan to bring disk usage back under budget, or `null` when no + * eviction is needed or possible. + * + * Invariants (covered by tests): + * - pinned entries are never candidates + * - protected tracks (current queue) are never candidates + * - candidates are taken coldest-first, deterministically + * - the plan stops once usage would drop to the low watermark + * ({@link CACHE_LOW_WATERMARK_RATIO} × budget) — hysteresis against + * one-in-one-out thrash at the boundary + */ +export function planEviction( + ledger: CacheLedger, + now: number, + context: DecideContext, + reason: EvictionReason, +): EvictionPlan | null { + if (ledger.budgetBytes === null) return null + + const used = usedBytes(ledger) + if (used <= ledger.budgetBytes) return null + + const target = ledger.budgetBytes * CACHE_LOW_WATERMARK_RATIO + + const candidates = Object.values(ledger.entries) + .filter( + (entry) => + entry.origin === 'cached' && + entry.state === 'present' && + !context.protectedTrackIds.has(entry.trackId), + ) + .sort((a, b) => compareByColdness(a, b, now)) + + const trackIds: string[] = [] + let freedBytes = 0 + + for (const entry of candidates) { + if (used - freedBytes <= target) break + trackIds.push(entry.trackId) + freedBytes += entry.sizeBytes + } + + if (trackIds.length === 0) return null + + return { trackIds, freedBytes, reason } +} + +/** + * Applies an eviction plan to the ledger: evicted entries are demoted to + * `wanted` (keeping their play history so a hot-but-evicted track re-admits + * with its score intact), then stale history is pruned. + */ +export function applyEviction(ledger: CacheLedger, plan: EvictionPlan, now: number): CacheLedger { + const entries = { ...ledger.entries } + + for (const trackId of plan.trackIds) { + const entry = entries[trackId] + if (!entry || entry.origin !== 'cached' || entry.state !== 'present') continue + + entries[trackId] = { ...entry, state: 'wanted', sizeBytes: 0 } + } + + return pruneStale({ ...ledger, entries }, now) +} + +/** + * Drops `cached` entries that aren't on disk (or in flight) and whose score + * has decayed below {@link PRUNE_SCORE_THRESHOLD}. This bounds ledger growth + * to recently-relevant tracks; pinned entries are never pruned. + */ +export function pruneStale(ledger: CacheLedger, now: number): CacheLedger { + const entries: CacheLedger['entries'] = {} + + for (const entry of Object.values(ledger.entries)) { + const prunable = + entry.origin === 'cached' && + entry.state !== 'present' && + entry.state !== 'fetching' && + scoreEntry(entry, now) < PRUNE_SCORE_THRESHOLD + + if (!prunable) entries[entry.trackId] = entry + } + + return { ...ledger, entries } +} diff --git a/src/cache/core/reconcile.ts b/src/cache/core/reconcile.ts new file mode 100644 index 000000000..189ccf734 --- /dev/null +++ b/src/cache/core/reconcile.ts @@ -0,0 +1,68 @@ +import { pruneStale } from './eviction' +import { CacheEntry, CacheLedger, DiskSnapshot } from './types' + +/** + * Repairs the ledger against disk truth. The ledger is metadata only — the + * storage layer owns the bytes — so on every startup (and after bulk + * operations) drift is resolved in both directions: + * + * - On disk but unknown to the ledger → adopted as `pinned`/`present`. + * Conservative: a file we can't explain is treated as user intent, never + * eviction fodder. This is also how pre-existing downloads migrate into + * the ledger on first run. + * - In the ledger as `present` but missing on disk → demoted to `wanted` + * (cached) or `failed` (pinned, so the UI can offer a re-download). + * - In the ledger as `fetching` with no active download task → the stuck + * download case: demoted to `wanted` so the next play (or pin) retries. + * - Sizes refreshed from disk for everything present. + * + * Stale non-resident entries are pruned at the end. Pure function; never + * mutates its inputs. + */ +export function reconcile(ledger: CacheLedger, snapshot: DiskSnapshot, now: number): CacheLedger { + const entries: CacheLedger['entries'] = {} + const onDisk = new Map(snapshot.present.map((track) => [track.trackId, track])) + const inFlight = new Set(snapshot.fetching) + + for (const entry of Object.values(ledger.entries)) { + entries[entry.trackId] = reconcileEntry(entry, onDisk.get(entry.trackId), inFlight) + } + + for (const track of snapshot.present) { + if (entries[track.trackId]) continue + + entries[track.trackId] = { + trackId: track.trackId, + origin: 'pinned', + state: 'present', + sizeBytes: track.sizeBytes, + addedAt: now, + lastPlayedAt: null, + playEvents: [], + } + } + + return pruneStale({ ...ledger, entries }, now) +} + +function reconcileEntry( + entry: CacheEntry, + diskTrack: { trackId: string; sizeBytes: number } | undefined, + inFlight: ReadonlySet, +): CacheEntry { + if (diskTrack) { + if (entry.state === 'present' && entry.sizeBytes === diskTrack.sizeBytes) return entry + return { ...entry, state: 'present', sizeBytes: diskTrack.sizeBytes } + } + + switch (entry.state) { + case 'present': + return entry.origin === 'pinned' + ? { ...entry, state: 'failed', sizeBytes: 0 } + : { ...entry, state: 'wanted', sizeBytes: 0 } + case 'fetching': + return inFlight.has(entry.trackId) ? entry : { ...entry, state: 'wanted', sizeBytes: 0 } + default: + return entry + } +} diff --git a/src/cache/core/scoring.ts b/src/cache/core/scoring.ts new file mode 100644 index 000000000..dfc7658c1 --- /dev/null +++ b/src/cache/core/scoring.ts @@ -0,0 +1,56 @@ +import { CACHE_SCORE_HALF_LIFE_MS, CacheEntry } from './types' + +/** + * Hotness score: exponentially-decayed play frequency. + * + * Each completed play contributes `0.5 ^ (age / halfLife)`, so a play loses + * half its weight every half-life. One formula yields both frequency and + * recency behavior: a track played ten times last month decays below a track + * played twice this week, with no separate LFU/LRU knobs to tune. + * + * Pure function of the entry and an explicit clock. + */ +export function scoreEntry( + entry: CacheEntry, + now: number, + halfLifeMs: number = CACHE_SCORE_HALF_LIFE_MS, +): number { + return entry.playEvents.reduce((total, playedAt) => { + const age = Math.max(0, now - playedAt) + return total + Math.pow(0.5, age / halfLifeMs) + }, 0) +} + +/** + * Stable ordering for eviction: coldest first. + * + * Ties break on least-recently-played, then oldest entry, then track id so + * the ordering — and therefore every eviction plan — is fully deterministic + * for a fixed `(entries, now)`. + */ +export function compareByColdness(a: CacheEntry, b: CacheEntry, now: number): number { + const scoreDelta = scoreEntry(a, now) - scoreEntry(b, now) + if (scoreDelta !== 0) return scoreDelta + + const lastPlayedDelta = (a.lastPlayedAt ?? 0) - (b.lastPlayedAt ?? 0) + if (lastPlayedDelta !== 0) return lastPlayedDelta + + const addedDelta = a.addedAt - b.addedAt + if (addedDelta !== 0) return addedDelta + + return a.trackId < b.trackId ? -1 : a.trackId > b.trackId ? 1 : 0 +} + +/** + * Records a completed play on an entry, capping stored timestamps at `cap` + * (oldest dropped first). Returns a new entry; never mutates. + */ +export function recordPlay(entry: CacheEntry, now: number, cap: number): CacheEntry { + const playEvents = [...entry.playEvents, now].slice(-cap) + + return { + ...entry, + playEvents, + lastPlayedAt: now, + } +} diff --git a/src/cache/core/types.ts b/src/cache/core/types.ts new file mode 100644 index 000000000..991c93ad2 --- /dev/null +++ b/src/cache/core/types.ts @@ -0,0 +1,132 @@ +/** + * Smart cache core types. + * + * Everything in `src/cache/core` is pure TypeScript — no React, React Native, + * or nitro imports — so the entire policy surface is unit-testable with plain + * objects and an explicit clock. + */ + +/** + * Why a track is on disk. + * + * `pinned` tracks were explicitly downloaded by the user and are never + * auto-evicted. `cached` tracks earned their place through listening and may + * be evicted when the budget is exceeded. + */ +export type CacheOrigin = 'pinned' | 'cached' + +/** + * Lifecycle of a cache entry. + * + * `wanted` — admitted (or pinned) but not on disk; a fetch may be issued + * `fetching` — a download has been dispatched and is in flight + * `present` — the file is on disk + * `failed` — a non-retryable fetch failure or a pinned file that vanished + */ +export type CacheEntryState = 'wanted' | 'fetching' | 'present' | 'failed' + +export type CacheEntry = { + trackId: string + origin: CacheOrigin + state: CacheEntryState + /** Bytes on disk; `0` unless `state` is `present` */ + sizeBytes: number + addedAt: number + lastPlayedAt: number | null + /** + * Timestamps of recent completed plays, newest last, capped at + * {@link MAX_PLAY_EVENTS_PER_ENTRY}. Kept as raw timestamps (not a counter) + * so the recency-decayed score is a pure function of the entry and a clock. + */ + playEvents: number[] +} + +export type CacheLedger = { + /** `null` means the smart cache is disabled */ + budgetBytes: number | null + entries: Record +} + +export type EvictionReason = 'over-budget' | 'budget-shrink' | 'storage-full' | 'reconcile' + +export type EvictionPlan = { + trackIds: string[] + freedBytes: number + reason: EvictionReason +} + +/** A track that exists on disk according to the storage layer */ +export type DiskTrack = { + trackId: string + sizeBytes: number +} + +/** Disk truth used to reconcile the ledger with the storage layer */ +export type DiskSnapshot = { + present: DiskTrack[] + /** Track ids with an active (in-flight) download task */ + fetching: string[] +} + +export type CacheEvent = + | { type: 'play-started'; trackId: string } + | { type: 'play-completed'; trackId: string } + | { type: 'pin'; trackId: string } + | { type: 'unpin'; trackId: string } + | { type: 'remove'; trackId: string } + | { type: 'fetch-succeeded'; trackId: string; sizeBytes: number } + | { type: 'fetch-failed'; trackId: string; retryable: boolean; storageFull?: boolean } + | { type: 'budget-changed'; budgetBytes: number | null } + | { type: 'eviction-confirmed'; plan: EvictionPlan } + | { type: 'disk-truth'; snapshot: DiskSnapshot } + +/** + * Commands the engine asks the service layer to perform. The engine never + * touches storage itself — it only describes what should happen next. + */ +export type Effect = + | { type: 'fetch'; trackId: string } + | { type: 'evict'; trackId: string } + | { type: 'confirm-eviction'; plan: EvictionPlan } + +export type DecideContext = { + /** + * Track ids that must not be evicted right now — typically the current + * play queue. Protected tracks are skipped by eviction planning and + * reconsidered on the next pass. + */ + protectedTrackIds: ReadonlySet +} + +export type Decision = { + ledger: CacheLedger + effects: Effect[] +} + +/** Half-life of a completed play's contribution to the hotness score */ +export const CACHE_SCORE_HALF_LIFE_MS = 14 * 24 * 60 * 60 * 1000 + +/** + * Eviction target as a fraction of the budget. Evicting down to 90% (rather + * than exactly 100%) prevents one-in-one-out thrash at the boundary. + */ +export const CACHE_LOW_WATERMARK_RATIO = 0.9 + +/** Cap on stored play timestamps per entry */ +export const MAX_PLAY_EVENTS_PER_ENTRY = 20 + +/** + * Entries that aren't on disk and whose score decays below this threshold are + * pruned from the ledger. A single play reaches 0.05 after ~60 days at the + * default half-life, which bounds ledger growth to recently-relevant tracks. + */ +export const PRUNE_SCORE_THRESHOLD = 0.05 + +export const EMPTY_LEDGER: CacheLedger = Object.freeze({ + budgetBytes: null, + entries: {}, +}) + +export const EMPTY_CONTEXT: DecideContext = Object.freeze({ + protectedTrackIds: new Set(), +}) diff --git a/src/cache/hooks.ts b/src/cache/hooks.ts new file mode 100644 index 000000000..73d4393bc --- /dev/null +++ b/src/cache/hooks.ts @@ -0,0 +1,53 @@ +import { useShallow } from 'zustand/react/shallow' +import { useCacheStore } from './adapters/ledger-store' +import { usedBytes } from './core/eviction' +import { CacheEntry, EvictionPlan } from './core/types' + +/** Current cache budget in bytes, or `null` when the smart cache is disabled */ +export const useCacheBudget = (): number | null => + useCacheStore((state) => state.ledger.budgetBytes) + +/** Bytes currently on disk according to the ledger (pinned + cached) */ +export const useCacheUsedBytes = (): number => useCacheStore((state) => usedBytes(state.ledger)) + +/** The eviction plan awaiting user confirmation, if any */ +export const usePendingEvictionPlan = (): EvictionPlan | null => + useCacheStore((state) => state.pendingEvictionPlan) + +/** Ledger entry for a track, if it is known to the cache */ +export const useCacheEntry = (trackId: string | null | undefined): CacheEntry | undefined => + useCacheStore((state) => (trackId ? state.ledger.entries[trackId] : undefined)) + +export type CacheBreakdown = { + pinnedCount: number + pinnedBytes: number + cachedCount: number + cachedBytes: number +} + +/** On-disk composition of the cache, split by origin */ +export const useCacheBreakdown = (): CacheBreakdown => + useCacheStore( + useShallow((state) => { + const breakdown: CacheBreakdown = { + pinnedCount: 0, + pinnedBytes: 0, + cachedCount: 0, + cachedBytes: 0, + } + + for (const entry of Object.values(state.ledger.entries)) { + if (entry.state !== 'present') continue + + if (entry.origin === 'pinned') { + breakdown.pinnedCount += 1 + breakdown.pinnedBytes += entry.sizeBytes + } else { + breakdown.cachedCount += 1 + breakdown.cachedBytes += entry.sizeBytes + } + } + + return breakdown + }), + ) diff --git a/src/cache/service.ts b/src/cache/service.ts new file mode 100644 index 000000000..aec1f9c5b --- /dev/null +++ b/src/cache/service.ts @@ -0,0 +1,228 @@ +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' +import { DownloadedTrack, TrackItem } from 'react-native-nitro-player' +import { ensureDownloadedTracks } from '../hooks/downloads/utils' +import { usePlayerQueueStore } from '../stores/player/queue' +import { useUsageSettingsStore } from '../stores/settings/usage' +import { captureError } from '../utils/logging' +import LoggingContext from '../utils/logging/enums' +import { mapDtosToTracks } from '../utils/mapping/item-to-track' +import { CacheStorageAdapter, nitroStorageAdapter } from './adapters/storage-adapter' +import { useCacheStore } from './adapters/ledger-store' +import { decide } from './core/engine' +import { CacheEvent, DecideContext, Effect } from './core/types' + +/** Budget applied when migrating a legacy auto-download user to the smart cache */ +export const DEFAULT_CACHE_BUDGET_BYTES = 4 * 1024 * 1024 * 1024 + +/** Playback fraction after which a listen counts as "started" (eager fetch) */ +const PLAY_STARTED_THRESHOLD = 0.3 + +/** + * The smart cache service: feeds {@link CacheEvent}s through the pure engine + * and interprets the resulting effects against a storage adapter. + * + * Events are processed strictly one at a time (a promise-chain pump), so the + * engine always decides against the ledger produced by the previous event — + * no interleaving between a decision and the effect dispatch that follows it. + * + * Built as a factory so tests can run the full service against a fake + * adapter; the app uses the {@link cacheService} singleton below. + */ +export function createCacheService(adapter: CacheStorageAdapter) { + /** Track payloads needed to execute fetch effects, keyed by track id */ + const knownTracks = new Map() + + /** Tracks that already emitted play-started this session */ + const playStartedNotified = new Set() + + let pump: Promise = Promise.resolve() + + /** Queue an event; resolves when the event and its effects have been handled */ + function dispatch(event: CacheEvent): Promise { + pump = pump.then(() => process(event)) + return pump + } + + async function process(event: CacheEvent): Promise { + try { + const store = useCacheStore.getState() + const { ledger, effects } = decide(store.ledger, event, Date.now(), currentContext()) + + if (ledger !== store.ledger) store.setLedger(ledger) + + await executeEffects(effects) + } catch (error) { + captureError(error, LoggingContext.SmartCache, `Failed to process ${event.type} event`) + } + } + + function currentContext(): DecideContext { + const { queue } = usePlayerQueueStore.getState() + + return { protectedTrackIds: new Set(queue.map((track) => track.id)) } + } + + async function executeEffects(effects: Effect[]): Promise { + for (const effect of effects) { + switch (effect.type) { + case 'fetch': + await executeFetch(effect.trackId) + break + case 'evict': + await executeEvict(effect.trackId) + break + case 'confirm-eviction': + useCacheStore.getState().setPendingEvictionPlan(effect.plan) + break + } + } + } + + async function executeFetch(trackId: string): Promise { + const track = knownTracks.get(trackId) + + // Without a track payload there is nothing to download with; return the + // entry to `wanted` so the next play retries. Queued, not awaited — the + // pump is currently processing the event that produced this effect. + if (!track) { + void dispatch({ type: 'fetch-failed', trackId, retryable: true }) + return + } + + try { + await adapter.fetch(track) + } catch (error) { + captureError(error, LoggingContext.SmartCache, `Failed to start fetch for ${trackId}`) + void dispatch({ type: 'fetch-failed', trackId, retryable: true }) + } + } + + async function executeEvict(trackId: string): Promise { + try { + await adapter.evict(trackId) + } catch (error) { + // The ledger already dropped the entry; reconciliation re-adopts the + // file if the delete genuinely failed. + captureError(error, LoggingContext.SmartCache, `Failed to evict ${trackId}`) + } + } + + return { + /** + * Startup: migrate the legacy auto-download setting once, then reconcile + * the ledger against disk truth (which also adopts any downloads that + * predate the ledger, and recovers stuck `fetching` entries). + */ + async initialize(): Promise { + const store = useCacheStore.getState() + + if (!store.legacyAutoDownloadMigrated) { + store.setLegacyAutoDownloadMigrated(true) + + const { autoDownload } = useUsageSettingsStore.getState() + if (autoDownload && store.ledger.budgetBytes === null) { + void dispatch({ + type: 'budget-changed', + budgetBytes: DEFAULT_CACHE_BUDGET_BYTES, + }) + } + } + + try { + const snapshot = await adapter.snapshot() + await dispatch({ type: 'disk-truth', snapshot }) + } catch (error) { + captureError(error, LoggingContext.SmartCache, 'Startup reconciliation failed') + } + }, + + /** + * Playback progress hook (called per second tick). Past the started + * threshold, gives the engine a chance to eagerly re-fetch a track it + * already wants while the user is clearly listening to it. + */ + notifyPlaybackProgress(position: number, totalDuration: number, track: TrackItem): void { + if (!track?.id || totalDuration <= 0) return + if (position / totalDuration <= PLAY_STARTED_THRESHOLD) return + if (playStartedNotified.has(track.id)) return + + playStartedNotified.add(track.id) + knownTracks.set(track.id, track) + + void dispatch({ type: 'play-started', trackId: track.id }) + }, + + /** + * A completed play (>80% listened — the same threshold as scrobbling). + * Records history and admits the track when the cache is enabled. + */ + notifyPlayCompleted(track: TrackItem): void { + if (!track?.id) return + + playStartedNotified.delete(track.id) + knownTracks.set(track.id, track) + + void dispatch({ type: 'play-completed', trackId: track.id }) + }, + + /** Explicit user download: pin every track so it is never auto-evicted */ + async pinTracks(items: BaseItemDto[]): Promise { + const downloadedTracks = await ensureDownloadedTracks() + const tracks = mapDtosToTracks(items, downloadedTracks) + + for (const track of tracks) knownTracks.set(track.id, track) + + await Promise.all(tracks.map((track) => dispatch({ type: 'pin', trackId: track.id }))) + }, + + /** Delete tracks from the cache — ledger entries and files */ + async removeTracks(trackIds: string[]): Promise { + await Promise.all(trackIds.map((trackId) => dispatch({ type: 'remove', trackId }))) + }, + + /** Enable, resize, or disable (`null`) the cache budget */ + setBudget(budgetBytes: number | null): Promise { + return dispatch({ type: 'budget-changed', budgetBytes }) + }, + + /** Apply the surfaced eviction plan the user just approved */ + confirmPendingEviction(): Promise { + const store = useCacheStore.getState() + const plan = store.pendingEvictionPlan + + store.setPendingEvictionPlan(null) + if (!plan) return Promise.resolve() + + return dispatch({ type: 'eviction-confirmed', plan }) + }, + + /** Dismiss the surfaced eviction plan without evicting */ + dismissPendingEviction(): void { + useCacheStore.getState().setPendingEvictionPlan(null) + }, + + /** Feed of native download completions (wired in services/downloads.ts) */ + notifyDownloadCompleted(download: DownloadedTrack): void { + void dispatch({ + type: 'fetch-succeeded', + trackId: download.trackId, + sizeBytes: download.fileSize ?? 0, + }) + }, + + /** Feed of native download failures (wired in services/downloads.ts) */ + notifyDownloadFailed(trackId: string, retryable: boolean, storageFull: boolean): void { + void dispatch({ type: 'fetch-failed', trackId, retryable, storageFull }) + }, + + /** Resolves once every event queued so far has been processed */ + flush(): Promise { + return pump + }, + } +} + +export type CacheService = ReturnType + +/** App-wide smart cache service bound to the nitro download manager */ +export const cacheService = createCacheService(nitroStorageAdapter) diff --git a/src/constants/versioned-storage.ts b/src/constants/versioned-storage.ts index 31e5b6a17..5826b6db5 100644 --- a/src/constants/versioned-storage.ts +++ b/src/constants/versioned-storage.ts @@ -13,6 +13,7 @@ const STORAGE_VERSION_KEY = 'storage-schema-version' */ export const STORAGE_SCHEMA_VERSIONS: Record = { 'player-queue-storage': 2, // Bumped to v2 for slim persistence + 'cache-ledger-storage': 1, // Smart cache ledger (src/cache) } /** diff --git a/src/hooks/downloads/functions/index.ts b/src/hooks/downloads/functions/index.ts deleted file mode 100644 index 4ce1c6edd..000000000 --- a/src/hooks/downloads/functions/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -import resolveTrackUrls from '../../../utils/fetching/track-media-info' -import { mapDtosToTracks } from '../../../utils/mapping/item-to-track' -import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' -import { DownloadManager } from 'react-native-nitro-player' -import { ensureDownloadedTracks } from '../utils' - -export async function downloadItems(items: BaseItemDto[]) { - // Filter out items that are already downloaded - const downloadedTracks = await ensureDownloadedTracks() - const downloadedTrackIds = downloadedTracks.map((t) => t.trackId) - const itemsToDownload = items.filter((item) => !downloadedTrackIds.includes(item.Id!)) - - const tracks = mapDtosToTracks(itemsToDownload, []) - - const resolvedTracks = await resolveTrackUrls(tracks, 'download') - - await Promise.all(resolvedTracks.map((track) => DownloadManager.downloadTrack(track))) -} diff --git a/src/hooks/downloads/mutations.ts b/src/hooks/downloads/mutations.ts index 69047d0ea..5d9535695 100644 --- a/src/hooks/downloads/mutations.ts +++ b/src/hooks/downloads/mutations.ts @@ -1,30 +1,22 @@ import { useMutation } from '@tanstack/react-query' -import { DownloadedTrack, DownloadManager } from 'react-native-nitro-player' -import { queryClient } from '../../constants/query-client' -import ALL_DOWNLOADS_KEY from './keys' -import { downloadItems } from './functions' +import { BaseItemDto } from '@jellyfin/sdk/lib/generated-client' +import { cacheService } from '../../cache/service' +/** + * Explicit user downloads are pins in the smart cache: fetched immediately + * and never auto-evicted. The cache service owns URL resolution, in-flight + * dedup (via the ledger state machine), and failure handling. + */ const useDownloadTracks = () => useMutation({ - mutationFn: downloadItems, + mutationFn: (items: BaseItemDto[]) => cacheService.pinTracks(items), }) export const useDeleteDownloads = () => { const deleteDownloads = useMutation({ - mutationFn: async (itemIds: string[]) => { - await Promise.all(itemIds.map((id) => DownloadManager.deleteDownloadedTrack(id!))) - }, - onSuccess: (_, items) => { - queryClient.setQueryData( - ALL_DOWNLOADS_KEY, - (oldData: DownloadedTrack[] | undefined) => { - if (!oldData) return [] - return oldData.filter( - (download) => !items.some((itemId) => itemId === download.trackId), - ) - }, - ) - }, + // The service removes ledger entries, deletes files, and updates the + // downloads query cache per track as each eviction lands + mutationFn: (itemIds: string[]) => cacheService.removeTracks(itemIds), }) return { diff --git a/src/providers/Storage/index.tsx b/src/providers/Storage/index.tsx index 105625294..1709a9835 100644 --- a/src/providers/Storage/index.tsx +++ b/src/providers/Storage/index.tsx @@ -1,10 +1,6 @@ import React, { PropsWithChildren, createContext, use, useState } from 'react' -import { - DownloadedTrack, - DownloadManager, - useDownloadProgress, - useDownloadStorage, -} from 'react-native-nitro-player' +import { DownloadedTrack, useDownloadProgress, useDownloadStorage } from 'react-native-nitro-player' +import { cacheService } from '../../cache/service' import useDownloads from '../../hooks/downloads' export type StorageSummary = { @@ -133,7 +129,8 @@ export function StorageProvider({ children }: PropsWithChildren): React.JSX.Elem if (!itemIds.length) return setIsDeleting(true) try { - await Promise.all(itemIds.map((id) => DownloadManager.deleteDownloadedTrack(id))) + // Route through the cache service so the ledger stays in sync + await cacheService.removeTracks(itemIds) await Promise.all([refetchDownloads(), refetchStorageInfo()]) setSelection((prev) => { const updated = { ...prev } diff --git a/src/screens/Storage/index.tsx b/src/screens/Storage/index.tsx index feebb0358..cb36214fb 100644 --- a/src/screens/Storage/index.tsx +++ b/src/screens/Storage/index.tsx @@ -11,7 +11,16 @@ import { SwitchWithLabel } from '../../components/Global/helpers/switch-with-lab import { RadioGroupItemWithLabel } from '../../components/Global/helpers/radio-group-item-with-label' import { formatBytes } from '../../utils/formatting/bytes' import { useDeletionToast } from '../../utils/toasts/deletion-toast' -import { DownloadQuality, useAutoDownload, useDownloadQuality } from '../../stores/settings/usage' +import { DownloadQuality, useDownloadQuality } from '../../stores/settings/usage' +import { + CacheBreakdown, + useCacheBreakdown, + useCacheBudget, + useCacheUsedBytes, + usePendingEvictionPlan, +} from '../../cache/hooks' +import { EvictionPlan } from '../../cache/core/types' +import { DEFAULT_CACHE_BUDGET_BYTES, cacheService } from '../../cache/service' import { DownloadedTrack, DownloadProgress, @@ -39,7 +48,10 @@ export default function StorageManagementScreen({ const { mutateAsync: deleteDownloads } = useDeleteDownloads() - const [autoDownload, setAutoDownload] = useAutoDownload() + const cacheBudget = useCacheBudget() + const cacheUsedBytes = useCacheUsedBytes() + const cacheBreakdown = useCacheBreakdown() + const pendingEvictionPlan = usePendingEvictionPlan() const [downloadQuality, setDownloadQuality] = useDownloadQuality() const [applyingSuggestionId, setApplyingSuggestionId] = useState(null) @@ -198,8 +210,10 @@ export default function StorageManagementScreen({ onDeleteAll={handleDeleteAll} /> @@ -551,28 +565,74 @@ const StatChip = ({ label, value }: { label: string; value: string }) => ( ) +const CACHE_BUDGET_PRESETS = [ + { bytes: 1024 * 1024 * 1024, label: '1 GB' }, + { bytes: 4 * 1024 * 1024 * 1024, label: '4 GB' }, + { bytes: 8 * 1024 * 1024 * 1024, label: '8 GB' }, + { bytes: 16 * 1024 * 1024 * 1024, label: '16 GB' }, + { bytes: 32 * 1024 * 1024 * 1024, label: '32 GB' }, +] + const DownloadSettingsSection = ({ - autoDownload, - setAutoDownload, + budgetBytes, + usedBytes, + breakdown, + pendingEvictionPlan, downloadQuality, setDownloadQuality, }: { - autoDownload: boolean - setAutoDownload: (value: boolean) => void + budgetBytes: number | null + usedBytes: number + breakdown: CacheBreakdown + pendingEvictionPlan: EvictionPlan | null downloadQuality: DownloadQuality setDownloadQuality: (value: DownloadQuality) => void }) => ( - Auto-Download Tracks + Smart Cache - Download tracks as they are played + Keep the music you actually listen to downloaded, within a size limit - + { + void cacheService.setBudget(enabled ? DEFAULT_CACHE_BUDGET_BYTES : null) + }} + size='$2' + /> + {budgetBytes !== null && ( + + Cache Size + + {`${formatBytes(usedBytes)} of ${formatBytes(budgetBytes)} used · ${ + breakdown.cachedCount + } cached · ${breakdown.pinnedCount} downloaded`} + + { + void cacheService.setBudget(Number(value)) + }} + > + {CACHE_BUDGET_PRESETS.map((preset) => ( + + ))} + + + )} + + {pendingEvictionPlan && } + Download Quality @@ -590,3 +650,42 @@ const DownloadSettingsSection = ({ ) + +const EvictionConfirmationCard = ({ plan }: { plan: EvictionPlan }) => ( + + + Make room? + + + {`This cache size requires removing ${plan.trackIds.length} ${ + plan.trackIds.length === 1 ? 'track' : 'tracks' + } (${formatBytes(plan.freedBytes)}). Tracks you downloaded yourself are never removed.`} + + + + + + +) diff --git a/src/services/downloads.ts b/src/services/downloads.ts index edf182d65..7a47624ea 100644 --- a/src/services/downloads.ts +++ b/src/services/downloads.ts @@ -3,6 +3,7 @@ import { queryClient } from '../constants/query-client' import ALL_DOWNLOADS_KEY from '../hooks/downloads/keys' import { MAX_CONCURRENT_DOWNLOADS } from '../configs/download.config' import { MAX_RETRY_ATTEMPTS } from '../configs/query.config' +import { cacheService } from '../cache/service' export default function configureDownloadManager() { DownloadManager.configure({ @@ -16,9 +17,29 @@ export default function configureDownloadManager() { }) DownloadManager.onDownloadComplete((download) => { + // Upsert by track id so a re-downloaded track replaces its old entry + // instead of duplicating it queryClient.setQueryData(ALL_DOWNLOADS_KEY, (oldData: DownloadedTrack[] | undefined) => { if (!oldData) return [download] - return [...oldData, download] + return [ + ...oldData.filter((existing) => existing.trackId !== download.trackId), + download, + ] }) + + cacheService.notifyDownloadCompleted(download) + }) + + DownloadManager.onDownloadStateChange((_downloadId, trackId, state, error) => { + if (state === 'failed') { + cacheService.notifyDownloadFailed( + trackId, + error?.isRetryable ?? true, + error?.reason === 'storage_full', + ) + } else if (state === 'cancelled') { + // A cancelled download can always be tried again later + cacheService.notifyDownloadFailed(trackId, true, false) + } }) } diff --git a/src/services/player/utils/auto-download.ts b/src/services/player/utils/auto-download.ts deleted file mode 100644 index 303cdac3f..000000000 --- a/src/services/player/utils/auto-download.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Track IDs for which we've already triggered (or confirmed) an auto-download this session. - -import { DownloadManager } from 'react-native-nitro-player' -import { useUsageSettingsStore } from '../../../stores/settings/usage' -import { TrackItem } from 'react-native-nitro-player/lib/types/PlayerQueue' -import resolveTrackUrls from '../../../utils/fetching/track-media-info' - -// Prevents redundant DownloadManager checks on every progress tick after the 30% threshold. -const autoDownloadTriggered = new Set() - -export default async function handleAutoDownload( - position: number, - totalDuration: number, - track: TrackItem, -) { - const { autoDownload } = useUsageSettingsStore.getState() - - if (position / totalDuration > 0.3 && track && autoDownload) { - // Fail fast if we've already triggered an auto-download for this track this session - if (autoDownloadTriggered.has(track.id)) return - - // Mark this track as having triggered an auto-download to prevent redundant checks - autoDownloadTriggered.add(track.id) - - const isDownloadedOrDownloadPending = - (await DownloadManager.isTrackDownloaded(track?.id ?? '')) || - (await DownloadManager.isDownloading(track?.id ?? '')) - - if (isDownloadedOrDownloadPending) return - - // Re-resolve the track URL using the download profile, not the stream profile - const [downloadTrack] = await resolveTrackUrls([track], 'download') - await DownloadManager.downloadTrack(downloadTrack) - } -} diff --git a/src/services/player/utils/event-handlers.ts b/src/services/player/utils/event-handlers.ts index 537ae60f2..672391754 100644 --- a/src/services/player/utils/event-handlers.ts +++ b/src/services/player/utils/event-handlers.ts @@ -3,6 +3,7 @@ import reportPlaybackStarted from '../../../api/mutations/playback/functions/pla import { usePlayerPlaybackStore } from '../../../stores/player/playback' import { usePlayerQueueStore } from '../../../stores/player/queue' import { TrackPlayer, Reason, TrackPlayerState, TrackItem } from 'react-native-nitro-player' +import { cacheService } from '../../../cache/service' import handleAutoDownload from './auto-download' import applyAudioNormalizationIfEnabled from '../../../utils/audio/normalization' import { captureError } from '../../../utils/logging' @@ -99,7 +100,8 @@ export async function onChangeTrack(track: TrackItem, reason?: Reason) { * * Reports playback progress back to Jellyfin every 10 seconds of playback. * - * Triggers an automatic download of the currently playing song after 30% playback. + * Notifies the smart cache once playback passes its started threshold so + * tracks the cache wants can be fetched while the user is listening. * * @param position The current position in seconds of the {@link TrackPlayer} * @param totalDuration The total duration of the currently playing {@link TrackItem} in seconds @@ -127,11 +129,13 @@ export async function onPlaybackProgress(position: number, totalDuration: number reportPlaybackProgress(currentTrack, flooredPosition, currentPlaybackState === 'paused') } + cacheService.notifyPlaybackProgress(position, totalDuration, currentTrack) + // Mark the track as completed if 2/3s of the track has been completed - if (position > (totalDuration / 3) * 2 && !trackMarkedAsListened) { reportPlaybackCompleted(currentTrack) trackMarkedAsListened = true + cacheService.notifyPlayCompleted(previousTrack) } handleAutoDownload(position, totalDuration, currentTrack).catch((error) => { diff --git a/src/stores/settings/usage.ts b/src/stores/settings/usage.ts index 0944efd0d..98426910e 100644 --- a/src/stores/settings/usage.ts +++ b/src/stores/settings/usage.ts @@ -11,6 +11,11 @@ type UsageSettingsStore = { downloadQuality: DownloadQuality setDownloadQuality: (downloadQuality: DownloadQuality) => void + /** + * @deprecated Superseded by the smart cache budget (`src/cache`). Kept so + * the one-shot migration in the cache service can read prior user intent; + * remove once that migration is retired. + */ autoDownload: boolean setAutoDownload: (autoDownload: boolean) => void } @@ -38,14 +43,6 @@ export const useUsageSettingsStore = create()( ), ) -export const useAutoDownload: () => [boolean, (autoDownload: boolean) => void] = () => { - const autoDownload = useUsageSettingsStore((state) => state.autoDownload) - - const setAutoDownload = useUsageSettingsStore((state) => state.setAutoDownload) - - return [autoDownload, setAutoDownload] -} - export const useDownloadQuality: () => [ DownloadQuality, (downloadQuality: DownloadQuality) => void, diff --git a/src/utils/logging/enums.ts b/src/utils/logging/enums.ts index f2c4e6195..aa7a5f117 100644 --- a/src/utils/logging/enums.ts +++ b/src/utils/logging/enums.ts @@ -3,6 +3,7 @@ enum LoggingContext { PlaybackReporting = 'Playback Reporting', NitroFetch = 'Nitro Fetch', AutoDownload = 'Auto Download', + SmartCache = 'Smart Cache', PublicSystemInfo = 'Public System Info', Queue = 'Queue', QueueStorage = 'Queue Storage',