import fs from 'fs/promises'; import path from 'path'; import sharp from 'sharp'; import { createConnection } from './database.js'; class PictureSyncer { constructor() { if (PictureSyncer.instance) { return PictureSyncer.instance; } this.cacheBaseDir = process.env.CACHE_LOCATION || '.'; // Track syncing state per group this.isSyncing = new Map(); // groupName -> boolean this.queuedSyncs = new Map(); // groupName -> { imageIds, groupName } PictureSyncer.instance = this; } async syncImages(imageIds, groupName) { // Check if already syncing this group if (this.isSyncing.get(groupName)) { if (this.queuedSyncs.has(groupName)) { console.log(`🚫 Image sync for '${groupName}' already in progress and queued. Ignoring.`); return; } console.log(`⏳ Image sync for '${groupName}' already in progress. Queuing.`); this.queuedSyncs.set(groupName, { imageIds, groupName }); return; } await this._doSync(imageIds, groupName); } async _doSync(imageIds, groupName) { this.isSyncing.set(groupName, true); try { await this._performSync(imageIds, groupName); } finally { this.isSyncing.set(groupName, false); // Process queued sync for this group if any if (this.queuedSyncs.has(groupName)) { console.log(`🔄 Processing queued image sync for '${groupName}'...`); const queued = this.queuedSyncs.get(groupName); this.queuedSyncs.delete(groupName); setImmediate(() => this.syncImages(queued.imageIds, queued.groupName)); } } } async _performSync(imageIds, groupName) { const groupDir = path.join(this.cacheBaseDir, 'img', groupName); // Ensure directory exists await fs.mkdir(groupDir, { recursive: true }); // Get existing files let existingFiles = []; try { existingFiles = await fs.readdir(groupDir); } catch (err) { // Directory might be empty or new } // Filter for image files (assuming we save as {id}.avif) const existingIds = existingFiles .filter(f => f.endsWith('.avif')) .map(f => parseInt(f.replace('.avif', ''))); const validIds = new Set(imageIds.filter(id => id !== null && id !== undefined)); // 1. Delete obsolete images const toDelete = existingIds.filter(id => !validIds.has(id)); for (const id of toDelete) { const filePath = path.join(groupDir, `${id}.avif`); await fs.unlink(filePath); } if (toDelete.length > 0) { console.log(`🗑️ Deleted ${toDelete.length} obsolete images.`); } // 2. Download missing images const toDownload = imageIds.filter(id => id !== null && id !== undefined && !existingIds.includes(id)); if (toDownload.length > 0) { console.log(`📥 Downloading ${toDownload.length} new images for group '${groupName}'...`); await this._downloadImages(toDownload, groupDir); } else { console.log(`✅ No new images to download for group '${groupName}'.`); } } async _downloadImages(ids, dir) { let pool; try { pool = await createConnection(); // Process in chunks to avoid huge queries const chunkSize = 50; for (let i = 0; i < ids.length; i += chunkSize) { const chunk = ids.slice(i, i + chunkSize); const list = chunk.join(','); const result = await pool.request().query(` SELECT kBild, bBild FROM tBild WHERE kBild IN (${list}) `); for (const record of result.recordset) { if (record.bBild) { const filePath = path.join(dir, `${record.kBild}.avif`); // Resize to 130x130 and convert to AVIF using sharp await sharp(record.bBild) .resize(130, 130, { fit: 'cover', position: 'center' }) .avif({ quality: 80 }) .toFile(filePath); } } const processed = Math.min(i + chunkSize, ids.length); if (processed === ids.length) { console.log(`✅ Processed ${processed}/${ids.length} images.`); } else { console.log(`⏳ Processed ${processed}/${ids.length} images...`); } } } catch (err) { console.error('❌ Error downloading images:', err); } finally { if (pool) { await pool.close(); } } } } const instance = new PictureSyncer(); export default instance;