jedmund-svelte/src/routes/api/media/bulk-upload/+server.ts

274 lines
7.1 KiB
TypeScript

import type { RequestHandler } from './$types'
import { prisma } from '$lib/server/database'
import { uploadFiles, isCloudinaryConfigured } from '$lib/server/cloudinary'
import { jsonResponse, errorResponse, checkAdminAuth } from '$lib/server/api-utils'
import { logger } from '$lib/server/logger'
import exifr from 'exifr'
// Extract EXIF data from image file
async function extractExifData(file: File) {
try {
logger.info(`Starting EXIF extraction for ${file.name}`, {
size: file.size,
type: file.type
})
const buffer = await file.arrayBuffer()
logger.info(`Buffer created for ${file.name}`, { bufferSize: buffer.byteLength })
// Try parsing without pick first to see all available data
const fullExif = await exifr.parse(buffer)
logger.info(`Full EXIF data available for ${file.name}:`, {
hasData: !!fullExif,
availableFields: fullExif ? Object.keys(fullExif).slice(0, 10) : [] // First 10 fields
})
// Now parse with specific fields
const exif = await exifr.parse(buffer, {
pick: [
'Make',
'Model',
'LensModel',
'FocalLength',
'FNumber',
'ExposureTime',
'ISO',
'ISOSpeedRatings', // Alternative ISO field
'DateTimeOriginal',
'DateTime', // Alternative date field
'GPSLatitude',
'GPSLongitude',
'Orientation',
'ColorSpace'
]
})
logger.info(`EXIF parse result for ${file.name}:`, {
hasExif: !!exif,
exifKeys: exif ? Object.keys(exif) : []
})
if (!exif) return null
// Format EXIF data
const formattedExif: any = {}
// Camera info
if (exif.Make && exif.Model) {
formattedExif.camera = `${exif.Make} ${exif.Model}`.replace(/\s+/g, ' ').trim()
}
// Lens info
if (exif.LensModel) {
formattedExif.lens = exif.LensModel
}
// Settings
if (exif.FocalLength) {
formattedExif.focalLength = `${exif.FocalLength}mm`
}
if (exif.FNumber) {
formattedExif.aperture = `f/${exif.FNumber}`
}
if (exif.ExposureTime) {
formattedExif.shutterSpeed =
exif.ExposureTime < 1 ? `1/${Math.round(1 / exif.ExposureTime)}` : `${exif.ExposureTime}s`
}
if (exif.ISO) {
formattedExif.iso = `ISO ${exif.ISO}`
} else if (exif.ISOSpeedRatings) {
// Handle alternative ISO field
const iso = Array.isArray(exif.ISOSpeedRatings)
? exif.ISOSpeedRatings[0]
: exif.ISOSpeedRatings
formattedExif.iso = `ISO ${iso}`
}
// Date taken
if (exif.DateTimeOriginal) {
formattedExif.dateTaken = exif.DateTimeOriginal
} else if (exif.DateTime) {
// Fallback to DateTime if DateTimeOriginal not available
formattedExif.dateTaken = exif.DateTime
}
// GPS coordinates
if (exif.GPSLatitude && exif.GPSLongitude) {
formattedExif.coordinates = {
latitude: exif.GPSLatitude,
longitude: exif.GPSLongitude
}
}
// Additional metadata
if (exif.Orientation) {
formattedExif.orientation =
exif.Orientation === 1 ? 'Horizontal (normal)' : `Rotated (${exif.Orientation})`
}
if (exif.ColorSpace) {
formattedExif.colorSpace = exif.ColorSpace
}
const result = Object.keys(formattedExif).length > 0 ? formattedExif : null
logger.info(`Final EXIF result for ${file.name}:`, {
hasData: !!result,
fields: result ? Object.keys(result) : []
})
return result
} catch (error) {
logger.warn('Failed to extract EXIF data', {
filename: file.name,
error: error instanceof Error ? error.message : 'Unknown error'
})
return null
}
}
export const POST: RequestHandler = async (event) => {
// Check authentication
if (!checkAdminAuth(event)) {
return errorResponse('Unauthorized', 401)
}
// Check if Cloudinary is configured
if (!isCloudinaryConfigured()) {
return errorResponse('Media upload service not configured', 503)
}
try {
const formData = await event.request.formData()
const files = formData.getAll('files') as File[]
const context = (formData.get('context') as string) || 'media'
if (!files || files.length === 0) {
return errorResponse('No files provided', 400)
}
// Validate all files
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp']
const maxSize = 10 * 1024 * 1024 // 10MB per file
const maxFiles = 50 // Maximum 50 files at once
if (files.length > maxFiles) {
return errorResponse(`Too many files. Maximum ${maxFiles} files allowed`, 400)
}
for (const file of files) {
if (!(file instanceof File)) {
return errorResponse('Invalid file format', 400)
}
if (!allowedTypes.includes(file.type)) {
return errorResponse(
`Invalid file type for ${file.name}. Allowed types: JPEG, PNG, WebP`,
400
)
}
if (file.size > maxSize) {
return errorResponse(`File ${file.name} is too large. Maximum size is 10MB`, 400)
}
}
logger.info(`Starting bulk upload of ${files.length} files`)
// Extract EXIF data before uploading (files might not be readable after upload)
const exifDataMap = new Map()
for (const file of files) {
if (file.type.startsWith('image/') && file.type !== 'image/svg+xml') {
logger.info(`Pre-extracting EXIF data for ${file.name}`)
const exifData = await extractExifData(file)
if (exifData) {
exifDataMap.set(file.name, exifData)
logger.info(`EXIF data found for ${file.name}`)
}
}
}
// Upload all files to Cloudinary
const uploadResults = await uploadFiles(files, context as 'media' | 'photos' | 'projects')
// Process results and save to database
const mediaRecords = []
const errors = []
for (let i = 0; i < uploadResults.length; i++) {
const result = uploadResults[i]
const file = files[i]
if (result.success) {
try {
// Get pre-extracted EXIF data
const exifData = exifDataMap.get(file.name) || null
const media = await prisma.media.create({
data: {
filename: file.name,
originalName: file.name,
mimeType: file.type,
size: file.size,
url: result.secureUrl!,
thumbnailUrl: result.thumbnailUrl,
width: result.width,
height: result.height,
exifData: exifData,
usedIn: []
}
})
mediaRecords.push({
id: media.id,
url: media.url,
thumbnailUrl: media.thumbnailUrl,
width: media.width,
height: media.height,
filename: media.filename,
exifData: media.exifData
})
} catch (dbError) {
errors.push({
filename: file.name,
error: 'Failed to save to database'
})
}
} else {
errors.push({
filename: file.name,
error: result.error || 'Upload failed'
})
}
}
logger.info(`Bulk upload completed: ${mediaRecords.length} successful, ${errors.length} failed`)
return jsonResponse(
{
success: mediaRecords.length,
failed: errors.length,
total: files.length,
media: mediaRecords,
errors: errors.length > 0 ? errors : undefined
},
201
)
} catch (error) {
logger.error('Bulk upload error', error as Error)
return errorResponse('Failed to upload files', 500)
}
}
// Handle preflight requests
export const OPTIONS: RequestHandler = async () => {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization'
}
})
}