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 { 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); }