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 { db, logActivity } = require('../database/db');
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
const { generateThumbnail } = require('../services/imageProcessor');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -93,6 +94,8 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
|
|||||||
}
|
}
|
||||||
|
|
||||||
let imported = 0;
|
let imported = 0;
|
||||||
|
let thumbnailsGenerated = 0;
|
||||||
|
let thumbnailsFailed = 0;
|
||||||
|
|
||||||
// Insert photos
|
// Insert photos
|
||||||
for (const f of dedupeMap.values()) {
|
for (const f of dedupeMap.values()) {
|
||||||
@@ -137,6 +140,34 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
|
|||||||
})
|
})
|
||||||
.returning('id');
|
.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);
|
imported += (inserted?.length ? 1 : 0);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
skipped++;
|
skipped++;
|
||||||
@@ -146,10 +177,14 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
|
|||||||
// Update event fields
|
// Update event fields
|
||||||
await db('events').where('id', eventId).update({ source_mode: 'reference', external_path });
|
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(
|
||||||
await logActivity('external_import_completed', { event_id: eventId, imported, skipped, external_path }, eventId, { type: 'admin' });
|
'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) {
|
} catch (error) {
|
||||||
logger.error('External media import failed', {
|
logger.error('External media import failed', {
|
||||||
eventId: req.params.id,
|
eventId: req.params.id,
|
||||||
|
|||||||
@@ -101,10 +101,17 @@ const contentTypeFor = (format) => {
|
|||||||
*
|
*
|
||||||
* Callers must ensure the source is on the local filesystem. For S3 mode
|
* Callers must ensure the source is on the local filesystem. For S3 mode
|
||||||
* regeneration flows, fetch via `withLocalCopy(storage, sourceKey, fn)` first.
|
* 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 = {}) {
|
async function generateThumbnail(imagePath, options = {}) {
|
||||||
const filename = path.basename(imagePath);
|
const sourceBasename = path.basename(imagePath);
|
||||||
const thumbnailFilename = `thumb_${filename}`;
|
const outputBasename = options.outputBasename || sourceBasename;
|
||||||
|
const thumbnailFilename = `thumb_${outputBasename}`;
|
||||||
const thumbnailRelKey = path.posix.join('thumbnails', thumbnailFilename);
|
const thumbnailRelKey = path.posix.join('thumbnails', thumbnailFilename);
|
||||||
const storage = getStorage();
|
const storage = getStorage();
|
||||||
|
|
||||||
@@ -168,7 +175,7 @@ async function generateThumbnail(imagePath, options = {}) {
|
|||||||
return thumbnailRelKey;
|
return thumbnailRelKey;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = (error && error.message) ? error.message : String(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
|
// Clean up any partially uploaded object
|
||||||
await storage.delete(thumbnailRelKey).catch(() => {});
|
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) {
|
async function ensureThumbnail(photo) {
|
||||||
const { resolvePhotoStorageKey } = require('./photoResolver');
|
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
|
||||||
let sourceKey;
|
|
||||||
try {
|
const event = await db('events').where('id', photo.event_id).first();
|
||||||
const event = await db('events').where('id', photo.event_id).first();
|
if (!event) {
|
||||||
sourceKey = resolvePhotoStorageKey(event, photo);
|
logger.error(`ensureThumbnail: event ${photo.event_id} not found for photo ${photo.id}`);
|
||||||
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}`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if thumbnail exists and is valid
|
// Check if thumbnail exists and is valid (works for any source).
|
||||||
if (photo.thumbnail_path) {
|
if (photo.thumbnail_path) {
|
||||||
const isValid = await isThumbnailValid(photo.thumbnail_path);
|
const isValid = await isThumbnailValid(photo.thumbnail_path);
|
||||||
if (isValid) {
|
if (isValid) {
|
||||||
@@ -244,10 +254,38 @@ async function ensureThumbnail(photo) {
|
|||||||
logger.warn(`Invalid thumbnail detected for photo ${photo.id}, regenerating...`);
|
logger.warn(`Invalid thumbnail detected for photo ${photo.id}, regenerating...`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new thumbnail (sources via withLocalCopy so this works in S3 mode)
|
const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference';
|
||||||
const newThumbnailPath = await withLocalCopy(sourceKey, (localPath) =>
|
|
||||||
generateThumbnail(localPath, { regenerate: true })
|
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) {
|
if (newThumbnailPath) {
|
||||||
await db('photos')
|
await db('photos')
|
||||||
|
|||||||
Reference in New Issue
Block a user