From e711d895ef2a9e52e3f7f68e8bda860776c0f864 Mon Sep 17 00:00:00 2001 From: sawradip Date: Sun, 1 Nov 2026 02:12:22 +0600 Subject: [PATCH 01/12] feat(reader): recover media whose URL fails while a book is open The reader kept an asset's drawn URL for the life of an open book. When the library moved an adopted key to another copy (a book pruned its file, or the library switched location after its check), or a signed link expired, the element stayed broken until the book was reopened. - Book.renewUrl(id, failed): drops that id's cached answer (only if it is the one that failed; concurrent failures share one new resolution) and resolves again. Implemented for bookFrom (host resolver: a new signed link), streamed and library books (OpenedLibraryBook.forgetUrl drops the index shard and refetches it with cache: no-cache), withLibraries (Library.forget), and the author wrapper. A resolution that threw (not "not available") is retried too. - useAssetSrc / mediaRenewer: an element's error event renews once after a short backoff and swaps the src; a renewed URL that fails before it loaded is left (no loop); a load resets it, so an expiring link renews again later. Wired to page/background/openable/modal images, thumbnails, posters, video, audio (resuming position), , and host renderers (onError). - assetBytes (labs, PDFs) renews once on a failed fetch. - createResolver: forget(id), and locate drops a shard whose path failed. - tools/reader-media-recover-e2e.ts: a disk library, media adopted at two paths, the drawn copy deleted while the book is open. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 1 + docs/guides/run-a-library-server.md | 4 + packages/reader/src/ChalkReader.tsx | 15 +- packages/reader/src/author/state.ts | 2 + packages/reader/src/book.ts | 59 ++++++-- packages/reader/src/libraries.ts | 14 ++ packages/reader/src/library-stream.test.ts | 47 +++++- packages/reader/src/media-recover.test.ts | 159 +++++++++++++++++++++ packages/reader/src/panels/Thumbnails.tsx | 6 +- packages/reader/src/present.tsx | 5 + packages/reader/src/render/Embeds.tsx | 40 ++++-- packages/reader/src/render/Markers.tsx | 6 +- packages/reader/src/render/PageView.tsx | 10 +- packages/reader/src/render/assets.ts | 94 +++++++++++- packages/reader/src/stream.ts | 34 ++++- packages/store/src/library-book/load.ts | 17 ++- packages/store/src/library.ts | 2 + packages/store/src/registry-library.ts | 7 +- packages/store/src/resolver.test.ts | 16 +++ packages/store/src/resolver.ts | 11 ++ tools/reader-media-recover-e2e.ts | 131 +++++++++++++++++ 21 files changed, 615 insertions(+), 65 deletions(-) create mode 100644 packages/reader/src/media-recover.test.ts create mode 100644 tools/reader-media-recover-e2e.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 23a5be3..b5559b0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,6 +51,7 @@ npx tsx tools/reader-library-e2e.mjs [url] [shots] # libraries: gallery, uploa node tools/reader-stream-e2e.mjs [url] [shots] # a streamed 60-page book: what is fetched, and when npx tsx tools/reader-library-book-e2e.ts [url] [--read-token t] # a book published into a (test) library, opened from it npx tsx tools/reader-library-auth-e2e.ts [url] [--port 4923] # signing in to a private library (a fake one in-process), refresh, sign-out +npx tsx tools/reader-media-recover-e2e.ts # media adopted at two paths, one deleted while the book is open: the reader recovers (a disk test library, ADOPT_PREFIXES=books/, built showcase) npx tsx tools/question-current-e2e.ts [--read-token t] # a corrected question reaches readers with no new links item (test library: writes) node tools/reader-insert-menu-e2e.mjs [url] # author mode's right-click menu: what is added lands where you clicked node tools/reader-save-e2e.mjs [url] # author mode's Save: the filled button, Ctrl+S, onDirtyChange, the ref's save() diff --git a/docs/guides/run-a-library-server.md b/docs/guides/run-a-library-server.md index 0a70227..1d415c4 100644 --- a/docs/guides/run-a-library-server.md +++ b/docs/guides/run-a-library-server.md @@ -167,6 +167,10 @@ the asset with that path as its location. Nothing is copied. cache). If every copy is gone, readers get a 404 (they verify what they get, so other bytes at a path are refused). Adopting a path again HEADs it and reads nothing. The schema step that allows this (step 4: `locations` keyed by key, store and path, with `checked_at` and `missing`) runs by itself on the first start. + A book already open in the reader recovers too: a picture, video, audio or model whose URL fails to load is asked + for again once (the reader drops the index shard it held and fetches it past the browser's cache) and drawn from + the copy the library names now; the same goes for a signed link (`/v1/assets/{key}/link`) that expired while a + video played, when the host's resolver hands out links. - A key the library already has (uploaded, or adopted elsewhere) stays where it is; adopting it again only updates its title, tags and the other descriptive fields, and reads nothing. diff --git a/packages/reader/src/ChalkReader.tsx b/packages/reader/src/ChalkReader.tsx index ee224cb..390c2a0 100644 --- a/packages/reader/src/ChalkReader.tsx +++ b/packages/reader/src/ChalkReader.tsx @@ -12,7 +12,7 @@ import { AudioEmbed, LabFrame, ModelViewer, VideoEmbed, localLabStorage, type La import { Modal } from './render/Modal.js'; import { FloatingWindow } from './render/FloatingWindow.js'; import { aspectFor, type Box as WindowBox } from './render/windowGeometry.js'; -import { useAssetUrl } from './render/assets.js'; +import { useAssetSrc, useResume } from './render/assets.js'; import { packOverlay, unpackOverlay, type BookPatch, type MergeReport, type PatchResult } from '@chalk-format/container'; import type { Overlay } from '@chalk-format/model'; import { useAnnotations } from './annotate/state.js'; @@ -887,10 +887,10 @@ function tooSmall(el: HTMLElement, lab: boolean): boolean { /** A full-size image, drawn as HTML so that it can be tapped when its author made it open (spec 1.3). */ function OpenableImage({ book, o, label, onActivate }: { book: Book; o: Extract; label: string; onActivate?: ((el: HTMLElement) => void) | undefined }) { - const url = useAssetUrl(book, o.assetId); + const { url, onError, onLoad } = useAssetSrc(book, o.assetId); return ( - {url ? : } + {url ? : } ); } @@ -908,10 +908,11 @@ function SnipView({ book, pageId, renderHtmlObject }: { book: Book; pageId: stri /** Icon-mode media opened in the dialog, at a comfortable size. */ function MediaInModal({ book, o, locale, theme, storage, onClose, fill }: { book: Book; o: LessonObject; locale: Locale; theme: 'light' | 'dark'; storage: LabStorage | null; onClose: () => void; fill?: boolean }) { - const url = useAssetUrl(book, o.type === 'image' ? o.assetId : o.type === 'embed' ? o.assetId : undefined); + const { url, onError, onLoad } = useAssetSrc(book, o.type === 'image' ? o.assetId : o.type === 'embed' ? o.assetId : undefined); + const resume = useResume(onError, onLoad); const pr = usePresenter(); const mediaClass = fill ? 'chalk-window-media' : 'chalk-modal-media'; - if (o.type === 'image') return url ? {pr.present(o).label : null; + if (o.type === 'image') return url ? {pr.present(o).label : null; if (o.type !== 'embed') return null; const big = fill ? { position: 'relative' as const, width: '100%', height: '100%' } : { position: 'relative' as const, width: '100%', aspectRatio: o.embed === 'model3d' ? '4 / 3' : '16 / 9' }; if (isLabObject(o)) return ; @@ -919,8 +920,8 @@ function MediaInModal({ book, o, locale, theme, storage, onClose, fill }: { book const p = pr.present(o); // opened by a tap: the student asked, so it plays (with sound unless the author muted it) const R = pr.renderers.video; - if (o.embed === 'video' && R) return
{R({ object: o, src: url ?? null, poster: null, presentation: p, where: 'popup', ...pr.env })}
; - if (o.embed === 'video') return url ?