screentinker/server/lib/content-ingest.js
ScreenTinker 5b069b9665 Probe video asynchronously — the sweep would have blocked the loop per file
The backfill is right, and it lands on a path that could not carry it yet.

deriveMediaMetadata spawned ffprobe and ffmpeg with execFileSync, each with a
15s timeout. Synchronously, those two calls stop the whole server for their
duration: no heartbeats, no socket traffic, no HTTP. That was survivable while
the only caller was a human-initiated upload — one file, someone waiting on it,
bounded by their patience.

The boot-time sweep removes every one of those mitigations. It walks the entire
library, unattended, on a server with live panels, once per boot. A library of
video rows therefore becomes a per-file event-loop stall, which is #240's
failure mode — blocked loop, missed heartbeats, panels marked offline,
reconnect churn — arriving from our own maintenance instead of from a
checkpoint. We spent yesterday removing one of those; this would have added
another, on a schedule.

So both spawns are awaited instead of blocked on. Both callers already awaited
deriveMediaMetadata, so this is invisible to them, and the ingest path stops
freezing the server for the length of an upload's probe as a side benefit —
that sync ffprobe has been known tech debt for a while.

Timeouts are unchanged and still asserted: async is not a licence to hang, or
one wedged file stops the sweep dead instead of moving on.

Also applied the PR's own phantom-path discipline to the video branch, which
still named its thumbnail before the encode: a failed ffmpeg left the row
claiming a file that was never written, which is the exact bug the image branch
was fixed for two commits earlier.

The new test measures the property rather than grepping for it — a timer keeps
ticking across a real spawn — so a future edit that reintroduces a sync call
fails here rather than in a customer's fleet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-07 08:42:34 -05:00

127 lines
6.9 KiB
JavaScript

'use strict';
// #73: shared content-ingest core. Extracted from routes/content.js POST / so the agency
// upload (routes/agency.js) produces BYTE-IDENTICAL first-class content (same thumbnail/
// dimensions/duration/insert) - an agency asset is indistinguishable from a dashboard
// upload. routes/content.js POST / is now a thin caller; behavior is unchanged (its
// existing tests are the regression guard).
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const { db } = require('../db/database');
const config = require('../config');
const { sanitizeString } = require('../middleware/sanitize');
const { videoDisplayDims, imageDisplayDims } = require('./media-orientation');
const { finalizeUpload } = require('./upload-sniff');
// Multer takes file.originalname from the multipart header, bypassing sanitizeBody, so
// HTML-escape here (renders as text in every UI sink). .normalize('NFC') first: macOS
// sends NFD-decomposed names; Linux/renderers expect NFC. Single point - every filename
// storage site flows through here.
function safeFilename(name) {
return sanitizeString((name || '').normalize('NFC'));
}
/*
* Everything we can learn from the BYTES: thumbnail, display dimensions, duration.
*
* Extracted so PUT /api/content/:id/replace derives them the same way an upload does. It used
* to carry its own shorter copy that handled images only — so replacing a video wiped the row's
* duration, dimensions and thumbnail, and replacing a portrait photo re-introduced the EXIF
* orientation bug (#170) that the ingest path fixes with imageDisplayDims + .rotate(). A second
* copy of this logic is a second place for it to rot; there is now one.
*
* Best-effort by contract: a missing ffprobe or a sharp failure yields nulls and a warning, never
* a throw — the file itself is already stored and is worth more than its metadata.
*
* @returns {{width:number|null, height:number|null, durationSec:number|null, thumbnailPath:string|null}}
*/
async function deriveMediaMetadata(sourcePath, filepath, mime) {
let width = null, height = null, durationSec = null, thumbnailPath = null;
try {
// SVG is deliberately NOT handed to sharp: rasterising it goes through librsvg, which
// is where the outstanding libvips CVEs live, and an SVG is already its own thumbnail.
if (mime === 'image/svg+xml') {
thumbnailPath = filepath;
} else if (mime.startsWith('image/')) {
const sharp = require('sharp');
const metadata = await sharp(sourcePath).metadata();
// #170: honor EXIF orientation so a portrait photo isn't stored as landscape.
({ width, height } = imageDisplayDims(metadata));
// Assign thumbnailPath only AFTER the write succeeds: a sharp failure used to
// return the already-assigned name for a file that was never written, storing a
// phantom thumbnail_path that the UI then requests forever as a broken image.
const thumbName = `thumb_${filepath}`;
await sharp(sourcePath)
.rotate() // #170: auto-orient per EXIF (and strip the tag) so the thumbnail matches
.resize(config.thumbnailWidth)
.jpeg({ quality: 70 })
.toFile(path.join(config.contentDir, thumbName));
thumbnailPath = thumbName;
} else if (mime.startsWith('video/')) {
try {
// execFile, NOT execFileSync. These two spawns each carry a 15s timeout, and run
// synchronously they block the event loop for their whole duration — nothing else on
// the server runs, including heartbeats and socket traffic. That was survivable while
// the only caller was a human-initiated upload; it stopped being survivable the moment
// a boot-time sweep started walking a whole library of them unattended, which is the
// #240 failure mode exactly (blocked loop -> missed heartbeats -> panels marked
// offline -> reconnect churn) arriving from our own maintenance.
//
// deriveMediaMetadata is already async and both callers already await it, so awaiting
// the subprocess instead of blocking on it is invisible to them.
const { execFile } = require('child_process');
const { promisify } = require('util');
const execFileAsync = promisify(execFile);
const { stdout: probe } = await execFileAsync('ffprobe',
['-v', 'quiet', '-print_format', 'json', '-show_format', '-show_streams', sourcePath],
{ timeout: 15000 }
);
const info = JSON.parse(probe);
if (info.format?.duration) durationSec = parseFloat(info.format.duration);
const videoStream = info.streams?.find(s => s.codec_type === 'video');
if (videoStream) {
// #170: honor the rotation/Display-Matrix so a portrait video isn't stored landscape.
// (ffmpeg auto-rotates the thumbnail below by default, so only the dims need fixing.)
({ width, height } = videoDisplayDims(videoStream));
}
// Same phantom-path discipline as the image branch above: name it only once the
// file exists, so a failed encode cannot leave the row claiming a thumbnail.
const thumbName = `thumb_${filepath.replace(/\.[^.]+$/, '.jpg')}`;
try {
await execFileAsync('ffmpeg',
['-y', '-i', sourcePath, '-ss', '2', '-vframes', '1', '-vf', `scale=${config.thumbnailWidth}:-1`, path.join(config.contentDir, thumbName)],
{ timeout: 15000 }
);
thumbnailPath = thumbName;
} catch { thumbnailPath = null; }
} catch (e) {
console.warn('ffprobe failed:', e.message);
}
}
} catch (e) {
console.warn('Thumbnail/metadata generation failed:', e.message);
}
return { width, height, durationSec, thumbnailPath };
}
// Process a multer-uploaded file (thumbnail + dimensions + duration) and insert a content
// row. Returns the content row. Throws on a hard failure (the caller maps to 500);
// thumbnail/metadata failures are best-effort (logged, non-fatal) exactly as before.
async function ingestUploadedFile({ file, userId, workspaceId, folderId = null }) {
const id = uuidv4();
// Content-derived extension + mime. Throws UnsupportedUploadError (and removes the temp
// file) when the bytes are not a supported media type; the caller maps that to a 400.
const { filepath, mime } = finalizeUpload(file);
const { width, height, durationSec, thumbnailPath } = await deriveMediaMetadata(file.path, filepath, mime);
db.prepare(`
INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, duration_sec, thumbnail_path, width, height, folder_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, userId, workspaceId, safeFilename(file.originalname), filepath, mime, file.size, durationSec, thumbnailPath, width, height, folderId || null);
return db.prepare('SELECT * FROM content WHERE id = ?').get(id);
}
module.exports = { ingestUploadedFile, safeFilename, deriveMediaMetadata };