a closed, named list of events — `files:uploaded`, `archive:extracted`,
`files:placed` — each carrying absolute paths and an owner. NOT an event bus in the
open sense: `emit(anyString, anything)` becomes an API nobody can change, because
the moment a plugin listens for a string that string is a contract with no list
saying so. a union means adding one is a deliberate edit and removing one fails to
compile.
TWO GUARANTEES, AND THEY ARE THE WHOLE DESIGN. emit never blocks: it schedules and
returns, so an upload does not wait for a subscriber thumbnailing a HEIC. and a
subscriber cannot fail the operation that triggered it — the upload is written and
answered before anybody is told, so a plugin that is down, slow or throwing gets a
line in the log. tested both ways, sync and async, plus that one failing listener
does not stop the next.
PLUGINS SUBSCRIBE THROUGH THE MANIFEST, same shape as `fileTypes`: `hooks: {
'files:uploaded': '/v1/on-upload' }`. resolved at EMIT time rather than at boot,
because plugins are installed, updated and disabled while the server runs and a
registration made at startup describes a machine that has since changed. nothing
here names a plugin.
CORE IS THE FIRST SUBSCRIBER, and that is deliberate rather than a shortcut. the
thumbnail cache belongs to the platform — its key, its route — so a plugin doing
this work would produce files the browser never looks at. the warmer calls the same
`buildThumbnail` the request path calls, which means HEIC still warms through
av-club via the fileTypes claim, without this file knowing that plugin exists.
AND A SINGLE-FLIGHT MAP, which the warmer made necessary. two callers now want the
same thumbnail as a matter of course: the warmer starts on upload and the browser
asks a moment later. measured before the fix — four concurrent requests plus the
warmer each decoded the same 4032x3024 HEIC and took a slot from a limit of four,
which is a warmer competing with the person waiting rather than helping them. after:
all five share one build, 5.5s once, then 130ms.
verified end to end: upload returns in 56ms, four concurrent requests all 200 in a
single 5.5s build, cached 130ms. 1679 tests, docs:check green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
108 lines
4.3 KiB
TypeScript
108 lines
4.3 KiB
TypeScript
import { join } from 'node:path';
|
|
import { DATA_PATH } from '../data-path';
|
|
import { buildThumbnail, thumbnailKind } from '../api/file-browser/thumbnails';
|
|
import { pluginFileTypes, sidecarFetch } from '../api/file-browser/plugin-file-types';
|
|
import { on, type FileEventPayload } from './bus';
|
|
|
|
/**
|
|
* Make the thumbnails before anybody asks for them.
|
|
*
|
|
* ── Why this is worth doing at all ──
|
|
*
|
|
* Thumbnails are generated on demand and cached. That is right — most files are never looked at — but
|
|
* it means the first person to open a folder waits for the work. For a jpeg that is 40ms nobody
|
|
* notices. For a HEIC it is four and a half seconds each, because ImageMagick decodes the whole
|
|
* 4032x3024 frame before sharp sees it. A folder of photographs from a phone is thirteen seconds of
|
|
* staring at file icons.
|
|
*
|
|
* An upload is the one moment the platform KNOWS which files are new, so it is the moment to do it.
|
|
*
|
|
* ── This is core subscribing to core, and that is the point ──
|
|
*
|
|
* The thumbnail cache belongs to the platform: keyed on its scheme, read by its route. A plugin doing
|
|
* this work would produce them somewhere the file browser never looks. So the warmer lives here and
|
|
* uses the same `buildThumbnail` the request path uses — which means a HEIC still warms through
|
|
* av-club, via the `fileTypes` claim, without this file knowing that plugin exists.
|
|
*
|
|
* ── ONE size, and it is the one both views ask for ──
|
|
*
|
|
* The cache key includes the size. Warming 128 when the browser asks for 256 is work that produces
|
|
* nothing anybody reads, so this must stay equal to what `FileItem` requests. Both views were made to
|
|
* ask for 256 for exactly this reason.
|
|
*/
|
|
|
|
const WARM_SIZE = 256;
|
|
|
|
/**
|
|
* How many to warm at once.
|
|
*
|
|
* Lower than the request path's own limit on purpose. This is background work competing with a person
|
|
* who is actually waiting: somebody opening a different folder while an upload warms must not queue
|
|
* behind it. `buildThumbnail` has its own cap too, so this is a second, tighter gate rather than the
|
|
* only one.
|
|
*/
|
|
const WARM_CONCURRENCY = 2;
|
|
|
|
/** Stop after this many. An unpacked archive can hold thousands, and warming all of them is not a favour. */
|
|
const WARM_LIMIT = 200;
|
|
|
|
async function warm(payload: FileEventPayload): Promise<void> {
|
|
const claimed = await pluginFileTypes();
|
|
|
|
// Only what will actually be asked for: core's own types plus whatever a plugin claims. Anything else
|
|
// has no thumbnail to make and would be a spawn per file for nothing.
|
|
const targets = payload.paths
|
|
.filter((path) => {
|
|
const name = path.slice(path.lastIndexOf('/') + 1);
|
|
return !!thumbnailKind(name) || claimed.has(name.slice(name.lastIndexOf('.') + 1).toLowerCase());
|
|
})
|
|
.slice(0, WARM_LIMIT);
|
|
|
|
if (targets.length === 0) return;
|
|
|
|
const userDataDir = join(DATA_PATH, payload.email);
|
|
let index = 0;
|
|
|
|
const worker = async () => {
|
|
while (index < targets.length) {
|
|
const path = targets[index++]!;
|
|
const name = path.slice(path.lastIndexOf('/') + 1);
|
|
const kind = thumbnailKind(name);
|
|
const claim = kind ? null : claimed.get(name.slice(name.lastIndexOf('.') + 1).toLowerCase());
|
|
|
|
/*
|
|
Exactly what the request path does, including its cache and its remembered failures — so a file
|
|
warmed here is a cache HIT when the browser asks, and a file that cannot be thumbnailed is not
|
|
attempted twice.
|
|
*/
|
|
await buildThumbnail(
|
|
path,
|
|
userDataDir,
|
|
kind ?? 'image',
|
|
WARM_SIZE,
|
|
claim?.thumbnail
|
|
? async () => {
|
|
const res = await sidecarFetch(
|
|
`${claim.thumbnail}?path=${encodeURIComponent(path)}&size=${WARM_SIZE}`,
|
|
payload.email,
|
|
);
|
|
return res.ok ? Buffer.from(await res.arrayBuffer()) : null;
|
|
}
|
|
: undefined,
|
|
).catch(() => {
|
|
// Already logged by the builder where it matters. A warm that fails costs nothing: the request
|
|
// path will try again and answer honestly.
|
|
});
|
|
}
|
|
};
|
|
|
|
await Promise.all(Array.from({ length: Math.min(WARM_CONCURRENCY, targets.length) }, worker));
|
|
}
|
|
|
|
/** Called once from start-up. */
|
|
export function registerThumbnailWarmer(): void {
|
|
on('files:uploaded', warm);
|
|
on('archive:extracted', warm);
|
|
on('files:placed', warm);
|
|
}
|