feat: server-side image optimization with sharp, persistent frame registry with source tracking
This commit is contained in:
@@ -3,6 +3,7 @@ const fetch = require('node-fetch');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const sharp = require('sharp');
|
||||||
const { WebSocketServer, WebSocket } = require('ws');
|
const { WebSocketServer, WebSocket } = require('ws');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
@@ -26,6 +27,27 @@ const SHOW_FAVORITES_ONLY = process.env.SHOW_FAVORITES_ONLY === 'true';
|
|||||||
const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL, 10) || 300;
|
const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL, 10) || 300;
|
||||||
const INCLUDE_VIDEOS = process.env.INCLUDE_VIDEOS !== 'false';
|
const INCLUDE_VIDEOS = process.env.INCLUDE_VIDEOS !== 'false';
|
||||||
|
|
||||||
|
// --- Image optimization ---
|
||||||
|
const IMAGE_MAX_WIDTH = parseInt(process.env.IMAGE_MAX_WIDTH, 10) || 1920;
|
||||||
|
const IMAGE_QUALITY = parseInt(process.env.IMAGE_QUALITY, 10) || 80;
|
||||||
|
const IMAGE_CACHE_MAX = parseInt(process.env.IMAGE_CACHE_MAX, 10) || 100;
|
||||||
|
|
||||||
|
const imageCache = new Map();
|
||||||
|
function cacheGet(key) {
|
||||||
|
const entry = imageCache.get(key);
|
||||||
|
if (!entry) return null;
|
||||||
|
imageCache.delete(key);
|
||||||
|
imageCache.set(key, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
function cacheSet(key, value) {
|
||||||
|
if (imageCache.size >= IMAGE_CACHE_MAX) {
|
||||||
|
const oldest = imageCache.keys().next().value;
|
||||||
|
imageCache.delete(oldest);
|
||||||
|
}
|
||||||
|
imageCache.set(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Auth configuration ---
|
// --- Auth configuration ---
|
||||||
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
|
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
|
||||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
|
||||||
@@ -97,7 +119,7 @@ function broadcastToAdmins(msg) {
|
|||||||
function getClientList() {
|
function getClientList() {
|
||||||
const list = [];
|
const list = [];
|
||||||
frameRegistry.forEach((f, ip) => {
|
frameRegistry.forEach((f, ip) => {
|
||||||
list.push({ id: f.id, ip: ip, name: f.name || '', status: f.status || 'offline', firstSeen: f.firstSeen, lastSeen: f.lastSeen, connectedAt: f.connectedAt, config: f.config || {} });
|
list.push({ id: f.id, ip: ip, name: f.name || '', status: f.status || 'offline', firstSeen: f.firstSeen, lastSeen: f.lastSeen, connectedAt: f.connectedAt, config: f.config || {}, source: f.source || null });
|
||||||
});
|
});
|
||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
@@ -141,9 +163,10 @@ wss.on('connection', (ws, req) => {
|
|||||||
frame.connectedAt = now;
|
frame.connectedAt = now;
|
||||||
frame.lastSeen = now;
|
frame.lastSeen = now;
|
||||||
if (msg.config) frame.config = msg.config;
|
if (msg.config) frame.config = msg.config;
|
||||||
|
if (msg.source) frame.source = msg.source;
|
||||||
log('Frame reconnected: ' + ip + ' ("' + frame.name + '")');
|
log('Frame reconnected: ' + ip + ' ("' + frame.name + '")');
|
||||||
} else {
|
} else {
|
||||||
frame = { id: ip.replace(/[.:]/g, '_'), ip: ip, name: '', status: msg.status || 'idle', firstSeen: now, connectedAt: now, lastSeen: now, config: msg.config || {}, ws: ws };
|
frame = { id: ip.replace(/[.:]/g, '_'), ip: ip, name: '', status: msg.status || 'idle', firstSeen: now, connectedAt: now, lastSeen: now, config: msg.config || {}, source: msg.source || null, ws: ws };
|
||||||
frameRegistry.set(ip, frame);
|
frameRegistry.set(ip, frame);
|
||||||
log('Frame registered: ' + ip);
|
log('Frame registered: ' + ip);
|
||||||
}
|
}
|
||||||
@@ -157,7 +180,8 @@ wss.on('connection', (ws, req) => {
|
|||||||
frame.status = msg.status || frame.status;
|
frame.status = msg.status || frame.status;
|
||||||
frame.lastSeen = new Date().toISOString();
|
frame.lastSeen = new Date().toISOString();
|
||||||
if (msg.config) frame.config = msg.config;
|
if (msg.config) frame.config = msg.config;
|
||||||
broadcastToAdmins({ type: 'clientUpdate', clientId: frame.id, client: { id: frame.id, ip, name: frame.name, status: frame.status, lastSeen: frame.lastSeen, config: frame.config } });
|
if (msg.source) frame.source = msg.source;
|
||||||
|
broadcastToAdmins({ type: 'clientUpdate', clientId: frame.id, client: { id: frame.id, ip, name: frame.name, status: frame.status, lastSeen: frame.lastSeen, config: frame.config, source: frame.source } });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -251,7 +275,7 @@ app.use(express.static(path.join(__dirname, 'public'), { setHeaders: (res, fp) =
|
|||||||
function mapAsset(a) { return { id: a.id, type: a.type, originalFileName: a.originalFileName, fileCreatedAt: a.fileCreatedAt, isFavorite: a.isFavorite, exifInfo: a.exifInfo ? { make: a.exifInfo.make, model: a.exifInfo.model, city: a.exifInfo.city, state: a.exifInfo.state, country: a.exifInfo.country, description: a.exifInfo.description, dateTimeOriginal: a.exifInfo.dateTimeOriginal } : null }; }
|
function mapAsset(a) { return { id: a.id, type: a.type, originalFileName: a.originalFileName, fileCreatedAt: a.fileCreatedAt, isFavorite: a.isFavorite, exifInfo: a.exifInfo ? { make: a.exifInfo.make, model: a.exifInfo.model, city: a.exifInfo.city, state: a.exifInfo.state, country: a.exifInfo.country, description: a.exifInfo.description, dateTimeOriginal: a.exifInfo.dateTimeOriginal } : null }; }
|
||||||
function filterAssets(assets) { return INCLUDE_VIDEOS ? assets.filter(a => a.type === 'IMAGE' || a.type === 'VIDEO') : assets.filter(a => a.type === 'IMAGE'); }
|
function filterAssets(assets) { return INCLUDE_VIDEOS ? assets.filter(a => a.type === 'IMAGE' || a.type === 'VIDEO') : assets.filter(a => a.type === 'IMAGE'); }
|
||||||
|
|
||||||
app.get('/api/config', (_req, res) => { res.json({ version: VERSION, slideshowInterval: SLIDESHOW_INTERVAL, transitionDuration: TRANSITION_DURATION, showClock: SHOW_CLOCK, showDate: SHOW_DATE, showExif: SHOW_EXIF, showProgress: SHOW_PROGRESS, imageFit: IMAGE_FIT, backgroundBlur: BACKGROUND_BLUR, shuffle: SHUFFLE, albumId: ALBUM_ID, showFavoritesOnly: SHOW_FAVORITES_ONLY, refreshInterval: REFRESH_INTERVAL, includeVideos: INCLUDE_VIDEOS, connected: !!API_KEY, authEnabled: AUTH_ENABLED }); });
|
app.get('/api/config', (_req, res) => { res.json({ version: VERSION, slideshowInterval: SLIDESHOW_INTERVAL, transitionDuration: TRANSITION_DURATION, showClock: SHOW_CLOCK, showDate: SHOW_DATE, showExif: SHOW_EXIF, showProgress: SHOW_PROGRESS, imageFit: IMAGE_FIT, backgroundBlur: BACKGROUND_BLUR, shuffle: SHUFFLE, albumId: ALBUM_ID, showFavoritesOnly: SHOW_FAVORITES_ONLY, refreshInterval: REFRESH_INTERVAL, includeVideos: INCLUDE_VIDEOS, connected: !!API_KEY, authEnabled: AUTH_ENABLED, imageMaxWidth: IMAGE_MAX_WIDTH, imageQuality: IMAGE_QUALITY }); });
|
||||||
app.get('/api/server-info', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/server/version', { headers: immichHeaders() }); if (!r.ok) throw new Error('Immich returned ' + r.status); const v = await r.json(); log('Immich OK v' + v.major + '.' + v.minor + '.' + v.patch); res.json({ ok: true, version: v }); } catch (e) { logErr('Immich failed: ' + e.message); res.status(502).json({ ok: false, error: e.message }); } });
|
app.get('/api/server-info', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/server/version', { headers: immichHeaders() }); if (!r.ok) throw new Error('Immich returned ' + r.status); const v = await r.json(); log('Immich OK v' + v.major + '.' + v.minor + '.' + v.patch); res.json({ ok: true, version: v }); } catch (e) { logErr('Immich failed: ' + e.message); res.status(502).json({ ok: false, error: e.message }); } });
|
||||||
app.get('/api/albums', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/albums', { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const a = await r.json(); log('Listed ' + a.length + ' albums'); res.json(a.map(x => ({ id: x.id, albumName: x.albumName, assetCount: x.assetCount, albumThumbnailAssetId: x.albumThumbnailAssetId, updatedAt: x.updatedAt }))); } catch (e) { logErr('Albums: ' + e.message); res.status(502).json({ error: e.message }); } });
|
app.get('/api/albums', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/albums', { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const a = await r.json(); log('Listed ' + a.length + ' albums'); res.json(a.map(x => ({ id: x.id, albumName: x.albumName, assetCount: x.assetCount, albumThumbnailAssetId: x.albumThumbnailAssetId, updatedAt: x.updatedAt }))); } catch (e) { logErr('Albums: ' + e.message); res.status(502).json({ error: e.message }); } });
|
||||||
app.get('/api/albums/:id', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/albums/' + req.params.id, { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const al = await r.json(); const a = filterAssets(al.assets || []).map(mapAsset); log('Album "' + al.albumName + '": ' + a.length + ' assets'); res.json({ id: al.id, albumName: al.albumName, assetCount: a.length, assets: a }); } catch (e) { logErr('Album: ' + e.message); res.status(502).json({ error: e.message }); } });
|
app.get('/api/albums/:id', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/albums/' + req.params.id, { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const al = await r.json(); const a = filterAssets(al.assets || []).map(mapAsset); log('Album "' + al.albumName + '": ' + a.length + ' assets'); res.json({ id: al.id, albumName: al.albumName, assetCount: a.length, assets: a }); } catch (e) { logErr('Album: ' + e.message); res.status(502).json({ error: e.message }); } });
|
||||||
@@ -260,7 +284,41 @@ app.get('/api/people/:id', async (req, res) => { try { const r = await fetch(IMM
|
|||||||
app.get('/api/people/:id/thumbnail', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/people/' + req.params.id + '/thumbnail', { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
|
app.get('/api/people/:id/thumbnail', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/people/' + req.params.id + '/thumbnail', { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
|
||||||
app.get('/api/assets/random', async (req, res) => { try { const c = Math.min(parseInt(req.query.count, 10) || 50, 250); const r = await fetch(IMMICH_URL + '/api/assets/random?count=' + c, { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); res.json(filterAssets(await r.json()).map(mapAsset)); } catch (e) { res.status(502).json({ error: e.message }); } });
|
app.get('/api/assets/random', async (req, res) => { try { const c = Math.min(parseInt(req.query.count, 10) || 50, 250); const r = await fetch(IMMICH_URL + '/api/assets/random?count=' + c, { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); res.json(filterAssets(await r.json()).map(mapAsset)); } catch (e) { res.status(502).json({ error: e.message }); } });
|
||||||
app.get('/api/assets/favorites', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/search/metadata', { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, size: 250, page: 1 }) }); if (!r.ok) throw new Error('' + r.status); const d = await r.json(); res.json(filterAssets(d.assets && d.assets.items ? d.assets.items : []).map(a => { var m = mapAsset(a); m.isFavorite = true; return m; })); } catch (e) { res.status(502).json({ error: e.message }); } });
|
app.get('/api/assets/favorites', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/search/metadata', { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, size: 250, page: 1 }) }); if (!r.ok) throw new Error('' + r.status); const d = await r.json(); res.json(filterAssets(d.assets && d.assets.items ? d.assets.items : []).map(a => { var m = mapAsset(a); m.isFavorite = true; return m; })); } catch (e) { res.status(502).json({ error: e.message }); } });
|
||||||
|
|
||||||
|
// --- Image endpoints ---
|
||||||
app.get('/api/assets/:id/thumbnail', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/assets/' + req.params.id + '/thumbnail?size=' + (req.query.size || 'preview'), { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
|
app.get('/api/assets/:id/thumbnail', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/assets/' + req.params.id + '/thumbnail?size=' + (req.query.size || 'preview'), { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
|
||||||
|
|
||||||
|
// Optimized: server-side resize + compress for low-powered frames
|
||||||
|
app.get('/api/assets/:id/optimized', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const w = Math.min(parseInt(req.query.w, 10) || IMAGE_MAX_WIDTH, 3840);
|
||||||
|
const q = Math.min(parseInt(req.query.q, 10) || IMAGE_QUALITY, 100);
|
||||||
|
const cacheKey = req.params.id + '_' + w + '_' + q;
|
||||||
|
const cached = cacheGet(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
res.set('Content-Type', cached.contentType);
|
||||||
|
res.set('Content-Length', cached.buffer.length);
|
||||||
|
res.set('Cache-Control', 'public, max-age=86400');
|
||||||
|
res.set('X-Frambe-Cache', 'hit');
|
||||||
|
return res.send(cached.buffer);
|
||||||
|
}
|
||||||
|
const r = await fetch(IMMICH_URL + '/api/assets/' + req.params.id + '/thumbnail?size=preview', { headers: { 'x-api-key': API_KEY } });
|
||||||
|
if (!r.ok) throw new Error('' + r.status);
|
||||||
|
const inputBuffer = Buffer.from(await r.arrayBuffer());
|
||||||
|
const optimized = await sharp(inputBuffer)
|
||||||
|
.resize(w, null, { withoutEnlargement: true, fit: 'inside' })
|
||||||
|
.jpeg({ quality: q, progressive: true })
|
||||||
|
.toBuffer();
|
||||||
|
cacheSet(cacheKey, { buffer: optimized, contentType: 'image/jpeg', timestamp: Date.now() });
|
||||||
|
log('Optimized ' + req.params.id + ': ' + inputBuffer.length + ' -> ' + optimized.length + ' bytes (' + w + 'px, q' + q + ')');
|
||||||
|
res.set('Content-Type', 'image/jpeg');
|
||||||
|
res.set('Content-Length', optimized.length);
|
||||||
|
res.set('Cache-Control', 'public, max-age=86400');
|
||||||
|
res.set('X-Frambe-Cache', 'miss');
|
||||||
|
res.send(optimized);
|
||||||
|
} catch (e) { logErr('Optimize: ' + e.message); res.status(502).json({ error: e.message }); }
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/assets/:id/video', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/assets/' + req.params.id + '/video/playback', { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'video/mp4'); res.set('Cache-Control', 'public, max-age=86400'); const cl = r.headers.get('content-length'); if (cl) res.set('Content-Length', cl); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
|
app.get('/api/assets/:id/video', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/assets/' + req.params.id + '/video/playback', { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'video/mp4'); res.set('Cache-Control', 'public, max-age=86400'); const cl = r.headers.get('content-length'); if (cl) res.set('Content-Length', cl); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
|
||||||
app.get('/api/assets/:id/original', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/assets/' + req.params.id + '/original', { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
|
app.get('/api/assets/:id/original', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/assets/' + req.params.id + '/original', { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
|
||||||
|
|
||||||
@@ -303,6 +361,7 @@ server.listen(PORT, '0.0.0.0', () => {
|
|||||||
log('API key: ' + (API_KEY ? 'configured (' + API_KEY.substring(0, 8) + '...)' : 'NOT SET'));
|
log('API key: ' + (API_KEY ? 'configured (' + API_KEY.substring(0, 8) + '...)' : 'NOT SET'));
|
||||||
log('Admin auth: ' + (AUTH_ENABLED ? 'ENABLED (user: ' + ADMIN_USERNAME + ')' : 'DISABLED (no ADMIN_PASSWORD set)'));
|
log('Admin auth: ' + (AUTH_ENABLED ? 'ENABLED (user: ' + ADMIN_USERNAME + ')' : 'DISABLED (no ADMIN_PASSWORD set)'));
|
||||||
log('API token: ' + (FRAMBE_API_TOKEN ? 'configured (' + FRAMBE_API_TOKEN.substring(0, 8) + '...)' : 'NOT SET (REST API open)'));
|
log('API token: ' + (FRAMBE_API_TOKEN ? 'configured (' + FRAMBE_API_TOKEN.substring(0, 8) + '...)' : 'NOT SET (REST API open)'));
|
||||||
|
log('Image optimization: max ' + IMAGE_MAX_WIDTH + 'px, quality ' + IMAGE_QUALITY + ', cache ' + IMAGE_CACHE_MAX + ' images');
|
||||||
log('Slideshow: ' + SLIDESHOW_INTERVAL + 's interval, refresh every ' + REFRESH_INTERVAL + 's');
|
log('Slideshow: ' + SLIDESHOW_INTERVAL + 's interval, refresh every ' + REFRESH_INTERVAL + 's');
|
||||||
log('Videos: ' + (INCLUDE_VIDEOS ? 'enabled' : 'disabled'));
|
log('Videos: ' + (INCLUDE_VIDEOS ? 'enabled' : 'disabled'));
|
||||||
log('Waiting for connections...');
|
log('Waiting for connections...');
|
||||||
|
|||||||
Reference in New Issue
Block a user