Merge pull request #424 from the-luap/fix/external-thumbnails-423
fix(external-media): pre-generate thumbnails so reference-mode galleries load fast (#423)
This commit is contained in:
@@ -7,6 +7,7 @@ const { list, resolveExternalPath, getExternalMediaRoot } = require('../services
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const sharp = require('sharp');
|
||||
const logger = require('../utils/logger');
|
||||
const { generateThumbnail } = require('../services/imageProcessor');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -93,6 +94,8 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
let thumbnailsGenerated = 0;
|
||||
let thumbnailsFailed = 0;
|
||||
|
||||
// Insert photos
|
||||
for (const f of dedupeMap.values()) {
|
||||
@@ -137,6 +140,34 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
|
||||
})
|
||||
.returning('id');
|
||||
|
||||
const photoId = Array.isArray(inserted) && inserted.length
|
||||
? (typeof inserted[0] === 'object' ? inserted[0].id : inserted[0])
|
||||
: null;
|
||||
|
||||
// Generate the thumbnail right away so the gallery grid can use the
|
||||
// managed thumbnail endpoint instead of falling back to the full
|
||||
// NAS-streamed original (#423). Best-effort: a single failure logs
|
||||
// a warning and leaves thumbnail_path=null — the gallery's
|
||||
// ensureThumbnail will retry lazily on first view. The cost of
|
||||
// doing this synchronously is ~100-300ms per image; for the
|
||||
// worst-case 1000-photo import that's still under the 5-minute
|
||||
// request timeout typical of the import flow.
|
||||
if (photoId != null) {
|
||||
try {
|
||||
const outputBasename = `ext${photoId}_${path.basename(f.rel)}`;
|
||||
const thumbnailPath = await generateThumbnail(f.full, { outputBasename });
|
||||
if (thumbnailPath) {
|
||||
await db('photos').where({ id: photoId }).update({ thumbnail_path: thumbnailPath });
|
||||
thumbnailsGenerated++;
|
||||
} else {
|
||||
thumbnailsFailed++;
|
||||
}
|
||||
} catch (thumbErr) {
|
||||
thumbnailsFailed++;
|
||||
logger.warn(`Thumbnail generation failed for external photo ${photoId} (${f.rel}): ${thumbErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
imported += (inserted?.length ? 1 : 0);
|
||||
} catch (e) {
|
||||
skipped++;
|
||||
@@ -146,10 +177,14 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
|
||||
// Update event fields
|
||||
await db('events').where('id', eventId).update({ source_mode: 'reference', external_path });
|
||||
|
||||
// Queue thumbnail generation lazily by reading thumbnails via ensure endpoint as needed
|
||||
await logActivity('external_import_completed', { event_id: eventId, imported, skipped, external_path }, eventId, { type: 'admin' });
|
||||
await logActivity(
|
||||
'external_import_completed',
|
||||
{ event_id: eventId, imported, skipped, thumbnailsGenerated, thumbnailsFailed, external_path },
|
||||
eventId,
|
||||
{ type: 'admin' }
|
||||
);
|
||||
|
||||
res.json({ imported, skipped, thumbnailsQueued: 0 });
|
||||
res.json({ imported, skipped, thumbnailsGenerated, thumbnailsFailed });
|
||||
} catch (error) {
|
||||
logger.error('External media import failed', {
|
||||
eventId: req.params.id,
|
||||
|
||||
@@ -101,10 +101,17 @@ const contentTypeFor = (format) => {
|
||||
*
|
||||
* Callers must ensure the source is on the local filesystem. For S3 mode
|
||||
* regeneration flows, fetch via `withLocalCopy(storage, sourceKey, fn)` first.
|
||||
*
|
||||
* options.outputBasename — override the basename portion of the thumbnail
|
||||
* filename (default: basename of imagePath). Used for external/reference
|
||||
* photos where the source basename can collide across events (#423) — the
|
||||
* import path passes a per-photo unique basename so two events both
|
||||
* referencing `IMG_0001.jpg` don't clobber each other's thumbnail.
|
||||
*/
|
||||
async function generateThumbnail(imagePath, options = {}) {
|
||||
const filename = path.basename(imagePath);
|
||||
const thumbnailFilename = `thumb_${filename}`;
|
||||
const sourceBasename = path.basename(imagePath);
|
||||
const outputBasename = options.outputBasename || sourceBasename;
|
||||
const thumbnailFilename = `thumb_${outputBasename}`;
|
||||
const thumbnailRelKey = path.posix.join('thumbnails', thumbnailFilename);
|
||||
const storage = getStorage();
|
||||
|
||||
@@ -168,7 +175,7 @@ async function generateThumbnail(imagePath, options = {}) {
|
||||
return thumbnailRelKey;
|
||||
} catch (error) {
|
||||
const msg = (error && error.message) ? error.message : String(error);
|
||||
logger.error(`Failed to generate thumbnail for ${filename}: ${msg}`);
|
||||
logger.error(`Failed to generate thumbnail for ${sourceBasename}: ${msg}`);
|
||||
|
||||
// Clean up any partially uploaded object
|
||||
await storage.delete(thumbnailRelKey).catch(() => {});
|
||||
@@ -220,22 +227,25 @@ async function withLocalCopy(sourceKey, fn) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate thumbnail if it's broken or missing
|
||||
* Regenerate thumbnail if it's broken or missing.
|
||||
*
|
||||
* Works for both managed photos (stored via the storage backend, possibly
|
||||
* S3) and external/reference photos (#423 — sourced from a local mount
|
||||
* outside the managed storage tree, e.g. NAS over SMB/NFS). External
|
||||
* photos historically had thumbnail_path=null, which forced the gallery
|
||||
* to fall back to streaming the full original on every tile — minutes of
|
||||
* load time for a 100-photo NAS-mounted gallery.
|
||||
*/
|
||||
async function ensureThumbnail(photo) {
|
||||
const { resolvePhotoStorageKey } = require('./photoResolver');
|
||||
let sourceKey;
|
||||
try {
|
||||
const event = await db('events').where('id', photo.event_id).first();
|
||||
sourceKey = resolvePhotoStorageKey(event, photo);
|
||||
logger.info(`Ensuring thumbnail for photo ${photo.id} from key: ${sourceKey}`);
|
||||
} catch (e) {
|
||||
const msg = (e && e.message) ? e.message : String(e);
|
||||
logger.error(`Failed to resolve original key for thumbnail (photo ${photo.id}): ${msg}`);
|
||||
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
|
||||
|
||||
const event = await db('events').where('id', photo.event_id).first();
|
||||
if (!event) {
|
||||
logger.error(`ensureThumbnail: event ${photo.event_id} not found for photo ${photo.id}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if thumbnail exists and is valid
|
||||
// Check if thumbnail exists and is valid (works for any source).
|
||||
if (photo.thumbnail_path) {
|
||||
const isValid = await isThumbnailValid(photo.thumbnail_path);
|
||||
if (isValid) {
|
||||
@@ -244,10 +254,38 @@ async function ensureThumbnail(photo) {
|
||||
logger.warn(`Invalid thumbnail detected for photo ${photo.id}, regenerating...`);
|
||||
}
|
||||
|
||||
// Generate new thumbnail (sources via withLocalCopy so this works in S3 mode)
|
||||
const newThumbnailPath = await withLocalCopy(sourceKey, (localPath) =>
|
||||
generateThumbnail(localPath, { regenerate: true })
|
||||
);
|
||||
const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference';
|
||||
|
||||
let newThumbnailPath;
|
||||
if (isExternal) {
|
||||
// External: source is on a local mount path. No withLocalCopy needed
|
||||
// (storage-backend abstraction doesn't apply — this is a direct fs
|
||||
// read). Use a per-photo unique outputBasename so two events both
|
||||
// referencing the same NAS basename can't clobber each other's thumb.
|
||||
let localPath;
|
||||
try {
|
||||
localPath = resolvePhotoFilePath(event, photo);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to resolve external file for thumbnail (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 thumbnail for external photo ${photo.id} from ${localPath}`);
|
||||
newThumbnailPath = await generateThumbnail(localPath, { regenerate: true, outputBasename });
|
||||
} else {
|
||||
let sourceKey;
|
||||
try {
|
||||
sourceKey = resolvePhotoStorageKey(event, photo);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to resolve original key for thumbnail (photo ${photo.id}): ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
logger.info(`Ensuring thumbnail for photo ${photo.id} from key: ${sourceKey}`);
|
||||
newThumbnailPath = await withLocalCopy(sourceKey, (localPath) =>
|
||||
generateThumbnail(localPath, { regenerate: true })
|
||||
);
|
||||
}
|
||||
|
||||
if (newThumbnailPath) {
|
||||
await db('photos')
|
||||
|
||||
Reference in New Issue
Block a user