mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
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
109 lines
5.2 KiB
JavaScript
109 lines
5.2 KiB
JavaScript
'use strict';
|
|
|
|
// Retroactive thumbnail generation. Ingest-time generation (lib/content-ingest) is
|
|
// best-effort by contract, so a row silently ends up without a thumbnail whenever it
|
|
// fails — most commonly video uploads on a host without ffmpeg installed, plus any
|
|
// content from before thumbnails existed. Those rows previously stayed bare forever:
|
|
// nothing ever looked at them again.
|
|
//
|
|
// This sweep runs once per boot (kicked off from server.js shortly after listen),
|
|
// finds local image/video rows with no thumbnail, and re-derives metadata for each.
|
|
// One file at a time with a pause between files: the point is to heal the library
|
|
// eventually, not to win a race against playback serving on the same box.
|
|
//
|
|
// Idempotent by construction — a generated thumbnail fills thumbnail_path, which
|
|
// removes the row from the next boot's query. Video rows are skipped wholesale when
|
|
// ffmpeg/ffprobe are missing (the [MEDIA] startup diagnostic already told the
|
|
// operator) rather than paying a doomed ffmpeg spawn per file per boot.
|
|
//
|
|
// The sweep can take a long time on a large bare library, and the replace/delete
|
|
// flows may touch the same rows meanwhile. Two consequences handled below:
|
|
// - the row UPDATE re-checks that thumbnail_path is STILL empty, so a thumbnail
|
|
// written concurrently by PUT /:id/replace is never clobbered with a frame of
|
|
// the pre-replace bytes;
|
|
// - a row that vanished (or was replaced) mid-derive gets its just-written thumb
|
|
// file removed again — contentDir has no garbage collector.
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { db } = require('../db/database');
|
|
const config = require('../config');
|
|
const { deriveMediaMetadata } = require('./content-ingest');
|
|
const { mediaToolStatus } = require('./media-tools');
|
|
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
// Undecodable files fail again every boot (thumbnail_path is the only idempotency
|
|
// marker — deliberately, so installing ffmpeg heals them). A video can burn two 15s
|
|
// subprocess timeouts, so a library with hundreds of corrupt clips must not turn
|
|
// every boot into an hour of doomed ffmpeg spawns: stop after this many failures
|
|
// and let the next boot take another bite.
|
|
const FAILURE_CAP = 25;
|
|
|
|
async function backfillMissingThumbnails({ delayMs = 500 } = {}) {
|
|
const tools = await mediaToolStatus();
|
|
// filepath != '' and no remote_url: only content whose bytes live in contentDir.
|
|
// Remote/YouTube/embed rows either carry a remote thumbnail URL already or have
|
|
// nothing local to derive one from.
|
|
const rows = db.prepare(`
|
|
SELECT id, filepath, mime_type FROM content
|
|
WHERE (thumbnail_path IS NULL OR thumbnail_path = '')
|
|
AND filepath != ''
|
|
AND (remote_url IS NULL OR remote_url = '')
|
|
AND (mime_type LIKE 'image/%' OR mime_type LIKE 'video/%')
|
|
`).all();
|
|
|
|
const stats = { scanned: rows.length, generated: 0, skipped: 0, failed: 0, aborted: false };
|
|
const updateStmt = db.prepare(`
|
|
UPDATE content SET thumbnail_path = ?,
|
|
width = COALESCE(width, ?), height = COALESCE(height, ?),
|
|
duration_sec = COALESCE(duration_sec, ?)
|
|
WHERE id = ? AND (thumbnail_path IS NULL OR thumbnail_path = '')
|
|
`);
|
|
// Thumbnail failed but the probe worked: keep the dims/duration (item-duration and
|
|
// orientation handling consume them) without marking the row healed — it stays
|
|
// eligible for a thumbnail retry next boot.
|
|
const metadataStmt = db.prepare(`
|
|
UPDATE content SET width = COALESCE(width, ?), height = COALESCE(height, ?),
|
|
duration_sec = COALESCE(duration_sec, ?)
|
|
WHERE id = ?
|
|
`);
|
|
|
|
for (const row of rows) {
|
|
if (stats.failed >= FAILURE_CAP) {
|
|
stats.aborted = true;
|
|
console.warn(`[MEDIA] thumbnail backfill: stopping after ${stats.failed} failures — will retry remaining rows next boot`);
|
|
break;
|
|
}
|
|
const isVideo = row.mime_type.startsWith('video/');
|
|
if (isVideo && (!tools.ffmpeg || !tools.ffprobe)) { stats.skipped++; continue; }
|
|
const storedName = path.basename(row.filepath);
|
|
const sourcePath = path.join(config.contentDir, storedName);
|
|
if (!fs.existsSync(sourcePath)) { stats.skipped++; continue; }
|
|
try {
|
|
const { width, height, durationSec, thumbnailPath } =
|
|
await deriveMediaMetadata(sourcePath, storedName, row.mime_type);
|
|
if (thumbnailPath) {
|
|
const res = updateStmt.run(thumbnailPath, width, height, durationSec, row.id);
|
|
if (res.changes === 0 && thumbnailPath !== storedName) {
|
|
// Row deleted/replaced while we were deriving. Don't leave the freshly
|
|
// written file orphaned. (thumbnailPath === storedName is the SVG
|
|
// self-thumbnail case — that file IS the content, never remove it.)
|
|
try { fs.unlinkSync(path.join(config.contentDir, path.basename(thumbnailPath))); } catch { /* best-effort */ }
|
|
}
|
|
stats.generated += res.changes;
|
|
} else {
|
|
if (width || height || durationSec) metadataStmt.run(width, height, durationSec, row.id);
|
|
stats.failed++; // deriveMediaMetadata already warned with the reason
|
|
}
|
|
} catch (e) {
|
|
stats.failed++;
|
|
console.warn(`Thumbnail backfill failed for ${row.id}: ${e.message}`);
|
|
}
|
|
await sleep(delayMs);
|
|
}
|
|
return stats;
|
|
}
|
|
|
|
module.exports = { backfillMissingThumbnails };
|