Files
picpeak/backend/scripts/regenerate-thumbnails.js
T
Paul Nothaft b581267031 fix(scripts): regenerate-thumbnails resolves external sources through ensureThumbnail (#1148) (#1151)
The CLI fallback carried the defect #1129 fixed in the admin route: it computed `storage/events/active/<photo.path>` and fs.access'd it, a location that does not exist for external or reference rows. Every one failed the check and was counted as an error, so on an external-media install the script was inert while reporting one error per photo.

Resolution now goes through ensureThumbnail, which already branches on source_origin and owns the per-photo ext<id>_ output name — sharing it is what stops the script and the route drifting apart again.

Also: videos skipped on every marker they can carry (fileWatcher writes type and mime_type but never media_type), responsive tiers backfilled alongside the canonical rendition, skip-vs-generate asked from isThumbnailValid rather than inferred from an unchanged path, tier failures counted rather than swallowed, and a nonzero exit when the backfill was incomplete.

The script is now importable with the CLI behind a require.main guard; it previously ran on require and called process.exit, so it could not be tested at all — which is why this survived #1129.

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:06:11 +02:00

197 lines
7.8 KiB
JavaScript

#!/usr/bin/env node
/**
* Fill in missing thumbnails for photos already in the database.
*
* The CLI fallback for when the admin UI is not reachable. It is deliberately
* "missing only": ensureThumbnail short-circuits on a thumbnail that is
* already present and valid, so re-running this is cheap and safe. To REBUILD
* everything after a settings change, use POST /api/admin/thumbnails/regenerate
* — that path drops the existing renditions first, which this one must not do.
*
* Resolution goes through ensureThumbnail rather than a hand-built path
* (#1148, same defect as #1129). This script used to compute
* `storage/events/active/<photo.path>` and fs.access it, a location that does
* not exist for `external` or `reference` rows — their originals live under
* the mount in events.external_path. Every such photo failed the check and was
* counted as an error, so on an external-media install the script was inert
* while reporting one error per photo.
*
* ensureThumbnail already branches on source_origin, resolves both kinds via
* photoResolver, uses the per-photo `ext<id>_` output name so two events
* referencing one NAS basename cannot clobber each other, and writes
* thumbnail_path back itself. Sharing it is what stops the script and the
* route drifting apart again.
*
* Usage:
* node scripts/regenerate-thumbnails.js [eventId] [--no-tiers]
*/
const { db } = require('../src/database/db');
const {
ensureThumbnail,
ensureThumbnailAtWidth,
isThumbnailValid,
THUMBNAIL_WIDTHS,
} = require('../src/services/imageProcessor');
async function regenerateThumbnails(eventId = null, { tiers = true } = {}) {
console.log('Starting thumbnail regeneration...');
// These columns are what ensureThumbnail and ensureThumbnailAtWidth branch
// on to resolve a source and name their output. Selecting a subset that
// misses source_origin/external_relpath is how the old path bug would come
// back — an external row would look managed and resolve under events/active.
let query = db('photos').select(
'id', 'event_id', 'path', 'filename', 'thumbnail_path',
'media_type', 'mime_type', 'source_origin', 'external_relpath'
);
if (eventId) {
query = query.where('event_id', eventId);
console.log(`Filtering for event ID: ${eventId}`);
}
// Skip videos. A video's thumbnail is a poster frame produced by
// videoProcessor, not a resize of the stored file, so handing the container
// to Sharp here only ever produced one error per row.
//
// Tested on every marker a video row can carry, not media_type alone:
// fileWatcher.processNewPhoto writes `type` and `mime_type` but never
// media_type, which defaults to 'image' — so an auto-imported video passes a
// media_type-only filter. Each clause is null-safe on its own so a row that
// simply has no mime_type is not swept up with them.
query = query
.where(function () {
this.whereNull('media_type').orWhere('media_type', '!=', 'video');
})
.where(function () {
this.whereNull('type').orWhere('type', '!=', 'video');
})
.where(function () {
this.whereNull('mime_type').orWhereNot('mime_type', 'like', 'video/%');
});
const photos = await query;
console.log(`Found ${photos.length} photos to process`);
let successCount = 0;
let skipCount = 0;
let errorCount = 0;
let tierCount = 0;
let tierFailures = 0;
for (const photo of photos) {
const label = photo.filename || `photo ${photo.id}`;
try {
const existing = photo.thumbnail_path;
// Asked BEFORE the call, not inferred from the returned path afterwards.
// On local and external storage the key is deterministic, so repairing a
// missing or corrupt thumbnail hands back the identical string — and
// comparing paths would report that repair as "already valid", which is
// the one number an operator running this is actually reading.
const wasValid = existing ? await isThumbnailValid(existing) : false;
const thumbnailPath = await ensureThumbnail(photo);
if (!thumbnailPath) {
console.error(`✗ Could not generate thumbnail for ${label}`);
errorCount++;
continue;
}
if (wasValid && thumbnailPath === existing) {
skipCount++;
} else {
successCount++;
console.log(`✓ Generated thumbnail for ${label}`);
}
// The responsive tiers (#1095/#1109) are cached separately from
// thumbnail_path, so a gallery can have every canonical rendition and
// still serve phones the full-size image. Backfilling them is the most
// likely reason to reach for this script at all, so it is the default.
// Each call is a no-op when the tier is already stored.
if (tiers) {
for (const width of THUMBNAIL_WIDTHS) {
// Two ways this fails and both have to be reported. It THROWS on an
// unexpected error, and it RETURNS NULL on the expected ones it
// handles itself — an unreachable mount, a storage operation that
// did not land. Ignoring the null said "complete" after backfilling
// nothing, which is worse than the error it was hiding.
let built = null;
try {
built = await ensureThumbnailAtWidth({ ...photo, thumbnail_path: thumbnailPath }, width);
} catch (error) {
console.warn(` ! tier ${width}px failed for ${label}: ${error.message}`);
}
// One missing tier is not a failed photo — the canonical rendition
// above is what the gallery falls back to — so this is counted
// separately rather than as an error against the photo.
if (built) tierCount++;
else {
tierFailures++;
console.warn(` ! tier ${width}px not built for ${label}`);
}
}
}
} catch (error) {
console.error(`✗ Failed for ${label}: ${error.message}`);
errorCount++;
}
}
console.log('\nThumbnail regeneration complete!');
console.log(`- Generated: ${successCount}`);
console.log(`- Skipped (already valid): ${skipCount}`);
console.log(`- Errors: ${errorCount}`);
if (tiers) {
console.log(`- Responsive tiers present: ${tierCount}`);
if (tierFailures) console.log(`- Responsive tiers NOT built: ${tierFailures}`);
}
console.log(`- Total processed: ${photos.length}`);
return { successCount, skipCount, errorCount, tierCount, tierFailures };
}
if (require.main === module) {
const args = process.argv.slice(2);
const tiers = !args.includes('--no-tiers');
const eventArg = args.find((a) => !a.startsWith('--'));
const eventId = eventArg ? parseInt(eventArg, 10) : null;
if (eventArg && !Number.isInteger(eventId)) {
console.error(`Not an event id: ${eventArg}`);
process.exit(1);
}
regenerateThumbnails(eventId, { tiers })
.then(async (result) => {
await db.destroy();
// Exit status is the only thing a cron job reads. Resolving with a
// nonzero errorCount and still exiting 0 told automation the backfill
// was done when it had failed — which is how an unavailable mount stays
// unnoticed until someone opens a gallery.
//
// tierFailures counts too: a run that was asked for tiers and could not
// build them is incomplete, even though the canonical renditions are
// intact and the gallery still works.
if (result.errorCount || result.tierFailures) {
console.error(
`Script completed with failures: ${result.errorCount} photo(s), `
+ `${result.tierFailures} tier(s)`
);
process.exit(1);
}
console.log('Script completed successfully');
process.exit(0);
})
.catch(async (error) => {
console.error('Script failed:', error);
await db.destroy().catch(() => {});
process.exit(1);
});
}
module.exports = { regenerateThumbnails };