Fix disk sync, ensure, and preload paths that previously reused the full image as a thumb. Co-authored-by: Cursor <cursoragent@cursor.com>
630 lines
21 KiB
JavaScript
630 lines
21 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 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,
|
|
};
|