diff --git a/server.js b/server.js index d6e49ce..e90aa0c 100644 --- a/server.js +++ b/server.js @@ -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')); });