screentinker/server/lib/media-tools.js
Rob K bfe8a4c907 Backfill missing thumbnails at boot, and say when ffmpeg is absent
Ingest-time thumbnail generation is best-effort by contract, so a row that
misses it stays bare forever: video uploads on a host without ffmpeg (a
SYSTEM dependency nothing surfaced), or content from before thumbnails
existed. Operators read that as "thumbnails don't work".

Two additions. A [MEDIA] startup diagnostic (async probe, cached) states
loudly whether ffmpeg/ffprobe were found, mirroring the [EMAIL] block. And
a once-per-boot sweep re-derives metadata for local image/video rows with
no thumbnail — serial, paced, delayed past boot, unref'd. The sweep's row
UPDATE re-checks that thumbnail_path is still empty so it never clobbers a
thumbnail written concurrently by the replace flow, removes its just-written
file when the row vanished mid-derive, salvages probed dims/duration even
when the thumbnail itself failed, and stops after 25 failures per boot so a
library of undecodable clips can't turn every restart into subprocess churn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131RYmVh8ePEhparD3mXBhU
2026-08-07 09:30:32 +01:00

33 lines
1.1 KiB
JavaScript

'use strict';
// Availability probe for the external media binaries (ffmpeg/ffprobe) that video
// thumbnail + duration extraction depends on. They are SYSTEM dependencies, not npm
// ones, so a deployment can easily lack them — and content-ingest's best-effort
// contract means every video then uploads fine but silently gets no thumbnail and
// no duration. Probed once and cached: the answer can't change without an operator
// installing packages, which comes with a restart anyway.
//
// Async on purpose: the first caller is server.js right after listen, and a hung
// binary (NFS-mounted PATH shim, broken wrapper) must degrade to a late log line,
// not block request serving on a freshly-bound port.
const { execFile } = require('child_process');
let cached = null;
function probeTool(bin) {
return new Promise((resolve) => {
execFile(bin, ['-version'], { timeout: 5000 }, (err) => resolve(!err));
});
}
function mediaToolStatus() {
if (!cached) {
cached = Promise.all([probeTool('ffmpeg'), probeTool('ffprobe')])
.then(([ffmpeg, ffprobe]) => ({ ffmpeg, ffprobe }));
}
return cached;
}
module.exports = { mediaToolStatus };