diff --git a/server.js b/server.js index 251a36d..683bf1e 100644 --- a/server.js +++ b/server.js @@ -26,241 +26,139 @@ 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'; - 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); -} - +function cacheGet(key) { const e=imageCache.get(key);if(!e)return null;imageCache.delete(key);imageCache.set(key,e);return e; } +function cacheSet(key,value) { if(imageCache.size>=IMAGE_CACHE_MAX){imageCache.delete(imageCache.keys().next().value);}imageCache.set(key,value); } 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; - const sessions = new Map(); const SESSION_TTL = 24 * 60 * 60 * 1000; - -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); }); } +function createSession(username) { const token=crypto.randomBytes(32).toString('hex'),now=Date.now();sessions.set(token,{username,createdAt:now,expiresAt:now+SESSION_TTL});return token; } +function validateSession(token) { if(!token)return false;const s=sessions.get(token);if(!s)return false;if(Date.now()>s.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); - -function requireAdminAuth(req, res, next) { - if (!AUTH_ENABLED) return next(); - const cookie = req.headers.cookie || ''; - const match = cookie.match(/frambe_session=([a-f0-9]+)/); - if (match && validateSession(match[1])) return next(); - if (req.headers['accept'] && req.headers['accept'].includes('application/json')) { - return res.status(401).json({ error: 'Not authenticated' }); - } - return res.redirect('/admin/login'); -} - -function requireApiToken(req, res, next) { - if (!FRAMBE_API_TOKEN) return next(); - const auth = req.headers['authorization'] || ''; - const hdr = req.headers['x-api-token'] || ''; - const qry = req.query.token || ''; - const provided = auth.startsWith('Bearer ') ? auth.slice(7) : (hdr || qry); - if (provided === FRAMBE_API_TOKEN) return next(); - return res.status(401).json({ error: 'Invalid API token' }); -} - -function immichHeaders() { return { 'x-api-key': API_KEY, 'Content-Type': 'application/json' }; } -function log(msg) { console.log('[' + new Date().toISOString() + '] ' + msg); } -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, - }; -} +function requireAdminAuth(req,res,next) { if(!AUTH_ENABLED)return next();const cookie=req.headers.cookie||'';const match=cookie.match(/frambe_session=([a-f0-9]+)/);if(match&&validateSession(match[1]))return next();if(req.headers['accept']&&req.headers['accept'].includes('application/json'))return res.status(401).json({error:'Not authenticated'});return res.redirect('/admin/login'); } +function requireApiToken(req,res,next) { if(!FRAMBE_API_TOKEN)return next();const auth=req.headers['authorization']||'',hdr=req.headers['x-api-token']||'',qry=req.query.token||'',provided=auth.startsWith('Bearer ')?auth.slice(7):(hdr||qry);if(provided===FRAMBE_API_TOKEN)return next();return res.status(401).json({error:'Invalid API token'}); } +function immichHeaders() { return {'x-api-key':API_KEY,'Content-Type':'application/json'}; } +function log(msg) { console.log('['+new Date().toISOString()+'] '+msg); } +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}; } const wss = new WebSocketServer({ server }); const clients = new Map(); +const adminSockets = new Set(); function getClientList() { const now = Date.now(); return Array.from(clients.values()).map(c => ({ - id: c.id, - userAgent: c.userAgent, - connectedAt: c.connectedAt, - firstSeen: c.firstSeen, - lastSeen: c.lastSeen, - status: c.status, - online: (now - c.lastSeen) < 35000, + id: c.id, name: c.name||'', ip: c.ip, userAgent: c.userAgent, + connectedAt: c.connectedAt, firstSeen: c.firstSeen, lastSeen: c.lastSeen, + status: c.status, online: (now - c.lastSeen) < 35000, + config: c.config||{}, source: c.source||null, })); } +function broadcastAdminClients() { + const msg = JSON.stringify({ type: 'clientList', clients: getClientList() }); + adminSockets.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { try { ws.send(msg); } catch(e) {} } }); +} + +function getClientIp(req) { + return (req.headers['x-forwarded-for']||'').split(',')[0].trim() || req.socket.remoteAddress || 'unknown'; +} + wss.on('connection', (ws, req) => { const id = crypto.randomBytes(8).toString('hex'); const now = Date.now(); 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) + ')'); + const ip = getClientIp(req); + let isAdmin = false; ws.send(JSON.stringify({ type: 'hello', clientId: id })); - broadcastAdminClients(); ws.on('message', (raw) => { try { const msg = JSON.parse(raw); - const c = clients.get(id); - if (c) { c.lastSeen = Date.now(); c.status = msg.status || c.status; } - if (msg.type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })); broadcastAdminClients(); } - else if (msg.type === 'status') { if (c) c.status = msg.status; broadcastAdminClients(); } - } catch (e) { } + if (msg.type === 'register') { + if (msg.role === 'admin') { + isAdmin = true; + adminSockets.add(ws); + log('WS admin: ' + ip); + ws.send(JSON.stringify({ type: 'clientList', clients: getClientList() })); + } else { + clients.set(id, { id, ws, name:'', ip, userAgent:ua, connectedAt:now, firstSeen:now, lastSeen:Date.now(), status:msg.status||'idle', config:msg.config||{}, source:msg.source||null }); + log('WS frame: ' + id + ' (' + ip + ')'); + 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(); } + } else if (msg.type === 'adminCommand') { + const target = 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); + } else { + 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); + 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(); } + } + } catch(e) { logErr('WS: ' + e.message); } }); ws.on('close', () => { - const c = clients.get(id); - if (c) { c.online = false; c.lastSeen = Date.now(); } - log('WS close: ' + id); - broadcastAdminClients(); + 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); } }); }); -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) { } } }); -} - 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; + const c = 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; } app.use(express.json()); app.use(express.urlencoded({ extended: false })); -app.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); }); -app.use((req, _res, next) => { log(req.method + ' ' + req.path); next(); }); +app.use((req,res,next)=>{res.set('Cache-Control','no-store');next();}); +app.use((req,_res,next)=>{log(req.method+' '+req.path);next();}); +app.get('/admin/login',(_req,res)=>{if(!AUTH_ENABLED)return res.redirect('/admin');res.sendFile(path.join(__dirname,'public','admin','login.html'));}); +app.use('/admin',requireAdminAuth,express.static(path.join(__dirname,'public/admin'))); +app.use(express.static(path.join(__dirname,'public'))); +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.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/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});}}); +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),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/: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/optimized',async(req,res)=>{try{const id=req.params.id,ck=id+':'+IMAGE_MAX_WIDTH+':'+IMAGE_QUALITY,hit=cacheGet(ck);if(hit){res.set('Content-Type','image/jpeg');res.set('Cache-Control','public, max-age=86400');return res.send(hit);}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(),opt=await sharp(buf).resize({width:IMAGE_MAX_WIDTH,withoutEnlargement:true}).jpeg({quality:IMAGE_QUALITY}).toBuffer();cacheSet(ck,opt);res.set('Content-Type','image/jpeg');res.set('Cache-Control','public, max-age=86400');res.send(opt);}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/clients',requireApiToken,(_req,res)=>{res.json({ok:true,clients:getClientList()});}); +app.post('/api/clients/:id/command',requireApiToken,(req,res)=>{const{action,payload}=req.body;if(!action)return res.status(400).json({error:'action required'});const sent=sendToClient(req.params.id,{type:'command',action,payload:payload||{}});if(!sent)return res.status(404).json({error:'Client not found or offline'});res.json({ok:true});}); +app.delete('/api/clients/:id',requireApiToken,(req,res)=>{const entry=Array.from(clients.entries()).find(([,c])=>c.id===req.params.id);if(!entry)return res.status(404).json({error:'Not found'});clients.delete(entry[0]);broadcastAdminClients();res.json({ok:true});}); +app.get('*',(_req,res)=>{res.sendFile(path.join(__dirname,'public','index.html'));}); -app.get('/admin/login', (_req, res) => { if (!AUTH_ENABLED) return res.redirect('/admin'); res.sendFile(path.join(__dirname, 'public', 'admin', 'login.html')); }); -app.use('/admin', requireAdminAuth, express.static(path.join(__dirname, 'public/admin'))); -app.use(express.static(path.join(__dirname, 'public'))); - -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.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 || ''; - const 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/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(); 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 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); res.json({ id: al.id, albumName: al.albumName, assetCount: a.length, assets: a }); } 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/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 && 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/: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/optimized', async (req, res) => { - const id = req.params.id; - const cacheKey = id + ':' + IMAGE_MAX_WIDTH + ':' + IMAGE_QUALITY; - const cached = cacheGet(cacheKey); - if (cached) { res.set('Content-Type', 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); 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.send(optimized); - } 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/clients', requireApiToken, (_req, res) => { res.json({ ok: true, clients: getClientList() }); }); -app.post('/api/clients/:id/command', requireApiToken, (req, res) => { - const { action, payload } = req.body; - if (!action) return res.status(400).json({ error: 'action required' }); - const sent = sendToClient(req.params.id, { type: 'command', action, payload: payload || {} }); - if (!sent) return res.status(404).json({ error: 'Client not found or offline' }); - res.json({ ok: true }); -}); -app.delete('/api/clients/:id', requireApiToken, (req, res) => { - const c = clients.get(req.params.id); - if (!c) return res.status(404).json({ error: 'Not found' }); - clients.delete(req.params.id); - broadcastAdminClients(); - res.json({ ok: true }); -}); - -app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); - -server.listen(PORT, () => { - log('--- Frambe v' + VERSION + ' ---'); - log('Listening on port ' + PORT); - log('Immich: ' + IMMICH_URL); - log('API key: ' + (API_KEY ? 'set' : 'NOT SET')); - log('Auth: ' + (AUTH_ENABLED ? 'enabled' : 'disabled')); - log('API token: ' + (FRAMBE_API_TOKEN ? 'set' : 'not set')); +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')); });