feat: pre-zip download all and photo replacement by name (#312, #313)

Pre-zip downloads:
- Generate ZIP in background after photo mutations (upload/delete/watermark change)
- Serve cached zip with Content-Length for instant downloads and native progress bar
- Falls back to on-the-fly streaming when no cache exists yet
- Frontend uses browser-native download when zip is ready (no blob buffering)
- New downloadZipService with debounced regeneration and in-memory locking

Photo replacement:
- Admin upload form gets "Replace existing photos with same name" checkbox
- Matches by original_filename (case-insensitive) within the same event
- Preserves photo ID, position, feedback, category, and visibility
- Updates file, thumbnail, dimensions, EXIF capture date on replacement
- Ambiguous matches (multiple photos with same name) skip replacement with warning
- New photoReplacementService with findReplacementCandidate and replacePhoto
This commit is contained in:
Paul Nothaft
2026-04-23 16:49:31 +02:00
parent 4353acebf9
commit e18afd3e6b
16 changed files with 664 additions and 36 deletions
+290
View File
@@ -0,0 +1,290 @@
/**
* DownloadZipService
*
* Pre-generates ZIP archives for "Download All" so guests get instant
* downloads with Content-Length instead of on-the-fly streaming that
* crashes mobile browsers.
*
* Pattern follows watermarkGeneratorService.js — singleton with
* in-memory locking and debounced background regeneration.
*/
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
const archiver = require('archiver');
const { db } = require('../database/db');
const watermarkService = require('./watermarkService');
const { resolvePhotoFilePath } = require('./photoResolver');
const { getStoragePath } = require('../config/storage');
const logger = require('../utils/logger');
const DEBOUNCE_MS = 5000;
class DownloadZipService {
constructor() {
this.activeBuilds = new Map(); // eventId -> { promise, version }
this.debounceTimers = new Map(); // eventId -> setTimeout handle
this.versions = new Map(); // eventId -> generation counter
}
/**
* Absolute path to the cached zip for an event slug.
*/
getCachePath(slug) {
return path.join(getStoragePath(), 'events', 'active', slug, '.download-cache', 'all.zip');
}
/**
* Check if a valid cached zip exists.
* Returns { path, size, generatedAt } or null.
*/
async getZipInfo(eventId) {
try {
const event = await db('events')
.where({ id: eventId })
.select('download_zip_path', 'download_zip_generated_at', 'slug')
.first();
if (!event || !event.download_zip_path) return null;
const absPath = this.getCachePath(event.slug);
try {
const stat = await fsp.stat(absPath);
return {
path: absPath,
size: stat.size,
generatedAt: event.download_zip_generated_at,
};
} catch {
// File gone — clear stale DB record
await db('events').where({ id: eventId }).update({
download_zip_path: null,
download_zip_generated_at: null,
});
return null;
}
} catch (err) {
logger.warn('downloadZipService.getZipInfo error', { eventId, error: err.message });
return null;
}
}
/**
* Generate the pre-zip for an event. Returns { success, path, size } or { success: false }.
* Concurrent calls for the same eventId share one in-flight build.
*/
async generateZip(eventId) {
// If already building, return the existing promise
const existing = this.activeBuilds.get(eventId);
if (existing) return existing.promise;
const version = (this.versions.get(eventId) || 0) + 1;
this.versions.set(eventId, version);
const promise = this._build(eventId, version);
this.activeBuilds.set(eventId, { promise, version });
try {
return await promise;
} finally {
// Only clear if this is still the active build
const current = this.activeBuilds.get(eventId);
if (current && current.version === version) {
this.activeBuilds.delete(eventId);
}
}
}
async _build(eventId, version) {
try {
const event = await db('events').where({ id: eventId }).first();
if (!event) return { success: false, error: 'Event not found' };
const photos = await db('photos')
.where({ event_id: eventId })
.select('*')
.orderBy('type', 'asc')
.orderBy('uploaded_at', 'desc');
if (photos.length === 0) return { success: false, error: 'No photos' };
// Watermark logic (same as gallery.js download-all)
const watermarkSettings = await watermarkService.getWatermarkSettings();
const eventWatermarkEnabled = event.watermark_downloads === true || event.watermark_downloads === 1;
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
const effectiveSettings = shouldApplyWatermark ? {
...watermarkSettings,
enabled: true,
text: event.watermark_text || watermarkSettings?.text || 'Protected',
} : null;
const cacheDir = path.dirname(this.getCachePath(event.slug));
await fsp.mkdir(cacheDir, { recursive: true });
const tmpPath = this.getCachePath(event.slug) + `.tmp.${Date.now()}`;
const finalPath = this.getCachePath(event.slug);
// Build zip — level 0 (store only) since photos are already compressed
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(tmpPath);
const archive = archiver('zip', { zlib: { level: 0 } });
output.on('close', resolve);
archive.on('error', reject);
archive.pipe(output);
const uniqueTypes = new Set(photos.map(p => p.type)).size;
const hasMultipleTypes = uniqueTypes > 1;
const addPhotos = async () => {
for (const photo of photos) {
// Check if build was invalidated
if (this.versions.get(eventId) !== version) {
archive.abort();
return reject(new Error('Build invalidated'));
}
let filePath;
try {
filePath = resolvePhotoFilePath(event, photo);
} catch {
continue;
}
let archiveName;
if (hasMultipleTypes) {
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
archiveName = path.join(folderName, photo.filename);
} else {
archiveName = photo.filename;
}
if (shouldApplyWatermark && effectiveSettings) {
try {
const buf = await watermarkService.applyWatermark(filePath, effectiveSettings);
archive.append(buf, { name: archiveName });
} catch (err) {
logger.warn('Skipping watermark in pre-zip', { photoId: photo.id, error: err.message });
}
} else {
archive.file(filePath, { name: archiveName });
}
}
archive.finalize();
};
addPhotos().catch(reject);
});
// Check version again — another invalidation may have arrived
if (this.versions.get(eventId) !== version) {
await fsp.unlink(tmpPath).catch(() => {});
return { success: false, error: 'Build invalidated' };
}
// Atomic rename
await fsp.rename(tmpPath, finalPath);
const stat = await fsp.stat(finalPath);
// Update DB
await db('events').where({ id: eventId }).update({
download_zip_path: `events/active/${event.slug}/.download-cache/all.zip`,
download_zip_generated_at: new Date(),
});
logger.info('Pre-zip generated', { eventId, slug: event.slug, size: stat.size, photos: photos.length });
return { success: true, path: finalPath, size: stat.size };
} catch (err) {
if (err.message === 'Build invalidated') {
return { success: false, error: 'Build invalidated' };
}
logger.error('downloadZipService._build error', { eventId, error: err.message });
return { success: false, error: err.message };
}
}
/**
* Invalidate the cached zip for an event.
* Deletes the file, clears DB, debounces regeneration.
*/
invalidate(eventId) {
// Bump version to signal any in-flight build is stale
this.versions.set(eventId, (this.versions.get(eventId) || 0) + 1);
// Cancel pending debounce
const timer = this.debounceTimers.get(eventId);
if (timer) clearTimeout(timer);
// Fire-and-forget cleanup
this._cleanup(eventId).catch(err =>
logger.warn('downloadZipService.invalidate cleanup error', { eventId, error: err.message })
);
// Debounce regeneration
const newTimer = setTimeout(() => {
this.debounceTimers.delete(eventId);
this.generateZip(eventId).catch(err =>
logger.warn('downloadZipService debounced regen error', { eventId, error: err.message })
);
}, DEBOUNCE_MS);
this.debounceTimers.set(eventId, newTimer);
}
/**
* Invalidate all events (e.g., global watermark settings changed).
*/
async invalidateAll() {
try {
const events = await db('events')
.whereNotNull('download_zip_path')
.select('id');
for (const event of events) {
this.invalidate(event.id);
}
} catch (err) {
logger.error('downloadZipService.invalidateAll error', { error: err.message });
}
}
/**
* Full cleanup — delete file and clear DB. Used on event deletion/archival.
*/
async cleanup(eventId) {
this.versions.set(eventId, (this.versions.get(eventId) || 0) + 1);
const timer = this.debounceTimers.get(eventId);
if (timer) {
clearTimeout(timer);
this.debounceTimers.delete(eventId);
}
await this._cleanup(eventId);
}
async _cleanup(eventId) {
try {
const event = await db('events')
.where({ id: eventId })
.select('slug', 'download_zip_path')
.first();
if (event && event.download_zip_path) {
const absPath = this.getCachePath(event.slug);
await fsp.unlink(absPath).catch(() => {});
// Also try to remove the cache directory if empty
const cacheDir = path.dirname(absPath);
await fsp.rmdir(cacheDir).catch(() => {});
}
await db('events').where({ id: eventId }).update({
download_zip_path: null,
download_zip_generated_at: null,
});
} catch (err) {
logger.warn('downloadZipService._cleanup error', { eventId, error: err.message });
}
}
}
module.exports = new DownloadZipService();
+19 -6
View File
@@ -7,6 +7,7 @@ const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcesso
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');
@@ -81,11 +82,15 @@ async function processNewPhoto(filePath) {
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
const mimeType = detectedMime || (isVideo ? 'video/mp4' : 'image/jpeg');
// Check if photo already exists
// Check if photo already exists (by filename or path, to handle replacements)
const existingPhoto = await db('photos')
.where({ event_id: event.id, filename: path.basename(filePath) })
.where({ event_id: event.id })
.where(function() {
this.where('filename', path.basename(filePath))
.orWhere('path', relativePath);
})
.first();
if (!existingPhoto) {
// Add to database
await db('photos').insert({
@@ -97,8 +102,9 @@ async function processNewPhoto(filePath) {
size_bytes: stats.size,
mime_type: mimeType
});
logger.info(`Added new photo: ${relativePath}`);
downloadZipService.invalidate(event.id);
} else {
logger.debug(`Photo already exists: ${relativePath}`);
}
@@ -106,10 +112,17 @@ async function processNewPhoto(filePath) {
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);
}
logger.info(`Removed photo: ${relativePath}`);
}
@@ -0,0 +1,146 @@
/**
* PhotoReplacementService
*
* Handles replacing existing photos by matching original_filename.
* Preserves the photo's ID, position, feedback, category, and visibility
* while updating the physical file and metadata.
*/
const path = require('path');
const fsp = require('fs/promises');
const sharp = require('sharp');
const { db } = require('../database/db');
const { generateThumbnail, extractCaptureDate } = require('./imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const watermarkGeneratorService = require('./watermarkGeneratorService');
const logger = require('../utils/logger');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
/**
* Find a replacement candidate by matching original_filename (case-insensitive).
* Returns the photo row if exactly one match, { ambiguous: true, count } if multiple, or null.
*/
async function findReplacementCandidate(eventId, originalFilename) {
if (!originalFilename) return null;
const matches = await db('photos')
.where({ event_id: eventId })
.whereRaw('LOWER(original_filename) = LOWER(?)', [originalFilename]);
if (matches.length === 1) return matches[0];
if (matches.length > 1) return { ambiguous: true, count: matches.length };
return null;
}
/**
* Replace an existing photo's file while preserving its DB identity.
*
* @param {Object} existingPhoto - The current photo DB row
* @param {string} newFileTempPath - Path to the new file (will be moved)
* @param {Object} opts - { originalFilename, mimeType, event }
* @returns {{ success: boolean, photo?: Object, error?: string }}
*/
async function replacePhoto(existingPhoto, newFileTempPath, { originalFilename, mimeType, event }) {
const eventDir = path.join(getStoragePath(), 'events', 'active', event.slug);
const categorySlug = existingPhoto.type === 'collage' ? 'collages' : 'individual';
const targetDir = path.join(eventDir, categorySlug);
try {
// Generate new filename
const ext = path.extname(originalFilename);
const newFilename = generatePhotoFilename(event.event_name, categorySlug, Date.now(), ext);
const tempTargetPath = path.join(targetDir, `_replacing_${Date.now()}_${newFilename}`);
const finalPath = path.join(targetDir, newFilename);
const relativePath = path.join(event.slug, categorySlug, newFilename);
// Write new file to temp name in target directory
await fsp.mkdir(targetDir, { recursive: true });
await fsp.copyFile(newFileTempPath, tempTargetPath);
// Delete old physical file
const oldFilePath = path.join(getStoragePath(), 'events', 'active', existingPhoto.path);
await fsp.unlink(oldFilePath).catch(() => {});
// Delete old thumbnail
if (existingPhoto.thumbnail_path) {
const oldThumbPath = path.join(getStoragePath(), existingPhoto.thumbnail_path);
await fsp.unlink(oldThumbPath).catch(() => {});
}
// Delete old watermark cache
try {
await watermarkGeneratorService.deleteForPhoto(existingPhoto.id);
} catch {
// Ignore — watermark may not exist
}
// Rename temp → final
await fsp.rename(tempTargetPath, finalPath);
// Extract metadata from new file
let capturedAt = null;
try {
capturedAt = await extractCaptureDate(finalPath);
} catch {
// No EXIF — keep null
}
let width = null;
let height = null;
try {
const metadata = await sharp(finalPath).metadata();
width = metadata.width || null;
height = metadata.height || null;
} catch {
// Non-image or corrupt
}
const stats = await fsp.stat(finalPath);
// Generate new thumbnail
let thumbnailPath = null;
try {
thumbnailPath = await generateThumbnail(finalPath);
} catch {
logger.warn('Failed to generate thumbnail for replaced photo', { photoId: existingPhoto.id });
}
// Update DB record — preserve id, event_id, category_id, type, visibility,
// uploaded_at, sort_order, feedback counts, view/download counts
const updates = {
filename: newFilename,
original_filename: originalFilename,
path: relativePath,
thumbnail_path: thumbnailPath,
size_bytes: stats.size,
width,
height,
captured_at: capturedAt,
mime_type: mimeType,
media_type: mimeType?.startsWith('video/') ? 'video' : 'image',
};
await db('photos').where({ id: existingPhoto.id }).update(updates);
const updatedPhoto = await db('photos').where({ id: existingPhoto.id }).first();
logger.info('Photo replaced', {
photoId: existingPhoto.id,
oldFilename: existingPhoto.filename,
newFilename,
originalFilename,
});
return {
success: true,
photo: updatedPhoto,
previousFilename: existingPhoto.filename,
};
} catch (err) {
logger.error('replacePhoto error', { photoId: existingPhoto.id, error: err.message });
return { success: false, error: err.message };
}
}
module.exports = { findReplacementCandidate, replacePhoto };