diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 7bc2408..c61d671 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -108,6 +108,14 @@ interface Shot { // the read in flight rather than the object under it. const shots = new Map>(); +// How often a running scan is allowed to have its catalogue read back, and how +// often the tab may walk the folder again just for being raised. Both are the +// same trade — the reading is watched, not driven — and both are seconds for the +// same reason: the roll is the long thing, and neither the strip nor a folder +// that has not moved is worth a frame of it. +const CATALOGUE_MS = 5000; +const CATCHUP_MS = 30000; + export function Library() { @@ -308,6 +316,14 @@ export function Library() { // walked into something new. let wrote = -1; let drew = ''; + // And when it was last read back. A batch lands every second or so on a + // folder of JPEGs, and reading a catalogue of a hundred thousand frames is a + // second and a half of the page held still and sixty megabytes of rows thrown + // away — a scan that read it back every batch spent the roll doing nothing + // else, and a folder of RAW, with a quarter of a gigabyte of LibRaw open + // beside it, went past what a page is given and took the tab with it. On a + // clock the strip still fills while the scan runs. + let read = 0; const watching = () => { bump(); const session = scanSession(); @@ -334,8 +350,10 @@ export function Library() { // may have no such count: the frames it has stored are then read back on a // frame, as they were before there was one. const written = progress.written ?? progress.done; - if (written !== wrote) { + const now = Date.now(); + if (written !== wrote && now - read >= CATALOGUE_MS) { wrote = written; + read = now; void reload(); } }; @@ -436,6 +454,11 @@ export function Library() { // frames that landed in the folder since. No note: the ring on the row and the // progress line are the report. const rootKey = node ? node.split('/')[0] : null; + // When the folder was last walked for being raised. It stands outside the + // effect because the effect is rebuilt for the folder the reader moves to, and + // the walk it guards is of the folder on the disk, where nothing has moved + // either way. + const synced = useRef(0); useEffect(() => { const root = folders.find((f) => f.name === rootKey); if (!loaded || !root || !ready.has(root.name)) return; @@ -444,6 +467,13 @@ export function Library() { // keeps this one from reading the same roll a second time. const catchUp = () => { if (scanSession()) return; + // A folder of a hundred thousand frames is a hundred thousand stats on the + // disk, and this screen is raised every time the reader comes back from the + // studio — so the walk waits for the folder to have had a moment to change, + // which is a second of a roll the reader was not going to notice and not + // the several it took to walk it again. + if (Date.now() - synced.current < CATCHUP_MS) return; + synced.current = Date.now(); void scanBusy().then((busy) => { if (!busy && !scanSession()) void scan(root, true); }); @@ -902,7 +932,10 @@ export function Library() { ) : null} - {folders.length === 0 ?

{t('lib.noFolders')}

: null} + {/* The folders are on their way in for as long as the catalogue behind + them is being read back, and a screen that says there is no folder + before it has looked is a screen that says it every visit. */} + {loaded && folders.length === 0 ?

{t('lib.noFolders')}

: null} {folders.length > 0 ? (
diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index c15ace3..95fd7b0 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -34,6 +34,10 @@ const BATCH = 50; // on the next frame is time the roll does not get back. Past a few, the disk and // the decoder are the limit and the page has less room to breathe. const LANES = 4; +// How often the walk's position is written down, in the same seconds as the +// catalogue is read back on: the position is the whole of what the walk has found +// and not read, and writing it is a string of a few megabytes on a long roll. +const WALK_MS = 2000; // One LibRaw open at a time, whoever asks for it. A RAW is opened inside a worker // the library builds with a quarter of a gigabyte of linear memory of its own @@ -210,7 +214,7 @@ export async function removeFolder(name: string): Promise { }); // The reading's position goes with the folder: a folder that is gone is not // one to carry on reading, and a folder picked again starts at the top. - clearWalk(name); + await clearWalk(name); } // A handle kept in IndexedDB comes back without its permission: the browser @@ -361,14 +365,33 @@ export interface ScanProgress { // the catalogue only holds the frames that were read — so the reading would // start again at the top of the roll and walk every folder a second time. // -// Local storage holds exactly that position, and holds it for the whole origin, -// which is the point: the tab that reloads carries on from the frame it stopped -// at, and so does the next context to open the app — the installed one beside the -// browser, where session storage, being the tab's own, handed it nothing and the -// walk started at the top of the roll a second time, every folder listed again -// and every frame it already held read again only to be skipped. The key goes -// when the reading finishes. -const WALK_KEY = (folder: string) => `recipescam.library.walk.${folder}`; +// The position is held for the whole origin, which is the point: the tab that +// reloads carries on from the frame it stopped at, and so does the next context +// to open the app — the installed one beside the browser, where session storage, +// being the tab's own, handed it nothing and the walk started at the top of the +// roll a second time, every folder listed again and every frame it already held +// read again only to be skipped. The file goes when the reading finishes. +// +// It is a file in the origin private file system and not a key in local storage, +// which is where it was. The position is one path per frame the walk has found +// and not read — about twenty-eight characters each, measured over a folder of +// three thousand — and local storage on this origin takes 5,000,000 characters +// and then throws, also measured. That is a hundred and seventy-five thousand +// frames: past it the first write of a long roll fails, the position is nowhere, +// and the next visit walks the whole roll from the top — the reader's own +// complaint, and one a roll of RAW reaches in a morning. OPFS takes the same text +// as a file, has room for a catalogue's worth of them, and is the origin's the +// same way. An unwritable position is still a reading that starts at the top: +// private mode lands there, as it always did. +const WALK_DIR = 'walk'; +const WALK_FILE = (folder: string) => `${encodeURIComponent(folder)}.json`; + +async function walkFile(folder: string, create: boolean): Promise { + const root = await navigator.storage?.getDirectory?.(); + if (!root) return null; + const dir = await root.getDirectoryHandle(WALK_DIR, { create }); + return dir.getFileHandle(WALK_FILE(folder), { create }); +} interface WalkSaved { // The folders the walk is through, and the ones it has not read yet, in the @@ -385,7 +408,7 @@ interface WalkSaved { progress: ScanProgress; } -function loadWalk(folder: string): WalkSaved | null { +async function loadWalk(folder: string): Promise { // One reading at a time: a peer that has announced this folder is walking it // right now, and what it has written down is a step of a walk still in // progress. Taken up here, two readings would write the same roll down at @@ -393,9 +416,9 @@ function loadWalk(folder: string): WalkSaved | null { // come up; now that the position is the origin's, it can. if (peer?.folder === folder) return null; try { - const raw = localStorage.getItem(WALK_KEY(folder)); - if (!raw) return null; - const saved = JSON.parse(raw) as WalkSaved; + const fh = await walkFile(folder, false); + if (!fh) return null; + const saved = JSON.parse(await (await fh.getFile()).text()) as WalkSaved; return Array.isArray(saved?.walked) && Array.isArray(saved?.pending) && Array.isArray(saved?.frames) && @@ -409,27 +432,33 @@ function loadWalk(folder: string): WalkSaved | null { } // A position that cannot be written down is a reading that starts from the top, -// which is what this did before — private mode and a full quota both land here. -function saveWalk( +// which is what this did before — private mode lands here, as it always did. +async function saveWalk( folder: string, walked: string[], pending: string[], frames: string[], restored: number, progress: ScanProgress -): void { +): Promise { try { - localStorage.setItem(WALK_KEY(folder), JSON.stringify({ walked, pending, frames, restored, progress } satisfies WalkSaved)); + const fh = await walkFile(folder, true); + if (!fh) return; + const w = await fh.createWritable(); + await w.write(JSON.stringify({ walked, pending, frames, restored, progress } satisfies WalkSaved)); + await w.close(); } catch { // Nothing to write down: the reading starts from the top, as it did before. } } -function clearWalk(folder: string): void { +async function clearWalk(folder: string): Promise { try { - localStorage.removeItem(WALK_KEY(folder)); + const root = await navigator.storage?.getDirectory?.(); + const dir = await root?.getDirectoryHandle(WALK_DIR, { create: false }); + await dir?.removeEntry(WALK_FILE(folder)); } catch { - // Nothing to clear. + // Absent is the desired state. } } @@ -468,7 +497,7 @@ export async function scanFolder( jump?: () => string | null ): Promise { const known = new Map((await listPhotos(folder.name)).map((p) => [p.id, p])); - const saved = loadWalk(folder.name); + const saved = await loadWalk(folder.name); const tree: Walk = { root: folder.handle, pending: [], walked: new Set(saved?.walked ?? []) }; // A queue comes back as paths, so the folders are asked for again; a reading // with nothing written down starts at the picked folder. @@ -528,9 +557,19 @@ export async function scanFolder( // frames the written-down count has already counted. let inHand: string[] = []; const pathOf = (row: LibraryPhoto) => (row.dir ? `${row.dir}/${row.name}` : row.name); - const write = () => { + // The position goes down on a clock, not on every batch. The whole of it is + // written each time — a copy of every path the walk has found and not read — + // and on a roll of a hundred thousand that is a few megabytes turned into a + // string per batch, which is the page held still for the whole roll. A position + // a step behind is a frame or two read again on the next visit, and a frame read + // again is skipped on its size and its time. + let wroteAt = 0; + const write = async () => { + const at = Date.now(); + if (at - wroteAt < WALK_MS) return; + wroteAt = at; const held = [...inHand, ...batch.map(pathOf)]; - saveWalk( + await saveWalk( folder.name, [...tree.walked], tree.pending.map((p) => p.rel), @@ -557,7 +596,7 @@ export async function scanFolder( tx.onerror = () => reject(tx.error); }); progress.written += rows.length; - write(); + await write(); }; // One frame, end to end: its bytes, its tile, its shutter time, and the row the // catalogue keeps. A frame that has not moved is dropped on its size and its @@ -696,7 +735,7 @@ export async function scanFolder( // reads the pass's frames again, and a frame already in the catalogue is // skipped anyway — where the other order would leave the last pass's // frames out of the catalogue until the next visit. - write(); + await write(); onProgress?.(progress); } await drain(); @@ -704,7 +743,7 @@ export async function scanFolder( await flush(); // A reading that came to its end has no position worth keeping: the next one // walks the roll from the top and skips what has not moved. - if (!stop) clearWalk(folder.name); + if (!stop) await clearWalk(folder.name); return progress; }