Files
fofos-admin/web/MediaSync.tsx
T
pastilhasandClaude Opus 5 01652f3480 a moved file relocates its ladder instead of rebuilding it, and orphans go
Moving a file into a folder made the sync look at a new path, find nothing, and
convert it again — minutes of work to produce bytes that already existed a
directory away — while the old folder stayed behind forever, because nothing ever
deleted one.

A move preserves size and mtime, and both are already in the manifest and in the
stat. So the sync indexes what is already derived on that pair, and a file that
has moved has its ladder RENAMED across.

An ambiguous match is refused rather than guessed. Two originals can share a size
and an mtime — a camera dump can write a whole card in one second — and
rebuilding is merely slow where attaching the wrong photographs to a name is
wrong.

Orphans are removed afterwards, and only on a WHOLE-TREE sync. A scoped run knows
about one folder, so everything outside it looks abandoned; deleting on that
basis would take out the library the first time somebody re-synced a subfolder.
Safe otherwise because derived/ is reproducible by definition, which is what its
name has meant since these two folders stopped being called `user` and `treated`.

Both are counted, recorded in the ledger and shown only when they happened, so an
ordinary run stays one short line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:42:20 +01:00

275 lines
11 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react';
import { CircleAlert, Loader2, RefreshCw } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { readableError } from './errors';
/**
* Building `derived/` from `originals/`, shown where those two folders are.
*
* ── Why it is not its own section ──
*
* It was, briefly. But a section called "Derived media" sitting beside the block that lists
* `originals/` and `derived/` is the same subject described twice, and the reader has to work out that
* the two are about one thing. The action belongs to the folders it acts on, so it lives under them.
*
* ── It only appears once there is somewhere to put the output ──
*
* No `media/`, or a partial one, means the button would fail on a missing directory. The Create button
* above it is the thing to press first, and offering both at once is offering a choice where there is
* an order.
*
* ── It polls, and only while something is running ──
*
* A full pass takes minutes, so the request that starts it returns immediately and this asks how it is
* going. The interval is torn down when the answer says it has stopped, so an idle panel makes no
* requests — which is what keeps a screen somebody left open from being a request a second all
* afternoon.
*
* ── Built and skipped are counted separately ──
*
* Re-running is normal and cheap: everything already correct is skipped, because each derived folder
* carries the size and mtime it was built from. "412 already current" is the system saying it had
* nothing to do, which is a different and more useful answer than "412 done".
*
* ── Failures stay ──
*
* One bad file in four hundred is exactly what a progress bar hides, and it is the only part of this
* anybody has to act on.
*/
type SyncError = { file: string; message: string };
type SyncState = {
running: boolean;
total: number | null;
done: number;
built: number;
skipped: number;
/** Ladders renamed into place because their original moved, rather than rebuilt. */
moved: number;
/** Derived folders removed because their original is gone. Whole-tree syncs only. */
pruned: number;
errors: SyncError[];
current: string[];
startedAt: string | null;
finishedAt: string | null;
fatal: string | null;
};
type Run = {
startedAt: string;
finishedAt: string;
trigger: 'manual' | 'watcher';
scope: string;
total: number;
built: number;
skipped: number;
moved: number;
pruned: number;
errors: SyncError[];
fatal: string | null;
};
type Reconciliation = { originals: number; current: number; missing: string[]; stale: string[] };
type Ledger = { runs: Run[]; reconciliation: Reconciliation };
/** How often to ask, while something is happening. Fast enough to look live, slow enough to be free. */
const POLL_MS = 1000;
/** Recent runs worth showing. Enough to see a pattern, few enough to stay a footnote. */
const SHOWN_RUNS = 5;
const clock = (iso: string) =>
new Date(iso).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
const day = (iso: string) => new Date(iso).toLocaleDateString(undefined, { day: 'numeric', month: 'short' });
/** `1m 12s`, or `8s`. Minutes matter here and hours have never happened. */
const took = (from: string, to: string) => {
const seconds = Math.max(0, Math.round((Date.parse(to) - Date.parse(from)) / 1000));
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
};
export const MediaSync = ({ onSettled }: { onSettled?: (settled: boolean) => void }) => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [state, setState] = useState<SyncState | null>(null);
const [ledger, setLedger] = useState<Ledger | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setState(await clientRef.current.get<SyncState>('/fofos-admin/_config/media/sync'));
} catch {
// Owner-only. Root Location above has already said so once if that is the reason.
setState(null);
}
}, []);
/*
The ledger is NOT polled.
It walks the whole tree reading manifests, so asking for it once a second beside a progress bar
would have every tick re-read the library. It is read on arrival and again when a run stops, which
are the only two moments its answer can have changed.
*/
const loadLedger = useCallback(async () => {
try {
setLedger(await clientRef.current.get<Ledger>('/fofos-admin/_config/media/ledger'));
} catch {
setLedger(null);
}
}, []);
useEffect(() => {
void load();
void loadLedger();
}, [load, loadLedger]);
useEffect(() => {
if (!state?.running) return;
const timer = setInterval(() => void load(), POLL_MS);
return () => clearInterval(timer);
}, [state?.running, load]);
// Re-read when a run STOPS. `running` going true→false is the edge, so a finished sync updates what
// is left to do without the panel being reopened.
const wasRunning = useRef(false);
useEffect(() => {
const running = state?.running ?? false;
if (wasRunning.current && !running) void loadLedger();
wasRunning.current = running;
}, [state?.running, loadLedger]);
const start = async () => {
setBusy(true);
setError(null);
try {
const result = await clientRef.current.post<{ started: boolean; state: SyncState }>(
'/fofos-admin/_config/media/sync',
{},
);
setState(result.state);
} catch (caught) {
setError(readableError(caught));
} finally {
setBusy(false);
}
};
const running = state?.running ?? false;
const total = state?.total ?? null;
const done = state?.done ?? 0;
// Null total means the walk has not finished, so there is nothing honest to divide by yet.
const percent = total && total > 0 ? Math.round((done / total) * 100) : null;
const reconciliation = ledger?.reconciliation ?? null;
const outstanding = (reconciliation?.missing.length ?? 0) + (reconciliation?.stale.length ?? 0);
/*
One bit, upward: is there anything left to do?
Reported rather than lifted, because everything else about this — the polling, the run history, the
errors — belongs here and would only be harder to read in a parent. What the parent needs is the
single fact it cannot compute: whether this part of the setup is finished.
*/
useEffect(() => {
if (reconciliation) onSettled?.(outstanding === 0);
}, [reconciliation, outstanding, onSettled]);
return (
<div className="flex flex-col gap-2 pt-1">
<div className="flex items-center justify-between gap-3">
{/* Not what the last run did — what is TRUE NOW. A run's counts age the moment somebody uploads
a file, and "62 built" three days ago says nothing about whether derived/ is current. This is
recomputed from the filesystem, using the same freshness test the sync itself uses. */}
<p className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{running
? total === null
? 'Reading originals…'
: `${done} of ${total}${state?.current.length ? ` · ${state.current.join(', ')}` : ''}`
: reconciliation
? outstanding === 0
? `${reconciliation.current} of ${reconciliation.originals} current`
: [
reconciliation.missing.length ? `${reconciliation.missing.length} missing` : '',
reconciliation.stale.length ? `${reconciliation.stale.length} changed` : '',
]
.filter(Boolean)
.join(' · ')
: 'Web-ready copies of everything in originals.'}
</p>
<Button variant="outline" size="sm" className="shrink-0" onClick={() => void start()} disabled={busy || running}>
{running ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
{running ? `Syncing${percent === null ? '' : ` ${percent}%`}` : outstanding > 0 ? `Sync ${outstanding}` : 'Sync now'}
</Button>
</div>
{running && (
<div className="h-1 w-full overflow-hidden rounded-full bg-muted">
{/* Indeterminate until the walk has counted: a bar that starts full and shrinks is worse than
one that has not started. */}
<div
className="h-full rounded-full bg-primary transition-[width] duration-500"
style={{ width: `${percent ?? 0}%` }}
/>
</div>
)}
{state?.fatal && (
<p className="flex items-start gap-1.5 text-xs text-destructive">
<CircleAlert className="mt-px size-3.5 shrink-0" />
{state.fatal}
</p>
)}
{state && state.errors.length > 0 && (
<ul className="max-h-32 space-y-1 overflow-auto rounded border border-border/60 p-2">
{state.errors.map((failure) => (
<li key={failure.file} className="text-xs">
<span className="font-medium">{failure.file}</span>
<span className="text-muted-foreground"> — {failure.message}</span>
</li>
))}
</ul>
)}
{error && <p className="text-xs text-destructive">{error}</p>}
{/*
The ledger. Survives a restart, which the live state above does not — and it is the only place a
failure from a run nobody watched is ever recorded, since a file that did not build leaves no
folder and no manifest behind it.
*/}
{ledger && ledger.runs.length > 0 && (
<ul className="space-y-0.5 pt-1">
{ledger.runs.slice(0, SHOWN_RUNS).map((run) => (
<li key={run.finishedAt} className="flex items-baseline gap-2 text-xs text-muted-foreground/70">
<span className="shrink-0 font-mono">
{day(run.finishedAt)} {clock(run.finishedAt)}
</span>
<span className="min-w-0 flex-1 truncate">
{run.scope ? `${run.scope} · ` : ''}
{run.built} built
{run.skipped ? `, ${run.skipped} current` : ''}
{/* Only when they happened, so an ordinary run stays one short line. */}
{run.moved ? `, ${run.moved} moved` : ''}
{run.pruned ? `, ${run.pruned} orphans removed` : ''}
{run.errors.length ? `, ${run.errors.length} failed` : ''}
{run.fatal ? ` — ${run.fatal}` : ''}
{run.trigger === 'watcher' ? ' · on upload' : ''}
</span>
<span className="shrink-0 font-mono">{took(run.startedAt, run.finishedAt)}</span>
</li>
))}
</ul>
)}
</div>
);
};