authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-07-08 23:10:41-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:45-07:00
log7bda48ef5d8953025b6c2bd49458753b0199f30b
tree36e49957af580c897995c31326ac17aeefb2d886
parent82d1470abd4b45708f0e4f91a4c05d954a09f1e4
signature Commit is signed but in an unrecognized format.

add "source of truth" server to replace the old cache url


10 files changed, 140 insertions(+), 1049 deletions(-)

framework/engine/ssr.ts+5-12
......@@ -8,20 +8,13 @@
88// to track needed client scripts without introducing patches to the engine.
99export type Addons = Record<string | symbol, unknown>;
1010
11export function ssrSync(node: Node): Result;
12export function ssrSync<A extends Addons>(node: Node, addon: A): Result<A>;
13export function ssrSync(node: Node, addon: Addons = {}) {
11export function ssrSync<A extends Addons>(node: Node, addon: A = {} as A) {
1412 const r = initRender(false, addon);
1513 const resolved = resolveNode(r, node);
1614 return { text: renderNode(resolved), addon };
1715}
1816
19export function ssrAsync(node: Node): Promise<Result>;
20export function ssrAsync<A extends Addons>(
21 node: Node,
22 addon: A,
23): Promise<Result<A>>;
24export function ssrAsync(node: Node, addon: Addons = {}) {
17export function ssrAsync<A extends Addons>(node: Node, addon: A = {} as A) {
2518 const r = initRender(true, addon);
2619 const resolved = resolveNode(r, node);
2720 if (r.async === 0) {
......@@ -142,13 +135,13 @@ export function resolveNode(r: Render, node: unknown): ResolvedNode {
142135 return resolveNode(r, tag(props));
143136 } catch (e) {
144137 const { 4: src } = node;
145 if (e && typeof e === "object") {
146 }
138 if (e && typeof e === "object") (e as { src?: string }).src = src;
139 throw e;
147140 } finally {
148141 currentRender = null;
149142 }
150143 }
151 if (typeof tag !== "string") throw new Error("Unexpected " + typeof type);
144 if (typeof tag !== "string") throw new Error("Unexpected " + inspect(type));
152145 const children = props?.children;
153146 if (children) return [kElement, tag, props, resolveNode(r, children)];
154147 return node;
framework/lib/sitegen.ts+4
......@@ -28,6 +28,10 @@ export function getRender() {
2828 });
2929}
3030
31export function inRender() {
32 return "sitegen" in ssr.getCurrentRender();
33}
34
3135/** Add a client-side script to the page. */
3236export function addScript(id: ScriptId | { value: ScriptId }) {
3337 getRender().scripts.add(typeof id === "string" ? id : id.value);
run.js+17-1
......@@ -2,6 +2,7 @@
22// all available plugins. Usage: "node run <script>"
33import * as util from "node:util";
44import * as zlib from "node:zlib";
5import * as url from "node:url";
56import process from "node:process";
67
78if (!zlib.zstdCompress) {
......@@ -62,7 +63,22 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {
6263 }
6364 process.argv = [process.argv[0], ...process.argv.slice(2)];
6465 try {
65 await hot.load(found).main?.();
66 const mod = await hot.load(found);
67 if (mod.main) mod.main();
68 else if (mod.default?.fetch) {
69 const protocol = "http";
70 const { serve } = hot.load("@hono/node-server");
71 serve({
72 fetch: mod.default.fetch,
73 }, ({ address, port }) => {
74 if (address === "::") address = "::1";
75 console.info(url.format({
76 protocol,
77 hostname: address,
78 port,
79 }));
80 });
81 }
6682 } catch (e) {
6783 console.error(util.inspect(e));
6884 }
src/backend.ts-10
......@@ -17,16 +17,6 @@ app.use(assets.middleware);
1717
1818// Handlers
1919app.notFound(assets.notFound);
20if (process.argv.includes("--development")) {
21 app.onError((err, c) => {
22 if (err instanceof HTTPException) {
23 // Get the custom response
24 return err.getResponse();
25 }
26
27 return c.text(util.inspect(err), 500);
28 });
29}
3020
3121export default app;
3222
src/file-viewer/backend.tsx+1-2
......@@ -43,13 +43,12 @@ app.post("/file/cotyledon", async (c) => {
4343
4444app.get("/file/*", async (c, next) => {
4545 const ua = c.req.header("User-Agent")?.toLowerCase() ?? "";
46 const lofi = ua.includes("msie") || ua.includes("rv:") || false;
46 const lofi = ua.includes("msie") || false;
4747
4848 // Discord ignores 'robots.txt' which violates the license agreement.
4949 if (ua.includes("discordbot")) {
5050 return next();
5151 }
52 console.log(ua, lofi);
5352
5453 let rawFilePath = c.req.path.slice(5) || "/";
5554 if (rawFilePath.endsWith("$partial")) {
src/file-viewer/bin/list.ts deleted-8
......@@ -1,8 +0,0 @@
1export function main() {
2 const meows = MediaFile.db.prepare(`
3 select * from media_files;
4 `).as(MediaFile).array();
5 console.log(meows);
6}
7
8import { MediaFile } from "@/file-viewer/models/MediaFile.ts";
src/file-viewer/bin/scan.ts deleted-1015
......@@ -1,1015 +0,0 @@
1// This file was started by AI and maintained by hand since.
2import "@paperclover/console/inject";
3import { Progress } from "@paperclover/console/Progress";
4import { Spinner } from "@paperclover/console/Spinner";
5import assert from "node:assert";
6import { execFile } from "node:child_process";
7import { existsSync, Stats } from "node:fs";
8import * as fsp from "node:fs/promises";
9import * as path from "node:path";
10import { promisify } from "node:util";
11import { BlobAsset, cache, FilePermissions, MediaFile } from "../db.ts";
12import { formatDate, formatSize } from "./share.ts";
13import { highlightCode, type Language } from "./highlight.ts";
14
15const execFileAsync = promisify(execFile);
16
17// Configuration
18const FILE_ROOT = process.env.SCAN_FILE_ROOT;
19if (!FILE_ROOT) {
20 throw new Error(
21 "FILE_ROOT environment variable not set (e.g. '/path/to/files')",
22 );
23}
24const LOCAL_DIR = path.resolve(FILE_ROOT);
25const DRY_RUN = process.argv.includes("--dry-run");
26const SHOULD_COMPRESS = true;
27const VERBOSE = process.argv.includes("--verbose");
28const SHOULD_SCRUB = true;
29const COMPRESS_STORE = process.env.COMPRESS_STORE ||
30 path.join(process.cwd(), ".clover/compressed");
31
32// Helper function for logging that respects verbose flag
33function log(message: string, always = false): void {
34 if (always || VERBOSE) {
35 console.log(message);
36 }
37}
38
39// File extensions that need duration metadata
40const MEDIA_EXTENSIONS = new Set([
41 ".mp4",
42 ".mkv",
43 ".webm",
44 ".avi",
45 ".mov",
46 ".mp3",
47 ".flac",
48 ".wav",
49 ".ogg",
50 ".m4a",
51]);
52
53// File extensions that need dimension metadata
54const IMAGE_EXTENSIONS = new Set([
55 ".jpg",
56 ".jpeg",
57 ".png",
58 ".gif",
59 ".webp",
60 ".avif",
61 ".heic",
62 ".svg",
63]);
64
65const VIDEO_EXTENSIONS = new Set([".mp4", ".mkv", ".webm", ".avi", ".mov"]);
66
67// File extensions that need metadata scrubbing
68const SCRUB_EXTENSIONS = new Set([
69 ".jpg",
70 ".jpeg",
71 ".png",
72 ".mov",
73 ".mp4",
74 ".m4a",
75]);
76
77const CODE_EXTENSIONS: Record<string, Language> = {
78 ".json": "json",
79 ".toml": "toml",
80 ".ts": "ts",
81 ".js": "ts",
82 ".tsx": "tsx",
83 ".jsx": "tsx",
84 ".css": "css",
85 ".py": "python",
86 ".lua": "lua",
87 ".sh": "shell",
88 ".bat": "dosbatch",
89 ".ps1": "powershell",
90 ".cmd": "dosbatch",
91 ".yaml": "yaml",
92 ".yml": "yaml",
93 ".zig": "zig",
94 ".astro": "astro",
95 ".mdx": "mdx",
96 ".xml": "xml",
97 ".jsonc": "json",
98 ".php": "php",
99 ".patch": "diff",
100 ".diff": "diff",
101};
102
103const READ_CONTENTS_EXTENSIONS = new Set([".txt", ".chat"]);
104
105// For files that have changed indexing logic, update the date here rescanning
106// will reconstruct the entire file object. This way you can incrementally
107// update new file types without having to reindex everything.
108const lastUpdateTypes: Record<string, Date> = {};
109lastUpdateTypes[".lnk"] = new Date("2025-05-13 13:58:00");
110for (const ext in CODE_EXTENSIONS) {
111 lastUpdateTypes[ext] = new Date("2025-05-13 13:58:00");
112}
113for (const ext of READ_CONTENTS_EXTENSIONS) {
114 lastUpdateTypes[ext] = new Date("2025-05-13 13:58:00");
115}
116lastUpdateTypes[".diff"] = new Date("2025-05-18 13:58:00");
117lastUpdateTypes[".patch"] = new Date("2025-05-18 13:58:00");
118
119// Helper functions for metadata extraction
120async function calculateHash(filePath: string): Promise<string> {
121 try {
122 const hash = await execFileAsync("sha1sum", [filePath]);
123 return hash.stdout.split(" ")[0];
124 } catch (error) {
125 console.error(`Error calculating hash for ${filePath}:`, error);
126 throw error;
127 }
128}
129
130async function calculateDuration(filePath: string): Promise<number> {
131 try {
132 const ext = path.extname(filePath).toLowerCase();
133 if (!MEDIA_EXTENSIONS.has(ext)) return 0;
134
135 const { stdout } = await execFileAsync("ffprobe", [
136 "-v",
137 "error",
138 "-show_entries",
139 "format=duration",
140 "-of",
141 "default=noprint_wrappers=1:nokey=1",
142 filePath,
143 ]);
144 return Math.ceil(parseFloat(stdout.trim()));
145 } catch (error) {
146 console.error(`Error calculating duration for ${filePath}:`, error);
147 return 0; // Return 0 for duration on error
148 }
149}
150
151async function calculateDimensions(filePath: string): Promise<string> {
152 const ext = path.extname(filePath).toLowerCase();
153 if (!IMAGE_EXTENSIONS.has(ext) && !VIDEO_EXTENSIONS.has(ext)) return "";
154
155 try {
156 if (ext === ".svg") {
157 // For SVG files, parse the file and extract width/height
158 const content = await fsp.readFile(filePath, "utf8");
159 const widthMatch = content.match(/width="(\d+)"/);
160 const heightMatch = content.match(/height="(\d+)"/);
161
162 if (widthMatch && heightMatch) {
163 return `${widthMatch[1]}x${heightMatch[1]}`;
164 }
165 } else if (IMAGE_EXTENSIONS.has(ext) || VIDEO_EXTENSIONS.has(ext)) {
166 // Use ffprobe for images and videos
167 const { stdout } = await execFileAsync("ffprobe", [
168 "-v",
169 "error",
170 "-select_streams",
171 "v:0",
172 "-show_entries",
173 "stream=width,height",
174 "-of",
175 "csv=s=x:p=0",
176 filePath,
177 ]);
178 return stdout.trim();
179 }
180 } catch (error) {
181 console.error(`Error calculating dimensions for ${filePath}:`, error);
182 }
183
184 return "";
185}
186
187// Helper function to check and remove location metadata
188async function scrubLocationMetadata(
189 filePath: string,
190 stats: Stats,
191): Promise<boolean> {
192 try {
193 const ext = path.extname(filePath).toLowerCase();
194 if (!SCRUB_EXTENSIONS.has(ext)) return false;
195
196 let hasLocation = false;
197 let args: string[] = [];
198
199 // Check for location metadata based on file type
200 const tempOutput = path.join(
201 path.dirname(filePath),
202 `.tmp.${path.basename(filePath)}`,
203 );
204 switch (ext) {
205 case ".jpg":
206 case ".jpeg":
207 case ".png":
208 // Check for GPS tags in EXIF
209 const { stdout: gpsCheck } = await execFileAsync("exiftool", [
210 "-gps:all",
211 filePath,
212 ]);
213 hasLocation = gpsCheck.trim().length > 0;
214 args = ["-gps:all=", filePath, "-o", tempOutput];
215 break;
216 case ".mov":
217 case ".mp4":
218 // Check for GPS metadata in video files
219 const { stdout: videoCheck } = await execFileAsync("exiftool", [
220 "-ee",
221 "-G3",
222 "-s",
223 filePath,
224 ]);
225 hasLocation = videoCheck.includes("GPS") ||
226 videoCheck.includes("Location");
227 args = ["-gps:all=", "-xmp:all=", filePath, "-o", tempOutput];
228 break;
229 case ".m4a":
230 // Check for location and other metadata in m4a files
231 const { stdout: m4aCheck } = await execFileAsync("exiftool", [
232 "-ee",
233 "-G3",
234 "-s",
235 filePath,
236 ]);
237 hasLocation = m4aCheck.includes("GPS") ||
238 m4aCheck.includes("Location") ||
239 m4aCheck.includes("Filename") ||
240 m4aCheck.includes("Title");
241
242 if (hasLocation) {
243 args = [
244 "-gps:all=",
245 "-location:all=",
246 "-filename:all=",
247 "-title=",
248 "-m4a:all=",
249 filePath,
250 "-o",
251 tempOutput,
252 ];
253 }
254 break;
255 }
256
257 const accessTime = stats.atime;
258 const modTime = stats.mtime;
259
260 let backup: string | null = null;
261 try {
262 if (hasLocation) {
263 if (DRY_RUN) return true;
264
265 // Prepare a backup
266 const tmp = path.join(
267 path.dirname(filePath),
268 `.tmp.backup.${path.basename(filePath)}`,
269 );
270 await fsp.copyFile(filePath, tmp);
271 await fsp.utimes(tmp, accessTime, modTime);
272 backup = tmp;
273
274 // Remove metadata
275 await execFileAsync("exiftool", args);
276 if (!existsSync(tempOutput)) {
277 throw new Error(`Failed to create output file: ${tempOutput}`);
278 }
279
280 // Restore original timestamps
281 await fsp.rename(tempOutput, filePath);
282 await fsp.utimes(filePath, accessTime, modTime);
283
284 // Backup is no longer needed
285 await fsp.unlink(backup);
286
287 log(
288 `Scrubbed location metadata in ${path.relative(LOCAL_DIR, filePath)}`,
289 true,
290 );
291 return true;
292 }
293 } catch (error) {
294 if (backup) {
295 await fsp.rename(backup, filePath);
296 }
297 if (existsSync(tempOutput)) {
298 await fsp.unlink(tempOutput);
299 }
300 throw error;
301 }
302 } catch (error) {
303 console.error(`Error scrubbing metadata for ${filePath}:`, error);
304 }
305
306 return false;
307}
308
309// Queue implementation for parallel processing
310type AsyncQueueProcessor<T> = (s: Spinner, item: T) => Promise<void>;
311class AsyncQueue<T> {
312 private queue: T[] = [];
313 private running = 0;
314 private maxConcurrent: number;
315 private processed = 0;
316 private progress?: Progress<{ active: Spinner[] }>;
317 private name: string;
318 private estimate?: number;
319
320 constructor(name: string, maxConcurrent: number) {
321 this.maxConcurrent = maxConcurrent;
322 this.name = name;
323 }
324
325 setEstimate(estimate: number) {
326 this.estimate = estimate;
327 if (this.progress) {
328 this.progress.total = Math.max(
329 this.processed + this.queue.length,
330 estimate,
331 );
332 }
333 }
334
335 getProgress() {
336 if (!this.progress) {
337 this.progress = new Progress({
338 spinner: null,
339 text: ({ active }) => {
340 const now = performance.now();
341 let text = `[${this.processed}/${
342 this.processed + this.queue.length
343 }] ${this.name}`;
344 let n = 0;
345 for (const item of active) {
346 let itemText = "- " + item.format(now);
347 text += `\n` +
348 itemText.slice(0, Math.max(0, process.stdout.columns - 1));
349 if (n > 10) {
350 text += `\n ... + ${active.length - n} more`;
351 break;
352 }
353 n++;
354 }
355 return text;
356 },
357 props: {
358 active: [] as Spinner[],
359 },
360 });
361 this.progress.total = this.estimate ?? 0;
362 this.progress.value = 0;
363 this.progress.fps = 30;
364 }
365 return this.progress;
366 }
367
368 async add(item: T, processor: AsyncQueueProcessor<T>): Promise<void> {
369 this.queue.push(item);
370 this.getProgress().total = Math.max(
371 this.processed + this.queue.length,
372 this.estimate ?? 0,
373 );
374 return this.processNext(processor);
375 }
376
377 async addBatch(items: T[], processor: AsyncQueueProcessor<T>): Promise<void> {
378 this.queue.push(...items);
379 this.getProgress().total = Math.max(
380 this.processed + this.queue.length,
381 this.estimate ?? 0,
382 );
383 return this.processNext(processor);
384 }
385
386 private async processNext(processor: AsyncQueueProcessor<T>): Promise<void> {
387 if (this.running >= this.maxConcurrent || this.queue.length === 0) {
388 return;
389 }
390
391 const item = this.queue.shift();
392 if (!item) return;
393
394 this.running++;
395
396 try {
397 const progress = this.getProgress();
398
399 let itemText = "";
400 if (typeof item === "string") {
401 itemText = item;
402 } else if (typeof item === "object" && item !== null && "path" in item) {
403 itemText = "" + item.path;
404 } else {
405 itemText = JSON.stringify(item);
406 }
407 if (itemText.startsWith(LOCAL_DIR)) {
408 itemText = path.relative(LOCAL_DIR, itemText);
409 }
410
411 const spinner = new Spinner(itemText);
412 spinner.stop();
413 progress.props.active.unshift(spinner);
414 await processor(spinner, item);
415 progress.props = {
416 active: progress.props.active.filter((s) => s !== spinner),
417 };
418 this.processed++;
419 progress.value = this.processed;
420 } catch (error) {
421 console.error(`Error processing ${this.name} queue item:`, error);
422 this.processed++;
423 this.getProgress().value = this.processed;
424 } finally {
425 this.running--;
426 await this.processNext(processor);
427 }
428 }
429
430 async waitForCompletion(): Promise<void> {
431 if (this.queue.length === 0 && this.running === 0) {
432 if (this.processed > 0) {
433 this.#success();
434 }
435 return;
436 }
437
438 return new Promise((resolve) => {
439 const checkInterval = setInterval(() => {
440 if (this.queue.length === 0 && this.running === 0) {
441 clearInterval(checkInterval);
442 this.#success();
443 resolve();
444 }
445 }, 100);
446 });
447 }
448
449 #success() {
450 this.getProgress().success(`${this.processed} ${this.name}`);
451 }
452}
453
454function skipBasename(basename: string): boolean {
455 // dot files must be incrementally tracked
456 if (basename === ".dirsort") return true;
457 if (basename === ".friends") return true;
458
459 return (
460 basename.startsWith(".") ||
461 basename.startsWith("._") ||
462 basename.startsWith(".tmp") ||
463 basename === ".DS_Store" ||
464 basename.toLowerCase() === "thumbs.db" ||
465 basename.toLowerCase() === "desktop.ini"
466 );
467}
468
469// File system scanner
470class FileSystemScanner {
471 private visitedPaths = new Set<string>();
472 private previousPaths = new Set<string>();
473 private dirQueue = new AsyncQueue<string>("Scan Directories", 10);
474 private fileQueue = new AsyncQueue<{ path: string; stat: any }>(
475 "File metadata",
476 20,
477 );
478 private compressQueue: AsyncQueue<{ file: MediaFile; path: string }> | null =
479 SHOULD_COMPRESS ? new AsyncQueue("Compress Assets", 10) : null;
480
481 private getDbPath(localPath: string): string {
482 // Convert local file system path to database path
483 const relativePath = path.relative(LOCAL_DIR, localPath);
484 return "/" + relativePath.split(path.sep).join(path.posix.sep);
485 }
486
487 private getLocalPath(dbPath: string): string {
488 // Convert database path to local file system path
489 return path.join(LOCAL_DIR, dbPath.slice(1));
490 }
491
492 async scanFile(s: Spinner, filePath: string, stat: any): Promise<void> {
493 const dbPath = this.getDbPath(filePath);
494
495 // Skip hidden files
496 const basename = path.basename(filePath);
497 if (skipBasename(basename)) {
498 return;
499 }
500
501 this.visitedPaths.add(dbPath);
502
503 // Get existing file info from db
504 const existingFile = MediaFile.getByPath(dbPath);
505
506 // Determine which date to use (for date protection)
507 let dateToUse = stat.mtime;
508 const year2025Start = new Date("2025-01-01T00:00:00Z");
509
510 if (
511 existingFile &&
512 existingFile.date < year2025Start &&
513 stat.mtime >= year2025Start
514 ) {
515 console.error(
516 `Error: ${dbPath} is ${
517 formatDate(
518 existingFile.date,
519 )
520 }, got modified to ${formatDate(stat.mtime)}`,
521 );
522 dateToUse = existingFile.date;
523 }
524
525 // Check if we need to reprocess the file
526 if (existingFile && existingFile.size === stat.size && existingFile.hash) {
527 maybe_skip: {
528 const lastUpdateDate = lastUpdateTypes[path.extname(filePath)];
529 if (lastUpdateDate && existingFile.lastUpdateDate < lastUpdateDate) {
530 console.log(
531 `Reprocessing ${dbPath} because indexing logic changed after ${
532 formatDate(
533 lastUpdateDate,
534 )
535 }`,
536 );
537 break maybe_skip;
538 }
539
540 if (SHOULD_COMPRESS && existingFile.processed !== 2) {
541 this.compressQueue!.add(
542 { file: existingFile, path: dbPath },
543 this.compressFile.bind(this),
544 );
545 }
546
547 // File hasn't changed, no need to reprocess
548 MediaFile.createFile({
549 path: dbPath,
550 date: dateToUse,
551 hash: existingFile.hash,
552 size: stat.size,
553 duration: existingFile.duration,
554 dimensions: existingFile.dimensions,
555 content: existingFile.contents,
556 });
557 return;
558 }
559 }
560
561 // Process the file
562 log(`Processing file: ${dbPath}`);
563
564 // Scrub location metadata if needed
565 if (SHOULD_SCRUB) {
566 if (await scrubLocationMetadata(filePath, stat)) {
567 // Re-stat the file in case it was modified
568 const newStat = await fsp.stat(filePath);
569 stat.size = newStat.size;
570 }
571 }
572
573 // Extract content
574 const hash = await calculateHash(filePath);
575 let content = "";
576 if (filePath.endsWith(".lnk")) {
577 content = (await fsp.readFile(filePath, "utf8")).trim();
578 }
579 const language = CODE_EXTENSIONS[path.extname(filePath)];
580 if (language) {
581 read_code: {
582 // An issue is that .ts is an overloaded extension, shared between
583 // 'transport stream' and 'typescript'.
584 //
585 // Filter used here is:
586 // - more than 1mb
587 // - invalid UTF-8
588 if (stat.size > 1_000_000) break read_code;
589 let code;
590 const buf = await fsp.readFile(filePath);
591 try {
592 code = new TextDecoder("utf-8", { fatal: true }).decode(buf);
593 } catch (error) {
594 break read_code;
595 }
596 content = await highlightCode(code, language);
597 }
598 }
599 if (!content && READ_CONTENTS_EXTENSIONS.has(path.extname(filePath))) {
600 content = await fsp.readFile(filePath, "utf8");
601 }
602 // End extract content
603
604 if (hash === existingFile?.hash) {
605 MediaFile.createFile({
606 path: dbPath,
607 date: dateToUse,
608 hash,
609 size: stat.size,
610 duration: existingFile.duration,
611 dimensions: existingFile.dimensions,
612 content,
613 });
614 return;
615 } else if (existingFile) {
616 if (existingFile.processed === 2) {
617 if (BlobAsset.decrementOrDelete(existingFile.hash)) {
618 log(
619 `Deleted compressed asset ${existingFile.hash}.{gzip, zstd}`,
620 true,
621 );
622 await fsp.unlink(
623 path.join(
624 COMPRESS_STORE,
625 existingFile.hash.substring(0, 2),
626 existingFile.hash + ".gz",
627 ),
628 );
629 await fsp.unlink(
630 path.join(
631 COMPRESS_STORE,
632 existingFile.hash.substring(0, 2),
633 existingFile.hash + ".zstd",
634 ),
635 );
636 }
637 }
638 }
639 const [duration, dimensions] = await Promise.all([
640 calculateDuration(filePath),
641 calculateDimensions(filePath),
642 ]);
643
644 // Update database with all metadata
645 MediaFile.createFile({
646 path: dbPath,
647 date: dateToUse,
648 hash,
649 size: stat.size,
650 duration,
651 dimensions,
652 content,
653 });
654
655 if (SHOULD_COMPRESS) {
656 this.compressQueue!.add(
657 {
658 file: MediaFile.getByPath(dbPath)!,
659 path: dbPath,
660 },
661 this.compressFile.bind(this),
662 );
663 }
664 }
665
666 async compressFile(s: Spinner, { file }: { file: MediaFile }): Promise<void> {
667 log(`Compressing file: ${file.path}`);
668 if (DRY_RUN) return;
669
670 const filePath = path.join(FILE_ROOT!, file.path);
671
672 const hash = file.hash;
673 const firstTwoChars = hash.substring(0, 2);
674 const compressDir = `${COMPRESS_STORE}/${firstTwoChars}`;
675 const compressPath = `${compressDir}/${hash}`;
676
677 // Create directory structure if it doesn't exist
678 await fsp.mkdir(compressDir, { recursive: true });
679
680 // Compress the file with gzip
681 const blob = BlobAsset.putOrIncrement(hash);
682 if (blob.refs > 1) {
683 log(
684 `Skipping compression of ${filePath} because it already exists in ${compressPath}`,
685 );
686 return;
687 }
688 // Check if already exists
689 if (existsSync(compressPath + ".gz")) {
690 file.setCompressed(true);
691 return;
692 }
693 try {
694 const gzipProcess = Bun.spawn(["gzip", "-c", filePath, "-9"], {
695 stdout: Bun.file(compressPath + ".gz"),
696 });
697 const zstdProcess = Bun.spawn(["zstd", "-c", filePath, "-9"], {
698 stdout: Bun.file(compressPath + ".zstd"),
699 });
700 const [gzipExited, zstdExited] = await Promise.all([
701 gzipProcess.exited,
702 zstdProcess.exited,
703 ]);
704 assert(gzipExited === 0);
705 assert(zstdExited === 0);
706 assert(existsSync(compressPath + ".gz"));
707 assert(existsSync(compressPath + ".zstd"));
708 file.setCompressed(true);
709 } catch (error) {
710 console.error(`Error compressing file ${filePath}:`, error);
711 BlobAsset.decrementOrDelete(hash);
712 file.setCompressed(false);
713 }
714 }
715
716 async scanDirectory(s: Spinner, dirPath: string): Promise<void> {
717 const dbPath = this.getDbPath(dirPath);
718
719 this.visitedPaths.add(dbPath);
720
721 // Create or update directory entry
722 log(`Scanning directory: ${dbPath}`);
723 if (!DRY_RUN) {
724 MediaFile.createOrUpdateDirectory(dbPath);
725 }
726
727 try {
728 const entries = await fsp.readdir(dirPath, { withFileTypes: true });
729
730 // Process files and subdirectories
731 for (const entry of entries) {
732 const entryPath = path.join(dirPath, entry.name);
733
734 // Skip hidden files and system files
735 if (skipBasename(entry.name)) {
736 continue;
737 }
738
739 if (entry.isDirectory()) {
740 // Queue subdirectory for scanning
741 this.dirQueue.add(entryPath, this.scanDirectory.bind(this));
742 } else if (entry.isFile()) {
743 // Queue file for processing
744 const stat = await fsp.stat(entryPath);
745
746 this.fileQueue.add(
747 { path: entryPath, stat },
748 async (s, item) => await this.scanFile(s, item.path, item.stat),
749 );
750 }
751 }
752 } catch (error) {
753 console.error(`Error scanning directory ${dirPath}:`, error);
754 }
755 }
756
757 async processDirectoryMetadata(dirPath: string): Promise<void> {
758 const dbPath = this.getDbPath(dirPath);
759 const dir = MediaFile.getByPath(dbPath);
760
761 if (!dir || dir.kind !== MediaFile.Kind.directory) {
762 return;
763 }
764
765 if (DRY_RUN) return;
766
767 const children = dir.getChildren();
768
769 // Calculate directory metadata
770 let totalSize = 0;
771 let newestDate = new Date(0);
772 let allHashes = "";
773
774 // Check for readme.txt
775 let readmeContent = "";
776
777 try {
778 readmeContent = await fsp.readFile(
779 path.join(dirPath, "readme.txt"),
780 "utf8",
781 );
782 } catch (error: any) {
783 console.info(`no readme ${dirPath}`);
784 if (error.code !== "ENOENT") {
785 console.error(`Error reading readme.txt in ${dirPath}:`, error);
786 }
787 }
788
789 let dirsort: string[] | null = null;
790 try {
791 dirsort = (await fsp.readFile(path.join(dirPath, ".dirsort"), "utf8"))
792 .split("\n")
793 .map((x) => x.trim())
794 .filter(Boolean);
795 } catch (error: any) {
796 if (error.code !== "ENOENT") {
797 console.error(`Error reading .dirsort in ${dirPath}:`, error);
798 }
799 }
800
801 if (await fsp.exists(path.join(dirPath, ".friends"))) {
802 FilePermissions.setPermissions(dbPath, 1);
803 } else {
804 FilePermissions.setPermissions(dbPath, 0);
805 }
806
807 // Process children
808 for (const child of children) {
809 totalSize += child.size;
810 allHashes += child.hash;
811
812 // Update newest date, ignoring readme.txt
813 if (!child.path.endsWith("/readme.txt") && child.date > newestDate) {
814 newestDate = child.date;
815 }
816 }
817
818 // Create a hash for the directory
819 const dirHash = new Bun.CryptoHasher("sha1")
820 .update(dbPath + allHashes)
821 .digest("hex");
822
823 // Update directory metadata
824 MediaFile.markDirectoryProcessed({
825 id: dir.id,
826 timestamp: newestDate,
827 contents: readmeContent,
828 size: totalSize,
829 hash: dirHash,
830 dirsort,
831 });
832 }
833
834 async findDeletedFiles(): Promise<void> {
835 if (DRY_RUN) return;
836
837 // Find all paths that exist in the DB but not in the filesystem
838 const deletedPaths = Array.from(this.previousPaths).filter(
839 (path) => !this.visitedPaths.has(path),
840 );
841
842 for (const dbPath of deletedPaths) {
843 const file = MediaFile.getByPath(dbPath);
844 if (!file) continue;
845
846 log(`Item Deleted: ${dbPath}`, true);
847 if (file.processed === 2) {
848 if (BlobAsset.decrementOrDelete(file.hash)) {
849 log(`Deleted compressed asset ${file.hash}.{gzip, zstd}`, true);
850 await fsp.unlink(
851 path.join(
852 COMPRESS_STORE,
853 file.hash.substring(0, 2),
854 file.hash + ".gz",
855 ),
856 );
857 await fsp.unlink(
858 path.join(
859 COMPRESS_STORE,
860 file.hash.substring(0, 2),
861 file.hash + ".zstd",
862 ),
863 );
864 }
865 }
866 MediaFile.deleteByPath(dbPath);
867 }
868 }
869
870 async loadPreviousPaths(): Promise<void> {
871 // Get all files and directories from the database
872 // This uses a custom query to get all paths at once
873 const getAllPathsQuery = cache
874 .prepare(`SELECT path, kind FROM media_files`)
875 .all() as {
876 path: string;
877 kind: MediaFile.Kind;
878 }[];
879
880 let dirs = 0;
881 let files = 0;
882 for (const row of getAllPathsQuery) {
883 this.previousPaths.add(row.path);
884 if (row.kind === MediaFile.Kind.directory) {
885 dirs++;
886 } else {
887 files++;
888 }
889 }
890
891 this.dirQueue.setEstimate(dirs);
892 this.fileQueue.setEstimate(files);
893
894 // log(`Loaded ${this.previousPaths.size} paths from database`, true);
895 }
896
897 async scan(): Promise<void> {
898 log(`Starting file system scan in ${LOCAL_DIR}`, true);
899
900 // Check if the root directory exists and is accessible
901 try {
902 const rootStat = await fsp.stat(LOCAL_DIR);
903 if (!rootStat.isDirectory()) {
904 throw new Error(`${LOCAL_DIR} is not a directory`);
905 }
906 } catch (error) {
907 console.error(`Error: Cannot access root directory ${LOCAL_DIR}`, error);
908 console.error(
909 `Aborting scan to prevent database corruption. Please check if the volume is mounted.`,
910 );
911 process.exit(1);
912 }
913
914 await this.loadPreviousPaths();
915
916 await this.dirQueue.add(LOCAL_DIR, this.scanDirectory.bind(this));
917
918 await this.dirQueue.waitForCompletion();
919 await this.fileQueue.waitForCompletion();
920
921 await this.findDeletedFiles();
922
923 const allDirs = Array.from(this.visitedPaths)
924 .filter((path) => {
925 const file = MediaFile.getByPath(path);
926 return file && file.kind === MediaFile.Kind.directory;
927 })
928 .sort((a, b) => b.length - a.length);
929
930 const dirMetadataQueue = new AsyncQueue<string>("Directory Metadata", 10);
931 for (const dirPath of allDirs) {
932 await this.processDirectoryMetadata(this.getLocalPath(dirPath));
933 }
934
935 await dirMetadataQueue.waitForCompletion();
936
937 if (SHOULD_COMPRESS) {
938 await this.compressQueue!.waitForCompletion();
939 }
940
941 log("Scan completed successfully!", true);
942 }
943}
944
945// Main execution
946function showHelp() {
947 console.log(`
948MediaFile Scanner - Index filesystem content for paperclover.net
949
950Environment variables:
951 FILE_ROOT Required. Path to the directory to scan
952 COMPRESS_STORE Optional. Path to store compressed files (default: .clover/compressed)
953
954Options:
955 --help Show this help message
956 --dry-run Don't make any changes to the database
957 --verbose Show detailed output
958
959Usage:
960 bun ./media/scan.ts [options]
961
962`);
963 process.exit(0);
964}
965
966{
967 // Show help if requested
968 if (process.argv.includes("--help")) {
969 showHelp();
970 process.exit(0);
971 }
972
973 // Check if the root directory exists before starting
974 if (!existsSync(LOCAL_DIR)) {
975 console.error(
976 `Error: Root directory ${LOCAL_DIR} does not exist or is not accessible.`,
977 );
978 console.error(`Please check if the volume is mounted correctly.`);
979 process.exit(1);
980 }
981
982 const startTime = Date.now();
983
984 try {
985 const scanner = new FileSystemScanner();
986 await scanner.scan();
987
988 const endTime = Date.now();
989 log(`Scan completed in ${(endTime - startTime) / 1000} seconds`, true);
990
991 const rootDir = MediaFile.getByPath("/")!;
992 const totalEntries = cache
993 .prepare(`SELECT COUNT(*) as count FROM media_files`)
994 .get() as { count: number };
995 const totalDuration = cache
996 .prepare(`SELECT SUM(duration) as duration FROM media_files`)
997 .get() as { duration: number };
998 console.log();
999 console.log("Global Stats");
1000 console.log(` Entry count: ${totalEntries.count}`);
1001 console.log(` Uncompressed size: ${formatSize(rootDir.size)}`);
1002 console.log(
1003 ` Total audio/video duration: ${
1004 (
1005 totalDuration.duration /
1006 60 /
1007 60
1008 ).toFixed(1)
1009 } hours`,
1010 );
1011 } catch (error) {
1012 console.error("Error during scan:", error);
1013 process.exit(1);
1014 }
1015}
src/file-viewer/views/clofi.tsx+1-1
......@@ -71,6 +71,7 @@ export default function MediaList({
7171 hasCotyledonCookie: boolean;
7272}) {
7373 addScript("./clofi.client.ts");
74 addScript("@/tags/hls-polyfill.client.ts");
7475
7576 const dirs: MediaFile[] = [];
7677 let dir: MediaFile | null = file;
......@@ -699,7 +700,6 @@ function CodeView({ file }: { file: MediaFile }) {
699700}
700701
701702function VideoView({ file }: { file: MediaFile }) {
702 addScript("@/tags/hls-polyfill.client.ts");
703703 const dimensions = file.parseDimensions() ?? { width: 1920, height: 1080 };
704704 return (
705705 <>
src/site.ts+5
......@@ -14,5 +14,10 @@ export const siteSections: Section[] = [
1414 // { root: join("fiction/"), pageBase: "/fiction" },
1515];
1616
17export const backends: string[] = [
18 join("backend.ts"),
19 join("source-of-truth.ts"),
20];
21
1722import * as path from "node:path";
1823import type { Section } from "#sitegen";
src/source-of-truth.ts created+107
......@@ -0,0 +1,107 @@
1// The "source of truth" server is the canonical storage for
2// paper clover's files. This is technically needed because
3// the VPS she uses can only store about 20gb of content, where
4// the contents of /file is about 48gb as of writing; only a
5// limited amount of data can be cached.
6//
7// What's great about this system is it also allows scaling the
8// website up into multiple servers, if that is ever desired.
9// When that happens, mutations to "q+a" will be moved here,
10// and the SQLite synchronization mechanism will apply to both
11// of those databases.
12//
13// An alternative to this would have been to use a SMB client,
14// but I've read that the systems used for caching don't work
15// like the HTTP Cache-Control header, where you can say a file
16// is valid for up to a certain amount of time. If we seriously
17// need cache busts (paper clover does not), the proper way
18// would be to push a message to all VPS nodes instead of
19// checking upstream if a file changed every time.
20const app = new Hono();
21export default app;
22
23const token = "bwaa";
24const nasRoot = "/Volumes/clover";
25
26const rawFileRoot = path.join(nasRoot, "Published");
27const derivedFileRoot = path.join(
28 nasRoot,
29 "Documents/Config/clover_file/derived",
30);
31
32type Awaitable<T> = T | Promise<T>;
33
34// Re-use file descriptors if the same file is being read twice.
35const fds = new Map<string, Awaitable<{ fd: number; refs: number }>>();
36
37app.get("/file/*", async (c) => {
38 const fullQuery = c.req.path.slice("/file".length);
39 const [filePath, derivedAsset, ...invalid] = fullQuery.split("$/");
40 if (invalid.length > 0) return c.notFound();
41 if (filePath.length <= 1) return c.notFound();
42 const permissions = FilePermissions.getByPrefix(filePath);
43 if (permissions !== 0) {
44 if (c.req.header("Authorization") !== token) {
45 return c.json({ error: "invalid authorization header" });
46 }
47 }
48 const file = MediaFile.getByPath(filePath);
49 if (!file || file.kind === MediaFileKind.directory) {
50 return c.notFound();
51 }
52 const fullPath = derivedAsset
53 ? path.join(derivedFileRoot, file.hash, derivedAsset)
54 : path.join(rawFileRoot, file.path);
55 let handle: { fd: number; refs: number } | null = null;
56 console.log("start", fullPath);
57 try {
58 handle = await fds.get(fullPath) ?? null;
59 if (!handle) {
60 const promise = openFile(fullPath, "r")
61 .then((fd) => ({ fd, refs: 0 }))
62 .catch((err) => {
63 fds.delete(fullPath);
64 throw err;
65 });
66 fds.set(file.path, promise);
67 fds.set(file.path, handle = await promise);
68 }
69 handle.refs += 1;
70 } catch (err: any) {
71 if (err.code === "ENOENT") {
72 return c.notFound();
73 }
74 throw err;
75 }
76 const nodeStream = fs.createReadStream(fullPath, {
77 fd: handle.fd,
78 fs: {
79 close: util.callbackify(async () => {
80 ASSERT(handle);
81 if ((handle.refs -= 1) <= 0) {
82 fds.delete(fullPath);
83 await closeFile(handle.fd);
84 }
85 handle = null;
86 }),
87 read: fsCallbacks.read,
88 },
89 });
90 c.header("Content-Type", mime.contentTypeFor(fullPath));
91 if (!derivedAsset)
92 c.header("Content-Length", file.size.toString());
93 return c.body(stream.Readable.toWeb(nodeStream) as ReadableStream);
94});
95
96const openFile = util.promisify(fsCallbacks.open);
97const closeFile = util.promisify(fsCallbacks.close);
98
99import { Hono } from "#hono";
100import { MediaFile, MediaFileKind } from "@/file-viewer/models/MediaFile.ts";
101import { FilePermissions } from "@/file-viewer/models/FilePermissions.ts";
102import * as path from "node:path";
103import * as fs from "#sitegen/fs";
104import * as fsCallbacks from "node:fs";
105import * as util from "node:util";
106import * as stream from "node:stream";
107import * as mime from "#sitegen/mime";