Cover sync¶
Status: proposed, not scheduled. Book covers currently don't travel with
metadata sync — this proposal weighs the options. The schema change itself,
when accepted, lands in docs/nostr-event-model.md (the binding contract),
not here.
What is true today¶
- Metadata edits already update the "book announcement" event. Kind 30101
is addressable (
d= the file's sha256), and every field in the edit dialog (title, creator, publisher, language, ISBN, description) lives in its content. Editing bumpsupdatedAt; the next explicit sync republishes the same event (same kind, samed), which replaces the old version on relays. No change needed for that half of the ask. - The cover is NOT in the 30101 event. Its content is JSON metadata only.
The only way cover bytes leave the device today is the per-book Blossom
file backup:
backupBook()uploads the EPUB and the cover blob, and recordsblossom: { servers, coverSha256 }in the book record (and thus in 30101).restoreBook()re-extracts the cover from the EPUB bytes, falling back to downloadingcoverSha256. - Consequences of that gap:
- A ghost book (metadata pulled, file not on this device) has no cover unless the book was Blossom-backed-up.
- Browse (someone's public shelf) shows no covers.
- A replaced cover (edit dialog) is device-local forever — and a restore overwrites it with the EPUB-embedded one.
Constraints¶
- Relay event size: our default relay caps events at 32 KB
(
maxEventSize = 32768, strfry.conf; strfry's own default is 64 KB — design for 32 KB so we work on the strictest relay we know of). - NIP-44 inflation: private 30101 content is NIP-44-to-self ciphertext, base64-encoded — payload bytes inflate ~4/3, twice (base64 image inside JSON, then base64 ciphertext). Working backwards from a 30 KB event budget: plaintext JSON ≤ ~22 KB → base64 cover ≤ ~20 KB → raw cover image ≤ ~15 KB. (NIP-44's own plaintext ceiling is 64 KB — not the binding limit here.)
- Design rule 2 (private by default): a private book's cover must not leak. Anything that puts the cover on a public-by-hash Blossom server trades a little privacy (covers reveal what you read to the server operator, and to anyone holding the hash).
- Design rule 5 (explicit only): cover upload/download must ride the existing explicit actions (import, edit-save, Sync, backup) — no new background transfers.
Option A — inline thumbnail in 30101 content¶
Add an optional field to the 30101 content schema (and the local Book
record it mirrors):
- Encode: at import and at edit-save, derive a thumbnail from the cover
blob — 2:3 center-crop, ≤ 192×288, JPEG with a quality loop that walks
down until the encoded size is under a hard 12 KB cap (skip the field if
even q=0.4 can't fit, e.g. pathological images). The full-resolution cover
blob stays in the local
coversstore; the thumbnail is only the wire format. - Decode: when a pulled 30101 wins LWW and carries
cover, write it to thecoversstore — but only if the local cover is absent or the remote record is strictly newer (the same rule the rest of the record already follows). - Degradation ratchet, and its fix: device B receives the thumbnail; if
B re-encoded a thumbnail of that thumbnail on its next push, quality
would decay generation by generation. Fix: tag cover records with
origin: 'original' | 'synced'; when encoding a draft from asyncedcover, reuse the stored base64 verbatim instead of re-encoding. Stable after one hop. - Shared books: the plaintext variant carries the same field → browse and ghost books get covers for free, for every reader. (~16 KB plaintext event — comfortably inside 32 KB.)
- Pros: zero new infrastructure; works for private books (cover rides inside the ciphertext — no leak); covers ghost books, browse, and edited-cover propagation in one move; additive schema change (old events simply lack the field; vibereader-era events unaffected).
- Cons: thumbnail quality only (~192×288 — fine for cards and browse, not for a large book-detail view); fattens every 30101 from ~1 KB to ~15–20 KB (sync pulls a few hundred KB for a 30-book shelf — acceptable, but no longer negligible); permanently spends half the 32 KB event budget.
Option B — standalone Blossom cover, pointer in the event¶
Decouple the cover upload from the full-file backup: upload the cover blob alone (10–100 KB — cheap) at import/edit/sync time, and put the pointer in the content:
Receivers fetch missing covers by hash after a sync pull (explicit action: the sync click covers it).
- Pros: full resolution; events stay ~1 KB; this is the architecturally "pure" split (relays for events, Blossom for blobs — PHILOSOPHY.md).
- Cons:
- Privacy: violates constraint 3 for private books — the cover becomes a public-by-hash blob and the Blossom server sees pubkey ↔ cover. Would need to be opt-in or shared-books-only, which reintroduces the gap for private books.
- Availability: requires a working Blossom server. Known ecosystem state (CLAUDE.md gotchas): nostr.download is the only usable public default, and the homelab has no Blossom server yet. Cover display on a new device would hard-depend on a third party staying up.
- More failure modes (CORS, 4xx, server churn) for a cosmetic feature.
Option C — hybrid (recommended)¶
A is the baseline, the existing backup path is the enhancement.
- Inline thumbnail in 30101 per Option A → every synced/browsed/ghost book has a card-quality cover, private books included, no new dependencies.
- Keep
blossom.coverSha256exactly as it works today (full-res cover uploaded alongside the file at backup time). Display preference: local original → Blossom full-res (already fetched on restore) → synced thumbnail. - Don't build B's standalone cover upload at all unless a real need for full-res covers on file-less devices shows up.
Implementation sketch (when scheduled)¶
docs/nostr-event-model.md: add the optionalcoverfield to the 30101 schema (doc-first, per its ownership header).db/types.ts:Covergainsorigin: 'original' | 'synced'(additive; existing records default to'original').Bookdoes NOT grow a field — the thumbnail is derived at encode time, keeping "the grid never touches blobs" true.read/nostr/events.ts:bookDraft()gains the thumbnail encode (quality-loop ≤ 12 KB, verbatim pass-through fororigin: 'synced');parseRemote()/parseForeign()passcoverthrough.read/nostr/sync.ts: on a winning 30101 pull, save the decoded cover (absent-or-newer rule).browse/ghost stores render foreign covers from the parsed field.- Edit dialog: unchanged — replacing a cover already bumps
updatedAt, which is what makes the next sync carry it. - e2e: encode→decode roundtrip with a fixture cover; two-profile live gate: replace cover on A, sync, cover appears on B (and on B's ghost card).
Open questions¶
- Thumbnail budget: 12 KB raw is conservative against the 32 KB relay cap — could go to ~20 KB if we accept being near the ceiling on relays we don't control. Start conservative.
- Should shared 30101s also carry a standard
imagetag (URL) when a Blossom cover exists, for interop with generic nostr clients that render link previews? Cheap, but only meaningful once shelves are consumed outside PlebChat. Defer. - WebP instead of JPEG (~30 % smaller at equal quality, universal decode
support by now)? Probably yes at implementation time; the schema's
mimeTypefield already allows it.