const fs = require('fs'); const path = require('path'); const pool = require('./db'); const { savePaintingImages, downloadImageToFile, 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 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; } async function replacePaintingImageFromUrl(paintingId, imageUrl) { 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 downloadImageToFile(imageUrl, fullDest, { force: true }); 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 }; } module.exports = { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, IMAGE_DIR, };