v1.5.0 - global settings, sleep scheduler, robust client dedup/pruning, server-controlled defaults
This commit is contained in:
@@ -2,12 +2,13 @@ const express = require('express');
|
||||
const fetch = require('node-fetch');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const sharp = require('sharp');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
require('dotenv').config();
|
||||
|
||||
const VERSION = '1.4.1';
|
||||
const VERSION = '1.5.0';
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
const PORT = process.env.PORT || 3000;
|
||||
@@ -50,22 +51,83 @@ function logErr(msg) { console.error('['+new Date().toISOString()+'] ERROR '+msg
|
||||
function filterAssets(assets) { return assets.filter(a=>{if(a.isTrashed)return false;if(!INCLUDE_VIDEOS&&a.type==='VIDEO')return false;return true;}); }
|
||||
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}; }
|
||||
|
||||
// === GLOBAL SETTINGS (persisted to disk) ===
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||
const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json');
|
||||
const DEFAULT_SETTINGS = {
|
||||
source: { source: 'random', albumId: null, personId: null },
|
||||
slideshowInterval: SLIDESHOW_INTERVAL,
|
||||
showClock: SHOW_CLOCK,
|
||||
showDate: SHOW_DATE,
|
||||
showExif: SHOW_EXIF,
|
||||
showProgress: SHOW_PROGRESS,
|
||||
sleep: { enabled: false, sleepAt: '23:00', wakeAt: '06:00' },
|
||||
};
|
||||
let globalSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||
function loadSettings() {
|
||||
try {
|
||||
if (fs.existsSync(SETTINGS_FILE)) {
|
||||
const raw = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
|
||||
globalSettings = Object.assign(JSON.parse(JSON.stringify(DEFAULT_SETTINGS)), raw);
|
||||
globalSettings.source = Object.assign({}, DEFAULT_SETTINGS.source, raw.source || {});
|
||||
globalSettings.sleep = Object.assign({}, DEFAULT_SETTINGS.sleep, raw.sleep || {});
|
||||
log('Settings loaded from ' + SETTINGS_FILE);
|
||||
} else { log('No settings file; using defaults'); }
|
||||
} catch(e) { logErr('Settings load: ' + e.message); }
|
||||
}
|
||||
function saveSettings() {
|
||||
try { if(!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR,{recursive:true}); fs.writeFileSync(SETTINGS_FILE, JSON.stringify(globalSettings,null,2)); }
|
||||
catch(e) { logErr('Settings save: ' + e.message); }
|
||||
}
|
||||
loadSettings();
|
||||
|
||||
// === TIME HELPERS FOR SLEEP SCHEDULE ===
|
||||
function parseHHMM(s) { if(!/^\d{1,2}:\d{2}$/.test(s||''))return null; const parts=s.split(':'),h=Number(parts[0]),m=Number(parts[1]); if(h>23||m>59)return null; return h*60+m; }
|
||||
function inSleepWindow(sleepAt, wakeAt, nowMin) {
|
||||
const s = parseHHMM(sleepAt), w = parseHHMM(wakeAt);
|
||||
if (s === null || w === null) return false;
|
||||
if (s === w) return false;
|
||||
if (s < w) return nowMin >= s && nowMin < w;
|
||||
return nowMin >= s || nowMin < w;
|
||||
}
|
||||
function nowMinuteOfDay() { const d=new Date(); return d.getHours()*60+d.getMinutes(); }
|
||||
function effectiveSleep(client) {
|
||||
const cs = client && client.config && client.config.sleep;
|
||||
if (cs && cs.override) return { enabled: !!cs.enabled, sleepAt: cs.sleepAt||globalSettings.sleep.sleepAt, wakeAt: cs.wakeAt||globalSettings.sleep.wakeAt };
|
||||
return globalSettings.sleep;
|
||||
}
|
||||
|
||||
const wss = new WebSocketServer({ server });
|
||||
const clients = new Map();
|
||||
const adminSockets = new Set();
|
||||
const CLIENT_TTL = parseInt(process.env.CLIENT_TTL, 10) || (7 * 24 * 60 * 60);
|
||||
const ONLINE_THRESHOLD = 35000;
|
||||
|
||||
// === CLIENT FINGERPRINTING & DEDUP ===
|
||||
function fingerprint(pid, ip, ua) {
|
||||
if (pid) return 'pid:' + pid;
|
||||
return 'sig:' + crypto.createHash('sha1').update((ip||'')+'|'+(ua||'')).digest('hex').slice(0, 16);
|
||||
}
|
||||
function findExisting(pid, ip, ua) {
|
||||
const fp = fingerprint(pid, ip, ua);
|
||||
for (const c of clients.values()) { if (c.fingerprint === fp) return c; }
|
||||
if (pid) { const sig = fingerprint(null, ip, ua); for (const c of clients.values()) { if (c.fingerprint === sig) return c; } }
|
||||
return null;
|
||||
}
|
||||
|
||||
function getClientList() {
|
||||
const now = Date.now();
|
||||
return Array.from(clients.values()).map(c => ({
|
||||
id: c.id, name: c.name||'', ip: c.ip, userAgent: c.userAgent,
|
||||
fingerprint: c.fingerprint, persistentId: c.persistentId || null,
|
||||
connectedAt: c.connectedAt, firstSeen: c.firstSeen, lastSeen: c.lastSeen,
|
||||
status: c.status, online: (now - c.lastSeen) < 35000,
|
||||
config: c.config||{}, source: c.source||null,
|
||||
status: c.status, online: (now - c.lastSeen) < ONLINE_THRESHOLD,
|
||||
config: c.config||{}, source: c.source||null, serverControlled: c.serverControlled !== false,
|
||||
}));
|
||||
}
|
||||
|
||||
function broadcastAdminClients() {
|
||||
const msg = JSON.stringify({ type: 'clientList', clients: getClientList() });
|
||||
const msg = JSON.stringify({ type: 'clientList', clients: getClientList(), settings: globalSettings });
|
||||
adminSockets.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { try { ws.send(msg); } catch(e) {} } });
|
||||
}
|
||||
|
||||
@@ -73,12 +135,57 @@ function getClientIp(req) {
|
||||
return (req.headers['x-forwarded-for']||'').split(',')[0].trim() || req.socket.remoteAddress || 'unknown';
|
||||
}
|
||||
|
||||
function resolvedConfigFor(client) {
|
||||
const g = globalSettings;
|
||||
const sleep = effectiveSleep(client);
|
||||
return {
|
||||
source: g.source,
|
||||
slideshowInterval: g.slideshowInterval,
|
||||
showClock: g.showClock, showDate: g.showDate, showExif: g.showExif, showProgress: g.showProgress,
|
||||
sleep: { enabled: sleep.enabled, sleepAt: sleep.sleepAt, wakeAt: sleep.wakeAt,
|
||||
sleeping: sleep.enabled && inSleepWindow(sleep.sleepAt, sleep.wakeAt, nowMinuteOfDay()) },
|
||||
};
|
||||
}
|
||||
function pushServerConfig(client) {
|
||||
if (!client || !client.ws || client.ws.readyState !== WebSocket.OPEN) return;
|
||||
if (client.serverControlled === false) return;
|
||||
try { client.ws.send(JSON.stringify({ type: 'serverConfig', config: resolvedConfigFor(client) })); } catch(e) {}
|
||||
}
|
||||
function pushServerConfigToAll() { clients.forEach(c => pushServerConfig(c)); }
|
||||
|
||||
// === SLEEP SCHEDULE TICK ===
|
||||
let lastTickMinute = -1;
|
||||
setInterval(() => {
|
||||
const m = nowMinuteOfDay();
|
||||
if (m === lastTickMinute) return;
|
||||
lastTickMinute = m;
|
||||
clients.forEach(c => {
|
||||
if (!c.ws || c.ws.readyState !== WebSocket.OPEN) return;
|
||||
const sleep = effectiveSleep(c);
|
||||
if (!sleep.enabled) return;
|
||||
const shouldSleep = inSleepWindow(sleep.sleepAt, sleep.wakeAt, m);
|
||||
if (shouldSleep && c.status !== 'sleeping') { try { c.ws.send(JSON.stringify({type:'command',action:'sleep',payload:{scheduled:true}})); } catch(e){} log('Schedule: sleep -> '+c.id); }
|
||||
else if (!shouldSleep && c.status === 'sleeping') { try { c.ws.send(JSON.stringify({type:'command',action:'wake',payload:{scheduled:true}})); } catch(e){} log('Schedule: wake -> '+c.id); }
|
||||
});
|
||||
}, 20000);
|
||||
|
||||
// === DEAD CLIENT PRUNING ===
|
||||
setInterval(() => {
|
||||
const now = Date.now(); let pruned = 0;
|
||||
for (const [k,c] of clients.entries()) {
|
||||
const offline = !c.ws || c.ws.readyState !== WebSocket.OPEN;
|
||||
if (offline && (now - c.lastSeen) > CLIENT_TTL * 1000) { clients.delete(k); pruned++; }
|
||||
}
|
||||
if (pruned) { log('Pruned '+pruned+' dead client(s)'); broadcastAdminClients(); }
|
||||
}, 60 * 60 * 1000);
|
||||
|
||||
wss.on('connection', (ws, req) => {
|
||||
const id = crypto.randomBytes(8).toString('hex');
|
||||
const now = Date.now();
|
||||
const ua = req.headers['user-agent'] || 'unknown';
|
||||
const ip = getClientIp(req);
|
||||
let isAdmin = false;
|
||||
let boundId = id;
|
||||
ws.send(JSON.stringify({ type: 'hello', clientId: id }));
|
||||
ws.on('message', (raw) => {
|
||||
try {
|
||||
@@ -88,25 +195,39 @@ wss.on('connection', (ws, req) => {
|
||||
isAdmin = true;
|
||||
adminSockets.add(ws);
|
||||
log('WS admin: ' + ip);
|
||||
ws.send(JSON.stringify({ type: 'clientList', clients: getClientList() }));
|
||||
ws.send(JSON.stringify({ type: 'clientList', clients: getClientList(), settings: globalSettings }));
|
||||
} else {
|
||||
const pid = msg.persistentId || null;
|
||||
const existing = pid ? Array.from(clients.values()).find(c => c.persistentId === pid) : null;
|
||||
const fp = fingerprint(pid, ip, ua);
|
||||
const existing = findExisting(pid, ip, ua);
|
||||
const effectiveId = existing ? existing.id : id;
|
||||
const firstName = existing ? existing.name : '';
|
||||
const firstSeen = existing ? existing.firstSeen : now;
|
||||
if (existing) { clients.delete(Array.from(clients.entries()).find(([,c]) => c.persistentId === pid)?.[0]); }
|
||||
clients.set(effectiveId, { id:effectiveId, persistentId:pid, ws, name:firstName, ip, userAgent:ua, connectedAt:now, firstSeen, lastSeen:Date.now(), status:msg.status||'idle', config:msg.config||{}, source:msg.source||null });
|
||||
log('WS frame: ' + effectiveId + (pid?' [persistent]':'') + ' (' + ip + ')');
|
||||
const priorConfig = existing ? (existing.config||{}) : {};
|
||||
const priorSource = existing ? existing.source : null;
|
||||
const sc = existing ? (existing.serverControlled !== false) : true;
|
||||
for (const [k,c] of Array.from(clients.entries())) { if (c.id === effectiveId || c.fingerprint === fp) clients.delete(k); }
|
||||
boundId = effectiveId;
|
||||
clients.set(effectiveId, {
|
||||
id: effectiveId, persistentId: pid, fingerprint: fp, ws,
|
||||
name: firstName, ip, userAgent: ua,
|
||||
connectedAt: now, firstSeen, lastSeen: Date.now(),
|
||||
status: msg.status||'idle',
|
||||
config: Object.assign({}, priorConfig, msg.config||{}),
|
||||
source: msg.source!==undefined ? msg.source : priorSource,
|
||||
serverControlled: sc,
|
||||
});
|
||||
log('WS frame: ' + effectiveId + (pid?' [pid]':' [sig]') + ' (' + ip + ')');
|
||||
pushServerConfig(clients.get(effectiveId));
|
||||
broadcastAdminClients();
|
||||
}
|
||||
} else if (msg.type === 'ping') {
|
||||
ws.send(JSON.stringify({ type: 'pong' }));
|
||||
} else if (msg.type === 'status') {
|
||||
const c = clients.get(id);
|
||||
if (c) { c.status=msg.status||c.status; c.lastSeen=Date.now(); if(msg.config)c.config=msg.config; if(msg.source!==undefined)c.source=msg.source; broadcastAdminClients(); }
|
||||
const c = clients.get(boundId);
|
||||
if (c) { c.status=msg.status||c.status; c.lastSeen=Date.now(); if(msg.config)c.config=Object.assign({},c.config,msg.config); if(msg.source!==undefined)c.source=msg.source; broadcastAdminClients(); }
|
||||
} else if (msg.type === 'adminCommand') {
|
||||
const target = Array.from(clients.values()).find(c => c.id === msg.targetId);
|
||||
const target = clients.get(msg.targetId) || Array.from(clients.values()).find(c => c.id === 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('Cmd "' + msg.action + '" -> ' + msg.targetId);
|
||||
@@ -114,22 +235,48 @@ wss.on('connection', (ws, req) => {
|
||||
ws.send(JSON.stringify({ type:'error', message:'Client not found or offline' }));
|
||||
}
|
||||
} else if (msg.type === 'renameClient') {
|
||||
const target = Array.from(clients.values()).find(c => c.id === msg.targetId);
|
||||
const target = clients.get(msg.targetId) || Array.from(clients.values()).find(c => c.id === msg.targetId);
|
||||
if (target) { target.name = msg.name; broadcastAdminClients(); }
|
||||
} else if (msg.type === 'removeClient') {
|
||||
const entry = Array.from(clients.entries()).find(([,c]) => c.id === msg.targetId);
|
||||
if (entry) { clients.delete(entry[0]); broadcastAdminClients(); }
|
||||
if (entry) { try { if(entry[1].ws&&entry[1].ws.readyState===WebSocket.OPEN) entry[1].ws.close(); } catch(e){} clients.delete(entry[0]); broadcastAdminClients(); }
|
||||
} else if (msg.type === 'setClientControl') {
|
||||
const target = clients.get(msg.targetId) || Array.from(clients.values()).find(c => c.id === msg.targetId);
|
||||
if (target) { target.serverControlled = !!msg.serverControlled; if(target.serverControlled) pushServerConfig(target); broadcastAdminClients(); }
|
||||
} else if (msg.type === 'setClientSleep') {
|
||||
const target = clients.get(msg.targetId) || Array.from(clients.values()).find(c => c.id === msg.targetId);
|
||||
if (target) { target.config = Object.assign({}, target.config, { sleep: msg.sleep||{} }); pushServerConfig(target); broadcastAdminClients(); }
|
||||
} else if (msg.type === 'updateSettings') {
|
||||
applySettings(msg.settings || {});
|
||||
ws.send(JSON.stringify({ type:'settingsSaved', settings: globalSettings }));
|
||||
pushServerConfigToAll();
|
||||
broadcastAdminClients();
|
||||
log('Global settings updated');
|
||||
} else if (msg.type === 'getSettings') {
|
||||
ws.send(JSON.stringify({ type:'settings', settings: globalSettings }));
|
||||
}
|
||||
} catch(e) { logErr('WS: ' + e.message); }
|
||||
});
|
||||
ws.on('close', () => {
|
||||
if (isAdmin) { adminSockets.delete(ws); log('WS admin left: ' + ip); }
|
||||
else { const c=clients.get(id); if(c){c.status='offline';c.ws=null;c.lastSeen=Date.now();broadcastAdminClients();} log('WS frame left: '+id); }
|
||||
else { const c=clients.get(boundId); if(c){c.status='offline';c.ws=null;c.lastSeen=Date.now();broadcastAdminClients();} log('WS frame left: '+boundId); }
|
||||
});
|
||||
});
|
||||
|
||||
function applySettings(patch) {
|
||||
if (patch.source) globalSettings.source = Object.assign({}, globalSettings.source, patch.source);
|
||||
if ('slideshowInterval' in patch) globalSettings.slideshowInterval = parseInt(patch.slideshowInterval,10)||globalSettings.slideshowInterval;
|
||||
['showClock','showDate','showExif','showProgress'].forEach(k => { if (k in patch) globalSettings[k] = !!patch[k]; });
|
||||
if (patch.sleep) globalSettings.sleep = Object.assign({}, globalSettings.sleep, {
|
||||
enabled: 'enabled' in patch.sleep ? !!patch.sleep.enabled : globalSettings.sleep.enabled,
|
||||
sleepAt: patch.sleep.sleepAt || globalSettings.sleep.sleepAt,
|
||||
wakeAt: patch.sleep.wakeAt || globalSettings.sleep.wakeAt,
|
||||
});
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
function sendToClient(clientId, payload) {
|
||||
const c = Array.from(clients.values()).find(x => x.id === clientId);
|
||||
const c = clients.get(clientId) || Array.from(clients.values()).find(x => x.id === clientId);
|
||||
if (!c || !c.ws || c.ws.readyState !== WebSocket.OPEN) return false;
|
||||
c.ws.send(JSON.stringify(payload)); return true;
|
||||
}
|
||||
@@ -146,6 +293,8 @@ app.get('/api/auth/status',(_req,res)=>{res.json({authEnabled:AUTH_ENABLED,apiTo
|
||||
app.post('/api/auth/login',(req,res)=>{if(!AUTH_ENABLED)return res.json({ok:true});const{username,password}=req.body;if(username===ADMIN_USERNAME&&password===ADMIN_PASSWORD){const token=createSession(username);res.setHeader('Set-Cookie','frambe_session='+token+'; HttpOnly; Path=/; Max-Age='+(SESSION_TTL/1000));return res.json({ok:true});}return res.status(401).json({ok:false,error:'Invalid credentials'});});
|
||||
app.post('/api/auth/logout',(req,res)=>{const cookie=req.headers.cookie||'',match=cookie.match(/frambe_session=([a-f0-9]+)/);if(match)sessions.delete(match[1]);res.setHeader('Set-Cookie','frambe_session=; HttpOnly; Path=/; Max-Age=0');res.json({ok:true});});
|
||||
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/settings',(_req,res)=>{res.json({ok:true,settings:globalSettings});});
|
||||
app.put('/api/settings',requireApiToken,(req,res)=>{applySettings(req.body||{});pushServerConfigToAll();broadcastAdminClients();res.json({ok:true,settings:globalSettings});});
|
||||
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(''+r.status);const v=await r.json();res.json({ok:true,version:v});}catch(e){res.status(502).json({ok:false,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: '+rOwn.status);const aOwn=await rOwn.json(),sharedRaw=rShared.ok?await rShared.json():[];const seen=new Set(),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('Albums: '+result.length);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(),a=filterAssets(al.assets||[]).map(mapAsset);res.json({id:al.id,albumName:al.albumName,assetCount:a.length,assets:a});}catch(e){res.status(502).json({error:e.message});}});
|
||||
@@ -167,4 +316,5 @@ server.listen(PORT,()=>{
|
||||
log('--- Frambe v'+VERSION+' ---');
|
||||
log('Port: '+PORT+' | Immich: '+IMMICH_URL);
|
||||
log('API key: '+(API_KEY?'set':'NOT SET')+' | Auth: '+(AUTH_ENABLED?'enabled':'disabled')+' | Token: '+(FRAMBE_API_TOKEN?'set':'not set'));
|
||||
log('Global sleep schedule: '+(globalSettings.sleep.enabled?(globalSettings.sleep.sleepAt+' -> '+globalSettings.sleep.wakeAt):'disabled'));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user