v1.4.1 — Shared Albums #2

Merged
jessikitty merged 30 commits from dev into main 2026-06-09 16:20:26 +10:00
Showing only changes of commit 213a7bd737 - Show all commits
+224 -1
View File
@@ -1 +1,224 @@
Y29uc3QgZXhwcmVzcyA9IHJlcXVpcmUoJ2V4cHJlc3MnKTsKY29uc3QgZmV0Y2ggPSByZXF1aXJlKCdub2RlLWZldGNoJyk7CmNvbnN0IHBhdGggPSByZXF1aXJlKCdwYXRoJyk7CmNvbnN0IGh0dHAgPSByZXF1aXJlKCdodHRwJyk7CmNvbnN0IGNyeXB0byA9IHJlcXVpcmUoJ2NyeXB0bycpOwpjb25zdCB7IFdlYlNvY2tldFNlcnZlciwgV2ViU29ja2V0IH0gPSByZXF1aXJlKCd3cycpOwpyZXF1aXJlKCdkb3RlbnYnKS5jb25maWcoKTsKCmNvbnN0IFZFUlNJT04gPSAnMS40LjAnOwpjb25zdCBhcHAgPSBleHByZXNzKCk7CmNvbnN0IHNlcnZlciA9IGh0dHAuY3JlYXRlU2VydmVyKGFwcCk7CmNvbnN0IFBPUlQgPSBwcm9jZXNzLmVudi5QT1JUIHx8IDMwMDA7CmNvbnN0IElNTUlDSF9VUkwgPSAocHJvY2Vzcy5lbnYuSU1NSUNIX1VSTCB8fCAnaHR0cDovL2xvY2FsaG9zdDoyMjgzJykucmVwbGFjZSgvXC8rJC8sICcnKTsKY29uc3QgQVBJX0tFWSA9IHByb2Nlc3MuZW52LklNTUlDSF9BUElfS0VZIHx8ICcnOwpjb25zdCBTTElERVNIT1dfSU5URVJWQUwgPSBwYXJzZUludChwcm9jZXNzLmVudi5TTElERVNIT1dfSU5URVJWQUwsIDEwKSB8fCAzMDsKY29uc3QgVFJBTlNJVElPTl9EVVJBVElPTiA9IHBhcnNlSW50KHByb2Nlc3MuZW52LlRSQU5TSVRJT05fRFVSQVRJT04sIDEwKSB8fCAyOwpjb25zdCBTSE9XX0NMT0NLID0gcHJvY2Vzcy5lbnYuU0hPV19DTE9DSyAhPT0gJ2ZhbHNlJzsKY29uc3QgU0hPV19EQVRFID0gcHJvY2Vzcy5lbnYuU0hPV19EQVRFICE9PSAnZmFsc2UnOwpjb25zdCBTSE9XX0VYSUYgPSBwcm9jZXNzLmVudi5TSE9XX0VYSUYgIT09ICdmYWxzZSc7CmNvbnN0IFNIT1dfUFJPR1JFU1MgPSBwcm9jZXNzLmVudi5TSE9XX1BST0dSRVNTICE9PSAnZmFsc2UnOwpjb25zdCBJTUFHRV9GSVQgPSBwcm9jZXNzLmVudi5JTUFHRV9GSVQgfHwgJ2NvbnRhaW4nOwpjb25zdCBCQUNLR1JPVU5EX0JMVVIgPSBwcm9jZXNzLmVudi5CQUNLR1JPVU5EX0JMVVIgIT09ICdmYWxzZSc7CmNvbnN0IFNIVUZGTEUgPSBwcm9jZXNzLmVudi5TSFVGRkxFICE9PSAnZmFsc2UnOwpjb25zdCBBTEJVTV9JRCA9IHByb2Nlc3MuZW52LkFMQlVNX0lEIHx8ICcnOwpjb25zdCBTSE9XX0ZBVk9SSVRFU19PTkxZID0gcHJvY2Vzcy5lbnYuU0hPV19GQVZPUklURVNfT05MWSA9PT0gJ3RydWUnOwpjb25zdCBSRUZSRVNIX0lOVEVSVkFMID0gcGFyc2VJbnQocHJvY2Vzcy5lbnYuUkVGUkVTSF9JTlRFUlZBTCwgMTApIHx8IDMwMDsKY29uc3QgSU5DTFVERV9WSURFT1MgPSBwcm9jZXNzLmVudi5JTkNMVURFX1ZJREVPUyAhPT0gJ2ZhbHNlJzsKCi8vIC0tLSBBdXRoIGNvbmZpZ3VyYXRpb24gLS0tCmNvbnN0IEFETUlOX1VTRVJOQU1FID0gcHJvY2Vzcy5lbnYuQURNSU5fVVNFUk5BTUUgfHwgJ2FkbWluJzsKY29uc3QgQURNSU5fUEFTU1dPUkQgPSBwcm9jZXNzLmVudi5BRE1JTl9QQVNTV09SRCB8fCAnJzsKY29uc3QgRlJBTUJFX0FQSV9UT0tFTiA9IHByb2Nlc3MuZW52LkZSQU1CRV9BUElfVE9LRU4gfHwgJyc7CmNvbnN0IEFVVEhfRU5BQkxFRCA9ICEhQURNSU5fUEFTU1dPUkQ7CgovLyBTZXNzaW9uIHN0b3JlOiB0b2tlbiAtPiB7IHVzZXJuYW1lLCBjcmVhdGVkQXQsIGV4cGlyZXNBdCB9CmNvbnN0IHNlc3Npb25zID0gbmV3IE1hcCgpOwpjb25zdCBTRVNTSU9OX1RUTCA9IDI0ICogNjAgKiA2MCAqIDEwMDA7IC8vIDI0IGhvdXJzCgpmdW5jdGlvbiBjcmVhdGVTZXNzaW9uKHVzZXJuYW1lKSB7CiAgY29uc3QgdG9rZW4gPSBjcnlwdG8ucmFuZG9tQnl0ZXMoMzIpLnRvU3RyaW5nKCdoZXgnKTsKICBjb25zdCBub3cgPSBEYXRlLm5vdygpOwogIHNlc3Npb25zLnNldCh0b2tlbiwgeyB1c2VybmFtZSwgY3JlYXRlZEF0OiBub3csIGV4cGlyZXNBdDogbm93ICsgU0VTU0lPTl9UVEwgfSk7CiAgcmV0dXJuIHRva2VuOwp9CgpmdW5jdGlvbiB2YWxpZGF0ZVNlc3Npb24odG9rZW4pIHsKICBpZiAoIXRva2VuKSByZXR1cm4gZmFsc2U7CiAgY29uc3Qgc2Vzc2lvbiA9IHNlc3Npb25zLmdldCh0b2tlbik7CiAgaWYgKCFzZXNzaW9uKSByZXR1cm4gZmFsc2U7CiAgaWYgKERhdGUubm93KCkgPiBzZXNzaW9uLmV4cGlyZXNBdCkgeyBzZXNzaW9ucy5kZWxldGUodG9rZW4pOyByZXR1cm4gZmFsc2U7IH0KICByZXR1cm4gdHJ1ZTsKfQoKZnVuY3Rpb24gY2xlYW51cFNlc3Npb25zKCkgeyBjb25zdCBub3cgPSBEYXRlLm5vdygpOyBzZXNzaW9ucy5mb3JFYWNoKChzLCB0KSA9PiB7IGlmIChub3cgPiBzLmV4cGlyZXNBdCkgc2Vzc2lvbnMuZGVsZXRlKHQpOyB9KTsgfQpzZXRJbnRlcnZhbChjbGVhbnVwU2Vzc2lvbnMsIDYwICogNjAgKiAxMDAwKTsgLy8gY2xlYW51cCBldmVyeSBob3VyCgovLyAtLS0gQWRtaW4gYXV0aCBtaWRkbGV3YXJlIChjb29raWUtYmFzZWQgZm9yIGJyb3dzZXIpIC0tLQpmdW5jdGlvbiByZXF1aXJlQWRtaW5BdXRoKHJlcSwgcmVzLCBuZXh0KSB7CiAgaWYgKCFBVVRIX0VOQUJMRUQpIHJldHVybiBuZXh0KCk7CiAgY29uc3QgY29va2llID0gcmVxLmhlYWRlcnMuY29va2llIHx8ICcnOwogIGNvbnN0IG1hdGNoID0gY29va2llLm1hdGNoKC9mcmFtYmVfc2Vzc2lvbj0oW2EtZjAtOV0rKS8pOwogIGNvbnN0IHRva2VuID0gbWF0Y2ggPyBtYXRjaFsxXSA6IG51bGw7CiAgaWYgKHZhbGlkYXRlU2Vzc2lvbih0b2tlbikpIHJldHVybiBuZXh0KCk7CiAgLy8gTm90IGF1dGhlbnRpY2F0ZWQg4oCUIGlmIHJlcXVlc3RpbmcgSFRNTCwgcmVkaXJlY3QgdG8gbG9naW47IG90aGVyd2lzZSA0MDEKICBpZiAocmVxLmFjY2VwdHMoJ2h0bWwnKSkgcmV0dXJuIHJlcy5yZWRpcmVjdCgnL2FkbWluL2xvZ2luJyk7CiAgcmV0dXJuIHJlcy5zdGF0dXMoNDAxKS5qc29uKHsgZXJyb3I6ICdVbmF1dGhvcml6ZWQnLCBtZXNzYWdlOiAnQWRtaW4gbG9naW4gcmVxdWlyZWQnIH0pOwp9CgovLyAtLS0gQVBJIHRva2VuIG1pZGRsZXdhcmUgKGZvciBleHRlcm5hbCBjYWxsZXJzIGxpa2UgSG9tZSBBc3Npc3RhbnQpIC0tLQpmdW5jdGlvbiByZXF1aXJlQXBpVG9rZW4ocmVxLCByZXMsIG5leHQpIHsKICAvLyBBY2NlcHQgZWl0aGVyOiBCZWFyZXIgdG9rZW4gaW4gQXV0aG9yaXphdGlvbiBoZWFkZXIsIG9yIHgtYXBpLXRva2VuIGhlYWRlciwgb3IgP3Rva2VuPSBxdWVyeSBwYXJhbQogIGNvbnN0IGF1dGhIZWFkZXIgPSByZXEuaGVhZGVycy5hdXRob3JpemF0aW9uIHx8ICcnOwogIGNvbnN0IGJlYXJlclRva2VuID0gYXV0aEhlYWRlci5zdGFydHNXaXRoKCdCZWFyZXIgJykgPyBhdXRoSGVhZGVyLnNsaWNlKDcpIDogJyc7CiAgY29uc3QgaGVhZGVyVG9rZW4gPSByZXEuaGVhZGVyc1sneC1hcGktdG9rZW4nXSB8fCAnJzsKICBjb25zdCBxdWVyeVRva2VuID0gcmVxLnF1ZXJ5LnRva2VuIHx8ICcnOwogIGNvbnN0IHByb3ZpZGVkID0gYmVhcmVyVG9rZW4gfHwgaGVhZGVyVG9rZW4gfHwgcXVlcnlUb2tlbjsKCiAgLy8gSWYgQVBJIHRva2VuIGlzIGNvbmZpZ3VyZWQsIHJlcXVpcmUgaXQKICBpZiAoRlJBTUJFX0FQSV9UT0tFTikgewogICAgaWYgKHByb3ZpZGVkID09PSBGUkFNQkVfQVBJX1RPS0VOKSByZXR1cm4gbmV4dCgpOwogIH0KCiAgLy8gQWxzbyBhY2NlcHQgdmFsaWQgYWRtaW4gc2Vzc2lvbiBjb29raWUKICBpZiAoQVVUSF9FTkFCTEVEKSB7CiAgICBjb25zdCBjb29raWUgPSByZXEuaGVhZGVycy5jb29raWUgfHwgJyc7CiAgICBjb25zdCBtYXRjaCA9IGNvb2tpZS5tYXRjaCgvZnJhbWJlX3Nlc3Npb249KFthLWYwLTlLine truncated const express = require('express');
const fetch = require('node-fetch');
const path = require('path');
const http = require('http');
const crypto = require('crypto');
const { WebSocketServer, WebSocket } = require('ws');
require('dotenv').config();
const VERSION = '1.4.0';
const app = express();
const server = http.createServer(app);
const PORT = process.env.PORT || 3000;
const IMMICH_URL = (process.env.IMMICH_URL || 'http://localhost:2283').replace(/\/+$/, '');
const API_KEY = process.env.IMMICH_API_KEY || '';
const SLIDESHOW_INTERVAL = parseInt(process.env.SLIDESHOW_INTERVAL, 10) || 30;
const TRANSITION_DURATION = parseInt(process.env.TRANSITION_DURATION, 10) || 2;
const SHOW_CLOCK = process.env.SHOW_CLOCK !== 'false';
const SHOW_DATE = process.env.SHOW_DATE !== 'false';
const SHOW_EXIF = process.env.SHOW_EXIF !== 'false';
const SHOW_PROGRESS = process.env.SHOW_PROGRESS !== 'false';
const IMAGE_FIT = process.env.IMAGE_FIT || 'contain';
const BACKGROUND_BLUR = process.env.BACKGROUND_BLUR !== 'false';
const SHUFFLE = process.env.SHUFFLE !== 'false';
const ALBUM_ID = process.env.ALBUM_ID || '';
const SHOW_FAVORITES_ONLY = process.env.SHOW_FAVORITES_ONLY === 'true';
const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL, 10) || 300;
const INCLUDE_VIDEOS = process.env.INCLUDE_VIDEOS !== 'false';
// --- Auth configuration ---
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
const FRAMBE_API_TOKEN = process.env.FRAMBE_API_TOKEN || '';
const AUTH_ENABLED = !!ADMIN_PASSWORD;
// Session store: token -> { username, createdAt, expiresAt }
const sessions = new Map();
const SESSION_TTL = 24 * 60 * 60 * 1000; // 24 hours
function createSession(username) {
const token = crypto.randomBytes(32).toString('hex');
const now = Date.now();
sessions.set(token, { username, createdAt: now, expiresAt: now + SESSION_TTL });
return token;
}
function validateSession(token) {
if (!token) return false;
const session = sessions.get(token);
if (!session) return false;
if (Date.now() > session.expiresAt) { sessions.delete(token); return false; }
return true;
}
function cleanupSessions() { const now = Date.now(); sessions.forEach((s, t) => { if (now > s.expiresAt) sessions.delete(t); }); }
setInterval(cleanupSessions, 60 * 60 * 1000); // cleanup every hour
// --- Admin auth middleware (cookie-based for browser) ---
function requireAdminAuth(req, res, next) {
if (!AUTH_ENABLED) return next();
const cookie = req.headers.cookie || '';
const match = cookie.match(/frambe_session=([a-f0-9]+)/);
const token = match ? match[1] : null;
if (validateSession(token)) return next();
if (req.accepts('html')) return res.redirect('/admin/login');
return res.status(401).json({ error: 'Unauthorized', message: 'Admin login required' });
}
// --- API token middleware (for external callers like Home Assistant) ---
function requireApiToken(req, res, next) {
const authHeader = req.headers.authorization || '';
const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
const headerToken = req.headers['x-api-token'] || '';
const queryToken = req.query.token || '';
const provided = bearerToken || headerToken || queryToken;
if (FRAMBE_API_TOKEN) {
if (provided === FRAMBE_API_TOKEN) return next();
}
if (AUTH_ENABLED) {
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' }; }
function log(msg) { console.log('[Frambe] ' + msg); }
function logErr(msg) { console.error('[Frambe] ERROR: ' + msg); }
const clients = new Map();
let clientNameStore = {};
function getClientIp(req) { return req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress || 'unknown'; }
function generateClientId(ip) { return ip.replace(/[.:]/g, '_'); }
function broadcastToAdmins(msg) { const d = JSON.stringify(msg); clients.forEach(c => { if (c.role === 'admin' && c.ws.readyState === WebSocket.OPEN) c.ws.send(d); }); }
function getClientList() { const list = []; clients.forEach((c, id) => { if (c.role === 'frame') list.push({ id, ip: c.ip, name: c.name || clientNameStore[c.ip] || '', status: c.status || 'unknown', connectedAt: c.connectedAt, lastSeen: c.lastSeen, config: c.config || {} }); }); return list; }
const wss = new WebSocketServer({ server, path: '/ws' });
wss.on('connection', (ws, req) => {
const ip = getClientIp(req);
const clientId = generateClientId(ip) + '_' + Date.now();
log('WebSocket connected: ' + ip + ' (' + clientId + ')');
const info = { ws, ip, role: 'frame', name: clientNameStore[ip] || '', status: 'connected', connectedAt: new Date().toISOString(), lastSeen: new Date().toISOString(), config: {} };
clients.set(clientId, info);
ws.send(JSON.stringify({ type: 'welcome', clientId, name: info.name }));
ws.on('message', raw => {
try {
const msg = JSON.parse(raw); info.lastSeen = new Date().toISOString();
switch (msg.type) {
case 'register':
info.role = msg.role || 'frame';
if (msg.role === 'admin') { log('Admin connected from ' + ip); ws.send(JSON.stringify({ type: 'clientList', clients: getClientList() })); }
else { log('Frame registered: ' + ip); info.status = msg.status || 'idle'; info.config = msg.config || {}; broadcastToAdmins({ type: 'clientList', clients: getClientList() }); }
break;
case 'status':
info.status = msg.status || info.status; if (msg.config) info.config = msg.config;
broadcastToAdmins({ type: 'clientUpdate', clientId, client: { id: clientId, ip: info.ip, name: info.name, status: info.status, lastSeen: info.lastSeen, config: info.config } });
break;
case 'adminCommand':
const target = clients.get(msg.targetId);
if (target && 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' }));
break;
case 'renameClient':
const rt = clients.get(msg.targetId);
if (rt) { rt.name = msg.name; clientNameStore[rt.ip] = msg.name; log('Renamed ' + msg.targetId + ' -> "' + msg.name + '"'); broadcastToAdmins({ type: 'clientList', clients: getClientList() }); }
break;
}
} catch (e) { logErr('WS parse error: ' + e.message); }
});
ws.on('close', () => { log('WebSocket disconnected: ' + ip); clients.delete(clientId); broadcastToAdmins({ type: 'clientList', clients: getClientList() }); });
});
app.use('/api', (req, _res, next) => { log('API ' + req.method + ' ' + req.originalUrl); next(); });
app.use(express.json());
// --- Auth endpoints ---
app.get('/api/auth/status', (_req, res) => {
res.json({ authEnabled: AUTH_ENABLED, apiTokenEnabled: !!FRAMBE_API_TOKEN });
});
app.post('/api/auth/login', (req, res) => {
if (!AUTH_ENABLED) return res.json({ ok: true, message: 'Auth not enabled' });
const { username, password } = req.body || {};
if (username === ADMIN_USERNAME && password === ADMIN_PASSWORD) {
const token = createSession(username);
res.setHeader('Set-Cookie', `frambe_session=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${SESSION_TTL / 1000}`);
log('Admin login: ' + username);
return res.json({ ok: true });
}
log('Failed login attempt: ' + (username || '(empty)'));
return res.status(401).json({ ok: false, error: 'Invalid credentials' });
});
app.post('/api/auth/logout', (req, res) => {
const cookie = req.headers.cookie || '';
const match = cookie.match(/frambe_session=([a-f0-9]+)/);
if (match) sessions.delete(match[1]);
res.setHeader('Set-Cookie', 'frambe_session=; Path=/; HttpOnly; Max-Age=0');
res.json({ ok: true });
});
// --- Login page (served without auth) ---
app.get('/admin/login', (_req, res) => {
if (!AUTH_ENABLED) return res.redirect('/admin');
res.sendFile(path.join(__dirname, 'public', 'admin', 'login.html'));
});
// --- Static files (non-admin pages don't require auth) ---
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 }); });
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/: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/: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/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/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?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true }))); } 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 }); } });
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 }); } });
// --- REST API: Client management (token-authenticated for Home Assistant etc.) ---
app.get('/api/clients', requireApiToken, (_req, res) => {
res.json({ ok: true, clients: getClientList() });
});
app.post('/api/clients/:id/command', requireApiToken, (req, res) => {
const { id } = req.params;
const { action, payload } = req.body || {};
if (!action) return res.status(400).json({ ok: false, error: 'Missing action' });
const target = clients.get(id);
if (!target) return res.status(404).json({ ok: false, error: 'Client not found' });
if (target.role !== 'frame') return res.status(400).json({ ok: false, error: 'Target is not a frame client' });
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 });
});
// --- Admin dashboard (auth-protected) ---
app.get('/admin', requireAdminAuth, (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'admin', 'index.html')); });
// --- Catch-all for frame SPA ---
app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); });
server.listen(PORT, '0.0.0.0', () => {
log('--- Frambe v' + VERSION + ' ---');
log('Server listening on port ' + PORT);
log('Admin dashboard: http://0.0.0.0:' + PORT + '/admin');
log('WebSocket: ws://0.0.0.0:' + PORT + '/ws');
log('Immich URL: ' + IMMICH_URL);
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('API token: ' + (FRAMBE_API_TOKEN ? 'configured (' + FRAMBE_API_TOKEN.substring(0, 8) + '...)' : 'NOT SET (REST API open)'));
log('Slideshow: ' + SLIDESHOW_INTERVAL + 's interval, refresh every ' + REFRESH_INTERVAL + 's');
log('Videos: ' + (INCLUDE_VIDEOS ? 'enabled' : 'disabled'));
log('Waiting for connections...');
});