diff --git a/category-syncer.js b/category-syncer.js index dea5241..55bc0cb 100644 --- a/category-syncer.js +++ b/category-syncer.js @@ -61,12 +61,11 @@ class CategorySyncer extends EventEmitter { try { await this._syncFromDb(); const duration = Date.now() - startTime; - console.log(`✅ Sync completed successfully in ${duration}ms.`); - // Log next sync time + // Log completion and next sync time const syncInterval = parseInt(process.env.SYNC_INTERVAL_MS) || 60000; const minutes = Math.round(syncInterval / 60000); - console.log(`⏰ Next sync in ${minutes} minute${minutes !== 1 ? 's' : ''}`); + console.log(`✅ Sync completed in ${duration}ms. Next sync in ${minutes} minute${minutes !== 1 ? 's' : ''}`); } catch (err) { console.error('❌ Sync failed:', err); } finally { diff --git a/index.js b/index.js index f4a4926..ff6c9d1 100644 --- a/index.js +++ b/index.js @@ -43,4 +43,4 @@ process.on('SIGINT', () => { }); // Start Express server -startServer(); +startServer(categorySyncer); diff --git a/picture-syncer.js b/picture-syncer.js index 74a2bd0..3c8ebd5 100644 --- a/picture-syncer.js +++ b/picture-syncer.js @@ -9,10 +9,48 @@ class PictureSyncer { 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 @@ -74,8 +112,12 @@ class PictureSyncer { for (const record of result.recordset) { if (record.bBild) { const filePath = path.join(dir, `${record.kBild}.avif`); - // Convert to AVIF using sharp + // 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); } diff --git a/server.js b/server.js index 6147a30..9f03181 100644 --- a/server.js +++ b/server.js @@ -6,27 +6,86 @@ import fs from 'fs/promises'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -export function startServer() { +export function startServer(categorySyncer) { const app = express(); const PORT = process.env.SERVER_PORT || 3000; const HOST = process.env.SERVER_HOST || '0.0.0.0'; const CACHE_DIR = process.env.CACHE_LOCATION || './cache'; - // Serve category tree JSON - app.get('/api/categories', async (req, res) => { + // Cache for ETags and data + const cache = { + categories: { etag: null, data: null }, + html: { etag: null, data: null } + }; + + // Function to calculate ETag for categories + async function updateCategoriesCache() { try { const treePath = path.join(CACHE_DIR, 'category_tree.json'); const data = await fs.readFile(treePath, 'utf-8'); - res.json(JSON.parse(data)); + const crypto = await import('crypto'); + cache.categories.etag = crypto.createHash('md5').update(data).digest('hex'); + cache.categories.data = data; + } catch (err) { + // Silently skip if file doesn't exist yet (will be created on first sync) + if (err.code !== 'ENOENT') { + console.error('Error updating categories cache:', err); + } + } + } + + // Function to calculate ETag for HTML + async function updateHtmlCache() { + try { + const htmlPath = path.join(__dirname, 'index.html'); + const data = await fs.readFile(htmlPath, 'utf-8'); + const crypto = await import('crypto'); + cache.html.etag = crypto.createHash('md5').update(data).digest('hex'); + cache.html.data = data; + } catch (err) { + console.error('Error updating HTML cache:', err); + } + } + + // Initialize caches on startup + updateHtmlCache(); + updateCategoriesCache(); + + // Update categories cache when sync completes + if (categorySyncer) { + categorySyncer.on('synced', ({ changed }) => { + if (changed) { + updateCategoriesCache(); + } + }); + } + + // Serve category tree JSON (with ETag for conditional caching) + app.get('/api/categories', async (req, res) => { + try { + // Check if client has cached version + if (req.headers['if-none-match'] === cache.categories.etag) { + return res.status(304).end(); // Not Modified + } + + // Set cache headers with ETag + res.set('Cache-Control', 'public, max-age=60, must-revalidate'); + res.set('ETag', cache.categories.etag); + + res.json(JSON.parse(cache.categories.data)); } catch (err) { res.status(500).json({ error: 'Failed to load category tree' }); } }); - // Serve category images + // Serve category images (long cache - images rarely change) app.get('/img/cat/:id.avif', (req, res) => { const { id } = req.params; const imagePath = path.join(CACHE_DIR, 'img', 'categories', `${id}.avif`); + + // Cache images for 1 year (immutable content) + res.set('Cache-Control', 'public, max-age=31536000, immutable'); + res.sendFile(path.resolve(imagePath), (err) => { if (err) { res.status(404).send('Image not found'); @@ -34,16 +93,24 @@ export function startServer() { }); }); - // Serve index.html - app.get('/', (req, res) => { - const htmlPath = path.join(__dirname, 'index.html'); - console.log('Attempting to serve:', htmlPath); - res.sendFile(htmlPath, (err) => { - if (err) { - console.error('Error serving index.html:', err); - res.status(500).send('Error loading page'); + // Serve index.html (with ETag for conditional caching) + app.get('/', async (req, res) => { + try { + // Check if client has cached version + if (req.headers['if-none-match'] === cache.html.etag) { + return res.status(304).end(); // Not Modified } - }); + + // Set cache headers with ETag + res.set('Cache-Control', 'public, max-age=300, must-revalidate'); + res.set('ETag', cache.html.etag); + res.set('Content-Type', 'text/html'); + + res.send(cache.html.data); + } catch (err) { + console.error('Error serving index.html:', err); + res.status(500).send('Error loading page'); + } }); app.listen(PORT, HOST, () => {