Ctrl K
ComunidadGuías…

proposals/2026-07-15-offline-pinned-cache.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Milestone 3, Phase 5: offline — pinned albums, an LRU audio cache, and an offline write-queue

## Goal
Let a phone play music with no connection to the host — recently-played tracks cached
automatically and whole albums pinned on purpose — while keeping revoke meaningful: a
revoked device that comes back online loses its downloads.

## Tier
T3. No wire change and no host change (`media.stream` already carries `offset`/`length`, so
a full or resumable download uses the existing method) — but it **relaxes the effective
meaning of revoke**, which is the security core of this app, so it gets the full gate.
The relaxation is bounded by the purge-on-reconnect control below.

## Background: why this is delicate
PearTune deliberately streams audio *through the live P2P connection* so that revoke cuts
playback (DONE 2026-07-13: "a phone that downloads a track to a file and plays it would
make the headline demo a LIE"). The graceful-reconnect decision (2026-07-14) already
softened this at the edge: a revoked device may finish the track ExoPlayer already
buffered. Offline pinning extends that softening from "the current buffer" to "bytes you
chose to download" — so it needs an explicit, bounded rule.

**The rule (Tim's call, 2026-07-15):** downloads persist and play offline; revoke still cuts
all NEW access *instantly* (no new stream, browse, art, or pin — unchanged); and a revoked
device, the moment it is online again and the host refuses it, **purges its entire audio +
state cache**. A lost phone that never reconnects keeps its downloads — which is physically
unpreventable (the bytes are on a device we cannot reach), and no worse than the same phone
having the files by any other means.

## Scope

### In
**A. Offline write-queue (the safe half, T2-shaped).** Favorites / resume / play-count
writes made while disconnected are optimistic locally and queued in `DATA_DIR/outbox.json`,
then flushed to the host in order on the next successful connect. Today these silently fail
offline. No host change — the `fav.set` / `resume.set` / `count.bump` methods already exist;
the queue just replays them. Idempotent by nature (LWW on the host), so a double-flush is
harmless.

**B. Audio cache + offline playback + the purge (the T3 half).**
- **Disk cache** at `DATA_DIR/audio/<trackId>` with an index `DATA_DIR/cache.json`
  (`trackId -> { size, pinned, lastPlayed, suffix }`). Two buckets share the dir:
  - **LRU auto-cache**: a track played to completion is written through to disk as its bytes
    stream past the shim, so "what you just heard" is offline-playable. Evicted oldest-first
    to stay under a size cap (Settings; default 1 GB).
  - **Pinned**: explicit downloads (below), never auto-evicted; shown and managed separately.
- **The shim serves the cache first.** `worklet/shim.js` already answers the player's Range
  requests from the live client; now it checks disk first — a complete cached/pinned file is
  served from disk (so playback works with no connection), and a miss streams from the client
  and writes through. Write-through only on a full sequential read (offset 0 → end); a seek
  read still streams live (partial-range caching is not worth the complexity in v1).
- **Purge on refused reconnect.** The client's reconnect already distinguishes a host that
  *refused* the connection (the firewall denied it — `conn` closes right after the DHT
  reaches the host, exactly as `pair()` detects a closed pairing window) from a host that is
  *unreachable* (a timeout). On a **refused** reconnect the worklet wipes `audio/`,
  `cache.json`, the state caches (favorites/playlists), and the outbox. On a **timeout** it
  changes nothing (your server being off must never delete your downloads). This is the
  control that keeps revoke meaningful.

**C. Explicit pinning + Downloads UI.**
- Pin an album or a track: the worklet downloads each track's full bytes via `media.stream`
  (resumable — it already takes `offset`/`length`, so an interrupted pin resumes), marks them
  `pinned` in the index, with progress surfaced in the UI. Unpin removes the bytes.
- A **Downloads** surface (in the "You" tab or Settings) lists pinned albums, shows total
  size, and offers unpin. Offline, this is the one place with playable content.
- **Cellular guard**: a "Download over cellular" setting (default OFF) — pinning a FLAC album
  is ~hundreds of MB; the client already knows the network type (expo-network, PR #10), so a
  pin over cellular with the toggle off is refused with a clear message.

### Settings (new)
- Cache size cap for the LRU bucket (500 MB / 1 GB / 2 GB / Unlimited), with current usage.
- "Clear cache" (LRU only) and "Manage downloads" (pinned).
- "Download over cellular" toggle.

### Out (later, flagged)
- **Partial-range caching / caching a seeked track** — v1 caches on full sequential play only.
- **A time-boxed lease** (playback expires N days after the last successful auth, even fully
  offline) as an *additional* hardening on top of purge-on-reconnect. Deferred; the
  purge-on-reconnect already covers "revoked device comes back online," and a never-online
  device is unpreventable either way. Revisit if the guest threat model wants a hard cap.
- **Auto-restore the paused queue on launch** (needs queue persistence; tracked separately).

## Compat / migration
- **Purely additive and client-only.** New files under the phone's data dir; no Hyperbee key,
  no wire framing, no host code. An old host is fine (the client only ever calls the existing
  `media.stream` / `fav.set` / …). Rolling back = stop caching and the app streams live as it
  does today; deleting `audio/` + `cache.json` + `outbox.json` removes the feature with zero
  effect on pairing, grants, or the host.
- The state caches (favorites/playlists) already exist and already render offline; this adds
  the WRITE side (outbox) and the AUDIO side (cache).

## Verify
- **Offline playback**: pin an album, enable airplane mode, play it end to end from disk.
- **Auto-cache**: play a track online, go offline, replay it — served from cache, no host.
- **Revoke purge (the load-bearing test)**: pin an album; revoke the device from the
  dashboard; the device is online → on the next reconnect the host *refuses* it and the app
  **purges** the downloads (they no longer play). Distinct from: stop the host container
  (unreachable) → downloads **survive** and still play offline. Both paths tested on the TCL +
  Umbrel.
- **Revoke still cuts NEW access within a second** (the CLAUDE.md acceptance test) — unchanged:
  browse / next uncached track / art / new pin are all denied immediately on revoke.
- **Write-queue**: favorite a track offline; reconnect; the host shows it favorited; a second
  device (same person) sees it — proving the queued write flushed.
- **Size cap / eviction**: set a small cap, play past it, confirm oldest LRU entries evict and
  pinned entries never do.
- **Cellular guard**: with the toggle off, a pin over cellular is refused with a message.
- `npm run verify` green with unit tests for the cache index (LRU eviction order, pinned
  protected, size accounting) and the outbox (order preserved, flush-once, idempotent replay).

## Rollback
- Client-only and additive; a feature flag gates the cache + outbox. Flipped off, the app is
  today's app (stream-live, no offline). No migration to reverse; the host is untouched, so
  nothing to roll back there.
- If the purge signal proves unreliable in the field (a network hiccup mistaken for a
  refusal), the fallback is conservative — treat an *ambiguous* reconnect failure as a timeout
  (keep the cache), never as a refusal (purge). We would rather under-purge (a revoked device
  keeps downloads a little longer) than delete a paying user's downloads on a bad wifi moment.

## Open questions
1. **The refused-vs-unreachable signal.** `pair()` already relies on it (a closed pairing
   window fires `conn.close`; an absent host times out). This proposal reuses that exact
   distinction for the media reconnect. The risk is a false *refusal* (deleting downloads on a
   transient close). Mitigation: require the refusal to be a clean firewall-style close *after
   the DHT reached the host*, and on any doubt fall back to "timeout" (never purge). Confirm on
   hardware that a real revoke reliably reads as refused and a real host-off reads as timeout.
   If the distinction is ever unreliable, add the time-boxed lease (deferred, above) as the
   robust backstop.
2. **Do guests pin?** v1 lets any granted device pin, backstopped by purge-on-reconnect. If
   the guest leak matters more later, gate pinning to owner-confirmed devices (the person model
   exists; "only my own devices" is a one-line grant check) — flagged, not built.
3. **One size budget or two?** Proposing two: pinned is user-managed (you chose it; shown, not
   capped), LRU is auto and capped. Simpler mental model than one shared budget.

## Phasing (ship incrementally, one proposal)
- **Phase A — offline write-queue.** Independent, no security surface; makes offline
  favorites/resume/counts work. Ship first.
- **Phase B — audio LRU cache + offline playback + purge-on-reconnect + cache settings.** The
  T3 core (the purge lands with the first persisted audio).
- **Phase C — explicit album pinning + Downloads UI + cellular toggle.** Builds on B.