const fs = require('fs'); const path = require('path'); const pool = require('./db'); const { savePaintingImages, downloadImageForFix, generateThumbnailFromFull, pickExt, } = require('../scripts/image-fetcher'); const IMAGE_DIR = path.resolve(process.env.IMAGE_DIR || './data/images'); const FETCH_TIMEOUT_MS = 15000; const inflight = new Map(); function safePaintingBase(artistName, title) { return `${artistName}_${title}`.replace(/[^a-zA-Z0-9_-]/g, '_'); } function safeArtistPortraitBase(artistName) { return artistName.replace(/[^a-zA-Z0-9_-]/g, '_'); } function unlinkIfExists(absPath) { if (absPath && fs.existsSync(absPath)) { try { fs.unlinkSync(absPath); } catch { // ignore } } } function withTimeout(promise, ms) { return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error('Image fetch timeout')), ms)), ]); } function localFileExists(relPath) { if (!relPath) return false; return fs.existsSync(path.join(IMAGE_DIR, relPath)); } function syncPaintingFromDisk(row) { const safeBase = `${row.artist_name}_${row.title}`.replace(/[^a-zA-Z0-9_-]/g, '_'); let imagePath = row.image_path; let thumbPath = row.thumbnail_path; for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.JPG']) { const full = path.join(IMAGE_DIR, 'paintings', safeBase + ext); if (!imagePath && fs.existsSync(full)) { imagePath = `paintings/${safeBase}${ext}`; } const thumb = path.join(IMAGE_DIR, 'paintings', 'thumbs', safeBase + '_thumb' + ext); if (!thumbPath && fs.existsSync(thumb)) { thumbPath = `paintings/thumbs/${safeBase}_thumb${ext}`; } } if (!thumbPath && imagePath) thumbPath = imagePath; return { imagePath, thumbPath }; } /** Fast preload: link local files only, no external API calls */ async function preloadArtistImagesLocal(artistId) { const rows = await pool.query( `SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name FROM paintings p JOIN artists a ON a.id = p.artist_id WHERE p.artist_id = $1`, [artistId] ); let linked = 0; for (const row of rows.rows) { const hasLocal = localFileExists(row.thumbnail_path) || localFileExists(row.image_path); if (hasLocal) { linked++; continue; } const synced = syncPaintingFromDisk(row); if (synced.imagePath || synced.thumbPath) { await pool.query( `UPDATE paintings SET image_path = COALESCE($1, image_path), thumbnail_path = COALESCE($2, thumbnail_path) WHERE id = $3`, [synced.imagePath, synced.thumbPath, row.id] ); linked++; } } return { fetched: linked, total: rows.rows.length }; } async function ensurePaintingImages(paintingId, size = 'thumb') { const key = `${paintingId}:${size}`; if (inflight.has(key)) return inflight.get(key); const promise = (async () => { const result = await pool.query( `SELECT p.id, p.title, p.wikipedia_title, p.image_path, p.thumbnail_path, a.name AS artist_name FROM paintings p JOIN artists a ON a.id = p.artist_id WHERE p.id = $1`, [paintingId] ); if (result.rows.length === 0) return null; const row = result.rows[0]; const wantThumb = size !== 'full'; if (wantThumb && localFileExists(row.thumbnail_path)) return row.thumbnail_path; if (!wantThumb && localFileExists(row.image_path)) return row.image_path; if (wantThumb && localFileExists(row.image_path)) return row.image_path; const synced = syncPaintingFromDisk(row); if (synced.imagePath || synced.thumbPath) { await pool.query( `UPDATE paintings SET image_path = COALESCE($1, image_path), thumbnail_path = COALESCE($2, thumbnail_path) WHERE id = $3`, [synced.imagePath, synced.thumbPath, paintingId] ); if (wantThumb && localFileExists(synced.thumbPath)) return synced.thumbPath; if (wantThumb && localFileExists(synced.imagePath)) return synced.imagePath; if (!wantThumb && localFileExists(synced.imagePath)) return synced.imagePath; } if (!row.wikipedia_title) return null; const saved = await withTimeout( savePaintingImages( row.wikipedia_title, `${row.artist_name}_${row.title}`, IMAGE_DIR, { artistName: row.artist_name, paintingTitle: row.title, type: 'painting' } ), FETCH_TIMEOUT_MS ).catch(() => ({ imagePath: null, thumbnailPath: null })); if (saved.imagePath || saved.thumbnailPath) { await pool.query( `UPDATE paintings SET image_path = COALESCE($1, image_path), thumbnail_path = COALESCE($2, thumbnail_path) WHERE id = $3`, [saved.imagePath, saved.thumbnailPath, paintingId] ); } if (wantThumb) return saved.thumbnailPath || saved.imagePath; return saved.imagePath || saved.thumbnailPath; })().finally(() => inflight.delete(key)); inflight.set(key, promise); return promise; } function pickExtFromMime(mimeType) { const map = { 'image/jpeg': '.jpg', 'image/jpg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif', }; return map[String(mimeType || '').toLowerCase()] || '.jpg'; } function unlinkPaintingFiles(row, safeBase) { unlinkIfExists(row.image_path ? path.join(IMAGE_DIR, row.image_path) : null); unlinkIfExists(row.thumbnail_path ? path.join(IMAGE_DIR, row.thumbnail_path) : null); const paintingsDir = path.join(IMAGE_DIR, 'paintings'); const thumbsDir = path.join(IMAGE_DIR, 'paintings', 'thumbs'); for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.JPG']) { unlinkIfExists(path.join(paintingsDir, safeBase + ext)); unlinkIfExists(path.join(thumbsDir, safeBase + '_thumb' + ext)); unlinkIfExists(path.join(thumbsDir, safeBase + '_thumb.jpg')); } } function unlinkPortraitFiles(row, safeBase) { unlinkIfExists(row.portrait_path ? path.join(IMAGE_DIR, row.portrait_path) : null); const portraitsDir = path.join(IMAGE_DIR, 'portraits'); for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.JPG']) { unlinkIfExists(path.join(portraitsDir, safeBase + ext)); } } async function deletePainting(paintingId) { const result = await pool.query( `SELECT p.id, p.title, p.image_path, p.thumbnail_path, p.artist_id, a.name AS artist_name FROM paintings p JOIN artists a ON a.id = p.artist_id WHERE p.id = $1`, [paintingId] ); if (result.rows.length === 0) { throw new Error('Painting not found'); } const row = result.rows[0]; const safeBase = safePaintingBase(row.artist_name, row.title); unlinkPaintingFiles(row, safeBase); inflight.delete(`${paintingId}:thumb`); inflight.delete(`${paintingId}:full`); await pool.query(`DELETE FROM paintings WHERE id = $1`, [paintingId]); return { id: row.id, artistId: row.artist_id, title: row.title }; } async function clearPaintingImage(paintingId) { const result = await pool.query( `SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name FROM paintings p JOIN artists a ON a.id = p.artist_id WHERE p.id = $1`, [paintingId] ); if (result.rows.length === 0) { throw new Error('Painting not found'); } const row = result.rows[0]; const safeBase = safePaintingBase(row.artist_name, row.title); unlinkPaintingFiles(row, safeBase); await pool.query( `UPDATE paintings SET image_path = NULL, thumbnail_path = NULL WHERE id = $1`, [paintingId] ); return { imagePath: null, thumbnailPath: null }; } async function clearArtistPortrait(artistId) { const result = await pool.query( `SELECT id, name, portrait_path FROM artists WHERE id = $1`, [artistId] ); if (result.rows.length === 0) { throw new Error('Artist not found'); } const row = result.rows[0]; const safeBase = safeArtistPortraitBase(row.name); unlinkPortraitFiles(row, safeBase); await pool.query(`UPDATE artists SET portrait_path = NULL WHERE id = $1`, [artistId]); return { portraitPath: null }; } async function replacePaintingImageFromBuffer(paintingId, buffer, mimeType) { if (!buffer?.length) { throw new Error('Empty image data'); } const sharp = require('sharp'); try { await sharp(buffer).metadata(); } catch { throw new Error('Invalid image file'); } const result = await pool.query( `SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name FROM paintings p JOIN artists a ON a.id = p.artist_id WHERE p.id = $1`, [paintingId] ); if (result.rows.length === 0) { throw new Error('Painting not found'); } const row = result.rows[0]; const safeBase = safePaintingBase(row.artist_name, row.title); const paintingsDir = path.join(IMAGE_DIR, 'paintings'); const thumbsDir = path.join(IMAGE_DIR, 'paintings', 'thumbs'); if (!fs.existsSync(paintingsDir)) fs.mkdirSync(paintingsDir, { recursive: true }); if (!fs.existsSync(thumbsDir)) fs.mkdirSync(thumbsDir, { recursive: true }); const fullExt = pickExtFromMime(mimeType); const fullDest = path.join(paintingsDir, safeBase + fullExt); const thumbDest = path.join(thumbsDir, safeBase + '_thumb.jpg'); unlinkPaintingFiles(row, safeBase); fs.writeFileSync(fullDest, buffer); let thumbnailPath = null; try { await generateThumbnailFromFull(fullDest, thumbDest); thumbnailPath = path.join('paintings', 'thumbs', safeBase + '_thumb.jpg').replace(/\\/g, '/'); } catch { thumbnailPath = path.join('paintings', safeBase + fullExt).replace(/\\/g, '/'); } const imagePath = path.join('paintings', safeBase + fullExt).replace(/\\/g, '/'); await pool.query( `UPDATE paintings SET image_path = $1, thumbnail_path = $2 WHERE id = $3`, [imagePath, thumbnailPath, paintingId] ); return { imagePath, thumbnailPath }; } async function replaceArtistPortraitFromBuffer(artistId, buffer, mimeType) { if (!buffer?.length) { throw new Error('Empty image data'); } const sharp = require('sharp'); try { await sharp(buffer).metadata(); } catch { throw new Error('Invalid image file'); } const result = await pool.query( `SELECT id, name, portrait_path FROM artists WHERE id = $1`, [artistId] ); if (result.rows.length === 0) { throw new Error('Artist not found'); } const row = result.rows[0]; const safeBase = safeArtistPortraitBase(row.name); const portraitsDir = path.join(IMAGE_DIR, 'portraits'); if (!fs.existsSync(portraitsDir)) fs.mkdirSync(portraitsDir, { recursive: true }); unlinkPortraitFiles(row, safeBase); const jpgDest = path.join(portraitsDir, safeBase + '.jpg'); try { await sharp(buffer) .rotate() .resize({ width: 900, height: 1100, fit: 'inside', withoutEnlargement: true }) .jpeg({ quality: 88 }) .toFile(jpgDest); } catch { const fullExt = pickExtFromMime(mimeType); const fullDest = path.join(portraitsDir, safeBase + fullExt); fs.writeFileSync(fullDest, buffer); const portraitPath = path.join('portraits', safeBase + fullExt).replace(/\\/g, '/'); await pool.query(`UPDATE artists SET portrait_path = $1 WHERE id = $2`, [portraitPath, artistId]); return { portraitPath }; } const portraitPath = path.join('portraits', safeBase + '.jpg').replace(/\\/g, '/'); await pool.query(`UPDATE artists SET portrait_path = $1 WHERE id = $2`, [portraitPath, artistId]); return { portraitPath }; } async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) { const result = await pool.query( `SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name FROM paintings p JOIN artists a ON a.id = p.artist_id WHERE p.id = $1`, [paintingId] ); if (result.rows.length === 0) { throw new Error('Painting not found'); } const row = result.rows[0]; const safeBase = safePaintingBase(row.artist_name, row.title); const paintingsDir = path.join(IMAGE_DIR, 'paintings'); const thumbsDir = path.join(IMAGE_DIR, 'paintings', 'thumbs'); if (!fs.existsSync(paintingsDir)) fs.mkdirSync(paintingsDir, { recursive: true }); if (!fs.existsSync(thumbsDir)) fs.mkdirSync(thumbsDir, { recursive: true }); const fullExt = pickExt(imageUrl); const fullDest = path.join(paintingsDir, safeBase + fullExt); const thumbDest = path.join(thumbsDir, safeBase + '_thumb.jpg'); unlinkIfExists(row.image_path ? path.join(IMAGE_DIR, row.image_path) : null); unlinkIfExists(row.thumbnail_path ? path.join(IMAGE_DIR, row.thumbnail_path) : null); unlinkIfExists(fullDest); unlinkIfExists(thumbDest); await downloadImageForFix(imageUrl, fullDest, context); let thumbnailPath = null; try { await generateThumbnailFromFull(fullDest, thumbDest); thumbnailPath = path.join('paintings', 'thumbs', safeBase + '_thumb.jpg').replace(/\\/g, '/'); } catch { thumbnailPath = path.join('paintings', safeBase + fullExt).replace(/\\/g, '/'); } const imagePath = path.join('paintings', safeBase + fullExt).replace(/\\/g, '/'); await pool.query( `UPDATE paintings SET image_path = $1, thumbnail_path = $2 WHERE id = $3`, [imagePath, thumbnailPath, paintingId] ); return { imagePath, thumbnailPath }; } async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) { const result = await pool.query( `SELECT id, name, portrait_path FROM artists WHERE id = $1`, [artistId] ); if (result.rows.length === 0) { throw new Error('Artist not found'); } const row = result.rows[0]; const safeBase = safeArtistPortraitBase(row.name); const portraitsDir = path.join(IMAGE_DIR, 'portraits'); if (!fs.existsSync(portraitsDir)) fs.mkdirSync(portraitsDir, { recursive: true }); const fullExt = pickExt(imageUrl); const fullDest = path.join(portraitsDir, safeBase + fullExt); unlinkIfExists(row.portrait_path ? path.join(IMAGE_DIR, row.portrait_path) : null); for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.JPG']) { unlinkIfExists(path.join(portraitsDir, safeBase + ext)); } await downloadImageForFix(imageUrl, fullDest, context); let portraitPath = path.join('portraits', safeBase + fullExt).replace(/\\/g, '/'); const jpgDest = path.join(portraitsDir, safeBase + '.jpg'); try { const sharp = require('sharp'); await sharp(fullDest) .rotate() .resize({ width: 900, height: 1100, fit: 'inside', withoutEnlargement: true }) .jpeg({ quality: 88 }) .toFile(jpgDest); if (fullDest !== jpgDest && fs.existsSync(fullDest)) { fs.unlinkSync(fullDest); } portraitPath = path.join('portraits', safeBase + '.jpg').replace(/\\/g, '/'); } catch { // keep downloaded file as-is } await pool.query(`UPDATE artists SET portrait_path = $1 WHERE id = $2`, [portraitPath, artistId]); return { portraitPath }; } module.exports = { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, IMAGE_DIR, };