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 PORTRAIT_THUMB_WIDTH = 256; const inflight = new Map(); function imageFileCacheKey(relPath) { if (!relPath || typeof relPath !== 'string') return null; const abs = path.join(IMAGE_DIR, relPath.replace(/^\//, '')); try { if (!fs.existsSync(abs)) return null; return Math.floor(fs.statSync(abs).mtimeMs); } catch { return null; } } function enrichPaintingRow(row) { if (!row || typeof row !== 'object') return row; return { ...row, image_cache_key: imageFileCacheKey(row.image_path), thumbnail_cache_key: imageFileCacheKey(row.thumbnail_path), }; } function enrichArtistRow(row) { if (!row || typeof row !== 'object') return row; return { ...row, portrait_cache_key: imageFileCacheKey(row.portrait_path), portrait_thumb_cache_key: imageFileCacheKey(row.portrait_thumb_path), }; } function paintingImagePayload(paths, extra = {}) { const imagePath = paths.imagePath ?? paths.image_path ?? null; const thumbnailPath = paths.thumbnailPath ?? paths.thumbnail_path ?? null; return { imagePath, thumbnailPath, image_cache_key: imageFileCacheKey(imagePath), thumbnail_cache_key: imageFileCacheKey(thumbnailPath), ...extra, }; } function artistPortraitPayload(paths, extra = {}) { const portraitPath = paths.portraitPath ?? paths.portrait_path ?? null; const portraitThumbPath = paths.portraitThumbPath ?? paths.portrait_thumb_path ?? null; return { portraitPath, portraitThumbPath, portrait_cache_key: imageFileCacheKey(portraitPath), portrait_thumb_cache_key: imageFileCacheKey(portraitThumbPath), ...extra, }; } 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 isPaintingThumbRel(rel) { if (!rel) return false; return rel.replace(/\\/g, '/').startsWith('paintings/thumbs/'); } /** Discover full/thumb files on disk for a painting basename. */ function discoverPaintingFilesOnDisk(safeBase) { let imagePath = null; let thumbPath = null; 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}`; } } const jpgThumb = path.join(IMAGE_DIR, 'paintings', 'thumbs', `${safeBase}_thumb.jpg`); if (!thumbPath && fs.existsSync(jpgThumb)) { thumbPath = `paintings/thumbs/${safeBase}_thumb.jpg`; } return { imagePath, thumbPath }; } /** * Ensure a dedicated thumbs/ file exists for a full painting image. * Regenerates from the full file when missing. */ async function ensurePaintingThumbFromFull(fullRel, safeBase) { if (!fullRel || !localFileExists(fullRel)) return null; const expectedThumb = `paintings/thumbs/${safeBase}_thumb.jpg`; if (localFileExists(expectedThumb)) return expectedThumb; try { return await writePaintingThumb(path.join(IMAGE_DIR, fullRel), safeBase); } catch (err) { console.warn(`Painting thumb generation failed for ${safeBase}:`, err.message); return null; } } /** Sync/link paths from disk; generate thumbnail when full exists without a thumbs/ file. */ async function syncPaintingFromDisk(row) { const safeBase = safePaintingBase(row.artist_name, row.title); let imagePath = localFileExists(row.image_path) ? row.image_path : null; let thumbPath = localFileExists(row.thumbnail_path) && isPaintingThumbRel(row.thumbnail_path) ? row.thumbnail_path : null; const discovered = discoverPaintingFilesOnDisk(safeBase); if (!imagePath && discovered.imagePath) imagePath = discovered.imagePath; if (!thumbPath && discovered.thumbPath) thumbPath = discovered.thumbPath; if (!thumbPath && imagePath) { thumbPath = (await ensurePaintingThumbFromFull(imagePath, safeBase)) || null; } return { imagePath, thumbPath }; } /** Fast preload: link local files only, no external API calls; regenerate missing thumbs from full files. */ 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 hasFull = localFileExists(row.image_path); const hasThumb = localFileExists(row.thumbnail_path) && isPaintingThumbRel(row.thumbnail_path); if (hasFull && hasThumb) { linked++; continue; } const synced = await 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'; const safeBase = safePaintingBase(row.artist_name, row.title); if (wantThumb && localFileExists(row.thumbnail_path) && isPaintingThumbRel(row.thumbnail_path)) { return row.thumbnail_path; } if (!wantThumb && localFileExists(row.image_path)) return row.image_path; // Full on disk but no dedicated thumb — regenerate before falling back to full file. if (wantThumb && localFileExists(row.image_path)) { const thumbRel = await ensurePaintingThumbFromFull(row.image_path, safeBase); if (thumbRel) { await pool.query(`UPDATE paintings SET thumbnail_path = $1 WHERE id = $2`, [ thumbRel, paintingId, ]); return thumbRel; } return row.image_path; } const synced = await 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) && isPaintingThumbRel(synced.thumbPath)) { return synced.thumbPath; } if (wantThumb && localFileExists(synced.imagePath)) { const thumbRel = await ensurePaintingThumbFromFull(synced.imagePath, safeBase); if (thumbRel) { await pool.query(`UPDATE paintings SET thumbnail_path = $1 WHERE id = $2`, [ thumbRel, paintingId, ]); return thumbRel; } 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); unlinkIfExists(row.portrait_thumb_path ? path.join(IMAGE_DIR, row.portrait_thumb_path) : null); const portraitsDir = path.join(IMAGE_DIR, 'portraits'); const thumbsDir = path.join(portraitsDir, 'thumbs'); for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.JPG']) { unlinkIfExists(path.join(portraitsDir, safeBase + ext)); } unlinkIfExists(path.join(thumbsDir, safeBase + '_thumb.jpg')); unlinkIfExists(path.join(thumbsDir, safeBase + '_thumb' + '.jpeg')); } async function writePortraitThumb(fullPath, safeBase) { const thumbsDir = path.join(IMAGE_DIR, 'portraits', 'thumbs'); if (!fs.existsSync(thumbsDir)) fs.mkdirSync(thumbsDir, { recursive: true }); const thumbDest = path.join(thumbsDir, `${safeBase}_thumb.jpg`); try { await generateThumbnailFromFull(fullPath, thumbDest, PORTRAIT_THUMB_WIDTH); return path.join('portraits', 'thumbs', `${safeBase}_thumb.jpg`).replace(/\\/g, '/'); } catch (err) { console.warn(`Portrait thumb generation failed for ${safeBase}:`, err.message); return null; } } /** Regenerate painting thumb from the saved full image (debug fix/upload, ~400px JPEG). */ async function writePaintingThumb(fullPath, safeBase) { const thumbsDir = path.join(IMAGE_DIR, 'paintings', 'thumbs'); if (!fs.existsSync(thumbsDir)) fs.mkdirSync(thumbsDir, { recursive: true }); const thumbDest = path.join(thumbsDir, `${safeBase}_thumb.jpg`); await generateThumbnailFromFull(fullPath, thumbDest); return path.join('paintings', 'thumbs', `${safeBase}_thumb.jpg`).replace(/\\/g, '/'); } async function updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath) { await pool.query( `UPDATE artists SET portrait_path = $1, portrait_thumb_path = $2 WHERE id = $3`, [portraitPath, portraitThumbPath, artistId] ); return { portraitPath, portraitThumbPath }; } 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 paintingImagePayload({ imagePath: null, thumbnailPath: null }); } async function clearArtistPortrait(artistId) { const result = await pool.query( `SELECT id, name, portrait_path, portrait_thumb_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, portrait_thumb_path = NULL WHERE id = $1`, [artistId] ); return artistPortraitPayload({ portraitPath: null, portraitThumbPath: 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); unlinkPaintingFiles(row, safeBase); fs.writeFileSync(fullDest, buffer); let thumbnailPath; try { thumbnailPath = await writePaintingThumb(fullDest, safeBase); } catch (err) { console.warn(`Painting thumb generation failed for painting ${paintingId}:`, err.message); 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 paintingImagePayload({ 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, portrait_thumb_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, '/'); const portraitThumbPath = (await writePortraitThumb(fullDest, safeBase)) || portraitPath; const updated = await updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath); return artistPortraitPayload(updated); } const portraitPath = path.join('portraits', safeBase + '.jpg').replace(/\\/g, '/'); const portraitThumbPath = (await writePortraitThumb(jpgDest, safeBase)) || portraitPath; const updated = await updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath); return artistPortraitPayload(updated); } 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); unlinkPaintingFiles(row, safeBase); await downloadImageForFix(imageUrl, fullDest, context); let thumbnailPath; try { thumbnailPath = await writePaintingThumb(fullDest, safeBase); } catch (err) { console.warn(`Painting thumb generation failed for painting ${paintingId}:`, err.message); 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 paintingImagePayload({ imagePath, thumbnailPath }); } async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) { const result = await pool.query( `SELECT id, name, portrait_path, portrait_thumb_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); unlinkPortraitFiles(row, safeBase); 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 } const sourceForThumb = fs.existsSync(jpgDest) ? jpgDest : path.join(IMAGE_DIR, portraitPath); const portraitThumbPath = (await writePortraitThumb(sourceForThumb, safeBase)) || portraitPath; const updated = await updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath); return artistPortraitPayload(updated); } module.exports = { ensurePaintingImages, preloadArtistImagesLocal, replacePaintingImageFromUrl, replaceArtistPortraitFromUrl, clearPaintingImage, deletePainting, clearArtistPortrait, replacePaintingImageFromBuffer, replaceArtistPortraitFromBuffer, enrichPaintingRow, enrichArtistRow, paintingImagePayload, artistPortraitPayload, imageFileCacheKey, IMAGE_DIR, };