* fix(preview): generate lightbox previews for external/reference photos (#1078) ensurePreviewImage() resolved its source only via resolvePhotoStorageKey(), which returns null for external/reference photos by design — those live on a media mount outside the managed storage tree. The null went straight into withLocalCopy(), which throws ("LocalFsStorage: invalid relative path: null"), so the preview route fell back to redirecting at the full-size original. A gallery whose photos are all external got no benefit from the preview tier (#492) at all: guests paid 5-12 MB on every lightbox open, with nothing surfaced in the admin UI. Add the external branch ensureThumbnail() has had since #423: resolve via resolvePhotoFilePath() and feed the mount path to generatePreviewImage() directly, with an ext<id>_ output basename so two events referencing the same NAS filename can't clobber each other's preview. Also close the adjacent hole that made the failure a throw rather than the documented null: a row with no source_origin in a reference-mode event takes its mode from the event, so resolvePhotoStorageKey returns null for it too. Return null instead of handing that to withLocalCopy. Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc * fix(preview): select the columns the external branch needs on bulk regenerate POST /api/admin/thumbnails/regenerate-previews selected only id, event_id, path, media_type, mime_type and preview_path, so photo.source_origin was undefined by the time ensurePreviewImage branched on it. Every external row in a reference gallery took the managed path, resolvePhotoStorageKey returned null for it, and the endpoint reported success while generating nothing. Add source_origin, external_relpath and filename to the select, plus a source-inspection test pinning the caller contract and a service-level test showing a column-starved row is indistinguishable from a managed one. Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc * style(test): single-quote the source-inspection needles Matches the repo eslint quotes rule (no avoidEscape) by dropping the nested quotes from the search strings rather than escaping them. Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
98fe9c7699
commit
af7970b069
@@ -658,17 +658,27 @@ async function isPreviewValid(previewPath) {
|
||||
* Lazy-generate the preview image for a photo if missing or invalid.
|
||||
* Returns the storage key or null on failure (callers fall back to
|
||||
* the original URL so the lightbox never shows a broken image).
|
||||
*
|
||||
* Handles both managed photos (via the storage backend, possibly S3) and
|
||||
* external/reference photos (#1078 — sourced from a local mount outside the
|
||||
* managed storage tree). Externals used to have no branch here at all:
|
||||
* resolvePhotoStorageKey returns null for them by design, that null reached
|
||||
* withLocalCopy, and the throw put every lightbox open back on the full-size
|
||||
* original — the exact cost the preview tier (#492) exists to avoid.
|
||||
*/
|
||||
async function ensurePreviewImage(photo) {
|
||||
const { resolvePhotoStorageKey } = require('./photoResolver');
|
||||
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
|
||||
|
||||
let sourceKey;
|
||||
let event;
|
||||
try {
|
||||
const event = await db('events').where('id', photo.event_id).first();
|
||||
sourceKey = resolvePhotoStorageKey(event, photo);
|
||||
event = await db('events').where('id', photo.event_id).first();
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) ? e.message : String(e);
|
||||
logger.error(`Failed to resolve original key for preview (photo ${photo.id}): ${msg}`);
|
||||
logger.error(`Failed to load event for preview (photo ${photo.id}): ${msg}`);
|
||||
return null;
|
||||
}
|
||||
if (!event) {
|
||||
logger.error(`ensurePreviewImage: event ${photo.event_id} not found for photo ${photo.id}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -678,14 +688,51 @@ async function ensurePreviewImage(photo) {
|
||||
logger.warn(`Invalid preview detected for photo ${photo.id}, regenerating…`);
|
||||
}
|
||||
|
||||
const newPreviewPath = await withLocalCopy(sourceKey, async (localPath) => {
|
||||
const proc = await withProcessableImage(localPath, sourceKey);
|
||||
const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference';
|
||||
|
||||
let newPreviewPath;
|
||||
if (isExternal) {
|
||||
// Mirrors ensureThumbnail's external branch: the source is a direct fs
|
||||
// read off the mount, so no withLocalCopy. The per-photo outputBasename
|
||||
// keeps two events that reference the same NAS basename from clobbering
|
||||
// each other's preview.
|
||||
let localPath;
|
||||
try {
|
||||
return await generatePreviewImage(proc.path, { regenerate: true, outputBasename: proc.outputBasename });
|
||||
} finally {
|
||||
await proc.cleanup();
|
||||
localPath = resolvePhotoFilePath(event, photo);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to resolve external file for preview (photo ${photo.id}): ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const sourceBasename = path.basename(photo.external_relpath || photo.filename || `photo-${photo.id}`);
|
||||
const outputBasename = `ext${photo.id}_${sourceBasename}`;
|
||||
logger.info(`Ensuring preview for external photo ${photo.id} from ${localPath}`);
|
||||
newPreviewPath = await generatePreviewImage(localPath, { regenerate: true, outputBasename });
|
||||
} else {
|
||||
let sourceKey;
|
||||
try {
|
||||
sourceKey = resolvePhotoStorageKey(event, photo);
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) ? e.message : String(e);
|
||||
logger.error(`Failed to resolve original key for preview (photo ${photo.id}): ${msg}`);
|
||||
return null;
|
||||
}
|
||||
if (!sourceKey) {
|
||||
// Reference-mode event holding a row with no source_origin: the mode
|
||||
// falls back to the event's and resolvePhotoStorageKey returns null.
|
||||
// Honour the documented null-on-failure contract instead of feeding
|
||||
// null into withLocalCopy, which throws out of this function.
|
||||
logger.warn(`No managed storage key for preview (photo ${photo.id}); skipping preview generation`);
|
||||
return null;
|
||||
}
|
||||
newPreviewPath = await withLocalCopy(sourceKey, async (localPath) => {
|
||||
const proc = await withProcessableImage(localPath, sourceKey);
|
||||
try {
|
||||
return await generatePreviewImage(proc.path, { regenerate: true, outputBasename: proc.outputBasename });
|
||||
} finally {
|
||||
await proc.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (newPreviewPath) {
|
||||
await db('photos').where({ id: photo.id }).update({ preview_path: newPreviewPath });
|
||||
|
||||
Reference in New Issue
Block a user