import { watch, type FSWatcher } from 'node:fs'; import { mkdir } from 'node:fs/promises'; import { db } from 'officerdb/db'; import { getOwnerUser } from 'officerdb'; import { fofosAdminSettings } from '../db/schema'; import { originalsDir } from './derive'; import { readSync, startSync } from './sync'; /** * Notice when `originals/` changes, and convert what arrived. * * ── Why a filesystem watcher and not the platform's event bus ── * * The bus was the obvious answer and it is not sufficient. It emits `files:uploaded` and * `archive:extracted` — things Officer's own file browser DID. Moving files into a folder emits * nothing at all, and neither would an rsync, a drop over SMB, or anything a person does in a terminal. * * That is not a bug in the bus: it reports actions, and this needs to know about a directory. Nine * files moved into a new folder produced no event, and the conversion sat waiting for somebody to * press a button they had no reason to think was needed. * * ── The debounce is long on purpose ── * * A move of nine files is dozens of events, an upload of two hundred is thousands, and a sync walks * the whole tree. Waiting for a stretch of quiet means one sync after the dust settles rather than one * per file — and because the sync skips everything already current, being slightly late costs nothing * while being eager costs a full walk per event. * * ── It cannot feed itself ── * * Only `originals/` is watched. The sync writes exclusively to `derived/`, so nothing it does can * trigger it again. That is worth stating because the opposite arrangement is a loop that looks like a * busy machine and nothing else. */ /** Long enough that a folder of files settles into one run, short enough to feel automatic. */ const SETTLE_MS = 20_000; let watcher: FSWatcher | null = null; let settle: ReturnType | null = null; async function syncIfIdle(email: string): Promise { // A run already going will pick up whatever landed — it walks the tree when it starts, and anything // that arrived after that is what the next quiet stretch is for. if (readSync(email).running) return; startSync(email, { trigger: 'watcher' }); } /** * Start, restart or stop the watch to match what is stored. * * The DIRECTORY is watched recursively rather than each file: files are the thing that appear and * disappear, so a watch on one is a watch on something that may not exist yet. */ export async function watchOriginals(): Promise { watcher?.close(); watcher = null; if (settle) clearTimeout(settle); settle = null; const row = (await db.select().from(fofosAdminSettings).limit(1))[0] ?? null; if (!row?.rootPath) return; /* Whose sync this is: the owner's. The media lives under one root regardless of who is looking, and every path this plugin resolves goes through `getOwnerHomeDir`, which returns the real login home on this installation whatever address it is handed. So the owner is not a choice here — it is the only account the files belong to, and asking the database is better than assuming an address. */ const owner = await getOwnerUser(); if (!owner?.email) return; const email = owner.email; const dir = await originalsDir(email); await mkdir(dir, { recursive: true }); try { watcher = watch(dir, { recursive: true }, () => { if (settle) clearTimeout(settle); settle = setTimeout(() => void syncIfIdle(email).catch(() => {}), SETTLE_MS); }); } catch { // No directory, no permission, or a platform without recursive watching. Left unwatched rather // than throwing: this runs at import, and the plugin failing to load over a missing folder would // take down the panel somebody would use to fix it. watcher = null; } }