diff --git a/backend/src/routes/adminExternalMedia.js b/backend/src/routes/adminExternalMedia.js index d88a0765..8566bfff 100644 --- a/backend/src/routes/adminExternalMedia.js +++ b/backend/src/routes/adminExternalMedia.js @@ -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, diff --git a/backend/src/services/imageProcessor.js b/backend/src/services/imageProcessor.js index 67b9f865..3ae13d24 100644 --- a/backend/src/services/imageProcessor.js +++ b/backend/src/services/imageProcessor.js @@ -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')