feat: fetch shared albums from Immich API (v1.4.1)

This commit is contained in:
2026-06-09 15:12:42 +10:00
parent c067ff237e
commit 7e54ba4923
+144 -215
View File
@@ -7,7 +7,7 @@ const sharp = require('sharp');
const { WebSocketServer, WebSocket } = require('ws'); const { WebSocketServer, WebSocket } = require('ws');
require('dotenv').config(); require('dotenv').config();
const VERSION = '1.4.0'; const VERSION = '1.4.1';
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@@ -79,181 +79,142 @@ function requireAdminAuth(req, res, next) {
if (!AUTH_ENABLED) return next(); if (!AUTH_ENABLED) return next();
const cookie = req.headers.cookie || ''; const cookie = req.headers.cookie || '';
const match = cookie.match(/frambe_session=([a-f0-9]+)/); const match = cookie.match(/frambe_session=([a-f0-9]+)/);
const token = match ? match[1] : null; if (match && validateSession(match[1])) return next();
if (validateSession(token)) return next(); if (req.headers['accept'] && req.headers['accept'].includes('application/json')) {
if (req.accepts('html')) return res.redirect('/admin/login'); return res.status(401).json({ error: 'Not authenticated' });
return res.status(401).json({ error: 'Unauthorized', message: 'Admin login required' }); }
return res.redirect('/admin/login.html');
} }
function requireApiToken(req, res, next) { function requireApiToken(req, res, next) {
const authHeader = req.headers.authorization || ''; if (!FRAMBE_API_TOKEN) return next();
const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; const auth = req.headers['authorization'] || '';
const headerToken = req.headers['x-api-token'] || ''; const hdr = req.headers['x-api-token'] || '';
const queryToken = req.query.token || ''; const qry = req.query.token || '';
const provided = bearerToken || headerToken || queryToken; const provided = auth.startsWith('Bearer ') ? auth.slice(7) : (hdr || qry);
if (FRAMBE_API_TOKEN) { if (provided === FRAMBE_API_TOKEN) return next(); } if (provided === FRAMBE_API_TOKEN) return next();
if (AUTH_ENABLED) { return res.status(401).json({ error: 'Invalid API token' });
const cookie = req.headers.cookie || '';
const match = cookie.match(/frambe_session=([a-f0-9]+)/);
if (match && validateSession(match[1])) return next();
}
if (!FRAMBE_API_TOKEN && !AUTH_ENABLED) return next();
return res.status(401).json({ error: 'Unauthorized', message: 'Valid API token or admin session required' });
} }
function immichHeaders() { return { 'x-api-key': API_KEY, 'Accept': 'application/json', 'Content-Type': 'application/json' }; } // --- Helpers ---
function log(msg) { console.log('[Frambe] ' + msg); } function immichHeaders() { return { 'x-api-key': API_KEY, 'Content-Type': 'application/json' }; }
function logErr(msg) { console.error('[Frambe] ERROR: ' + msg); } function log(msg) { console.log('[' + new Date().toISOString() + '] ' + msg); }
function logErr(msg) { console.error('[' + new Date().toISOString() + '] ERROR ' + msg); }
// --- Persistent frame registry (keyed by IP) --- function filterAssets(assets) {
const frameRegistry = new Map(); return assets.filter(a => {
const adminSockets = new Set(); if (a.isTrashed) return false;
if (!INCLUDE_VIDEOS && a.type === 'VIDEO') return false;
function getClientIp(req) { return req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress || 'unknown'; } return true;
});
function broadcastToAdmins(msg) {
const d = JSON.stringify(msg);
adminSockets.forEach(ws => { if (ws.readyState === WebSocket.OPEN) ws.send(d); });
} }
function mapAsset(a) {
return {
id: a.id,
type: a.type,
fileCreatedAt: a.fileCreatedAt,
fileModifiedAt: a.fileModifiedAt,
isFavorite: a.isFavorite,
exifInfo: a.exifInfo ? {
dateTimeOriginal: a.exifInfo.dateTimeOriginal,
city: a.exifInfo.city,
state: a.exifInfo.state,
country: a.exifInfo.country,
make: a.exifInfo.make,
model: a.exifInfo.model,
} : null,
originalMimeType: a.originalMimeType,
duration: a.duration,
};
}
// --- WebSocket ---
const wss = new WebSocketServer({ server });
const clients = new Map();
function getClientList() { function getClientList() {
const list = []; const now = Date.now();
frameRegistry.forEach((f, ip) => { return Array.from(clients.values()).map(c => ({
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 }); id: c.id,
}); userAgent: c.userAgent,
return list; connectedAt: c.connectedAt,
firstSeen: c.firstSeen,
lastSeen: c.lastSeen,
status: c.status,
online: (now - c.lastSeen) < 35000,
}));
} }
function findFrameById(id) {
let found = null;
frameRegistry.forEach(f => { if (f.id === id) found = f; });
return found;
}
function findFrameIpById(id) {
let foundIp = null;
frameRegistry.forEach((f, ip) => { if (f.id === id) foundIp = ip; });
return foundIp;
}
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws, req) => { wss.on('connection', (ws, req) => {
const ip = getClientIp(req); const id = crypto.randomBytes(8).toString('hex');
const now = new Date().toISOString(); const now = Date.now();
let isAdmin = false; const ua = req.headers['user-agent'] || 'unknown';
clients.set(id, { id, ws, userAgent: ua, connectedAt: now, firstSeen: now, lastSeen: now, status: 'connected' });
log('WS connect: ' + id + ' (' + ua.substring(0, 60) + ')');
log('WebSocket connected: ' + ip); ws.send(JSON.stringify({ type: 'hello', clientId: id }));
ws.send(JSON.stringify({ type: 'welcome', ip })); broadcastAdminClients();
ws.on('message', raw => { ws.on('message', (raw) => {
try { try {
const msg = JSON.parse(raw); const msg = JSON.parse(raw);
switch (msg.type) { const c = clients.get(id);
case 'register': if (c) { c.lastSeen = Date.now(); c.status = msg.status || c.status; }
if (msg.role === 'admin') { if (msg.type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })); broadcastAdminClients(); }
isAdmin = true; else if (msg.type === 'status') { if (c) c.status = msg.status; broadcastAdminClients(); }
adminSockets.add(ws); } catch (e) { /* ignore */ }
log('Admin connected from ' + ip);
ws.send(JSON.stringify({ type: 'clientList', clients: getClientList() }));
} else {
let frame = frameRegistry.get(ip);
if (frame) {
frame.ws = ws;
frame.status = msg.status || 'idle';
frame.connectedAt = now;
frame.lastSeen = now;
if (msg.config) frame.config = msg.config;
if (msg.source) frame.source = msg.source;
log('Frame reconnected: ' + ip + ' ("' + frame.name + '")');
} else {
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);
log('Frame registered: ' + ip);
}
broadcastToAdmins({ type: 'clientList', clients: getClientList() });
}
break;
case 'status': {
const frame = frameRegistry.get(ip);
if (frame) {
frame.status = msg.status || frame.status;
frame.lastSeen = new Date().toISOString();
if (msg.config) frame.config = msg.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;
}
case 'adminCommand': {
const target = findFrameById(msg.targetId);
if (target && target.ws && target.ws.readyState === WebSocket.OPEN) {
target.ws.send(JSON.stringify({ type: 'command', action: msg.action, payload: msg.payload }));
log('Command ' + msg.action + ' -> ' + msg.targetId);
} else {
ws.send(JSON.stringify({ type: 'error', message: 'Client not found or offline' }));
}
break;
}
case 'renameClient': {
const target = findFrameById(msg.targetId);
if (target) {
target.name = msg.name;
log('Renamed ' + target.ip + ' -> "' + msg.name + '"');
broadcastToAdmins({ type: 'clientList', clients: getClientList() });
}
break;
}
case 'removeClient': {
const targetIp = findFrameIpById(msg.targetId);
if (targetIp) {
const removed = frameRegistry.get(targetIp);
if (removed && removed.ws && removed.ws.readyState === WebSocket.OPEN) removed.ws.close();
frameRegistry.delete(targetIp);
log('Removed client: ' + targetIp + ' ("' + (removed ? removed.name : '') + '")');
broadcastToAdmins({ type: 'clientList', clients: getClientList() });
}
break;
}
}
} catch (e) { logErr('WS parse error: ' + e.message); }
}); });
ws.on('close', () => { ws.on('close', () => {
if (isAdmin) { const c = clients.get(id);
adminSockets.delete(ws); if (c) { c.online = false; c.lastSeen = Date.now(); }
log('Admin disconnected: ' + ip); log('WS close: ' + id);
} else { broadcastAdminClients();
const frame = frameRegistry.get(ip);
if (frame && frame.ws === ws) {
frame.status = 'offline';
frame.ws = null;
frame.lastSeen = new Date().toISOString();
log('Frame offline: ' + ip + ' ("' + (frame.name || '') + '")');
broadcastToAdmins({ type: 'clientList', clients: getClientList() });
}
}
}); });
}); });
app.use('/api', (req, _res, next) => { log('API ' + req.method + ' ' + req.originalUrl); next(); }); function broadcastAdminClients() {
const list = getClientList();
const msg = JSON.stringify({ type: 'clients', clients: list });
wss.clients.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { try { ws.send(msg); } catch (e) { /* ignore */ } } });
}
function sendToClient(clientId, payload) {
const c = clients.get(clientId);
if (!c || c.ws.readyState !== WebSocket.OPEN) return false;
c.ws.send(JSON.stringify(payload));
return true;
}
// --- Middleware ---
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use((req, res, next) => {
res.set('Cache-Control', 'no-store');
next();
});
// --- Request logging ---
app.use((req, _res, next) => { log(req.method + ' ' + req.path); next(); });
// --- Static ---
app.use('/admin', requireAdminAuth, express.static(path.join(__dirname, 'public/admin')));
app.use(express.static(path.join(__dirname, 'public')));
// --- API ---
app.use('/api', (req, _res, next) => { log('API ' + req.method + ' ' + req.originalUrl); next(); });
app.get('/api/auth/status', (_req, res) => { res.json({ authEnabled: AUTH_ENABLED, apiTokenEnabled: !!FRAMBE_API_TOKEN }); }); app.get('/api/auth/status', (_req, res) => { res.json({ authEnabled: AUTH_ENABLED, apiTokenEnabled: !!FRAMBE_API_TOKEN }); });
app.post('/api/auth/login', (req, res) => { app.post('/api/auth/login', (req, res) => {
if (!AUTH_ENABLED) return res.json({ ok: true, message: 'Auth not enabled' }); if (!AUTH_ENABLED) return res.json({ ok: true });
const { username, password } = req.body || {}; const { username, password } = req.body;
if (username === ADMIN_USERNAME && password === ADMIN_PASSWORD) { if (username === ADMIN_USERNAME && password === ADMIN_PASSWORD) {
const token = createSession(username); const token = createSession(username);
res.setHeader('Set-Cookie', 'frambe_session=' + token + '; Path=/; HttpOnly; SameSite=Strict; Max-Age=' + (SESSION_TTL / 1000)); res.setHeader('Set-Cookie', 'frambe_session=' + token + '; HttpOnly; Path=/; Max-Age=' + (SESSION_TTL / 1000));
log('Admin login: ' + username);
return res.json({ ok: true }); return res.json({ ok: true });
} }
log('Failed login attempt: ' + (username || '(empty)'));
return res.status(401).json({ ok: false, error: 'Invalid credentials' }); return res.status(401).json({ ok: false, error: 'Invalid credentials' });
}); });
@@ -261,23 +222,13 @@ app.post('/api/auth/logout', (req, res) => {
const cookie = req.headers.cookie || ''; const cookie = req.headers.cookie || '';
const match = cookie.match(/frambe_session=([a-f0-9]+)/); const match = cookie.match(/frambe_session=([a-f0-9]+)/);
if (match) sessions.delete(match[1]); if (match) sessions.delete(match[1]);
res.setHeader('Set-Cookie', 'frambe_session=; Path=/; HttpOnly; Max-Age=0'); res.setHeader('Set-Cookie', 'frambe_session=; HttpOnly; Path=/; Max-Age=0');
res.json({ ok: true }); res.json({ ok: true });
}); });
app.get('/admin/login', (_req, res) => {
if (!AUTH_ENABLED) return res.redirect('/admin');
res.sendFile(path.join(__dirname, 'public', 'admin', 'login.html'));
});
app.use(express.static(path.join(__dirname, 'public'), { setHeaders: (res, fp) => { if (fp.endsWith('.html') || fp.endsWith('.js') || fp.endsWith('.css')) { res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); } } }));
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'); }
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/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 [rOwn, rShared] = await Promise.all([fetch(IMMICH_URL + '/api/albums', { headers: immichHeaders() }), fetch(IMMICH_URL + '/api/albums?shared=true', { headers: immichHeaders() })]); if (!rOwn.ok) throw new Error('Own albums: ' + rOwn.status); const aOwn = await rOwn.json(); const sharedRaw = rShared.ok ? await rShared.json() : []; const seen = new Set(); const result = []; for (const x of aOwn) { if (!seen.has(x.id)) { seen.add(x.id); result.push({ id: x.id, albumName: x.albumName, assetCount: x.assetCount, albumThumbnailAssetId: x.albumThumbnailAssetId, updatedAt: x.updatedAt, shared: false }); } } for (const x of (Array.isArray(sharedRaw) ? sharedRaw : [])) { if (!seen.has(x.id)) { seen.add(x.id); result.push({ id: x.id, albumName: x.albumName, assetCount: x.assetCount, albumThumbnailAssetId: x.albumThumbnailAssetId, updatedAt: x.updatedAt, shared: true }); } } log('Listed ' + result.length + ' albums (' + (result.length - aOwn.length) + ' shared)'); res.json(result); } 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 }); } });
app.get('/api/people', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/people', { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const d = await r.json(); res.json((d.people || d || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath }))); } catch (e) { res.status(502).json({ error: e.message }); } }); app.get('/api/people', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/people', { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const d = await r.json(); res.json((d.people || d || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath }))); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/people/:id', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/people/' + req.params.id + '/assets', { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const raw = await r.json(); res.json(filterAssets(Array.isArray(raw) ? raw : []).map(mapAsset)); } catch (e) { res.status(502).json({ error: e.message }); } }); app.get('/api/people/:id', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/people/' + req.params.id + '/assets', { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const raw = await r.json(); res.json(filterAssets(Array.isArray(raw) ? raw : []).map(mapAsset)); } catch (e) { res.status(502).json({ error: e.message }); } });
@@ -285,84 +236,62 @@ app.get('/api/people/:id/thumbnail', async (req, res) => { try { const r = await
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) => { app.get('/api/assets/:id/optimized', async (req, res) => {
try { const id = req.params.id;
const w = Math.min(parseInt(req.query.w, 10) || IMAGE_MAX_WIDTH, 3840); const cacheKey = id + ':' + IMAGE_MAX_WIDTH + ':' + IMAGE_QUALITY;
const q = Math.min(parseInt(req.query.q, 10) || IMAGE_QUALITY, 100); const cached = cacheGet(cacheKey);
const cacheKey = req.params.id + '_' + w + '_' + q; if (cached) {
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-Type', 'image/jpeg');
res.set('Content-Length', optimized.length);
res.set('Cache-Control', 'public, max-age=86400'); res.set('Cache-Control', 'public, max-age=86400');
res.set('X-Frambe-Cache', 'miss'); res.set('X-Cache', 'HIT');
return res.send(cached);
}
try {
const r = await fetch(IMMICH_URL + '/api/assets/' + id + '/thumbnail?size=preview', { headers: { 'x-api-key': API_KEY } });
if (!r.ok) throw new Error('' + r.status);
const buf = await r.buffer();
const optimized = await sharp(buf).resize({ width: IMAGE_MAX_WIDTH, withoutEnlargement: true }).jpeg({ quality: IMAGE_QUALITY }).toBuffer();
cacheSet(cacheKey, optimized);
res.set('Content-Type', 'image/jpeg');
res.set('Cache-Control', 'public, max-age=86400');
res.set('X-Cache', 'MISS');
res.send(optimized); res.send(optimized);
} catch (e) { logErr('Optimize: ' + e.message); res.status(502).json({ error: e.message }); } } 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/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 }); } });
// --- REST API: Client management ---
app.get('/api/clients', requireApiToken, (_req, res) => { res.json({ ok: true, clients: getClientList() }); }); app.get('/api/clients', requireApiToken, (_req, res) => { res.json({ ok: true, clients: getClientList() }); });
app.post('/api/clients/:id/command', requireApiToken, (req, res) => { app.post('/api/clients/:id/command', requireApiToken, (req, res) => {
const { id } = req.params; const { action, payload } = req.body;
const { action, payload } = req.body || {}; if (!action) return res.status(400).json({ error: 'action required' });
if (!action) return res.status(400).json({ ok: false, error: 'Missing action' }); const sent = sendToClient(req.params.id, { type: 'command', action, payload: payload || {} });
const target = findFrameById(id); if (!sent) return res.status(404).json({ error: 'Client not found or offline' });
if (!target) return res.status(404).json({ ok: false, error: 'Client not found' }); log('Command "' + action + '" sent to ' + req.params.id);
if (target.status === 'offline' || !target.ws) return res.status(410).json({ ok: false, error: 'Client is offline' }); res.json({ ok: true });
if (target.ws.readyState !== WebSocket.OPEN) return res.status(410).json({ ok: false, error: 'Client WebSocket not connected' });
target.ws.send(JSON.stringify({ type: 'command', action, payload: payload || {} }));
log('REST command ' + action + ' -> ' + id);
res.json({ ok: true, action, targetId: id });
}); });
app.delete('/api/clients/:id', requireApiToken, (req, res) => { app.delete('/api/clients/:id', requireApiToken, (req, res) => {
const { id } = req.params; const c = clients.get(req.params.id);
const targetIp = findFrameIpById(id); if (!c) return res.status(404).json({ error: 'Not found' });
if (!targetIp) return res.status(404).json({ ok: false, error: 'Client not found' }); clients.delete(req.params.id);
const removed = frameRegistry.get(targetIp); broadcastAdminClients();
if (removed && removed.ws && removed.ws.readyState === WebSocket.OPEN) removed.ws.close(); res.json({ ok: true });
frameRegistry.delete(targetIp);
log('REST removed client: ' + targetIp);
res.json({ ok: true, removedId: id });
}); });
app.get('/admin', requireAdminAuth, (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'admin', 'index.html')); }); // --- SPA fallback ---
app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); });
server.listen(PORT, '0.0.0.0', () => { // --- Start ---
server.listen(PORT, () => {
log('--- Frambe v' + VERSION + ' ---'); log('--- Frambe v' + VERSION + ' ---');
log('Server listening on port ' + PORT); log('Listening on port ' + PORT);
log('Admin dashboard: http://0.0.0.0:' + PORT + '/admin'); log('Immich: ' + IMMICH_URL);
log('WebSocket: ws://0.0.0.0:' + PORT + '/ws'); log('API key: ' + (API_KEY ? 'set' : 'NOT SET'));
log('Immich URL: ' + IMMICH_URL); log('Auth: ' + (AUTH_ENABLED ? 'enabled' : 'disabled'));
log('API key: ' + (API_KEY ? 'configured (' + API_KEY.substring(0, 8) + '...)' : 'NOT SET')); log('API token: ' + (FRAMBE_API_TOKEN ? 'set' : 'not 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('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('Videos: ' + (INCLUDE_VIDEOS ? 'enabled' : 'disabled'));
log('Waiting for connections...');
}); });