Files
picpeak/backend/src/services/fileWatcher.js
T
Paul Nothaft 8337a716b1 fix(file-watcher): bound concurrent photo processing (#846)
* fix(file-watcher): bound concurrent photo processing

chokidar fires 'add' once per file — with no ignoreInitial option the
boot scan fires it for every existing file, and a bulk drop into the
watch folder fires it for every new one at once. Each handler runs DB
lookups plus (for new files) a full sharp pipeline; sharp.concurrency(2)
only caps libvips threads WITHIN one operation, not the number of
parallel pipelines, so unbounded handlers can OOM small hosts.

Gate both 'add' and 'unlink' through a shared p-limit
(FILE_WATCHER_CONCURRENCY, default 2, floor 1) — mass deletes otherwise
burst DB work and ZIP-cache invalidation the same way. p-limit is pinned
to ^3.1.0, the last CommonJS release.

Adapted from the filpgame fork (426ca491) — thanks @filpgame; extended
to cover 'unlink', documented in .env.example, plus a lock-in test for
the existing Sharp cache/concurrency caps this bound relies on.

* chore(compose): pass FILE_WATCHER_CONCURRENCY into the backend container (codex review of #846)

The backend service uses an explicit environment list (no env_file), so
the documented override never reached the container in the default
compose deployments. Added to both compose files + root .env.example.
2026-07-19 22:00:46 +02:00

190 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const chokidar = require('chokidar');
const path = require('path');
const fs = require('fs').promises;
const sharp = require('sharp');
const pLimit = require('p-limit');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
const logger = require('../utils/logger');
const { isVideoMimeType } = require('./videoProcessor');
const mime = require('mime-types');
const downloadZipService = require('./downloadZipService');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const WATCH_PATH = () => path.join(getStoragePath(), 'events/active');
// Bound concurrent watcher work. chokidar fires 'add' once per file — with no
// ignoreInitial option the boot scan fires it for EVERY existing file, and a
// bulk drop into the watch folder fires it for every new one at once. Each
// handler runs DB lookups and (for new files) a full sharp pipeline;
// sharp.concurrency(2) only caps libvips threads WITHIN one operation, not the
// number of parallel pipelines, so unbounded handlers can OOM small hosts.
// 'unlink' shares the limiter: mass deletes otherwise burst DB work and
// ZIP-cache invalidation the same way.
const configuredConcurrency = Number.parseInt(process.env.FILE_WATCHER_CONCURRENCY || '2', 10);
const watcherConcurrency = Number.isFinite(configuredConcurrency)
? Math.max(1, configuredConcurrency)
: 2;
const processLimit = pLimit(watcherConcurrency);
function startFileWatcher() {
// Auto-import via filesystem watching only works with the local storage
// backend. In S3 mode there is no local directory to watch — every photo
// must enter through the admin upload API. Skip cleanly with a clear log
// so operators aren't surprised by the missing feature.
const backend = (process.env.STORAGE_BACKEND || 'local').toLowerCase();
if (backend !== 'local') {
logger.warn(`[fileWatcher] auto-import disabled — STORAGE_BACKEND=${backend}. Use the admin upload API instead.`);
return null;
}
const watcher = chokidar.watch(WATCH_PATH(), {
ignored: /(^|[\/\\])\../, // ignore dotfiles
persistent: true,
awaitWriteFinish: {
stabilityThreshold: 2000,
pollInterval: 100
}
});
watcher
.on('add', (filePath) => {
processLimit(() => processNewPhoto(filePath)).catch((error) => {
logger.error('Error processing new photo:', error);
});
})
.on('unlink', (filePath) => {
processLimit(() => removePhoto(filePath)).catch((error) => {
logger.error('Error removing photo:', error);
});
});
logger.info('File watcher started');
}
async function processNewPhoto(filePath) {
const relativePath = path.relative(WATCH_PATH(), filePath);
const pathParts = relativePath.split(path.sep);
if (pathParts.length < 2) return; // Not in correct folder structure
const eventSlug = pathParts[0];
const photoType = pathParts[1] === 'collages' ? 'collage' : 'individual';
// Check if this is an image or video file
const ext = path.extname(filePath).toLowerCase();
const detectedMime = mime.lookup(filePath) || '';
const isVideo = isVideoMimeType(detectedMime, filePath) || ['.mp4', '.mov', '.webm'].includes(ext);
if (!isVideo && !['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
// Skip temporary upload files
const filename = path.basename(filePath);
if (filename.startsWith('temp_')) {
logger.debug(`Skipping temporary upload file: ${filename}`);
return;
}
// Find the event
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
if (!event) return;
// Get file stats
const stats = await fs.stat(filePath);
// Generate thumbnail or placeholder
let thumbnailPath = null;
if (isVideo) {
thumbnailPath = await generateVideoPlaceholder(filename);
} else {
thumbnailPath = await generateThumbnail(filePath);
}
// Calculate relative thumbnail path
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
const mimeType = detectedMime || (isVideo ? 'video/mp4' : 'image/jpeg');
// Capture image dimensions so aspect-aware layouts (masonry / mosaic /
// justified) can size each card to the photo's real proportions
// instead of the 800×600 fallback in MasonryGalleryLayout (#447).
// Skip videos — those would need ffprobe.
let dimensions = null;
if (!isVideo) {
try {
const metadata = await sharp(filePath).metadata();
if (metadata.width && metadata.height) {
dimensions = { width: metadata.width, height: metadata.height };
}
} catch (err) {
logger.debug(`Could not read image dimensions for ${filename}: ${err.message}`);
}
}
// Check if photo already exists (by filename or path, to handle replacements)
const existingPhoto = await db('photos')
.where({ event_id: event.id })
.where(function() {
this.where('filename', path.basename(filePath))
.orWhere('path', relativePath);
})
.first();
if (!existingPhoto) {
// Add to database
const insertResult = await db('photos').insert({
event_id: event.id,
filename: path.basename(filePath),
path: relativePath,
thumbnail_path: relativeThumbPath,
type: isVideo ? 'video' : photoType,
size_bytes: stats.size,
mime_type: mimeType,
...(dimensions && { width: dimensions.width, height: dimensions.height })
}).returning('id');
const photoId = insertResult[0]?.id || insertResult[0];
logger.info(`Added new photo: ${relativePath}`);
downloadZipService.invalidate(event.id);
// Webhook (#327) — auto-import path. Only fires in local mode since
// the watcher is disabled in S3 mode.
try {
const webhookService = require('./webhookService');
await webhookService.fire('photo.uploaded', {
event: { id: event.id, slug: event.slug, event_name: event.event_name },
photo: { id: photoId, filename: path.basename(filePath), size_bytes: stats.size, source: 'auto-import' },
});
} catch (e) { /* non-fatal */ }
} else {
logger.debug(`Photo already exists: ${relativePath}`);
}
}
async function removePhoto(filePath) {
const relativePath = path.relative(WATCH_PATH(), filePath);
// Look up event before deleting to invalidate zip cache
const photo = await db('photos').where({ path: relativePath }).first();
// Remove from database
await db('photos').where({ path: relativePath }).delete();
if (photo) {
downloadZipService.invalidate(photo.event_id);
// Webhook (#327) — fire only if the row actually existed.
try {
const event = await db('events').where({ id: photo.event_id }).first();
const webhookService = require('./webhookService');
await webhookService.fire('photo.deleted', {
event: { id: photo.event_id, slug: event?.slug, event_name: event?.event_name },
photo: { id: photo.id, filename: photo.filename, source: 'auto-import' },
});
} catch (e) { /* non-fatal */ }
}
logger.info(`Removed photo: ${relativePath}`);
}
module.exports = { startFileWatcher };