authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-09 23:34:10-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-12 18:10:53-07:00
log12f1b96207363924cca0970b5e6cb515a6d7541a
tree6a7082de6ca703b06e9f72831b15fe16276e7c24
parentb6380f3a43e39e9571af27722d728fc17327d472
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ

feat: watcher-based file indexing

Assisted-by: Claude:claude-fable-5

39 files changed, 2995 insertions(+), 1200 deletions(-)

framework/lib/sqlite.ts+33-16
...@@ -26,7 +26,7 @@ export class WrappedDatabase {...@@ -26,7 +26,7 @@ export class WrappedDatabase {
2626
27 constructor(file: string) {27 constructor(file: string) {
28 this.file = file;28 this.file = file;
29 this.node = new DatabaseSync(file);29 this.node = WrappedDatabase.open(file);
30 this.node.exec(`30 this.node.exec(`
31 create table if not exists clover_migrations (31 create table if not exists clover_migrations (
32 key text not null primary key,32 key text not null primary key,
...@@ -35,6 +35,16 @@ export class WrappedDatabase {...@@ -35,6 +35,16 @@ export class WrappedDatabase {
35 `);35 `);
36 }36 }
3737
38 // wal so the source of truth can serve reads + db snapshots while the
39 // indexer writes. foreign keys are per-connection and must be re-applied
40 // on reload.
41 private static open(file: string) {
42 const node = new DatabaseSync(file);
43 node.exec(`pragma journal_mode = wal;`);
44 node.exec(`pragma foreign_keys = on;`);
45 return node;
46 }
47
38 // TODO: add migration support48 // TODO: add migration support
39 // the idea is you keep `schema` as the new schema but can add49 // the idea is you keep `schema` as the new schema but can add
40 // migrations to the mix really easily.50 // migrations to the mix really easily.
...@@ -60,20 +70,13 @@ export class WrappedDatabase {...@@ -60,20 +70,13 @@ export class WrappedDatabase {
60 );70 );
61 query = lines.map((x) => x.slice(trim)).join("\n");71 query = lines.map((x) => x.slice(trim)).join("\n");
6272
63 let prepared;73 const stmt = new Stmt<Args, Result>(this, query);
64 try {
65 prepared = this.node.prepare(query);
66 } catch (err) {
67 if (err) (err as { query: string }).query = query;
68 throw err;
69 }
70 const stmt = new Stmt<Args, Result>(prepared);
71 this.stmts.push(stmt);74 this.stmts.push(stmt);
72 return stmt;75 return stmt;
73 }76 }
7477
75 reload() {78 reload() {
76 const newNode = new DatabaseSync(this.file);79 const newNode = WrappedDatabase.open(this.file);
77 this.node.close();80 this.node.close();
78 this.node = newNode;81 this.node = newNode;
79 for (const stmt of this.stmts) {82 for (const stmt of this.stmts) {
...@@ -83,17 +86,31 @@ export class WrappedDatabase {...@@ -83,17 +86,31 @@ export class WrappedDatabase {
83}86}
8487
85export class Stmt<Args extends unknown[] = unknown[], Row = unknown> {88export class Stmt<Args extends unknown[] = unknown[], Row = unknown> {
86 #node: StatementSync;89 // statements prepare lazily on first use, so that importing a model
90 // module never requires its tables to exist yet (the migration scripts
91 // depend on this, and it makes `reload` cheap).
92 #db: WrappedDatabase;
93 #lazyNode: StatementSync | null = null;
87 #class: any | null = null;94 #class: any | null = null;
88 query: string;95 query: string;
8996
90 constructor(node: StatementSync) {97 constructor(db: WrappedDatabase, query: string) {
91 this.#node = node;98 this.#db = db;
92 this.query = node.sourceSQL;99 this.query = query;
100 }
101
102 get #node(): StatementSync {
103 if (this.#lazyNode) return this.#lazyNode;
104 try {
105 return this.#lazyNode = this.#db.node.prepare(this.query);
106 } catch (err) {
107 if (err) (err as any).query = this.query;
108 throw err;
109 }
93 }110 }
94111
95 private reload(db: DatabaseSync) {112 private reload(_db: DatabaseSync) {
96 this.#node = db.prepare(this.query);113 this.#lazyNode = null;
97 }114 }
98115
99 /** Get one row */116 /** Get one row */
lib/log.ts+19-6
...@@ -457,11 +457,24 @@ export function createTerminalWidgetHost(...@@ -457,11 +457,24 @@ export function createTerminalWidgetHost(
457 timer = null;457 timer = null;
458 ASSERT(!rendering);458 ASSERT(!rendering);
459 rendering = true;459 rendering = true;
460 // the finally is load-bearing: if a render callback or terminal write
461 // throws, `rendering` must reset, or the exit path's cancel() asserts
462 // and masks the original error.
463 try {
464 redrawCallbackInner();
465 } finally {
466 rendering = false;
467 }
468 }
469
470 function redrawCallbackInner() {
460 redrawTime = (lastFlush = now()) - 0.00001; // windows time precision workaround471 redrawTime = (lastFlush = now()) - 0.00001; // windows time precision workaround
461472
462 // trivial path when not using widgets473 // trivial path when not using widgets
463 if (!lines.length && !widgets.length) {474 if (!lines.length && !widgets.length) {
464 ASSERT(buffer);475 // a redraw can get scheduled with nothing to write (e.g. widgets torn
476 // down before the timer fired); it is a no-op, not an error
477 if (!buffer) return;
465 needsToRestoreCursor = false;478 needsToRestoreCursor = false;
466 needsToSaveCursor = false;479 needsToSaveCursor = false;
467 if (writeOutputTemporaryLock) {480 if (writeOutputTemporaryLock) {
...@@ -482,7 +495,6 @@ export function createTerminalWidgetHost(...@@ -482,7 +495,6 @@ export function createTerminalWidgetHost(
482 }495 }
483 partialLineIndex = partialLineLength(buffer);496 partialLineIndex = partialLineLength(buffer);
484 buffer = "";497 buffer = "";
485 rendering = false;
486 return;498 return;
487 }499 }
488500
...@@ -544,7 +556,6 @@ export function createTerminalWidgetHost(...@@ -544,7 +556,6 @@ export function createTerminalWidgetHost(
544 if (buffer) terminal.writeOutput(buffer);556 if (buffer) terminal.writeOutput(buffer);
545 buffer = "";557 buffer = "";
546 if (hasSyncStart) terminal.writeInteractive(ansi.syncEnd);558 if (hasSyncStart) terminal.writeInteractive(ansi.syncEnd);
547 rendering = false;
548 return;559 return;
549 }560 }
550561
...@@ -645,7 +656,6 @@ export function createTerminalWidgetHost(...@@ -645,7 +656,6 @@ export function createTerminalWidgetHost(
645 needsToSaveCursor = false;656 needsToSaveCursor = false;
646 lines = newWidgetLines;657 lines = newWidgetLines;
647 buffer = "";658 buffer = "";
648 rendering = false;
649 }659 }
650660
651 function redrawSoon(ms: number) {661 function redrawSoon(ms: number) {
...@@ -770,8 +780,11 @@ export function createTerminalWidgetHost(...@@ -770,8 +780,11 @@ export function createTerminalWidgetHost(
770 };780 };
771 },781 },
772 cancel() {782 cancel() {
773 ASSERT(!rendering, "cannot call cancel() during rendering");783 // cancel may be reached from exit handlers while a redraw is on the
774 flushAndClear(false);784 // stack (a crash inside a render callback unwinds through here); skip
785 // the flush in that case and just tear down, so the original error
786 // is the one that surfaces.
787 if (!rendering) flushAndClear(false);
775 widgets.splice(0, widgets.length);788 widgets.splice(0, widgets.length);
776 internals.splice(0, internals.length);789 internals.splice(0, internals.length);
777 },790 },
lib/progress.test.ts+25
...@@ -248,6 +248,30 @@ test("event encoding round trip cases", async () => {...@@ -248,6 +248,30 @@ test("event encoding round trip cases", async () => {
248 },248 },
249 [9],249 [9],
250 ]);250 ]);
251
252 // log messages: a non-info level without scope/stack/custom previously
253 // desynced the whole stream (the flag tests used `&&` instead of `&`),
254 // and the message/stack-frame loops never decremented their counters
255 await roundTrip([
256 0,
257 {
258 k: 1 as EncodedKey,
259 t: "encode av1",
260 l: [
261 { level: "warn", text: "deprecated pixel format", time: 1768210850000 },
262 { level: "info", text: "hello", time: 1768210851000 },
263 {
264 level: "error",
265 text: "scoped + stacked",
266 time: 1768210852000,
267 scope: "ffmpeg",
268 stack: [{ fn: "spawn", file: "ffmpeg.ts", line: 12, col: 3 }],
269 },
270 ],
271 [progress.internals.kNode]: fakeNode,
272 },
273 [1],
274 ]);
251});275});
252276
253function cleanProgressEvent(event: progress.StreamEvent) {277function cleanProgressEvent(event: progress.StreamEvent) {
...@@ -255,6 +279,7 @@ function cleanProgressEvent(event: progress.StreamEvent) {...@@ -255,6 +279,7 @@ function cleanProgressEvent(event: progress.StreamEvent) {
255 typeof x === "object" && !Array.isArray(x)279 typeof x === "object" && !Array.isArray(x)
256 ? testing.removeUndefinedKeys({280 ? testing.removeUndefinedKeys({
257 ...x,281 ...x,
282 l: x.l?.map((m) => testing.removeUndefinedKeys({ ...m })),
258 E: undefined,283 E: undefined,
259 p: undefined,284 p: undefined,
260 s: undefined,285 s: undefined,
lib/progress.ts+36-11
...@@ -1007,6 +1007,7 @@ export function decodeEventStream<...@@ -1007,6 +1007,7 @@ export function decodeEventStream<
1007 }1007 }
1008 ASSERT(hasEmittedEnd, "Stream terminated early.");1008 ASSERT(hasEmittedEnd, "Stream terminated early.");
1009 } catch (err) {1009 } catch (err) {
1010 console.error(err);
1010 reader.cancel(err);1011 reader.cancel(err);
1011 if (!signal.aborted) reject(err);1012 if (!signal.aborted) reject(err);
1012 } finally {1013 } finally {
...@@ -1251,6 +1252,9 @@ class Decoder<...@@ -1251,6 +1252,9 @@ class Decoder<
1251 t: text,1252 t: text,
1252 v: value,1253 v: value,
1253 e: total,1254 e: total,
1255 s: showTotal,
1256 p: passive,
1257 h: hidden,
1254 l: messages,1258 l: messages,
1255 c: children,1259 c: children,
1256 E: estimatedTime,1260 E: estimatedTime,
...@@ -1265,6 +1269,9 @@ class Decoder<...@@ -1265,6 +1269,9 @@ class Decoder<
1265 if (text) node.text = text;1269 if (text) node.text = text;
1266 if (value) node.value = value;1270 if (value) node.value = value;
1267 if (total) node.total = total;1271 if (total) node.total = total;
1272 if (showTotal != null) node.showTotal = showTotal;
1273 if (passive != null) node.passive = passive;
1274 if (hidden != null) node.hidden = hidden;
1268 for (const message of messages ?? []) node.log.writeMessage(message);1275 for (const message of messages ?? []) node.log.writeMessage(message);
1269 if (estimatedTime) node.estimatedTime = estimatedTime;1276 if (estimatedTime) node.estimatedTime = estimatedTime;
1270 if (formattedValue) node.valueFormatter = () => formattedValue;1277 if (formattedValue) node.valueFormatter = () => formattedValue;
...@@ -1275,6 +1282,9 @@ class Decoder<...@@ -1275,6 +1282,9 @@ class Decoder<
1275 text,1282 text,
1276 value,1283 value,
1277 total,1284 total,
1285 showTotal,
1286 passive,
1287 hidden,
1278 messages,1288 messages,
1279 estimatedTime,1289 estimatedTime,
1280 formattedValue,1290 formattedValue,
...@@ -1301,7 +1311,9 @@ class Decoder<...@@ -1301,7 +1311,9 @@ class Decoder<
13011311
1302 startRecursive(key: EncodedKey, opts: PendingStart): Ref {1312 startRecursive(key: EncodedKey, opts: PendingStart): Ref {
1303 ASSERT(this.pendingStart.delete(key));1313 ASSERT(this.pendingStart.delete(key));
1304 const parentId = UNWRAP(this.pendingParents.get(key));1314 // a node whose parent linkage is missing (malformed stream) attaches at
1315 // the root rather than killing the whole decoder
1316 const parentId = this.pendingParents.get(key) ?? (0 as EncodedKey);
1305 let ref: Ref | null = parentId === 01317 let ref: Ref | null = parentId === 0
1306 ? this.target1318 ? this.target
1307 : this.active.get(parentId) ?? null;1319 : this.active.get(parentId) ?? null;
...@@ -1311,13 +1323,14 @@ class Decoder<...@@ -1311,13 +1323,14 @@ class Decoder<
1311 UNWRAP(this.pendingStart.get(parentId)),1323 UNWRAP(this.pendingStart.get(parentId)),
1312 );1324 );
1313 }1325 }
1314 const { text, estimatedTime, ...options } = opts;1326 const { text, estimatedTime, formattedValue, ...options } = opts;
1315 const node = ref.start(text, {1327 const node = ref.start(text, {
1316 ...options,1328 ...options,
1317 estimateCompletion: false,1329 estimateCompletion: false,
1318 });1330 });
1319 this.active.set(key, node);1331 this.active.set(key, node);
1320 node.estimatedTime = estimatedTime;1332 node.estimatedTime = estimatedTime;
1333 if (formattedValue) node.valueFormatter = () => formattedValue;
1321 this.pendingParents.delete(key);1334 this.pendingParents.delete(key);
1322 return node;1335 return node;
1323 }1336 }
...@@ -1327,6 +1340,9 @@ interface PendingStart {...@@ -1327,6 +1340,9 @@ interface PendingStart {
1327 text: string;1340 text: string;
1328 value?: number;1341 value?: number;
1329 total?: number;1342 total?: number;
1343 showTotal?: boolean;
1344 passive?: boolean;
1345 hidden?: boolean;
1330 messages?: log.Message[];1346 messages?: log.Message[];
1331 estimatedTime?: number | null;1347 estimatedTime?: number | null;
1332 formattedValue?: string;1348 formattedValue?: string;
...@@ -1504,21 +1520,27 @@ export function decodeByteStream<...@@ -1504,21 +1520,27 @@ export function decodeByteStream<
1504 target: Ref,1520 target: Ref,
1505): async.Cancelable<Result> & Events<EventMap> {1521): async.Cancelable<Result> & Events<EventMap> {
1506 let cancelled = false;1522 let cancelled = false;
1523 let reader: stream.BufferedReader | null = null;
1507 return decodeEventStream<Result, EventMap>(1524 return decodeEventStream<Result, EventMap>(
1508 new ReadableStream<StreamEvent>({1525 new ReadableStream<StreamEvent>({
1509 async start(controller) {1526 async start(controller) {
1510 using reader = new stream.BufferedReader(encoded.getReader());1527 reader = new stream.BufferedReader(encoded.getReader());
1511 try {1528 try {
1512 while (true) controller.enqueue(await readStreamEvent(reader));1529 while (reader) controller.enqueue(await readStreamEvent(reader));
1513 } catch (e) {1530 } catch (e) {
1514 if (!cancelled) {1531 if (!cancelled) {
1515 reader.cancel(e);1532 reader.cancel(e);
1516 throw e;1533 throw e;
1517 }1534 }
1535 } finally {
1536 reader?.releaseLock();
1537 reader = null;
1518 }1538 }
1519 },1539 },
1520 cancel(reason) {1540 cancel(reason) {
1521 cancelled = true;1541 cancelled = true;
1542 reader?.releaseLock();
1543 reader = null;
1522 encoded.cancel(reason);1544 encoded.cancel(reason);
1523 },1545 },
1524 }),1546 }),
...@@ -1560,20 +1582,23 @@ async function readStreamEvent(r: stream.BufferedReader): Promise<StreamEvent> {...@@ -1560,20 +1582,23 @@ async function readStreamEvent(r: stream.BufferedReader): Promise<StreamEvent> {
1560 if (flags.changedLogs) {1582 if (flags.changedLogs) {
1561 l = [];1583 l = [];
1562 let len = await r.varUint();1584 let len = await r.varUint();
1563 while (len > 0) {1585 while (len-- > 0) {
1564 const msgFlags = await r.u8();1586 const msgFlags = await r.u8();
1565 const level = logLevelSerialize[msgFlags & 0b1111] ?? "info";1587 const level = logLevelSerialize[msgFlags & 0b1111] ?? "info";
1566 const hasScope = msgFlags && (1 << 5) > 0;1588 // bitwise tests: `msgFlags && (1 << 5) > 0` parsed as
1567 const hasStack = msgFlags && (1 << 6) > 0;1589 // `msgFlags && true`, which fabricated scope/stack/custom reads for
1568 const hasCustom = msgFlags && (1 << 7) > 0;1590 // any message with a non-zero level and desynced the entire stream
1591 const hasScope = (msgFlags & (1 << 5)) !== 0;
1592 const hasStack = (msgFlags & (1 << 6)) !== 0;
1593 const hasCustom = (msgFlags & (1 << 7)) !== 0;
1569 const text = await r.stringWithLength();1594 const text = await r.stringWithLength();
1570 const time = await r.varUint();1595 const time = await r.varUint();
1571 const scope = hasScope ? await r.stringWithLength() : undefined;1596 const scope = hasScope ? await r.stringWithLength() : undefined;
1572 let stack: stack.Frame[] | undefined = undefined;1597 let stack: stack.Frame[] | undefined = undefined;
1573 if (hasStack) {1598 if (hasStack) {
1574 stack = [];1599 stack = [];
1575 let len = await r.varUint();1600 let frames = await r.varUint();
1576 while (len > 0) {1601 while (frames-- > 0) {
1577 const fn = await r.stringWithLength();1602 const fn = await r.stringWithLength();
1578 const file = await r.stringWithLength();1603 const file = await r.stringWithLength();
1579 const line = await r.varUint();1604 const line = await r.varUint();
...@@ -1756,7 +1781,7 @@ const global: Ref = /** @__PURE__ */ ((root = new Root()) => (attachToScreen(roo...@@ -1756,7 +1781,7 @@ const global: Ref = /** @__PURE__ */ ((root = new Root()) => (attachToScreen(roo
1756 * a {@linkcode Ref} to the global progress root. unlike referencing the1781 * a {@linkcode Ref} to the global progress root. unlike referencing the
1757 * namespace import, this value is tree-shakable.1782 * namespace import, this value is tree-shakable.
1758 */1783 */
1759export const globalRoot: Ref = { start: global.start };1784export const globalRoot: Ref = { start: (text, opts) => global.start(text, opts) };
17601785
1761/**1786/**
1762 * for testing. not covered by semver1787 * for testing. not covered by semver
lib/queue.ts+8-1
...@@ -176,4 +176,11 @@ function insertSorted<T extends { priority: number }>(arr: T[], item: T) {...@@ -176,4 +176,11 @@ function insertSorted<T extends { priority: number }>(arr: T[], item: T) {
176declare var navigator: { hardwareConcurrency: number };176declare var navigator: { hardwareConcurrency: number };
177const global = new PriorityQueue(navigator.hardwareConcurrency);177const global = new PriorityQueue(navigator.hardwareConcurrency);
178178
179import { UNWRAP } from "./assert.ts";179/** adjust the global queue's concurrency */
180export function setConcurrency(n: number) {
181 ASSERT(Number.isInteger(n) && n > 0, `setConcurrency(${n})`);
182 global.coresRemain += n - global.concurrency;
183 global.concurrency = n;
184}
185
186import { ASSERT, UNWRAP } from "./assert.ts";
lib/subprocess/ffmpeg.ts+3
...@@ -11,6 +11,8 @@ export interface SpawnOptions {...@@ -11,6 +11,8 @@ export interface SpawnOptions {
11 ffmpeg?: string;11 ffmpeg?: string;
12 progress: progress.Node;12 progress: progress.Node;
13 cwd?: string; // TODO: Path13 cwd?: string; // TODO: Path
14 /** kills the process when aborted */
15 signal?: AbortSignal;
14}16}
1517
16/**18/**
...@@ -34,6 +36,7 @@ export async function spawn(options: SpawnOptions) {...@@ -34,6 +36,7 @@ export async function spawn(options: SpawnOptions) {
34 stdio: ["ignore", "inherit", "pipe"],36 stdio: ["ignore", "inherit", "pipe"],
35 env: { ...process.env, SVT_LOG: "2" },37 env: { ...process.env, SVT_LOG: "2" },
36 cwd: cwd?.toString(),38 cwd: cwd?.toString(),
39 signal: options.signal,
37 });40 });
38 const parser = new Parse();41 const parser = new Parse();
39 let running = true;42 let running = true;
run.js+1
...@@ -81,6 +81,7 @@ console["log"] = log.log;...@@ -81,6 +81,7 @@ console["log"] = log.log;
81process.on("uncaughtException", (error) => {81process.on("uncaughtException", (error) => {
82 console.error("Uncaught Exception");82 console.error("Uncaught Exception");
83 console.error(error);83 console.error(error);
84 log.getDrawLock("long");
84 process.exit(1);85 process.exit(1);
85});86});
8687
src/backend.ts+1
...@@ -65,3 +65,4 @@ import { type Context, Hono, type Next } from "hono";...@@ -65,3 +65,4 @@ import { type Context, Hono, type Next } from "hono";
65import { logger } from "hono/logger";65import { logger } from "hono/logger";
66import { trimTrailingSlash } from "hono/trailing-slash";66import { trimTrailingSlash } from "hono/trailing-slash";
67import * as admin from "./admin.ts";67import * as admin from "./admin.ts";
68import "./file-viewer/sync.ts";
src/bin/download-db.ts+5-10
...@@ -1,14 +1,8 @@...@@ -1,14 +1,8 @@
1// Pull production state for local development. cache.sqlite comes from the
2// source of truth's /db route (no ssh needed); questions.sqlite is still
3// rsync'd off the web node since the source of truth does not own it yet.
1export async function main() {4export async function main() {
2 await rsync.spawn({5 await sync.revalidate();
3 cwd: Path.resolve("."),
4 args: [
5 "-a",
6 "--progress",
7 "clo@paperclover.net:~/paperclover.net/.clover/cache.sqlite",
8 ".clover/cache.sqlite",
9 ],
10 progress: progress.start("download file cache"),
11 });
12 await rsync.spawn({6 await rsync.spawn({
13 cwd: Path.resolve("."),7 cwd: Path.resolve("."),
14 args: [8 args: [
...@@ -24,3 +18,4 @@ export async function main() {...@@ -24,3 +18,4 @@ export async function main() {
24import { Path } from "#sitegen/path";18import { Path } from "#sitegen/path";
25import * as progress from "@clo/lib/progress";19import * as progress from "@clo/lib/progress";
26import * as rsync from "../file-viewer/rsync.ts";20import * as rsync from "../file-viewer/rsync.ts";
21import * as sync from "../file-viewer/sync.ts";
src/bin/tail-progress.ts created+50
...@@ -0,0 +1,50 @@
1// Tail the source of truth's indexing progress from anywhere:
2//
3// node run tail-progress # production
4// node run tail-progress http://zenith:43201/progress # staging
5//
6// Opens a fetch to the /progress route and renders the live progress tree
7// in this terminal. Reconnects automatically; each (re)connect resumes from
8// the server's current state, since the stream encoder snapshots on attach.
9const defaultUrl = "https://db.paperclover.net/progress";
10
11export async function main() {
12 const url = process.argv[2] ?? defaultUrl;
13 const token = process.env.CLOVER_SOT_KEY;
14 if (!token) {
15 console.warn("CLOVER_SOT_KEY is not set; the server will likely 401");
16 }
17
18 let delay = 1000;
19 while (true) {
20 const connectedAt = Date.now();
21 try {
22 const res = await fetch(url, {
23 headers: token ? { Authorization: token } : {},
24 });
25 if (res.status === 401) {
26 console.error("unauthorized; set CLOVER_SOT_KEY");
27 process.exit(1);
28 }
29 if (!res.ok || !res.body) {
30 throw new Error(`server responded ${res.status} ${res.statusText}`);
31 }
32 console.info(`connected to ${url}`);
33 // resolves only when the stream ends; the indexer root never ends, so
34 // this await effectively lasts until disconnect.
35 await progress.decodeByteStream(
36 res.body as ReadableStream<Uint8Array>,
37 progress.globalRoot,
38 );
39 } catch (err: any) {
40 console.warn(`disconnected: ${err?.message ?? err}`);
41 }
42 if (Date.now() - connectedAt > 15_000) delay = 1000;
43 else delay = Math.min(delay * 2, 30_000);
44 console.info(`reconnecting in ${Math.round(delay / 1000)}s...`);
45 await async.delay(delay);
46 }
47}
48
49import * as async from "@clo/lib/async";
50import * as progress from "@clo/lib/progress";
src/file-viewer/backend.ts+3-1
...@@ -83,7 +83,9 @@ app.get("/file/*", async (c, next) => {...@@ -83,7 +83,9 @@ app.get("/file/*", async (c, next) => {
83 // - Old browsers like Internet Explorer act as `?view=dl`83 // - Old browsers like Internet Explorer act as `?view=dl`
84 let viewMode = c.req.query("view");84 let viewMode = c.req.query("view");
85 if (c.req.query("dl") != null) viewMode = "download";85 if (c.req.query("dl") != null) viewMode = "download";
86 if (lofi) viewMode = file.extension === ".html" ? "embed" : "download";86 if (lofi) {
87 viewMode = file.extension.toLowerCase() === ".html" ? "embed" : "download";
88 }
8789
88 if (90 if (
89 viewMode == null && !derivedKey91 viewMode == null && !derivedKey
src/file-viewer/bin/file-scan.ts+36-999
...@@ -1,408 +1,32 @@...@@ -1,408 +1,32 @@
1// The file scanner incrementally updates an sqlite database with file1// Run one full scan of the file store from this machine, rendering progress
2// stats. Additionally, it runs "processors" on files, which precompute2// in the terminal. In production the indexer service inside the source of
3// expensive data such as running `ffprobe` on all media to get the3// truth server does this continuously; this wrapper exists for development
4// duration.4// (and the nostalgia of watching it go).
5//5//
6// Processors are also used to derive compressed and optimized assets,6// CLOVER_FILE_RAW=/tmp/store CLOVER_FILE_DERIVED=/tmp/derived \
7// which is how automatic JXL / AV1 encoding is done. Derived files are7// CLOVER_DB=.clover node run file-scan
8// uploaded to the clover NAS to be pulled by VPS instances for hosting.
9const sotToken = process.env.CLOVER_SOT_KEY;
10
11export async function main() {8export async function main() {
12 const start = performance.now();9 const start = performance.now();
13 using _ = log.startWidget({10 using _ = log.startWidget({
14 format: ({ now }) => `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`,11 format: ({ now }) => `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`,
15 });12 });
1613
17 const promises = new async.PromiseAggregator();14 const scanner = new Scanner({
1815 root: Path.resolve(rawFileRoot),
19 const walkQueue = new queue.PriorityQueue(10);16 progress: progress.globalRoot,
2017 settleMs: Number(process.env.CLOVER_SETTLE_MS ?? 10_000),
21 const dirsNode = progress.start("Walk Tree", { total: 1 });
22 dirsNode.passive = true;
23 const fileNode = progress.start("Process File", { total: 0 });
24 fileNode.sortChildren = (a, b) => {
25 const ac = a.children.length > 0 ? 1 : 0;
26 const bc = b.children.length > 0 ? 1 : 0;
27 if (ac !== bc) return bc - ac;
28 return a.text.localeCompare(b.text);
29 };
30
31 // Read a directory or file stat and queue up changed files.
32 const scanDirectory = walkQueue.wrap(
33 async (path: Path) => {
34 const publicPath = toPublicPath(path);
35 using node = dirsNode.start(publicPath + " - stat");
36 using _ = ts.defer(() => dirsNode.inc());
37
38 const stat = await path.stat();
39
40 const mediaFile = MediaFile.getByPath(publicPath);
41
42 if (stat.isDirectory()) {
43 node.text = publicPath + " - reading";
44 const items = (await path.readDir())
45 .filter((child) => !skipBasename(child.base))
46 .map((child) => (promises.push(scanDirectory(child)), child.base));
47 dirsNode.total += items.length;
48
49 for (const child of mediaFile?.getChildren() ?? []) {
50 if (items.includes(child.basename)) continue;
51 const recursive = child.kind === MediaFileKind.directory
52 ? [child, ...child.getRecursiveFileChildren()]
53 : [child];
54 for (const deletion of recursive) deletion.delete();
55 }
56
57 return;
58 }
59
60 if (
61 !mediaFile // All processes must be performed if there is no file.
62 // Rerun all processors if it changed
63 || stat.size !== mediaFile.size
64 || stat.mtime.getTime() !== mediaFile.date.getTime()
65 ) {
66 promises.push(updateMetadata({ path, publicPath, stat, mediaFile }));
67 } else {
68 // If the scanners changed, it may mean more processes should be run.
69 await queueProcessors({
70 path,
71 stat,
72 mediaFile,
73 node: fileNode.start(publicPath.slice(1)),
74 });
75 }
76 },
77 );
78 const updateMetadata = queue.wrap(
79 async ({ path, publicPath, stat, mediaFile }: UpdateMetadataArgs) => {
80 using errorHandler = new DisposableStack();
81 const label = publicPath.slice(1);
82 const node = errorHandler.use(fileNode.start(label));
83
84 await scrubLocationMetadata(path, stat, node);
85
86 node.text = `${label} - hashing`;
87 const hash = await new Promise<string>((resolve, reject) => {
88 const reader = fs.createReadStream(path.toString());
89 reader.on("error", reject);
90
91 const hasher = crypto.createHash("sha1").setEncoding("hex");
92 hasher.on("error", reject);
93 hasher.on("readable", () => resolve(hasher.read()));
94
95 reader.pipe(hasher);
96 });
97 let date = stat.mtime;
98 if (
99 mediaFile
100 && mediaFile.date.getTime() < stat.mtime.getTime()
101 && Date.now() - stat.mtime.getTime() < monthMilliseconds
102 ) {
103 date = mediaFile.date;
104 console.warn(
105 `M-time on ${publicPath} was likely corrupted. ${
106 formatDate(
107 mediaFile.date,
108 )
109 } -> ${formatDate(stat.mtime)}`,
110 );
111 }
112 mediaFile = MediaFile.createFile({
113 path: publicPath,
114 date,
115 hash,
116 size: stat.size,
117 duration: mediaFile?.duration ?? 0,
118 dimensions: mediaFile?.dimensions ?? "",
119 contents: mediaFile?.contents ?? "",
120 });
121 const parent = mediaFile.getParent();
122 if (parent) parent.setProcessed(0);
123
124 node.text = `${label}`;
125 await queueProcessors({
126 path,
127 stat,
128 mediaFile,
129 node,
130 });
131 errorHandler.move();
132 },
133 () => ({ priority: -1 }),
134 );
135 const queueFileProcessor = queue.wrap(async function({
136 path,
137 stat,
138 mediaFile,
139 processor,
140 index,
141 after,
142 fileNode: innerNode,
143 }: ProcessJob) {
144 using node = innerNode.start(processor.name);
145 await processor.run({
146 path,
147 stat,
148 mediaFile,
149 node,
150 });
151 fileNode.value += 1;
152 mediaFile.setProcessed(mediaFile.processed | (1 << (16 + index)));
153 for (const dependantJob of after) {
154 ASSERT(
155 dependantJob.needs > 0,
156 `dependantJob.needs > 0, ${dependantJob.needs}`,
157 );
158 dependantJob.needs -= 1;
159 if (dependantJob.needs == 0) {
160 promises.push(queueFileProcessor(dependantJob));
161 }
162 }
163 }, (job) => ({ cores: job.processor.cores ?? 0 }));
164
165 function decodeProcessors(input: string) {
166 return input
167 .split(";")
168 .filter(Boolean)
169 .map(([a, b, c]) => ({
170 id: a,
171 hash: (UNWRAP(b).charCodeAt(0) << 8) + UNWRAP(c).charCodeAt(0),
172 }));
173 }
174
175 async function queueProcessors({
176 path,
177 stat,
178 mediaFile,
179 node,
180 }: Omit<ProcessFileArgs, "spin">) {
181 using errorHandler = new DisposableStack();
182 errorHandler.use(node);
183
184 node.showTotal = false;
185 node.passive = true;
186
187 const ext = mediaFile.extensionNonEmpty.toLowerCase();
188 let possible = processors.filter((p) => p.include ? p.include.has(ext) : !p.exclude?.has(ext));
189 if (possible.length === 0) return;
190 ASSERT(possible.length < 16, "too many bits");
191
192 const hash = possible.reduce((a, b) => a ^ b.hash, 0) | 1;
193 ASSERT(hash <= 0xffff, `${hash.toString(16)} has no bits above 16 set`);
194 let processed = mediaFile.processed;
195
196 // If the hash has changed, migrate the bitfield over.
197 // This also runs when the processor hash is in it's initial 0 state.
198 let order: ReturnType<typeof decodeProcessors>;
199 try {
200 order = decodeProcessors(mediaFile.processors);
201 } catch {
202 // this function sucks and this system sucks i hate it.
203 order = [];
204 }
205 if ((processed & 0xffff) !== hash) {
206 const previous = order.filter(
207 (_, i) => (processed & (1 << (16 + i))) !== 0,
208 );
209 processed = hash;
210 for (const { id, hash } of previous) {
211 const p = processors.find((p) => p.id === id);
212 if (!p) continue;
213 const index = possible.indexOf(p);
214 if (index !== -1 && p.hash === hash) processed |= 1 << (16 + index);
215 }
216 mediaFile.setProcessors(
217 processed,
218 possible
219 .map((p) => p.id + String.fromCharCode(p.hash >> 8, p.hash & 0xff))
220 .join(";"),
221 );
222 } else {
223 possible = order.map(({ id }) => UNWRAP(possible.find((p) => p.id === id)));
224 }
225
226 // Queue needed processors.
227 const jobs: ProcessJob[] = [];
228 for (let i = 0, { length } = possible; i < length; i += 1) {
229 if ((processed & (1 << (16 + i))) === 0) {
230 const processor = UNWRAP(possible[i]);
231 const job: ProcessJob = {
232 path,
233 stat,
234 mediaFile,
235 processor,
236 index: i,
237 after: [],
238 needs: processor.depends.length,
239 fileNode: node,
240 };
241 jobs.push(job);
242 if (job.needs === 0) promises.push(queueFileProcessor(job));
243 }
244 }
245 node.total = jobs.length;
246 fileNode.total += jobs.length;
247 for (const job of jobs) {
248 for (const dependId of job.processor.depends) {
249 const dependJob = jobs.find((j) => j.processor.id === dependId);
250 if (dependJob) {
251 dependJob.after.push(job);
252 } else {
253 ASSERT(job.needs > 0, `job.needs !== 0, ${job.needs}`);
254 job.needs -= 1;
255 if (job.needs === 0) promises.push(queueFileProcessor(job));
256 }
257 }
258 }
259 if (node.total > 0) {
260 errorHandler.move();
261
262 const parent = mediaFile.getParent();
263 if (parent) parent.setProcessed(0);
264 }
265 }
266
267 // Add the root & recursively iterate!
268 const rootPath = Path.resolve(root);
269 if (!rootPath.ifExistsSync()) {
270 throw new Error(`file store ${rootPath} is not mounted`);
271 }
272 promises.push(scanDirectory(rootPath));
273
274 await promises.all();
275 fileNode.end();
276 dirsNode.end();
277
278 // Update directory metadata
279 using dirMetaNode = progress.start("update directory metadata");
280 const dirs = MediaFile.getDirectoriesToReindex()
281 .sort((a, b) => b.path.length - a.path.length);
282
283 for (const dir of dirs) {
284 using _ = dirMetaNode.start(dir.path);
285 const children = dir.getChildren();
286
287 // readme.txt
288 const readmeContent = children.find((x) => x.basename === "readme.txt")?.contents ?? "";
289
290 // dirsort
291 let dirsort: string[] | null = null;
292 const dirSortRaw = children.find((x) => x.basename === ".dirsort")?.contents ?? "";
293 if (dirSortRaw) {
294 dirsort = dirSortRaw
295 .split("\n")
296 .map((x) => x.trim())
297 .filter(Boolean);
298 }
299
300 // Permissions
301 if (children.some((x) => x.basename === ".friends")) {
302 FilePermissions.setPermissions(dir.path, 1);
303 } else {
304 FilePermissions.setPermissions(dir.path, 0);
305 }
306
307 // Recursive stats.
308 let totalSize = 0;
309 let newestDate = new Date(0);
310 let allHashes = "";
311 for (const child of children) {
312 totalSize += child.size;
313 allHashes += child.hash;
314
315 if (child.basename !== "/readme.txt" && child.date > newestDate) {
316 newestDate = child.date;
317 }
318 }
319
320 // Project Date
321 const dateFile = children.find((x) => x.basename === ".date");
322 if (dateFile) {
323 const date = new Date(dateFile.contents);
324 newestDate = date;
325 dir.setProcessors(
326 0,
327 JSON.stringify({ hideChildrenDates: true }),
328 );
329 } else {
330 dir.setProcessors(0, "");
331 }
332
333 const dirHash = crypto
334 .createHash("sha1")
335 .update(dir.path + allHashes)
336 .digest("hex");
337
338 MediaFile.markDirectoryProcessed({
339 id: dir.id,
340 timestamp: newestDate,
341 contents: readmeContent,
342 size: totalSize,
343 hash: dirHash,
344 dirsort,
345 });
346 }
347 dirMetaNode.end();
348
349 // Sync to remote
350 if (
351 ((await derived.workDir.ifExistsSync()?.readDir())?.length ?? 0) > 0
352 ) {
353 await rsync.spawn({
354 args: [
355 "--links",
356 "--recursive",
357 "--times",
358 "--partial",
359 "--progress",
360 // "--remove-source-files",
361 "--delay-updates",
362 "--exclude=tmp.*",
363 derived.workDir.toString() + "/",
364 "clo@file.paperclover.net:/mnt/storage1/clover/Documents/Config/paperclover/derived/",
365 ],
366 progress: progress.start("upload derived assets"),
367 cwd: process.cwd(),
368 });
369
370 await fs.removeEmptyDirectories(derived.workDir.toString());
371 } else {
372 console.info("No new derived assets");
373 }
374
375 MediaFile.db.prepare("VACUUM").run();
376 MediaFile.db.reload();
377
378 await rsync.spawn({
379 args: [
380 MediaFile.db.file,
381 "clo@file.paperclover.net:/mnt/storage1/clover/Documents/Config/paperclover/cache.sqlite",
382 ],
383 progress: progress.start("Uploading database (source of truth)"),
384 cwd: process.cwd(),
385 });18 });
386 await rsync.spawn({19 await scanner.sweep();
387 args: [20 await scanner.waitIdle();
388 MediaFile.db.file,21 dirmeta.run(progress.globalRoot);
389 "clo@paperclover.net:paperclover.net/.clover/cache.sqlite",22
390 ],23 const orphaned = derived.findOrphanedRoots();
391 progress: progress.start("Uploading database (web node)"),24 for (const orphan of orphaned) {
392 });25 console.info("delete orphaned " + orphan.key);
393 if (sotToken) {26 await derived.deleteRootFiles(orphan);
394 const res = await fetch("https://db.paperclover.net/reload", {27 derived.deleteRoot(orphan);
395 method: "POST",28 }
396 headers: {29 await derived.cleanAbandonedTmp();
397 Authorization: sotToken,
398 },
399 });
400 if (!res.ok) {
401 console.warn(
402 `Failed to reload remote database ${res.status} ${res.statusText}`,
403 );
404 }
405 } else console.warn("Missing SOT token");
40630
407 console.info(31 console.info(
408 "Updated file viewer index in \x1b[1m"32 "Updated file viewer index in \x1b[1m"
...@@ -426,6 +50,11 @@ export async function main() {...@@ -426,6 +50,11 @@ export async function main() {
426 from derived_files50 from derived_files
427 `)51 `)
428 .getNonNull();52 .getNonNull();
53 const { failed } = MediaFile.db
54 .prepare<[], { failed: number }>(`
55 select count(*) as failed from file_processors where status = 2
56 `)
57 .getNonNull();
429 const canonicalSize = UNWRAP(MediaFile.getByPath("/")).size;58 const canonicalSize = UNWRAP(MediaFile.getByPath("/")).size;
43059
431 console.info();60 console.info();
...@@ -435,614 +64,22 @@ export async function main() {...@@ -435,614 +64,22 @@ export async function main() {
435 + `- Derived Count: \x1b[1m${derivedCount}\x1b[0m\n`64 + `- Derived Count: \x1b[1m${derivedCount}\x1b[0m\n`
436 + `- Media Duration: \x1b[1m${string.formatDurationLetters(duration)}\x1b[0m\n`65 + `- Media Duration: \x1b[1m${string.formatDurationLetters(duration)}\x1b[0m\n`
437 + `- Canonical Size: \x1b[1m${string.formatByteSize(canonicalSize)}\x1b[0m\n`66 + `- Canonical Size: \x1b[1m${string.formatByteSize(canonicalSize)}\x1b[0m\n`
438 + `- Derived Size: \x1b[1m${string.formatByteSize(derivedSize)}\x1b[0m\n`,67 + `- Derived Size: \x1b[1m${string.formatByteSize(derivedSize)}\x1b[0m\n`
439 );68 + (failed > 0
440}69 ? `- \x1b[31mFailed Processors: ${failed}\x1b[0m (select * from file_processors where status = 2)\n`
44170 : ""),
442interface Process {
443 name: string;
444 cores?: number;
445 enable?: boolean;
446 include?: Set<string>;
447 exclude?: Set<string>;
448 depends?: string[];
449 version?: number;
450 /* Perform an action. */
451 run(args: ProcessFileArgs): Promise<void>;
452}
453
454const ffprobeBin = testProgram("ffprobe", "--help");
455const ffmpegBin = testProgram("ffmpeg", "--help");
456
457const ffmpegOptions = ["-hide_banner", "-loglevel", "warning"];
458
459// NOTE: Never re-order the processors. Add new ones at the end.
460const procDuration: Process = {
461 name: "calculate duration",
462 enable: ffprobeBin !== null,
463 include: rules.extsDuration,
464 cores: 1,
465 async run({ path, mediaFile }) {
466 const { stdout } = await subprocess.exec(ffprobeBin!, [
467 "-v",
468 "error",
469 "-show_entries",
470 "format=duration",
471 "-of",
472 "default=noprint_wrappers=1:nokey=1",
473 path.toString(),
474 ]);
475
476 const duration = parseFloat(stdout.trim());
477 if (Number.isNaN(duration)) {
478 throw new Error("Could not extract duration from " + stdout);
479 }
480 mediaFile.setDuration(Math.ceil(duration));
481 },
482};
483
484const procDimensions: Process = {
485 name: "calculate dimensions",
486 enable: ffprobeBin != null,
487 include: rules.extsDimensions,
488 cores: 1,
489 async run({ path, mediaFile }) {
490 const { ext } = path;
491
492 let dimensions;
493
494 if (ext === ".svg") {
495 // Parse out of text data
496 const content = await path.read("utf-8");
497 const widthMatch = content.match(/width="(\d+)"/);
498 const heightMatch = content.match(/height="(\d+)"/);
499
500 if (widthMatch && heightMatch) {
501 dimensions = `${widthMatch[1]}x${heightMatch[1]}`;
502 }
503 } else if (rules.extsImage.has(ext)) {
504 // Use magick to observe streams
505 const { stdout } = await subprocess.exec("magick", [
506 "identify",
507 "-auto-orient",
508 "-format",
509 "%w %h",
510 path.toString(),
511 ]);
512 const [w, h] = stdout.split(" ").map((x) => Number(x));
513 if (w && h) {
514 dimensions = w + "x" + h;
515 }
516 } else {
517 // Use ffprobe to observe streams
518 const { stdout } = await subprocess.exec("ffprobe", [
519 "-v",
520 "error",
521 "-select_streams",
522 "v:0",
523 "-show_entries",
524 "stream=width,height",
525 "-of",
526 "json",
527 path.toString(),
528 ]);
529 const result = JSON.parse(stdout);
530 const stream = result.streams[0];
531 if (stream) {
532 dimensions = UNWRAP(stream.width) + "x" + UNWRAP(stream.height);
533 }
534 }
535
536 mediaFile.setDimensions(dimensions ?? "");
537 },
538};
539
540const procLoadTextContents: Process = {
541 name: "load text content",
542 include: rules.extsReadContents,
543 cores: 1,
544 version: 2,
545 async run({ path, mediaFile, stat }) {
546 if (stat.size > 1_000_000) return;
547 const text = await path.read("utf-8");
548 mediaFile.setContents(text);
549 },
550};
551
552const procHighlightCode: Process = {
553 name: "highlight source code",
554 include: new Set(rules.extsCode.keys()),
555 cores: 1,
556 version: 2,
557 async run({ path, mediaFile, stat }) {
558 const language = UNWRAP(
559 rules.extsCode.get(path.ext.toLowerCase()),
560 );
561 // An issue is that .ts is an overloaded extension, shared between
562 // 'transport stream' and 'typescript'.
563 //
564 // Filter used here is:
565 // - more than 1mb
566 // - invalid UTF-8
567 if (stat.size > 1_000_000) return;
568 let code;
569 const buf = await path.read();
570 try {
571 code = new TextDecoder("utf-8", { fatal: true }).decode(buf);
572 } catch (error) {
573 mediaFile.setContents("");
574 return;
575 }
576 const content = await highlight.highlightCode(code, language);
577 mediaFile.setContents(content);
578 },
579};
580
581const procImageSubsets: Process = {
582 name: "encode image subsets",
583 include: rules.extsImage,
584 depends: [procDimensions.name],
585 version: 3,
586 async run({ path, mediaFile, node }) {
587 const { width, height } = UNWRAP(mediaFile.parseDimensions());
588 const targetSizes = transcodeRules.imageSizes.filter((w) => w < width);
589 const baseStatus = node.text;
590
591 using stack = new DisposableStack();
592 for (const size of targetSizes) {
593 const { w, h } = resizeDimensions(width, height, size);
594 for (const { ext, args } of transcodeRules.imagePresets) {
595 node.text = baseStatus + ` (${w}x${h}, ${ext.slice(1).toUpperCase()})`;
596
597 stack.use(
598 await derived.produce({
599 mediaFile,
600 node,
601 cores: 2,
602 subkey: `${size}${ext}`,
603 async producer(dir) {
604 await subprocess.exec(ffmpegBin!, [
605 ...ffmpegOptions,
606 "-i",
607 path.toString(),
608 "-vf",
609 `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`,
610 ...args,
611 dir.join(`${size}${ext}`).toString(),
612 ]);
613 },
614 }),
615 );
616 }
617 }
618
619 stack.move();
620 },
621};
622
623const videoArgsCache = new async.OnceMap(transcodeRules.getVideoInputArgs);
624
625const qualityMap: Record<string, string> = {
626 u: "ultra-high",
627 h: "high",
628 m: "medium",
629 l: "low",
630 d: "data-saving",
631};
632const procVideos = transcodeRules.videoFormats.map<Process>((preset) => ({
633 name: `encode av1 ${UNWRAP(qualityMap[UNWRAP(preset.id[1])])}`,
634 include: rules.extsVideo,
635 enable: ffmpegBin != null,
636 depends: [procDuration.name, procDimensions.name],
637 version: 4,
638 async run({ path, mediaFile, node }) {
639 if ((mediaFile.duration ?? 0) < 5) return;
640 if (!mediaFile.dimensions) return;
641
642 if (mediaFile.path === "/2021/top-10000-bread/output.mp4") return;
643
644 await derived.produce({
645 mediaFile,
646 node,
647 cores: 4,
648 subkey: `av1-${preset.id}`,
649 async producer(dir) {
650 const input = await videoArgsCache.getOrRun(path);
651 const dimensions = mediaFile.parseDimensions();
652 ASSERT(dimensions);
653 const args = transcodeRules.getAv1VideoArgs(
654 preset,
655 dimensions,
656 UNWRAP(input.video, "frick on " + path + JSON.stringify(input)),
657 dir,
658 );
659
660 await ffmpeg.spawn({
661 ffmpeg: ffmpegBin!,
662 progress: node,
663 args,
664 cwd: dir.toString(),
665 });
666 },
667 });
668 },
669}));
670const procVideoAudios = transcodeRules.audioFormats.map<Process>((preset) => ({
671 name: `encode opus ${UNWRAP(qualityMap[UNWRAP(preset.id)])}`,
672 include: rules.extsVideo,
673 enable: ffmpegBin != null,
674 depends: [procDuration.name, procDimensions.name],
675 version: 3,
676 async run({ path, mediaFile, node }) {
677 if ((mediaFile.duration ?? 0) < 5) return;
678 if (!mediaFile.dimensions) return;
679 if (mediaFile.path === "/2021/top-10000-bread/output.mp4") return;
680 await derived.produce({
681 mediaFile,
682 node,
683 cores: 4,
684 subkey: `opus-${preset.id}`,
685 async producer(dir) {
686 const input = await videoArgsCache.getOrRun(path);
687 ASSERT(input.video);
688 if (!input.audio) return;
689 const args = transcodeRules.getOpusAudioArgs(preset, input.audio, dir);
690
691 await ffmpeg.spawn({
692 ffmpeg: ffmpegBin!,
693 progress: node,
694 args,
695 cwd: dir.toString(),
696 });
697 },
698 });
699 },
700}));
701const procDash: Process = {
702 name: `encode mpeg-dash`,
703 include: rules.extsVideo,
704 enable: ffmpegBin != null,
705 depends: [...procVideos, ...procVideoAudios].map((x) => x.name),
706 version: 7,
707 async run({ path, mediaFile, node }) {
708 if ((mediaFile.duration ?? 0) < 5) return;
709 if (!mediaFile.dimensions) return;
710
711 if (mediaFile.path === "/2021/top-10000-bread/output.mp4") return;
712 await derived.produce({
713 mediaFile,
714 node,
715 cores: 1,
716 subkey: `dash-av1`,
717 async producer(dir) {
718 const input = await videoArgsCache.getOrRun(path);
719 const videos = transcodeRules.videoFormats.map(
720 (preset) =>
721 UNWRAP(dir.parent).join(
722 `av1-${preset.id}`,
723 transcodeRules.av1FileName,
724 ),
725 );
726 const audios = input.audio
727 ? transcodeRules.audioFormats.map(
728 (preset) =>
729 UNWRAP(dir.parent).join(
730 `opus-${preset.id}`,
731 transcodeRules.opusFileName,
732 ),
733 )
734 : [];
735 const args = transcodeRules.getMpegDashArgs(
736 videos,
737 audios,
738 dir,
739 );
740
741 await dir.join("d").makeDir();
742
743 await ffmpeg.spawn({
744 ffmpeg: ffmpegBin!,
745 progress: node,
746 args,
747 cwd: dir.toString(),
748 });
749 },
750 });
751 },
752};
753const procH264Hls: Process = {
754 name: `encode h.264 hls`,
755 include: rules.extsVideo,
756 enable: ffmpegBin != null,
757 depends: [procDuration.name, procDimensions.name],
758 async run({ path, mediaFile, node }) {
759 if ((mediaFile.duration ?? 0) < 5) return;
760 if (!mediaFile.dimensions) return;
761
762 await derived.produce({
763 mediaFile,
764 node,
765 cores: 8,
766 subkey: `hls`,
767 async producer(dir) {
768 const input = await videoArgsCache.getOrRun(path);
769 const args = transcodeRules.getH264HlsArgs(input, dir);
770
771 await ffmpeg.spawn({
772 ffmpeg: ffmpegBin!,
773 progress: node,
774 args,
775 cwd: dir.toString(),
776 });
777 },
778 });
779 },
780};
781
782const procCompression = [
783 { name: "gzip", fn: () => zlib.createGzip({ level: 9 }) },
784 { name: "zstd", fn: () => zlib.createZstdCompress() },
785].map<Process>(
786 ({ name, fn }) => ({
787 name: `compress ${name}`,
788 exclude: rules.extsPreCompressed,
789 async run({ path, mediaFile, node }) {
790 await derived.produce({
791 mediaFile,
792 node,
793 cores: 1,
794 subkey: name,
795 async producer(dir) {
796 await stream.promises.pipeline(
797 fs.createReadStream(path.toString()),
798 fn(),
799 fs.createWriteStream(dir.join(name).toString()),
800 );
801 },
802 });
803 },
804 }),
805);
806
807const processors = [
808 procDimensions,
809 procDuration,
810 procLoadTextContents,
811 procHighlightCode,
812 procImageSubsets,
813 ...procVideos,
814 ...procVideoAudios,
815 procDash,
816 ...procCompression,
817 procH264Hls,
818].map((process, id, all) => {
819 const strIndex = (id: number) => String.fromCharCode("a".charCodeAt(0) + id);
820 return {
821 ...(process as Process),
822 id: strIndex(id),
823 // Create a unique key.
824 hash: new Uint16Array(
825 crypto
826 .createHash("sha1")
827 .update(
828 process.run.toString()
829 + (process.version ? String(process.version) : ""),
830 )
831 .digest().buffer,
832 ).reduce((a, b) => a ^ b),
833 depends: (process.depends ?? []).map((depend) => {
834 const index = all.findIndex((p) => p.name === depend);
835 if (index === -1) throw new Error(`Cannot find depend '${depend}'`);
836 if (index === id) throw new Error(`Cannot depend on self: '${depend}'`);
837 return strIndex(index);
838 }),
839 };
840});
841
842function resizeDimensions(w: number, h: number, desiredWidth: number) {
843 ASSERT(desiredWidth < w, `${desiredWidth} < ${w}`);
844 return { w: desiredWidth, h: Math.floor((h / w) * desiredWidth) };
845}
846
847interface UpdateMetadataArgs {
848 path: Path;
849 publicPath: string;
850 stat: fs.Stats;
851 mediaFile: MediaFile | null;
852}
853
854interface ProcessFileArgs {
855 path: Path;
856 stat: fs.Stats;
857 mediaFile: MediaFile;
858 node: progress.Node;
859}
860
861interface ProcessJob {
862 path: Path;
863 stat: fs.Stats;
864 mediaFile: MediaFile;
865 processor: (typeof processors)[0];
866 index: number;
867 after: ProcessJob[];
868 needs: number;
869 fileNode: progress.Node;
870}
871
872function skipBasename(basename: string): boolean {
873 // dot files must be incrementally tracked
874 if (basename === ".dirsort") return false;
875 if (basename === ".friends") return false;
876 if (basename === ".date") return false;
877
878 return (
879 basename.startsWith(".")
880 // basename.startsWith("._") ||
881 // basename.startsWith(".tmp") ||
882 // basename === ".DS_Store" ||
883 || basename.toLowerCase() === "thumbs.db"
884 || basename.toLowerCase() === "desktop.ini"
885 );71 );
886}72}
88773
888function toPublicPath(diskPath: Path) {
889 if (diskPath.toString() === root) return "/";
890 return "/" + path.relative(root, diskPath.toString()).replaceAll("\\", "/");
891}
892
893function testProgram(name: string, helpArgument: string) {
894 try {
895 child_process.spawnSync(name, [helpArgument]);
896 return name;
897 } catch (err) {
898 console.warn(`Missing or corrupt executable '${name}'`);
899 }
900 return null;
901}
902
903// Helper function to check and remove location metadata
904async function scrubLocationMetadata(
905 path: Path,
906 stats: fs.Stats,
907 progress: progress.Ref,
908): Promise<boolean> {
909 using _ = progress.start("scrub exif metadata");
910 const ext = path.ext.toLowerCase();
911 if (!rules.extsScrubExif.has(ext)) return false;
912
913 let hasLocation = false;
914 let args: string[] = [];
915
916 // Check for location metadata based on file type
917 const tempOutput = UNWRAP(path.parent).join(`.tmp.${path.base}`);
918 switch (ext) {
919 case ".jpg":
920 case ".jpeg":
921 case ".png":
922 const { stdout: gpsCheck } = await subprocess.exec("exiftool", [
923 "-gps:all",
924 path.toString(),
925 ]);
926 hasLocation = gpsCheck.trim().length > 0;
927 args = ["-gps:all=", path.toString(), "-o", tempOutput.toString()];
928 break;
929 case ".mov":
930 case ".mp4":
931 const { stdout: videoCheck } = await subprocess.exec("exiftool", [
932 "-ee",
933 "-G3",
934 "-s",
935 path.toString(),
936 ]);
937 hasLocation = videoCheck.includes("GPS")
938 || videoCheck.includes("Location");
939 args = [
940 "-gps:all=",
941 "-xmp:all=",
942 path.toString(),
943 "-o",
944 tempOutput.toString(),
945 ];
946 break;
947 case ".m4a":
948 const { stdout: m4aCheck } = await subprocess.exec("exiftool", [
949 "-ee",
950 "-G3",
951 "-s",
952 path.toString(),
953 ]);
954 hasLocation = m4aCheck.includes("GPS")
955 || m4aCheck.includes("Location")
956 || m4aCheck.includes("Filename")
957 || m4aCheck.includes("Title");
958
959 if (hasLocation) {
960 args = [
961 "-gps:all=",
962 "-location:all=",
963 "-filename:all=",
964 "-title=",
965 "-m4a:all=",
966 path.toString(),
967 "-o",
968 tempOutput.toString(),
969 ];
970 }
971 break;
972 }
973
974 const accessTime = stats.atime;
975 const modTime = stats.mtime;
976
977 let backup: Path | null = null;
978 try {
979 if (hasLocation) {
980 // Prepare a backup
981 const tmp = UNWRAP(path.parent).join(`.tmp.backup.${path.base}`);
982 await fsp.copyFile(path.toString(), tmp.toString());
983 await fsp.utimes(tmp.toString(), accessTime, modTime);
984 backup = tmp;
985
986 // Remove metadata
987 await subprocess.exec("exiftool", args);
988 if (!tempOutput.ifExistsSync()) {
989 throw new Error(`Failed to create output file: ${tempOutput}`);
990 }
991
992 // Restore original timestamps
993 await fsp.rename(tempOutput.toString(), path.toString());
994 await fsp.utimes(path.toString(), accessTime, modTime);
995
996 // Backup is no longer needed
997 await fsp.unlink(backup.toString());
998
999 console.info(
1000 `Scrubbed location metadata in ${path.relative(Path.resolve(root))}`,
1001 );
1002 return true;
1003 }
1004 } catch (error) {
1005 if (backup) {
1006 await fsp.rename(backup.toString(), path.toString());
1007 }
1008 if (fs.existsSync(tempOutput.toString())) {
1009 await fsp.unlink(tempOutput.toString());
1010 }
1011 throw error;
1012 }
1013
1014 return false;
1015}
1016
1017const monthMilliseconds = 30 * 24 * 60 * 60 * 1000;
1018
1019import * as fs from "#sitegen/fs";
1020import { Path } from "#sitegen/path";
1021
1022import * as async from "@clo/lib/async";
1023import * as log from "@clo/lib/log";74import * as log from "@clo/lib/log";
1024import * as progress from "@clo/lib/progress";75import * as progress from "@clo/lib/progress";
1025import * as queue from "@clo/lib/queue";
1026import * as string from "@clo/lib/string";76import * as string from "@clo/lib/string";
1027import * as subprocess from "@clo/lib/subprocess";
1028import * as ts from "@clo/lib/ts";
102977
1030import * as child_process from "node:child_process";78import { Path } from "#sitegen/path";
1031import * as crypto from "node:crypto";79import { UNWRAP } from "@clo/lib/assert";
1032import * as fsp from "node:fs/promises";
1033import * as path from "node:path";
1034import * as stream from "node:stream";
1035import * as zlib from "node:zlib";
103680
1037import { formatDate } from "#src/file-viewer/format.ts";81import * as dirmeta from "#src/file-viewer/indexer/dirmeta.ts";
1038import * as highlight from "#src/file-viewer/highlight.ts";82import { Scanner } from "#src/file-viewer/indexer/scan.ts";
1039import * as derived from "#src/file-viewer/models/derived.ts";83import * as derived from "#src/file-viewer/models/derived.ts";
1040import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts";84import { MediaFile } from "#src/file-viewer/models/MediaFile.ts";
1041import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts";85import { rawFileRoot } from "../paths.ts";
1042import * as rsync from "#src/file-viewer/rsync.ts";
1043import * as rules from "#src/file-viewer/rules.ts";
1044import * as transcodeRules from "#src/file-viewer/transcode-rules.ts";
1045import * as ffmpeg from "@clo/lib/subprocess/ffmpeg";
1046
1047import { ASSERT, UNWRAP } from "@clo/lib/assert";
1048import { rawFileRoot as root } from "../paths.ts";
src/file-viewer/bin/file-trim.ts deleted-17
...@@ -1,17 +0,0 @@
1export async function main() {
2 const start = performance.now();
3 using _ = log.startWidget({
4 format: (now) => `paper clover's file scanner [${((now - start) / 1000).toFixed(1)}s]`,
5 });
6
7 const orphaned = derived.findOrphanedRoots();
8 for (const root of orphaned) {
9 console.info("delete " + root.key);
10 derived.deleteRoot(root);
11 }
12
13 // TODO: delete unreferenced files
14}
15
16import * as log from "@clo/lib/log";
17import * as derived from "../models/derived.ts";
src/file-viewer/bin/migrate-db.ts created+331
...@@ -0,0 +1,331 @@
1// One-shot migration of cache.sqlite from the old bitfield processor
2// tracking ("v1") to the processors/file_processors tables ("v2").
3//
4// CLOVER_DB=.clover node run migrate-db
5//
6// There is exactly one real copy of this database; migrate it once locally,
7// verify the site works, then place the migrated file on all machines
8// alongside the new code. The old `processed` column packed a 16-bit hash of
9// the applicable processor set plus per-processor "ran" bits indexed into
10// the `processors` string, whose entries were [letter id][2-char hash of
11// the processor's source code]. Completions are carried over as
12// done-at-current-version: the first sweep after migration must re-run
13// NOTHING (re-encoding the entire store would take weeks). Force re-runs
14// later by bumping a processor's version.
15//
16// This file intentionally avoids importing the models (their prepared
17// statements require the new schema) and instead uses raw SQL. It imports
18// the registry only for processor names, versions, and applicability.
19
20// the old positional letter ids, frozen. a..q matched the old array order.
21const letterMap: Record<string, string> = {
22 a: "dimensions",
23 b: "duration",
24 c: "text-contents",
25 d: "highlight-code",
26 e: "image-subsets",
27 f: "av1-au",
28 g: "av1-ah",
29 h: "av1-am",
30 i: "av1-al",
31 j: "av1-ad",
32 k: "opus-h",
33 l: "opus-m",
34 m: "opus-d",
35 n: "dash",
36 o: "gzip",
37 p: "zstd",
38 q: "h264-hls",
39};
40
41export async function main() {
42 const db = getDb("cache.sqlite");
43 const raw = db.node;
44 console.info(`migrating ${db.file}`);
45
46 // -- preflight --
47 const columns = raw.prepare(`pragma table_info(media_files)`).all() as {
48 name: string;
49 }[];
50 if (columns.length === 0) {
51 console.error("media_files does not exist; nothing to migrate");
52 process.exit(1);
53 }
54 if (!columns.some((c) => c.name === "processed")) {
55 console.info("already migrated (no `processed` column); nothing to do");
56 return;
57 }
58
59 // -- backup --
60 raw.exec(`pragma wal_checkpoint(truncate);`);
61 const backup = db.file + ".pre-v2";
62 if (fs.existsSync(backup) && !process.argv.includes("--force")) {
63 console.error(`backup ${backup} already exists; pass --force to continue`);
64 process.exit(1);
65 }
66 // content-only copy: fs.copyFile's metadata preservation gets EPERM'd on
67 // the NAS datasets (restrictive ACL mode), plain writes do not.
68 await stream.promises.pipeline(
69 fs.createReadStream(db.file),
70 fs.createWriteStream(backup),
71 );
72 console.info(`backed up to ${backup}`);
73
74 const now = Date.now();
75 const summary = new Map<string, { done: number; pending: number }>();
76 for (const p of registry.processors) {
77 summary.set(p.name, { done: 0, pending: 0 });
78 }
79
80 raw.exec(`pragma foreign_keys = off;`);
81 raw.exec(`begin;`);
82 try {
83 // -- new tables (kept in sync with models/ProcessorState.ts) --
84 raw.exec(/* SQL */ `
85 create table if not exists processors (
86 id integer primary key autoincrement,
87 name text not null unique,
88 version integer not null
89 );
90 create table if not exists file_processors (
91 file integer not null references media_files(id) on delete cascade,
92 processor integer not null references processors(id) on delete cascade,
93 version integer not null,
94 status integer not null,
95 updated integer not null,
96 error text,
97 primary key (file, processor)
98 );
99 create index if not exists file_processors_processor
100 on file_processors (processor);
101 `);
102 // mark the table-creation key so models/ProcessorState.ts skips its DDL
103 raw.prepare(
104 `insert or ignore into clover_migrations (key, version) values (?, ?);`,
105 ).run("processor_state", 1);
106
107 const ids = new Map<string, number>();
108 const insertProcessor = raw.prepare(
109 `insert into processors (name, version) values (?, ?)
110 on conflict(name) do update set version = excluded.version
111 returning id;`,
112 );
113 for (const p of registry.processors) {
114 const { id } = insertProcessor.get(p.name, p.version) as { id: number };
115 ids.set(p.name, id);
116 }
117
118 // -- carry over completions from the bitfield --
119 const files = raw.prepare(
120 `select id, path, processed, processors from media_files where kind = 1;`,
121 ).all() as {
122 id: number;
123 path: string;
124 processed: number;
125 processors: string;
126 }[];
127 const insertState = raw.prepare(
128 `insert or replace into file_processors
129 (file, processor, version, status, updated, error)
130 values (?, ?, ?, 1, ?, null);`,
131 );
132 let doneRows = 0;
133 let undecodable = 0;
134 for (const file of files) {
135 let entries: string[];
136 try {
137 entries = decodeProcessorLetters(file.processors);
138 } catch {
139 undecodable += 1;
140 continue;
141 }
142 for (let i = 0; i < entries.length; i += 1) {
143 if ((file.processed & (1 << (16 + i))) === 0) continue;
144 const name = letterMap[UNWRAP(entries[i])];
145 if (!name) continue; // processor no longer exists
146 const proc = registry.byName(name);
147 if (!proc) continue;
148 insertState.run(file.id, UNWRAP(ids.get(name)), proc.version, now);
149 UNWRAP(summary.get(name)).done += 1;
150 doneRows += 1;
151 }
152 }
153
154 // -- rebuild media_files without the bitfield columns --
155 raw.exec(/* SQL */ `
156 create table media_files_new (
157 id integer primary key autoincrement,
158 parent_id integer,
159 path text,
160 kind integer not null,
161 timestamp integer not null,
162 timestamp_updated integer not null default current_timestamp,
163 hash text not null,
164 size integer not null,
165 duration integer not null default 0,
166 dimensions text not null default "",
167 contents text not null,
168 dirsort text,
169 config text not null default "",
170 dir_reindex integer not null default 0,
171 pending integer not null default 0,
172 foreign key (parent_id) references media_files(id) on delete cascade
173 );
174 insert into media_files_new (
175 id, parent_id, path, kind, timestamp, timestamp_updated, hash, size,
176 duration, dimensions, contents, dirsort, config, dir_reindex, pending)
177 select
178 id, parent_id, path, kind, timestamp, timestamp_updated, hash, size,
179 duration, dimensions, contents, dirsort,
180 case when kind = 0 and processors like '{%' then processors else '' end,
181 case when kind = 0 and processed = 0 then 1 else 0 end,
182 0
183 from media_files;
184 drop table media_files;
185 alter table media_files_new rename to media_files;
186 `);
187
188 // -- collapse case duplicates --
189 // the file stores are case-insensitive, so two case spellings of one
190 // path are the same physical file. the old scanner could record both;
191 // keep the newest row (matching the new unique nocase index) and move
192 // any children over.
193 const dupeGroups = raw.prepare(
194 `select group_concat(id) ids, max(id) keep, lower(path) lp
195 from media_files group by lower(path) having count(*) > 1;`,
196 ).all() as { ids: string; keep: number; lp: string }[];
197 for (const group of dupeGroups) {
198 const drop = group.ids.split(",").map(Number)
199 .filter((id) => id !== group.keep);
200 console.warn(
201 `case-duplicate rows for ${group.lp}: keeping ${group.keep}, dropping ${drop.join(", ")}`,
202 );
203 const dropList = drop.join(",");
204 raw.exec(/* SQL */ `
205 update media_files set parent_id = ${group.keep}
206 where parent_id in (${dropList});
207 delete from file_processors where file in (${dropList});
208 delete from derived_refs where file in (${dropList});
209 delete from media_files where id in (${dropList});
210 `);
211 }
212
213 raw.exec(/* SQL */ `
214 create unique index media_files_path
215 on media_files (path collate nocase);
216 create index media_files_parent_id on media_files (parent_id);
217 create index media_files_file_children on media_files (kind, path);
218 create index media_files_dir_reindex on media_files (kind, dir_reindex);
219 `);
220
221 // -- compute `pending` from applicability minus completions --
222 const states = raw.prepare(
223 `select processor, version from file_processors where file = ?;`,
224 );
225 const setPending = raw.prepare(
226 `update media_files set pending = ? where id = ?;`,
227 );
228 const pendingPaths: string[] = [];
229 for (const file of files) {
230 const ext = extensionNonEmpty(file.path).toLowerCase();
231 const applicable = registry.applicableFor(ext);
232 if (applicable.length === 0) continue;
233 const done = new Map(
234 (states.all(file.id) as { processor: number; version: number }[])
235 .map((row) => [row.processor, row.version]),
236 );
237 const missing = applicable.filter(
238 (p) => done.get(UNWRAP(ids.get(p.name))) !== p.version,
239 );
240 if (missing.length === 0) continue;
241 setPending.run(missing.length, file.id);
242 for (const p of missing) UNWRAP(summary.get(p.name)).pending += 1;
243 if (pendingPaths.length < 32) {
244 pendingPaths.push(
245 `${file.path} (${missing.map((p) => p.name).join(", ")})`,
246 );
247 }
248 }
249
250 raw.exec(`commit;`);
251 raw.exec(`pragma foreign_keys = on;`);
252
253 const violations = raw.prepare(`pragma foreign_key_check;`).all();
254 if (violations.length > 0) {
255 console.error("foreign key violations after migration:", violations);
256 process.exit(1);
257 }
258 raw.exec(`vacuum;`);
259 raw.exec(`pragma wal_checkpoint(truncate);`);
260
261 // -- summary --
262 console.info("");
263 console.info(`migrated ${files.length} files, ${doneRows} completions`);
264 if (undecodable) {
265 console.warn(`${undecodable} files had undecodable processor strings`);
266 }
267 console.info("per-processor state (done / pending):");
268 for (const [name, { done, pending }] of summary) {
269 const warn = pending > 0 && heavyProcessors.has(name) ? " <-- WILL RUN" : "";
270 console.info(
271 ` ${name.padEnd(16)} ${String(done).padStart(6)} / ${String(pending).padStart(4)}${warn}`,
272 );
273 }
274 if (pendingPaths.length > 0) {
275 console.info("");
276 console.info("files with pending work (first 32):");
277 for (const line of pendingPaths) console.info(" " + line);
278 } else {
279 console.info("no files have pending work; first sweep will be a no-op");
280 }
281 } catch (err) {
282 raw.exec(`rollback;`);
283 raw.exec(`pragma foreign_keys = on;`);
284 console.error("migration failed and was rolled back");
285 throw err;
286 }
287}
288
289/** decode the old `processors` column into its letter ids */
290function decodeProcessorLetters(input: string): string[] {
291 return input
292 .split(";")
293 .filter(Boolean)
294 .map(([a, b, c]) => {
295 UNWRAP(b);
296 UNWRAP(c);
297 return UNWRAP(a);
298 });
299}
300
301/** mirror of MediaFile.extensionNonEmpty for a raw path string */
302function extensionNonEmpty(filePath: string) {
303 const basename = path.basename(filePath);
304 const ext = path.extname(basename);
305 if (ext === "") return basename;
306 return ext;
307}
308
309// processors expensive enough that an accidental re-run is a disaster
310const heavyProcessors = new Set([
311 "image-subsets",
312 "av1-au",
313 "av1-ah",
314 "av1-am",
315 "av1-al",
316 "av1-ad",
317 "opus-h",
318 "opus-m",
319 "opus-d",
320 "dash",
321 "h264-hls",
322]);
323
324import * as fs from "node:fs";
325import * as path from "node:path";
326import * as stream from "node:stream";
327
328import { getDb } from "#sitegen/sqlite";
329import { UNWRAP } from "@clo/lib/assert";
330
331import * as registry from "#src/file-viewer/indexer/registry.ts";
src/file-viewer/indexer/dirmeta.ts created+108
...@@ -0,0 +1,108 @@
1// Directory metadata pass: readme contents, explicit sort order, friend
2// permissions, recursive size/date/hash aggregates, and the `.date`
3// override. Driven by the `dir_reindex` flag, which the scanner sets on
4// parents whenever children change. Ported from the tail of file-scan.ts.
5const console = log.scoped("indexer");
6
7export function run(p: progress.Ref): boolean {
8 const dirs = MediaFile.getDirectoriesToReindex()
9 .sort((a, b) => b.path.length - a.path.length);
10 if (dirs.length === 0) return false;
11 using node = p.start("update directory metadata", { total: dirs.length });
12
13 for (const dir of dirs) {
14 using _ = node.start(dir.path);
15 try {
16 processDir(dir);
17 } catch (err) {
18 // one broken directory must not starve the rest of the pass; the
19 // dir_reindex flag stays set, so it retries on the next trigger
20 console.error(`directory metadata for ${dir.path} failed:`, err);
21 }
22 node.inc();
23 }
24 console.info(`updated metadata for ${dirs.length} directories`);
25 return true;
26}
27
28function processDir(dir: MediaFile) {
29 const children = dir.getChildren();
30
31 // readme.txt
32 const readmeContent = children.find((x) => x.basename === "readme.txt")?.contents ?? "";
33
34 // dirsort
35 let dirsort: string[] | null = null;
36 const dirSortRaw = children.find((x) => x.basename === ".dirsort")?.contents ?? "";
37 if (dirSortRaw) {
38 dirsort = dirSortRaw
39 .split("\n")
40 .map((x) => x.trim())
41 .filter(Boolean);
42 }
43
44 // Permissions
45 if (children.some((x) => x.basename === ".friends")) {
46 FilePermissions.setPermissions(dir.path, 1);
47 } else {
48 FilePermissions.setPermissions(dir.path, 0);
49 }
50
51 // Recursive stats.
52 let totalSize = 0;
53 let newestDate = new Date(0);
54 let allHashes = "";
55 for (const child of children) {
56 totalSize += child.size;
57 allHashes += child.hash;
58
59 // readme.txt and hidden files don't render a date in the UI, so they
60 // must not contribute to the directory's date either.
61 const dateExempt = child.basename === "readme.txt" || child.basename.startsWith(".");
62 if (!dateExempt && child.date > newestDate) {
63 newestDate = child.date;
64 }
65 }
66
67 // Project Date
68 const dateFile = children.find((x) => x.basename === ".date");
69 if (dateFile) {
70 const date = new Date(dateFile.contents);
71 if (Number.isNaN(date.getTime())) {
72 // a fresh .date file has no extracted contents until the
73 // text-contents processor lands; leave dir_reindex set and let a
74 // later pass pick this directory back up. a genuinely malformed
75 // .date keeps warning here until it is fixed on disk.
76 console.warn(
77 `${dir.path}/.date is empty or unparseable; deferring date override`,
78 );
79 return;
80 }
81 newestDate = date;
82 dir.setConfig(JSON.stringify({ hideChildrenDates: true }));
83 } else {
84 dir.setConfig("");
85 }
86
87 const dirHash = crypto
88 .createHash("sha1")
89 .update(dir.path + allHashes)
90 .digest("hex");
91
92 MediaFile.markDirectoryProcessed({
93 id: dir.id,
94 timestamp: newestDate,
95 contents: readmeContent,
96 size: totalSize,
97 hash: dirHash,
98 dirsort,
99 });
100}
101
102import * as crypto from "node:crypto";
103
104import * as log from "@clo/lib/log";
105import * as progress from "@clo/lib/progress";
106
107import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts";
108import { MediaFile } from "#src/file-viewer/models/MediaFile.ts";
src/file-viewer/indexer/processors/compress.ts created+35
...@@ -0,0 +1,35 @@
1// pre-compressed variants for static serving. applies to everything that is
2// not already compressed (media containers, archives, ...).
3export const processors: Processor[] = [
4 { name: "gzip", fn: () => zlib.createGzip({ level: 9 }) },
5 { name: "zstd", fn: () => zlib.createZstdCompress() },
6].map(({ name, fn }) => ({
7 name,
8 version: 1,
9 title: `compress ${name}`,
10 exclude: rules.extsPreCompressed,
11 async run({ path, mediaFile, node, signal }) {
12 await derived.produce({
13 mediaFile,
14 node,
15 cores: 1,
16 subkey: name,
17 async producer(dir) {
18 await stream.promises.pipeline(
19 fs.createReadStream(path.toString()),
20 fn(),
21 fs.createWriteStream(dir.join(name).toString()),
22 { signal },
23 );
24 },
25 });
26 },
27}));
28
29import * as fs from "node:fs";
30import * as stream from "node:stream";
31import * as zlib from "node:zlib";
32
33import * as derived from "#src/file-viewer/models/derived.ts";
34import * as rules from "#src/file-viewer/rules.ts";
35import type { Processor } from "../registry.ts";
src/file-viewer/indexer/processors/media.ts created+352
...@@ -0,0 +1,352 @@
1// ffmpeg/ffprobe/magick-based processors: duration, dimensions, optimized
2// image subsets, av1+opus+dash streaming variants, and h264 hls. bodies are
3// ported unchanged from the old file-scan.ts.
4const ffprobeBin = testProgram("ffprobe", "--help");
5const ffmpegBin = testProgram("ffmpeg", "--help");
6
7const ffmpegOptions = ["-hide_banner", "-loglevel", "warning"];
8
9const procDuration: Processor = {
10 name: "duration",
11 version: 1,
12 title: "calculate duration",
13 enable: ffprobeBin !== null,
14 include: rules.extsDuration,
15 cores: 1,
16 async run({ path, mediaFile, signal }) {
17 const { stdout } = await subprocess.exec(ffprobeBin!, [
18 "-v",
19 "error",
20 "-show_entries",
21 "format=duration",
22 "-of",
23 "default=noprint_wrappers=1:nokey=1",
24 path.toString(),
25 ], { signal });
26
27 const duration = parseFloat(stdout.trim());
28 if (Number.isNaN(duration)) {
29 throw new Error("Could not extract duration from " + stdout);
30 }
31 mediaFile.setDuration(Math.ceil(duration));
32 },
33};
34
35const procDimensions: Processor = {
36 name: "dimensions",
37 version: 2,
38 title: "calculate dimensions",
39 enable: ffprobeBin != null,
40 include: rules.extsDimensions,
41 cores: 1,
42 async run({ path, mediaFile, signal }) {
43 const { ext } = path;
44
45 let dimensions;
46
47 if (ext === ".svg") {
48 // Parse out of text data
49 const content = await path.read("utf-8");
50 const widthMatch = content.match(/width="(\d+)"/);
51 const heightMatch = content.match(/height="(\d+)"/);
52
53 if (widthMatch && heightMatch) {
54 dimensions = `${widthMatch[1]}x${heightMatch[1]}`;
55 }
56 } else if (rules.extsImage.has(ext)) {
57 // Use magick to observe streams
58 const { stdout } = await subprocess.exec("magick", [
59 "identify",
60 "-auto-orient",
61 "-format",
62 "%w %h",
63 path.toString(),
64 ], { signal });
65 const [w, h] = stdout.split(" ").map((x) => Number(x));
66 if (w && h) {
67 dimensions = w + "x" + h;
68 }
69 } else {
70 // Use ffprobe to observe streams
71 const { stdout } = await subprocess.exec("ffprobe", [
72 "-v",
73 "error",
74 "-select_streams",
75 "v:0",
76 "-show_entries",
77 "stream=width,height:stream_side_data=rotation",
78 "-of",
79 "json",
80 path.toString(),
81 ], { signal });
82 const result = JSON.parse(stdout);
83 const stream = result.streams[0];
84 if (stream) {
85 let width = UNWRAP(stream.width);
86 let height = UNWRAP(stream.height);
87 // phone videos store the sensor's landscape frame plus a display
88 // matrix; everything downstream (scale filters, aspect-ratio css)
89 // wants the rotated display dimensions.
90 const rotation = (stream.side_data_list ?? [])
91 .find((s: any) => typeof s.rotation === "number")?.rotation ?? 0;
92 if (Math.abs(rotation) % 180 === 90) {
93 [width, height] = [height, width];
94 }
95 dimensions = width + "x" + height;
96 }
97 }
98
99 mediaFile.setDimensions(dimensions ?? "");
100 },
101};
102
103const procImageSubsets: Processor = {
104 name: "image-subsets",
105 version: 3,
106 title: "encode image subsets",
107 include: rules.extsImage,
108 depends: [procDimensions.name],
109 async run({ path, mediaFile, node, signal }) {
110 const dims = mediaFile.parseDimensions();
111 // dimensions could not be extracted; nothing to scale.
112 if (!dims) return;
113 const { width, height } = dims;
114 const targetSizes = transcodeRules.imageSizes.filter((w) => w < width);
115 const baseStatus = node.text;
116
117 using stack = new DisposableStack();
118 for (const size of targetSizes) {
119 const { w, h } = resizeDimensions(width, height, size);
120 for (const { ext, args } of transcodeRules.imagePresets) {
121 node.text = baseStatus + ` (${w}x${h}, ${ext.slice(1).toUpperCase()})`;
122
123 stack.use(
124 await derived.produce({
125 mediaFile,
126 node,
127 cores: 2,
128 subkey: `${size}${ext}`,
129 async producer(dir) {
130 await subprocess.exec(ffmpegBin!, [
131 ...ffmpegOptions,
132 "-i",
133 path.toString(),
134 "-vf",
135 `scale=${w}:${h}:force_original_aspect_ratio=increase,crop=${w}:${h}`,
136 ...args,
137 dir.join(`${size}${ext}`).toString(),
138 ], { signal });
139 },
140 }),
141 );
142 }
143 }
144
145 stack.move();
146 },
147};
148
149const videoArgsCache = new async.OnceMap(transcodeRules.getVideoInputArgs);
150
151const qualityMap: Record<string, string> = {
152 u: "ultra-high",
153 h: "high",
154 m: "medium",
155 l: "low",
156 d: "data-saving",
157};
158
159function skipVideo(mediaFile: MediaFile) {
160 if ((mediaFile.duration ?? 0) < 5) return true;
161 if (!mediaFile.dimensions) return true;
162 if (rules.processDenyList.has(mediaFile.path)) return true;
163 return false;
164}
165
166const procVideos = transcodeRules.videoFormats.map<Processor>((preset) => ({
167 name: `av1-${preset.id}`,
168 version: 4,
169 title: `encode av1 ${UNWRAP(qualityMap[UNWRAP(preset.id[1])])}`,
170 include: rules.extsVideo,
171 enable: ffmpegBin != null,
172 depends: [procDuration.name, procDimensions.name],
173 async run({ path, mediaFile, node, signal }) {
174 if (skipVideo(mediaFile)) return;
175
176 await derived.produce({
177 mediaFile,
178 node,
179 cores: 4,
180 subkey: `av1-${preset.id}`,
181 async producer(dir) {
182 const input = await videoArgsCache.getOrRun(path);
183 const dimensions = mediaFile.parseDimensions();
184 ASSERT(dimensions);
185 const args = transcodeRules.getAv1VideoArgs(
186 preset,
187 dimensions,
188 UNWRAP(input.video, "frick on " + path + JSON.stringify(input)),
189 dir,
190 );
191
192 await ffmpeg.spawn({
193 ffmpeg: ffmpegBin!,
194 progress: node,
195 args,
196 cwd: dir.toString(),
197 signal,
198 });
199 },
200 });
201 },
202}));
203
204const procVideoAudios = transcodeRules.audioFormats.map<Processor>((preset) => ({
205 name: `opus-${preset.id}`,
206 version: 3,
207 title: `encode opus ${UNWRAP(qualityMap[UNWRAP(preset.id)])}`,
208 include: rules.extsVideo,
209 enable: ffmpegBin != null,
210 depends: [procDuration.name, procDimensions.name],
211 async run({ path, mediaFile, node, signal }) {
212 if (skipVideo(mediaFile)) return;
213 await derived.produce({
214 mediaFile,
215 node,
216 cores: 4,
217 subkey: `opus-${preset.id}`,
218 async producer(dir) {
219 const input = await videoArgsCache.getOrRun(path);
220 ASSERT(input.video);
221 if (!input.audio) return;
222 const args = transcodeRules.getOpusAudioArgs(preset, input.audio, dir);
223
224 await ffmpeg.spawn({
225 ffmpeg: ffmpegBin!,
226 progress: node,
227 args,
228 cwd: dir.toString(),
229 signal,
230 });
231 },
232 });
233 },
234}));
235
236const procDash: Processor = {
237 name: "dash",
238 version: 7,
239 title: "encode mpeg-dash",
240 include: rules.extsVideo,
241 enable: ffmpegBin != null,
242 depends: [...procVideos, ...procVideoAudios].map((x) => x.name),
243 async run({ path, mediaFile, node, signal }) {
244 if (skipVideo(mediaFile)) return;
245 await derived.produce({
246 mediaFile,
247 node,
248 cores: 1,
249 subkey: `dash-av1`,
250 async producer(dir) {
251 const input = await videoArgsCache.getOrRun(path);
252 const videos = transcodeRules.videoFormats.map(
253 (preset) =>
254 UNWRAP(dir.parent).join(
255 `av1-${preset.id}`,
256 transcodeRules.av1FileName,
257 ),
258 );
259 const audios = input.audio
260 ? transcodeRules.audioFormats.map(
261 (preset) =>
262 UNWRAP(dir.parent).join(
263 `opus-${preset.id}`,
264 transcodeRules.opusFileName,
265 ),
266 )
267 : [];
268 const args = transcodeRules.getMpegDashArgs(
269 videos,
270 audios,
271 dir,
272 );
273
274 await dir.join("d").makeDir();
275
276 await ffmpeg.spawn({
277 ffmpeg: ffmpegBin!,
278 progress: node,
279 args,
280 cwd: dir.toString(),
281 signal,
282 });
283 },
284 });
285 },
286};
287
288const procH264Hls: Processor = {
289 name: "h264-hls",
290 version: 1,
291 title: "encode h.264 hls",
292 include: rules.extsVideo,
293 enable: ffmpegBin != null,
294 depends: [procDuration.name, procDimensions.name],
295 async run({ path, mediaFile, node, signal }) {
296 if (skipVideo(mediaFile)) return;
297
298 await derived.produce({
299 mediaFile,
300 node,
301 cores: 8,
302 subkey: `hls`,
303 async producer(dir) {
304 const input = await videoArgsCache.getOrRun(path);
305 const args = transcodeRules.getH264HlsArgs(input, dir);
306
307 await ffmpeg.spawn({
308 ffmpeg: ffmpegBin!,
309 progress: node,
310 args,
311 cwd: dir.toString(),
312 signal,
313 });
314 },
315 });
316 },
317};
318
319function resizeDimensions(w: number, h: number, desiredWidth: number) {
320 ASSERT(desiredWidth < w, `${desiredWidth} < ${w}`);
321 return { w: desiredWidth, h: Math.floor((h / w) * desiredWidth) };
322}
323
324function testProgram(name: string, helpArgument: string) {
325 // spawnSync does not throw on a missing binary; it reports `error`
326 const result = child_process.spawnSync(name, [helpArgument]);
327 if (!result.error) return name;
328 console.warn(`Missing or corrupt executable '${name}'`);
329 return null;
330}
331
332export const processors: Processor[] = [
333 procDimensions,
334 procDuration,
335 procImageSubsets,
336 ...procVideos,
337 ...procVideoAudios,
338 procDash,
339 procH264Hls,
340];
341
342import { ASSERT, UNWRAP } from "@clo/lib/assert";
343import * as async from "@clo/lib/async";
344import * as subprocess from "@clo/lib/subprocess";
345import * as ffmpeg from "@clo/lib/subprocess/ffmpeg";
346import * as child_process from "node:child_process";
347
348import * as derived from "#src/file-viewer/models/derived.ts";
349import type { MediaFile } from "#src/file-viewer/models/MediaFile.ts";
350import * as rules from "#src/file-viewer/rules.ts";
351import * as transcodeRules from "#src/file-viewer/transcode-rules.ts";
352import type { Processor } from "../registry.ts";
src/file-viewer/indexer/processors/text.ts created+56
...@@ -0,0 +1,56 @@
1// text-content extraction processors. `text-contents` fills
2// `media_files.contents` for plain text files; `highlight-code` fills it
3// with pre-rendered syntax highlighting html for source code.
4const procLoadTextContents: Processor = {
5 name: "text-contents",
6 version: 2,
7 title: "load text content",
8 include: rules.extsReadContents,
9 cores: 1,
10 async run({ path, mediaFile, stat }) {
11 if (stat.size > 1_000_000) return;
12 const text = await path.read("utf-8");
13 mediaFile.setContents(text);
14 },
15};
16
17const procHighlightCode: Processor = {
18 name: "highlight-code",
19 version: 2,
20 title: "highlight source code",
21 include: new Set(rules.extsCode.keys()),
22 cores: 1,
23 async run({ path, mediaFile, stat }) {
24 const language = UNWRAP(
25 rules.extsCode.get(path.ext.toLowerCase()),
26 );
27 // An issue is that .ts is an overloaded extension, shared between
28 // 'transport stream' and 'typescript'.
29 //
30 // Filter used here is:
31 // - more than 1mb
32 // - invalid UTF-8
33 if (stat.size > 1_000_000) return;
34 let code;
35 const buf = await path.read();
36 try {
37 code = new TextDecoder("utf-8", { fatal: true }).decode(buf);
38 } catch (error) {
39 mediaFile.setContents("");
40 return;
41 }
42 const content = await highlight.highlightCode(code, language);
43 mediaFile.setContents(content);
44 },
45};
46
47export const processors: Processor[] = [
48 procLoadTextContents,
49 procHighlightCode,
50];
51
52import { UNWRAP } from "@clo/lib/assert";
53
54import * as highlight from "#src/file-viewer/highlight.ts";
55import * as rules from "#src/file-viewer/rules.ts";
56import type { Processor } from "../registry.ts";
src/file-viewer/indexer/registry.ts created+100
...@@ -0,0 +1,100 @@
1// The processor registry. Each processor has a stable string `name` and an
2// integer `version`; bumping the version makes every applicable file re-run
3// that processor on the next sweep. This replaces the old scheme of hashing
4// `run.toString()` into a bitfield (rest in peace).
5//
6// This module is intentionally free of database imports so that the one-shot
7// database migration can reuse the definitions and applicability rules
8// before the new schema exists.
9export interface Processor {
10 /** stable identifier, stored in the `processors` table. never rename. */
11 name: string;
12 /** bump to re-run this processor on all applicable files */
13 version: number;
14 /** human-readable label for progress display */
15 title: string;
16 /** approximate cores used while running, for the scheduler */
17 cores?: number;
18 /** false when a required tool (ffmpeg, ...) is missing on this host */
19 enable?: boolean;
20 /** if set, only these extensions apply; otherwise all but `exclude` */
21 include?: Set<string>;
22 /** extensions that do not apply (only when `include` is unset) */
23 exclude?: Set<string>;
24 /** processor names that must complete before this one runs */
25 depends?: string[];
26 run(args: ProcessorRunArgs): Promise<void>;
27}
28
29export interface ProcessorRunArgs {
30 path: Path;
31 stat: fs.Stats;
32 mediaFile: MediaFile;
33 node: progress.Node;
34 /** aborts when the file is deleted mid-run; pass to subprocesses */
35 signal: AbortSignal;
36}
37
38export const processors: readonly Processor[] = [
39 ...mediaProcessors,
40 ...textProcessors,
41 ...compressProcessors,
42];
43
44// integrity checks, evaluated once at import
45{
46 const names = new Set<string>();
47 for (const p of processors) {
48 if (names.has(p.name)) throw new Error(`duplicate processor: ${p.name}`);
49 names.add(p.name);
50 ASSERT(Number.isInteger(p.version) && p.version >= 1, p.name);
51 for (const depend of p.depends ?? []) {
52 if (depend === p.name) throw new Error(`${p.name} depends on itself`);
53 if (!processors.some((o) => o.name === depend)) {
54 throw new Error(`${p.name} depends on unknown '${depend}'`);
55 }
56 }
57 }
58}
59
60/**
61 * which processors apply to a file extension. extension must come from
62 * `MediaFile.extensionNonEmpty`, lowercased. deterministic across hosts:
63 * `enable` and the disable list do not affect applicability, only execution,
64 * so `media_files.pending` means the same thing everywhere.
65 */
66export function applicableFor(ext: string): Processor[] {
67 return processors.filter((p) => p.include ? p.include.has(ext) : !p.exclude?.has(ext));
68}
69
70const disabledList = new Set(
71 (process.env.CLOVER_PROCESSORS_DISABLE ?? "")
72 .split(",")
73 .map((x) => x.trim())
74 .filter(Boolean),
75);
76for (const name of disabledList) {
77 if (!processors.some((p) => p.name === name)) {
78 console.warn(`CLOVER_PROCESSORS_DISABLE: unknown processor '${name}'`);
79 }
80}
81
82/** false when the host cannot or should not execute this processor */
83export function canExecute(p: Processor): boolean {
84 return p.enable !== false && !disabledList.has(p.name);
85}
86
87export function byName(name: string): Processor | null {
88 return processors.find((p) => p.name === name) ?? null;
89}
90
91import type * as progress from "@clo/lib/progress";
92import type * as fs from "node:fs";
93
94import type { Path } from "#sitegen/path";
95import type { MediaFile } from "#src/file-viewer/models/MediaFile.ts";
96
97import { ASSERT } from "@clo/lib/assert";
98import { processors as compressProcessors } from "./processors/compress.ts";
99import { processors as mediaProcessors } from "./processors/media.ts";
100import { processors as textProcessors } from "./processors/text.ts";
src/file-viewer/indexer/scan.ts created+559
...@@ -0,0 +1,559 @@
1// The scanner keeps `media_files` in sync with the raw file store and
2// schedules processors. It is used three ways:
3// - a full sweep over the entire store (boot + periodic)
4// - a targeted scan of one path (file watcher events)
5// - the `file-scan` cli wrapper for development machines
6//
7// Files become visible in the database the moment their metadata row is
8// written; processors run afterwards and trickle their outputs in. The
9// `pending` column on `media_files` tells the UI that more data is coming.
10// `scanPath` resolves once metadata is settled; processor jobs continue in
11// the background on the global queue (await `waitIdle` to block on them).
12//
13// A file that is still being written (a large SMB upload, for example) is
14// not touched until it has been stable for `settleMs`: no hashing, no exif
15// scrubbing, no processors. The scanner just waits and re-stats.
16const console = log.scoped("indexer");
17
18export interface ScannerOptions {
19 /** root of the raw file store */
20 root: Path;
21 /** where the two persistent progress nodes live */
22 progress: progress.Ref;
23 /** notified when database contents change, for the web-node pinger */
24 onChange?: (kind: ChangeKind) => void;
25 /** called whenever all queued work (walks + processors) finishes */
26 onIdle?: () => void;
27 /** a file must be unmodified for this long before it is indexed */
28 settleMs?: number;
29}
30
31/** `metadata` is urgent (new/removed files); `processed` is lazy. */
32export type ChangeKind = "metadata" | "processed";
33
34export class Scanner {
35 root: Path;
36 onChange: (kind: ChangeKind) => void;
37 onIdle: () => void;
38 settleMs: number;
39 /** processor name -> `processors` table row id */
40 ids: Map<string, number>;
41
42 walkQueue = new queue.PriorityQueue(10);
43 // scrub+hash runs on its own small pool, never the global queue: encode
44 // jobs hold global cores for minutes to hours, and a saturated pool would
45 // starve metadata updates — files must become visible immediately even
46 // mid-encode-storm. two slots bound disk thrash while staying responsive.
47 hashQueue = new queue.PriorityQueue(2);
48 #inFlight = 0;
49 #idleWaiters: (() => void)[] = [];
50
51 // the entire progress display is two permanent top-level groups: what is
52 // being indexed right now, and which processors are running. passive
53 // nodes vanish when idle. no per-batch wrapper nodes; it does not matter
54 // whether work came from the boot sweep, the watcher, or /scan.
55 indexNode: progress.Node;
56 processNode: progress.Node;
57
58 constructor(options: ScannerOptions) {
59 this.root = options.root;
60 this.onChange = options.onChange ?? (() => {});
61 this.onIdle = options.onIdle ?? (() => {});
62 this.settleMs = options.settleMs ?? 10_000;
63 this.ids = ProcessorState.syncRegistry(registry.processors);
64 if (!this.root.ifExistsSync()) {
65 throw new Error(`file store ${this.root} is not mounted`);
66 }
67 this.indexNode = options.progress.start("indexing files", {
68 passive: true,
69 showTotal: false,
70 });
71 this.processNode = options.progress.start("running processors", {
72 passive: true,
73 showTotal: false,
74 });
75 this.processNode.sortChildren = (a, b) => {
76 const ac = a.children.length > 0 ? 1 : 0;
77 const bc = b.children.length > 0 ? 1 : 0;
78 if (ac !== bc) return bc - ac;
79 return a.text.localeCompare(b.text);
80 };
81 }
82
83 /** scan the whole store. resolves when all metadata rows are updated. */
84 sweep(): Promise<void> {
85 return this.scanPath(this.root);
86 }
87
88 /**
89 * scan one absolute path (file or directory). resolves when the subtree's
90 * metadata is settled; processor jobs continue in the background.
91 */
92 async scanPath(path: Path): Promise<void> {
93 const ctx: ScanContext = {
94 promises: new async.PromiseAggregator(),
95 };
96 ctx.promises.push(this.#visit(path, ctx));
97 await ctx.promises.all();
98 }
99
100 /** wait for all background processor jobs to finish */
101 async waitIdle(): Promise<void> {
102 if (this.#inFlight === 0) return;
103 await new Promise<void>((resolve) => this.#idleWaiters.push(resolve));
104 }
105
106 #beginJob() {
107 this.#inFlight += 1;
108 }
109 #endJob() {
110 ASSERT(this.#inFlight > 0);
111 this.#inFlight -= 1;
112 if (this.#inFlight === 0) {
113 const waiters = this.#idleWaiters;
114 this.#idleWaiters = [];
115 for (const w of waiters) w();
116 this.onIdle();
117 }
118 }
119
120 #visit = this.walkQueue.wrap(async (path: Path, ctx: ScanContext) => {
121 const publicPath = toPublicPath(this.root, path);
122 using node = this.indexNode.start(publicPath + " - stat");
123
124 let stat: fs.Stats;
125 try {
126 stat = await path.stat();
127 } catch (err) {
128 if (error.code(err) !== "ENOENT") throw err;
129 // deleted; watcher events often describe files already gone
130 this.#removePath(publicPath);
131 return;
132 }
133
134 const mediaFile = MediaFile.getByPath(publicPath);
135
136 // the row may carry a different spelling of the same path (case-only
137 // rename: same inode, same mtime, so no metadata pass runs). adopt the
138 // on-disk spelling — only the parent's listing knows it; both the event
139 // path and the stored path can be stale.
140 if (mediaFile && mediaFile.id !== 0 && mediaFile.path !== publicPath) {
141 await this.#reconcileCase(path, mediaFile);
142 }
143
144 if (stat.isDirectory()) {
145 node.text = publicPath + " - reading";
146 const items = (await path.readDir())
147 .filter((child) => !skipBasename(child.base))
148 .map((child) => (
149 ctx.promises.push(this.#visit(child, ctx)), child.base
150 ));
151
152 // reconcile deletions against the database. the store is
153 // case-insensitive, so compare names folded; a case-only rename is the
154 // same file, not a delete + create.
155 const names = new Set(items.map((name) => name.toLowerCase()));
156 for (const child of mediaFile?.getChildren() ?? []) {
157 if (names.has(child.basename.toLowerCase())) continue;
158 this.#removeFile(child);
159 }
160 return;
161 }
162
163 if (
164 !mediaFile
165 || stat.size !== mediaFile.size
166 || stat.mtime.getTime() !== mediaFile.date.getTime()
167 ) {
168 // do not hold a walk queue slot while settling/hashing. concurrent
169 // visits of the same path (watch events racing a sweep, or two case
170 // spellings of one physical file) share one metadata update instead
171 // of hashing — or worse, scrubbing — the file twice. one broken file
172 // logs and moves on; it must not abort the surrounding scan.
173 const flightKey = publicPath.toLowerCase();
174 let job = this.#metaInFlight.get(flightKey);
175 if (!job) {
176 job = this.#updateMetadata({ path, publicPath, stat, mediaFile })
177 .catch((err) => console.error(`indexing ${publicPath} failed:`, err))
178 .finally(() => this.#metaInFlight.delete(flightKey));
179 this.#metaInFlight.set(flightKey, job);
180 }
181 ctx.promises.push(job);
182 } else {
183 this.#queueProcessors({ path, stat, mediaFile });
184 }
185 });
186
187 #metaInFlight = new Map<string, Promise<void>>();
188
189 async #reconcileCase(path: Path, mediaFile: MediaFile) {
190 const parent = path.parent;
191 if (!parent) return;
192 let names: string[];
193 try {
194 names = (await parent.readDir()).map((entry) => entry.base);
195 } catch {
196 return;
197 }
198 const folded = path.base.toLowerCase();
199 const trueName = names.find((name) => name.toLowerCase() === folded);
200 if (!trueName) return;
201 const truePath = toPublicPath(this.root, parent.join(trueName));
202 if (truePath === mediaFile.path) return;
203 console.info(`case rename ${mediaFile.path} -> ${truePath}`);
204 mediaFile.updatePath(truePath);
205 mediaFile.getParent()?.markDirReindex();
206 this.onChange("metadata");
207 }
208
209 #removePath(publicPath: string) {
210 const row = MediaFile.getByPath(publicPath);
211 if (!row || row.id === 0) return;
212 this.#removeFile(row);
213 }
214
215 #removeFile(file: MediaFile) {
216 const recursive = file.kind === MediaFileKind.directory
217 ? [file, ...file.getRecursiveFileChildren()]
218 : [file];
219 for (const deletion of recursive) {
220 deletion.delete();
221 // kill in-flight processors; a deleted file must not keep encoding
222 this.#fileAborts.get(deletion.id)?.abort();
223 }
224 console.info(`deleted ${file.path}`);
225 file.getParent()?.markDirReindex();
226 this.onChange("metadata");
227 }
228
229 async #updateMetadata(
230 { path, publicPath, stat, mediaFile }: {
231 path: Path;
232 publicPath: string;
233 stat: fs.Stats;
234 mediaFile: MediaFile | null;
235 },
236 ) {
237 const label = publicPath.slice(1);
238 using node = this.indexNode.start(label);
239
240 // hold off on everything until the file has stopped changing.
241 const settled = await this.#waitUntilSettled(path, stat, node);
242 if (settled === null) {
243 this.#removePath(publicPath);
244 return;
245 }
246 stat = settled;
247
248 node.text = `${label} - waiting to hash`;
249 const hash = await this.hashQueue.run({
250 cores: 1,
251 run: async () => {
252 if (await scrub.scrubLocationMetadata(path, stat, node)) {
253 stat = await path.stat();
254 }
255 node.text = `${label} - hashing`;
256 return await hashFile(path);
257 },
258 });
259
260 let date = stat.mtime;
261 if (
262 mediaFile
263 && mediaFile.date.getTime() < stat.mtime.getTime()
264 && Date.now() - stat.mtime.getTime() < monthMilliseconds
265 ) {
266 date = mediaFile.date;
267 console.warn(
268 `M-time on ${publicPath} was likely corrupted. ${formatDate(mediaFile.date)} -> ${formatDate(stat.mtime)}`,
269 );
270 }
271
272 const contentChanged = !mediaFile || mediaFile.hash !== hash;
273 mediaFile = MediaFile.createFile({
274 path: publicPath,
275 date,
276 hash,
277 size: stat.size,
278 duration: mediaFile?.duration ?? 0,
279 dimensions: mediaFile?.dimensions ?? "",
280 contents: mediaFile?.contents ?? "",
281 });
282 if (contentChanged) ProcessorState.invalidateFile(mediaFile.id);
283
284 mediaFile.getParent()?.markDirReindex();
285 this.onChange("metadata");
286
287 node.text = label;
288 this.#queueProcessors({ path, stat, mediaFile });
289 }
290
291 async #waitUntilSettled(
292 path: Path,
293 stat: fs.Stats,
294 node: progress.Node,
295 ): Promise<fs.Stats | null> {
296 const baseText = node.text;
297 let polls = 0;
298 while (true) {
299 const age = Date.now() - stat.mtime.getTime();
300 if (age >= this.settleMs) break;
301 // mtimes in the future cannot settle; the corruption guard dates them
302 if (age < -60_000) break;
303 node.text = `${baseText} - waiting for upload to finish`;
304 await async.delay(Math.min(this.settleMs, 2500));
305 if ((polls += 1) === 240) { // roughly ten minutes
306 console.warn(`${path} has been unstable for a long time`);
307 }
308 try {
309 stat = await path.stat();
310 } catch (err) {
311 if (error.code(err) === "ENOENT") return null;
312 throw err;
313 }
314 }
315 node.text = baseText;
316 return stat;
317 }
318
319 // files whose processor pipeline is currently queued or running. a second
320 // scan of the file (sweep racing the watcher) must not double-queue jobs
321 // or reset `pending` mid-flight. the abort controller cancels the
322 // pipeline's subprocesses when the file is deleted.
323 #activeProcessing = new Set<number>();
324 #fileAborts = new Map<number, AbortController>();
325
326 #queueProcessors(
327 args: {
328 path: Path;
329 stat: fs.Stats;
330 mediaFile: MediaFile;
331 },
332 ) {
333 const { mediaFile } = args;
334 if (this.#activeProcessing.has(mediaFile.id)) return;
335 const ext = mediaFile.extensionNonEmpty.toLowerCase();
336 const applicable = registry.applicableFor(ext);
337 if (applicable.length === 0) {
338 if (mediaFile.pending !== 0) mediaFile.setPending(0);
339 return;
340 }
341
342 const states = ProcessorState.getStates(mediaFile.id);
343 const needed = applicable.filter((p) => {
344 const state = states.get(UNWRAP(this.ids.get(p.name)));
345 return !state || state.version !== p.version
346 || state.status === ProcessorState.ProcessorStatus.failed;
347 });
348 if (mediaFile.pending !== needed.length) {
349 mediaFile.setPending(needed.length);
350 }
351 if (needed.length === 0) return;
352
353 // a processor can only run when the host has its tools and all of its
354 // dependencies are either previously-done or also runnable now.
355 const runnable = new Set<registry.Processor>();
356 let grew = true;
357 while (grew) {
358 grew = false;
359 for (const p of needed) {
360 if (runnable.has(p) || !registry.canExecute(p)) continue;
361 const ok = (p.depends ?? []).every((depend) => {
362 const dep = needed.find((o) => o.name === depend);
363 return !dep || runnable.has(dep);
364 });
365 if (ok) {
366 runnable.add(p);
367 grew = true;
368 }
369 }
370 }
371 if (runnable.size === 0) return;
372
373 this.#activeProcessing.add(mediaFile.id);
374 const abort = new AbortController();
375 this.#fileAborts.set(mediaFile.id, abort);
376 const node = this.processNode.start(mediaFile.path.slice(1), {
377 passive: true,
378 showTotal: false,
379 total: runnable.size,
380 });
381 // the whole pipeline is accounted upfront: counting per-started-job
382 // would let the in-flight count transiently hit zero between a
383 // dependency finishing and its dependants starting.
384 for (let i = 0; i < runnable.size; i += 1) this.#beginJob();
385 let remaining = runnable.size;
386 const settleJob = () => {
387 node.value += 1;
388 this.#endJob();
389 if ((remaining -= 1) === 0) {
390 node.end();
391 this.#activeProcessing.delete(mediaFile.id);
392 this.#fileAborts.delete(mediaFile.id);
393 }
394 };
395
396 const jobs = [...runnable].map<ProcessJob>((processor) => ({
397 processor,
398 after: [],
399 needs: 0,
400 done: false,
401 }));
402 for (const job of jobs) {
403 for (const depend of job.processor.depends ?? []) {
404 const dependJob = jobs.find((j) => j.processor.name === depend);
405 if (dependJob) {
406 dependJob.after.push(job);
407 job.needs += 1;
408 }
409 }
410 }
411
412 // when a job fails, everything transitively depending on it is
413 // abandoned: still pending, retried together on the next sweep.
414 const abandon = (job: ProcessJob) => {
415 for (const dependant of job.after) {
416 if (dependant.done) continue;
417 dependant.done = true;
418 settleJob();
419 mediaFile.decPending();
420 abandon(dependant);
421 }
422 };
423
424 const start = (job: ProcessJob) => {
425 queue.run({
426 cores: job.processor.cores ?? 0,
427 run: () => this.#executeJob(job.processor, args, node, abort.signal),
428 }).then(() => {
429 job.done = true;
430 settleJob();
431 for (const dependant of job.after) {
432 ASSERT(dependant.needs > 0);
433 dependant.needs -= 1;
434 if (dependant.needs === 0 && !dependant.done) start(dependant);
435 }
436 }, () => {
437 job.done = true;
438 settleJob();
439 abandon(job);
440 });
441 };
442 for (const job of jobs) if (job.needs === 0) start(job);
443 }
444
445 async #executeJob(
446 processor: registry.Processor,
447 { path, stat, mediaFile }: {
448 path: Path;
449 stat: fs.Stats;
450 mediaFile: MediaFile;
451 },
452 parent: progress.Node,
453 signal: AbortSignal,
454 ) {
455 // deleted while this job sat in the queue: skip silently. the delete
456 // already cleaned up `file_processors` and `pending`.
457 const rowExists = () => MediaFile.getByPath(mediaFile.path)?.id === mediaFile.id;
458 if (signal.aborted || !rowExists()) return;
459
460 using node = parent.start(processor.title);
461 const id = UNWRAP(this.ids.get(processor.name));
462 try {
463 await processor.run({ path, stat, mediaFile, node, signal });
464 if (signal.aborted || !rowExists()) return;
465 ProcessorState.recordResult(
466 mediaFile.id,
467 id,
468 processor.version,
469 ProcessorState.ProcessorStatus.done,
470 );
471 mediaFile.decPending();
472 this.onChange("processed");
473 } catch (err: any) {
474 if (signal.aborted) {
475 console.info(`${processor.name} aborted on ${mediaFile.path} (deleted)`);
476 throw err;
477 }
478 if (rowExists()) {
479 const message = String(err?.stack ?? err).slice(0, 4000);
480 ProcessorState.recordResult(
481 mediaFile.id,
482 id,
483 processor.version,
484 ProcessorState.ProcessorStatus.failed,
485 message,
486 );
487 mediaFile.decPending();
488 this.onChange("processed");
489 }
490 console.error(`${processor.name} failed on ${mediaFile.path}:`, err);
491 throw err;
492 }
493 }
494}
495
496interface ScanContext {
497 promises: async.PromiseAggregator;
498}
499
500interface ProcessJob {
501 processor: registry.Processor;
502 after: ProcessJob[];
503 needs: number;
504 done: boolean;
505}
506
507export function hashFile(path: Path): Promise<string> {
508 return new Promise<string>((resolve, reject) => {
509 const reader = fs.createReadStream(path.toString());
510 reader.on("error", reject);
511
512 const hasher = crypto.createHash("sha1").setEncoding("hex");
513 hasher.on("error", reject);
514 hasher.on("readable", () => resolve(hasher.read()));
515
516 reader.pipe(hasher);
517 });
518}
519
520export function skipBasename(basename: string): boolean {
521 // dot files must be incrementally tracked
522 if (basename === ".dirsort") return false;
523 if (basename === ".friends") return false;
524 if (basename === ".date") return false;
525
526 return (
527 basename.startsWith(".")
528 || basename.startsWith("tmp.")
529 || basename.toLowerCase() === "thumbs.db"
530 || basename.toLowerCase() === "desktop.ini"
531 );
532}
533
534export function toPublicPath(root: Path, diskPath: Path) {
535 if (diskPath.toString() === root.toString()) return "/";
536 return "/"
537 + path.relative(root.toString(), diskPath.toString()).replaceAll("\\", "/");
538}
539
540const monthMilliseconds = 30 * 24 * 60 * 60 * 1000;
541
542import * as crypto from "node:crypto";
543import * as fs from "node:fs";
544import * as path from "node:path";
545
546import { Path } from "#sitegen/path";
547import { ASSERT, UNWRAP } from "@clo/lib/assert";
548import * as async from "@clo/lib/async";
549import * as error from "@clo/lib/error";
550import * as log from "@clo/lib/log";
551import * as progress from "@clo/lib/progress";
552import * as queue from "@clo/lib/queue";
553import * as ts from "@clo/lib/ts";
554
555import { formatDate } from "#src/file-viewer/format.ts";
556import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts";
557import * as ProcessorState from "#src/file-viewer/models/ProcessorState.ts";
558import * as registry from "./registry.ts";
559import * as scrub from "./scrub.ts";
src/file-viewer/indexer/scrub.ts created+160
...@@ -0,0 +1,160 @@
1// gps/location metadata removal, ported from the old file-scan.ts. runs
2// before hashing so the stored hash always reflects the scrubbed file.
3// requires the file to be fully written (the scanner's stability gate runs
4// first); modifies the file in place while preserving timestamps.
5const exiftoolBin = testProgram("exiftool");
6
7// `-ee` on an iphone video dumps per-frame embedded metadata, which easily
8// exceeds execFile's default 1MB stdout buffer
9const execOptions = { maxBuffer: 64 * 1024 * 1024 };
10
11export async function scrubLocationMetadata(
12 path: Path,
13 stats: fs.Stats,
14 progress: progress.Ref,
15): Promise<boolean> {
16 using _ = progress.start("scrub exif metadata");
17 const ext = path.ext.toLowerCase();
18 if (!rules.extsScrubExif.has(ext)) return false;
19 if (!exiftoolBin) {
20 warnMissingExiftool();
21 return false;
22 }
23
24 let hasLocation = false;
25 let args: string[] = [];
26
27 // Check for location metadata based on file type
28 const tempOutput = UNWRAP(path.parent).join(`.tmp.${path.base}`);
29 switch (ext) {
30 case ".jpg":
31 case ".jpeg":
32 case ".png":
33 const { stdout: gpsCheck } = await subprocess.exec("exiftool", [
34 "-gps:all",
35 path.toString(),
36 ], execOptions);
37 hasLocation = gpsCheck.trim().length > 0;
38 args = ["-gps:all=", path.toString(), "-o", tempOutput.toString()];
39 break;
40 case ".mov":
41 case ".mp4":
42 const { stdout: videoCheck } = await subprocess.exec("exiftool", [
43 "-ee",
44 "-G3",
45 "-s",
46 path.toString(),
47 ], execOptions);
48 hasLocation = videoCheck.includes("GPS")
49 || videoCheck.includes("Location");
50 args = [
51 "-gps:all=",
52 "-xmp:all=",
53 path.toString(),
54 "-o",
55 tempOutput.toString(),
56 ];
57 break;
58 case ".m4a":
59 const { stdout: m4aCheck } = await subprocess.exec("exiftool", [
60 "-ee",
61 "-G3",
62 "-s",
63 path.toString(),
64 ], execOptions);
65 hasLocation = m4aCheck.includes("GPS")
66 || m4aCheck.includes("Location")
67 || m4aCheck.includes("Filename")
68 || m4aCheck.includes("Title");
69
70 if (hasLocation) {
71 args = [
72 "-gps:all=",
73 "-location:all=",
74 "-filename:all=",
75 "-title=",
76 "-m4a:all=",
77 path.toString(),
78 "-o",
79 tempOutput.toString(),
80 ];
81 }
82 break;
83 }
84
85 const accessTime = stats.atime;
86 const modTime = stats.mtime;
87
88 let backup: Path | null = null;
89 try {
90 if (hasLocation) {
91 // Prepare a backup. content-only copy: fs.copyFile's metadata
92 // preservation gets EPERM'd on the NAS datasets, plain writes do not.
93 const tmp = UNWRAP(path.parent).join(`.tmp.backup.${path.base}`);
94 await stream.promises.pipeline(
95 fs.createReadStream(path.toString()),
96 fs.createWriteStream(tmp.toString()),
97 );
98 await fsp.utimes(tmp.toString(), accessTime, modTime);
99 backup = tmp;
100
101 // a leftover temp from a crashed run makes exiftool refuse to write
102 await tempOutput.delete({ force: true });
103
104 // Remove metadata
105 await subprocess.exec("exiftool", args, execOptions);
106 if (!tempOutput.ifExistsSync()) {
107 throw new Error(`Failed to create output file: ${tempOutput}`);
108 }
109
110 // Restore original timestamps
111 await fsp.rename(tempOutput.toString(), path.toString());
112 await fsp.utimes(path.toString(), accessTime, modTime);
113
114 // Backup is no longer needed
115 await fsp.unlink(backup.toString());
116
117 console.info(`Scrubbed location metadata in ${path}`);
118 return true;
119 }
120 } catch (error) {
121 // restore is best-effort: a concurrent scrub of the same physical file
122 // (case-insensitive store) may have already consumed the backup
123 if (backup && fs.existsSync(backup.toString())) {
124 await fsp.rename(backup.toString(), path.toString());
125 }
126 if (fs.existsSync(tempOutput.toString())) {
127 await fsp.unlink(tempOutput.toString());
128 }
129 throw error;
130 }
131
132 return false;
133}
134
135let warnedMissingExiftool = false;
136function warnMissingExiftool() {
137 if (warnedMissingExiftool) return;
138 warnedMissingExiftool = true;
139 console.warn(
140 "exiftool is not installed; skipping gps metadata scrubbing entirely",
141 );
142}
143
144function testProgram(name: string) {
145 // spawnSync does not throw on a missing binary; it reports `error`
146 const result = child_process.spawnSync(name, ["-ver"]);
147 return result.error ? null : name;
148}
149
150import * as child_process from "node:child_process";
151import * as fs from "node:fs";
152import * as fsp from "node:fs/promises";
153import * as stream from "node:stream";
154
155import { Path } from "#sitegen/path";
156import { UNWRAP } from "@clo/lib/assert";
157import * as progress from "@clo/lib/progress";
158import * as subprocess from "@clo/lib/subprocess";
159
160import * as rules from "#src/file-viewer/rules.ts";
src/file-viewer/indexer/service.ts created+297
...@@ -0,0 +1,297 @@
1// The indexer service: owns the singleton progress root that all scanning
2// and processing operations report into, watches the file store, runs
3// periodic full sweeps, and pings web nodes when the database changes.
4//
5// One progress root for the lifetime of the process; it is never ended.
6// `GET /progress` on the source of truth attaches encoders to it, which
7// snapshot current state on connect and then stream deltas.
8const console = log.scoped("indexer");
9
10/** the singleton progress root. */
11export const root = new progress.Root();
12
13/** bumped on every database mutation; `GET /db` uses this to invalidate */
14export let generation = 0;
15
16// -- database change events --
17// consumers (the /db/events sse route) subscribe here; the indexer emits a
18// debounced beacon after changes. anything else that swaps the database
19// (e.g. the /reload route) can emit through `emitDbChange`.
20const dbSubscribers = new Set<(generation: number) => void>();
21
22/** subscribe to database-changed beacons. dispose to unsubscribe. */
23export function subscribeDbChange(fn: (generation: number) => void): ts.Dispose {
24 dbSubscribers.add(fn);
25 return ts.defer(() => void dbSubscribers.delete(fn));
26}
27
28/** notify all subscribers that the database changed */
29export function emitDbChange() {
30 generation += 1;
31 for (const fn of dbSubscribers) fn(generation);
32}
33
34export interface ServiceOptions {
35 /** raw file store root. default: paths.rawFileRoot */
36 root?: string;
37 /** full sweep interval. default: CLOVER_SCAN_INTERVAL or 24h */
38 sweepIntervalMs?: number;
39 /** stability window before indexing a changed file */
40 settleMs?: number;
41 /** disable the file watcher (CLOVER_WATCH=0) */
42 watch?: boolean;
43}
44
45let started = false;
46
47export function start(options: ServiceOptions = {}) {
48 ASSERT(!started, "indexer service started twice");
49 started = true;
50
51 const cores = Number(process.env.CLOVER_INDEX_CORES ?? 0);
52 if (cores > 0) queue.setConcurrency(cores);
53
54 const service = new Service(options);
55 service.init();
56 return service;
57}
58
59export class Service {
60 scanner: Scanner;
61 sweepIntervalMs: number;
62 watchEnabled: boolean;
63 watcher: fs.FSWatcher | null = null;
64
65 // paths that changed according to the watcher, waiting to be scanned
66 #dirty = new Map<string, number>();
67 #dirtyTimer: NodeJS.Timeout | null = null;
68 #sweeping = false;
69
70 constructor(options: ServiceOptions) {
71 this.scanner = new Scanner({
72 root: Path.resolve(options.root ?? paths.rawFileRoot),
73 progress: root,
74 settleMs: options.settleMs
75 ?? Number(process.env.CLOVER_SETTLE_MS ?? 10_000),
76 onChange: (kind) => this.#onDbChange(kind),
77 onIdle: () => this.#onIdle(),
78 });
79 this.sweepIntervalMs = options.sweepIntervalMs
80 ?? parseDuration(process.env.CLOVER_SCAN_INTERVAL ?? "24h");
81 this.watchEnabled = options.watch ?? process.env.CLOVER_WATCH !== "0";
82 }
83
84 init() {
85 void this.sweep("boot");
86 setInterval(() => void this.sweep("periodic"), this.sweepIntervalMs)
87 .unref();
88 if (this.watchEnabled) this.#startWatcher();
89 console.info(
90 `indexer service started (root: ${this.scanner.root}, `
91 + `sweep every ${string.formatDurationLetters(this.sweepIntervalMs / 1000)}, `
92 + `watch: ${this.watchEnabled})`,
93 );
94 }
95
96 /** run a full sweep (or a subtree scan when `subPath` is given) */
97 async sweep(reason: string, subPath?: string) {
98 if (this.#sweeping && !subPath) {
99 console.warn(`skipping ${reason} sweep; one is already running`);
100 return;
101 }
102 try {
103 if (!subPath) this.#sweeping = true;
104 const target = subPath
105 ? this.scanner.root.join("." + path.posix.normalize("/" + subPath))
106 : this.scanner.root;
107 await this.scanner.scanPath(target);
108 this.#runDirMeta();
109 if (!subPath) await this.#maintenance();
110 } catch (err) {
111 console.error(`${reason} scan failed:`, err);
112 } finally {
113 if (!subPath) this.#sweeping = false;
114 }
115 }
116
117 // delete derived assets whose last referencing file is gone, plus tmp
118 // directories left behind by crashed producers. folded in from the old
119 // `file-trim` binary.
120 async #maintenance() {
121 using node = this.scanner.indexNode.start("maintenance");
122 const orphaned = derived.findOrphanedRoots();
123 for (const orphan of orphaned) {
124 node.text = `delete orphaned ${orphan.key}`;
125 await derived.deleteRootFiles(orphan);
126 derived.deleteRoot(orphan);
127 }
128 const cleaned = await derived.cleanAbandonedTmp();
129 if (orphaned.length + cleaned > 0) {
130 console.info(
131 `maintenance: ${orphaned.length} orphaned roots, ${cleaned} stale tmp dirs`,
132 );
133 this.#onDbChange("processed");
134 }
135 }
136
137 // -- file watcher --
138
139 #startWatcher() {
140 try {
141 this.watcher = fs.watch(
142 this.scanner.root.toString(),
143 { recursive: true },
144 (_event, subPath) => subPath && this.#markDirty(subPath),
145 );
146 this.watcher.on("error", (err) => {
147 console.error("file watcher died, relying on periodic sweeps:", err);
148 this.watcher = null;
149 });
150 } catch (err) {
151 console.error("file watcher unavailable, relying on sweeps:", err);
152 this.watcher = null;
153 }
154 }
155
156 #markDirty(subPath: string) {
157 subPath = subPath.replaceAll("\\", "/");
158 // ignore events for paths the scanner would skip anyway (.DS_Store and
159 // friends), but also for anything inside a skipped directory.
160 if (subPath.split("/").some((part) => skipBasename(part))) return;
161 this.#dirty.set(subPath, Date.now());
162 this.#dirtyTimer ??= setTimeout(() => {
163 this.#dirtyTimer = null;
164 void this.#flushDirty();
165 }, watchDebounceMs);
166 }
167
168 // targets currently being scanned. a path that keeps emitting events (a
169 // large upload sitting in the settle gate) must not pile up concurrent
170 // scans of itself; it is re-checked once the in-flight scan completes.
171 #scanning = new Map<string, Promise<void>>();
172
173 async #flushDirty() {
174 if (this.#dirty.size === 0) return;
175 const all = [...this.#dirty.keys()];
176 this.#dirty.clear();
177
178 // coalesce: if a parent path is queued, skip its children. scanning is
179 // recursive for directories, so the parent covers them. comparisons are
180 // case-insensitive to match the file store's semantics; rename events
181 // can spell the same physical path two ways.
182 const sorted = all.sort();
183 const targets: string[] = [];
184 for (const p of sorted) {
185 const lp = p.toLowerCase();
186 const covered = targets.some((t) => {
187 const lt = t.toLowerCase();
188 return lp === lt || lp.startsWith(lt + "/");
189 });
190 if (!covered) targets.push(p);
191 }
192
193 const fresh: string[] = [];
194 for (const target of targets) {
195 // defer paths already being scanned; the tail of the flush that owns
196 // them re-arms the timer, which picks these back up.
197 if (this.#scanning.has(target.toLowerCase())) {
198 this.#dirty.set(target, Date.now());
199 } else fresh.push(target);
200 }
201 if (fresh.length === 0) return;
202
203 // parallel: one file mid-upload (settling) must not block the others
204 await Promise.all(fresh.map((target) => {
205 const job = this.scanner
206 .scanPath(this.scanner.root.join(target))
207 .catch((err) => console.error(`watch scan of ${target} failed:`, err))
208 .finally(() => {
209 this.#scanning.delete(target.toLowerCase());
210 });
211 this.#scanning.set(target.toLowerCase(), job);
212 return job;
213 }));
214 this.#runDirMeta();
215
216 // events that arrived during the scans (including deferred re-checks of
217 // the paths scanned just now) get their own flush.
218 if (this.#dirty.size > 0) {
219 this.#dirtyTimer ??= setTimeout(() => {
220 this.#dirtyTimer = null;
221 void this.#flushDirty();
222 }, watchDebounceMs);
223 }
224 }
225
226 // -- directory metadata --
227
228 // the dir meta pass also runs after processors finish, since readme.txt
229 // contents arrive via the text-contents processor.
230 #onIdle() {
231 if (this.#dirMetaTimer) return;
232 this.#dirMetaTimer = setTimeout(() => {
233 this.#dirMetaTimer = null;
234 this.#runDirMeta();
235 }, 1000);
236 this.#dirMetaTimer.unref();
237 }
238 #dirMetaTimer: NodeJS.Timeout | null = null;
239
240 #runDirMeta() {
241 try {
242 if (dirmeta.run(this.scanner.indexNode)) this.#onDbChange("metadata");
243 } catch (err) {
244 console.error("directory metadata pass failed:", err);
245 }
246 }
247
248 // -- database change beacons --
249 // consumers subscribe via /db/events and pull /db when beaconed. metadata
250 // changes (new files) flush fast; processor completions are batched
251 // coarsely since they arrive in bursts during encodes.
252
253 #beaconTimer: NodeJS.Timeout | null = null;
254 #beaconDeadline = Infinity;
255
256 #onDbChange(kind: ChangeKind) {
257 const delay = kind === "metadata" ? metadataBeaconMs : processedBeaconMs;
258 const deadline = Date.now() + delay;
259 if (deadline < this.#beaconDeadline) {
260 this.#beaconDeadline = deadline;
261 if (this.#beaconTimer) clearTimeout(this.#beaconTimer);
262 this.#beaconTimer = setTimeout(() => {
263 this.#beaconTimer = null;
264 this.#beaconDeadline = Infinity;
265 emitDbChange();
266 }, delay);
267 this.#beaconTimer.unref();
268 }
269 }
270}
271
272export function parseDuration(text: string): number {
273 const match = text.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/);
274 if (!match) throw new Error(`cannot parse duration: ${JSON.stringify(text)}`);
275 const scale = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
276 return Number(match[1]) * scale[(match[2] ?? "ms") as keyof typeof scale];
277}
278
279const watchDebounceMs = 1000;
280const metadataBeaconMs = 2_000;
281const processedBeaconMs = 30_000;
282
283import * as fs from "node:fs";
284import * as path from "node:path";
285
286import { Path } from "#sitegen/path";
287import { ASSERT } from "@clo/lib/assert";
288import * as log from "@clo/lib/log";
289import * as progress from "@clo/lib/progress";
290import * as queue from "@clo/lib/queue";
291import * as string from "@clo/lib/string";
292import * as ts from "@clo/lib/ts";
293
294import * as derived from "#src/file-viewer/models/derived.ts";
295import * as paths from "#src/file-viewer/paths.ts";
296import * as dirmeta from "./dirmeta.ts";
297import { type ChangeKind, Scanner, skipBasename } from "./scan.ts";
src/file-viewer/models/FilePermissions.ts+7-5
...@@ -32,21 +32,23 @@ export class FilePermissions {...@@ -32,21 +32,23 @@ export class FilePermissions {
32 }32 }
33}33}
3434
35// comparisons fold case to match the file store: a permission on
36// "/2026/friends" must also gate a request spelled "/2026/Friends"
35const getByPrefixQuery = db.prepare<37const getByPrefixQuery = db.prepare<
36 [prefix: string],38 [prefix: string],
37 Pick<FilePermissions, "allow">39 Pick<FilePermissions, "allow">
38>(/* SQL */ `40>(/* SQL */ `
39 select allow 41 select allow
40 from permissions 42 from permissions
41 where ? glob prefix || '*'43 where lower(?) glob lower(prefix) || '*'
42 order by length(prefix) desc 44 order by length(prefix) desc
43 limit 1;45 limit 1;
44`);46`);
45const getExactQuery = db.prepare<47const getExactQuery = db.prepare<
46 [file: string],48 [file: string],
47 Pick<FilePermissions, "allow">49 Pick<FilePermissions, "allow">
48>(/* SQL */ `50>(/* SQL */ `
49 select allow from permissions where ? == prefix51 select allow from permissions where ? == prefix collate nocase
50`);52`);
5153
52const insertQuery = db.prepare<[{ prefix: string; allow: number }]>(/* SQL */ `54const insertQuery = db.prepare<[{ prefix: string; allow: number }]>(/* SQL */ `
src/file-viewer/models/MediaFile.ts+86-53
...@@ -5,7 +5,7 @@ db.table(...@@ -5,7 +5,7 @@ db.table(
5 create table media_files (5 create table media_files (
6 id integer primary key autoincrement,6 id integer primary key autoincrement,
7 parent_id integer,7 parent_id integer,
8 path text unique,8 path text,
9 kind integer not null,9 kind integer not null,
10 timestamp integer not null,10 timestamp integer not null,
11 timestamp_updated integer not null default current_timestamp,11 timestamp_updated integer not null default current_timestamp,
...@@ -15,18 +15,20 @@ db.table(...@@ -15,18 +15,20 @@ db.table(
15 dimensions text not null default "",15 dimensions text not null default "",
16 contents text not null,16 contents text not null,
17 dirsort text,17 dirsort text,
18 processed integer not null,18 config text not null default "",
19 processors text not null default "",19 dir_reindex integer not null default 0,
20 foreign key (parent_id) references media_files(id)20 pending integer not null default 0,
21 foreign key (parent_id) references media_files(id) on delete cascade
21 );22 );
22 -- index for quickly looking up files by path23 -- path lookups fold case: the underlying file stores (zfs smb datasets,
23 create index media_files_path on media_files (path);24 -- apfs) are case-insensitive, so two case spellings are one file
25 create unique index media_files_path on media_files (path collate nocase);
24 -- index for quickly looking up children26 -- index for quickly looking up children
25 create index media_files_parent_id on media_files (parent_id);27 create index media_files_parent_id on media_files (parent_id);
26 -- index for quickly looking up recursive file children28 -- index for quickly looking up recursive file children
27 create index media_files_file_children on media_files (kind, path);29 create index media_files_file_children on media_files (kind, path);
28 -- index for finding directories that need to be processed30 -- index for finding directories that need re-indexing
29 create index media_files_directory_processed on media_files (kind, processed);31 create index media_files_dir_reindex on media_files (kind, dir_reindex);
30`,32`,
31);33);
3234
...@@ -73,14 +75,18 @@ export class MediaFile {...@@ -73,14 +75,18 @@ export class MediaFile {
73 /** in bytes */75 /** in bytes */
74 size!: number;76 size!: number;
75 /**77 /**
76 * 0 - not processed78 * For directories, a JSON-encoded object derived from special files
77 * non-zero - processed79 * (`.date` sets hideChildrenDates). Empty string otherwise.
78 *80 */
79 * file: a bit-field of the processors.81 config!: string;
80 * directory: this is for re-indexing contents82 /** for directories: 1 when the metadata pass must revisit this dir */
83 dir_reindex!: number;
84 /**
85 * number of processors queued or running for this file. when zero, all
86 * derived data (duration, dimensions, contents, derived assets) is as
87 * complete as it will get. the UI uses this to show "still processing".
81 */88 */
82 processed!: number;89 pending!: number;
83 processors!: string;
8490
85 // -- instance ops --91 // -- instance ops --
86 get date() {92 get date() {
...@@ -134,14 +140,20 @@ export class MediaFile {...@@ -134,14 +140,20 @@ export class MediaFile {
134 ASSERT(result.kind === MediaFileKind.directory);140 ASSERT(result.kind === MediaFileKind.directory);
135 return result;141 return result;
136 }142 }
137 setProcessed(processed: number) {143 setConfig(config: string) {
138 setProcessedQuery.run({ id: this.id, processed });144 setConfigQuery.run({ id: this.id, config });
139 this.processed = processed;145 this.config = config;
146 }
147 markDirReindex() {
148 markDirReindexQuery.run(this.id);
149 this.dir_reindex = 1;
150 }
151 setPending(pending: number) {
152 setPendingQuery.run({ id: this.id, pending });
153 this.pending = pending;
140 }154 }
141 setProcessors(processed: number, processors: string) {155 decPending() {
142 setProcessorsQuery.run({ id: this.id, processed, processors });156 this.pending = decPendingQuery.getNonNull(this.id).pending;
143 this.processed = processed;
144 this.processors = processors;
145 }157 }
146 setDuration(duration: number) {158 setDuration(duration: number) {
147 setDurationQuery.run({ id: this.id, duration });159 setDurationQuery.run({ id: this.id, duration });
...@@ -162,6 +174,14 @@ export class MediaFile {...@@ -162,6 +174,14 @@ export class MediaFile {
162 delete() {174 delete() {
163 deleteCascadeQuery.run({ id: this.id });175 deleteCascadeQuery.run({ id: this.id });
164 }176 }
177 /** adopt a new spelling of the same path (the file stores fold case) */
178 updatePath(newPath: string) {
179 if (this.kind === MediaFileKind.directory) {
180 updatePathPrefixQuery.run({ old: this.path + "/", new: newPath + "/" });
181 }
182 updatePathQuery.run({ id: this.id, path: newPath });
183 this.path = newPath;
184 }
165185
166 // -- static ops --186 // -- static ops --
167 static getByPath(filePath: string): MediaFile | null {187 static getByPath(filePath: string): MediaFile | null {
...@@ -179,7 +199,9 @@ export class MediaFile {...@@ -179,7 +199,9 @@ export class MediaFile {
179 contents: "the file scanner has not been run yet",199 contents: "the file scanner has not been run yet",
180 dirsort: null,200 dirsort: null,
181 size: 0,201 size: 0,
182 processed: 1,202 config: "",
203 dir_reindex: 0,
204 pending: 0,
183 });205 });
184 }206 }
185 return null;207 return null;
...@@ -269,9 +291,6 @@ export class MediaFile {...@@ -269,9 +291,6 @@ export class MediaFile {
269 size,291 size,
270 });292 });
271 }293 }
272 static setProcessed(id: number, processed: number) {
273 setProcessedQuery.run({ id, processed });
274 }
275 static createOrUpdateDirectory(dirPath: string) {294 static createOrUpdateDirectory(dirPath: string) {
276 const id = MediaFile.getOrPutDirectoryId(dirPath);295 const id = MediaFile.getOrPutDirectoryId(dirPath);
277 return updateDirectoryQuery.get(id);296 return updateDirectoryQuery.get(id);
...@@ -323,15 +342,16 @@ const createDirectoryQuery = db.prepare<...@@ -323,15 +342,16 @@ const createDirectoryQuery = db.prepare<
323 /* SQL */ `342 /* SQL */ `
324 insert into media_files (343 insert into media_files (
325 path, parent_id, kind, timestamp, timestamp_updated, hash,344 path, parent_id, kind, timestamp, timestamp_updated, hash,
326 size, duration, dimensions, contents, dirsort, processed)345 size, duration, dimensions, contents, dirsort, dir_reindex)
327 values (346 values (
328 $path, $parentId, ${MediaFileKind.directory}, 0, $time, '', 347 $path, $parentId, ${MediaFileKind.directory}, 0, $time, '',
329 0, 0, '', '', '', 0)348 0, 0, '', '', '', 1)
330 returning id;349 returning id;
331`,350`,
332);351);
333const getDirectoryIdQuery = db.prepare<[string], { id: number }>(/* SQL */ `352const getDirectoryIdQuery = db.prepare<[string], { id: number }>(/* SQL */ `
334 SELECT id FROM media_files WHERE path = ? AND kind = ${MediaFileKind.directory};353 SELECT id FROM media_files
354 WHERE path = ? collate nocase AND kind = ${MediaFileKind.directory};
335`);355`);
336const createFileQuery = db.prepare<[{356const createFileQuery = db.prepare<[{
337 path: string;357 path: string;
...@@ -346,38 +366,41 @@ const createFileQuery = db.prepare<[{...@@ -346,38 +366,41 @@ const createFileQuery = db.prepare<[{
346}], void>(/* SQL */ `366}], void>(/* SQL */ `
347 insert into media_files (367 insert into media_files (
348 path, parent_id, kind, timestamp, timestamp_updated, hash,368 path, parent_id, kind, timestamp, timestamp_updated, hash,
349 size, duration, dimensions, contents, processed)369 size, duration, dimensions, contents)
350 values (370 values (
351 $path, $parentId, ${MediaFileKind.file}, $timestamp, $timestampUpdated,371 $path, $parentId, ${MediaFileKind.file}, $timestamp, $timestampUpdated,
352 $hash, $size, $duration, $dimensions, $contents, 0)372 $hash, $size, $duration, $dimensions, $contents)
353 on conflict(path) do update set 373 on conflict(path collate nocase) do update set
374 path = excluded.path,
354 timestamp = excluded.timestamp,375 timestamp = excluded.timestamp,
355 timestamp_updated = excluded.timestamp_updated,376 timestamp_updated = excluded.timestamp_updated,
377 hash = excluded.hash,
356 duration = excluded.duration,378 duration = excluded.duration,
357 size = excluded.size,379 size = excluded.size,
358 contents = excluded.contents,380 contents = excluded.contents
359 processed = case
360 when media_files.hash != excluded.hash then 0
361 else media_files.processed
362 end
363 returning *;381 returning *;
364`).as(MediaFile);382`).as(MediaFile);
365const setProcessedQuery = db.prepare<[{383const setConfigQuery = db.prepare<[{
366 id: number;384 id: number;
367 processed: number;385 config: string;
368}]>(/* SQL */ `386}]>(/* SQL */ `
369 update media_files set processed = $processed where id = $id;387 update media_files set config = $config where id = $id;
388`);
389const markDirReindexQuery = db.prepare<[id: number]>(/* SQL */ `
390 update media_files set dir_reindex = 1 where id = ?;
370`);391`);
371const setProcessorsQuery = db.prepare<[{392const setPendingQuery = db.prepare<[{
372 id: number;393 id: number;
373 processed: number;394 pending: number;
374 processors: string;
375}]>(/* SQL */ `395}]>(/* SQL */ `
376 update media_files set396 update media_files set pending = $pending where id = $id;
377 processed = $processed,
378 processors = $processors
379 where id = $id;
380`);397`);
398const decPendingQuery = db.prepare<[id: number], { pending: number }>(
399 /* SQL */ `
400 update media_files set pending = max(0, pending - 1) where id = ?
401 returning pending;
402`,
403);
381const setDurationQuery = db.prepare<[{404const setDurationQuery = db.prepare<[{
382 id: number;405 id: number;
383 duration: number;406 duration: number;
...@@ -397,8 +420,18 @@ const setContentsQuery = db.prepare<[{...@@ -397,8 +420,18 @@ const setContentsQuery = db.prepare<[{
397 update media_files set contents = $contents where id = $id;420 update media_files set contents = $contents where id = $id;
398`);421`);
399const getByPathQuery = db.prepare<[string]>(/* SQL */ `422const getByPathQuery = db.prepare<[string]>(/* SQL */ `
400 select * from media_files where path = ?;423 select * from media_files where path = ? collate nocase;
401`).as(MediaFile);424`).as(MediaFile);
425const updatePathQuery = db.prepare<[{ id: number; path: string }]>(/* SQL */ `
426 update media_files set path = $path where id = $id;
427`);
428const updatePathPrefixQuery = db.prepare<[{ old: string; new: string }]>(
429 /* SQL */ `
430 update media_files
431 set path = $new || substr(path, length($old) + 1)
432 where lower(substr(path, 1, length($old))) = lower($old);
433`,
434);
402const markDirectoryProcessedQuery = db.prepare<[{435const markDirectoryProcessedQuery = db.prepare<[{
403 timestamp: number;436 timestamp: number;
404 contents: string;437 contents: string;
...@@ -408,7 +441,7 @@ const markDirectoryProcessedQuery = db.prepare<[{...@@ -408,7 +441,7 @@ const markDirectoryProcessedQuery = db.prepare<[{
408 id: number;441 id: number;
409}]>(/* SQL */ `442}]>(/* SQL */ `
410 update media_files set443 update media_files set
411 processed = 1,444 dir_reindex = 0,
412 timestamp = $timestamp,445 timestamp = $timestamp,
413 contents = $contents,446 contents = $contents,
414 dirsort = $dirsort,447 dirsort = $dirsort,
...@@ -417,7 +450,7 @@ const markDirectoryProcessedQuery = db.prepare<[{...@@ -417,7 +450,7 @@ const markDirectoryProcessedQuery = db.prepare<[{
417 where id = $id;450 where id = $id;
418`);451`);
419const updateDirectoryQuery = db.prepare<[id: number]>(/* SQL */ `452const updateDirectoryQuery = db.prepare<[id: number]>(/* SQL */ `
420 update media_files set processed = 0 where id = ?;453 update media_files set dir_reindex = 1 where id = ?;
421`);454`);
422455
423const getChildrenQuery = db.prepare<[id: number]>(/* SQL */ `456const getChildrenQuery = db.prepare<[id: number]>(/* SQL */ `
...@@ -448,8 +481,8 @@ const deleteCascadeQuery = db.prepare<[{ id: number }]>(/* SQL */ `...@@ -448,8 +481,8 @@ const deleteCascadeQuery = db.prepare<[{ id: number }]>(/* SQL */ `
448const getDirectoriesToReindexQuery = db.prepare(`481const getDirectoriesToReindexQuery = db.prepare(`
449 with recursive directory_chain as (482 with recursive directory_chain as (
450 -- base case483 -- base case
451 select id, parent_id, path from media_files 484 select id, parent_id, path from media_files
452 where kind = 0 and processed = 0485 where kind = 0 and dir_reindex = 1
453 -- recurse to find all parents so that size/hash can be updated486 -- recurse to find all parents so that size/hash can be updated
454 union487 union
455 select m.id, m.parent_id, m.path488 select m.id, m.parent_id, m.path
src/file-viewer/models/ProcessorState.ts created+146
...@@ -0,0 +1,146 @@
1// Tracks which processors have run on which files. Replaces the old
2// `processed` bitfield + `processors` string columns on `media_files`.
3//
4// - `processors` is the registry: one row per known processor, with the
5// version that the current code declares. rows for processors removed
6// from the code are pruned (cascading their file state).
7// - `file_processors` holds completions only. "pending" is the absence of
8// a row, or a row with a stale version. failures are recorded with
9// status=2 + the error text, and retried on the next sweep.
10const db = getDb("cache.sqlite");
11db.table(
12 "processor_state",
13 /* SQL */ `
14 create table if not exists processors (
15 id integer primary key autoincrement,
16 name text not null unique,
17 version integer not null
18 );
19 create table if not exists file_processors (
20 file integer not null references media_files(id) on delete cascade,
21 processor integer not null references processors(id) on delete cascade,
22 version integer not null,
23 status integer not null,
24 updated integer not null,
25 error text,
26 primary key (file, processor)
27 );
28 create index file_processors_processor on file_processors (processor);
29`,
30);
31
32export enum ProcessorStatus {
33 done = 1,
34 failed = 2,
35}
36
37export interface FileProcessorRow {
38 file: number;
39 processor: number;
40 version: number;
41 status: ProcessorStatus;
42 updated: number;
43 error: string | null;
44}
45
46/**
47 * upsert the registry and prune processors that no longer exist in code.
48 * returns a map of processor name to its row id, used by the scanner.
49 */
50export function syncRegistry(
51 defs: readonly { name: string; version: number }[],
52): Map<string, number> {
53 const ids = new Map<string, number>();
54 const tx = db.node;
55 tx.exec("begin");
56 try {
57 for (const { name, version } of defs) {
58 const { id } = upsertProcessorQuery.getNonNull({ name, version });
59 ids.set(name, id);
60 }
61 const known = new Set(defs.map((d) => d.name));
62 for (const { id, name } of allProcessorsQuery.array()) {
63 if (!known.has(name)) deleteProcessorQuery.run(id);
64 }
65 tx.exec("commit");
66 } catch (err) {
67 tx.exec("rollback");
68 throw err;
69 }
70 return ids;
71}
72
73/** completion state for one file, keyed by processor row id */
74export function getStates(
75 fileId: number,
76): Map<number, Pick<FileProcessorRow, "version" | "status">> {
77 const map = new Map<number, { version: number; status: ProcessorStatus }>();
78 for (const row of getStatesQuery.array(fileId)) {
79 map.set(row.processor, { version: row.version, status: row.status });
80 }
81 return map;
82}
83
84export function recordResult(
85 fileId: number,
86 processorId: number,
87 version: number,
88 status: ProcessorStatus,
89 error: string | null = null,
90) {
91 recordResultQuery.run({
92 file: fileId,
93 processor: processorId,
94 version,
95 status,
96 updated: Date.now(),
97 error,
98 });
99}
100
101/** the file's contents changed (hash mismatch); all processors must re-run */
102export function invalidateFile(fileId: number) {
103 invalidateFileQuery.run(fileId);
104}
105
106// -- queries --
107const upsertProcessorQuery = db.prepare<
108 [{ name: string; version: number }],
109 { id: number }
110>(/* SQL */ `
111 insert into processors (name, version) values ($name, $version)
112 on conflict(name) do update set version = excluded.version
113 returning id;
114`);
115const allProcessorsQuery = db.prepare<[], { id: number; name: string }>(
116 /* SQL */ `
117 select id, name from processors;
118`,
119);
120const deleteProcessorQuery = db.prepare<[id: number]>(/* SQL */ `
121 delete from processors where id = ?;
122`);
123const getStatesQuery = db.prepare<[file: number], FileProcessorRow>(/* SQL */ `
124 select * from file_processors where file = ?;
125`);
126const recordResultQuery = db.prepare<[{
127 file: number;
128 processor: number;
129 version: number;
130 status: number;
131 updated: number;
132 error: string | null;
133}]>(/* SQL */ `
134 insert into file_processors (file, processor, version, status, updated, error)
135 values ($file, $processor, $version, $status, $updated, $error)
136 on conflict(file, processor) do update set
137 version = excluded.version,
138 status = excluded.status,
139 updated = excluded.updated,
140 error = excluded.error;
141`);
142const invalidateFileQuery = db.prepare<[file: number]>(/* SQL */ `
143 delete from file_processors where file = ?;
144`);
145
146import { getDb } from "#sitegen/sqlite";
src/file-viewer/models/derived.ts+59-3
...@@ -39,7 +39,10 @@ db.table(...@@ -39,7 +39,10 @@ db.table(
39 `,39 `,
40);40);
4141
42export const workDir = Path.resolve(".clover/derived");42// derived assets are written directly into the derived file store. files are
43// produced into a `tmp.`-prefixed sibling directory and renamed into place,
44// so a crash never leaves a partially-written asset at its final path.
45export const workDir = Path.resolve(derivedFileRoot);
4346
44let ongoing = new Map<string, Promise<number>>();47let ongoing = new Map<string, Promise<number>>();
4548
...@@ -63,8 +66,13 @@ export async function produce(...@@ -63,8 +66,13 @@ export async function produce(
63 if (root) break brk;66 if (root) break brk;
64 const { promise, resolve, reject } = Promise.withResolvers<number>();67 const { promise, resolve, reject } = Promise.withResolvers<number>();
65 ongoing.set(key, promise);68 ongoing.set(key, promise);
69 // the rejection is rethrown to this caller; concurrent callers await
70 // through `ongoing`, but when there are none the rejection would
71 // otherwise be unobserved
72 promise.catch(() => {});
73 const finalDir = workDir.join(key);
74 const tmp = workDir.join(`${file.hash}/tmp.${subkey}`);
66 try {75 try {
67 const tmp = workDir.join(key);
68 node.hidden = true;76 node.hidden = true;
69 const filesWithStats = await queue.run({77 const filesWithStats = await queue.run({
70 cores,78 cores,
...@@ -106,6 +114,11 @@ export async function produce(...@@ -106,6 +114,11 @@ export async function produce(
106 );114 );
107 },115 },
108 });116 });
117 // move into the final location before recording rows; readers only
118 // discover the asset through the database, so this is safe.
119 await finalDir.delete({ recursive: true, force: true });
120 await fsp.rename(tmp.toString(), finalDir.toString());
121
109 db.node.exec("BEGIN");122 db.node.exec("BEGIN");
110 try {123 try {
111 root = insertRootQuery.getNonNull({ key, date: Date.now() }).id;124 root = insertRootQuery.getNonNull({ key, date: Date.now() }).id;
...@@ -126,6 +139,11 @@ export async function produce(...@@ -126,6 +139,11 @@ export async function produce(
126 resolve(root);139 resolve(root);
127 } catch (e) {140 } catch (e) {
128 if (root) deleteRootQuery.run({ root });141 if (root) deleteRootQuery.run({ root });
142 // remove partial output from disk: the tmp dir if the producer died,
143 // or the renamed final dir if the database writes failed (e.g. the
144 // source file was deleted while this asset was being produced)
145 await tmp.delete({ recursive: true, force: true }).catch(() => {});
146 await finalDir.delete({ recursive: true, force: true }).catch(() => {});
129 reject(e);147 reject(e);
130 throw e;148 throw e;
131 } finally {149 } finally {
...@@ -173,6 +191,42 @@ export function deleteRoot(root: { id: number }) {...@@ -173,6 +191,42 @@ export function deleteRoot(root: { id: number }) {
173 return deleteRootQuery.run({ root: root.id });191 return deleteRootQuery.run({ root: root.id });
174}192}
175193
194/** remove an orphaned root's files from the derived store */
195export async function deleteRootFiles(root: { key: string }) {
196 const dir = workDir.join(root.key);
197 await dir.delete({ recursive: true, force: true });
198 // reclaim the per-hash parent directory once its last root is gone
199 // (rmdir refuses non-empty directories, which is exactly what we want)
200 await fsp.rmdir(UNWRAP(dir.parent).toString()).catch(() => {});
201}
202
203/**
204 * delete `tmp.*` directories left behind by crashed producers. only removes
205 * directories untouched for a day, to never race an ongoing producer.
206 *
207 * `tmp.*` FILES are never touched: encode intermediates inside completed
208 * roots (`av1-au/tmp.av1.mp4`, ...) are intentionally named that way to be
209 * excluded from `derived_files`, but the dash producer reads them across
210 * root directories, so they must persist.
211 */
212export async function cleanAbandonedTmp(): Promise<number> {
213 let cleaned = 0;
214 const dayAgo = Date.now() - 24 * 60 * 60 * 1000;
215 for (const hashDir of await workDir.readDir().catch(() => [])) {
216 for (const sub of await hashDir.readDir().catch(() => [])) {
217 if (!sub.base.startsWith("tmp.")) continue;
218 try {
219 const stat = await sub.stat();
220 if (!stat.isDirectory()) continue;
221 if (stat.mtime.getTime() > dayAgo) continue;
222 await sub.delete({ recursive: true, force: true });
223 cleaned += 1;
224 } catch {}
225 }
226 }
227 return cleaned;
228}
229
176const insertRootQuery = db.prepare<230const insertRootQuery = db.prepare<
177 [{ key: string; date: number }],231 [{ key: string; date: number }],
178 { id: number }232 { id: number }
...@@ -214,9 +268,11 @@ const findOrphanedRootsQuery = db.prepare<...@@ -214,9 +268,11 @@ const findOrphanedRootsQuery = db.prepare<
214268
215import { Path } from "#sitegen/path";269import { Path } from "#sitegen/path";
216import { getDb } from "#sitegen/sqlite";270import { getDb } from "#sitegen/sqlite";
217import { ASSERT } from "@clo/lib/assert";271import { ASSERT, UNWRAP } from "@clo/lib/assert";
218import * as progress from "@clo/lib/progress";272import * as progress from "@clo/lib/progress";
219import * as queue from "@clo/lib/queue";273import * as queue from "@clo/lib/queue";
220import * as crypto from "node:crypto";274import * as crypto from "node:crypto";
221import * as fs from "node:fs";275import * as fs from "node:fs";
276import * as fsp from "node:fs/promises";
277import { derivedFileRoot } from "../paths.ts";
222import type { MediaFile } from "./MediaFile.ts";278import type { MediaFile } from "./MediaFile.ts";
src/file-viewer/rules.ts+6
...@@ -107,6 +107,12 @@ export const extsPreCompressed = new Set([...@@ -107,6 +107,12 @@ export const extsPreCompressed = new Set([
107]);107]);
108extsPreCompressed.delete(".svg");108extsPreCompressed.delete(".svg");
109109
110// files that should never have media processors (transcodes) run on them,
111// usually because the file is intentionally cursed.
112export const processDenyList = new Set([
113 "/2021/top-10000-bread/output.mp4",
114]);
115
110export function fileIcon(116export function fileIcon(
111 file: Pick<MediaFile, "kind" | "basename" | "path">,117 file: Pick<MediaFile, "kind" | "basename" | "path">,
112 dirOpen?: boolean,118 dirOpen?: boolean,
src/file-viewer/sync.ts created+160
...@@ -0,0 +1,160 @@
1// Keeps the web node's copy of cache.sqlite in sync with the source of
2// truth. The SOT serves a server-sent-events feed at /db/events; this
3// module holds it open and pulls /db on every beacon. The server emits a
4// beacon on connect, so boot, reconnect, and missed-while-disconnected all
5// resolve through the same path — there is no polling and no TTL.
6//
7// Pulls are ETag-guarded (no-change is a tiny 304; the etag is persisted so
8// a reboot does not re-download an unchanged database), land in a temp
9// file, and atomically rename over .clover/cache.sqlite before hot-swapping
10// the open database handle.
11const console = log.scoped("sync");
12
13const token = process.env.CLOVER_SOT_KEY ?? null;
14const enabled = process.env.CLOVER_DB_SYNC !== "0" && token != null;
15const sotUrl = new URL(process.env.CLOVER_SOT_URL ?? "https://db.paperclover.net")
16 .toString().replace(/\/+$/g, "");
17if (!enabled) {
18 console.warn(
19 "database sync disabled"
20 + (token ? " (CLOVER_DB_SYNC=0)" : " (no CLOVER_SOT_KEY)"),
21 );
22}
23
24let inFlight: Promise<void> | null = null;
25
26export function revalidate(): Promise<void> {
27 return inFlight ??= revalidateInner().finally(() => {
28 inFlight = null;
29 });
30}
31
32async function revalidateInner(): Promise<void> {
33 const db = getDb("cache.sqlite");
34 const res = await fetch(`${sotUrl}/db`, {
35 headers: {
36 Authorization: token!,
37 ...etag() ? { "If-None-Match": etag()! } : null,
38 },
39 signal: AbortSignal.timeout(120_000),
40 });
41 if (res.status === 304) return;
42 if (!res.ok || !res.body) {
43 throw new Error(`source of truth responded ${res.status} ${res.statusText}`);
44 }
45
46 const tmp = db.file + ".tmp";
47 await stream.promises.pipeline(
48 stream.Readable.fromWeb(res.body as any),
49 fs.createWriteStream(tmp),
50 );
51 // fsync before the rename so a power cut cannot leave a torn file
52 const handle = await fsp.open(tmp, "r+");
53 await handle.sync();
54 await handle.close();
55 await fsp.rename(tmp, db.file);
56 db.reload();
57 saveEtag(res.headers.get("ETag"));
58 console.info(`database updated (${string.formatByteSize(Number(res.headers.get("Content-Length") ?? "0"))})`);
59}
60
61// -- the persisted etag, stored beside the database --
62let cachedEtag: string | null | undefined;
63function etagPath() {
64 return getDb("cache.sqlite").file + ".etag";
65}
66function etag(): string | null {
67 if (cachedEtag === undefined) {
68 try {
69 cachedEtag = fs.readFileSync(etagPath(), "utf-8").trim() || null;
70 } catch {
71 cachedEtag = null;
72 }
73 }
74 return cachedEtag;
75}
76function saveEtag(value: string | null) {
77 cachedEtag = value;
78 try {
79 fs.writeFileSync(etagPath(), value ?? "");
80 } catch (err) {
81 console.warn("could not persist database etag:", err);
82 }
83}
84
85// -- the subscription --
86// a hand-rolled sse reader over fetch (EventSource cannot send the
87// Authorization header). any event block triggers a revalidate; a failed
88// revalidate aborts the connection so the reconnect path retries it.
89async function subscribeLoop() {
90 let backoff = 1000;
91 while (true) {
92 const ctrl = new AbortController();
93 let watchdog: NodeJS.Timeout | null = null;
94 try {
95 const res = await fetch(`${sotUrl}/db/events`, {
96 headers: { Authorization: token! },
97 signal: ctrl.signal,
98 });
99 if (!res.ok || !res.body) {
100 throw new Error(`${res.status} ${res.statusText}`);
101 }
102 console.info(`subscribed to database changes from ${sotUrl}`);
103 backoff = 1000;
104
105 // the server keepalives every 25s; 90s of silence means the
106 // connection died without a FIN
107 const armWatchdog = () => {
108 if (watchdog) clearTimeout(watchdog);
109 watchdog = setTimeout(
110 () => ctrl.abort(new Error("no keepalive")),
111 90_000,
112 );
113 watchdog.unref();
114 };
115 armWatchdog();
116 const reader = res.body.getReader();
117 const decoder = new TextDecoder();
118 let buffer = "";
119 while (true) {
120 const { value, done } = await reader.read();
121 if (done) break;
122 armWatchdog();
123 buffer += decoder.decode(value, { stream: true });
124 const blocks = buffer.split("\n\n");
125 buffer = blocks.pop()!;
126 const changed = blocks.some((block) =>
127 block.split("\n").some((line) => line.startsWith("event:") || line.startsWith("data:"))
128 );
129 if (changed) {
130 void revalidate().catch((err) => {
131 console.error("revalidate failed:", err);
132 ctrl.abort(new Error("revalidate failed"));
133 });
134 }
135 }
136 throw new Error("stream ended");
137 } catch (err: any) {
138 console.warn(
139 `database subscription lost (${err?.message ?? err}); `
140 + `retrying in ${Math.round(backoff / 1000)}s`,
141 );
142 } finally {
143 if (watchdog) clearTimeout(watchdog);
144 ctrl.abort();
145 }
146 await new Promise<void>((resolve) => setTimeout(resolve, backoff).unref());
147 backoff = Math.min(backoff * 2, 60_000);
148 }
149}
150
151if (enabled) void subscribeLoop();
152
153import * as fs from "node:fs";
154import * as fsp from "node:fs/promises";
155import * as stream from "node:stream";
156
157import { getDb } from "#sitegen/sqlite";
158
159import * as log from "@clo/lib/log";
160import * as string from "@clo/lib/string";
src/file-viewer/tags/media-dir.marko+1-1
...@@ -27,7 +27,7 @@ static interface DirConfig {...@@ -27,7 +27,7 @@ static interface DirConfig {
27 return { sorted, activeFilename, readme };27 return { sorted, activeFilename, readme };
28})()>28})()>
2929
30<const/extraConfig: DirConfig=dir.processors ? JSON.parse(dir.processors) : {}>30<const/extraConfig: DirConfig=dir.config ? JSON.parse(dir.config) : {}>
3131
32<define/Inner>32<define/Inner>
33 <div.content.primary>33 <div.content.primary>
src/file-viewer/tags/media-panel.marko+1-1
...@@ -100,7 +100,7 @@ static const cotyledonEndingFile = "/2024/for everone";...@@ -100,7 +100,7 @@ static const cotyledonEndingFile = "/2024/for everone";
100 </div>100 </div>
101101
102 // content102 // content
103 <const/ext=path.extname(file.path)>103 <const/ext=path.extname(file.path).toLowerCase()>
104 <if=file.path === "/">104 <if=file.path === "/">
105 <media-root-dir dir=file ...{ activeFilename, isLast, hasCotyledonCookie }/>105 <media-root-dir dir=file ...{ activeFilename, isLast, hasCotyledonCookie }/>
106 </if>106 </if>
src/file-viewer/tags/view-audio.marko+1-1
...@@ -25,7 +25,7 @@ export interface Input {...@@ -25,7 +25,7 @@ export interface Input {
25<audio controls preload="none" style="width: 100%">25<audio controls preload="none" style="width: 100%">
26 <source26 <source
27 src="/file" + file.path27 src="/file" + file.path
28 type=`audio/${path.extname(file.path).substring(1)}`28 type=`audio/${path.extname(file.path).substring(1).toLowerCase()}`
29 >29 >
30</audio>30</audio>
31//<if=lyricsFile>31//<if=lyricsFile>
src/file-viewer/tags/view-code.marko+1-2
...@@ -9,8 +9,7 @@ export interface Input {...@@ -9,8 +9,7 @@ export interface Input {
9<if=contents>9<if=contents>
10 <pre style="white-space:pre-wrap">$!{contents}</pre>10 <pre style="white-space:pre-wrap">$!{contents}</pre>
11</if>11</if>
12<else if=size>12<else if=(size > 1_000_000)>
13 ${" "}1_000_000>
14 <view-download-too-big ...input/>13 <view-download-too-big ...input/>
15</else>14</else>
16<else>15<else>
src/file-viewer/tags/view-download.marko+1-1
...@@ -28,7 +28,7 @@ export interface Input {...@@ -28,7 +28,7 @@ export interface Input {
28 <a href="/file" + file.path + "?dl" aria-label="download">${file.basename}</a>28 <a href="/file" + file.path + "?dl" aria-label="download">${file.basename}</a>
29</define>29</define>
3030
31<const/ext=path.extname(file.basename)>31<const/ext=path.extname(file.basename).toLowerCase()>
32<if=ext === ".blend">32<if=ext === ".blend">
33 <p>33 <p>
34 <code>.blend</code> files can be open in34 <code>.blend</code> files can be open in
src/file-viewer/tags/view-text.marko+16-11
...@@ -145,18 +145,23 @@ static function highlightHashComments(text: string) {...@@ -145,18 +145,23 @@ static function highlightHashComments(text: string) {
145 file: { path, basename, contents },145 file: { path, basename, contents },
146 siblings,146 siblings,
147}=input>147}=input>
148<pre style={ "white-space": "pre-wrap", "max-width": "70ch" }>148<if=!contents && input.file.pending>
149 <p>this file was just added and is still being processed. come back soon!</p>
150</if>
151<else>
152 <pre style={ "white-space": "pre-wrap", "max-width": "70ch" }>
149$!{153$!{
150 path.startsWith("/2021/phoenix-write/maps") && basename === "map.txt"154 path.startsWith("/2021/phoenix-write/maps") && basename === "map.txt"
151 ? // special cased for phoenix write maps155 ? // special cased for phoenix write maps
152 highlightHashComments(contents)156 highlightHashComments(contents)
153 : // normal157 : // normal
154 highlightLinksInTextView(158 highlightLinksInTextView(
155 contents,159 contents,
156 siblings.filter((f) => f.kind === MediaFileKind.file),160 siblings.filter((f) => f.kind === MediaFileKind.file),
157 )161 )
158}162 }
159</pre>163 </pre>
164</else>
160165
161import * as string from "@clo/lib/string";166import * as string from "@clo/lib/string";
162import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts";167import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts";
src/source-of-truth.dockerfile+22-22
...@@ -1,31 +1,31 @@...@@ -1,31 +1,31 @@
1from node:24 as builder1# The source of truth server: file serving + the auto-indexer service.
2# No bundling step; the whole source tree plus node_modules ship in the
3# image and `node run` executes it the same way development does.
4#
5# trixie rather than bookworm: the dimensions processor needs the `magick`
6# entry point, which is ImageMagick 7 (bookworm only packages v6).
7from node:24-trixie
28
3run apt install -y git9# media processors shell out to these. exiftool is `libimage-exiftool-perl`
410# on debian; curl is for the compose healthcheck.
5workdir /paperclover.net11run apt-get update && apt-get install -y --no-install-recommends \
6copy package*.json ./12 ffmpeg \
7run npm ci13 imagemagick \
8copy . ./14 libimage-exiftool-perl \
9run npx esbuild --bundle \15 curl \
10 framework/backend/entry-node.ts \16 && rm -rf /var/lib/apt/lists/*
11 --platform=node \
12 --format=esm \
13 --define:globalThis.CLOVER_SERVER_ENTRY='"#src/source-of-truth.ts"' \
14 --minify \
15 --sourcemap=linked \
16 --entry-names=index \
17 --outdir=/app
18
19from node:24 as runtime
2017
21workdir /app18workdir /app
22copy --from=builder /app/ ./19copy package.json package-lock.json ./
20run npm ci --no-audit --no-fund
21copy . .
22# prime run.js's dependency hash so container boot never re-runs npm
23run node run || true
2324
24env PORT=4320025env PORT=80
25# must be configured26# must be configured
26env CLOVER_DB=/dev/null27env CLOVER_DB=/dev/null
27env CLOVER_FILE_RAW=/dev/null28env CLOVER_FILE_RAW=/dev/null
28env CLOVER_FILE_DERIVED=/dev/null29env CLOVER_FILE_DERIVED=/dev/null
2930
30cmd ["node", "--enable-source-maps", "/app/index.js"]31cmd ["node", "run", "source-of-truth"]
31
src/source-of-truth.ts+165-3
...@@ -22,7 +22,7 @@...@@ -22,7 +22,7 @@
22 */22 */
23const app = new Hono();23const app = new Hono();
24export default app;24export default app;
25export const port = 4000;25export const port = Number(process.env.PORT ?? 4000);
2626
27const token = UNWRAP(process.env.CLOVER_SOT_KEY);27const token = UNWRAP(process.env.CLOVER_SOT_KEY);
2828
...@@ -33,6 +33,26 @@ if (!fs.existsSync(derivedFileRoot)) {...@@ -33,6 +33,26 @@ if (!fs.existsSync(derivedFileRoot)) {
33 throw new Error(`${derivedFileRoot} does not exist`);33 throw new Error(`${derivedFileRoot} does not exist`);
34}34}
3535
36/**
37 * `node run source-of-truth` boots the full source of truth: the indexer
38 * service (file watcher, periodic sweeps, processors) plus this http server.
39 * set CLOVER_INDEXER=0 for a serve-only process.
40 */
41export async function main() {
42 if (process.env.CLOVER_INDEXER !== "0") {
43 service = indexer.start();
44 }
45 const server = await http.serve({
46 respond: async (request) => app.fetch(request),
47 port,
48 });
49 console.info(`source of truth live at ${server.url}`);
50 const close = () => void server.close().then(() => process.exit(0));
51 process.on("SIGINT", close);
52 process.on("SIGTERM", close);
53}
54let service: indexer.Service | null = null;
55
36// Re-use file descriptors if the same file is being read twice.56// Re-use file descriptors if the same file is being read twice.
37const fds = new Map<string, Awaitable<{ fd: number; refs: number }>>();57const fds = new Map<string, Awaitable<{ fd: number; refs: number }>>();
3858
...@@ -52,6 +72,13 @@ app.get("/file/*", async (c) => {...@@ -52,6 +72,13 @@ app.get("/file/*", async (c) => {
52 }72 }
53 const file = MediaFile.getByPath(filePath);73 const file = MediaFile.getByPath(filePath);
54 if (!file || file.kind === MediaFileKind.directory) return c.notFound();74 if (!file || file.kind === MediaFileKind.directory) return c.notFound();
75 // derived asset names are plain relative paths under the hash directory
76 if (
77 derivedAsset
78 && derivedAsset.split("/").some((part) => part === "" || part === "." || part === "..")
79 ) {
80 return c.notFound();
81 }
55 const fullPath = derivedAsset82 const fullPath = derivedAsset
56 ? path.join(derivedFileRoot, file.hash, derivedAsset)83 ? path.join(derivedFileRoot, file.hash, derivedAsset)
57 : path.join(rawFileRoot, file.path);84 : path.join(rawFileRoot, file.path);
...@@ -65,8 +92,8 @@ app.get("/file/*", async (c) => {...@@ -65,8 +92,8 @@ app.get("/file/*", async (c) => {
65 fds.delete(fullPath);92 fds.delete(fullPath);
66 throw err;93 throw err;
67 });94 });
68 fds.set(file.path, promise);95 fds.set(fullPath, promise);
69 fds.set(file.path, handle = await promise);96 fds.set(fullPath, handle = await promise);
70 }97 }
71 handle.refs += 1;98 handle.refs += 1;
72 } catch (err: any) {99 } catch (err: any) {
...@@ -97,18 +124,153 @@ app.post("/reload", async (c) => {...@@ -97,18 +124,153 @@ app.post("/reload", async (c) => {
97 return c.json({ error: "invalid authorization header" }, 401);124 return c.json({ error: "invalid authorization header" }, 401);
98 }125 }
99 MediaFile.db.reload();126 MediaFile.db.reload();
127 indexer.emitDbChange();
100 return c.body(null, 204);128 return c.body(null, 204);
101});129});
102130
131// a tiny server-sent-events feed of database changes. consumers (web nodes,
132// dev servers) hold this open and pull /db whenever a beacon arrives; the
133// connection itself is the subscription, so the source of truth never needs
134// a list of its consumers. events carry the generation, but clients can
135// treat any event as "something changed" — /db pulls are ETag-guarded.
136app.get("/db/events", (c) => {
137 if (c.req.header("Authorization") !== token) {
138 return c.json({ error: "invalid authorization header" }, 401);
139 }
140 const encoder = new TextEncoder();
141 let cleanup: (() => void) | null = null;
142 const stream = new ReadableStream<Uint8Array>({
143 start(controller) {
144 const send = (text: string) => controller.enqueue(encoder.encode(text));
145 // an event on connect makes clients revalidate immediately, covering
146 // anything they missed while disconnected
147 send(`retry: 5000\n\n`);
148 send(`event: change\ndata: ${indexer.generation}\n\n`);
149 const unsubscribe = indexer.subscribeDbChange((generation) => {
150 send(`event: change\ndata: ${generation}\n\n`);
151 });
152 // keepalive comments so proxies and clients hold the connection open
153 const keepalive = setInterval(() => send(`: keepalive\n\n`), 25_000);
154 keepalive.unref();
155 cleanup = () => {
156 unsubscribe[Symbol.dispose]();
157 clearInterval(keepalive);
158 };
159 },
160 cancel() {
161 cleanup?.();
162 },
163 });
164 c.header("Content-Type", "text/event-stream");
165 c.header("Cache-Control", "no-store");
166 return c.body(stream);
167});
168
169// live progress of all indexing operations, as a clover progress stream.
170// each request snapshots the current tree state and then follows along;
171// connect any time with `node run tail-progress`.
172app.get("/progress", (c) => {
173 if (c.req.header("Authorization") !== token) {
174 return c.json({ error: "invalid authorization header" }, 401);
175 }
176 c.header("Content-Type", progress.contentType);
177 return c.body(progress.encodeByteStream(indexer.root) as ReadableStream);
178});
179
180// download a consistent snapshot of cache.sqlite. web nodes call this with
181// `If-None-Match` on a stale-while-revalidate schedule, and immediately
182// when pinged at /internal/db-changed.
183app.get("/db", async (c) => {
184 if (c.req.header("Authorization") !== token) {
185 return c.json({ error: "invalid authorization header" }, 401);
186 }
187 const snap = await getDbSnapshot();
188 if (c.req.header("If-None-Match") === snap.etag) {
189 return c.body(null, 304);
190 }
191 c.header("Content-Type", "application/vnd.sqlite3");
192 c.header("Content-Length", String(snap.size));
193 c.header("ETag", snap.etag);
194 return c.body(
195 stream.Readable.toWeb(fs.createReadStream(snap.path)) as ReadableStream,
196 );
197});
198
199// trigger a sweep (or a subtree scan with {"path": "/2025"}). progress is
200// visible on /progress; this returns immediately.
201app.post("/scan", async (c) => {
202 if (c.req.header("Authorization") !== token) {
203 return c.json({ error: "invalid authorization header" }, 401);
204 }
205 if (!service) return c.json({ error: "indexer is disabled" }, 409);
206 const body = await c.req.json().catch(() => ({}));
207 const subPath = typeof body.path === "string" ? body.path : undefined;
208 void service.sweep("manual", subPath);
209 return c.json({ ok: true }, 202);
210});
211
212// -- database snapshotting --
213// `VACUUM INTO` writes a compact, transactionally-consistent copy that does
214// not depend on -wal sidecar files. regenerated lazily when the indexer
215// reports changes, at most every 30 seconds.
216interface DbSnapshot {
217 gen: number;
218 time: number;
219 path: string;
220 etag: string;
221 size: number;
222}
223let snapshot: DbSnapshot | null = null;
224let snapshotInFlight: Promise<DbSnapshot> | null = null;
225
226function getDbSnapshot() {
227 const gen = indexer.generation;
228 // a stale-generation snapshot is never served: subscribers pull exactly
229 // once per beacon, so a 304 here would leave them out of date until the
230 // ttl backstop. beacons are debounced upstream, which bounds how often
231 // the vacuum can actually run.
232 if (snapshot && snapshot.gen === gen && fs.existsSync(snapshot.path)) {
233 return Promise.resolve(snapshot);
234 }
235 return snapshotInFlight ??= (async () => {
236 try {
237 const dir = process.env.CLOVER_DB ?? ".clover";
238 const file = path.join(dir, "db-snapshot.sqlite");
239 await fs.rm(file, { force: true });
240 MediaFile.db.node.exec(
241 `vacuum into '${file.replaceAll("'", "''")}';`,
242 );
243 const [hash, { size }] = await Promise.all([
244 hashFile(new Path(file)),
245 fs.stat(file),
246 ]);
247 return snapshot = {
248 gen,
249 time: Date.now(),
250 path: file,
251 etag: `"${hash}"`,
252 size,
253 };
254 } finally {
255 snapshotInFlight = null;
256 }
257 })();
258}
259
103const openFile = util.promisify(fsCallbacks.open);260const openFile = util.promisify(fsCallbacks.open);
104const closeFile = util.promisify(fsCallbacks.close);261const closeFile = util.promisify(fsCallbacks.close);
105const fstat = util.promisify(fsCallbacks.fstat);262const fstat = util.promisify(fsCallbacks.fstat);
106263
107import * as fs from "#sitegen/fs";264import * as fs from "#sitegen/fs";
265import { Path } from "#sitegen/path";
266import { hashFile } from "#src/file-viewer/indexer/scan.ts";
267import * as indexer from "#src/file-viewer/indexer/service.ts";
108import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts";268import { FilePermissions } from "#src/file-viewer/models/FilePermissions.ts";
109import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts";269import { MediaFile, MediaFileKind } from "#src/file-viewer/models/MediaFile.ts";
110import { ASSERT, UNWRAP } from "@clo/lib/assert";270import { ASSERT, UNWRAP } from "@clo/lib/assert";
271import * as http from "@clo/lib/http";
111import * as mime from "@clo/lib/mime";272import * as mime from "@clo/lib/mime";
273import * as progress from "@clo/lib/progress";
112import { Hono } from "hono";274import { Hono } from "hono";
113import * as fsCallbacks from "node:fs";275import * as fsCallbacks from "node:fs";
114import * as path from "node:path";276import * as path from "node:path";
src/tags/clover-media.marko+32-20
...@@ -14,28 +14,13 @@ export interface Input {...@@ -14,28 +14,13 @@ export interface Input {
14<else if=extsVideo.has(file.extension.toLowerCase())>14<else if=extsVideo.has(file.extension.toLowerCase())>
15 <clover-video minimal noFigure ...{ file }/>15 <clover-video minimal noFigure ...{ file }/>
16</else>16</else>
17<else if=file.extension === ".gif">17<else if=file.extension.toLowerCase() === ".gif">
18 <img src=`/file${escapeUri(file.path)}` ...rest>18 <img src=`/file${escapeUri(file.path)}` ...rest>
19</else>19</else>
20<else>20<else>
21 <const/base=`/file${escapeUri(file.path)}`>21 <const/base=`/file${escapeUri(file.path)}`>
22 <const/{ width }=UNWRAP(file.parseDimensions())>22 <const/dims=file.parseDimensions()>
23 <const/targetSizes=transcodeRules.imageSizes.filter((w) => w < width)>23 <if=!dims || file.pending>
24 <const/sizes=(
25 displayWidthEstimate
26 ? typeof displayWidthEstimate === "number"
27 ? `${displayWidthEstimate}px`
28 : displayWidthEstimate
29 : `100vw`
30 )>
31 <picture>
32 <for|ext| of=["jxl", "webp"]>
33 <source
34 type=`image/${ext}`
35 srcset=targetSizes.map((s) => `${base}:/${s}.${ext} ${s}w`)
36 ...{ sizes }
37 >
38 </for>
39 <img24 <img
40 src=base25 src=base
41 style=(26 style=(
...@@ -45,11 +30,38 @@ export interface Input {...@@ -45,11 +30,38 @@ export interface Input {
45 )30 )
46 ...rest31 ...rest
47 >32 >
48 </picture>33 </if>
34 <else>
35 <const/targetSizes=transcodeRules.imageSizes.filter((w) => w < dims.width)>
36 <const/sizes=(
37 displayWidthEstimate
38 ? typeof displayWidthEstimate === "number"
39 ? `${displayWidthEstimate}px`
40 : displayWidthEstimate
41 : `100vw`
42 )>
43 <picture>
44 <for|ext| of=["jxl", "webp"]>
45 <source
46 type=`image/${ext}`
47 srcset=targetSizes.map((s) => `${base}:/${s}.${ext} ${s}w`).join(",")
48 ...{ sizes }
49 >
50 </for>
51 <img
52 src=base
53 style=(
54 align
55 ? { "object-position": align, width: "100%", height: "100%" }
56 : undefined
57 )
58 ...rest
59 >
60 </picture>
61 </else>
49</else>62</else>
5063
51import * as transcodeRules from "#src/file-viewer/transcode-rules.ts";64import * as transcodeRules from "#src/file-viewer/transcode-rules.ts";
52import { extsVideo } from "#src/file-viewer/rules.ts";65import { extsVideo } from "#src/file-viewer/rules.ts";
53import { MediaFile } from "#src/file-viewer/models/MediaFile.ts";66import { MediaFile } from "#src/file-viewer/models/MediaFile.ts";
54import { UNWRAP } from "@clo/lib/assert";
55import { escapeUri } from "#src/file-viewer/format.ts";67import { escapeUri } from "#src/file-viewer/format.ts";
src/tags/clover-video.client.ts+55-15
...@@ -65,21 +65,7 @@ function decidePlaybackMode(): Promise<PlaybackMode> {...@@ -65,21 +65,7 @@ function decidePlaybackMode(): Promise<PlaybackMode> {
65 console.warn(`does not implement av1+opus`);65 console.warn(`does not implement av1+opus`);
66 }66 }
6767
68 return canNativelyPlayHls().then((nativeHls): PlaybackMode => {68 return decideHlsMode();
69 if (nativeHls) return "hls-native";
70 // @ts-expect-error
71 return (hls ??= import("/js/scripts/vendor/hls.js"))
72 .then((hls): PlaybackMode => {
73 // Polyfill HLS (Chrome 23, Firefox 42, IE11 on Win8)
74 // TODO: ES modules have a minimum of Chrome 63 or Firefox 60
75 if (hls?.default?.isSupported?.() || hls?.isSupported()) {
76 return "hls-polyfill";
77 }
78 console.warn("does not support hls.js");
79 // Other browsers
80 return "none";
81 });
82 });
83 })69 })
84 .catch((e): PlaybackMode => {70 .catch((e): PlaybackMode => {
85 console.warn(e);71 console.warn(e);
...@@ -92,6 +78,28 @@ function decidePlaybackMode(): Promise<PlaybackMode> {...@@ -92,6 +78,28 @@ function decidePlaybackMode(): Promise<PlaybackMode> {
92 });78 });
93}79}
9480
81// the hls fallback decision is also needed by av1-capable browsers when a
82// video has an hls manifest but no dash one (dash encodes can fail or lag)
83function decideHlsMode(): Promise<PlaybackMode> {
84 return canNativelyPlayHls().then(
85 (nativeHls): PlaybackMode | Promise<PlaybackMode> => {
86 if (nativeHls) return "hls-native";
87 // @ts-expect-error
88 return (hls ??= import("/js/scripts/vendor/hls.js"))
89 .then((hls): PlaybackMode => {
90 // Polyfill HLS (Chrome 23, Firefox 42, IE11 on Win8)
91 // TODO: ES modules have a minimum of Chrome 63 or Firefox 60
92 if (hls?.default?.isSupported?.() || hls?.isSupported()) {
93 return "hls-polyfill";
94 }
95 console.warn("does not support hls.js");
96 // Other browsers
97 return "none";
98 });
99 },
100 );
101}
102
95function hydrateVideoInner(103function hydrateVideoInner(
96 container: HTMLElement,104 container: HTMLElement,
97 mode: PlaybackMode,105 mode: PlaybackMode,
...@@ -109,6 +117,38 @@ function hydrateVideoInner(...@@ -109,6 +117,38 @@ function hydrateVideoInner(
109 const dashFile = `${src}:/dash.mpd`;117 const dashFile = `${src}:/dash.mpd`;
110 const hlsFile = `${src}:/master.m3u8`;118 const hlsFile = `${src}:/master.m3u8`;
111119
120 // data-streams lists which manifests exist ("dash", "hls", or both). a
121 // manifest may be missing because the indexer has not finished the file
122 // yet ("pending"), it skipped it (short/tiny videos, "none"), or that
123 // encode failed. never pick a backend whose manifest would 404; fall back
124 // to playing the original file directly.
125 const streams = (video.getAttribute("data-streams") ?? "").split(",");
126 const hasDash = streams.includes("dash");
127 const hasHls = streams.includes("hls");
128 if (!hasDash && !hasHls) {
129 if (streams.includes("pending")) {
130 const note = document.createElement("p");
131 note.className = "clover-video-pending";
132 note.textContent = "this video is still being processed, so quality selection is "
133 + "unavailable. if it does not play, come back later :(";
134 (container.querySelector("figcaption") ?? video.parentElement)
135 ?.appendChild(note);
136 }
137 video.src = src;
138 video.controls = true;
139 onCloverVideoInit?.(id, video);
140 return;
141 }
142 if (mode === "av1" && !hasDash) {
143 // av1-capable browser, but only an hls manifest exists for this video
144 decideHlsMode().then((m) => hydrateVideoInner(container, m));
145 return;
146 }
147 if ((mode === "hls-native" || mode === "hls-polyfill") && !hasHls) {
148 // dash-only video on a browser that cannot decode av1: raw playback
149 mode = "none";
150 }
151
112 if (forceMode) {152 if (forceMode) {
113 const figcaption = container.querySelector("figcaption");153 const figcaption = container.querySelector("figcaption");
114 if (figcaption) {154 if (figcaption) {
src/tags/clover-video.marko+18-1
...@@ -29,13 +29,29 @@ export interface Input {...@@ -29,13 +29,29 @@ export interface Input {
29 : null29 : null
30)>30)>
3131
32<const/{ width, height }=file.parseDimensions()>32<const/{ width, height }=file.parseDimensions() ?? {
33 width: 1920,
34 height: 1080,
35}>
36<!-- which streaming manifests exist ("dash", "hls", or both). the player
37must only pick a backend whose manifest is actually present: dash can fail
38or lag behind hls for the same file. "pending" tells it to show a
39come-back-later note instead of silently failing -->
40<const/streams=(
41 [
42 derived.get(file, "dash.mpd") && "dash",
43 derived.get(file, "master.m3u8") && "hls",
44 ]
45 .filter(Boolean)
46 .join(",") || (file.pending ? "pending" : "none")
47)>
3348
34<define/Inner>49<define/Inner>
35 <video50 <video
36 ...{ id }51 ...{ id }
37 class={ "clover-video": noFigure, minimal: noFigure && minimal }52 class={ "clover-video": noFigure, minimal: noFigure && minimal }
38 data-src=file.path53 data-src=file.path
54 data-streams=streams
39 poster=poster ? `/file${poster.path}` : null55 poster=poster ? `/file${poster.path}` : null
40 width=width56 width=width
41 height=height57 height=height
...@@ -83,4 +99,5 @@ export interface Input {...@@ -83,4 +99,5 @@ export interface Input {
83client import "./clover-video.client.ts";99client import "./clover-video.client.ts";
84100
85import { MediaFile } from "#src/file-viewer/models/MediaFile.ts";101import { MediaFile } from "#src/file-viewer/models/MediaFile.ts";
102import * as derived from "#src/file-viewer/models/derived.ts";
86import { UNWRAP, ASSERT } from "@clo/lib/assert";103import { UNWRAP, ASSERT } from "@clo/lib/assert";