- Fix admin video upload missing media_type/mime_type and video processing (#203) - Fix Gallery-Premium Select All using atomic callbacks instead of stale closure loop (#220) - Add photo dimension repair endpoint and admin UI (#180) - Add E2E tests for all three fixes
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Module-level progress state
|
||||
let repairProgress = {
|
||||
isRunning: false,
|
||||
lastResult: null
|
||||
};
|
||||
|
||||
// Repair photo dimensions (background job)
|
||||
router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), async (req, res) => {
|
||||
try {
|
||||
if (repairProgress.isRunning) {
|
||||
return res.status(409).json({ error: 'Repair is already running' });
|
||||
}
|
||||
|
||||
const photos = await db('photos')
|
||||
.where(function () {
|
||||
this.whereNull('width').orWhereNull('height');
|
||||
})
|
||||
.where(function () {
|
||||
this.where('media_type', '!=', 'video').orWhereNull('media_type');
|
||||
})
|
||||
.select('id', 'path', 'filename');
|
||||
|
||||
if (photos.length === 0) {
|
||||
return res.json({ message: 'No photos need dimension repair', count: 0 });
|
||||
}
|
||||
|
||||
// Return immediately
|
||||
res.json({
|
||||
message: `Started repairing dimensions for ${photos.length} photos`,
|
||||
count: photos.length
|
||||
});
|
||||
|
||||
// Process in background
|
||||
repairProgress.isRunning = true;
|
||||
repairProgress.lastResult = null;
|
||||
|
||||
setImmediate(async () => {
|
||||
let sharp;
|
||||
try {
|
||||
sharp = require('sharp');
|
||||
} catch (err) {
|
||||
logger.error('Sharp not available for dimension repair:', err.message);
|
||||
repairProgress.isRunning = false;
|
||||
repairProgress.lastResult = { success: 0, failed: 0, error: 'Sharp not available' };
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const photo of photos) {
|
||||
try {
|
||||
if (!photo.path) {
|
||||
logger.warn(`Photo ${photo.id} has no path, skipping dimension repair`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const storagePath = getStoragePath();
|
||||
const fullPath = path.join(storagePath, 'events/active', photo.path);
|
||||
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
} catch (err) {
|
||||
logger.warn(`File not found for photo ${photo.id}: ${fullPath}`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const metadata = await sharp(fullPath).metadata();
|
||||
|
||||
if (metadata.width && metadata.height) {
|
||||
await db('photos')
|
||||
.where({ id: photo.id })
|
||||
.update({
|
||||
width: metadata.width,
|
||||
height: metadata.height,
|
||||
updated_at: db.fn.now()
|
||||
});
|
||||
successCount++;
|
||||
|
||||
if (successCount % 50 === 0) {
|
||||
logger.info(`Dimension repair progress: ${successCount} updated...`);
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Could not extract dimensions for photo ${photo.id}`);
|
||||
errorCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error repairing dimensions for photo ${photo.id}:`, error);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
repairProgress.isRunning = false;
|
||||
repairProgress.lastResult = { success: successCount, failed: errorCount };
|
||||
logger.info(`Dimension repair complete: ${successCount} success, ${errorCount} errors`);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error starting dimension repair:', error);
|
||||
res.status(500).json({ error: 'Failed to start dimension repair' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get dimension repair status
|
||||
router.get('/repair-dimensions/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||
try {
|
||||
const totalPhotos = await db('photos')
|
||||
.where(function () {
|
||||
this.where('media_type', '!=', 'video').orWhereNull('media_type');
|
||||
})
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
const withDimensions = await db('photos')
|
||||
.where(function () {
|
||||
this.where('media_type', '!=', 'video').orWhereNull('media_type');
|
||||
})
|
||||
.whereNotNull('width')
|
||||
.whereNotNull('height')
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
const total = Number(totalPhotos.count);
|
||||
const withDims = Number(withDimensions.count);
|
||||
|
||||
res.json({
|
||||
total,
|
||||
withDimensions: withDims,
|
||||
withoutDimensions: total - withDims,
|
||||
isRunning: repairProgress.isRunning,
|
||||
lastResult: repairProgress.lastResult
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching dimension repair status:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch dimension repair status' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -6,6 +6,7 @@ const { db, logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { generateThumbnail, ensureThumbnail, extractCaptureDate } = require('../services/imageProcessor');
|
||||
const { processUploadedVideo, isVideoMimeType } = require('../services/videoProcessor');
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||
@@ -263,6 +264,10 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
console.log(`Could not extract EXIF date for ${file.originalname}`);
|
||||
}
|
||||
|
||||
// Determine media type
|
||||
const isVideo = isVideoMimeType(file.mimetype);
|
||||
const mediaType = isVideo ? 'video' : 'image';
|
||||
|
||||
// Prepare photo data for batch insert
|
||||
const photoData = {
|
||||
event_id: parseInt(eventId),
|
||||
@@ -273,7 +278,9 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
type: photoType,
|
||||
category_id: parsedCategoryId, // Save the selected category
|
||||
size_bytes: tempStats.size, // Use actual file size from stat
|
||||
captured_at: capturedAt // EXIF capture date (if available)
|
||||
captured_at: capturedAt, // EXIF capture date (if available)
|
||||
media_type: mediaType,
|
||||
mime_type: file.mimetype
|
||||
};
|
||||
|
||||
batchPhotos.push(photoData);
|
||||
@@ -317,26 +324,65 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
throw new Error(`File size mismatch after move: expected ${operation.photoData.size_bytes}, got ${finalStats.size}`);
|
||||
}
|
||||
|
||||
// Generate thumbnail with final path
|
||||
// Generate thumbnail and extract metadata
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
const isVideoFile = isVideoMimeType(operation.photoData.mime_type);
|
||||
let thumbnailPath = null;
|
||||
try {
|
||||
thumbnailPath = await generateThumbnail(operation.finalPath);
|
||||
|
||||
// Update the database with thumbnail path
|
||||
if (thumbnailPath && insertedIds[idx]) {
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({ thumbnail_path: thumbnailPath });
|
||||
try {
|
||||
if (isVideoFile) {
|
||||
// Process video: extract metadata and generate thumbnail
|
||||
const thumbnailDir = path.join(getStoragePath(), 'thumbnails');
|
||||
await fs.mkdir(thumbnailDir, { recursive: true });
|
||||
const videoThumbnailPath = path.join(thumbnailDir, `thumb_${operation.filename.replace(/\.[^.]+$/, '.jpg')}`);
|
||||
|
||||
const result = await processUploadedVideo(operation.finalPath, videoThumbnailPath);
|
||||
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
|
||||
|
||||
if (photoId && result.metadata) {
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({
|
||||
thumbnail_path: thumbnailPath,
|
||||
duration: result.metadata.duration,
|
||||
video_codec: result.metadata.videoCodec,
|
||||
audio_codec: result.metadata.audioCodec,
|
||||
width: result.metadata.width,
|
||||
height: result.metadata.height
|
||||
});
|
||||
}
|
||||
} else {
|
||||
thumbnailPath = await generateThumbnail(operation.finalPath);
|
||||
|
||||
// Update the database with thumbnail path and image dimensions
|
||||
if (photoId) {
|
||||
const updateData = {};
|
||||
if (thumbnailPath) updateData.thumbnail_path = thumbnailPath;
|
||||
|
||||
try {
|
||||
const sharp = require('sharp');
|
||||
const metadata = await sharp(operation.finalPath).metadata();
|
||||
if (metadata.width && metadata.height) {
|
||||
updateData.width = metadata.width;
|
||||
updateData.height = metadata.height;
|
||||
}
|
||||
} catch (metadataError) {
|
||||
console.warn(`Could not extract image dimensions for ${operation.filename}:`, metadataError.message);
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update(updateData);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (thumbError) {
|
||||
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
|
||||
console.error(`Thumbnail/metadata processing failed for ${operation.filename}:`, thumbError.message);
|
||||
}
|
||||
|
||||
// Queue watermark generation in background (non-blocking)
|
||||
// This pre-generates watermarked versions for fast serving in lightbox
|
||||
if (insertedIds[idx]) {
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
// Queue watermark generation in background (non-blocking, images only)
|
||||
if (photoId && !isVideoFile) {
|
||||
watermarkGeneratorService.generateForPhoto(photoId)
|
||||
.catch(err => console.warn(`Watermark generation queued failed for photo ${photoId}:`, err.message));
|
||||
}
|
||||
@@ -801,6 +847,11 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
|
||||
category_id: photo.category_id || photo.type,
|
||||
category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
|
||||
category_slug: photo.pc_slug || photo.type,
|
||||
media_type: photo.media_type || 'image',
|
||||
mime_type: photo.mime_type || null,
|
||||
width: photo.width || null,
|
||||
height: photo.height || null,
|
||||
duration: photo.duration || null,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
// Feedback data
|
||||
|
||||
Reference in New Issue
Block a user