Files
Art-gallery/server/image-service.js
T
Danila KhodjaefandCursor 21e3e41e48 Add one-command dev-to-prod release with clear SUCCESS/FAILED banners.
Introduce devtoprod:release orchestrator, config file, CLI result footers on deploy scripts, auto-thumb regeneration on curator fixes, and updated deploy documentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:54:56 +03:00

498 lines
16 KiB
JavaScript

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 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);
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 { 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 { 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 { 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;
return updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath);
}
const portraitPath = path.join('portraits', safeBase + '.jpg').replace(/\\/g, '/');
const portraitThumbPath = (await writePortraitThumb(jpgDest, safeBase)) || portraitPath;
return updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath);
}
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 { 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;
return updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath);
}
module.exports = {
ensurePaintingImages,
preloadArtistImagesLocal,
replacePaintingImageFromUrl,
replaceArtistPortraitFromUrl,
clearPaintingImage,
deletePainting,
clearArtistPortrait,
replacePaintingImageFromBuffer,
replaceArtistPortraitFromBuffer,
IMAGE_DIR,
};