Ctrl K
ComunidadGuías…

proposals/2026-07-29-persist-album-art.md

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

Archivo original ↗Preguntar a KiBot
# Persist album art once per library, not once per session

## Goal

Stop re-fetching cover art from the host on every cold start: store each cover on disk
the first time it is seen, at the size it was requested, and keep it until its library is
removed.

## Tier

T2. New persisted state (art keyed by size, plus a blob-to-library index) and a change to
what library removal deletes. No wire change, no host change, phone-side only.

## Context: what happens today

Two layers, and only one of them survives the app closing.

- **In memory**: `artCache` in `worklet/shim.js`, a `Map` capped at `ART_CACHE_MAX = 120`,
  keyed `coverId + ':' + size`. Makes scrolling a grid cheap. Gone on restart.
- **On disk**: `ArtStore` at `DATA_DIR/art` (`worklet/art-cache.js`), keyed by `coverId`
  ALONE, with no size.

The only writer to disk is the **pin/download** path (`src/bare.js:3625`, inside album
pinning). **Browsing never persists a cover.**

And because the disk key has no size, the shim can only read from disk at one size:

```js
if (artStore && (localOnly || (size === DEFAULT_ART_SIZE && leaseOk()))) {
```

`DEFAULT_ART_SIZE` is 300. The library grid requests 120, 350 or 500 depending on density.
So **grid art never comes from disk at all**, even for an album that has been downloaded.

Net effect: every cold start re-fetches every visible cover over the P2P connection. When
that connection is relayed, it is bandwidth PeerLoom pays for, spent repeatedly on bytes
that do not change. Measured context: three cellular runs on 2026-07-29 had the relay
carrying the connection in most of them, and art is explicitly NOT gated by the new
relay-audio consent (proposal 2026-07-29-relay-audio-consent, decision 1), so it crosses
the relay whether or not the user allowed music to.

## The load-bearing constraint, already documented in the code

`src/bare.js` on the shared blob caches:

> Ids are hashed per library and cannot be attributed back to one, so a precise
> per-library purge needs the cache index to record the library - logged as a follow-up

So today, removing ONE library purges none of its blobs. Only removing the **last** library
clears anything (`audioCache.clear()` + `artStore.clear()`). "Keep it until they delete
that library" therefore cannot be built on the current store - it needs that index. This
proposal builds it, which also closes the pre-existing gap for the AUDIO cache.

The shared-at-the-root design must survive it, and for good reasons stated where it is
defined: ids are already namespaced per library so nothing collides, bytes de-dupe across
libraries, and a track that is mid-play keeps streaming when you switch libraries. So the
fix is an INDEX recording ownership, not a move to per-library directories.

## Scope

Changes:

- `worklet/art-cache.js` - key entries by `coverId` + `size`, mirroring the in-memory key.
  `has/get/put` take a size. The filename becomes `encodeURIComponent(coverId) + '@' + size`.
- `worklet/shim.js` - drop the `size === DEFAULT_ART_SIZE` restriction on the disk read, since
  each size now has its own entry, and WRITE to the store after a successful live fetch
  (the browse path, not only pinning).
- A blob index at `DATA_DIR/blob-owners.json`: `blobKey -> libraryId`, written when art or
  audio is stored. Small, and it is the thing that makes per-library purge possible.
- `removeHost` - purge the blobs the index attributes to that library, for BOTH art and
  audio. The last-library `clear()` stays as the belt-and-braces path.
- A cap on browse-populated art, since it is now unbounded where pinning was bounded by the
  pinned albums. Count-based LRU (see open question 2).

Does NOT change:

- The wire protocol, the host, grants, revoke.
- `AUDIO_DIR`/`ART_DIR` staying SHARED at the root, or the de-dupe and
  keeps-playing-across-a-switch properties that depend on it.
- The lease gate on serving cached art. A revoked or long-offline device still goes dark.
- Whether art crosses the relay without consent. That is decision 1 of the consent
  proposal and is unchanged here; this change makes it happen far less often, which is a
  side benefit and not a reason to revisit the consent boundary.

## Compat

Phone-side, additive, no migration required.

- Existing `DATA_DIR/art` files are keyed without a size. They are simply never hit by the
  new size-keyed lookups, so they become dead bytes. Delete them on first run of the new
  code (a one-line sweep of size-less filenames) rather than leaving orphans forever.
- `blob-owners.json` absent means "nothing attributed yet": removal falls back to today's
  behaviour (purge nothing per library, clear everything when the last library goes), so an
  upgrade is never worse than the status quo.
- Downgrade: an older build ignores the index and reads the old flat key, finding nothing,
  so it re-fetches. Lossy, never broken.

## Verify

Unit:

- `art-cache` round-trips per size: `put(id, 300, a)` and `put(id, 500, b)` are distinct
  entries and `get(id, 500)` returns `b`. This is the bug being fixed, so it gets a test.
- The index attributes a blob to a library, and purging library A leaves library B's blobs
  intact - including the case where BOTH libraries have a cover with the same id, which is
  what the de-dupe makes possible.
- A size-less legacy filename is swept on first run.
- Cap eviction removes the least recently used and never a pinned album's cover.

On device (TCL, wifi is fine - this needs no relay):

1. Cold start, browse a library, note the covers load. Force-stop and cold start again:
   the grid must populate with NO `shim:art-*` fetches in the log, which is the whole point.
2. `run-as` the app and confirm `DATA_DIR/art` holds size-suffixed files after browsing
   ALONE, with nothing pinned.
3. Pair a second library, browse it, remove the FIRST one, and confirm only the first
   library's blobs went.
4. Remove the last library and confirm everything is cleared, as today.

A byte-level check is available if wanted: browse over a relayed cellular connection, then
cold start again and confirm the relay's counter moves on the first pass and not the second.
That is the actual saving, stated in bytes.

## Rollback

Revert. The index and the size-keyed files are additive and an older build ignores both;
the only irreversible act is the legacy sweep, which deletes art that build could not read
anyway and which re-fetches on demand.

## Decisions (Tim, 2026-07-29)

1. **Stale art: accept it, plus a manual refresh.** Covers live until their library is
   removed, which is predictable and never re-downloads on a timer. Most sources change the
   coverId when the art changes, so that case self-invalidates for free. A "Refresh artwork"
   action covers the rest. Rejected: a periodic re-check, which would reintroduce exactly the
   repeated downloads this change exists to remove, only less often.
2. **A generous COUNT cap, with pinned covers exempt.** LRU eviction, a few thousand
   entries, and never evicting a downloaded album's cover. Deliberately NOT a user-facing
   setting - nobody wants to tune an artwork budget, and putting art in competition with
   music for one storage number would be a worse trade than the disk it saves.
3. **The audio cache's per-library purge lands in the SAME change.** The index makes it
   possible and the code comment already asks for it. Today removing one library strands its
   downloaded audio - 97 MB measured on 2026-07-21 - with no way left to reclaim it. Doing
   art alone would leave a documented gap open with the mechanism sitting right there unused.
4. **Ordering: proposal 2026-07-29-relay-audio-consent merged first** (PR #259, merged), so
   this work branches off a clean master rather than stacking on an open PR that edits the
   same `worklet/shim.js`.

## Open questions

None outstanding. The three that gated implementation are decided above.