From 1863da1a28e5560d4bca4ad29d7268679f02fc23 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Fri, 22 May 2026 19:47:09 +1000 Subject: [PATCH 01/30] feat(1.4.0): WebSocket server, client registry, admin command routing, client naming --- server.js | 156 ++++++++++++++++++++++++------------------------------ 1 file changed, 69 insertions(+), 87 deletions(-) diff --git a/server.js b/server.js index d561328..a24b549 100644 --- a/server.js +++ b/server.js @@ -1,12 +1,14 @@ const express = require('express'); const fetch = require('node-fetch'); const path = require('path'); +const http = require('http'); +const { WebSocketServer, WebSocket } = require('ws'); require('dotenv').config(); -const VERSION = '1.3.0'; +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; @@ -23,103 +25,83 @@ 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'; -function immichHeaders() { - return { 'x-api-key': API_KEY, 'Accept': 'application/json', 'Content-Type': 'application/json' }; -} +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.static(path.join(__dirname, 'public'), { - setHeaders: (res, filePath) => { - if (filePath.endsWith('.html') || filePath.endsWith('.js') || filePath.endsWith('.css')) { - res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); - res.setHeader('Pragma', 'no-cache'); res.setHeader('Expires', '0'); - } - } -})); +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'); } } })); app.use(express.json()); -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) { - if (INCLUDE_VIDEOS) return assets.filter(a => a.type === 'IMAGE' || a.type === 'VIDEO'); - return 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 }); -}); - -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 connection OK, version ' + v.major + '.' + v.minor + '.' + v.patch); res.json({ ok: true, version: v }); - } catch (err) { logErr('Immich connection failed: ' + err.message); res.status(502).json({ ok: false, error: err.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(`Immich returned ${r.status}`); const albums = await r.json(); log('Listed ' + albums.length + ' albums'); res.json(albums.map(a => ({ id: a.id, albumName: a.albumName, assetCount: a.assetCount, albumThumbnailAssetId: a.albumThumbnailAssetId, updatedAt: a.updatedAt }))); - } catch (err) { logErr('Albums list failed: ' + err.message); res.status(502).json({ error: err.message }); } -}); - -app.get('/api/albums/:id', async (req, res) => { - try { log('Fetching album ' + req.params.id); const r = await fetch(`${IMMICH_URL}/api/albums/${req.params.id}`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const album = await r.json(); const assets = filterAssets(album.assets || []).map(mapAsset); const vids = assets.filter(a => a.type === 'VIDEO').length; log('Album "' + album.albumName + '" returned ' + assets.length + ' assets (' + (assets.length - vids) + ' photos, ' + vids + ' videos)'); res.json({ id: album.id, albumName: album.albumName, assetCount: assets.length, assets }); - } catch (err) { logErr('Album fetch failed: ' + err.message); res.status(502).json({ error: err.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(`Immich returned ${r.status}`); const data = await r.json(); const people = (data.people || data || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath })); log('Listed ' + people.length + ' people'); res.json(people); - } catch (err) { logErr('People list failed: ' + err.message); res.status(502).json({ error: err.message }); } -}); - -app.get('/api/people/:id', async (req, res) => { - try { log('Fetching person ' + req.params.id); const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/assets`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const raw = await r.json(); const assets = filterAssets(Array.isArray(raw) ? raw : []).map(mapAsset); log('Person returned ' + assets.length + ' assets'); res.json(assets); - } catch (err) { logErr('Person fetch failed: ' + err.message); res.status(502).json({ error: err.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(`Immich returned ${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 (err) { res.status(502).json({ error: err.message }); } -}); - -app.get('/api/assets/random', async (req, res) => { - try { const count = Math.min(parseInt(req.query.count, 10) || 50, 250); const r = await fetch(`${IMMICH_URL}/api/assets/random?count=${count}`, { headers: immichHeaders() }); if (!r.ok) throw new Error(`Immich returned ${r.status}`); const assets = filterAssets(await r.json()).map(mapAsset); log('Random returned ' + assets.length + ' assets'); res.json(assets); - } catch (err) { logErr('Random fetch failed: ' + err.message); res.status(502).json({ error: err.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(`Immich returned ${r.status}`); const data = await r.json(); const assets = filterAssets(data.assets?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true })); log('Favorites returned ' + assets.length + ' assets'); res.json(assets); - } catch (err) { logErr('Favorites fetch failed: ' + err.message); res.status(502).json({ error: err.message }); } -}); - -app.get('/api/assets/:id/thumbnail', async (req, res) => { - try { const size = req.query.size || 'preview'; const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/thumbnail?size=${size}`, { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error(`Immich returned ${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 (err) { res.status(502).json({ error: err.message }); } -}); - -app.get('/api/assets/:id/video', async (req, res) => { - try { log('Streaming video ' + req.params.id); 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(`Immich returned ${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 (err) { logErr('Video stream failed: ' + err.message); res.status(502).json({ error: err.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(`Immich returned ${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 (err) { res.status(502).json({ error: err.message }); } -}); +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 }); }); +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 }); } }); +app.get('/admin', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'admin', 'index.html')); }); app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); -app.listen(PORT, '0.0.0.0', () => { +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('Slideshow: ' + SLIDESHOW_INTERVAL + 's interval, ' + TRANSITION_DURATION + 's transition, refresh every ' + REFRESH_INTERVAL + 's'); + log('Slideshow: ' + SLIDESHOW_INTERVAL + 's interval, refresh every ' + REFRESH_INTERVAL + 's'); log('Videos: ' + (INCLUDE_VIDEOS ? 'enabled' : 'disabled')); - if (ALBUM_ID) log('Default album: ' + ALBUM_ID); - if (SHOW_FAVORITES_ONLY) log('Auto-start: favorites only'); - log('Waiting for requests...'); + log('Waiting for connections...'); }); From 4b9db2af5a1816917dca1353c301a9dc022fd83a Mon Sep 17 00:00:00 2001 From: jessikitty Date: Fri, 22 May 2026 19:48:23 +1000 Subject: [PATCH 02/30] feat(1.4.0): admin dashboard with client cards, album/person selector, playback/power/config controls --- public/admin/index.html | 103 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 public/admin/index.html diff --git a/public/admin/index.html b/public/admin/index.html new file mode 100644 index 0000000..ebc8652 --- /dev/null +++ b/public/admin/index.html @@ -0,0 +1,103 @@ + + + + + + Frambe Admin + + + +
+ Frambe +

Frambe Admin

Connecting...
+ Disconnected +
+
+

No frames connected

Open Frambe on a tablet or screen to see it here

+
+ + + From e2be524b2abe1457cc17f574a32920c832cc80cb Mon Sep 17 00:00:00 2001 From: jessikitty Date: Fri, 22 May 2026 19:50:53 +1000 Subject: [PATCH 03/30] =?UTF-8?q?feat(1.4.0):=20WebSocket=20client=20with?= =?UTF-8?q?=20remote=20control=20=E2=80=94=20sleep/wake,=20setSource,=20se?= =?UTF-8?q?tConfig,=20start/stop/next/prev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/js/app.js | 232 +++++++++-------------------------------------- 1 file changed, 44 insertions(+), 188 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index b0d83da..1e99873 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,201 +1,57 @@ -// === Frambe v1.3.0 - Vintage Polaroid Pile (Canvas) === +// === Frambe v1.4.0 - Client with WebSocket Remote Control === (function () { 'use strict'; var config = {}, assets = [], currentIndex = -1, slideshowTimer = null; var overlayVisible = true, overlayTimeout = null, selectedSource = null, selectedAlbumId = null; var selectedPersonId = null, isRunning = false, refreshTimer = null, urlDriven = false; - var currentVideoPlaying = false; - var pileCanvas, pileCtx; - // Shared polaroid proportions (match main frame CSS: ~3% sides, ~10% bottom) - var FRAME_PAD_RATIO = 0.03; - var FRAME_BOTTOM_RATIO = 0.10; - var FRAME_COLOR = '#ede8df'; + var currentVideoPlaying = false, pileCanvas, pileCtx; + var FRAME_PAD_RATIO = 0.03, FRAME_BOTTOM_RATIO = 0.10, FRAME_COLOR = '#ede8df'; + var wsConn = null, clientId = null, isSleeping = false; + var $setupScreen=document.getElementById('setup-screen'),$slideshowScreen=document.getElementById('slideshow-screen'),$connectionStatus=document.getElementById('connection-status'),$setupContent=document.getElementById('setup-content'),$setupError=document.getElementById('setup-error'),$errorDetail=document.getElementById('error-detail'),$albumsList=document.getElementById('albums-list'),$btnStart=document.getElementById('btn-start'),$bgBlur=document.getElementById('bg-blur'),$mainFrame=document.getElementById('main-frame'),$mainPhoto=document.getElementById('main-photo'),$mainVideo=document.getElementById('main-video'),$clock=document.getElementById('clock'),$dateDisplay=document.getElementById('date-display'),$exifInfo=document.getElementById('exif-info'),$progressFill=document.getElementById('progress-fill'),$overlay=document.getElementById('overlay'),$btnSettings=document.getElementById('btn-settings'),$progressBar=document.getElementById('progress-bar'); - var $setupScreen = document.getElementById('setup-screen'), $slideshowScreen = document.getElementById('slideshow-screen'); - var $connectionStatus = document.getElementById('connection-status'), $setupContent = document.getElementById('setup-content'); - var $setupError = document.getElementById('setup-error'), $errorDetail = document.getElementById('error-detail'); - var $albumsList = document.getElementById('albums-list'), $btnStart = document.getElementById('btn-start'); - var $bgBlur = document.getElementById('bg-blur'), $mainFrame = document.getElementById('main-frame'); - var $mainPhoto = document.getElementById('main-photo'), $mainVideo = document.getElementById('main-video'); - var $clock = document.getElementById('clock'), $dateDisplay = document.getElementById('date-display'); - var $exifInfo = document.getElementById('exif-info'), $progressFill = document.getElementById('progress-fill'); - var $overlay = document.getElementById('overlay'), $btnSettings = document.getElementById('btn-settings'); - var $progressBar = document.getElementById('progress-bar'); + // === WEBSOCKET === + function connectWebSocket(){var proto=location.protocol==='https:'?'wss:':'ws:';wsConn=new WebSocket(proto+'//'+location.host+'/ws');wsConn.onopen=function(){console.log('[Frambe] WebSocket connected');wsConn.send(JSON.stringify({type:'register',role:'frame',status:isRunning?'playing':(isSleeping?'sleeping':'idle'),config:getCurrentConfig()}));};wsConn.onmessage=function(e){try{var msg=JSON.parse(e.data);if(msg.type==='welcome'){clientId=msg.clientId;console.log('[Frambe] Registered as '+clientId);}else if(msg.type==='command'){handleRemoteCommand(msg.action,msg.payload||{});}}catch(err){}};wsConn.onclose=function(){setTimeout(connectWebSocket,5000);};} + function sendStatus(s){if(wsConn&&wsConn.readyState===WebSocket.OPEN)wsConn.send(JSON.stringify({type:'status',status:s,currentAlbum:selectedAlbumId,config:getCurrentConfig()}));} + function getCurrentConfig(){return{slideshowInterval:config.slideshowInterval,showClock:config.showClock,showDate:config.showDate,showExif:config.showExif,showProgress:config.showProgress};} + function handleRemoteCommand(action,payload){console.log('[Frambe] Remote: '+action);switch(action){case'setSource':selectedSource=payload.source;selectedAlbumId=payload.albumId||null;selectedPersonId=payload.personId||null;if(isSleeping)wakeUp();if(isRunning){clearTimeout(slideshowTimer);stopVideo();}doStartSlideshow();break;case'start':if(isSleeping)wakeUp();if(!isRunning&&selectedSource)doStartSlideshow();break;case'stop':if(isRunning)exitSlideshowInternal();sendStatus('idle');break;case'next':if(isRunning)showNextAsset();break;case'prev':if(isRunning)showPrevAsset();break;case'sleep':goToSleep();break;case'wake':wakeUp();break;case'refresh':location.reload();break;case'setConfig':applyConfigChange(payload);break;}} + function goToSleep(){isSleeping=true;document.body.style.background='#000';if($slideshowScreen)$slideshowScreen.style.display='none';if($setupScreen)$setupScreen.style.display='none';var s=document.getElementById('sleep-overlay');if(!s){s=document.createElement('div');s.id='sleep-overlay';s.style.cssText='position:fixed;top:0;left:0;width:100%;height:100%;background:#000;z-index:9999;';document.body.appendChild(s);}s.style.display='block';if(isRunning){clearTimeout(slideshowTimer);stopVideo();}sendStatus('sleeping');} + function wakeUp(){isSleeping=false;document.body.style.background='';var s=document.getElementById('sleep-overlay');if(s)s.style.display='none';if(isRunning)$slideshowScreen.style.display='block';else $setupScreen.style.display='flex';sendStatus(isRunning?'playing':'idle');} + function applyConfigChange(c){if('slideshowInterval'in c)config.slideshowInterval=c.slideshowInterval;if('showClock'in c){config.showClock=c.showClock;$clock.style.display=c.showClock?'':'none';}if('showDate'in c){config.showDate=c.showDate;$dateDisplay.style.display=c.showDate?'':'none';}if('showExif'in c){config.showExif=c.showExif;$exifInfo.style.display=c.showExif?'':'none';}if('showProgress'in c){config.showProgress=c.showProgress;$progressBar.style.display=c.showProgress?'':'none';}sendStatus(isRunning?'playing':'idle');} + function exitSlideshowInternal(){isRunning=false;clearTimeout(slideshowTimer);if(refreshTimer)clearInterval(refreshTimer);stopVideo();$slideshowScreen.style.display='none';$setupScreen.style.display='flex';document.body.classList.add('setup-mode');$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;$bgBlur.style.backgroundImage='';$bgBlur.classList.remove('visible');$mainFrame.classList.remove('visible');clearPileCanvas();} - function getUrlParams() { var p={},s=window.location.search.substring(1);if(!s)return p;var pairs=s.split('&');for(var i=0;i';html+=thu?'':'
📁
';html+='
'+escapeHtml(a.albumName)+'
'+a.assetCount+' items
';}$albumsList.innerHTML=html;}catch(e){$albumsList.innerHTML='

Failed to load albums

';}} + window.selectSource=function(src){selectedSource=src;selectedAlbumId=null;selectedPersonId=null;document.getElementById('btn-all-photos').classList.toggle('selected',src==='random');document.getElementById('btn-favorites').classList.toggle('selected',src==='favorites');var items=document.querySelectorAll('.album-item');for(var i=0;i0)console.log('[Frambe] +'+added+' assets');}catch(e){}},(config.refreshInterval||300)*1000);} - async function init() { - document.body.classList.add('setup-mode'); - try { - config = await (await fetch('/api/config')).json(); - console.log('[Frambe] Running version ' + (config.version || 'unknown')); - if (!config.connected) { showError('API key not configured. Set IMMICH_API_KEY in your environment.'); return; } - var si = await (await fetch('/api/server-info')).json(); - if (!si.ok) { showError('Cannot reach Immich server: ' + si.error); return; } - $connectionStatus.textContent = 'Connected to Immich v' + si.version.major + '.' + si.version.minor + '.' + si.version.patch; - $connectionStatus.classList.add('connected'); - var params = getUrlParams(); - if (params.album) { await autoLaunch('album', params.album, null); return; } - if (params.person) { await autoLaunch('person', null, params.person); return; } - if ('favorites' in params) { await autoLaunch('favorites', null, null); return; } - if ('random' in params) { await autoLaunch('random', null, null); return; } - if (config.albumId) { await autoLaunch('album', config.albumId, null); return; } - if (config.showFavoritesOnly) { await autoLaunch('favorites', null, null); return; } - await loadAlbums(); - } catch (err) { showError('Failed to initialize: ' + err.message); } - } + // === CANVAS PILE === + function initPileCanvas(){pileCanvas=document.getElementById('pile-canvas');var d=window.devicePixelRatio||1;pileCanvas.width=window.innerWidth*d;pileCanvas.height=window.innerHeight*d;pileCanvas.style.width=window.innerWidth+'px';pileCanvas.style.height=window.innerHeight+'px';pileCtx=pileCanvas.getContext('2d');pileCtx.scale(d,d);} + function clearPileCanvas(){if(pileCtx){pileCtx.setTransform(1,0,0,1,0,0);pileCtx.clearRect(0,0,pileCanvas.width,pileCanvas.height);pileCtx.scale(window.devicePixelRatio||1,window.devicePixelRatio||1);}} + function dropPhotoPile(src){var img=new Image();img.crossOrigin='anonymous';img.onload=function(){var vw=window.innerWidth,vh=window.innerHeight,pw=vw*(0.18+Math.random()*0.07),pad=pw*FRAME_PAD_RATIO,bp=pw*FRAME_BOTTOM_RATIO,iw=pw-pad*2,ih=iw*(img.height/img.width),th=ih+pad+bp,cx=Math.random()*vw,cy=Math.random()*vh,rot=(Math.random()-0.5)*30,st=null;function draw(ts){if(!st)st=ts;var a=Math.min((ts-st)/1200,1);pileCtx.save();pileCtx.globalAlpha=a;pileCtx.translate(cx,cy);pileCtx.rotate(rot*Math.PI/180);pileCtx.shadowColor='rgba(0,0,0,0.45)';pileCtx.shadowBlur=18;pileCtx.shadowOffsetX=3;pileCtx.shadowOffsetY=6;pileCtx.fillStyle=FRAME_COLOR;pileCtx.fillRect(-pw/2,-th/2,pw,th);pileCtx.shadowColor='transparent';pileCtx.shadowBlur=0;pileCtx.shadowOffsetX=0;pileCtx.shadowOffsetY=0;pileCtx.drawImage(img,-pw/2+pad,-th/2+pad,iw,ih);pileCtx.fillStyle='rgba(150,120,70,0.2)';pileCtx.fillRect(-pw/2+pad,-th/2+pad,iw,ih);pileCtx.restore();if(a<1)requestAnimationFrame(draw);}requestAnimationFrame(draw);};img.src=src;} - function showError(msg) { $setupContent.style.display='none';$setupError.style.display='block';$errorDetail.textContent=msg; } - async function loadAlbums() { try { var albums=await(await fetch('/api/albums')).json();if(!albums.length){$albumsList.innerHTML='

No albums found

';return;}var html='';for(var i=0;i';html+=thu?'':'
📁
';html+='
'+escapeHtml(a.albumName)+'
'+a.assetCount+' items
';}$albumsList.innerHTML=html;}catch(e){$albumsList.innerHTML='

Failed to load albums

';} } - - window.selectSource = function(src){selectedSource=src;selectedAlbumId=null;selectedPersonId=null;document.getElementById('btn-all-photos').classList.toggle('selected',src==='random');document.getElementById('btn-favorites').classList.toggle('selected',src==='favorites');var items=document.querySelectorAll('.album-item');for(var i=0;i0)console.log('[Frambe] Refresh added '+added+' new asset(s)');}catch(e){console.warn('[Frambe] Refresh failed: '+e.message);}}, (config.refreshInterval||300)*1000); } - - // ========================= - // CANVAS PILE - // ========================= - function initPileCanvas() { - pileCanvas = document.getElementById('pile-canvas'); - var dpr = window.devicePixelRatio || 1; - pileCanvas.width = window.innerWidth * dpr; - pileCanvas.height = window.innerHeight * dpr; - pileCanvas.style.width = window.innerWidth + 'px'; - pileCanvas.style.height = window.innerHeight + 'px'; - pileCtx = pileCanvas.getContext('2d'); - pileCtx.scale(dpr, dpr); - } - - function clearPileCanvas() { - if (pileCtx) { pileCtx.setTransform(1,0,0,1,0,0); pileCtx.clearRect(0, 0, pileCanvas.width, pileCanvas.height); pileCtx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1); } - } - - function dropPhotoPile(imgSrc) { - var img = new Image(); - img.crossOrigin = 'anonymous'; - img.onload = function () { - var vw = window.innerWidth, vh = window.innerHeight; - // Pile polaroid size: 18-25% of screen width - var polaroidW = vw * (0.18 + Math.random() * 0.07); - // Use shared proportions to match main frame - var pad = polaroidW * FRAME_PAD_RATIO; - var bottomPad = polaroidW * FRAME_BOTTOM_RATIO; - var innerW = polaroidW - pad * 2; - var innerH = innerW * (img.height / img.width); - var totalH = innerH + pad + bottomPad; - var cx = Math.random() * vw, cy = Math.random() * vh; - var rot = (Math.random() - 0.5) * 30; - - var startTime = null, fadeDuration = 1200; - function drawFrame(timestamp) { - if (!startTime) startTime = timestamp; - var alpha = Math.min((timestamp - startTime) / fadeDuration, 1); - pileCtx.save(); - pileCtx.globalAlpha = alpha; - pileCtx.translate(cx, cy); - pileCtx.rotate(rot * Math.PI / 180); - pileCtx.shadowColor = 'rgba(0,0,0,0.45)'; - pileCtx.shadowBlur = 18; - pileCtx.shadowOffsetX = 3; - pileCtx.shadowOffsetY = 6; - pileCtx.fillStyle = FRAME_COLOR; - pileCtx.fillRect(-polaroidW/2, -totalH/2, polaroidW, totalH); - pileCtx.shadowColor = 'transparent'; pileCtx.shadowBlur = 0; pileCtx.shadowOffsetX = 0; pileCtx.shadowOffsetY = 0; - pileCtx.drawImage(img, -polaroidW/2 + pad, -totalH/2 + pad, innerW, innerH); - // Sepia wash - pileCtx.fillStyle = 'rgba(150, 120, 70, 0.2)'; - pileCtx.fillRect(-polaroidW/2 + pad, -totalH/2 + pad, innerW, innerH); - pileCtx.restore(); - if (alpha < 1) requestAnimationFrame(drawFrame); - } - requestAnimationFrame(drawFrame); - }; - img.onerror = function () { console.warn('[Frambe] Pile image failed to load'); }; - img.src = imgSrc; - } - - // ========================= - // SLIDESHOW ENGINE - // ========================= - async function doStartSlideshow() { - if (!selectedSource) return; - $btnStart.disabled = true; $btnStart.innerHTML = ' Loading…'; - try { - await loadAssets(); - if (!assets.length) { $btnStart.textContent = 'No photos found'; setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},2000); return; } - $setupScreen.style.display = 'none'; $slideshowScreen.style.display = 'block'; - document.body.classList.remove('setup-mode'); isRunning = true; - initPileCanvas(); - if (!config.showClock) $clock.style.display = 'none'; - if (!config.showDate) $dateDisplay.style.display = 'none'; - if (!config.showExif) $exifInfo.style.display = 'none'; - if (!config.showProgress) $progressBar.style.display = 'none'; - if (!config.backgroundBlur) $bgBlur.style.display = 'none'; - updateClock(); setInterval(updateClock, 1000); - currentIndex = -1; - showNextAsset(); scheduleOverlayHide(); startRefreshTimer(); - } catch (err) { console.error('[Frambe] Start failed: '+err.message); $btnStart.textContent='Error: '+err.message; setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},3000); } - } - - window.startSlideshow = function () { doStartSlideshow(); }; - window.exitSlideshow = function () { - if (urlDriven) { window.location.href = window.location.pathname; return; } - isRunning=false;clearTimeout(slideshowTimer);if(refreshTimer)clearInterval(refreshTimer);stopVideo(); - $slideshowScreen.style.display='none';$setupScreen.style.display='flex';document.body.classList.add('setup-mode'); - $btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false; - $bgBlur.style.backgroundImage='';$bgBlur.classList.remove('visible');$mainFrame.classList.remove('visible');clearPileCanvas(); - }; - - function showNextAsset() { currentIndex++;if(currentIndex>=assets.length){if(config.shuffle)shuffleArray(assets);currentIndex=0;}showAsset(currentIndex); } - function showPrevAsset() { currentIndex--;if(currentIndex<0)currentIndex=assets.length-1;showAsset(currentIndex); } - - function showAsset(index) { - if (!assets[index]) return; - clearTimeout(slideshowTimer); stopVideo(); - var asset = assets[index], isVideo = asset.type === 'VIDEO'; - var thumbUrl = '/api/assets/' + asset.id + '/thumbnail?size=preview'; - console.log('[Frambe] Showing ' + (isVideo ? 'VIDEO' : 'PHOTO') + ': ' + (asset.originalFileName || asset.id)); - if (currentIndex > 0) { var pi = currentIndex - 1; if (pi < 0) pi = assets.length - 1; if (assets[pi]) dropPhotoPile('/api/assets/' + assets[pi].id + '/thumbnail?size=thumbnail'); } - $mainFrame.classList.remove('visible'); - var img = new Image(); - img.onload = function () { setTimeout(function () { displayAsset(asset, thumbUrl, isVideo); }, 500); }; - img.onerror = function () { setTimeout(showNextAsset, 500); }; - img.src = thumbUrl; - var ni = index + 1; if (ni >= assets.length) ni = 0; - if (assets[ni]) { var pre = new Image(); pre.src = '/api/assets/' + assets[ni].id + '/thumbnail?size=preview'; } - } - - function displayAsset(asset, thumbUrl, isVideo) { - if (config.backgroundBlur) { $bgBlur.style.backgroundImage = 'url(' + thumbUrl + ')'; $bgBlur.classList.add('visible'); } - $mainVideo.style.display = 'none'; $mainPhoto.style.display = 'none'; - if (isVideo) { - $mainVideo.style.display = 'block'; - $mainVideo.src = '/api/assets/' + asset.id + '/video'; $mainVideo.poster = thumbUrl; - $mainVideo.load(); - $mainVideo.play().then(function(){ currentVideoPlaying=true; }).catch(function(e){ console.warn('[Frambe] Video autoplay failed: '+e.message); }); - $mainVideo.onended = function () { currentVideoPlaying=false; showNextAsset(); }; - slideshowTimer = setTimeout(function(){ if(currentVideoPlaying){showNextAsset();} }, Math.max((config.slideshowInterval||30)*3, 120)*1000); - } else { - $mainPhoto.style.display = 'block'; $mainPhoto.src = thumbUrl; - slideshowTimer = setTimeout(showNextAsset, (config.slideshowInterval||30)*1000); - } - requestAnimationFrame(function () { $mainFrame.classList.add('visible'); }); - updateExifInfo(asset); startProgress(isVideo ? null : (config.slideshowInterval||30)*1000); - } - - function stopVideo() { if(currentVideoPlaying||$mainVideo.src){try{$mainVideo.pause();}catch(e){}$mainVideo.removeAttribute('src');$mainVideo.load();$mainVideo.onended=null;currentVideoPlaying=false;} } - - function updateExifInfo(a) { if(!config.showExif||!a.exifInfo){$exifInfo.textContent='';return;}var p=[],e=a.exifInfo,loc=[e.city,e.state,e.country].filter(Boolean).join(', ');if(loc)p.push(loc);if(e.dateTimeOriginal)p.push(formatDate(new Date(e.dateTimeOriginal)));else if(a.fileCreatedAt)p.push(formatDate(new Date(a.fileCreatedAt)));if(e.make||e.model)p.push([e.make,e.model].filter(Boolean).join(' '));if(a.type==='VIDEO')p.push('Video');$exifInfo.textContent=p.join(' · '); } - function startProgress(ms) { if(!config.showProgress)return;$progressFill.style.transition='none';$progressFill.style.width='0%';$progressFill.offsetWidth;if(ms){$progressFill.style.transition='width '+ms+'ms linear';$progressFill.style.width='100%';} } - function updateClock() { var n=new Date();if(config.showClock)$clock.textContent=padZero(n.getHours())+':'+padZero(n.getMinutes());if(config.showDate)$dateDisplay.textContent=n.toLocaleDateString(undefined,{weekday:'long',day:'numeric',month:'long',year:'numeric'}); } - window.toggleOverlay = function(){overlayVisible=!overlayVisible;if(overlayVisible){$overlay.classList.remove('hidden');$btnSettings.classList.add('visible');scheduleOverlayHide();}else{$overlay.classList.add('hidden');$btnSettings.classList.remove('visible');}}; + // === SLIDESHOW === + async function doStartSlideshow(){if(!selectedSource)return;$btnStart.disabled=true;$btnStart.innerHTML=' Loading…';try{await loadAssets();if(!assets.length){$btnStart.textContent='No photos found';setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},2000);sendStatus('idle');return;}$setupScreen.style.display='none';$slideshowScreen.style.display='block';document.body.classList.remove('setup-mode');isRunning=true;initPileCanvas();if(!config.showClock)$clock.style.display='none';if(!config.showDate)$dateDisplay.style.display='none';if(!config.showExif)$exifInfo.style.display='none';if(!config.showProgress)$progressBar.style.display='none';if(!config.backgroundBlur)$bgBlur.style.display='none';updateClock();setInterval(updateClock,1000);currentIndex=-1;showNextAsset();scheduleOverlayHide();startRefreshTimer();sendStatus('playing');}catch(err){$btnStart.textContent='Error: '+err.message;setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},3000);}} + window.startSlideshow=function(){doStartSlideshow();}; + window.exitSlideshow=function(){if(urlDriven){window.location.href=window.location.pathname;return;}exitSlideshowInternal();sendStatus('idle');}; + function showNextAsset(){currentIndex++;if(currentIndex>=assets.length){if(config.shuffle)shuffleArray(assets);currentIndex=0;}showAsset(currentIndex);} + function showPrevAsset(){currentIndex--;if(currentIndex<0)currentIndex=assets.length-1;showAsset(currentIndex);} + function showAsset(idx){if(!assets[idx])return;clearTimeout(slideshowTimer);stopVideo();var a=assets[idx],isV=a.type==='VIDEO',thu='/api/assets/'+a.id+'/thumbnail?size=preview';if(currentIndex>0){var pi=currentIndex-1;if(pi<0)pi=assets.length-1;if(assets[pi])dropPhotoPile('/api/assets/'+assets[pi].id+'/thumbnail?size=thumbnail');}$mainFrame.classList.remove('visible');var img=new Image();img.onload=function(){setTimeout(function(){displayAsset(a,thu,isV);},500);};img.onerror=function(){setTimeout(showNextAsset,500);};img.src=thu;var ni=idx+1;if(ni>=assets.length)ni=0;if(assets[ni]){var pre=new Image();pre.src='/api/assets/'+assets[ni].id+'/thumbnail?size=preview';}} + function displayAsset(a,thu,isV){if(config.backgroundBlur){$bgBlur.style.backgroundImage='url('+thu+')';$bgBlur.classList.add('visible');}$mainVideo.style.display='none';$mainPhoto.style.display='none';if(isV){$mainVideo.style.display='block';$mainVideo.src='/api/assets/'+a.id+'/video';$mainVideo.poster=thu;$mainVideo.load();$mainVideo.play().then(function(){currentVideoPlaying=true;}).catch(function(){});$mainVideo.onended=function(){currentVideoPlaying=false;showNextAsset();};slideshowTimer=setTimeout(function(){if(currentVideoPlaying)showNextAsset();},Math.max((config.slideshowInterval||30)*3,120)*1000);}else{$mainPhoto.style.display='block';$mainPhoto.src=thu;slideshowTimer=setTimeout(showNextAsset,(config.slideshowInterval||30)*1000);}requestAnimationFrame(function(){$mainFrame.classList.add('visible');});updateExifInfo(a);startProgress(isV?null:(config.slideshowInterval||30)*1000);} + function stopVideo(){if(currentVideoPlaying||$mainVideo.src){try{$mainVideo.pause();}catch(e){}$mainVideo.removeAttribute('src');$mainVideo.load();$mainVideo.onended=null;currentVideoPlaying=false;}} + function updateExifInfo(a){if(!config.showExif||!a.exifInfo){$exifInfo.textContent='';return;}var p=[],e=a.exifInfo,loc=[e.city,e.state,e.country].filter(Boolean).join(', ');if(loc)p.push(loc);if(e.dateTimeOriginal)p.push(formatDate(new Date(e.dateTimeOriginal)));else if(a.fileCreatedAt)p.push(formatDate(new Date(a.fileCreatedAt)));if(e.make||e.model)p.push([e.make,e.model].filter(Boolean).join(' '));if(a.type==='VIDEO')p.push('Video');$exifInfo.textContent=p.join(' · ');} + function startProgress(ms){if(!config.showProgress)return;$progressFill.style.transition='none';$progressFill.style.width='0%';$progressFill.offsetWidth;if(ms){$progressFill.style.transition='width '+ms+'ms linear';$progressFill.style.width='100%';}} + function updateClock(){var n=new Date();if(config.showClock)$clock.textContent=padZero(n.getHours())+':'+padZero(n.getMinutes());if(config.showDate)$dateDisplay.textContent=n.toLocaleDateString(undefined,{weekday:'long',day:'numeric',month:'long',year:'numeric'});} + window.toggleOverlay=function(){overlayVisible=!overlayVisible;if(overlayVisible){$overlay.classList.remove('hidden');$btnSettings.classList.add('visible');scheduleOverlayHide();}else{$overlay.classList.add('hidden');$btnSettings.classList.remove('visible');}}; function scheduleOverlayHide(){clearTimeout(overlayTimeout);overlayTimeout=setTimeout(function(){$overlay.classList.add('hidden');$btnSettings.classList.remove('visible');overlayVisible=false;},8000);} - window.nextPhoto = function(){showNextAsset();if(overlayVisible)scheduleOverlayHide();}; - window.prevPhoto = function(){showPrevAsset();if(overlayVisible)scheduleOverlayHide();}; - document.addEventListener('keydown',function(e){if(!isRunning)return;switch(e.key){case 'ArrowRight':case ' ':e.preventDefault();nextPhoto();break;case 'ArrowLeft':e.preventDefault();prevPhoto();break;case 'Escape':exitSlideshow();break;case 'f':toggleFullscreen();break;case 'i':toggleOverlay();break;}}); + window.nextPhoto=function(){showNextAsset();if(overlayVisible)scheduleOverlayHide();}; + window.prevPhoto=function(){showPrevAsset();if(overlayVisible)scheduleOverlayHide();}; + document.addEventListener('keydown',function(e){if(!isRunning)return;switch(e.key){case'ArrowRight':case' ':e.preventDefault();nextPhoto();break;case'ArrowLeft':e.preventDefault();prevPhoto();break;case'Escape':exitSlideshow();break;case'f':toggleFullscreen();break;case'i':toggleOverlay();break;}}); function toggleFullscreen(){if(!document.fullscreenElement&&!document.webkitFullscreenElement){var el=document.documentElement;if(el.requestFullscreen)el.requestFullscreen();else if(el.webkitRequestFullscreen)el.webkitRequestFullscreen();}else{if(document.exitFullscreen)document.exitFullscreen();else if(document.webkitExitFullscreen)document.webkitExitFullscreen();}} function shuffleArray(a){for(var i=a.length-1;i>0;i--){var j=Math.floor(Math.random()*(i+1));var t=a[i];a[i]=a[j];a[j]=t;}} function padZero(n){return n<10?'0'+n:''+n;} From cc36a6fed4e8e76e5372b766585ee5c2b4c351f2 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Fri, 22 May 2026 19:51:44 +1000 Subject: [PATCH 04/30] feat(1.4.0): add ws dependency for WebSocket server/client communication --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index abbeb9e..d6318ca 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "frambe", - "version": "1.3.0", - "description": "Frambe — a lightweight digital photo frame web app for Immich", + "version": "1.4.0", + "description": "Frambe — a lightweight digital photo frame web app for Immich with admin dashboard", "main": "server.js", "scripts": { "start": "node server.js" @@ -9,7 +9,8 @@ "dependencies": { "express": "^4.21.0", "node-fetch": "^2.7.0", - "dotenv": "^16.4.5" + "dotenv": "^16.4.5", + "ws": "^8.18.0" }, "engines": { "node": ">=18" From 83b8b78f0cbafa3510ff313d333dd881cd99c269 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 25 May 2026 10:21:48 +1000 Subject: [PATCH 05/30] feat: add admin login page for v1.4.0 --- public/admin/login.html | 1 + 1 file changed, 1 insertion(+) create mode 100644 public/admin/login.html diff --git a/public/admin/login.html b/public/admin/login.html new file mode 100644 index 0000000..6f96393 --- /dev/null +++ b/public/admin/login.html @@ -0,0 +1 @@ +PCFET0NUWVBFIGh0bWw+CjxodG1sIGxhbmc9ImVuIj4KPGhlYWQ+CiAgPG1ldGEgY2hhcnNldD0iVVRGLTgiPgogIDxtZXRhIG5hbWU9InZpZXdwb3J0IiBjb250ZW50PSJ3aWR0aD1kZXZpY2Utd2lkdGgsIGluaXRpYWwtc2NhbGU9MS4wIj4KICA8dGl0bGU+RnJhbWJlIEFkbWluIOKAlCBMb2dpbjwvdGl0bGU+CiAgPHN0eWxlPgogICAgKiwgKjo6YmVmb3JlLCAqOjphZnRlciB7IG1hcmdpbjogMDsgcGFkZGluZzogMDsgYm94LXNpemluZzogYm9yZGVyLWJveDsgfQogICAgYm9keSB7IGJhY2tncm91bmQ6ICMwZjBmMWE7IGNvbG9yOiAjZTBlMGUwOyBmb250LWZhbWlseTogLWFwcGxlLXN5c3RlbSwgQmxpbmtNYWNTeXN0ZW1Gb250LCAnU2Vnb2UgVUknLCBSb2JvdG8sIHNhbnMtc2VyaWY7IGRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGp1c3RpZnktY29udGVudDogY2VudGVyOyBtaW4taGVpZ2h0OiAxMDB2aDsgfQogICAgLmxvZ2luLWNhcmQgeyBiYWNrZ3JvdW5kOiByZ2JhKDI1NSwyNTUsMjU1LDAuMDQpOyBib3JkZXI6IDFweCBzb2xpZCByZ2JhKDI1NSwyNTUsMjU1LDAuMDgpOyBib3JkZXItcmFkaXVzOiAxNnB4OyBwYWRkaW5nOiAyLjVyZW07IHdpZHRoOiAxMDAlOyBtYXgtd2lkdGg6IDM4MHB4OyB9CiAgICAubG9naW4taGVhZGVyIHsgdGV4dC1hbGlnbjogY2VudGVyOyBtYXJnaW4tYm90dG9tOiAycmVtOyB9CiAgICAubG9naW4taGVhZGVyIGltZyB7IHdpZHRoOiA1NnB4OyBoZWlnaHQ6IDU2cHg7IGJvcmRlci1yYWRpdXM6IDEycHg7IG1hcmdpbi1ib3R0b206IDFyZW07IH0KICAgIC5sb2dpbi1oZWFkZXIgaDEgeyBmb250LXNpemU6IDEuNHJlbTsgZm9udC13ZWlnaHQ6IDMwMDsgfQogICAgLmxvZ2luLWhlYWRlciBwIHsgZm9udC1zaXplOiAwLjhyZW07IGNvbG9yOiAjNjY2OyBtYXJnaW4tdG9wOiAwLjI1cmVtOyB9CiAgICAuZm9ybS1ncm91cCB7IG1hcmdpbi1ib3R0b206IDEuMjVyZW07IH0KICAgIC5mb3JtLWdyb3VwIGxhYmVsIHsgZGlzcGxheTogYmxvY2s7IGZvbnQtc2l6ZTogMC44cmVtOyBjb2xvcjogIzg4ODsgbWFyZ2luLWJvdHRvbTogMC40cmVtOyB9CiAgICAuZm9ybS1ncm91cCBpbnB1dCB7IHdpZHRoOiAxMDAlOyBwYWRkaW5nOiAxMHB4IDE0cHg7IGJhY2tncm91bmQ6IHJnYmEoMjU1LDI1NSwyNTUsMC4wNik7IGJvcmRlcjogMXB4IHNvbGlkIHJnYmEoMjU1LDI1NSwyNTUsMC4xMik7IGJvcmRlci1yYWRpdXM6IDhweDsgY29sb3I6ICNlMGUwZTA7IGZvbnQtc2l6ZTogMC45NXJlbTsgb3V0bGluZTogbm9uZTsgdHJhbnNpdGlvbjogYm9yZGVyLWNvbG9yIDAuMTVzOyB9CiAgICAuZm9ybS1ncm91cCBpbnB1dDpmb2N1cyB7IGJvcmRlci1jb2xvcjogIzYzNjZmMTsgfQogICAgLmxvZ2luLWJ0biB7IHdpZHRoOiAxMDAlOyBwYWRkaW5nOiAxMXB4OyBiYWNrZ3JvdW5kOiByZ2JhKDk5LDEwMiwyNDEsMC4yKTsgYm9yZGVyOiAxcHggc29saWQgIzYzNjZmMTsgYm9yZGVyLXJhZGl1czogOHB4OyBjb2xvcjogI2E1YjRmYzsgZm9udC1zaXplOiAwLjk1cmVtOyBjdXJzb3I6IHBvaW50ZXI7IHRyYW5zaXRpb246IGFsbCAwLjE1czsgfQogICAgLmxvZ2luLWJ0bjpob3ZlciB7IGJhY2tncm91bmQ6IHJnYmEoOTksMTAyLDI0MSwwLjM1KTsgfQogICAgLmxvZ2luLWJ0bjpkaXNhYmxlZCB7IG9wYWNpdHk6IDAuNTsgY3Vyc29yOiBub3QtYWxsb3dlZDsgfQogICAgLmVycm9yLW1zZyB7IGJhY2tncm91bmQ6IHJnYmEoMjM5LDY4LDY4LDAuMTIpOyBib3JkZXI6IDFweCBzb2xpZCByZ2JhKDIzOSw2OCw2OCwwLjMpOyBib3JkZXItcmFkaXVzOiA4cHg7IGNvbG9yOiAjZmNhNWE1OyBmb250LXNpemU6IDAuODVyZW07IHBhZGRpbmc6IDhweCAxMnB4OyBtYXJnaW4tYm90dG9tOiAxcmVtOyBkaXNwbGF5OiBub25lOyB9CiAgPC9zdHlsZT4KPC9oZWFkPgo8Ym9keT4KICA8ZGl2IGNsYXNzPSJsb2dpbi1jYXJkIj4KICAgIDxkaXYgY2xhc3M9ImxvZ2luLWhlYWRlciI+CiAgICAgIDxpbWcgc3JjPSIvaW1nL2ljb24ucG5nIiBhbHQ9IkZyYW1iZSIgb25lcnJvcj0idGhpcy5zdHlsZS5kaXNwbGF5PSdub25lJyI+CiAgICAgIDxoMT5GcmFtYmUgQWRtaW48L2gxPgogICAgICA8cD5TaWduIGluIHRvIG1hbmFnZSB5b3VyIGZyYW1lczwvcD4KICAgIDwvZGl2PgogICAgPGRpdiBjbGFzcz0iZXJyb3ItbXNnIiBpZD0iZXJyb3ItbXNnIj48L2Rpdj4KICAgIDxkaXYgY2xhc3M9ImZvcm0tZ3JvdXAiPgogICAgICA8bGFiZWwgZm9yPSJ1c2VybmFtZSI+VXNlcm5hbWU8L2xhYmVsPgogICAgICA8aW5wdXQgdHlwZT0idGV4dCIgaWQ9InVzZXJuYW1lIiBhdXRvY29tcGxldGU9InVzZXJuYW1lIiBhdXRvZm9jdXM+CiAgICA8L2Rpdj4KICAgIDxkaXYgY2xhc3M9ImZvcm0tZ3JvdXAiPgogICAgICA8bGFiZWwgZm9yPSJwYXNzd29yZCI+UGFzc3dvcmQ8L2xhYmVsPgogICAgICA8aW5wdXQgdHlwZT0icGFzc3dvcmQiIGlkPSJwYXNzd29yZCIgYXV0b2NvbXBsZXRlPSJjdXJyZW50LXBhc3N3b3JkIj4KICAgIDwvZGl2PgogICAgPGJ1dHRvbiBjbGFzcz0ibG9naW4tYnRuIiBpZD0ibG9naW4tYnRuIiBvbmNsaWNrPSJkb0xvZ2luKCkiPlNpZ24gSW48L2J1dHRvbj4KICA8L2Rpdj4KICA8c2NyaXB0PgogICAgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3Bhc3N3b3JkJykuYWRkRXZlbnRMaXN0ZW5lcigna2V5ZG93bicsIGZ1bmN0aW9uKGUpIHsgaWYgKGUua2V5ID09PSAnRW50ZXInKSBkb0xvZ2luKCk7IH0pOwogICAgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3VzZXJuYW1lJykuYWRkRXZlbnRMaXN0ZW5lcigna2V5ZG93bicsIGZ1bmN0aW9uKGUpIHsgaWYgKGUua2V5ID09PSAnRW50ZXInKSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgncGFzc3dvcmQnKS5mb2N1cygpOyB9KTsKCiAgICBhc3luYyBmdW5jdGlvbiBkb0xvZ2luKCkgewogICAgICB2YXIgYnRuID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2xvZ2luLWJ0bicpOwogICAgICB2YXIgZXJyRWwgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnZXJyb3ItbXNnJyk7CiAgICAgIGJ0bi5kaXNhYmxlZCA9IHRydWU7CiAgICAgIGVyckVsLnN0eWxlLmRpc3BsYXkgPSAnbm9uZSc7CiAgICAgIHZhciB1c2VybmFtZSA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCd1c2VybmFtZScpLnZhbHVlLnRyaW0oKTsKICAgICAgdmFyIHBhc3N3b3JkID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3Bhc3N3b3JkJykudmFsdWU7CiAgICAgIGlmICghdXNlcm5hbWUgfHwgIXBhc3N3b3JkKSB7IGVyckVsLnRleHRDb250ZW50ID0gJ1BsZWFzZSBlbnRlciBib3RoIHVzZXJuYW1lIGFuZCBwYXNzd29yZCc7IGVyckVsLnN0eWxlLmRpc3BsYXkgPSAnYmxvY2snOyBidG4uZGlzYWJsZWQgPSBmYWxzZTsgcmV0dXJuOyB9CiAgICAgIHRyeSB7CiAgICAgICAgdmFyIHIgPSBhd2FpdCBmZXRjaCgnL2FwaS9hdXRoL2xvZ2luJywgeyBtZXRob2Q6ICdQT1NUJywgaGVhZGVyczogeyAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH0sIGJvZHk6IEpTT04uc3RyaW5naWZ5KHsgdXNlcm5hbWU6IHVzZXJuYW1lLCBwYXNzd29yZDogcGFzc3dvcmQgfSkgfSk7CiAgICAgICAgdmFyIGQgPSBhd2FpdCByLmpzb24oKTsKICAgICAgICBpZiAoZC5vaykgeyB3aW5kb3cubG9jYXRpb24uaHJlZiA9ICcvYWRtaW4nOyB9CiAgICAgICAgZWxzZSB7IGVyckVsLnRleHRDb250ZW50ID0gZC5lcnJvciB8fCAnTG9naW4gZmFpbGVkJzsgZXJyRWwuc3R5bGUuZGlzcGxheSA9ICdibG9jayc7IH0KICAgICAgfSBjYXRjaCAoZSkgeyBlcnJFbC50ZXh0Q29udGVudCA9ICdDb25uZWN0aW9uIGVycm9yJzsgZXJyRWwuc3R5bGUuZGlzcGxheSA9ICdibG9jayc7IH0KICAgICAgYnRuLmRpc2FibGVkID0gZmFsc2U7CiAgICB9CiAgPC9zY3JpcHQ+CjwvYm9keT4KPC9odG1sPgo= \ No newline at end of file From ec79ac47526746ccdcc60703f8f5f977b776fc98 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 25 May 2026 10:28:15 +1000 Subject: [PATCH 06/30] feat: admin login, API token auth, REST client endpoints for v1.4.0 --- server.js | 108 +----------------------------------------------------- 1 file changed, 1 insertion(+), 107 deletions(-) diff --git a/server.js b/server.js index a24b549..28e03bd 100644 --- a/server.js +++ b/server.js @@ -1,107 +1 @@ -const express = require('express'); -const fetch = require('node-fetch'); -const path = require('path'); -const http = require('http'); -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'; - -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.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'); } } })); -app.use(express.json()); - -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 }); }); -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 }); } }); -app.get('/admin', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'admin', 'index.html')); }); -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('Slideshow: ' + SLIDESHOW_INTERVAL + 's interval, refresh every ' + REFRESH_INTERVAL + 's'); - log('Videos: ' + (INCLUDE_VIDEOS ? 'enabled' : 'disabled')); - log('Waiting for connections...'); -}); +Y29uc3QgZXhwcmVzcyA9IHJlcXVpcmUoJ2V4cHJlc3MnKTsKY29uc3QgZmV0Y2ggPSByZXF1aXJlKCdub2RlLWZldGNoJyk7CmNvbnN0IHBhdGggPSByZXF1aXJlKCdwYXRoJyk7CmNvbnN0IGh0dHAgPSByZXF1aXJlKCdodHRwJyk7CmNvbnN0IGNyeXB0byA9IHJlcXVpcmUoJ2NyeXB0bycpOwpjb25zdCB7IFdlYlNvY2tldFNlcnZlciwgV2ViU29ja2V0IH0gPSByZXF1aXJlKCd3cycpOwpyZXF1aXJlKCdkb3RlbnYnKS5jb25maWcoKTsKCmNvbnN0IFZFUlNJT04gPSAnMS40LjAnOwpjb25zdCBhcHAgPSBleHByZXNzKCk7CmNvbnN0IHNlcnZlciA9IGh0dHAuY3JlYXRlU2VydmVyKGFwcCk7CmNvbnN0IFBPUlQgPSBwcm9jZXNzLmVudi5QT1JUIHx8IDMwMDA7CmNvbnN0IElNTUlDSF9VUkwgPSAocHJvY2Vzcy5lbnYuSU1NSUNIX1VSTCB8fCAnaHR0cDovL2xvY2FsaG9zdDoyMjgzJykucmVwbGFjZSgvXC8rJC8sICcnKTsKY29uc3QgQVBJX0tFWSA9IHByb2Nlc3MuZW52LklNTUlDSF9BUElfS0VZIHx8ICcnOwpjb25zdCBTTElERVNIT1dfSU5URVJWQUwgPSBwYXJzZUludChwcm9jZXNzLmVudi5TTElERVNIT1dfSU5URVJWQUwsIDEwKSB8fCAzMDsKY29uc3QgVFJBTlNJVElPTl9EVVJBVElPTiA9IHBhcnNlSW50KHByb2Nlc3MuZW52LlRSQU5TSVRJT05fRFVSQVRJT04sIDEwKSB8fCAyOwpjb25zdCBTSE9XX0NMT0NLID0gcHJvY2Vzcy5lbnYuU0hPV19DTE9DSyAhPT0gJ2ZhbHNlJzsKY29uc3QgU0hPV19EQVRFID0gcHJvY2Vzcy5lbnYuU0hPV19EQVRFICE9PSAnZmFsc2UnOwpjb25zdCBTSE9XX0VYSUYgPSBwcm9jZXNzLmVudi5TSE9XX0VYSUYgIT09ICdmYWxzZSc7CmNvbnN0IFNIT1dfUFJPR1JFU1MgPSBwcm9jZXNzLmVudi5TSE9XX1BST0dSRVNTICE9PSAnZmFsc2UnOwpjb25zdCBJTUFHRV9GSVQgPSBwcm9jZXNzLmVudi5JTUFHRV9GSVQgfHwgJ2NvbnRhaW4nOwpjb25zdCBCQUNLR1JPVU5EX0JMVVIgPSBwcm9jZXNzLmVudi5CQUNLR1JPVU5EX0JMVVIgIT09ICdmYWxzZSc7CmNvbnN0IFNIVUZGTEUgPSBwcm9jZXNzLmVudi5TSFVGRkxFICE9PSAnZmFsc2UnOwpjb25zdCBBTEJVTV9JRCA9IHByb2Nlc3MuZW52LkFMQlVNX0lEIHx8ICcnOwpjb25zdCBTSE9XX0ZBVk9SSVRFU19PTkxZID0gcHJvY2Vzcy5lbnYuU0hPV19GQVZPUklURVNfT05MWSA9PT0gJ3RydWUnOwpjb25zdCBSRUZSRVNIX0lOVEVSVkFMID0gcGFyc2VJbnQocHJvY2Vzcy5lbnYuUkVGUkVTSF9JTlRFUlZBTCwgMTApIHx8IDMwMDsKY29uc3QgSU5DTFVERV9WSURFT1MgPSBwcm9jZXNzLmVudi5JTkNMVURFX1ZJREVPUyAhPT0gJ2ZhbHNlJzsKCi8vIC0tLSBBdXRoIGNvbmZpZ3VyYXRpb24gLS0tCmNvbnN0IEFETUlOX1VTRVJOQU1FID0gcHJvY2Vzcy5lbnYuQURNSU5fVVNFUk5BTUUgfHwgJ2FkbWluJzsKY29uc3QgQURNSU5fUEFTU1dPUkQgPSBwcm9jZXNzLmVudi5BRE1JTl9QQVNTV09SRCB8fCAnJzsKY29uc3QgRlJBTUJFX0FQSV9UT0tFTiA9IHByb2Nlc3MuZW52LkZSQU1CRV9BUElfVE9LRU4gfHwgJyc7CmNvbnN0IEFVVEhfRU5BQkxFRCA9ICEhQURNSU5fUEFTU1dPUkQ7CgovLyBTZXNzaW9uIHN0b3JlOiB0b2tlbiAtPiB7IHVzZXJuYW1lLCBjcmVhdGVkQXQsIGV4cGlyZXNBdCB9CmNvbnN0IHNlc3Npb25zID0gbmV3IE1hcCgpOwpjb25zdCBTRVNTSU9OX1RUTCA9IDI0ICogNjAgKiA2MCAqIDEwMDA7IC8vIDI0IGhvdXJzCgpmdW5jdGlvbiBjcmVhdGVTZXNzaW9uKHVzZXJuYW1lKSB7CiAgY29uc3QgdG9rZW4gPSBjcnlwdG8ucmFuZG9tQnl0ZXMoMzIpLnRvU3RyaW5nKCdoZXgnKTsKICBjb25zdCBub3cgPSBEYXRlLm5vdygpOwogIHNlc3Npb25zLnNldCh0b2tlbiwgeyB1c2VybmFtZSwgY3JlYXRlZEF0OiBub3csIGV4cGlyZXNBdDogbm93ICsgU0VTU0lPTl9UVEwgfSk7CiAgcmV0dXJuIHRva2VuOwp9CgpmdW5jdGlvbiB2YWxpZGF0ZVNlc3Npb24odG9rZW4pIHsKICBpZiAoIXRva2VuKSByZXR1cm4gZmFsc2U7CiAgY29uc3Qgc2Vzc2lvbiA9IHNlc3Npb25zLmdldCh0b2tlbik7CiAgaWYgKCFzZXNzaW9uKSByZXR1cm4gZmFsc2U7CiAgaWYgKERhdGUubm93KCkgPiBzZXNzaW9uLmV4cGlyZXNBdCkgeyBzZXNzaW9ucy5kZWxldGUodG9rZW4pOyByZXR1cm4gZmFsc2U7IH0KICByZXR1cm4gdHJ1ZTsKfQoKZnVuY3Rpb24gY2xlYW51cFNlc3Npb25zKCkgeyBjb25zdCBub3cgPSBEYXRlLm5vdygpOyBzZXNzaW9ucy5mb3JFYWNoKChzLCB0KSA9PiB7IGlmIChub3cgPiBzLmV4cGlyZXNBdCkgc2Vzc2lvbnMuZGVsZXRlKHQpOyB9KTsgfQpzZXRJbnRlcnZhbChjbGVhbnVwU2Vzc2lvbnMsIDYwICogNjAgKiAxMDAwKTsgLy8gY2xlYW51cCBldmVyeSBob3VyCgovLyAtLS0gQWRtaW4gYXV0aCBtaWRkbGV3YXJlIChjb29raWUtYmFzZWQgZm9yIGJyb3dzZXIpIC0tLQpmdW5jdGlvbiByZXF1aXJlQWRtaW5BdXRoKHJlcSwgcmVzLCBuZXh0KSB7CiAgaWYgKCFBVVRIX0VOQUJMRUQpIHJldHVybiBuZXh0KCk7CiAgY29uc3QgY29va2llID0gcmVxLmhlYWRlcnMuY29va2llIHx8ICcnOwogIGNvbnN0IG1hdGNoID0gY29va2llLm1hdGNoKC9mcmFtYmVfc2Vzc2lvbj0oW2EtZjAtOV0rKS8pOwogIGNvbnN0IHRva2VuID0gbWF0Y2ggPyBtYXRjaFsxXSA6IG51bGw7CiAgaWYgKHZhbGlkYXRlU2Vzc2lvbih0b2tlbikpIHJldHVybiBuZXh0KCk7CiAgLy8gTm90IGF1dGhlbnRpY2F0ZWQg4oCUIGlmIHJlcXVlc3RpbmcgSFRNTCwgcmVkaXJlY3QgdG8gbG9naW47IG90aGVyd2lzZSA0MDEKICBpZiAocmVxLmFjY2VwdHMoJ2h0bWwnKSkgcmV0dXJuIHJlcy5yZWRpcmVjdCgnL2FkbWluL2xvZ2luJyk7CiAgcmV0dXJuIHJlcy5zdGF0dXMoNDAxKS5qc29uKHsgZXJyb3I6ICdVbmF1dGhvcml6ZWQnLCBtZXNzYWdlOiAnQWRtaW4gbG9naW4gcmVxdWlyZWQnIH0pOwp9CgovLyAtLS0gQVBJIHRva2VuIG1pZGRsZXdhcmUgKGZvciBleHRlcm5hbCBjYWxsZXJzIGxpa2UgSG9tZSBBc3Npc3RhbnQpIC0tLQpmdW5jdGlvbiByZXF1aXJlQXBpVG9rZW4ocmVxLCByZXMsIG5leHQpIHsKICAvLyBBY2NlcHQgZWl0aGVyOiBCZWFyZXIgdG9rZW4gaW4gQXV0aG9yaXphdGlvbiBoZWFkZXIsIG9yIHgtYXBpLXRva2VuIGhlYWRlciwgb3IgP3Rva2VuPSBxdWVyeSBwYXJhbQogIGNvbnN0IGF1dGhIZWFkZXIgPSByZXEuaGVhZGVycy5hdXRob3JpemF0aW9uIHx8ICcnOwogIGNvbnN0IGJlYXJlclRva2VuID0gYXV0aEhlYWRlci5zdGFydHNXaXRoKCdCZWFyZXIgJykgPyBhdXRoSGVhZGVyLnNsaWNlKDcpIDogJyc7CiAgY29uc3QgaGVhZGVyVG9rZW4gPSByZXEuaGVhZGVyc1sneC1hcGktdG9rZW4nXSB8fCAnJzsKICBjb25zdCBxdWVyeVRva2VuID0gcmVxLnF1ZXJ5LnRva2VuIHx8ICcnOwogIGNvbnN0IHByb3ZpZGVkID0gYmVhcmVyVG9rZW4gfHwgaGVhZGVyVG9rZW4gfHwgcXVlcnlUb2tlbjsKCiAgLy8gSWYgQVBJIHRva2VuIGlzIGNvbmZpZ3VyZWQsIHJlcXVpcmUgaXQKICBpZiAoRlJBTUJFX0FQSV9UT0tFTikgewogICAgaWYgKHByb3ZpZGVkID09PSBGUkFNQkVfQVBJX1RPS0VOKSByZXR1cm4gbmV4dCgpOwogIH0KCiAgLy8gQWxzbyBhY2NlcHQgdmFsaWQgYWRtaW4gc2Vzc2lvbiBjb29raWUKICBpZiAoQVVUSF9FTkFCTEVEKSB7CiAgICBjb25zdCBjb29raWUgPSByZXEuaGVhZGVycy5jb29raWUgfHwgJyc7CiAgICBjb25zdCBtYXRjaCA9IGNvb2tpZS5tYXRjaCgvZnJhbWJlX3Nlc3Npb249KFthLWYwLTldKykvKTsKICAgIGlmIChtYXRjaCAmJiB2YWxpZGF0ZVNlc3Npb24obWF0Y2hbMV0pKSByZXR1cm4gbmV4dCgpOwogIH0KCiAgLy8gSWYgbmVpdGhlciBhdXRoIG1ldGhvZCBpcyBjb25maWd1cmVkLCBhbGxvdyBvcGVuIGFjY2VzcwogIGlmICghRlJBTUJFX0FQSV9UT0tFTiAmJiAhQVVUSF9FTkFCTEVEKSByZXR1cm4gbmV4dCgpOwoKICByZXR1cm4gcmVzLnN0YXR1cyg0MDEpLmpzb24oeyBlcnJvcjogJ1VuYXV0aG9yaXplZCcsIG1lc3NhZ2U6ICdWYWxpZCBBUEkgdG9rZW4gb3IgYWRtaW4gc2Vzc2lvbiByZXF1aXJlZCcgfSk7Cn0KCmZ1bmN0aW9uIGltbWljaEhlYWRlcnMoKSB7IHJldHVybiB7ICd4LWFwaS1rZXknOiBBUElfS0VZLCAnQWNjZXB0JzogJ2FwcGxpY2F0aW9uL2pzb24nLCAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH07IH0KZnVuY3Rpb24gbG9nKG1zZykgeyBjb25zb2xlLmxvZygnW0ZyYW1iZV0gJyArIG1zZyk7IH0KZnVuY3Rpb24gbG9nRXJyKG1zZykgeyBjb25zb2xlLmVycm9yKCdbRnJhbWJlXSBFUlJPUjogJyArIG1zZyk7IH0KCmNvbnN0IGNsaWVudHMgPSBuZXcgTWFwKCk7CmxldCBjbGllbnROYW1lU3RvcmUgPSB7fTsKZnVuY3Rpb24gZ2V0Q2xpZW50SXAocmVxKSB7IHJldHVybiByZXEuaGVhZGVyc1sneC1mb3J3YXJkZWQtZm9yJ10/LnNwbGl0KCcsJylbMF0udHJpbSgpIHx8IHJlcS5zb2NrZXQucmVtb3RlQWRkcmVzcyB8fCAndW5rbm93bic7IH0KZnVuY3Rpb24gZ2VuZXJhdGVDbGllbnRJZChpcCkgeyByZXR1cm4gaXAucmVwbGFjZSgvWy46XS9nLCAnXycpOyB9CmZ1bmN0aW9uIGJyb2FkY2FzdFRvQWRtaW5zKG1zZykgeyBjb25zdCBkID0gSlNPTi5zdHJpbmdpZnkobXNnKTsgY2xpZW50cy5mb3JFYWNoKGMgPT4geyBpZiAoYy5yb2xlID09PSAnYWRtaW4nICYmIGMud3MucmVhZHlTdGF0ZSA9PT0gV2ViU29ja2V0Lk9QRU4pIGMud3Muc2VuZChkKTsgfSk7IH0KZnVuY3Rpb24gZ2V0Q2xpZW50TGlzdCgpIHsgY29uc3QgbGlzdCA9IFtdOyBjbGllbnRzLmZvckVhY2goKGMsIGlkKSA9PiB7IGlmIChjLnJvbGUgPT09ICdmcmFtZScpIGxpc3QucHVzaCh7IGlkLCBpcDogYy5pcCwgbmFtZTogYy5uYW1lIHx8IGNsaWVudE5hbWVTdG9yZVtjLmlwXSB8fCAnJywgc3RhdHVzOiBjLnN0YXR1cyB8fCAndW5rbm93bicsIGNvbm5lY3RlZEF0OiBjLmNvbm5lY3RlZEF0LCBsYXN0U2VlbjogYy5sYXN0U2VlbiwgY29uZmlnOiBjLmNvbmZpZyB8fCB7fSB9KTsgfSk7IHJldHVybiBsaXN0OyB9Cgpjb25zdCB3c3MgPSBuZXcgV2ViU29ja2V0U2VydmVyKHsgc2VydmVyLCBwYXRoOiAnL3dzJyB9KTsKd3NzLm9uKCdjb25uZWN0aW9uJywgKHdzLCByZXEpID0+IHsKICBjb25zdCBpcCA9IGdldENsaWVudElwKHJlcSk7CiAgY29uc3QgY2xpZW50SWQgPSBnZW5lcmF0ZUNsaWVudElkKGlwKSArICdfJyArIERhdGUubm93KCk7CiAgbG9nKCdXZWJTb2NrZXQgY29ubmVjdGVkOiAnICsgaXAgKyAnICgnICsgY2xpZW50SWQgKyAnKScpOwogIGNvbnN0IGluZm8gPSB7IHdzLCBpcCwgcm9sZTogJ2ZyYW1lJywgbmFtZTogY2xpZW50TmFtZVN0b3JlW2lwXSB8fCAnJywgc3RhdHVzOiAnY29ubmVjdGVkJywgY29ubmVjdGVkQXQ6IG5ldyBEYXRlKCkudG9JU09TdHJpbmcoKSwgbGFzdFNlZW46IG5ldyBEYXRlKCkudG9JU09TdHJpbmcoKSwgY29uZmlnOiB7fSB9OwogIGNsaWVudHMuc2V0KGNsaWVudElkLCBpbmZvKTsKICB3cy5zZW5kKEpTT04uc3RyaW5naWZ5KHsgdHlwZTogJ3dlbGNvbWUnLCBjbGllbnRJZCwgbmFtZTogaW5mby5uYW1lIH0pKTsKICB3cy5vbignbWVzc2FnZScsIHJhdyA9PiB7CiAgICB0cnkgewogICAgICBjb25zdCBtc2cgPSBKU09OLnBhcnNlKHJhdyk7IGluZm8ubGFzdFNlZW4gPSBuZXcgRGF0ZSgpLnRvSVNPU3RyaW5nKCk7CiAgICAgIHN3aXRjaCAobXNnLnR5cGUpIHsKICAgICAgICBjYXNlICdyZWdpc3Rlcic6CiAgICAgICAgICBpbmZvLnJvbGUgPSBtc2cucm9sZSB8fCAnZnJhbWUnOwogICAgICAgICAgaWYgKG1zZy5yb2xlID09PSAnYWRtaW4nKSB7IGxvZygnQWRtaW4gY29ubmVjdGVkIGZyb20gJyArIGlwKTsgd3Muc2VuZChKU09OLnN0cmluZ2lmeSh7IHR5cGU6ICdjbGllbnRMaXN0JywgY2xpZW50czogZ2V0Q2xpZW50TGlzdCgpIH0pKTsgfQogICAgICAgICAgZWxzZSB7IGxvZygnRnJhbWUgcmVnaXN0ZXJlZDogJyArIGlwKTsgaW5mby5zdGF0dXMgPSBtc2cuc3RhdHVzIHx8ICdpZGxlJzsgaW5mby5jb25maWcgPSBtc2cuY29uZmlnIHx8IHt9OyBicm9hZGNhc3RUb0FkbWlucyh7IHR5cGU6ICdjbGllbnRMaXN0JywgY2xpZW50czogZ2V0Q2xpZW50TGlzdCgpIH0pOyB9CiAgICAgICAgICBicmVhazsKICAgICAgICBjYXNlICdzdGF0dXMnOgogICAgICAgICAgaW5mby5zdGF0dXMgPSBtc2cuc3RhdHVzIHx8IGluZm8uc3RhdHVzOyBpZiAobXNnLmNvbmZpZykgaW5mby5jb25maWcgPSBtc2cuY29uZmlnOwogICAgICAgICAgYnJvYWRjYXN0VG9BZG1pbnMoeyB0eXBlOiAnY2xpZW50VXBkYXRlJywgY2xpZW50SWQsIGNsaWVudDogeyBpZDogY2xpZW50SWQsIGlwOiBpbmZvLmlwLCBuYW1lOiBpbmZvLm5hbWUsIHN0YXR1czogaW5mby5zdGF0dXMsIGxhc3RTZWVuOiBpbmZvLmxhc3RTZWVuLCBjb25maWc6IGluZm8uY29uZmlnIH0gfSk7CiAgICAgICAgICBicmVhazsKICAgICAgICBjYXNlICdhZG1pbkNvbW1hbmQnOgogICAgICAgICAgY29uc3QgdGFyZ2V0ID0gY2xpZW50cy5nZXQobXNnLnRhcmdldElkKTsKICAgICAgICAgIGlmICh0YXJnZXQgJiYgdGFyZ2V0LndzLnJlYWR5U3RhdGUgPT09IFdlYlNvY2tldC5PUEVOKSB7IHRhcmdldC53cy5zZW5kKEpTT04uc3RyaW5naWZ5KHsgdHlwZTogJ2NvbW1hbmQnLCBhY3Rpb246IG1zZy5hY3Rpb24sIHBheWxvYWQ6IG1zZy5wYXlsb2FkIH0pKTsgbG9nKCdDb21tYW5kICcgKyBtc2cuYWN0aW9uICsgJyAtPiAnICsgbXNnLnRhcmdldElkKTsgfQogICAgICAgICAgZWxzZSB3cy5zZW5kKEpTT04uc3RyaW5naWZ5KHsgdHlwZTogJ2Vycm9yJywgbWVzc2FnZTogJ0NsaWVudCBub3QgZm91bmQnIH0pKTsKICAgICAgICAgIGJyZWFrOwogICAgICAgIGNhc2UgJ3JlbmFtZUNsaWVudCc6CiAgICAgICAgICBjb25zdCBydCA9IGNsaWVudHMuZ2V0KG1zZy50YXJnZXRJZCk7CiAgICAgICAgICBpZiAocnQpIHsgcnQubmFtZSA9IG1zZy5uYW1lOyBjbGllbnROYW1lU3RvcmVbcnQuaXBdID0gbXNnLm5hbWU7IGxvZygnUmVuYW1lZCAnICsgbXNnLnRhcmdldElkICsgJyAtPiAiJyArIG1zZy5uYW1lICsgJyInKTsgYnJvYWRjYXN0VG9BZG1pbnMoeyB0eXBlOiAnY2xpZW50TGlzdCcsIGNsaWVudHM6IGdldENsaWVudExpc3QoKSB9KTsgfQogICAgICAgICAgYnJlYWs7CiAgICAgIH0KICAgIH0gY2F0Y2ggKGUpIHsgbG9nRXJyKCdXUyBwYXJzZSBlcnJvcjogJyArIGUubWVzc2FnZSk7IH0KICB9KTsKICB3cy5vbignY2xvc2UnLCAoKSA9PiB7IGxvZygnV2ViU29ja2V0IGRpc2Nvbm5lY3RlZDogJyArIGlwKTsgY2xpZW50cy5kZWxldGUoY2xpZW50SWQpOyBicm9hZGNhc3RUb0FkbWlucyh7IHR5cGU6ICdjbGllbnRMaXN0JywgY2xpZW50czogZ2V0Q2xpZW50TGlzdCgpIH0pOyB9KTsKfSk7CgphcHAudXNlKCcvYXBpJywgKHJlcSwgX3JlcywgbmV4dCkgPT4geyBsb2coJ0FQSSAnICsgcmVxLm1ldGhvZCArICcgJyArIHJlcS5vcmlnaW5hbFVybCk7IG5leHQoKTsgfSk7CmFwcC51c2UoZXhwcmVzcy5qc29uKCkpOwoKLy8gLS0tIEF1dGggZW5kcG9pbnRzIC0tLQphcHAuZ2V0KCcvYXBpL2F1dGgvc3RhdHVzJywgKF9yZXEsIHJlcykgPT4gewogIHJlcy5qc29uKHsgYXV0aEVuYWJsZWQ6IEFVVEhfRU5BQkxFRCwgYXBpVG9rZW5FbmFibGVkOiAhIUZSQU1CRV9BUElfVE9LRU4gfSk7Cn0pOwoKYXBwLnBvc3QoJy9hcGkvYXV0aC9sb2dpbicsIChyZXEsIHJlcykgPT4gewogIGlmICghQVVUSF9FTkFCTEVEKSByZXR1cm4gcmVzLmpzb24oeyBvazogdHJ1ZSwgbWVzc2FnZTogJ0F1dGggbm90IGVuYWJsZWQnIH0pOwogIGNvbnN0IHsgdXNlcm5hbWUsIHBhc3N3b3JkIH0gPSByZXEuYm9keSB8fCB7fTsKICBpZiAodXNlcm5hbWUgPT09IEFETUlOX1VTRVJOQU1FICYmIHBhc3N3b3JkID09PSBBRE1JTl9QQVNTV09SRCkgewogICAgY29uc3QgdG9rZW4gPSBjcmVhdGVTZXNzaW9uKHVzZXJuYW1lKTsKICAgIHJlcy5zZXRIZWFkZXIoJ1NldC1Db29raWUnLCBgZnJhbWJlX3Nlc3Npb249JHt0b2tlbn07IFBhdGg9LzsgSHR0cE9ubHk7IFNhbWVTaXRlPVN0cmljdDsgTWF4LUFnZT0ke1NFU1NJT05fVFRMIC8gMTAwMH1gKTsKICAgIGxvZygnQWRtaW4gbG9naW46ICcgKyB1c2VybmFtZSk7CiAgICByZXR1cm4gcmVzLmpzb24oeyBvazogdHJ1ZSB9KTsKICB9CiAgbG9nKCdGYWlsZWQgbG9naW4gYXR0ZW1wdDogJyArICh1c2VybmFtZSB8fCAnKGVtcHR5KScpKTsKICByZXR1cm4gcmVzLnN0YXR1cyg0MDEpLmpzb24oeyBvazogZmFsc2UsIGVycm9yOiAnSW52YWxpZCBjcmVkZW50aWFscycgfSk7Cn0pOwoKYXBwLnBvc3QoJy9hcGkvYXV0aC9sb2dvdXQnLCAocmVxLCByZXMpID0+IHsKICBjb25zdCBjb29raWUgPSByZXEuaGVhZGVycy5jb29raWUgfHwgJyc7CiAgY29uc3QgbWF0Y2ggPSBjb29raWUubWF0Y2goL2ZyYW1iZV9zZXNzaW9uPShbYS1mMC05XSspLyk7CiAgaWYgKG1hdGNoKSBzZXNzaW9ucy5kZWxldGUobWF0Y2hbMV0pOwogIHJlcy5zZXRIZWFkZXIoJ1NldC1Db29raWUnLCAnZnJhbWJlX3Nlc3Npb249OyBQYXRoPS87IEh0dHBPbmx5OyBNYXgtQWdlPTAnKTsKICByZXMuanNvbih7IG9rOiB0cnVlIH0pOwp9KTsKCi8vIC0tLSBMb2dpbiBwYWdlIChzZXJ2ZWQgd2l0aG91dCBhdXRoKSAtLS0KYXBwLmdldCgnL2FkbWluL2xvZ2luJywgKF9yZXEsIHJlcykgPT4gewogIGlmICghQVVUSF9FTkFCTEVEKSByZXR1cm4gcmVzLnJlZGlyZWN0KCcvYWRtaW4nKTsKICByZXMuc2VuZEZpbGUocGF0aC5qb2luKF9fZGlybmFtZSwgJ3B1YmxpYycsICdhZG1pbicsICdsb2dpbi5odG1sJykpOwp9KTsKCi8vIC0tLSBTdGF0aWMgZmlsZXMgKG5vbi1hZG1pbiBwYWdlcyBkb24ndCByZXF1aXJlIGF1dGgpIC0tLQphcHAudXNlKGV4cHJlc3Muc3RhdGljKHBhdGguam9pbihfX2Rpcm5hbWUsICdwdWJsaWMnKSwgeyBzZXRIZWFkZXJzOiAocmVzLCBmcCkgPT4geyBpZiAoZnAuZW5kc1dpdGgoJy5odG1sJykgfHwgZnAuZW5kc1dpdGgoJy5qcycpIHx8IGZwLmVuZHNXaXRoKCcuY3NzJykpIHsgcmVzLnNldEhlYWRlcignQ2FjaGUtQ29udHJvbCcsICduby1jYWNoZSwgbm8tc3RvcmUsIG11c3QtcmV2YWxpZGF0ZScpOyByZXMuc2V0SGVhZGVyKCdQcmFnbWEnLCAnbm8tY2FjaGUnKTsgcmVzLnNldEhlYWRlcignRXhwaXJlcycsICcwJyk7IH0gfSB9KSk7CgpmdW5jdGlvbiBtYXBBc3NldChhKSB7IHJldHVybiB7IGlkOiBhLmlkLCB0eXBlOiBhLnR5cGUsIG9yaWdpbmFsRmlsZU5hbWU6IGEub3JpZ2luYWxGaWxlTmFtZSwgZmlsZUNyZWF0ZWRBdDogYS5maWxlQ3JlYXRlZEF0LCBpc0Zhdm9yaXRlOiBhLmlzRmF2b3JpdGUsIGV4aWZJbmZvOiBhLmV4aWZJbmZvID8geyBtYWtlOiBhLmV4aWZJbmZvLm1ha2UsIG1vZGVsOiBhLmV4aWZJbmZvLm1vZGVsLCBjaXR5OiBhLmV4aWZJbmZvLmNpdHksIHN0YXRlOiBhLmV4aWZJbmZvLnN0YXRlLCBjb3VudHJ5OiBhLmV4aWZJbmZvLmNvdW50cnksIGRlc2NyaXB0aW9uOiBhLmV4aWZJbmZvLmRlc2NyaXB0aW9uLCBkYXRlVGltZU9yaWdpbmFsOiBhLmV4aWZJbmZvLmRhdGVUaW1lT3JpZ2luYWwgfSA6IG51bGwgfTsgfQpmdW5jdGlvbiBmaWx0ZXJBc3NldHMoYXNzZXRzKSB7IHJldHVybiBJTkNMVURFX1ZJREVPUyA/IGFzc2V0cy5maWx0ZXIoYSA9PiBhLnR5cGUgPT09ICdJTUFHRScgfHwgYS50eXBlID09PSAnVklERU8nKSA6IGFzc2V0cy5maWx0ZXIoYSA9PiBhLnR5cGUgPT09ICdJTUFHRScpOyB9CgphcHAuZ2V0KCcvYXBpL2NvbmZpZycsIChfcmVxLCByZXMpID0+IHsgcmVzLmpzb24oeyB2ZXJzaW9uOiBWRVJTSU9OLCBzbGlkZXNob3dJbnRlcnZhbDogU0xJREVTSE9XX0lOVEVSVkFMLCB0cmFuc2l0aW9uRHVyYXRpb246IFRSQU5TSVRJT05fRFVSQVRJT04sIHNob3dDbG9jazogU0hPV19DTE9DSywgc2hvd0RhdGU6IFNIT1dfREFURSwgc2hvd0V4aWY6IFNIT1dfRVhJRiwgc2hvd1Byb2dyZXNzOiBTSE9XX1BST0dSRVNTLCBpbWFnZUZpdDogSU1BR0VfRklULCBiYWNrZ3JvdW5kQmx1cjogQkFDS0dST1VORF9CTFVSLCBzaHVmZmxlOiBTSFVGRkxFLCBhbGJ1bUlkOiBBTEJVTV9JRCwgc2hvd0Zhdm9yaXRlc09ubHk6IFNIT1dfRkFWT1JJVEVTX09OTFksIHJlZnJlc2hJbnRlcnZhbDogUkVGUkVTSF9JTlRFUlZBTCwgaW5jbHVkZVZpZGVvczogSU5DTFVERV9WSURFT1MsIGNvbm5lY3RlZDogISFBUElfS0VZLCBhdXRoRW5hYmxlZDogQVVUSF9FTkFCTEVEIH0pOyB9KTsKYXBwLmdldCgnL2FwaS9zZXJ2ZXItaW5mbycsIGFzeW5jIChfcmVxLCByZXMpID0+IHsgdHJ5IHsgY29uc3QgciA9IGF3YWl0IGZldGNoKGAke0lNTUlDSF9VUkx9L2FwaS9zZXJ2ZXIvdmVyc2lvbmAsIHsgaGVhZGVyczogaW1taWNoSGVhZGVycygpIH0pOyBpZiAoIXIub2spIHRocm93IG5ldyBFcnJvcihgSW1taWNoIHJldHVybmVkICR7ci5zdGF0dXN9YCk7IGNvbnN0IHYgPSBhd2FpdCByLmpzb24oKTsgbG9nKCdJbW1pY2ggT0sgdicgKyB2Lm1ham9yICsgJy4nICsgdi5taW5vciArICcuJyArIHYucGF0Y2gpOyByZXMuanNvbih7IG9rOiB0cnVlLCB2ZXJzaW9uOiB2IH0pOyB9IGNhdGNoIChlKSB7IGxvZ0VycignSW1taWNoIGZhaWxlZDogJyArIGUubWVzc2FnZSk7IHJlcy5zdGF0dXMoNTAyKS5qc29uKHsgb2s6IGZhbHNlLCBlcnJvcjogZS5tZXNzYWdlIH0pOyB9IH0pOwphcHAuZ2V0KCcvYXBpL2FsYnVtcycsIGFzeW5jIChfcmVxLCByZXMpID0+IHsgdHJ5IHsgY29uc3QgciA9IGF3YWl0IGZldGNoKGAke0lNTUlDSF9VUkx9L2FwaS9hbGJ1bXNgLCB7IGhlYWRlcnM6IGltbWljaEhlYWRlcnMoKSB9KTsgaWYgKCFyLm9rKSB0aHJvdyBuZXcgRXJyb3IoYCR7ci5zdGF0dXN9YCk7IGNvbnN0IGEgPSBhd2FpdCByLmpzb24oKTsgbG9nKCdMaXN0ZWQgJyArIGEubGVuZ3RoICsgJyBhbGJ1bXMnKTsgcmVzLmpzb24oYS5tYXAoeCA9PiAoeyBpZDogeC5pZCwgYWxidW1OYW1lOiB4LmFsYnVtTmFtZSwgYXNzZXRDb3VudDogeC5hc3NldENvdW50LCBhbGJ1bVRodW1ibmFpbEFzc2V0SWQ6IHguYWxidW1UaHVtYm5haWxBc3NldElkLCB1cGRhdGVkQXQ6IHgudXBkYXRlZEF0IH0pKSk7IH0gY2F0Y2ggKGUpIHsgbG9nRXJyKCdBbGJ1bXM6ICcgKyBlLm1lc3NhZ2UpOyByZXMuc3RhdHVzKDUwMikuanNvbih7IGVycm9yOiBlLm1lc3NhZ2UgfSk7IH0gfSk7CmFwcC5nZXQoJy9hcGkvYWxidW1zLzppZCcsIGFzeW5jIChyZXEsIHJlcykgPT4geyB0cnkgeyBjb25zdCByID0gYXdhaXQgZmV0Y2goYCR7SU1NSUNIX1VSTH0vYXBpL2FsYnVtcy8ke3JlcS5wYXJhbXMuaWR9YCwgeyBoZWFkZXJzOiBpbW1pY2hIZWFkZXJzKCkgfSk7IGlmICghci5vaykgdGhyb3cgbmV3IEVycm9yKGAke3Iuc3RhdHVzfWApOyBjb25zdCBhbCA9IGF3YWl0IHIuanNvbigpOyBjb25zdCBhID0gZmlsdGVyQXNzZXRzKGFsLmFzc2V0cyB8fCBbXSkubWFwKG1hcEFzc2V0KTsgbG9nKCdBbGJ1bSAiJyArIGFsLmFsYnVtTmFtZSArICciOiAnICsgYS5sZW5ndGggKyAnIGFzc2V0cycpOyByZXMuanNvbih7IGlkOiBhbC5pZCwgYWxidW1OYW1lOiBhbC5hbGJ1bU5hbWUsIGFzc2V0Q291bnQ6IGEubGVuZ3RoLCBhc3NldHM6IGEgfSk7IH0gY2F0Y2ggKGUpIHsgbG9nRXJyKCdBbGJ1bTogJyArIGUubWVzc2FnZSk7IHJlcy5zdGF0dXMoNTAyKS5qc29uKHsgZXJyb3I6IGUubWVzc2FnZSB9KTsgfSB9KTsKYXBwLmdldCgnL2FwaS9wZW9wbGUnLCBhc3luYyAoX3JlcSwgcmVzKSA9PiB7IHRyeSB7IGNvbnN0IHIgPSBhd2FpdCBmZXRjaChgJHtJTU1JQ0hfVVJMfS9hcGkvcGVvcGxlYCwgeyBoZWFkZXJzOiBpbW1pY2hIZWFkZXJzKCkgfSk7IGlmICghci5vaykgdGhyb3cgbmV3IEVycm9yKGAke3Iuc3RhdHVzfWApOyBjb25zdCBkID0gYXdhaXQgci5qc29uKCk7IHJlcy5qc29uKChkLnBlb3BsZSB8fCBkIHx8IFtdKS5tYXAocCA9PiAoeyBpZDogcC5pZCwgbmFtZTogcC5uYW1lLCB0aHVtYm5haWxQYXRoOiBwLnRodW1ibmFpbFBhdGggfSkpKTsgfSBjYXRjaCAoZSkgeyByZXMuc3RhdHVzKDUwMikuanNvbih7IGVycm9yOiBlLm1lc3NhZ2UgfSk7IH0gfSk7CmFwcC5nZXQoJy9hcGkvcGVvcGxlLzppZCcsIGFzeW5jIChyZXEsIHJlcykgPT4geyB0cnkgeyBjb25zdCByID0gYXdhaXQgZmV0Y2goYCR7SU1NSUNIX1VSTH0vYXBpL3Blb3BsZS8ke3JlcS5wYXJhbXMuaWR9L2Fzc2V0c2AsIHsgaGVhZGVyczogaW1taWNoSGVhZGVycygpIH0pOyBpZiAoIXIub2spIHRocm93IG5ldyBFcnJvcihgJHtyLnN0YXR1c31gKTsgY29uc3QgcmF3ID0gYXdhaXQgci5qc29uKCk7IHJlcy5qc29uKGZpbHRlckFzc2V0cyhBcnJheS5pc0FycmF5KHJhdykgPyByYXcgOiBbXSkubWFwKG1hcEFzc2V0KSk7IH0gY2F0Y2ggKGUpIHsgcmVzLnN0YXR1cyg1MDIpLmpzb24oeyBlcnJvcjogZS5tZXNzYWdlIH0pOyB9IH0pOwphcHAuZ2V0KCcvYXBpL3Blb3BsZS86aWQvdGh1bWJuYWlsJywgYXN5bmMgKHJlcSwgcmVzKSA9PiB7IHRyeSB7IGNvbnN0IHIgPSBhd2FpdCBmZXRjaChgJHtJTU1JQ0hfVVJMfS9hcGkvcGVvcGxlLyR7cmVxLnBhcmFtcy5pZH0vdGh1bWJuYWlsYCwgeyBoZWFkZXJzOiB7ICd4LWFwaS1rZXknOiBBUElfS0VZIH0gfSk7IGlmICghci5vaykgdGhyb3cgbmV3IEVycm9yKGAke3Iuc3RhdHVzfWApOyByZXMuc2V0KCdDb250ZW50LVR5cGUnLCByLmhlYWRlcnMuZ2V0KCdjb250ZW50LXR5cGUnKSB8fCAnaW1hZ2UvanBlZycpOyByZXMuc2V0KCdDYWNoZS1Db250cm9sJywgJ3B1YmxpYywgbWF4LWFnZT04NjQwMCcpOyByLmJvZHkucGlwZShyZXMpOyB9IGNhdGNoIChlKSB7IHJlcy5zdGF0dXMoNTAyKS5qc29uKHsgZXJyb3I6IGUubWVzc2FnZSB9KTsgfSB9KTsKYXBwLmdldCgnL2FwaS9hc3NldHMvcmFuZG9tJywgYXN5bmMgKHJlcSwgcmVzKSA9PiB7IHRyeSB7IGNvbnN0IGMgPSBNYXRoLm1pbihwYXJzZUludChyZXEucXVlcnkuY291bnQsIDEwKSB8fCA1MCwgMjUwKTsgY29uc3QgciA9IGF3YWl0IGZldGNoKGAke0lNTUlDSF9VUkx9L2FwaS9hc3NldHMvcmFuZG9tP2NvdW50PSR7Y31gLCB7IGhlYWRlcnM6IGltbWljaEhlYWRlcnMoKSB9KTsgaWYgKCFyLm9rKSB0aHJvdyBuZXcgRXJyb3IoYCR7ci5zdGF0dXN9YCk7IHJlcy5qc29uKGZpbHRlckFzc2V0cyhhd2FpdCByLmpzb24oKSkubWFwKG1hcEFzc2V0KSk7IH0gY2F0Y2ggKGUpIHsgcmVzLnN0YXR1cyg1MDIpLmpzb24oeyBlcnJvcjogZS5tZXNzYWdlIH0pOyB9IH0pOwphcHAuZ2V0KCcvYXBpL2Fzc2V0cy9mYXZvcml0ZXMnLCBhc3luYyAoX3JlcSwgcmVzKSA9PiB7IHRyeSB7IGNvbnN0IHIgPSBhd2FpdCBmZXRjaChgJHtJTU1JQ0hfVVJMfS9hcGkvc2VhcmNoL21ldGFkYXRhYCwgeyBtZXRob2Q6ICdQT1NUJywgaGVhZGVyczogaW1taWNoSGVhZGVycygpLCBib2R5OiBKU09OLnN0cmluZ2lmeSh7IGlzRmF2b3JpdGU6IHRydWUsIHNpemU6IDI1MCwgcGFnZTogMSB9KSB9KTsgaWYgKCFyLm9rKSB0aHJvdyBuZXcgRXJyb3IoYCR7ci5zdGF0dXN9YCk7IGNvbnN0IGQgPSBhd2FpdCByLmpzb24oKTsgcmVzLmpzb24oZmlsdGVyQXNzZXRzKGQuYXNzZXRzPy5pdGVtcyB8fCBbXSkubWFwKGEgPT4gKHsgLi4ubWFwQXNzZXQoYSksIGlzRmF2b3JpdGU6IHRydWUgfSkpKTsgfSBjYXRjaCAoZSkgeyByZXMuc3RhdHVzKDUwMikuanNvbih7IGVycm9yOiBlLm1lc3NhZ2UgfSk7IH0gfSk7CmFwcC5nZXQoJy9hcGkvYXNzZXRzLzppZC90aHVtYm5haWwnLCBhc3luYyAocmVxLCByZXMpID0+IHsgdHJ5IHsgY29uc3QgciA9IGF3YWl0IGZldGNoKGAke0lNTUlDSF9VUkx9L2FwaS9hc3NldHMvJHtyZXEucGFyYW1zLmlkfS90aHVtYm5haWw/c2l6ZT0ke3JlcS5xdWVyeS5zaXplIHx8ICdwcmV2aWV3J31gLCB7IGhlYWRlcnM6IHsgJ3gtYXBpLWtleSc6IEFQSV9LRVkgfSB9KTsgaWYgKCFyLm9rKSB0aHJvdyBuZXcgRXJyb3IoYCR7ci5zdGF0dXN9YCk7IHJlcy5zZXQoJ0NvbnRlbnQtVHlwZScsIHIuaGVhZGVycy5nZXQoJ2NvbnRlbnQtdHlwZScpIHx8ICdpbWFnZS9qcGVnJyk7IHJlcy5zZXQoJ0NhY2hlLUNvbnRyb2wnLCAncHVibGljLCBtYXgtYWdlPTg2NDAwJyk7IHIuYm9keS5waXBlKHJlcyk7IH0gY2F0Y2ggKGUpIHsgcmVzLnN0YXR1cyg1MDIpLmpzb24oeyBlcnJvcjogZS5tZXNzYWdlIH0pOyB9IH0pOwphcHAuZ2V0KCcvYXBpL2Fzc2V0cy86aWQvdmlkZW8nLCBhc3luYyAocmVxLCByZXMpID0+IHsgdHJ5IHsgY29uc3QgciA9IGF3YWl0IGZldGNoKGAke0lNTUlDSF9VUkx9L2FwaS9hc3NldHMvJHtyZXEucGFyYW1zLmlkfS92aWRlby9wbGF5YmFja2AsIHsgaGVhZGVyczogeyAneC1hcGkta2V5JzogQVBJX0tFWSB9IH0pOyBpZiAoIXIub2spIHRocm93IG5ldyBFcnJvcihgJHtyLnN0YXR1c31gKTsgcmVzLnNldCgnQ29udGVudC1UeXBlJywgci5oZWFkZXJzLmdldCgnY29udGVudC10eXBlJykgfHwgJ3ZpZGVvL21wNCcpOyByZXMuc2V0KCdDYWNoZS1Db250cm9sJywgJ3B1YmxpYywgbWF4LWFnZT04NjQwMCcpOyBjb25zdCBjbCA9IHIuaGVhZGVycy5nZXQoJ2NvbnRlbnQtbGVuZ3RoJyk7IGlmIChjbCkgcmVzLnNldCgnQ29udGVudC1MZW5ndGgnLCBjbCk7IHIuYm9keS5waXBlKHJlcyk7IH0gY2F0Y2ggKGUpIHsgcmVzLnN0YXR1cyg1MDIpLmpzb24oeyBlcnJvcjogZS5tZXNzYWdlIH0pOyB9IH0pOwphcHAuZ2V0KCcvYXBpL2Fzc2V0cy86aWQvb3JpZ2luYWwnLCBhc3luYyAocmVxLCByZXMpID0+IHsgdHJ5IHsgY29uc3QgciA9IGF3YWl0IGZldGNoKGAke0lNTUlDSF9VUkx9L2FwaS9hc3NldHMvJHtyZXEucGFyYW1zLmlkfS9vcmlnaW5hbGAsIHsgaGVhZGVyczogeyAneC1hcGkta2V5JzogQVBJX0tFWSB9IH0pOyBpZiAoIXIub2spIHRocm93IG5ldyBFcnJvcihgJHtyLnN0YXR1c31gKTsgcmVzLnNldCgnQ29udGVudC1UeXBlJywgci5oZWFkZXJzLmdldCgnY29udGVudC10eXBlJykgfHwgJ2ltYWdlL2pwZWcnKTsgcmVzLnNldCgnQ2FjaGUtQ29udHJvbCcsICdwdWJsaWMsIG1heC1hZ2U9ODY0MDAnKTsgci5ib2R5LnBpcGUocmVzKTsgfSBjYXRjaCAoZSkgeyByZXMuc3RhdHVzKDUwMikuanNvbih7IGVycm9yOiBlLm1lc3NhZ2UgfSk7IH0gfSk7CgovLyAtLS0gUkVTVCBBUEk6IENsaWVudCBtYW5hZ2VtZW50ICh0b2tlbi1hdXRoZW50aWNhdGVkIGZvciBIb21lIEFzc2lzdGFudCBldGMuKSAtLS0KYXBwLmdldCgnL2FwaS9jbGllbnRzJywgcmVxdWlyZUFwaVRva2VuLCAoX3JlcSwgcmVzKSA9PiB7CiAgcmVzLmpzb24oeyBvazogdHJ1ZSwgY2xpZW50czogZ2V0Q2xpZW50TGlzdCgpIH0pOwp9KTsKCmFwcC5wb3N0KCcvYXBpL2NsaWVudHMvOmlkL2NvbW1hbmQnLCByZXF1aXJlQXBpVG9rZW4sIChyZXEsIHJlcykgPT4gewogIGNvbnN0IHsgaWQgfSA9IHJlcS5wYXJhbXM7CiAgY29uc3QgeyBhY3Rpb24sIHBheWxvYWQgfSA9IHJlcS5ib2R5IHx8IHt9OwoKICBpZiAoIWFjdGlvbikgcmV0dXJuIHJlcy5zdGF0dXMoNDAwKS5qc29uKHsgb2s6IGZhbHNlLCBlcnJvcjogJ01pc3NpbmcgYWN0aW9uJyB9KTsKCiAgY29uc3QgdGFyZ2V0ID0gY2xpZW50cy5nZXQoaWQpOwogIGlmICghdGFyZ2V0KSByZXR1cm4gcmVzLnN0YXR1cyg0MDQpLmpzb24oeyBvazogZmFsc2UsIGVycm9yOiAnQ2xpZW50IG5vdCBmb3VuZCcgfSk7CiAgaWYgKHRhcmdldC5yb2xlICE9PSAnZnJhbWUnKSByZXR1cm4gcmVzLnN0YXR1cyg0MDApLmpzb24oeyBvazogZmFsc2UsIGVycm9yOiAnVGFyZ2V0IGlzIG5vdCBhIGZyYW1lIGNsaWVudCcgfSk7CiAgaWYgKHRhcmdldC53cy5yZWFkeVN0YXRlICE9PSBXZWJTb2NrZXQuT1BFTikgcmV0dXJuIHJlcy5zdGF0dXMoNDEwKS5qc29uKHsgb2s6IGZhbHNlLCBlcnJvcjogJ0NsaWVudCBXZWJTb2NrZXQgbm90IGNvbm5lY3RlZCcgfSk7CgogIHRhcmdldC53cy5zZW5kKEpTT04uc3RyaW5naWZ5KHsgdHlwZTogJ2NvbW1hbmQnLCBhY3Rpb24sIHBheWxvYWQ6IHBheWxvYWQgfHwge30gfSkpOwogIGxvZygnUkVTVCBjb21tYW5kICcgKyBhY3Rpb24gKyAnIC0+ICcgKyBpZCk7CiAgcmVzLmpzb24oeyBvazogdHJ1ZSwgYWN0aW9uLCB0YXJnZXRJZDogaWQgfSk7Cn0pOwoKLy8gLS0tIEFkbWluIGRhc2hib2FyZCAoYXV0aC1wcm90ZWN0ZWQpIC0tLQphcHAuZ2V0KCcvYWRtaW4nLCByZXF1aXJlQWRtaW5BdXRoLCAoX3JlcSwgcmVzKSA9PiB7IHJlcy5zZW5kRmlsZShwYXRoLmpvaW4oX19kaXJuYW1lLCAncHVibGljJywgJ2FkbWluJywgJ2luZGV4Lmh0bWwnKSk7IH0pOwoKLy8gLS0tIENhdGNoLWFsbCBmb3IgZnJhbWUgU1BBIC0tLQphcHAuZ2V0KCcqJywgKF9yZXEsIHJlcykgPT4geyByZXMuc2VuZEZpbGUocGF0aC5qb2luKF9fZGlybmFtZSwgJ3B1YmxpYycsICdpbmRleC5odG1sJykpOyB9KTsKCnNlcnZlci5saXN0ZW4oUE9SVCwgJzAuMC4wLjAnLCAoKSA9PiB7CiAgbG9nKCctLS0gRnJhbWJlIHYnICsgVkVSU0lPTiArICcgLS0tJyk7CiAgbG9nKCdTZXJ2ZXIgbGlzdGVuaW5nIG9uIHBvcnQgJyArIFBPUlQpOwogIGxvZygnQWRtaW4gZGFzaGJvYXJkOiBodHRwOi8vMC4wLjAuMDonICsgUE9SVCArICcvYWRtaW4nKTsKICBsb2coJ1dlYlNvY2tldDogd3M6Ly8wLjAuMC4wOicgKyBQT1JUICsgJy93cycpOwogIGxvZygnSW1taWNoIFVSTDogJyArIElNTUlDSF9VUkwpOwogIGxvZygnQVBJIGtleTogJyArIChBUElfS0VZID8gJ2NvbmZpZ3VyZWQgKCcgKyBBUElfS0VZLnN1YnN0cmluZygwLCA4KSArICcuLi4pJyA6ICdOT1QgU0VUJykpOwogIGxvZygnQWRtaW4gYXV0aDogJyArIChBVVRIX0VOQUJMRUQgPyAnRU5BQkxFRCAodXNlcjogJyArIEFETUlOX1VTRVJOQU1FICsgJyknIDogJ0RJU0FCTEVEIChubyBBRE1JTl9QQVNTV09SRCBzZXQpJykpOwogIGxvZygnQVBJIHRva2VuOiAnICsgKEZSQU1CRV9BUElfVE9LRU4gPyAnY29uZmlndXJlZCAoJyArIEZSQU1CRV9BUElfVE9LRU4uc3Vic3RyaW5nKDAsIDgpICsgJy4uLiknIDogJ05PVCBTRVQgKFJFU1QgQVBJIG9wZW4pJykpOwogIGxvZygnU2xpZGVzaG93OiAnICsgU0xJREVTSE9XX0lOVEVSVkFMICsgJ3MgaW50ZXJ2YWwsIHJlZnJlc2ggZXZlcnkgJyArIFJFRlJFU0hfSU5URVJWQUwgKyAncycpOwogIGxvZygnVmlkZW9zOiAnICsgKElOQ0xVREVfVklERU9TID8gJ2VuYWJsZWQnIDogJ2Rpc2FibGVkJykpOwogIGxvZygnV2FpdGluZyBmb3IgY29ubmVjdGlvbnMuLi4nKTsKfSk7Cg== \ No newline at end of file From a269456f41da6d40b986455e4618501a70013749 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 25 May 2026 16:23:58 +1000 Subject: [PATCH 07/30] feat: add logout button and auth check to admin dashboard --- public/admin/index.html | 104 +--------------------------------------- 1 file changed, 1 insertion(+), 103 deletions(-) diff --git a/public/admin/index.html b/public/admin/index.html index ebc8652..cffb2c1 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -1,103 +1 @@ - - - - - - Frambe Admin - - - -
- Frambe -

Frambe Admin

Connecting...
- Disconnected -
-
-

No frames connected

Open Frambe on a tablet or screen to see it here

-
- - - +PCFET0NUWVBFIGh0bWw+CjxodG1sIGxhbmc9ImVuIj4KPGhlYWQ+CiAgPG1ldGEgY2hhcnNldD0iVVRGLTgiPgogIDxtZXRhIG5hbWU9InZpZXdwb3J0IiBjb250ZW50PSJ3aWR0aD1kZXZpY2Utd2lkdGgsIGluaXRpYWwtc2NhbGU9MS4wIj4KICA8dGl0bGU+RnJhbWJlIEFkbWluPC90aXRsZT4KICA8c3R5bGU+CiAgICAqLCAqOjpiZWZvcmUsICo6OmFmdGVyIHsgbWFyZ2luOiAwOyBwYWRkaW5nOiAwOyBib3gtc2l6aW5nOiBib3JkZXItYm94OyB9CiAgICBib2R5IHsgYmFja2dyb3VuZDogIzBmMGYxYTsgY29sb3I6ICNlMGUwZTA7IGZvbnQtZmFtaWx5OiAtYXBwbGUtc3lzdGVtLCBCbGlua01hY1N5c3RlbUZvbnQsICdTZWdvZSBVSScsIFJvYm90bywgc2Fucy1zZXJpZjsgcGFkZGluZzogMS41cmVtOyBtaW4taGVpZ2h0OiAxMDB2aDsgfQogICAgaDEgeyBmb250LXNpemU6IDEuNnJlbTsgZm9udC13ZWlnaHQ6IDMwMDsgbWFyZ2luLWJvdHRvbTogMC4yNXJlbTsgfQogICAgLmhlYWRlciB7IGRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogMXJlbTsgbWFyZ2luLWJvdHRvbTogMS41cmVtOyBib3JkZXItYm90dG9tOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwwLjEpOyBwYWRkaW5nLWJvdHRvbTogMXJlbTsgfQogICAgLmhlYWRlciBpbWcgeyB3aWR0aDogNDhweDsgaGVpZ2h0OiA0OHB4OyBib3JkZXItcmFkaXVzOiAxMHB4OyB9CiAgICAuaGVhZGVyIC52ZXJzaW9uIHsgZm9udC1zaXplOiAwLjhyZW07IGNvbG9yOiAjNjY2OyB9CiAgICAuaGVhZGVyLXJpZ2h0IHsgbWFyZ2luLWxlZnQ6IGF1dG87IGRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogMC43NXJlbTsgfQogICAgLnN0YXR1cy1kb3QgeyBkaXNwbGF5OiBpbmxpbmUtYmxvY2s7IHdpZHRoOiA4cHg7IGhlaWdodDogOHB4OyBib3JkZXItcmFkaXVzOiA1MCU7IG1hcmdpbi1yaWdodDogNnB4OyB9CiAgICAuc3RhdHVzLWRvdC5vbmxpbmUgeyBiYWNrZ3JvdW5kOiAjNGFkZTgwOyB9CiAgICAuc3RhdHVzLWRvdC5zbGVlcGluZyB7IGJhY2tncm91bmQ6ICNmYmJmMjQ7IH0KICAgIC5zdGF0dXMtZG90LnBsYXlpbmcgeyBiYWNrZ3JvdW5kOiAjNjBhNWZhOyB9CiAgICAuY2xpZW50cy1ncmlkIHsgZGlzcGxheTogZ3JpZDsgZ3JpZC10ZW1wbGF0ZS1jb2x1bW5zOiByZXBlYXQoYXV0by1maWxsLCBtaW5tYXgoMzQwcHgsIDFmcikpOyBnYXA6IDFyZW07IH0KICAgIC5jbGllbnQtY2FyZCB7IGJhY2tncm91bmQ6IHJnYmEoMjU1LDI1NSwyNTUsMC4wNCk7IGJvcmRlcjogMXB4IHNvbGlkIHJnYmEoMjU1LDI1NSwyNTUsMC4wOCk7IGJvcmRlci1yYWRpdXM6IDEycHg7IHBhZGRpbmc6IDEuMjVyZW07IHRyYW5zaXRpb246IGFsbCAwLjJzOyB9CiAgICAuY2xpZW50LWNhcmQ6aG92ZXIgeyBib3JkZXItY29sb3I6IHJnYmEoOTksMTAyLDI0MSwwLjMpOyBiYWNrZ3JvdW5kOiByZ2JhKDI1NSwyNTUsMjU1LDAuMDYpOyB9CiAgICAuY2xpZW50LWhlYWRlciB7IGRpc3BsYXk6IGZsZXg7IGp1c3RpZnktY29udGVudDogc3BhY2UtYmV0d2VlbjsgYWxpZ24taXRlbXM6IGNlbnRlcjsgbWFyZ2luLWJvdHRvbTogMXJlbTsgfQogICAgLmNsaWVudC1uYW1lIHsgZm9udC1zaXplOiAxLjFyZW07IGZvbnQtd2VpZ2h0OiA1MDA7IGRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogMC41cmVtOyB9CiAgICAuY2xpZW50LWlwIHsgZm9udC1zaXplOiAwLjc1cmVtOyBjb2xvcjogIzg4ODsgZm9udC1mYW1pbHk6IG1vbm9zcGFjZTsgfQogICAgLmNsaWVudC1zdGF0dXMgeyBmb250LXNpemU6IDAuOHJlbTsgY29sb3I6ICNhYWE7IHRleHQtdHJhbnNmb3JtOiBjYXBpdGFsaXplOyB9CiAgICAubmFtZS1pbnB1dCB7IGJhY2tncm91bmQ6IHRyYW5zcGFyZW50OyBib3JkZXI6IDFweCBzb2xpZCByZ2JhKDI1NSwyNTUsMjU1LDAuMTUpOyBib3JkZXItcmFkaXVzOiA2cHg7IGNvbG9yOiAjZmZmOyBmb250LXNpemU6IDAuOXJlbTsgcGFkZGluZzogNHB4IDhweDsgd2lkdGg6IDE0MHB4OyB9CiAgICAubmFtZS1pbnB1dDpmb2N1cyB7IG91dGxpbmU6IG5vbmU7IGJvcmRlci1jb2xvcjogIzYzNjZmMTsgfQogICAgLmNvbnRyb2xzIHsgZGlzcGxheTogZmxleDsgZmxleC1kaXJlY3Rpb246IGNvbHVtbjsgZ2FwOiAwLjc1cmVtOyB9CiAgICAuY29udHJvbC1yb3cgeyBkaXNwbGF5OiBmbGV4OyBhbGlnbi1pdGVtczogY2VudGVyOyBnYXA6IDAuNzVyZW07IGZsZXgtd3JhcDogd3JhcDsgfQogICAgLmNvbnRyb2wtbGFiZWwgeyBmb250LXNpemU6IDAuOHJlbTsgY29sb3I6ICM4ODg7IG1pbi13aWR0aDogNjBweDsgfQogICAgLmJ0biB7IHBhZGRpbmc6IDZweCAxNHB4OyBib3JkZXI6IDFweCBzb2xpZCByZ2JhKDI1NSwyNTUsMjU1LDAuMTUpOyBib3JkZXItcmFkaXVzOiA4cHg7IGJhY2tncm91bmQ6IHJnYmEoMjU1LDI1NSwyNTUsMC4wNik7IGNvbG9yOiAjZTBlMGUwOyBmb250LXNpemU6IDAuOHJlbTsgY3Vyc29yOiBwb2ludGVyOyB0cmFuc2l0aW9uOiBhbGwgMC4xNXM7IHdoaXRlLXNwYWNlOiBub3dyYXA7IH0KICAgIC5idG46aG92ZXIgeyBiYWNrZ3JvdW5kOiByZ2JhKDI1NSwyNTUsMjU1LDAuMTIpOyBib3JkZXItY29sb3I6IHJnYmEoMjU1LDI1NSwyNTUsMC4yNSk7IH0KICAgIC5idG4uZGFuZ2VyIHsgYmFja2dyb3VuZDogcmdiYSgyMzksNjgsNjgsMC4xNSk7IGJvcmRlci1jb2xvcjogI2VmNDQ0NDsgY29sb3I6ICNmY2E1YTU7IH0KICAgIC5idG4uZGFuZ2VyOmhvdmVyIHsgYmFja2dyb3VuZDogcmdiYSgyMzksNjgsNjgsMC4zKTsgfQogICAgLmJ0bi5zdWNjZXNzIHsgYmFja2dyb3VuZDogcmdiYSgzNCwxOTcsOTQsMC4xNSk7IGJvcmRlci1jb2xvcjogIzIyYzU1ZTsgY29sb3I6ICM4NmVmYWM7IH0KICAgIC5idG4uc3VjY2Vzczpob3ZlciB7IGJhY2tncm91bmQ6IHJnYmEoMzQsMTk3LDk0LDAuMyk7IH0KICAgIC5idG4ubG9nb3V0IHsgYmFja2dyb3VuZDogcmdiYSgyMzksNjgsNjgsMC4xKTsgYm9yZGVyLWNvbG9yOiByZ2JhKDIzOSw2OCw2OCwwLjMpOyBjb2xvcjogI2ZjYTVhNTsgZm9udC1zaXplOiAwLjc1cmVtOyBwYWRkaW5nOiA0cHggMTJweDsgfQogICAgLmJ0bi5sb2dvdXQ6aG92ZXIgeyBiYWNrZ3JvdW5kOiByZ2JhKDIzOSw2OCw2OCwwLjI1KTsgfQogICAgc2VsZWN0IHsgcGFkZGluZzogNnB4IDEwcHg7IGJvcmRlcjogMXB4IHNvbGlkIHJnYmEoMjU1LDI1NSwyNTUsMC4xNSk7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogcmdiYSgyNTUsMjU1LDI1NSwwLjA2KTsgY29sb3I6ICNlMGUwZTA7IGZvbnQtc2l6ZTogMC44cmVtOyBjdXJzb3I6IHBvaW50ZXI7IG1heC13aWR0aDogMjAwcHg7IH0KICAgIHNlbGVjdDpmb2N1cyB7IG91dGxpbmU6IG5vbmU7IGJvcmRlci1jb2xvcjogIzYzNjZmMTsgfQogICAgb3B0aW9uIHsgYmFja2dyb3VuZDogIzFhMWEyZTsgY29sb3I6ICNlMGUwZTA7IH0KICAgIGlucHV0W3R5cGU9cmFuZ2VdIHsgd2lkdGg6IDEyMHB4OyBhY2NlbnQtY29sb3I6ICM2MzY2ZjE7IH0KICAgIC5yYW5nZS12YWx1ZSB7IGZvbnQtc2l6ZTogMC44cmVtOyBjb2xvcjogI2FhYTsgbWluLXdpZHRoOiAzMHB4OyB9CiAgICAudG9nZ2xlIHsgcG9zaXRpb246IHJlbGF0aXZlOyB3aWR0aDogNDBweDsgaGVpZ2h0OiAyMnB4OyBjdXJzb3I6IHBvaW50ZXI7IH0KICAgIC50b2dnbGUgaW5wdXQgeyBkaXNwbGF5OiBub25lOyB9CiAgICAudG9nZ2xlLXNsaWRlciB7IHBvc2l0aW9uOiBhYnNvbHV0ZTsgdG9wOiAwOyBsZWZ0OiAwOyByaWdodDogMDsgYm90dG9tOiAwOyBiYWNrZ3JvdW5kOiByZ2JhKDI1NSwyNTUsMjU1LDAuMSk7IGJvcmRlci1yYWRpdXM6IDExcHg7IHRyYW5zaXRpb246IDAuMnM7IH0KICAgIC50b2dnbGUtc2xpZGVyOjpiZWZvcmUgeyBjb250ZW50OiAnJzsgcG9zaXRpb246IGFic29sdXRlOyB3aWR0aDogMTZweDsgaGVpZ2h0OiAxNnB4OyBsZWZ0OiAzcHg7IGJvdHRvbTogM3B4OyBiYWNrZ3JvdW5kOiAjODg4OyBib3JkZXItcmFkaXVzOiA1MCU7IHRyYW5zaXRpb246IDAuMnM7IH0KICAgIC50b2dnbGUgaW5wdXQ6Y2hlY2tlZCArIC50b2dnbGUtc2xpZGVyIHsgYmFja2dyb3VuZDogcmdiYSg5OSwxMDIsMjQxLDAuNCk7IH0KICAgIC50b2dnbGUgaW5wdXQ6Y2hlY2tlZCArIC50b2dnbGUtc2xpZGVyOjpiZWZvcmUgeyB0cmFuc2Zvcm06IHRyYW5zbGF0ZVgoMThweCk7IGJhY2tncm91bmQ6ICNhNWI0ZmM7IH0KICAgIC5lbXB0eS1zdGF0ZSB7IHRleHQtYWxpZ246IGNlbnRlcjsgcGFkZGluZzogNHJlbSAycmVtOyBjb2xvcjogIzY2NjsgfQogICAgLmVtcHR5LXN0YXRlIGgyIHsgZm9udC1zaXplOiAxLjJyZW07IGZvbnQtd2VpZ2h0OiA0MDA7IG1hcmdpbi1ib3R0b206IDAuNXJlbTsgY29sb3I6ICM4ODg7IH0KICAgIC53cy1zdGF0dXMgeyBmb250LXNpemU6IDAuNzVyZW07IHBhZGRpbmc6IDRweCAxMHB4OyBib3JkZXItcmFkaXVzOiAyMHB4OyB9CiAgICAud3Mtc3RhdHVzLmNvbm5lY3RlZCB7IGJhY2tncm91bmQ6IHJnYmEoMzQsMTk3LDk0LDAuMTUpOyBjb2xvcjogIzg2ZWZhYzsgfQogICAgLndzLXN0YXR1cy5kaXNjb25uZWN0ZWQgeyBiYWNrZ3JvdW5kOiByZ2JhKDIzOSw2OCw2OCwwLjE1KTsgY29sb3I6ICNmY2E1YTU7IH0KICAgIC5kaXZpZGVyIHsgYm9yZGVyOiBub25lOyBib3JkZXItdG9wOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwwLjA2KTsgbWFyZ2luOiAwLjVyZW0gMDsgfQogIDwvc3R5bGU+CjwvaGVhZD4KPGJvZHk+CiAgPGRpdiBjbGFzcz0iaGVhZGVyIj4KICAgIDxpbWcgc3JjPSIvaW1nL2ljb24ucG5nIiBhbHQ9IkZyYW1iZSIgb25lcnJvcj0idGhpcy5zdHlsZS5kaXNwbGF5PSdub25lJyI+CiAgICA8ZGl2PjxoMT5GcmFtYmUgQWRtaW48L2gxPjxzcGFuIGNsYXNzPSJ2ZXJzaW9uIiBpZD0idmVyc2lvbi10ZXh0Ij5Db25uZWN0aW5nLi4uPC9zcGFuPjwvZGl2PgogICAgPGRpdiBjbGFzcz0iaGVhZGVyLXJpZ2h0Ij4KICAgICAgPHNwYW4gY2xhc3M9IndzLXN0YXR1cyBkaXNjb25uZWN0ZWQiIGlkPSJ3cy1zdGF0dXMiPkRpc2Nvbm5lY3RlZDwvc3Bhbj4KICAgICAgPGJ1dHRvbiBjbGFzcz0iYnRuIGxvZ291dCIgaWQ9ImxvZ291dC1idG4iIHN0eWxlPSJkaXNwbGF5Om5vbmUiIG9uY2xpY2s9ImRvTG9nb3V0KCkiPkxvZ291dDwvYnV0dG9uPgogICAgPC9kaXY+CiAgPC9kaXY+CiAgPGRpdiBjbGFzcz0iY2xpZW50cy1ncmlkIiBpZD0iY2xpZW50cy1ncmlkIj4KICAgIDxkaXYgY2xhc3M9ImVtcHR5LXN0YXRlIj48aDI+Tm8gZnJhbWVzIGNvbm5lY3RlZDwvaDI+PHA+T3BlbiBGcmFtYmUgb24gYSB0YWJsZXQgb3Igc2NyZWVuIHRvIHNlZSBpdCBoZXJlPC9wPjwvZGl2PgogIDwvZGl2PgogIDxzY3JpcHQ+CiAgICB2YXIgd3M9bnVsbCwgY2xpZW50c0RhdGE9e30sIGFsYnVtc0NhY2hlPVtdLCBwZW9wbGVDYWNoZT1bXSwgYXV0aEVuYWJsZWQ9ZmFsc2U7CgogICAgLy8gQ2hlY2sgaWYgYXV0aCBpcyBlbmFibGVkIGFuZCBzaG93L2hpZGUgbG9nb3V0IGJ1dHRvbgogICAgKGFzeW5jIGZ1bmN0aW9uIGNoZWNrQXV0aCgpIHsKICAgICAgdHJ5IHsKICAgICAgICB2YXIgciA9IGF3YWl0IChhd2FpdCBmZXRjaCgnL2FwaS9hdXRoL3N0YXR1cycpKS5qc29uKCk7CiAgICAgICAgYXV0aEVuYWJsZWQgPSByLmF1dGhFbmFibGVkOwogICAgICAgIGlmIChhdXRoRW5hYmxlZCkgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2xvZ291dC1idG4nKS5zdHlsZS5kaXNwbGF5ID0gJyc7CiAgICAgIH0gY2F0Y2goZSkge30KICAgIH0pKCk7CgogICAgYXN5bmMgZnVuY3Rpb24gZG9Mb2dvdXQoKSB7CiAgICAgIHRyeSB7IGF3YWl0IGZldGNoKCcvYXBpL2F1dGgvbG9nb3V0JywgeyBtZXRob2Q6ICdQT1NUJyB9KTsgfSBjYXRjaChlKSB7fQogICAgICB3aW5kb3cubG9jYXRpb24uaHJlZiA9ICcvYWRtaW4vbG9naW4nOwogICAgfQoKICAgIGZ1bmN0aW9uIGNvbm5lY3QoKSB7CiAgICAgIHZhciBwcm90byA9IGxvY2F0aW9uLnByb3RvY29sPT09J2h0dHBzOic/J3dzczonOid3czonOwogICAgICB3cyA9IG5ldyBXZWJTb2NrZXQocHJvdG8rJy8vJytsb2NhdGlvbi5ob3N0Kycvd3MnKTsKICAgICAgd3Mub25vcGVuID0gZnVuY3Rpb24oKXsgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3dzLXN0YXR1cycpLnRleHRDb250ZW50PSdDb25uZWN0ZWQnOyBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnd3Mtc3RhdHVzJykuY2xhc3NOYW1lPSd3cy1zdGF0dXMgY29ubmVjdGVkJzsgd3Muc2VuZChKU09OLnN0cmluZ2lmeSh7dHlwZToncmVnaXN0ZXInLHJvbGU6J2FkbWluJ30pKTsgbG9hZEFsYnVtc0FuZFBlb3BsZSgpOyB9OwogICAgICB3cy5vbm1lc3NhZ2UgPSBmdW5jdGlvbihlKXsgdmFyIG1zZz1KU09OLnBhcnNlKGUuZGF0YSk7IGlmKG1zZy50eXBlPT09J2NsaWVudExpc3QnKXtjbGllbnRzRGF0YT17fTttc2cuY2xpZW50cy5mb3JFYWNoKGZ1bmN0aW9uKGMpe2NsaWVudHNEYXRhW2MuaWRdPWM7fSk7cmVuZGVyQ2xpZW50cygpO30gZWxzZSBpZihtc2cudHlwZT09PSdjbGllbnRVcGRhdGUnKXtjbGllbnRzRGF0YVttc2cuY2xpZW50SWRdPW1zZy5jbGllbnQ7cmVuZGVyQ2xpZW50cygpO30gfTsKICAgICAgd3Mub25jbG9zZSA9IGZ1bmN0aW9uKCl7IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCd3cy1zdGF0dXMnKS50ZXh0Q29udGVudD0nRGlzY29ubmVjdGVkJzsgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3dzLXN0YXR1cycpLmNsYXNzTmFtZT0nd3Mtc3RhdHVzIGRpc2Nvbm5lY3RlZCc7IHNldFRpbWVvdXQoY29ubmVjdCwzMDAwKTsgfTsKICAgIH0KICAgIGFzeW5jIGZ1bmN0aW9uIGxvYWRBbGJ1bXNBbmRQZW9wbGUoKXsgdHJ5eyB2YXIgYz1hd2FpdChhd2FpdCBmZXRjaCgnL2FwaS9jb25maWcnKSkuanNvbigpOyBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgndmVyc2lvbi10ZXh0JykudGV4dENvbnRlbnQ9J3YnKyhjLnZlcnNpb258fCc/Jyk7IGFsYnVtc0NhY2hlPWF3YWl0KGF3YWl0IGZldGNoKCcvYXBpL2FsYnVtcycpKS5qc29uKCk7IHBlb3BsZUNhY2hlPWF3YWl0KGF3YWl0IGZldGNoKCcvYXBpL3Blb3BsZScpKS5qc29uKCk7IH1jYXRjaChlKXt9IH0KICAgIGZ1bmN0aW9uIHNlbmRDb21tYW5kKGlkLGFjdGlvbixwYXlsb2FkKXsgaWYod3MmJndzLnJlYWR5U3RhdGU9PT1XZWJTb2NrZXQuT1BFTikgd3Muc2VuZChKU09OLnN0cmluZ2lmeSh7dHlwZTonYWRtaW5Db21tYW5kJyx0YXJnZXRJZDppZCxhY3Rpb246YWN0aW9uLHBheWxvYWQ6cGF5bG9hZHx8e319KSk7IH0KICAgIGZ1bmN0aW9uIHJlbmFtZUNsaWVudChpZCxuYW1lKXsgaWYod3MmJndzLnJlYWR5U3RhdGU9PT1XZWJTb2NrZXQuT1BFTikgd3Muc2VuZChKU09OLnN0cmluZ2lmeSh7dHlwZToncmVuYW1lQ2xpZW50Jyx0YXJnZXRJZDppZCxuYW1lOm5hbWV9KSk7IH0KICAgIGZ1bmN0aW9uIGhhbmRsZVNvdXJjZUNoYW5nZShpZCx2YWwpeyBpZighdmFsKXJldHVybjsgaWYodmFsPT09J3JhbmRvbScpc2VuZENvbW1hbmQoaWQsJ3NldFNvdXJjZScse3NvdXJjZToncmFuZG9tJ30pOyBlbHNlIGlmKHZhbD09PSdmYXZvcml0ZXMnKXNlbmRDb21tYW5kKGlkLCdzZXRTb3VyY2UnLHtzb3VyY2U6J2Zhdm9yaXRlcyd9KTsgZWxzZSBpZih2YWwuc3RhcnRzV2l0aCgnYWxidW06Jykpc2VuZENvbW1hbmQoaWQsJ3NldFNvdXJjZScse3NvdXJjZTonYWxidW0nLGFsYnVtSWQ6dmFsLnN1YnN0cmluZyg2KX0pOyBlbHNlIGlmKHZhbC5zdGFydHNXaXRoKCdwZXJzb246Jykpc2VuZENvbW1hbmQoaWQsJ3NldFNvdXJjZScse3NvdXJjZToncGVyc29uJyxwZXJzb25JZDp2YWwuc3Vic3RyaW5nKDcpfSk7IH0KICAgIGZ1bmN0aW9uIGVzYyhzKXsgdmFyIGQ9ZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnZGl2Jyk7ZC5hcHBlbmRDaGlsZChkb2N1bWVudC5jcmVhdGVUZXh0Tm9kZShzfHwnJykpO3JldHVybiBkLmlubmVySFRNTDsgfQogICAgZnVuY3Rpb24gcmVuZGVyQ2xpZW50cygpewogICAgICB2YXIgZ3JpZD1kb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY2xpZW50cy1ncmlkJyksaWRzPU9iamVjdC5rZXlzKGNsaWVudHNEYXRhKTsKICAgICAgaWYoIWlkcy5sZW5ndGgpe2dyaWQuaW5uZXJIVE1MPSc8ZGl2IGNsYXNzPSJlbXB0eS1zdGF0ZSI+PGgyPk5vIGZyYW1lcyBjb25uZWN0ZWQ8L2gyPjxwPk9wZW4gRnJhbWJlIG9uIGEgdGFibGV0IG9yIHNjcmVlbiB0byBzZWUgaXQgaGVyZTwvcD48L2Rpdj4nO3JldHVybjt9CiAgICAgIHZhciBodG1sPScnOwogICAgICBpZHMuZm9yRWFjaChmdW5jdGlvbihpZCl7IHZhciBjPWNsaWVudHNEYXRhW2lkXSxzYz1jLnN0YXR1cz09PSdwbGF5aW5nJz8ncGxheWluZyc6Yy5zdGF0dXM9PT0nc2xlZXBpbmcnPydzbGVlcGluZyc6J29ubGluZScsY2ZnPWMuY29uZmlnfHx7fTsKICAgICAgICBodG1sKz0nPGRpdiBjbGFzcz0iY2xpZW50LWNhcmQiPic7CiAgICAgICAgaHRtbCs9JzxkaXYgY2xhc3M9ImNsaWVudC1oZWFkZXIiPjxkaXY+PGRpdiBjbGFzcz0iY2xpZW50LW5hbWUiPjxzcGFuIGNsYXNzPSJzdGF0dXMtZG90ICcrc2MrJyI+PC9zcGFuPjxpbnB1dCBjbGFzcz0ibmFtZS1pbnB1dCIgdmFsdWU9IicrZXNjKGMubmFtZXx8JycpKyciIHBsYWNlaG9sZGVyPSInK2VzYyhjLmlwKSsnIiBvbmNoYW5nZT0icmVuYW1lQ2xpZW50KFwnJytpZCsnXCcsdGhpcy52YWx1ZSkiLz48L2Rpdj48ZGl2IGNsYXNzPSJjbGllbnQtaXAiPicrZXNjKGMuaXApKyc8L2Rpdj48L2Rpdj48ZGl2IGNsYXNzPSJjbGllbnQtc3RhdHVzIj4nK2VzYyhjLnN0YXR1c3x8J2Nvbm5lY3RlZCcpKyc8L2Rpdj48L2Rpdj4nOwogICAgICAgIGh0bWwrPSc8ZGl2IGNsYXNzPSJjb250cm9scyI+JzsKICAgICAgICBodG1sKz0nPGRpdiBjbGFzcz0iY29udHJvbC1yb3ciPjxzcGFuIGNsYXNzPSJjb250cm9sLWxhYmVsIj5Tb3VyY2U8L3NwYW4+PHNlbGVjdCBvbmNoYW5nZT0iaGFuZGxlU291cmNlQ2hhbmdlKFwnJytpZCsnXCcsdGhpcy52YWx1ZSkiPjxvcHRpb24gdmFsdWU9IiI+LS0gU2VsZWN0IC0tPC9vcHRpb24+PG9wdGlvbiB2YWx1ZT0icmFuZG9tIj5SYW5kb20gUGhvdG9zPC9vcHRpb24+PG9wdGlvbiB2YWx1ZT0iZmF2b3JpdGVzIj5GYXZvcml0ZXM8L29wdGlvbj4nOwogICAgICAgIGFsYnVtc0NhY2hlLmZvckVhY2goZnVuY3Rpb24oYSl7aHRtbCs9JzxvcHRpb24gdmFsdWU9ImFsYnVtOicrYS5pZCsnIj4nK2VzYyhhLmFsYnVtTmFtZSkrJyAoJythLmFzc2V0Q291bnQrJyk8L29wdGlvbj4nO30pOwogICAgICAgIHBlb3BsZUNhY2hlLmZpbHRlcihmdW5jdGlvbihwKXtyZXR1cm4gcC5uYW1lO30pLmZvckVhY2goZnVuY3Rpb24ocCl7aHRtbCs9JzxvcHRpb24gdmFsdWU9InBlcnNvbjonK3AuaWQrJyI+Jytlc2MocC5uYW1lKSsnIChwZXJzb24pPC9vcHRpb24+Jzt9KTsKICAgICAgICBodG1sKz0nPC9zZWxlY3Q+PC9kaXY+PGhyIGNsYXNzPSJkaXZpZGVyIj4nOwogICAgICAgIGh0bWwrPSc8ZGl2IGNsYXNzPSJjb250cm9sLXJvdyI+PHNwYW4gY2xhc3M9ImNvbnRyb2wtbGFiZWwiPlBsYXliYWNrPC9zcGFuPjxidXR0b24gY2xhc3M9ImJ0biBzdWNjZXNzIiBvbmNsaWNrPSJzZW5kQ29tbWFuZChcJycraWQrJ1wnLFwnc3RhcnRcJykiPlN0YXJ0PC9idXR0b24+PGJ1dHRvbiBjbGFzcz0iYnRuIiBvbmNsaWNrPSJzZW5kQ29tbWFuZChcJycraWQrJ1wnLFwnc3RvcFwnKSI+U3RvcDwvYnV0dG9uPjxidXR0b24gY2xhc3M9ImJ0biIgb25jbGljaz0ic2VuZENvbW1hbmQoXCcnK2lkKydcJyxcJ25leHRcJykiPk5leHQ8L2J1dHRvbj48YnV0dG9uIGNsYXNzPSJidG4iIG9uY2xpY2s9InNlbmRDb21tYW5kKFwnJytpZCsnXCcsXCdwcmV2XCcpIj5QcmV2PC9idXR0b24+PC9kaXY+JzsKICAgICAgICBodG1sKz0nPGRpdiBjbGFzcz0iY29udHJvbC1yb3ciPjxzcGFuIGNsYXNzPSJjb250cm9sLWxhYmVsIj5Qb3dlcjwvc3Bhbj48YnV0dG9uIGNsYXNzPSJidG4gZGFuZ2VyIiBvbmNsaWNrPSJzZW5kQ29tbWFuZChcJycraWQrJ1wnLFwnc2xlZXBcJykiPlNsZWVwPC9idXR0b24+PGJ1dHRvbiBjbGFzcz0iYnRuIHN1Y2Nlc3MiIG9uY2xpY2s9InNlbmRDb21tYW5kKFwnJytpZCsnXCcsXCd3YWtlXCcpIj5XYWtlPC9idXR0b24+PGJ1dHRvbiBjbGFzcz0iYnRuIiBvbmNsaWNrPSJzZW5kQ29tbWFuZChcJycraWQrJ1wnLFwncmVmcmVzaFwnKSI+UmVmcmVzaDwvYnV0dG9uPjwvZGl2Pic7CiAgICAgICAgaHRtbCs9JzxociBjbGFzcz0iZGl2aWRlciI+JzsKICAgICAgICBodG1sKz0nPGRpdiBjbGFzcz0iY29udHJvbC1yb3ciPjxzcGFuIGNsYXNzPSJjb250cm9sLWxhYmVsIj5JbnRlcnZhbDwvc3Bhbj48aW5wdXQgdHlwZT0icmFuZ2UiIG1pbj0iNSIgbWF4PSIxMjAiIHZhbHVlPSInKyhjZmcuc2xpZGVzaG93SW50ZXJ2YWx8fDMwKSsnIiBvbmlucHV0PSJ0aGlzLm5leHRFbGVtZW50U2libGluZy50ZXh0Q29udGVudD10aGlzLnZhbHVlK1wnc1wnIiBvbmNoYW5nZT0ic2VuZENvbW1hbmQoXCcnK2lkKydcJyxcJ3NldENvbmZpZ1wnLHtzbGlkZXNob3dJbnRlcnZhbDpwYXJzZUludCh0aGlzLnZhbHVlKX0pIj48c3BhbiBjbGFzcz0icmFuZ2UtdmFsdWUiPicrKGNmZy5zbGlkZXNob3dJbnRlcnZhbHx8MzApKydzPC9zcGFuPjwvZGl2Pic7CiAgICAgICAgaHRtbCs9JzxkaXYgY2xhc3M9ImNvbnRyb2wtcm93Ij48c3BhbiBjbGFzcz0iY29udHJvbC1sYWJlbCI+Q2xvY2s8L3NwYW4+PGxhYmVsIGNsYXNzPSJ0b2dnbGUiPjxpbnB1dCB0eXBlPSJjaGVja2JveCIgJysoY2ZnLnNob3dDbG9jayE9PWZhbHNlPydjaGVja2VkJzonJykrJyBvbmNoYW5nZT0ic2VuZENvbW1hbmQoXCcnK2lkKydcJyxcJ3NldENvbmZpZ1wnLHtzaG93Q2xvY2s6dGhpcy5jaGVja2VkfSkiPjxzcGFuIGNsYXNzPSJ0b2dnbGUtc2xpZGVyIj48L3NwYW4+PC9sYWJlbD48c3BhbiBjbGFzcz0iY29udHJvbC1sYWJlbCI+RGF0ZTwvc3Bhbj48bGFiZWwgY2xhc3M9InRvZ2dsZSI+PGlucHV0IHR5cGU9ImNoZWNrYm94IiAnKyhjZmcuc2hvd0RhdGUhPT1mYWxzZT8nY2hlY2tlZCc6JycpKycgb25jaGFuZ2U9InNlbmRDb21tYW5kKFwnJytpZCsnXCcsXCdzZXRDb25maWdcJyx7c2hvd0RhdGU6dGhpcy5jaGVja2VkfSkiPjxzcGFuIGNsYXNzPSJ0b2dnbGUtc2xpZGVyIj48L3NwYW4+PC9sYWJlbD48L2Rpdj4nOwogICAgICAgIGh0bWwrPSc8ZGl2IGNsYXNzPSJjb250cm9sLXJvdyI+PHNwYW4gY2xhc3M9ImNvbnRyb2wtbGFiZWwiPkVYSUY8L3NwYW4+PGxhYmVsIGNsYXNzPSJ0b2dnbGUiPjxpbnB1dCB0eXBlPSJjaGVja2JveCIgJysoY2ZnLnNob3dFeGlmIT09ZmFsc2U/J2NoZWNrZWQnOicnKSsnIG9uY2hhbmdlPSJzZW5kQ29tbWFuZChcJycraWQrJ1wnLFwnc2V0Q29uZmlnXCcse3Nob3dFeGlmOnRoaXMuY2hlY2tlZH0pIj48c3BhbiBjbGFzcz0idG9nZ2xlLXNsaWRlciI+PC9zcGFuPjwvbGFiZWw+PHNwYW4gY2xhc3M9ImNvbnRyb2wtbGFiZWwiPlByb2dyZXNzPC9zcGFuPjxsYWJlbCBjbGFzcz0idG9nZ2xlIj48aW5wdXQgdHlwZT0iY2hlY2tib3giICcrKGNmZy5zaG93UHJvZ3Jlc3MhPT1mYWxzZT8nY2hlY2tlZCc6JycpKycgb25jaGFuZ2U9InNlbmRDb21tYW5kKFwnJytpZCsnXCcsXCdzZXRDb25maWdcJyx7c2hvd1Byb2dyZXNzOnRoaXMuY2hlY2tlZH0pIj48c3BhbiBjbGFzcz0idG9nZ2xlLXNsaWRlciI+PC9zcGFuPjwvbGFiZWw+PC9kaXY+JzsKICAgICAgICBodG1sKz0nPC9kaXY+PC9kaXY+JzsKICAgICAgfSk7CiAgICAgIGdyaWQuaW5uZXJIVE1MPWh0bWw7CiAgICB9CiAgICBjb25uZWN0KCk7CiAgPC9zY3JpcHQ+CjwvYm9keT4KPC9odG1sPgo= \ No newline at end of file From 355f26eaf117e58fc5db04df1b57b0bb9ed65895 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 25 May 2026 16:24:58 +1000 Subject: [PATCH 08/30] docs: add auth env vars to .env.example --- .env.example | 27 +-------------------------- 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/.env.example b/.env.example index 7ec5314..6957dd1 100644 --- a/.env.example +++ b/.env.example @@ -1,26 +1 @@ -# === Frambe Configuration === - -# REQUIRED -IMMICH_URL=http://your-immich-server:2283 -IMMICH_API_KEY=your-api-key-here - -# Slideshow -SLIDESHOW_INTERVAL=30 -TRANSITION_DURATION=2 -IMAGE_FIT=contain -SHUFFLE=true -BACKGROUND_BLUR=true -REFRESH_INTERVAL=300 - -# Overlays -SHOW_CLOCK=true -SHOW_DATE=true -SHOW_EXIF=true -SHOW_PROGRESS=true - -# Auto-start (optional — or use URL params instead) -# ALBUM_ID= -# SHOW_FAVORITES_ONLY=false - -# Server (internal port — Docker maps externally via docker-compose) -PORT=3000 +IyA9PT0gRnJhbWJlIENvbmZpZ3VyYXRpb24gPT09CgojIFJFUVVJUkVECklNTUlDSF9VUkw9aHR0cDovL3lvdXItaW1taWNoLXNlcnZlcjoyMjgzCklNTUlDSF9BUElfS0VZPXlvdXItYXBpLWtleS1oZXJlCgojIFNsaWRlc2hvdwpTTElERVNIT1dfSU5URVJWQUw9MzAKVFJBTlNJVElPTl9EVVJBVElPTj0yCklNQUdFX0ZJVD1jb250YWluClNIVUZGTEU9dHJ1ZQpCQUNLR1JPVU5EX0JMVVI9dHJ1ZQpSRUZSRVNIX0lOVEVSVkFMPTMwMAoKIyBPdmVybGF5cwpTSE9XX0NMT0NLPXRydWUKU0hPV19EQVRFPXRydWUKU0hPV19FWElGPXRydWUKU0hPV19QUk9HUkVTUz10cnVlCgojIEF1dG8tc3RhcnQgKG9wdGlvbmFsIOKAlCBvciB1c2UgVVJMIHBhcmFtcyBpbnN0ZWFkKQojIEFMQlVNX0lEPQojIFNIT1dfRkFWT1JJVEVTX09OTFk9ZmFsc2UKCiMgQWRtaW4gQXV0aGVudGljYXRpb24gKG9wdGlvbmFsIOKAlCBsZWF2ZSBBRE1JTl9QQVNTV09SRCBibGFuayB0byBkaXNhYmxlKQpBRE1JTl9VU0VSTkFNRT1hZG1pbgpBRE1JTl9QQVNTV09SRD0KIyBBRE1JTl9QQVNTV09SRD1jaGFuZ2VtZQoKIyBBUEkgVG9rZW4gZm9yIGV4dGVybmFsIGFjY2VzcyAoSG9tZSBBc3Npc3RhbnQsIHNjcmlwdHMsIGV0Yy4pCiMgV2hlbiBzZXQsIFJFU1QgZW5kcG9pbnRzIHJlcXVpcmUgdGhpcyB0b2tlbiB2aWEgQmVhcmVyIGF1dGggb3IgeC1hcGktdG9rZW4gaGVhZGVyCiMgRlJBTUJFX0FQSV9UT0tFTj15b3VyLXNlY3JldC10b2tlbi1oZXJlCgojIFNlcnZlciAoaW50ZXJuYWwgcG9ydCDigJQgRG9ja2VyIG1hcHMgZXh0ZXJuYWxseSB2aWEgZG9ja2VyLWNvbXBvc2UpClBPUlQ9MzAwMAo= \ No newline at end of file From 402b6a0def91d8ac71f8d35e8a5df42fa044a49f Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 25 May 2026 16:25:58 +1000 Subject: [PATCH 09/30] docs: add auth env vars to docker-compose.yml --- docker-compose.yml | 32 +------------------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 457c2d8..affe6e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,31 +1 @@ -version: "3.8" - -services: - frambe: - build: . - container_name: frambe - restart: unless-stopped - ports: - - "3030:3000" - environment: - # REQUIRED - - IMMICH_URL=http://your-immich-server:2283 - - IMMICH_API_KEY=your-api-key-here - - # Slideshow - - SLIDESHOW_INTERVAL=30 - - TRANSITION_DURATION=2 - - IMAGE_FIT=contain - - SHUFFLE=true - - BACKGROUND_BLUR=true - - REFRESH_INTERVAL=300 # Seconds between album/person refresh checks - - # Overlays - - SHOW_CLOCK=true - - SHOW_DATE=true - - SHOW_EXIF=true - - SHOW_PROGRESS=true - - # Auto-start (optional — or use URL params instead) - # - ALBUM_ID= - # - SHOW_FAVORITES_ONLY=false +dmVyc2lvbjogIjMuOCIKCnNlcnZpY2VzOgogIGZyYW1iZToKICAgIGJ1aWxkOiAuCiAgICBjb250YWluZXJfbmFtZTogZnJhbWJlCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgcG9ydHM6CiAgICAgIC0gIjMwMzA6MzAwMCIKICAgIGVudmlyb25tZW50OgogICAgICAjIFJFUVVJUkVECiAgICAgIC0gSU1NSUNIX1VSTD1odHRwOi8veW91ci1pbW1pY2gtc2VydmVyOjIyODMKICAgICAgLSBJTU1JQ0hfQVBJX0tFWT15b3VyLWFwaS1rZXktaGVyZQoKICAgICAgIyBTbGlkZXNob3cKICAgICAgLSBTTElERVNIT1dfSU5URVJWQUw9MzAKICAgICAgLSBUUkFOU0lUSU9OX0RVUkFUSU9OPTIKICAgICAgLSBJTUFHRV9GSVQ9Y29udGFpbgogICAgICAtIFNIVUZGTEU9dHJ1ZQogICAgICAtIEJBQ0tHUk9VTkRfQkxVUj10cnVlCiAgICAgIC0gUkVGUkVTSF9JTlRFUlZBTD0zMDAgICAgICAgICAjIFNlY29uZHMgYmV0d2VlbiBhbGJ1bS9wZXJzb24gcmVmcmVzaCBjaGVja3MKCiAgICAgICMgT3ZlcmxheXMKICAgICAgLSBTSE9XX0NMT0NLPXRydWUKICAgICAgLSBTSE9XX0RBVEU9dHJ1ZQogICAgICAtIFNIT1dfRVhJRj10cnVlCiAgICAgIC0gU0hPV19QUk9HUkVTUz10cnVlCgogICAgICAjIEFkbWluIEF1dGhlbnRpY2F0aW9uIChsZWF2ZSBBRE1JTl9QQVNTV09SRCBibGFuayB0byBkaXNhYmxlIGxvZ2luKQogICAgICAtIEFETUlOX1VTRVJOQU1FPWFkbWluCiAgICAgICMgLSBBRE1JTl9QQVNTV09SRD1jaGFuZ2VtZQoKICAgICAgIyBBUEkgVG9rZW4gZm9yIGV4dGVybmFsIGFjY2VzcyAoSG9tZSBBc3Npc3RhbnQsIHNjcmlwdHMsIGV0Yy4pCiAgICAgICMgLSBGUkFNQkVfQVBJX1RPS0VOPXlvdXItc2VjcmV0LXRva2VuLWhlcmUKCiAgICAgICMgQXV0by1zdGFydCAob3B0aW9uYWwg4oCUIG9yIHVzZSBVUkwgcGFyYW1zIGluc3RlYWQpCiAgICAgICMgLSBBTEJVTV9JRD0KICAgICAgIyAtIFNIT1dfRkFWT1JJVEVTX09OTFk9ZmFsc2UK \ No newline at end of file From b8ea8eb1505a68354b2fc0732e10920c71480f1e Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 25 May 2026 16:30:58 +1000 Subject: [PATCH 10/30] docs: update README with v1.4.0 auth and REST API documentation --- README.md | 141 +----------------------------------------------------- 1 file changed, 1 insertion(+), 140 deletions(-) diff --git a/README.md b/README.md index d8c5dfe..fe58dc4 100644 --- a/README.md +++ b/README.md @@ -1,140 +1 @@ -# Frambe - -

- Frambe -

- -A lightweight, self-contained Docker web application that connects to your [Immich](https://immich.app/) server and displays photos in a beautiful full-screen slideshow — perfect for turning old tablets, spare screens, and Raspberry Pis into digital photo frames. - -## ✨ Features - -- **Immich API Integration** — Connects securely via API key (kept server-side) -- **Album Browser** — Select any album, random photos, or favorites only -- **Person / Face Support** — Display photos of a specific person via Immich's face recognition -- **URL-Based Zero-Touch Launch** — Skip the setup screen entirely with query parameters -- **Auto-Refresh** — Periodically checks for new photos added to the source album/person -- **Smooth Crossfade** — Double-buffered image transitions with configurable duration -- **Background Blur** — Blurred backdrop fills the space behind non-covering images -- **Clock & Date Overlay** — Always know the time at a glance -- **EXIF Info** — Shows photo location, date, and camera info -- **Progress Bar** — Subtle indicator of time until next photo -- **Touch Controls** — Tap left/right edges to navigate, centre to toggle overlay -- **Keyboard Controls** — Arrow keys, Space, F (fullscreen), I (info), Esc (exit) -- **Screen Wake Lock** — Prevents screen sleep on supported devices -- **Responsive** — Works on any screen size from phone to TV -- **Older Device Friendly** — Vanilla HTML/CSS/JS, no heavy frameworks -- **Docker Containerised** — Single container, minimal footprint - -## 🚀 Quick Start - -### 1. Get your Immich API Key - -1. Open your Immich web interface -2. Click your profile picture → **Account Settings** → **API Keys** -3. Create a new key with `asset.read` and `album.read` permissions - -### 2. Run with Docker Compose - -```bash -git clone https://gitea.hideawaygaming.com.au/jessikitty/frambe.git -cd frambe -``` - -Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then: - -```bash -docker compose up -d -``` - -Open `http://your-server:3030` in a browser on your tablet/screen. - -### 3. Run with Docker directly - -```bash -docker build -t frambe . -docker run -d \ - --name frambe \ - -p 3030:3000 \ - -e IMMICH_URL=http://your-immich-server:2283 \ - -e IMMICH_API_KEY=your-api-key \ - --restart unless-stopped \ - frambe -``` - -## 🔗 Zero-Touch URL Parameters - -Skip the setup screen entirely by passing query parameters. This is ideal for dedicated frames — just bookmark the URL on each tablet: - -| URL | What it shows | -|---|---| -| `http://server:3030/?album=ALBUM_UUID` | Photos from a specific album | -| `http://server:3030/?person=PERSON_UUID` | Photos of a specific person (face recognition) | -| `http://server:3030/?favorites` | Favorite photos only | -| `http://server:3030/?random` | Random photos from the library | - -You can find album and person UUIDs in Immich's web interface URL bar when viewing an album or person. - -## ⚙️ Configuration - -All settings are via environment variables: - -| Variable | Default | Description | -|---|---|---| -| `IMMICH_URL` | *(required)* | Your Immich server URL | -| `IMMICH_API_KEY` | *(required)* | Immich API key | -| `SLIDESHOW_INTERVAL` | `30` | Seconds between photos | -| `TRANSITION_DURATION` | `2` | Crossfade duration in seconds | -| `IMAGE_FIT` | `contain` | `contain` or `cover` | -| `SHUFFLE` | `true` | Randomise photo order | -| `BACKGROUND_BLUR` | `true` | Show blurred backdrop | -| `SHOW_CLOCK` | `true` | Display clock overlay | -| `SHOW_DATE` | `true` | Display date overlay | -| `SHOW_EXIF` | `true` | Display photo metadata | -| `SHOW_PROGRESS` | `true` | Display progress bar | -| `REFRESH_INTERVAL` | `300` | Seconds between source refresh checks (new photos) | -| `ALBUM_ID` | *(empty)* | Auto-start with specific album (env-based) | -| `SHOW_FAVORITES_ONLY` | `false` | Auto-start with favorites (env-based) | -| `PORT` | `3000` | Internal server port (Docker maps externally via compose) | - -## 🎮 Controls - -### Touch / Mouse -- **Left 20%** of screen — Previous photo -- **Centre 60%** — Toggle overlay (clock, info, close button) -- **Right 20%** — Next photo - -### Keyboard -- `←` / `→` — Previous / Next photo -- `Space` — Next photo -- `F` — Toggle fullscreen -- `I` — Toggle info overlay -- `Esc` — Exit to album selection - -## 📱 Tablet Setup Tips - -1. Open the frame URL in your tablet's browser (use a `?album=` or `?person=` URL for zero-touch) -2. Add to Home Screen for a full-screen app experience -3. Enable kiosk mode or guided access to lock to the app -4. Disable screen timeout in your device settings - -## 🏗️ Architecture - -``` -┌──────────────┐ ┌──────────────┐ ┌──────────────┐ -│ Browser │ HTTP │ Frambe │ API │ Immich │ -│ (Tablet) │◄───────►│ (Node.js) │◄───────►│ Server │ -└──────────────┘ :3030 └──────────────┘ :2283 └──────────────┘ -``` - -The Node.js backend acts as a secure proxy — your Immich API key never reaches the browser. The frontend periodically polls the backend for new photos so albums stay up to date without restarting. - -## 📋 Version History - -- **1.2.1** — Fix port mapping (3030:3000 external:internal), fix URL param auto-launch not starting slideshow -- **1.2.0** — URL params (`?album=`, `?person=`, `?favorites`, `?random`), person/face support, periodic auto-refresh, app icon, default port changed to 3030 -- **1.1.0** — Rebrand to Frambe -- **1.0.0** — Initial release: album browser, slideshow with crossfade, clock/date/EXIF overlays, touch & keyboard controls, Docker deployment - -## 📄 License - -MIT +IyBGcmFtYmUKCjxwIGFsaWduPSJjZW50ZXIiPgogIDxpbWcgc3JjPSJwdWJsaWMvaW1nL2ljb24ucG5nIiBhbHQ9IkZyYW1iZSIgd2lkdGg9IjE4MCI+CjwvcD4KCkEgbGlnaHR3ZWlnaHQsIHNlbGYtY29udGFpbmVkIERvY2tlciB3ZWIgYXBwbGljYXRpb24gdGhhdCBjb25uZWN0cyB0byB5b3VyIFtJbW1pY2hdKGh0dHBzOi8vaW1taWNoLmFwcC8pIHNlcnZlciBhbmQgZGlzcGxheXMgcGhvdG9zIGluIGEgYmVhdXRpZnVsIGZ1bGwtc2NyZWVuIHNsaWRlc2hvdyDigJQgcGVyZmVjdCBmb3IgdHVybmluZyBvbGQgdGFibGV0cywgc3BhcmUgc2NyZWVucywgYW5kIFJhc3BiZXJyeSBQaXMgaW50byBkaWdpdGFsIHBob3RvIGZyYW1lcy4KCiMjIOKcqCBGZWF0dXJlcwoKLSAqKkltbWljaCBBUEkgSW50ZWdyYXRpb24qKiDigJQgQ29ubmVjdHMgc2VjdXJlbHkgdmlhIEFQSSBrZXkgKGtlcHQgc2VydmVyLXNpZGUpCi0gKipBbGJ1bSBCcm93c2VyKiog4oCUIFNlbGVjdCBhbnkgYWxidW0sIHJhbmRvbSBwaG90b3MsIG9yIGZhdm9yaXRlcyBvbmx5Ci0gKipQZXJzb24gLyBGYWNlIFN1cHBvcnQqKiDigJQgRGlzcGxheSBwaG90b3Mgb2YgYSBzcGVjaWZpYyBwZXJzb24gdmlhIEltbWljaCdzIGZhY2UgcmVjb2duaXRpb24KLSAqKkFkbWluIERhc2hib2FyZCoqIOKAlCBSZWFsLXRpbWUgV2ViU29ja2V0LWJhc2VkIGNvbnRyb2wgcGFuZWwgZm9yIGFsbCBjb25uZWN0ZWQgZnJhbWVzCi0gKipBZG1pbiBBdXRoZW50aWNhdGlvbioqIOKAlCBPcHRpb25hbCB1c2VybmFtZS9wYXNzd29yZCBsb2dpbiB0byBwcm90ZWN0IHRoZSBhZG1pbiBkYXNoYm9hcmQKLSAqKlJFU1QgQVBJIHdpdGggVG9rZW4gQXV0aCoqIOKAlCBDb250cm9sIGZyYW1lcyBmcm9tIEhvbWUgQXNzaXN0YW50LCBzY3JpcHRzLCBvciBleHRlcm5hbCB0b29scwotICoqVVJMLUJhc2VkIFplcm8tVG91Y2ggTGF1bmNoKiog4oCUIFNraXAgdGhlIHNldHVwIHNjcmVlbiBlbnRpcmVseSB3aXRoIHF1ZXJ5IHBhcmFtZXRlcnMKLSAqKkF1dG8tUmVmcmVzaCoqIOKAlCBQZXJpb2RpY2FsbHkgY2hlY2tzIGZvciBuZXcgcGhvdG9zIGFkZGVkIHRvIHRoZSBzb3VyY2UgYWxidW0vcGVyc29uCi0gKipTbW9vdGggQ3Jvc3NmYWRlKiog4oCUIERvdWJsZS1idWZmZXJlZCBpbWFnZSB0cmFuc2l0aW9ucyB3aXRoIGNvbmZpZ3VyYWJsZSBkdXJhdGlvbgotICoqQmFja2dyb3VuZCBCbHVyKiog4oCUIEJsdXJyZWQgYmFja2Ryb3AgZmlsbHMgdGhlIHNwYWNlIGJlaGluZCBub24tY292ZXJpbmcgaW1hZ2VzCi0gKipDbG9jayAmIERhdGUgT3ZlcmxheSoqIOKAlCBBbHdheXMga25vdyB0aGUgdGltZSBhdCBhIGdsYW5jZQotICoqRVhJRiBJbmZvKiog4oCUIFNob3dzIHBob3RvIGxvY2F0aW9uLCBkYXRlLCBhbmQgY2FtZXJhIGluZm8KLSAqKlByb2dyZXNzIEJhcioqIOKAlCBTdWJ0bGUgaW5kaWNhdG9yIG9mIHRpbWUgdW50aWwgbmV4dCBwaG90bwotICoqVG91Y2ggQ29udHJvbHMqKiDigJQgVGFwIGxlZnQvcmlnaHQgZWRnZXMgdG8gbmF2aWdhdGUsIGNlbnRyZSB0byB0b2dnbGUgb3ZlcmxheQotICoqS2V5Ym9hcmQgQ29udHJvbHMqKiDigJQgQXJyb3cga2V5cywgU3BhY2UsIEYgKGZ1bGxzY3JlZW4pLCBJIChpbmZvKSwgRXNjIChleGl0KQotICoqU2NyZWVuIFdha2UgTG9jayoqIOKAlCBQcmV2ZW50cyBzY3JlZW4gc2xlZXAgb24gc3VwcG9ydGVkIGRldmljZXMKLSAqKlJlc3BvbnNpdmUqKiDigJQgV29ya3Mgb24gYW55IHNjcmVlbiBzaXplIGZyb20gcGhvbmUgdG8gVFYKLSAqKk9sZGVyIERldmljZSBGcmllbmRseSoqIOKAlCBWYW5pbGxhIEhUTUwvQ1NTL0pTLCBubyBoZWF2eSBmcmFtZXdvcmtzCi0gKipEb2NrZXIgQ29udGFpbmVyaXNlZCoqIOKAlCBTaW5nbGUgY29udGFpbmVyLCBtaW5pbWFsIGZvb3RwcmludAoKIyMg8J+agCBRdWljayBTdGFydAoKIyMjIDEuIEdldCB5b3VyIEltbWljaCBBUEkgS2V5CgoxLiBPcGVuIHlvdXIgSW1taWNoIHdlYiBpbnRlcmZhY2UKMi4gQ2xpY2sgeW91ciBwcm9maWxlIHBpY3R1cmUg4oaSICoqQWNjb3VudCBTZXR0aW5ncyoqIOKGkiAqKkFQSSBLZXlzKioKMy4gQ3JlYXRlIGEgbmV3IGtleSB3aXRoIGBhc3NldC5yZWFkYCBhbmQgYGFsYnVtLnJlYWRgIHBlcm1pc3Npb25zCgojIyMgMi4gUnVuIHdpdGggRG9ja2VyIENvbXBvc2UKCmBgYGJhc2gKZ2l0IGNsb25lIGh0dHBzOi8vZ2l0ZWEuaGlkZWF3YXlnYW1pbmcuY29tLmF1L2plc3Npa2l0dHkvZnJhbWJlLmdpdApjZCBmcmFtYmUKYGBgCgpFZGl0IGBkb2NrZXItY29tcG9zZS55bWxgIGFuZCBzZXQgeW91ciBgSU1NSUNIX1VSTGAgYW5kIGBJTU1JQ0hfQVBJX0tFWWAsIHRoZW46CgpgYGBiYXNoCmRvY2tlciBjb21wb3NlIHVwIC1kCmBgYAoKT3BlbiBgaHR0cDovL3lvdXItc2VydmVyOjMwMzBgIGluIGEgYnJvd3NlciBvbiB5b3VyIHRhYmxldC9zY3JlZW4uCgojIyMgMy4gUnVuIHdpdGggRG9ja2VyIGRpcmVjdGx5CgpgYGBiYXNoCmRvY2tlciBidWlsZCAtdCBmcmFtYmUgLgpkb2NrZXIgcnVuIC1kIFwKICAtLW5hbWUgZnJhbWJlIFwKICAtcCAzMDMwOjMwMDAgXAogIC1lIElNTUlDSF9VUkw9aHR0cDovL3lvdXItaW1taWNoLXNlcnZlcjoyMjgzIFwKICAtZSBJTU1JQ0hfQVBJX0tFWT15b3VyLWFwaS1rZXkgXAogIC0tcmVzdGFydCB1bmxlc3Mtc3RvcHBlZCBcCiAgZnJhbWJlCmBgYAoKIyMg8J+UkSBBdXRoZW50aWNhdGlvbgoKIyMjIEFkbWluIERhc2hib2FyZCBMb2dpbgoKUHJvdGVjdCB0aGUgYWRtaW4gZGFzaGJvYXJkIHdpdGggYSB1c2VybmFtZSBhbmQgcGFzc3dvcmQ6CgpgYGB5YW1sCmVudmlyb25tZW50OgogIC0gQURNSU5fVVNFUk5BTUU9YWRtaW4KICAtIEFETUlOX1BBU1NXT1JEPXlvdXItc2VjdXJlLXBhc3N3b3JkCmBgYAoKV2hlbiBgQURNSU5fUEFTU1dPUkRgIGlzIHNldCwgYWNjZXNzaW5nIGAvYWRtaW5gIHJlcXVpcmVzIHNpZ25pbmcgaW4uIFdoZW4gbm90IHNldCwgdGhlIGRhc2hib2FyZCBpcyBvcGVuICh1c2VmdWwgZm9yIHRydXN0ZWQgbG9jYWwgbmV0d29ya3MpLgoKIyMjIEFQSSBUb2tlbiBmb3IgRXh0ZXJuYWwgQWNjZXNzCgpFbmFibGUgdG9rZW4tYXV0aGVudGljYXRlZCBSRVNUIEFQSSBhY2Nlc3MgZm9yIEhvbWUgQXNzaXN0YW50LCBzY3JpcHRzLCBvciBvdGhlciBleHRlcm5hbCB0b29sczoKCmBgYHlhbWwKZW52aXJvbm1lbnQ6CiAgLSBGUkFNQkVfQVBJX1RPS0VOPXlvdXItc2VjcmV0LXRva2VuLWhlcmUKYGBgCgojIyDwn5SMIFJFU1QgQVBJCgpXaGVuIGBGUkFNQkVfQVBJX1RPS0VOYCBpcyBjb25maWd1cmVkLCB0aGUgZm9sbG93aW5nIGVuZHBvaW50cyBhcmUgYXZhaWxhYmxlOgoKIyMjIExpc3QgQ29ubmVjdGVkIEZyYW1lcwoKYGBgCkdFVCAvYXBpL2NsaWVudHMKQXV0aG9yaXphdGlvbjogQmVhcmVyIHlvdXItc2VjcmV0LXRva2VuLWhlcmUKYGBgCgpSZXR1cm5zIGFsbCBjb25uZWN0ZWQgZnJhbWUgY2xpZW50cyB3aXRoIHRoZWlyIHN0YXR1cywgSVAsIG5hbWUsIGFuZCBjb25maWcuCgojIyMgU2VuZCBDb21tYW5kIHRvIGEgRnJhbWUKCmBgYApQT1NUIC9hcGkvY2xpZW50cy86aWQvY29tbWFuZApBdXRob3JpemF0aW9uOiBCZWFyZXIgeW91ci1zZWNyZXQtdG9rZW4taGVyZQpDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL2pzb24KCnsKICAiYWN0aW9uIjogIm5leHQiLAogICJwYXlsb2FkIjoge30KfQpgYGAKCkF2YWlsYWJsZSBhY3Rpb25zOiBgc3RhcnRgLCBgc3RvcGAsIGBuZXh0YCwgYHByZXZgLCBgc2xlZXBgLCBgd2FrZWAsIGByZWZyZXNoYCwgYHNldFNvdXJjZWAsIGBzZXRDb25maWdgCgojIyMgSG9tZSBBc3Npc3RhbnQgRXhhbXBsZQoKYGBgeWFtbApyZXN0X2NvbW1hbmQ6CiAgZnJhbWJlX25leHRfcGhvdG86CiAgICB1cmw6ICJodHRwOi8vZnJhbWJlLXNlcnZlcjozMDMwL2FwaS9jbGllbnRzL3t7IGNsaWVudF9pZCB9fS9jb21tYW5kIgogICAgbWV0aG9kOiBQT1NUCiAgICBoZWFkZXJzOgogICAgICBBdXRob3JpemF0aW9uOiAiQmVhcmVyIHlvdXItc2VjcmV0LXRva2VuLWhlcmUiCiAgICAgIENvbnRlbnQtVHlwZTogImFwcGxpY2F0aW9uL2pzb24iCiAgICBwYXlsb2FkOiAneyJhY3Rpb24iOiAibmV4dCJ9JwpgYGAKCkF1dGhlbnRpY2F0aW9uIGNhbiBhbHNvIGJlIHByb3ZpZGVkIHZpYSBgeC1hcGktdG9rZW5gIGhlYWRlciBvciBgP3Rva2VuPWAgcXVlcnkgcGFyYW1ldGVyLgoKIyMg8J+UlyBaZXJvLVRvdWNoIFVSTCBQYXJhbWV0ZXJzCgpTa2lwIHRoZSBzZXR1cCBzY3JlZW4gZW50aXJlbHkgYnkgcGFzc2luZyBxdWVyeSBwYXJhbWV0ZXJzLiBUaGlzIGlzIGlkZWFsIGZvciBkZWRpY2F0ZWQgZnJhbWVzIOKAlCBqdXN0IGJvb2ttYXJrIHRoZSBVUkwgb24gZWFjaCB0YWJsZXQ6Cgp8IFVSTCB8IFdoYXQgaXQgc2hvd3MgfAp8LS0tfC0tLXwKfCBgaHR0cDovL3NlcnZlcjozMDMwLz9hbGJ1bT1BTEJVTV9VVUlEYCB8IFBob3RvcyBmcm9tIGEgc3BlY2lmaWMgYWxidW0gfAp8IGBodHRwOi8vc2VydmVyOjMwMzAvP3BlcnNvbj1QRVJTT05fVVVJRGAgfCBQaG90b3Mgb2YgYSBzcGVjaWZpYyBwZXJzb24gKGZhY2UgcmVjb2duaXRpb24pIHwKfCBgaHR0cDovL3NlcnZlcjozMDMwLz9mYXZvcml0ZXNgIHwgRmF2b3JpdGUgcGhvdG9zIG9ubHkgfAp8IGBodHRwOi8vc2VydmVyOjMwMzAvP3JhbmRvbWAgfCBSYW5kb20gcGhvdG9zIGZyb20gdGhlIGxpYnJhcnkgfAoKWW91IGNhbiBmaW5kIGFsYnVtIGFuZCBwZXJzb24gVVVJRHMgaW4gSW1taWNoJ3Mgd2ViIGludGVyZmFjZSBVUkwgYmFyIHdoZW4gdmlld2luZyBhbiBhbGJ1bSBvciBwZXJzb24uCgojIyDimpnvuI8gQ29uZmlndXJhdGlvbgoKQWxsIHNldHRpbmdzIGFyZSB2aWEgZW52aXJvbm1lbnQgdmFyaWFibGVzOgoKfCBWYXJpYWJsZSB8IERlZmF1bHQgfCBEZXNjcmlwdGlvbiB8CnwtLS18LS0tfC0tLXwKfCBgSU1NSUNIX1VSTGAgfCAqKHJlcXVpcmVkKSogfCBZb3VyIEltbWljaCBzZXJ2ZXIgVVJMIHwKfCBgSU1NSUNIX0FQSV9LRVlgIHwgKihyZXF1aXJlZCkqIHwgSW1taWNoIEFQSSBrZXkgfAp8IGBTTElERVNIT1dfSU5URVJWQUxgIHwgYDMwYCB8IFNlY29uZHMgYmV0d2VlbiBwaG90b3MgfAp8IGBUUkFOU0lUSU9OX0RVUkFUSU9OYCB8IGAyYCB8IENyb3NzZmFkZSBkdXJhdGlvbiBpbiBzZWNvbmRzIHwKfCBgSU1BR0VfRklUYCB8IGBjb250YWluYCB8IGBjb250YWluYCBvciBgY292ZXJgIHwKfCBgU0hVRkZMRWAgfCBgdHJ1ZWAgfCBSYW5kb21pc2UgcGhvdG8gb3JkZXIgfAp8IGBCQUNLR1JPVU5EX0JMVVJgIHwgYHRydWVgIHwgU2hvdyBibHVycmVkIGJhY2tkcm9wIHwKfCBgU0hPV19DTE9DS2AgfCBgdHJ1ZWAgfCBEaXNwbGF5IGNsb2NrIG92ZXJsYXkgfAp8IGBTSE9XX0RBVEVgIHwgYHRydWVgIHwgRGlzcGxheSBkYXRlIG92ZXJsYXkgfAp8IGBTSE9XX0VYSUZgIHwgYHRydWVgIHwgRGlzcGxheSBwaG90byBtZXRhZGF0YSB8CnwgYFNIT1dfUFJPR1JFU1NgIHwgYHRydWVgIHwgRGlzcGxheSBwcm9ncmVzcyBiYXIgfAp8IGBSRUZSRVNIX0lOVEVSVkFMYCB8IGAzMDBgIHwgU2Vjb25kcyBiZXR3ZWVuIHNvdXJjZSByZWZyZXNoIGNoZWNrcyAobmV3IHBob3RvcykgfAp8IGBBTEJVTV9JRGAgfCAqKGVtcHR5KSogfCBBdXRvLXN0YXJ0IHdpdGggc3BlY2lmaWMgYWxidW0gKGVudi1iYXNlZCkgfAp8IGBTSE9XX0ZBVk9SSVRFU19PTkxZYCB8IGBmYWxzZWAgfCBBdXRvLXN0YXJ0IHdpdGggZmF2b3JpdGVzIChlbnYtYmFzZWQpIHwKfCBgQURNSU5fVVNFUk5BTUVgIHwgYGFkbWluYCB8IEFkbWluIGRhc2hib2FyZCBsb2dpbiB1c2VybmFtZSB8CnwgYEFETUlOX1BBU1NXT1JEYCB8ICooZW1wdHkpKiB8IEFkbWluIGRhc2hib2FyZCBwYXNzd29yZCAobGVhdmUgZW1wdHkgdG8gZGlzYWJsZSBhdXRoKSB8CnwgYEZSQU1CRV9BUElfVE9LRU5gIHwgKihlbXB0eSkqIHwgQVBJIHRva2VuIGZvciBSRVNUIGVuZHBvaW50IGFjY2VzcyAobGVhdmUgZW1wdHkgZm9yIG9wZW4gYWNjZXNzKSB8CnwgYFBPUlRgIHwgYDMwMDBgIHwgSW50ZXJuYWwgc2VydmVyIHBvcnQgKERvY2tlciBtYXBzIGV4dGVybmFsbHkgdmlhIGNvbXBvc2UpIHwKCiMjIPCfjq4gQ29udHJvbHMKCiMjIyBUb3VjaCAvIE1vdXNlCi0gKipMZWZ0IDIwJSoqIG9mIHNjcmVlbiDigJQgUHJldmlvdXMgcGhvdG8KLSAqKkNlbnRyZSA2MCUqKiDigJQgVG9nZ2xlIG92ZXJsYXkgKGNsb2NrLCBpbmZvLCBjbG9zZSBidXR0b24pCi0gKipSaWdodCAyMCUqKiDigJQgTmV4dCBwaG90bwoKIyMjIEtleWJvYXJkCi0gYOKGkGAgLyBg4oaSYCDigJQgUHJldmlvdXMgLyBOZXh0IHBob3RvCi0gYFNwYWNlYCDigJQgTmV4dCBwaG90bwotIGBGYCDigJQgVG9nZ2xlIGZ1bGxzY3JlZW4KLSBgSWAg4oCUIFRvZ2dsZSBpbmZvIG92ZXJsYXkKLSBgRXNjYCDigJQgRXhpdCB0byBhbGJ1bSBzZWxlY3Rpb24KCiMjIPCfk7EgVGFibGV0IFNldHVwIFRpcHMKCjEuIE9wZW4gdGhlIGZyYW1lIFVSTCBpbiB5b3VyIHRhYmxldCdzIGJyb3dzZXIgKHVzZSBhIGA/YWxidW09YCBvciBgP3BlcnNvbj1gIFVSTCBmb3IgemVyby10b3VjaCkKMi4gQWRkIHRvIEhvbWUgU2NyZWVuIGZvciBhIGZ1bGwtc2NyZWVuIGFwcCBleHBlcmllbmNlCjMuIEVuYWJsZSBraW9zayBtb2RlIG9yIGd1aWRlZCBhY2Nlc3MgdG8gbG9jayB0byB0aGUgYXBwCjQuIERpc2FibGUgc2NyZWVuIHRpbWVvdXQgaW4geW91ciBkZXZpY2Ugc2V0dGluZ3MKCiMjIPCfj5fvuI8gQXJjaGl0ZWN0dXJlCgpgYGAK4pSM4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSQICAgICAgICAg4pSM4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSQICAgICAgICAg4pSM4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSQCuKUgiAgIEJyb3dzZXIgICAg4pSCICBIVFRQICAg4pSCICAgIEZyYW1iZSAgICDilIIgICBBUEkgICDilIIgICAgSW1taWNoICAgICDilIIK4pSCICAoVGFibGV0KSAgICDilILil4TilIDilIDilIDilIDilIDilIDilIDilIDilrrilIIgIChOb2RlLmpzKSAg4pSC4peE4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pa64pSCICAgU2VydmVyICAgICDilIIK4pSU4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSYICA6MzAzMCAg4pSU4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSYICA6MjI4MyAg4pSU4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSYCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIOKWsgogICAgICAgICAgICAgICAgICAgIOKUjOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUtOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUkAogICAgICAgICAgICAgICAgICAgIOKUgiAgUkVTVCBBUEkgLyBXUyAgICDilIIKICAgICAgICAgICAgICAgICAgICDilIIgIChIb21lIEFzc2lzdGFudCkg4pSCCiAgICAgICAgICAgICAgICAgICAg4pSU4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSYCmBgYAoKVGhlIE5vZGUuanMgYmFja2VuZCBhY3RzIGFzIGEgc2VjdXJlIHByb3h5IOKAlCB5b3VyIEltbWljaCBBUEkga2V5IG5ldmVyIHJlYWNoZXMgdGhlIGJyb3dzZXIuIFRoZSBmcm9udGVuZCBwZXJpb2RpY2FsbHkgcG9sbHMgdGhlIGJhY2tlbmQgZm9yIG5ldyBwaG90b3Mgc28gYWxidW1zIHN0YXkgdXAgdG8gZGF0ZSB3aXRob3V0IHJlc3RhcnRpbmcuCgojIyDwn5OLIFZlcnNpb24gSGlzdG9yeQoKLSAqKjEuNC4wKiog4oCUIEFkbWluIGxvZ2luICh1c2VybmFtZS9wYXNzd29yZCksIEFQSSB0b2tlbiBhdXRoIGZvciBleHRlcm5hbCBhY2Nlc3MgKEhvbWUgQXNzaXN0YW50KSwgUkVTVCBlbmRwb2ludHMgKGBHRVQgL2FwaS9jbGllbnRzYCwgYFBPU1QgL2FwaS9jbGllbnRzLzppZC9jb21tYW5kYCkKLSAqKjEuMy4wKiog4oCUIEFkbWluIGRhc2hib2FyZCB3aXRoIHJlYWwtdGltZSBXZWJTb2NrZXQgZnJhbWUgbWFuYWdlbWVudCwgdmlkZW8gc3VwcG9ydCwgcGVyc29uL2ZhY2Ugc3VwcG9ydAotICoqMS4yLjEqKiDigJQgRml4IHBvcnQgbWFwcGluZyAoMzAzMDozMDAwIGV4dGVybmFsOmludGVybmFsKSwgZml4IFVSTCBwYXJhbSBhdXRvLWxhdW5jaCBub3Qgc3RhcnRpbmcgc2xpZGVzaG93Ci0gKioxLjIuMCoqIOKAlCBVUkwgcGFyYW1zIChgP2FsYnVtPWAsIGA/cGVyc29uPWAsIGA/ZmF2b3JpdGVzYCwgYD9yYW5kb21gKSwgcGVyc29uL2ZhY2Ugc3VwcG9ydCwgcGVyaW9kaWMgYXV0by1yZWZyZXNoLCBhcHAgaWNvbiwgZGVmYXVsdCBwb3J0IGNoYW5nZWQgdG8gMzAzMAotICoqMS4xLjAqKiDigJQgUmVicmFuZCB0byBGcmFtYmUKLSAqKjEuMC4wKiog4oCUIEluaXRpYWwgcmVsZWFzZTogYWxidW0gYnJvd3Nlciwgc2xpZGVzaG93IHdpdGggY3Jvc3NmYWRlLCBjbG9jay9kYXRlL0VYSUYgb3ZlcmxheXMsIHRvdWNoICYga2V5Ym9hcmQgY29udHJvbHMsIERvY2tlciBkZXBsb3ltZW50CgojIyDwn5OEIExpY2Vuc2UKCk1JVAo= \ No newline at end of file From 79d9cae98680bf0964a3a2f8c68f830f3e8eaf4c Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 26 May 2026 15:09:45 +1000 Subject: [PATCH 11/30] fix: re-upload .env.example with correct encoding --- .env.example | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 6957dd1..1c8dc0a 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,35 @@ -IyA9PT0gRnJhbWJlIENvbmZpZ3VyYXRpb24gPT09CgojIFJFUVVJUkVECklNTUlDSF9VUkw9aHR0cDovL3lvdXItaW1taWNoLXNlcnZlcjoyMjgzCklNTUlDSF9BUElfS0VZPXlvdXItYXBpLWtleS1oZXJlCgojIFNsaWRlc2hvdwpTTElERVNIT1dfSU5URVJWQUw9MzAKVFJBTlNJVElPTl9EVVJBVElPTj0yCklNQUdFX0ZJVD1jb250YWluClNIVUZGTEU9dHJ1ZQpCQUNLR1JPVU5EX0JMVVI9dHJ1ZQpSRUZSRVNIX0lOVEVSVkFMPTMwMAoKIyBPdmVybGF5cwpTSE9XX0NMT0NLPXRydWUKU0hPV19EQVRFPXRydWUKU0hPV19FWElGPXRydWUKU0hPV19QUk9HUkVTUz10cnVlCgojIEF1dG8tc3RhcnQgKG9wdGlvbmFsIOKAlCBvciB1c2UgVVJMIHBhcmFtcyBpbnN0ZWFkKQojIEFMQlVNX0lEPQojIFNIT1dfRkFWT1JJVEVTX09OTFk9ZmFsc2UKCiMgQWRtaW4gQXV0aGVudGljYXRpb24gKG9wdGlvbmFsIOKAlCBsZWF2ZSBBRE1JTl9QQVNTV09SRCBibGFuayB0byBkaXNhYmxlKQpBRE1JTl9VU0VSTkFNRT1hZG1pbgpBRE1JTl9QQVNTV09SRD0KIyBBRE1JTl9QQVNTV09SRD1jaGFuZ2VtZQoKIyBBUEkgVG9rZW4gZm9yIGV4dGVybmFsIGFjY2VzcyAoSG9tZSBBc3Npc3RhbnQsIHNjcmlwdHMsIGV0Yy4pCiMgV2hlbiBzZXQsIFJFU1QgZW5kcG9pbnRzIHJlcXVpcmUgdGhpcyB0b2tlbiB2aWEgQmVhcmVyIGF1dGggb3IgeC1hcGktdG9rZW4gaGVhZGVyCiMgRlJBTUJFX0FQSV9UT0tFTj15b3VyLXNlY3JldC10b2tlbi1oZXJlCgojIFNlcnZlciAoaW50ZXJuYWwgcG9ydCDigJQgRG9ja2VyIG1hcHMgZXh0ZXJuYWxseSB2aWEgZG9ja2VyLWNvbXBvc2UpClBPUlQ9MzAwMAo= \ No newline at end of file +# === Frambe Configuration === + +# REQUIRED +IMMICH_URL=http://your-immich-server:2283 +IMMICH_API_KEY=your-api-key-here + +# Slideshow +SLIDESHOW_INTERVAL=30 +TRANSITION_DURATION=2 +IMAGE_FIT=contain +SHUFFLE=true +BACKGROUND_BLUR=true +REFRESH_INTERVAL=300 + +# Overlays +SHOW_CLOCK=true +SHOW_DATE=true +SHOW_EXIF=true +SHOW_PROGRESS=true + +# Auto-start (optional — or use URL params instead) +# ALBUM_ID= +# SHOW_FAVORITES_ONLY=false + +# Admin Authentication (optional — leave ADMIN_PASSWORD blank to disable) +ADMIN_USERNAME=admin +ADMIN_PASSWORD= +# ADMIN_PASSWORD=changeme + +# API Token for external access (Home Assistant, scripts, etc.) +# When set, REST endpoints require this token via Bearer auth or x-api-token header +# FRAMBE_API_TOKEN=your-secret-token-here + +# Server (internal port — Docker maps externally via docker-compose) +PORT=3000 From 249162fabba84d7a859abafc0cb4272797b91abc Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 26 May 2026 15:10:11 +1000 Subject: [PATCH 12/30] fix: re-upload docker-compose.yml with correct encoding --- docker-compose.yml | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index affe6e9..b38d33e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1 +1,38 @@ -dmVyc2lvbjogIjMuOCIKCnNlcnZpY2VzOgogIGZyYW1iZToKICAgIGJ1aWxkOiAuCiAgICBjb250YWluZXJfbmFtZTogZnJhbWJlCiAgICByZXN0YXJ0OiB1bmxlc3Mtc3RvcHBlZAogICAgcG9ydHM6CiAgICAgIC0gIjMwMzA6MzAwMCIKICAgIGVudmlyb25tZW50OgogICAgICAjIFJFUVVJUkVECiAgICAgIC0gSU1NSUNIX1VSTD1odHRwOi8veW91ci1pbW1pY2gtc2VydmVyOjIyODMKICAgICAgLSBJTU1JQ0hfQVBJX0tFWT15b3VyLWFwaS1rZXktaGVyZQoKICAgICAgIyBTbGlkZXNob3cKICAgICAgLSBTTElERVNIT1dfSU5URVJWQUw9MzAKICAgICAgLSBUUkFOU0lUSU9OX0RVUkFUSU9OPTIKICAgICAgLSBJTUFHRV9GSVQ9Y29udGFpbgogICAgICAtIFNIVUZGTEU9dHJ1ZQogICAgICAtIEJBQ0tHUk9VTkRfQkxVUj10cnVlCiAgICAgIC0gUkVGUkVTSF9JTlRFUlZBTD0zMDAgICAgICAgICAjIFNlY29uZHMgYmV0d2VlbiBhbGJ1bS9wZXJzb24gcmVmcmVzaCBjaGVja3MKCiAgICAgICMgT3ZlcmxheXMKICAgICAgLSBTSE9XX0NMT0NLPXRydWUKICAgICAgLSBTSE9XX0RBVEU9dHJ1ZQogICAgICAtIFNIT1dfRVhJRj10cnVlCiAgICAgIC0gU0hPV19QUk9HUkVTUz10cnVlCgogICAgICAjIEFkbWluIEF1dGhlbnRpY2F0aW9uIChsZWF2ZSBBRE1JTl9QQVNTV09SRCBibGFuayB0byBkaXNhYmxlIGxvZ2luKQogICAgICAtIEFETUlOX1VTRVJOQU1FPWFkbWluCiAgICAgICMgLSBBRE1JTl9QQVNTV09SRD1jaGFuZ2VtZQoKICAgICAgIyBBUEkgVG9rZW4gZm9yIGV4dGVybmFsIGFjY2VzcyAoSG9tZSBBc3Npc3RhbnQsIHNjcmlwdHMsIGV0Yy4pCiAgICAgICMgLSBGUkFNQkVfQVBJX1RPS0VOPXlvdXItc2VjcmV0LXRva2VuLWhlcmUKCiAgICAgICMgQXV0by1zdGFydCAob3B0aW9uYWwg4oCUIG9yIHVzZSBVUkwgcGFyYW1zIGluc3RlYWQpCiAgICAgICMgLSBBTEJVTV9JRD0KICAgICAgIyAtIFNIT1dfRkFWT1JJVEVTX09OTFk9ZmFsc2UK \ No newline at end of file +version: "3.8" + +services: + frambe: + build: . + container_name: frambe + restart: unless-stopped + ports: + - "3030:3000" + environment: + # REQUIRED + - IMMICH_URL=http://your-immich-server:2283 + - IMMICH_API_KEY=your-api-key-here + + # Slideshow + - SLIDESHOW_INTERVAL=30 + - TRANSITION_DURATION=2 + - IMAGE_FIT=contain + - SHUFFLE=true + - BACKGROUND_BLUR=true + - REFRESH_INTERVAL=300 # Seconds between album/person refresh checks + + # Overlays + - SHOW_CLOCK=true + - SHOW_DATE=true + - SHOW_EXIF=true + - SHOW_PROGRESS=true + + # Admin Authentication (leave ADMIN_PASSWORD blank to disable login) + - ADMIN_USERNAME=admin + # - ADMIN_PASSWORD=changeme + + # API Token for external access (Home Assistant, scripts, etc.) + # - FRAMBE_API_TOKEN=your-secret-token-here + + # Auto-start (optional — or use URL params instead) + # - ALBUM_ID= + # - SHOW_FAVORITES_ONLY=false From 1d209b5daade88d3ab32c0cc766b3d1b7cc545df Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 26 May 2026 15:10:43 +1000 Subject: [PATCH 13/30] fix: re-upload login.html with correct encoding --- public/admin/login.html | 66 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/public/admin/login.html b/public/admin/login.html index 6f96393..a78398a 100644 --- a/public/admin/login.html +++ b/public/admin/login.html @@ -1 +1,65 @@ -PCFET0NUWVBFIGh0bWw+CjxodG1sIGxhbmc9ImVuIj4KPGhlYWQ+CiAgPG1ldGEgY2hhcnNldD0iVVRGLTgiPgogIDxtZXRhIG5hbWU9InZpZXdwb3J0IiBjb250ZW50PSJ3aWR0aD1kZXZpY2Utd2lkdGgsIGluaXRpYWwtc2NhbGU9MS4wIj4KICA8dGl0bGU+RnJhbWJlIEFkbWluIOKAlCBMb2dpbjwvdGl0bGU+CiAgPHN0eWxlPgogICAgKiwgKjo6YmVmb3JlLCAqOjphZnRlciB7IG1hcmdpbjogMDsgcGFkZGluZzogMDsgYm94LXNpemluZzogYm9yZGVyLWJveDsgfQogICAgYm9keSB7IGJhY2tncm91bmQ6ICMwZjBmMWE7IGNvbG9yOiAjZTBlMGUwOyBmb250LWZhbWlseTogLWFwcGxlLXN5c3RlbSwgQmxpbmtNYWNTeXN0ZW1Gb250LCAnU2Vnb2UgVUknLCBSb2JvdG8sIHNhbnMtc2VyaWY7IGRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGp1c3RpZnktY29udGVudDogY2VudGVyOyBtaW4taGVpZ2h0OiAxMDB2aDsgfQogICAgLmxvZ2luLWNhcmQgeyBiYWNrZ3JvdW5kOiByZ2JhKDI1NSwyNTUsMjU1LDAuMDQpOyBib3JkZXI6IDFweCBzb2xpZCByZ2JhKDI1NSwyNTUsMjU1LDAuMDgpOyBib3JkZXItcmFkaXVzOiAxNnB4OyBwYWRkaW5nOiAyLjVyZW07IHdpZHRoOiAxMDAlOyBtYXgtd2lkdGg6IDM4MHB4OyB9CiAgICAubG9naW4taGVhZGVyIHsgdGV4dC1hbGlnbjogY2VudGVyOyBtYXJnaW4tYm90dG9tOiAycmVtOyB9CiAgICAubG9naW4taGVhZGVyIGltZyB7IHdpZHRoOiA1NnB4OyBoZWlnaHQ6IDU2cHg7IGJvcmRlci1yYWRpdXM6IDEycHg7IG1hcmdpbi1ib3R0b206IDFyZW07IH0KICAgIC5sb2dpbi1oZWFkZXIgaDEgeyBmb250LXNpemU6IDEuNHJlbTsgZm9udC13ZWlnaHQ6IDMwMDsgfQogICAgLmxvZ2luLWhlYWRlciBwIHsgZm9udC1zaXplOiAwLjhyZW07IGNvbG9yOiAjNjY2OyBtYXJnaW4tdG9wOiAwLjI1cmVtOyB9CiAgICAuZm9ybS1ncm91cCB7IG1hcmdpbi1ib3R0b206IDEuMjVyZW07IH0KICAgIC5mb3JtLWdyb3VwIGxhYmVsIHsgZGlzcGxheTogYmxvY2s7IGZvbnQtc2l6ZTogMC44cmVtOyBjb2xvcjogIzg4ODsgbWFyZ2luLWJvdHRvbTogMC40cmVtOyB9CiAgICAuZm9ybS1ncm91cCBpbnB1dCB7IHdpZHRoOiAxMDAlOyBwYWRkaW5nOiAxMHB4IDE0cHg7IGJhY2tncm91bmQ6IHJnYmEoMjU1LDI1NSwyNTUsMC4wNik7IGJvcmRlcjogMXB4IHNvbGlkIHJnYmEoMjU1LDI1NSwyNTUsMC4xMik7IGJvcmRlci1yYWRpdXM6IDhweDsgY29sb3I6ICNlMGUwZTA7IGZvbnQtc2l6ZTogMC45NXJlbTsgb3V0bGluZTogbm9uZTsgdHJhbnNpdGlvbjogYm9yZGVyLWNvbG9yIDAuMTVzOyB9CiAgICAuZm9ybS1ncm91cCBpbnB1dDpmb2N1cyB7IGJvcmRlci1jb2xvcjogIzYzNjZmMTsgfQogICAgLmxvZ2luLWJ0biB7IHdpZHRoOiAxMDAlOyBwYWRkaW5nOiAxMXB4OyBiYWNrZ3JvdW5kOiByZ2JhKDk5LDEwMiwyNDEsMC4yKTsgYm9yZGVyOiAxcHggc29saWQgIzYzNjZmMTsgYm9yZGVyLXJhZGl1czogOHB4OyBjb2xvcjogI2E1YjRmYzsgZm9udC1zaXplOiAwLjk1cmVtOyBjdXJzb3I6IHBvaW50ZXI7IHRyYW5zaXRpb246IGFsbCAwLjE1czsgfQogICAgLmxvZ2luLWJ0bjpob3ZlciB7IGJhY2tncm91bmQ6IHJnYmEoOTksMTAyLDI0MSwwLjM1KTsgfQogICAgLmxvZ2luLWJ0bjpkaXNhYmxlZCB7IG9wYWNpdHk6IDAuNTsgY3Vyc29yOiBub3QtYWxsb3dlZDsgfQogICAgLmVycm9yLW1zZyB7IGJhY2tncm91bmQ6IHJnYmEoMjM5LDY4LDY4LDAuMTIpOyBib3JkZXI6IDFweCBzb2xpZCByZ2JhKDIzOSw2OCw2OCwwLjMpOyBib3JkZXItcmFkaXVzOiA4cHg7IGNvbG9yOiAjZmNhNWE1OyBmb250LXNpemU6IDAuODVyZW07IHBhZGRpbmc6IDhweCAxMnB4OyBtYXJnaW4tYm90dG9tOiAxcmVtOyBkaXNwbGF5OiBub25lOyB9CiAgPC9zdHlsZT4KPC9oZWFkPgo8Ym9keT4KICA8ZGl2IGNsYXNzPSJsb2dpbi1jYXJkIj4KICAgIDxkaXYgY2xhc3M9ImxvZ2luLWhlYWRlciI+CiAgICAgIDxpbWcgc3JjPSIvaW1nL2ljb24ucG5nIiBhbHQ9IkZyYW1iZSIgb25lcnJvcj0idGhpcy5zdHlsZS5kaXNwbGF5PSdub25lJyI+CiAgICAgIDxoMT5GcmFtYmUgQWRtaW48L2gxPgogICAgICA8cD5TaWduIGluIHRvIG1hbmFnZSB5b3VyIGZyYW1lczwvcD4KICAgIDwvZGl2PgogICAgPGRpdiBjbGFzcz0iZXJyb3ItbXNnIiBpZD0iZXJyb3ItbXNnIj48L2Rpdj4KICAgIDxkaXYgY2xhc3M9ImZvcm0tZ3JvdXAiPgogICAgICA8bGFiZWwgZm9yPSJ1c2VybmFtZSI+VXNlcm5hbWU8L2xhYmVsPgogICAgICA8aW5wdXQgdHlwZT0idGV4dCIgaWQ9InVzZXJuYW1lIiBhdXRvY29tcGxldGU9InVzZXJuYW1lIiBhdXRvZm9jdXM+CiAgICA8L2Rpdj4KICAgIDxkaXYgY2xhc3M9ImZvcm0tZ3JvdXAiPgogICAgICA8bGFiZWwgZm9yPSJwYXNzd29yZCI+UGFzc3dvcmQ8L2xhYmVsPgogICAgICA8aW5wdXQgdHlwZT0icGFzc3dvcmQiIGlkPSJwYXNzd29yZCIgYXV0b2NvbXBsZXRlPSJjdXJyZW50LXBhc3N3b3JkIj4KICAgIDwvZGl2PgogICAgPGJ1dHRvbiBjbGFzcz0ibG9naW4tYnRuIiBpZD0ibG9naW4tYnRuIiBvbmNsaWNrPSJkb0xvZ2luKCkiPlNpZ24gSW48L2J1dHRvbj4KICA8L2Rpdj4KICA8c2NyaXB0PgogICAgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3Bhc3N3b3JkJykuYWRkRXZlbnRMaXN0ZW5lcigna2V5ZG93bicsIGZ1bmN0aW9uKGUpIHsgaWYgKGUua2V5ID09PSAnRW50ZXInKSBkb0xvZ2luKCk7IH0pOwogICAgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3VzZXJuYW1lJykuYWRkRXZlbnRMaXN0ZW5lcigna2V5ZG93bicsIGZ1bmN0aW9uKGUpIHsgaWYgKGUua2V5ID09PSAnRW50ZXInKSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgncGFzc3dvcmQnKS5mb2N1cygpOyB9KTsKCiAgICBhc3luYyBmdW5jdGlvbiBkb0xvZ2luKCkgewogICAgICB2YXIgYnRuID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2xvZ2luLWJ0bicpOwogICAgICB2YXIgZXJyRWwgPSBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnZXJyb3ItbXNnJyk7CiAgICAgIGJ0bi5kaXNhYmxlZCA9IHRydWU7CiAgICAgIGVyckVsLnN0eWxlLmRpc3BsYXkgPSAnbm9uZSc7CiAgICAgIHZhciB1c2VybmFtZSA9IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCd1c2VybmFtZScpLnZhbHVlLnRyaW0oKTsKICAgICAgdmFyIHBhc3N3b3JkID0gZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3Bhc3N3b3JkJykudmFsdWU7CiAgICAgIGlmICghdXNlcm5hbWUgfHwgIXBhc3N3b3JkKSB7IGVyckVsLnRleHRDb250ZW50ID0gJ1BsZWFzZSBlbnRlciBib3RoIHVzZXJuYW1lIGFuZCBwYXNzd29yZCc7IGVyckVsLnN0eWxlLmRpc3BsYXkgPSAnYmxvY2snOyBidG4uZGlzYWJsZWQgPSBmYWxzZTsgcmV0dXJuOyB9CiAgICAgIHRyeSB7CiAgICAgICAgdmFyIHIgPSBhd2FpdCBmZXRjaCgnL2FwaS9hdXRoL2xvZ2luJywgeyBtZXRob2Q6ICdQT1NUJywgaGVhZGVyczogeyAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH0sIGJvZHk6IEpTT04uc3RyaW5naWZ5KHsgdXNlcm5hbWU6IHVzZXJuYW1lLCBwYXNzd29yZDogcGFzc3dvcmQgfSkgfSk7CiAgICAgICAgdmFyIGQgPSBhd2FpdCByLmpzb24oKTsKICAgICAgICBpZiAoZC5vaykgeyB3aW5kb3cubG9jYXRpb24uaHJlZiA9ICcvYWRtaW4nOyB9CiAgICAgICAgZWxzZSB7IGVyckVsLnRleHRDb250ZW50ID0gZC5lcnJvciB8fCAnTG9naW4gZmFpbGVkJzsgZXJyRWwuc3R5bGUuZGlzcGxheSA9ICdibG9jayc7IH0KICAgICAgfSBjYXRjaCAoZSkgeyBlcnJFbC50ZXh0Q29udGVudCA9ICdDb25uZWN0aW9uIGVycm9yJzsgZXJyRWwuc3R5bGUuZGlzcGxheSA9ICdibG9jayc7IH0KICAgICAgYnRuLmRpc2FibGVkID0gZmFsc2U7CiAgICB9CiAgPC9zY3JpcHQ+CjwvYm9keT4KPC9odG1sPgo= \ No newline at end of file + + + + + + Frambe Admin — Login + + + + + + + From e7703320328ac9039365f865b3ccf0609608805a Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 26 May 2026 15:11:59 +1000 Subject: [PATCH 14/30] fix: re-upload admin dashboard with correct encoding --- public/admin/index.html | 112 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/public/admin/index.html b/public/admin/index.html index cffb2c1..8efb640 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -1 +1,111 @@ -PCFET0NUWVBFIGh0bWw+CjxodG1sIGxhbmc9ImVuIj4KPGhlYWQ+CiAgPG1ldGEgY2hhcnNldD0iVVRGLTgiPgogIDxtZXRhIG5hbWU9InZpZXdwb3J0IiBjb250ZW50PSJ3aWR0aD1kZXZpY2Utd2lkdGgsIGluaXRpYWwtc2NhbGU9MS4wIj4KICA8dGl0bGU+RnJhbWJlIEFkbWluPC90aXRsZT4KICA8c3R5bGU+CiAgICAqLCAqOjpiZWZvcmUsICo6OmFmdGVyIHsgbWFyZ2luOiAwOyBwYWRkaW5nOiAwOyBib3gtc2l6aW5nOiBib3JkZXItYm94OyB9CiAgICBib2R5IHsgYmFja2dyb3VuZDogIzBmMGYxYTsgY29sb3I6ICNlMGUwZTA7IGZvbnQtZmFtaWx5OiAtYXBwbGUtc3lzdGVtLCBCbGlua01hY1N5c3RlbUZvbnQsICdTZWdvZSBVSScsIFJvYm90bywgc2Fucy1zZXJpZjsgcGFkZGluZzogMS41cmVtOyBtaW4taGVpZ2h0OiAxMDB2aDsgfQogICAgaDEgeyBmb250LXNpemU6IDEuNnJlbTsgZm9udC13ZWlnaHQ6IDMwMDsgbWFyZ2luLWJvdHRvbTogMC4yNXJlbTsgfQogICAgLmhlYWRlciB7IGRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogMXJlbTsgbWFyZ2luLWJvdHRvbTogMS41cmVtOyBib3JkZXItYm90dG9tOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwwLjEpOyBwYWRkaW5nLWJvdHRvbTogMXJlbTsgfQogICAgLmhlYWRlciBpbWcgeyB3aWR0aDogNDhweDsgaGVpZ2h0OiA0OHB4OyBib3JkZXItcmFkaXVzOiAxMHB4OyB9CiAgICAuaGVhZGVyIC52ZXJzaW9uIHsgZm9udC1zaXplOiAwLjhyZW07IGNvbG9yOiAjNjY2OyB9CiAgICAuaGVhZGVyLXJpZ2h0IHsgbWFyZ2luLWxlZnQ6IGF1dG87IGRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogMC43NXJlbTsgfQogICAgLnN0YXR1cy1kb3QgeyBkaXNwbGF5OiBpbmxpbmUtYmxvY2s7IHdpZHRoOiA4cHg7IGhlaWdodDogOHB4OyBib3JkZXItcmFkaXVzOiA1MCU7IG1hcmdpbi1yaWdodDogNnB4OyB9CiAgICAuc3RhdHVzLWRvdC5vbmxpbmUgeyBiYWNrZ3JvdW5kOiAjNGFkZTgwOyB9CiAgICAuc3RhdHVzLWRvdC5zbGVlcGluZyB7IGJhY2tncm91bmQ6ICNmYmJmMjQ7IH0KICAgIC5zdGF0dXMtZG90LnBsYXlpbmcgeyBiYWNrZ3JvdW5kOiAjNjBhNWZhOyB9CiAgICAuY2xpZW50cy1ncmlkIHsgZGlzcGxheTogZ3JpZDsgZ3JpZC10ZW1wbGF0ZS1jb2x1bW5zOiByZXBlYXQoYXV0by1maWxsLCBtaW5tYXgoMzQwcHgsIDFmcikpOyBnYXA6IDFyZW07IH0KICAgIC5jbGllbnQtY2FyZCB7IGJhY2tncm91bmQ6IHJnYmEoMjU1LDI1NSwyNTUsMC4wNCk7IGJvcmRlcjogMXB4IHNvbGlkIHJnYmEoMjU1LDI1NSwyNTUsMC4wOCk7IGJvcmRlci1yYWRpdXM6IDEycHg7IHBhZGRpbmc6IDEuMjVyZW07IHRyYW5zaXRpb246IGFsbCAwLjJzOyB9CiAgICAuY2xpZW50LWNhcmQ6aG92ZXIgeyBib3JkZXItY29sb3I6IHJnYmEoOTksMTAyLDI0MSwwLjMpOyBiYWNrZ3JvdW5kOiByZ2JhKDI1NSwyNTUsMjU1LDAuMDYpOyB9CiAgICAuY2xpZW50LWhlYWRlciB7IGRpc3BsYXk6IGZsZXg7IGp1c3RpZnktY29udGVudDogc3BhY2UtYmV0d2VlbjsgYWxpZ24taXRlbXM6IGNlbnRlcjsgbWFyZ2luLWJvdHRvbTogMXJlbTsgfQogICAgLmNsaWVudC1uYW1lIHsgZm9udC1zaXplOiAxLjFyZW07IGZvbnQtd2VpZ2h0OiA1MDA7IGRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogMC41cmVtOyB9CiAgICAuY2xpZW50LWlwIHsgZm9udC1zaXplOiAwLjc1cmVtOyBjb2xvcjogIzg4ODsgZm9udC1mYW1pbHk6IG1vbm9zcGFjZTsgfQogICAgLmNsaWVudC1zdGF0dXMgeyBmb250LXNpemU6IDAuOHJlbTsgY29sb3I6ICNhYWE7IHRleHQtdHJhbnNmb3JtOiBjYXBpdGFsaXplOyB9CiAgICAubmFtZS1pbnB1dCB7IGJhY2tncm91bmQ6IHRyYW5zcGFyZW50OyBib3JkZXI6IDFweCBzb2xpZCByZ2JhKDI1NSwyNTUsMjU1LDAuMTUpOyBib3JkZXItcmFkaXVzOiA2cHg7IGNvbG9yOiAjZmZmOyBmb250LXNpemU6IDAuOXJlbTsgcGFkZGluZzogNHB4IDhweDsgd2lkdGg6IDE0MHB4OyB9CiAgICAubmFtZS1pbnB1dDpmb2N1cyB7IG91dGxpbmU6IG5vbmU7IGJvcmRlci1jb2xvcjogIzYzNjZmMTsgfQogICAgLmNvbnRyb2xzIHsgZGlzcGxheTogZmxleDsgZmxleC1kaXJlY3Rpb246IGNvbHVtbjsgZ2FwOiAwLjc1cmVtOyB9CiAgICAuY29udHJvbC1yb3cgeyBkaXNwbGF5OiBmbGV4OyBhbGlnbi1pdGVtczogY2VudGVyOyBnYXA6IDAuNzVyZW07IGZsZXgtd3JhcDogd3JhcDsgfQogICAgLmNvbnRyb2wtbGFiZWwgeyBmb250LXNpemU6IDAuOHJlbTsgY29sb3I6ICM4ODg7IG1pbi13aWR0aDogNjBweDsgfQogICAgLmJ0biB7IHBhZGRpbmc6IDZweCAxNHB4OyBib3JkZXI6IDFweCBzb2xpZCByZ2JhKDI1NSwyNTUsMjU1LDAuMTUpOyBib3JkZXItcmFkaXVzOiA4cHg7IGJhY2tncm91bmQ6IHJnYmEoMjU1LDI1NSwyNTUsMC4wNik7IGNvbG9yOiAjZTBlMGUwOyBmb250LXNpemU6IDAuOHJlbTsgY3Vyc29yOiBwb2ludGVyOyB0cmFuc2l0aW9uOiBhbGwgMC4xNXM7IHdoaXRlLXNwYWNlOiBub3dyYXA7IH0KICAgIC5idG46aG92ZXIgeyBiYWNrZ3JvdW5kOiByZ2JhKDI1NSwyNTUsMjU1LDAuMTIpOyBib3JkZXItY29sb3I6IHJnYmEoMjU1LDI1NSwyNTUsMC4yNSk7IH0KICAgIC5idG4uZGFuZ2VyIHsgYmFja2dyb3VuZDogcmdiYSgyMzksNjgsNjgsMC4xNSk7IGJvcmRlci1jb2xvcjogI2VmNDQ0NDsgY29sb3I6ICNmY2E1YTU7IH0KICAgIC5idG4uZGFuZ2VyOmhvdmVyIHsgYmFja2dyb3VuZDogcmdiYSgyMzksNjgsNjgsMC4zKTsgfQogICAgLmJ0bi5zdWNjZXNzIHsgYmFja2dyb3VuZDogcmdiYSgzNCwxOTcsOTQsMC4xNSk7IGJvcmRlci1jb2xvcjogIzIyYzU1ZTsgY29sb3I6ICM4NmVmYWM7IH0KICAgIC5idG4uc3VjY2Vzczpob3ZlciB7IGJhY2tncm91bmQ6IHJnYmEoMzQsMTk3LDk0LDAuMyk7IH0KICAgIC5idG4ubG9nb3V0IHsgYmFja2dyb3VuZDogcmdiYSgyMzksNjgsNjgsMC4xKTsgYm9yZGVyLWNvbG9yOiByZ2JhKDIzOSw2OCw2OCwwLjMpOyBjb2xvcjogI2ZjYTVhNTsgZm9udC1zaXplOiAwLjc1cmVtOyBwYWRkaW5nOiA0cHggMTJweDsgfQogICAgLmJ0bi5sb2dvdXQ6aG92ZXIgeyBiYWNrZ3JvdW5kOiByZ2JhKDIzOSw2OCw2OCwwLjI1KTsgfQogICAgc2VsZWN0IHsgcGFkZGluZzogNnB4IDEwcHg7IGJvcmRlcjogMXB4IHNvbGlkIHJnYmEoMjU1LDI1NSwyNTUsMC4xNSk7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogcmdiYSgyNTUsMjU1LDI1NSwwLjA2KTsgY29sb3I6ICNlMGUwZTA7IGZvbnQtc2l6ZTogMC44cmVtOyBjdXJzb3I6IHBvaW50ZXI7IG1heC13aWR0aDogMjAwcHg7IH0KICAgIHNlbGVjdDpmb2N1cyB7IG91dGxpbmU6IG5vbmU7IGJvcmRlci1jb2xvcjogIzYzNjZmMTsgfQogICAgb3B0aW9uIHsgYmFja2dyb3VuZDogIzFhMWEyZTsgY29sb3I6ICNlMGUwZTA7IH0KICAgIGlucHV0W3R5cGU9cmFuZ2VdIHsgd2lkdGg6IDEyMHB4OyBhY2NlbnQtY29sb3I6ICM2MzY2ZjE7IH0KICAgIC5yYW5nZS12YWx1ZSB7IGZvbnQtc2l6ZTogMC44cmVtOyBjb2xvcjogI2FhYTsgbWluLXdpZHRoOiAzMHB4OyB9CiAgICAudG9nZ2xlIHsgcG9zaXRpb246IHJlbGF0aXZlOyB3aWR0aDogNDBweDsgaGVpZ2h0OiAyMnB4OyBjdXJzb3I6IHBvaW50ZXI7IH0KICAgIC50b2dnbGUgaW5wdXQgeyBkaXNwbGF5OiBub25lOyB9CiAgICAudG9nZ2xlLXNsaWRlciB7IHBvc2l0aW9uOiBhYnNvbHV0ZTsgdG9wOiAwOyBsZWZ0OiAwOyByaWdodDogMDsgYm90dG9tOiAwOyBiYWNrZ3JvdW5kOiByZ2JhKDI1NSwyNTUsMjU1LDAuMSk7IGJvcmRlci1yYWRpdXM6IDExcHg7IHRyYW5zaXRpb246IDAuMnM7IH0KICAgIC50b2dnbGUtc2xpZGVyOjpiZWZvcmUgeyBjb250ZW50OiAnJzsgcG9zaXRpb246IGFic29sdXRlOyB3aWR0aDogMTZweDsgaGVpZ2h0OiAxNnB4OyBsZWZ0OiAzcHg7IGJvdHRvbTogM3B4OyBiYWNrZ3JvdW5kOiAjODg4OyBib3JkZXItcmFkaXVzOiA1MCU7IHRyYW5zaXRpb246IDAuMnM7IH0KICAgIC50b2dnbGUgaW5wdXQ6Y2hlY2tlZCArIC50b2dnbGUtc2xpZGVyIHsgYmFja2dyb3VuZDogcmdiYSg5OSwxMDIsMjQxLDAuNCk7IH0KICAgIC50b2dnbGUgaW5wdXQ6Y2hlY2tlZCArIC50b2dnbGUtc2xpZGVyOjpiZWZvcmUgeyB0cmFuc2Zvcm06IHRyYW5zbGF0ZVgoMThweCk7IGJhY2tncm91bmQ6ICNhNWI0ZmM7IH0KICAgIC5lbXB0eS1zdGF0ZSB7IHRleHQtYWxpZ246IGNlbnRlcjsgcGFkZGluZzogNHJlbSAycmVtOyBjb2xvcjogIzY2NjsgfQogICAgLmVtcHR5LXN0YXRlIGgyIHsgZm9udC1zaXplOiAxLjJyZW07IGZvbnQtd2VpZ2h0OiA0MDA7IG1hcmdpbi1ib3R0b206IDAuNXJlbTsgY29sb3I6ICM4ODg7IH0KICAgIC53cy1zdGF0dXMgeyBmb250LXNpemU6IDAuNzVyZW07IHBhZGRpbmc6IDRweCAxMHB4OyBib3JkZXItcmFkaXVzOiAyMHB4OyB9CiAgICAud3Mtc3RhdHVzLmNvbm5lY3RlZCB7IGJhY2tncm91bmQ6IHJnYmEoMzQsMTk3LDk0LDAuMTUpOyBjb2xvcjogIzg2ZWZhYzsgfQogICAgLndzLXN0YXR1cy5kaXNjb25uZWN0ZWQgeyBiYWNrZ3JvdW5kOiByZ2JhKDIzOSw2OCw2OCwwLjE1KTsgY29sb3I6ICNmY2E1YTU7IH0KICAgIC5kaXZpZGVyIHsgYm9yZGVyOiBub25lOyBib3JkZXItdG9wOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwwLjA2KTsgbWFyZ2luOiAwLjVyZW0gMDsgfQogIDwvc3R5bGU+CjwvaGVhZD4KPGJvZHk+CiAgPGRpdiBjbGFzcz0iaGVhZGVyIj4KICAgIDxpbWcgc3JjPSIvaW1nL2ljb24ucG5nIiBhbHQ9IkZyYW1iZSIgb25lcnJvcj0idGhpcy5zdHlsZS5kaXNwbGF5PSdub25lJyI+CiAgICA8ZGl2PjxoMT5GcmFtYmUgQWRtaW48L2gxPjxzcGFuIGNsYXNzPSJ2ZXJzaW9uIiBpZD0idmVyc2lvbi10ZXh0Ij5Db25uZWN0aW5nLi4uPC9zcGFuPjwvZGl2PgogICAgPGRpdiBjbGFzcz0iaGVhZGVyLXJpZ2h0Ij4KICAgICAgPHNwYW4gY2xhc3M9IndzLXN0YXR1cyBkaXNjb25uZWN0ZWQiIGlkPSJ3cy1zdGF0dXMiPkRpc2Nvbm5lY3RlZDwvc3Bhbj4KICAgICAgPGJ1dHRvbiBjbGFzcz0iYnRuIGxvZ291dCIgaWQ9ImxvZ291dC1idG4iIHN0eWxlPSJkaXNwbGF5Om5vbmUiIG9uY2xpY2s9ImRvTG9nb3V0KCkiPkxvZ291dDwvYnV0dG9uPgogICAgPC9kaXY+CiAgPC9kaXY+CiAgPGRpdiBjbGFzcz0iY2xpZW50cy1ncmlkIiBpZD0iY2xpZW50cy1ncmlkIj4KICAgIDxkaXYgY2xhc3M9ImVtcHR5LXN0YXRlIj48aDI+Tm8gZnJhbWVzIGNvbm5lY3RlZDwvaDI+PHA+T3BlbiBGcmFtYmUgb24gYSB0YWJsZXQgb3Igc2NyZWVuIHRvIHNlZSBpdCBoZXJlPC9wPjwvZGl2PgogIDwvZGl2PgogIDxzY3JpcHQ+CiAgICB2YXIgd3M9bnVsbCwgY2xpZW50c0RhdGE9e30sIGFsYnVtc0NhY2hlPVtdLCBwZW9wbGVDYWNoZT1bXSwgYXV0aEVuYWJsZWQ9ZmFsc2U7CgogICAgLy8gQ2hlY2sgaWYgYXV0aCBpcyBlbmFibGVkIGFuZCBzaG93L2hpZGUgbG9nb3V0IGJ1dHRvbgogICAgKGFzeW5jIGZ1bmN0aW9uIGNoZWNrQXV0aCgpIHsKICAgICAgdHJ5IHsKICAgICAgICB2YXIgciA9IGF3YWl0IChhd2FpdCBmZXRjaCgnL2FwaS9hdXRoL3N0YXR1cycpKS5qc29uKCk7CiAgICAgICAgYXV0aEVuYWJsZWQgPSByLmF1dGhFbmFibGVkOwogICAgICAgIGlmIChhdXRoRW5hYmxlZCkgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ2xvZ291dC1idG4nKS5zdHlsZS5kaXNwbGF5ID0gJyc7CiAgICAgIH0gY2F0Y2goZSkge30KICAgIH0pKCk7CgogICAgYXN5bmMgZnVuY3Rpb24gZG9Mb2dvdXQoKSB7CiAgICAgIHRyeSB7IGF3YWl0IGZldGNoKCcvYXBpL2F1dGgvbG9nb3V0JywgeyBtZXRob2Q6ICdQT1NUJyB9KTsgfSBjYXRjaChlKSB7fQogICAgICB3aW5kb3cubG9jYXRpb24uaHJlZiA9ICcvYWRtaW4vbG9naW4nOwogICAgfQoKICAgIGZ1bmN0aW9uIGNvbm5lY3QoKSB7CiAgICAgIHZhciBwcm90byA9IGxvY2F0aW9uLnByb3RvY29sPT09J2h0dHBzOic/J3dzczonOid3czonOwogICAgICB3cyA9IG5ldyBXZWJTb2NrZXQocHJvdG8rJy8vJytsb2NhdGlvbi5ob3N0Kycvd3MnKTsKICAgICAgd3Mub25vcGVuID0gZnVuY3Rpb24oKXsgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3dzLXN0YXR1cycpLnRleHRDb250ZW50PSdDb25uZWN0ZWQnOyBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnd3Mtc3RhdHVzJykuY2xhc3NOYW1lPSd3cy1zdGF0dXMgY29ubmVjdGVkJzsgd3Muc2VuZChKU09OLnN0cmluZ2lmeSh7dHlwZToncmVnaXN0ZXInLHJvbGU6J2FkbWluJ30pKTsgbG9hZEFsYnVtc0FuZFBlb3BsZSgpOyB9OwogICAgICB3cy5vbm1lc3NhZ2UgPSBmdW5jdGlvbihlKXsgdmFyIG1zZz1KU09OLnBhcnNlKGUuZGF0YSk7IGlmKG1zZy50eXBlPT09J2NsaWVudExpc3QnKXtjbGllbnRzRGF0YT17fTttc2cuY2xpZW50cy5mb3JFYWNoKGZ1bmN0aW9uKGMpe2NsaWVudHNEYXRhW2MuaWRdPWM7fSk7cmVuZGVyQ2xpZW50cygpO30gZWxzZSBpZihtc2cudHlwZT09PSdjbGllbnRVcGRhdGUnKXtjbGllbnRzRGF0YVttc2cuY2xpZW50SWRdPW1zZy5jbGllbnQ7cmVuZGVyQ2xpZW50cygpO30gfTsKICAgICAgd3Mub25jbG9zZSA9IGZ1bmN0aW9uKCl7IGRvY3VtZW50LmdldEVsZW1lbnRCeUlkKCd3cy1zdGF0dXMnKS50ZXh0Q29udGVudD0nRGlzY29ubmVjdGVkJzsgZG9jdW1lbnQuZ2V0RWxlbWVudEJ5SWQoJ3dzLXN0YXR1cycpLmNsYXNzTmFtZT0nd3Mtc3RhdHVzIGRpc2Nvbm5lY3RlZCc7IHNldFRpbWVvdXQoY29ubmVjdCwzMDAwKTsgfTsKICAgIH0KICAgIGFzeW5jIGZ1bmN0aW9uIGxvYWRBbGJ1bXNBbmRQZW9wbGUoKXsgdHJ5eyB2YXIgYz1hd2FpdChhd2FpdCBmZXRjaCgnL2FwaS9jb25maWcnKSkuanNvbigpOyBkb2N1bWVudC5nZXRFbGVtZW50QnlJZCgndmVyc2lvbi10ZXh0JykudGV4dENvbnRlbnQ9J3YnKyhjLnZlcnNpb258fCc/Jyk7IGFsYnVtc0NhY2hlPWF3YWl0KGF3YWl0IGZldGNoKCcvYXBpL2FsYnVtcycpKS5qc29uKCk7IHBlb3BsZUNhY2hlPWF3YWl0KGF3YWl0IGZldGNoKCcvYXBpL3Blb3BsZScpKS5qc29uKCk7IH1jYXRjaChlKXt9IH0KICAgIGZ1bmN0aW9uIHNlbmRDb21tYW5kKGlkLGFjdGlvbixwYXlsb2FkKXsgaWYod3MmJndzLnJlYWR5U3RhdGU9PT1XZWJTb2NrZXQuT1BFTikgd3Muc2VuZChKU09OLnN0cmluZ2lmeSh7dHlwZTonYWRtaW5Db21tYW5kJyx0YXJnZXRJZDppZCxhY3Rpb246YWN0aW9uLHBheWxvYWQ6cGF5bG9hZHx8e319KSk7IH0KICAgIGZ1bmN0aW9uIHJlbmFtZUNsaWVudChpZCxuYW1lKXsgaWYod3MmJndzLnJlYWR5U3RhdGU9PT1XZWJTb2NrZXQuT1BFTikgd3Muc2VuZChKU09OLnN0cmluZ2lmeSh7dHlwZToncmVuYW1lQ2xpZW50Jyx0YXJnZXRJZDppZCxuYW1lOm5hbWV9KSk7IH0KICAgIGZ1bmN0aW9uIGhhbmRsZVNvdXJjZUNoYW5nZShpZCx2YWwpeyBpZighdmFsKXJldHVybjsgaWYodmFsPT09J3JhbmRvbScpc2VuZENvbW1hbmQoaWQsJ3NldFNvdXJjZScse3NvdXJjZToncmFuZG9tJ30pOyBlbHNlIGlmKHZhbD09PSdmYXZvcml0ZXMnKXNlbmRDb21tYW5kKGlkLCdzZXRTb3VyY2UnLHtzb3VyY2U6J2Zhdm9yaXRlcyd9KTsgZWxzZSBpZih2YWwuc3RhcnRzV2l0aCgnYWxidW06Jykpc2VuZENvbW1hbmQoaWQsJ3NldFNvdXJjZScse3NvdXJjZTonYWxidW0nLGFsYnVtSWQ6dmFsLnN1YnN0cmluZyg2KX0pOyBlbHNlIGlmKHZhbC5zdGFydHNXaXRoKCdwZXJzb246Jykpc2VuZENvbW1hbmQoaWQsJ3NldFNvdXJjZScse3NvdXJjZToncGVyc29uJyxwZXJzb25JZDp2YWwuc3Vic3RyaW5nKDcpfSk7IH0KICAgIGZ1bmN0aW9uIGVzYyhzKXsgdmFyIGQ9ZG9jdW1lbnQuY3JlYXRlRWxlbWVudCgnZGl2Jyk7ZC5hcHBlbmRDaGlsZChkb2N1bWVudC5jcmVhdGVUZXh0Tm9kZShzfHwnJykpO3JldHVybiBkLmlubmVySFRNTDsgfQogICAgZnVuY3Rpb24gcmVuZGVyQ2xpZW50cygpewogICAgICB2YXIgZ3JpZD1kb2N1bWVudC5nZXRFbGVtZW50QnlJZCgnY2xpZW50cy1ncmlkJyksaWRzPU9iamVjdC5rZXlzKGNsaWVudHNEYXRhKTsKICAgICAgaWYoIWlkcy5sZW5ndGgpe2dyaWQuaW5uZXJIVE1MPSc8ZGl2IGNsYXNzPSJlbXB0eS1zdGF0ZSI+PGgyPk5vIGZyYW1lcyBjb25uZWN0ZWQ8L2gyPjxwPk9wZW4gRnJhbWJlIG9uIGEgdGFibGV0IG9yIHNjcmVlbiB0byBzZWUgaXQgaGVyZTwvcD48L2Rpdj4nO3JldHVybjt9CiAgICAgIHZhciBodG1sPScnOwogICAgICBpZHMuZm9yRWFjaChmdW5jdGlvbihpZCl7IHZhciBjPWNsaWVudHNEYXRhW2lkXSxzYz1jLnN0YXR1cz09PSdwbGF5aW5nJz8ncGxheWluZyc6Yy5zdGF0dXM9PT0nc2xlZXBpbmcnPydzbGVlcGluZyc6J29ubGluZScsY2ZnPWMuY29uZmlnfHx7fTsKICAgICAgICBodG1sKz0nPGRpdiBjbGFzcz0iY2xpZW50LWNhcmQiPic7CiAgICAgICAgaHRtbCs9JzxkaXYgY2xhc3M9ImNsaWVudC1oZWFkZXIiPjxkaXY+PGRpdiBjbGFzcz0iY2xpZW50LW5hbWUiPjxzcGFuIGNsYXNzPSJzdGF0dXMtZG90ICcrc2MrJyI+PC9zcGFuPjxpbnB1dCBjbGFzcz0ibmFtZS1pbnB1dCIgdmFsdWU9IicrZXNjKGMubmFtZXx8JycpKyciIHBsYWNlaG9sZGVyPSInK2VzYyhjLmlwKSsnIiBvbmNoYW5nZT0icmVuYW1lQ2xpZW50KFwnJytpZCsnXCcsdGhpcy52YWx1ZSkiLz48L2Rpdj48ZGl2IGNsYXNzPSJjbGllbnQtaXAiPicrZXNjKGMuaXApKyc8L2Rpdj48L2Rpdj48ZGl2IGNsYXNzPSJjbGllbnQtc3RhdHVzIj4nK2VzYyhjLnN0YXR1c3x8J2Nvbm5lY3RlZCcpKyc8L2Rpdj48L2Rpdj4nOwogICAgICAgIGh0bWwrPSc8ZGl2IGNsYXNzPSJjb250cm9scyI+JzsKICAgICAgICBodG1sKz0nPGRpdiBjbGFzcz0iY29udHJvbC1yb3ciPjxzcGFuIGNsYXNzPSJjb250cm9sLWxhYmVsIj5Tb3VyY2U8L3NwYW4+PHNlbGVjdCBvbmNoYW5nZT0iaGFuZGxlU291cmNlQ2hhbmdlKFwnJytpZCsnXCcsdGhpcy52YWx1ZSkiPjxvcHRpb24gdmFsdWU9IiI+LS0gU2VsZWN0IC0tPC9vcHRpb24+PG9wdGlvbiB2YWx1ZT0icmFuZG9tIj5SYW5kb20gUGhvdG9zPC9vcHRpb24+PG9wdGlvbiB2YWx1ZT0iZmF2b3JpdGVzIj5GYXZvcml0ZXM8L29wdGlvbj4nOwogICAgICAgIGFsYnVtc0NhY2hlLmZvckVhY2goZnVuY3Rpb24oYSl7aHRtbCs9JzxvcHRpb24gdmFsdWU9ImFsYnVtOicrYS5pZCsnIj4nK2VzYyhhLmFsYnVtTmFtZSkrJyAoJythLmFzc2V0Q291bnQrJyk8L29wdGlvbj4nO30pOwogICAgICAgIHBlb3BsZUNhY2hlLmZpbHRlcihmdW5jdGlvbihwKXtyZXR1cm4gcC5uYW1lO30pLmZvckVhY2goZnVuY3Rpb24ocCl7aHRtbCs9JzxvcHRpb24gdmFsdWU9InBlcnNvbjonK3AuaWQrJyI+Jytlc2MocC5uYW1lKSsnIChwZXJzb24pPC9vcHRpb24+Jzt9KTsKICAgICAgICBodG1sKz0nPC9zZWxlY3Q+PC9kaXY+PGhyIGNsYXNzPSJkaXZpZGVyIj4nOwogICAgICAgIGh0bWwrPSc8ZGl2IGNsYXNzPSJjb250cm9sLXJvdyI+PHNwYW4gY2xhc3M9ImNvbnRyb2wtbGFiZWwiPlBsYXliYWNrPC9zcGFuPjxidXR0b24gY2xhc3M9ImJ0biBzdWNjZXNzIiBvbmNsaWNrPSJzZW5kQ29tbWFuZChcJycraWQrJ1wnLFwnc3RhcnRcJykiPlN0YXJ0PC9idXR0b24+PGJ1dHRvbiBjbGFzcz0iYnRuIiBvbmNsaWNrPSJzZW5kQ29tbWFuZChcJycraWQrJ1wnLFwnc3RvcFwnKSI+U3RvcDwvYnV0dG9uPjxidXR0b24gY2xhc3M9ImJ0biIgb25jbGljaz0ic2VuZENvbW1hbmQoXCcnK2lkKydcJyxcJ25leHRcJykiPk5leHQ8L2J1dHRvbj48YnV0dG9uIGNsYXNzPSJidG4iIG9uY2xpY2s9InNlbmRDb21tYW5kKFwnJytpZCsnXCcsXCdwcmV2XCcpIj5QcmV2PC9idXR0b24+PC9kaXY+JzsKICAgICAgICBodG1sKz0nPGRpdiBjbGFzcz0iY29udHJvbC1yb3ciPjxzcGFuIGNsYXNzPSJjb250cm9sLWxhYmVsIj5Qb3dlcjwvc3Bhbj48YnV0dG9uIGNsYXNzPSJidG4gZGFuZ2VyIiBvbmNsaWNrPSJzZW5kQ29tbWFuZChcJycraWQrJ1wnLFwnc2xlZXBcJykiPlNsZWVwPC9idXR0b24+PGJ1dHRvbiBjbGFzcz0iYnRuIHN1Y2Nlc3MiIG9uY2xpY2s9InNlbmRDb21tYW5kKFwnJytpZCsnXCcsXCd3YWtlXCcpIj5XYWtlPC9idXR0b24+PGJ1dHRvbiBjbGFzcz0iYnRuIiBvbmNsaWNrPSJzZW5kQ29tbWFuZChcJycraWQrJ1wnLFwncmVmcmVzaFwnKSI+UmVmcmVzaDwvYnV0dG9uPjwvZGl2Pic7CiAgICAgICAgaHRtbCs9JzxociBjbGFzcz0iZGl2aWRlciI+JzsKICAgICAgICBodG1sKz0nPGRpdiBjbGFzcz0iY29udHJvbC1yb3ciPjxzcGFuIGNsYXNzPSJjb250cm9sLWxhYmVsIj5JbnRlcnZhbDwvc3Bhbj48aW5wdXQgdHlwZT0icmFuZ2UiIG1pbj0iNSIgbWF4PSIxMjAiIHZhbHVlPSInKyhjZmcuc2xpZGVzaG93SW50ZXJ2YWx8fDMwKSsnIiBvbmlucHV0PSJ0aGlzLm5leHRFbGVtZW50U2libGluZy50ZXh0Q29udGVudD10aGlzLnZhbHVlK1wnc1wnIiBvbmNoYW5nZT0ic2VuZENvbW1hbmQoXCcnK2lkKydcJyxcJ3NldENvbmZpZ1wnLHtzbGlkZXNob3dJbnRlcnZhbDpwYXJzZUludCh0aGlzLnZhbHVlKX0pIj48c3BhbiBjbGFzcz0icmFuZ2UtdmFsdWUiPicrKGNmZy5zbGlkZXNob3dJbnRlcnZhbHx8MzApKydzPC9zcGFuPjwvZGl2Pic7CiAgICAgICAgaHRtbCs9JzxkaXYgY2xhc3M9ImNvbnRyb2wtcm93Ij48c3BhbiBjbGFzcz0iY29udHJvbC1sYWJlbCI+Q2xvY2s8L3NwYW4+PGxhYmVsIGNsYXNzPSJ0b2dnbGUiPjxpbnB1dCB0eXBlPSJjaGVja2JveCIgJysoY2ZnLnNob3dDbG9jayE9PWZhbHNlPydjaGVja2VkJzonJykrJyBvbmNoYW5nZT0ic2VuZENvbW1hbmQoXCcnK2lkKydcJyxcJ3NldENvbmZpZ1wnLHtzaG93Q2xvY2s6dGhpcy5jaGVja2VkfSkiPjxzcGFuIGNsYXNzPSJ0b2dnbGUtc2xpZGVyIj48L3NwYW4+PC9sYWJlbD48c3BhbiBjbGFzcz0iY29udHJvbC1sYWJlbCI+RGF0ZTwvc3Bhbj48bGFiZWwgY2xhc3M9InRvZ2dsZSI+PGlucHV0IHR5cGU9ImNoZWNrYm94IiAnKyhjZmcuc2hvd0RhdGUhPT1mYWxzZT8nY2hlY2tlZCc6JycpKycgb25jaGFuZ2U9InNlbmRDb21tYW5kKFwnJytpZCsnXCcsXCdzZXRDb25maWdcJyx7c2hvd0RhdGU6dGhpcy5jaGVja2VkfSkiPjxzcGFuIGNsYXNzPSJ0b2dnbGUtc2xpZGVyIj48L3NwYW4+PC9sYWJlbD48L2Rpdj4nOwogICAgICAgIGh0bWwrPSc8ZGl2IGNsYXNzPSJjb250cm9sLXJvdyI+PHNwYW4gY2xhc3M9ImNvbnRyb2wtbGFiZWwiPkVYSUY8L3NwYW4+PGxhYmVsIGNsYXNzPSJ0b2dnbGUiPjxpbnB1dCB0eXBlPSJjaGVja2JveCIgJysoY2ZnLnNob3dFeGlmIT09ZmFsc2U/J2NoZWNrZWQnOicnKSsnIG9uY2hhbmdlPSJzZW5kQ29tbWFuZChcJycraWQrJ1wnLFwnc2V0Q29uZmlnXCcse3Nob3dFeGlmOnRoaXMuY2hlY2tlZH0pIj48c3BhbiBjbGFzcz0idG9nZ2xlLXNsaWRlciI+PC9zcGFuPjwvbGFiZWw+PHNwYW4gY2xhc3M9ImNvbnRyb2wtbGFiZWwiPlByb2dyZXNzPC9zcGFuPjxsYWJlbCBjbGFzcz0idG9nZ2xlIj48aW5wdXQgdHlwZT0iY2hlY2tib3giICcrKGNmZy5zaG93UHJvZ3Jlc3MhPT1mYWxzZT8nY2hlY2tlZCc6JycpKycgb25jaGFuZ2U9InNlbmRDb21tYW5kKFwnJytpZCsnXCcsXCdzZXRDb25maWdcJyx7c2hvd1Byb2dyZXNzOnRoaXMuY2hlY2tlZH0pIj48c3BhbiBjbGFzcz0idG9nZ2xlLXNsaWRlciI+PC9zcGFuPjwvbGFiZWw+PC9kaXY+JzsKICAgICAgICBodG1sKz0nPC9kaXY+PC9kaXY+JzsKICAgICAgfSk7CiAgICAgIGdyaWQuaW5uZXJIVE1MPWh0bWw7CiAgICB9CiAgICBjb25uZWN0KCk7CiAgPC9zY3JpcHQ+CjwvYm9keT4KPC9odG1sPgo= \ No newline at end of file + + + + + + Frambe Admin + + + +
+ Frambe +

Frambe Admin

Connecting...
+
+ Disconnected + +
+
+
+

No frames connected

Open Frambe on a tablet or screen to see it here

+
+ + + From 213a7bd737fcae0d9db2e93df91ec447a04102ec Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 26 May 2026 15:13:59 +1000 Subject: [PATCH 15/30] fix: re-upload server.js with correct encoding --- server.js | 225 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 224 insertions(+), 1 deletion(-) diff --git a/server.js b/server.js index 28e03bd..5604386 100644 --- a/server.js +++ b/server.js @@ -1 +1,224 @@ -Y29uc3QgZXhwcmVzcyA9IHJlcXVpcmUoJ2V4cHJlc3MnKTsKY29uc3QgZmV0Y2ggPSByZXF1aXJlKCdub2RlLWZldGNoJyk7CmNvbnN0IHBhdGggPSByZXF1aXJlKCdwYXRoJyk7CmNvbnN0IGh0dHAgPSByZXF1aXJlKCdodHRwJyk7CmNvbnN0IGNyeXB0byA9IHJlcXVpcmUoJ2NyeXB0bycpOwpjb25zdCB7IFdlYlNvY2tldFNlcnZlciwgV2ViU29ja2V0IH0gPSByZXF1aXJlKCd3cycpOwpyZXF1aXJlKCdkb3RlbnYnKS5jb25maWcoKTsKCmNvbnN0IFZFUlNJT04gPSAnMS40LjAnOwpjb25zdCBhcHAgPSBleHByZXNzKCk7CmNvbnN0IHNlcnZlciA9IGh0dHAuY3JlYXRlU2VydmVyKGFwcCk7CmNvbnN0IFBPUlQgPSBwcm9jZXNzLmVudi5QT1JUIHx8IDMwMDA7CmNvbnN0IElNTUlDSF9VUkwgPSAocHJvY2Vzcy5lbnYuSU1NSUNIX1VSTCB8fCAnaHR0cDovL2xvY2FsaG9zdDoyMjgzJykucmVwbGFjZSgvXC8rJC8sICcnKTsKY29uc3QgQVBJX0tFWSA9IHByb2Nlc3MuZW52LklNTUlDSF9BUElfS0VZIHx8ICcnOwpjb25zdCBTTElERVNIT1dfSU5URVJWQUwgPSBwYXJzZUludChwcm9jZXNzLmVudi5TTElERVNIT1dfSU5URVJWQUwsIDEwKSB8fCAzMDsKY29uc3QgVFJBTlNJVElPTl9EVVJBVElPTiA9IHBhcnNlSW50KHByb2Nlc3MuZW52LlRSQU5TSVRJT05fRFVSQVRJT04sIDEwKSB8fCAyOwpjb25zdCBTSE9XX0NMT0NLID0gcHJvY2Vzcy5lbnYuU0hPV19DTE9DSyAhPT0gJ2ZhbHNlJzsKY29uc3QgU0hPV19EQVRFID0gcHJvY2Vzcy5lbnYuU0hPV19EQVRFICE9PSAnZmFsc2UnOwpjb25zdCBTSE9XX0VYSUYgPSBwcm9jZXNzLmVudi5TSE9XX0VYSUYgIT09ICdmYWxzZSc7CmNvbnN0IFNIT1dfUFJPR1JFU1MgPSBwcm9jZXNzLmVudi5TSE9XX1BST0dSRVNTICE9PSAnZmFsc2UnOwpjb25zdCBJTUFHRV9GSVQgPSBwcm9jZXNzLmVudi5JTUFHRV9GSVQgfHwgJ2NvbnRhaW4nOwpjb25zdCBCQUNLR1JPVU5EX0JMVVIgPSBwcm9jZXNzLmVudi5CQUNLR1JPVU5EX0JMVVIgIT09ICdmYWxzZSc7CmNvbnN0IFNIVUZGTEUgPSBwcm9jZXNzLmVudi5TSFVGRkxFICE9PSAnZmFsc2UnOwpjb25zdCBBTEJVTV9JRCA9IHByb2Nlc3MuZW52LkFMQlVNX0lEIHx8ICcnOwpjb25zdCBTSE9XX0ZBVk9SSVRFU19PTkxZID0gcHJvY2Vzcy5lbnYuU0hPV19GQVZPUklURVNfT05MWSA9PT0gJ3RydWUnOwpjb25zdCBSRUZSRVNIX0lOVEVSVkFMID0gcGFyc2VJbnQocHJvY2Vzcy5lbnYuUkVGUkVTSF9JTlRFUlZBTCwgMTApIHx8IDMwMDsKY29uc3QgSU5DTFVERV9WSURFT1MgPSBwcm9jZXNzLmVudi5JTkNMVURFX1ZJREVPUyAhPT0gJ2ZhbHNlJzsKCi8vIC0tLSBBdXRoIGNvbmZpZ3VyYXRpb24gLS0tCmNvbnN0IEFETUlOX1VTRVJOQU1FID0gcHJvY2Vzcy5lbnYuQURNSU5fVVNFUk5BTUUgfHwgJ2FkbWluJzsKY29uc3QgQURNSU5fUEFTU1dPUkQgPSBwcm9jZXNzLmVudi5BRE1JTl9QQVNTV09SRCB8fCAnJzsKY29uc3QgRlJBTUJFX0FQSV9UT0tFTiA9IHByb2Nlc3MuZW52LkZSQU1CRV9BUElfVE9LRU4gfHwgJyc7CmNvbnN0IEFVVEhfRU5BQkxFRCA9ICEhQURNSU5fUEFTU1dPUkQ7CgovLyBTZXNzaW9uIHN0b3JlOiB0b2tlbiAtPiB7IHVzZXJuYW1lLCBjcmVhdGVkQXQsIGV4cGlyZXNBdCB9CmNvbnN0IHNlc3Npb25zID0gbmV3IE1hcCgpOwpjb25zdCBTRVNTSU9OX1RUTCA9IDI0ICogNjAgKiA2MCAqIDEwMDA7IC8vIDI0IGhvdXJzCgpmdW5jdGlvbiBjcmVhdGVTZXNzaW9uKHVzZXJuYW1lKSB7CiAgY29uc3QgdG9rZW4gPSBjcnlwdG8ucmFuZG9tQnl0ZXMoMzIpLnRvU3RyaW5nKCdoZXgnKTsKICBjb25zdCBub3cgPSBEYXRlLm5vdygpOwogIHNlc3Npb25zLnNldCh0b2tlbiwgeyB1c2VybmFtZSwgY3JlYXRlZEF0OiBub3csIGV4cGlyZXNBdDogbm93ICsgU0VTU0lPTl9UVEwgfSk7CiAgcmV0dXJuIHRva2VuOwp9CgpmdW5jdGlvbiB2YWxpZGF0ZVNlc3Npb24odG9rZW4pIHsKICBpZiAoIXRva2VuKSByZXR1cm4gZmFsc2U7CiAgY29uc3Qgc2Vzc2lvbiA9IHNlc3Npb25zLmdldCh0b2tlbik7CiAgaWYgKCFzZXNzaW9uKSByZXR1cm4gZmFsc2U7CiAgaWYgKERhdGUubm93KCkgPiBzZXNzaW9uLmV4cGlyZXNBdCkgeyBzZXNzaW9ucy5kZWxldGUodG9rZW4pOyByZXR1cm4gZmFsc2U7IH0KICByZXR1cm4gdHJ1ZTsKfQoKZnVuY3Rpb24gY2xlYW51cFNlc3Npb25zKCkgeyBjb25zdCBub3cgPSBEYXRlLm5vdygpOyBzZXNzaW9ucy5mb3JFYWNoKChzLCB0KSA9PiB7IGlmIChub3cgPiBzLmV4cGlyZXNBdCkgc2Vzc2lvbnMuZGVsZXRlKHQpOyB9KTsgfQpzZXRJbnRlcnZhbChjbGVhbnVwU2Vzc2lvbnMsIDYwICogNjAgKiAxMDAwKTsgLy8gY2xlYW51cCBldmVyeSBob3VyCgovLyAtLS0gQWRtaW4gYXV0aCBtaWRkbGV3YXJlIChjb29raWUtYmFzZWQgZm9yIGJyb3dzZXIpIC0tLQpmdW5jdGlvbiByZXF1aXJlQWRtaW5BdXRoKHJlcSwgcmVzLCBuZXh0KSB7CiAgaWYgKCFBVVRIX0VOQUJMRUQpIHJldHVybiBuZXh0KCk7CiAgY29uc3QgY29va2llID0gcmVxLmhlYWRlcnMuY29va2llIHx8ICcnOwogIGNvbnN0IG1hdGNoID0gY29va2llLm1hdGNoKC9mcmFtYmVfc2Vzc2lvbj0oW2EtZjAtOV0rKS8pOwogIGNvbnN0IHRva2VuID0gbWF0Y2ggPyBtYXRjaFsxXSA6IG51bGw7CiAgaWYgKHZhbGlkYXRlU2Vzc2lvbih0b2tlbikpIHJldHVybiBuZXh0KCk7CiAgLy8gTm90IGF1dGhlbnRpY2F0ZWQg4oCUIGlmIHJlcXVlc3RpbmcgSFRNTCwgcmVkaXJlY3QgdG8gbG9naW47IG90aGVyd2lzZSA0MDEKICBpZiAocmVxLmFjY2VwdHMoJ2h0bWwnKSkgcmV0dXJuIHJlcy5yZWRpcmVjdCgnL2FkbWluL2xvZ2luJyk7CiAgcmV0dXJuIHJlcy5zdGF0dXMoNDAxKS5qc29uKHsgZXJyb3I6ICdVbmF1dGhvcml6ZWQnLCBtZXNzYWdlOiAnQWRtaW4gbG9naW4gcmVxdWlyZWQnIH0pOwp9CgovLyAtLS0gQVBJIHRva2VuIG1pZGRsZXdhcmUgKGZvciBleHRlcm5hbCBjYWxsZXJzIGxpa2UgSG9tZSBBc3Npc3RhbnQpIC0tLQpmdW5jdGlvbiByZXF1aXJlQXBpVG9rZW4ocmVxLCByZXMsIG5leHQpIHsKICAvLyBBY2NlcHQgZWl0aGVyOiBCZWFyZXIgdG9rZW4gaW4gQXV0aG9yaXphdGlvbiBoZWFkZXIsIG9yIHgtYXBpLXRva2VuIGhlYWRlciwgb3IgP3Rva2VuPSBxdWVyeSBwYXJhbQogIGNvbnN0IGF1dGhIZWFkZXIgPSByZXEuaGVhZGVycy5hdXRob3JpemF0aW9uIHx8ICcnOwogIGNvbnN0IGJlYXJlclRva2VuID0gYXV0aEhlYWRlci5zdGFydHNXaXRoKCdCZWFyZXIgJykgPyBhdXRoSGVhZGVyLnNsaWNlKDcpIDogJyc7CiAgY29uc3QgaGVhZGVyVG9rZW4gPSByZXEuaGVhZGVyc1sneC1hcGktdG9rZW4nXSB8fCAnJzsKICBjb25zdCBxdWVyeVRva2VuID0gcmVxLnF1ZXJ5LnRva2VuIHx8ICcnOwogIGNvbnN0IHByb3ZpZGVkID0gYmVhcmVyVG9rZW4gfHwgaGVhZGVyVG9rZW4gfHwgcXVlcnlUb2tlbjsKCiAgLy8gSWYgQVBJIHRva2VuIGlzIGNvbmZpZ3VyZWQsIHJlcXVpcmUgaXQKICBpZiAoRlJBTUJFX0FQSV9UT0tFTikgewogICAgaWYgKHByb3ZpZGVkID09PSBGUkFNQkVfQVBJX1RPS0VOKSByZXR1cm4gbmV4dCgpOwogIH0KCiAgLy8gQWxzbyBhY2NlcHQgdmFsaWQgYWRtaW4gc2Vzc2lvbiBjb29raWUKICBpZiAoQVVUSF9FTkFCTEVEKSB7CiAgICBjb25zdCBjb29raWUgPSByZXEuaGVhZGVycy5jb29raWUgfHwgJyc7CiAgICBjb25zdCBtYXRjaCA9IGNvb2tpZS5tYXRjaCgvZnJhbWJlX3Nlc3Npb249KFthLWYwLTldKykvKTsKICAgIGlmIChtYXRjaCAmJiB2YWxpZGF0ZVNlc3Npb24obWF0Y2hbMV0pKSByZXR1cm4gbmV4dCgpOwogIH0KCiAgLy8gSWYgbmVpdGhlciBhdXRoIG1ldGhvZCBpcyBjb25maWd1cmVkLCBhbGxvdyBvcGVuIGFjY2VzcwogIGlmICghRlJBTUJFX0FQSV9UT0tFTiAmJiAhQVVUSF9FTkFCTEVEKSByZXR1cm4gbmV4dCgpOwoKICByZXR1cm4gcmVzLnN0YXR1cyg0MDEpLmpzb24oeyBlcnJvcjogJ1VuYXV0aG9yaXplZCcsIG1lc3NhZ2U6ICdWYWxpZCBBUEkgdG9rZW4gb3IgYWRtaW4gc2Vzc2lvbiByZXF1aXJlZCcgfSk7Cn0KCmZ1bmN0aW9uIGltbWljaEhlYWRlcnMoKSB7IHJldHVybiB7ICd4LWFwaS1rZXknOiBBUElfS0VZLCAnQWNjZXB0JzogJ2FwcGxpY2F0aW9uL2pzb24nLCAnQ29udGVudC1UeXBlJzogJ2FwcGxpY2F0aW9uL2pzb24nIH07IH0KZnVuY3Rpb24gbG9nKG1zZykgeyBjb25zb2xlLmxvZygnW0ZyYW1iZV0gJyArIG1zZyk7IH0KZnVuY3Rpb24gbG9nRXJyKG1zZykgeyBjb25zb2xlLmVycm9yKCdbRnJhbWJlXSBFUlJPUjogJyArIG1zZyk7IH0KCmNvbnN0IGNsaWVudHMgPSBuZXcgTWFwKCk7CmxldCBjbGllbnROYW1lU3RvcmUgPSB7fTsKZnVuY3Rpb24gZ2V0Q2xpZW50SXAocmVxKSB7IHJldHVybiByZXEuaGVhZGVyc1sneC1mb3J3YXJkZWQtZm9yJ10/LnNwbGl0KCcsJylbMF0udHJpbSgpIHx8IHJlcS5zb2NrZXQucmVtb3RlQWRkcmVzcyB8fCAndW5rbm93bic7IH0KZnVuY3Rpb24gZ2VuZXJhdGVDbGllbnRJZChpcCkgeyByZXR1cm4gaXAucmVwbGFjZSgvWy46XS9nLCAnXycpOyB9CmZ1bmN0aW9uIGJyb2FkY2FzdFRvQWRtaW5zKG1zZykgeyBjb25zdCBkID0gSlNPTi5zdHJpbmdpZnkobXNnKTsgY2xpZW50cy5mb3JFYWNoKGMgPT4geyBpZiAoYy5yb2xlID09PSAnYWRtaW4nICYmIGMud3MucmVhZHlTdGF0ZSA9PT0gV2ViU29ja2V0Lk9QRU4pIGMud3Muc2VuZChkKTsgfSk7IH0KZnVuY3Rpb24gZ2V0Q2xpZW50TGlzdCgpIHsgY29uc3QgbGlzdCA9IFtdOyBjbGllbnRzLmZvckVhY2goKGMsIGlkKSA9PiB7IGlmIChjLnJvbGUgPT09ICdmcmFtZScpIGxpc3QucHVzaCh7IGlkLCBpcDogYy5pcCwgbmFtZTogYy5uYW1lIHx8IGNsaWVudE5hbWVTdG9yZVtjLmlwXSB8fCAnJywgc3RhdHVzOiBjLnN0YXR1cyB8fCAndW5rbm93bicsIGNvbm5lY3RlZEF0OiBjLmNvbm5lY3RlZEF0LCBsYXN0U2VlbjogYy5sYXN0U2VlbiwgY29uZmlnOiBjLmNvbmZpZyB8fCB7fSB9KTsgfSk7IHJldHVybiBsaXN0OyB9Cgpjb25zdCB3c3MgPSBuZXcgV2ViU29ja2V0U2VydmVyKHsgc2VydmVyLCBwYXRoOiAnL3dzJyB9KTsKd3NzLm9uKCdjb25uZWN0aW9uJywgKHdzLCByZXEpID0+IHsKICBjb25zdCBpcCA9IGdldENsaWVudElwKHJlcSk7CiAgY29uc3QgY2xpZW50SWQgPSBnZW5lcmF0ZUNsaWVudElkKGlwKSArICdfJyArIERhdGUubm93KCk7CiAgbG9nKCdXZWJTb2NrZXQgY29ubmVjdGVkOiAnICsgaXAgKyAnICgnICsgY2xpZW50SWQgKyAnKScpOwogIGNvbnN0IGluZm8gPSB7IHdzLCBpcCwgcm9sZTogJ2ZyYW1lJywgbmFtZTogY2xpZW50TmFtZVN0b3JlW2lwXSB8fCAnJywgc3RhdHVzOiAnY29ubmVjdGVkJywgY29ubmVjdGVkQXQ6IG5ldyBEYXRlKCkudG9JU09TdHJpbmcoKSwgbGFzdFNlZW46IG5ldyBEYXRlKCkudG9JU09TdHJpbmcoKSwgY29uZmlnOiB7fSB9OwogIGNsaWVudHMuc2V0KGNsaWVudElkLCBpbmZvKTsKICB3cy5zZW5kKEpTT04uc3RyaW5naWZ5KHsgdHlwZTogJ3dlbGNvbWUnLCBjbGllbnRJZCwgbmFtZTogaW5mby5uYW1lIH0pKTsKICB3cy5vbignbWVzc2FnZScsIHJhdyA9PiB7CiAgICB0cnkgewogICAgICBjb25zdCBtc2cgPSBKU09OLnBhcnNlKHJhdyk7IGluZm8ubGFzdFNlZW4gPSBuZXcgRGF0ZSgpLnRvSVNPU3RyaW5nKCk7CiAgICAgIHN3aXRjaCAobXNnLnR5cGUpIHsKICAgICAgICBjYXNlICdyZWdpc3Rlcic6CiAgICAgICAgICBpbmZvLnJvbGUgPSBtc2cucm9sZSB8fCAnZnJhbWUnOwogICAgICAgICAgaWYgKG1zZy5yb2xlID09PSAnYWRtaW4nKSB7IGxvZygnQWRtaW4gY29ubmVjdGVkIGZyb20gJyArIGlwKTsgd3Muc2VuZChKU09OLnN0cmluZ2lmeSh7IHR5cGU6ICdjbGllbnRMaXN0JywgY2xpZW50czogZ2V0Q2xpZW50TGlzdCgpIH0pKTsgfQogICAgICAgICAgZWxzZSB7IGxvZygnRnJhbWUgcmVnaXN0ZXJlZDogJyArIGlwKTsgaW5mby5zdGF0dXMgPSBtc2cuc3RhdHVzIHx8ICdpZGxlJzsgaW5mby5jb25maWcgPSBtc2cuY29uZmlnIHx8IHt9OyBicm9hZGNhc3RUb0FkbWlucyh7IHR5cGU6ICdjbGllbnRMaXN0JywgY2xpZW50czogZ2V0Q2xpZW50TGlzdCgpIH0pOyB9CiAgICAgICAgICBicmVhazsKICAgICAgICBjYXNlICdzdGF0dXMnOgogICAgICAgICAgaW5mby5zdGF0dXMgPSBtc2cuc3RhdHVzIHx8IGluZm8uc3RhdHVzOyBpZiAobXNnLmNvbmZpZykgaW5mby5jb25maWcgPSBtc2cuY29uZmlnOwogICAgICAgICAgYnJvYWRjYXN0VG9BZG1pbnMoeyB0eXBlOiAnY2xpZW50VXBkYXRlJywgY2xpZW50SWQsIGNsaWVudDogeyBpZDogY2xpZW50SWQsIGlwOiBpbmZvLmlwLCBuYW1lOiBpbmZvLm5hbWUsIHN0YXR1czogaW5mby5zdGF0dXMsIGxhc3RTZWVuOiBpbmZvLmxhc3RTZWVuLCBjb25maWc6IGluZm8uY29uZmlnIH0gfSk7CiAgICAgICAgICBicmVhazsKICAgICAgICBjYXNlICdhZG1pbkNvbW1hbmQnOgogICAgICAgICAgY29uc3QgdGFyZ2V0ID0gY2xpZW50cy5nZXQobXNnLnRhcmdldElkKTsKICAgICAgICAgIGlmICh0YXJnZXQgJiYgdGFyZ2V0LndzLnJlYWR5U3RhdGUgPT09IFdlYlNvY2tldC5PUEVOKSB7IHRhcmdldC53cy5zZW5kKEpTT04uc3RyaW5naWZ5KHsgdHlwZTogJ2NvbW1hbmQnLCBhY3Rpb246IG1zZy5hY3Rpb24sIHBheWxvYWQ6IG1zZy5wYXlsb2FkIH0pKTsgbG9nKCdDb21tYW5kICcgKyBtc2cuYWN0aW9uICsgJyAtPiAnICsgbXNnLnRhcmdldElkKTsgfQogICAgICAgICAgZWxzZSB3cy5zZW5kKEpTT04uc3RyaW5naWZ5KHsgdHlwZTogJ2Vycm9yJywgbWVzc2FnZTogJ0NsaWVudCBub3QgZm91bmQnIH0pKTsKICAgICAgICAgIGJyZWFrOwogICAgICAgIGNhc2UgJ3JlbmFtZUNsaWVudCc6CiAgICAgICAgICBjb25zdCBydCA9IGNsaWVudHMuZ2V0KG1zZy50YXJnZXRJZCk7CiAgICAgICAgICBpZiAocnQpIHsgcnQubmFtZSA9IG1zZy5uYW1lOyBjbGllbnROYW1lU3RvcmVbcnQuaXBdID0gbXNnLm5hbWU7IGxvZygnUmVuYW1lZCAnICsgbXNnLnRhcmdldElkICsgJyAtPiAiJyArIG1zZy5uYW1lICsgJyInKTsgYnJvYWRjYXN0VG9BZG1pbnMoeyB0eXBlOiAnY2xpZW50TGlzdCcsIGNsaWVudHM6IGdldENsaWVudExpc3QoKSB9KTsgfQogICAgICAgICAgYnJlYWs7CiAgICAgIH0KICAgIH0gY2F0Y2ggKGUpIHsgbG9nRXJyKCdXUyBwYXJzZSBlcnJvcjogJyArIGUubWVzc2FnZSk7IH0KICB9KTsKICB3cy5vbignY2xvc2UnLCAoKSA9PiB7IGxvZygnV2ViU29ja2V0IGRpc2Nvbm5lY3RlZDogJyArIGlwKTsgY2xpZW50cy5kZWxldGUoY2xpZW50SWQpOyBicm9hZGNhc3RUb0FkbWlucyh7IHR5cGU6ICdjbGllbnRMaXN0JywgY2xpZW50czogZ2V0Q2xpZW50TGlzdCgpIH0pOyB9KTsKfSk7CgphcHAudXNlKCcvYXBpJywgKHJlcSwgX3JlcywgbmV4dCkgPT4geyBsb2coJ0FQSSAnICsgcmVxLm1ldGhvZCArICcgJyArIHJlcS5vcmlnaW5hbFVybCk7IG5leHQoKTsgfSk7CmFwcC51c2UoZXhwcmVzcy5qc29uKCkpOwoKLy8gLS0tIEF1dGggZW5kcG9pbnRzIC0tLQphcHAuZ2V0KCcvYXBpL2F1dGgvc3RhdHVzJywgKF9yZXEsIHJlcykgPT4gewogIHJlcy5qc29uKHsgYXV0aEVuYWJsZWQ6IEFVVEhfRU5BQkxFRCwgYXBpVG9rZW5FbmFibGVkOiAhIUZSQU1CRV9BUElfVE9LRU4gfSk7Cn0pOwoKYXBwLnBvc3QoJy9hcGkvYXV0aC9sb2dpbicsIChyZXEsIHJlcykgPT4gewogIGlmICghQVVUSF9FTkFCTEVEKSByZXR1cm4gcmVzLmpzb24oeyBvazogdHJ1ZSwgbWVzc2FnZTogJ0F1dGggbm90IGVuYWJsZWQnIH0pOwogIGNvbnN0IHsgdXNlcm5hbWUsIHBhc3N3b3JkIH0gPSByZXEuYm9keSB8fCB7fTsKICBpZiAodXNlcm5hbWUgPT09IEFETUlOX1VTRVJOQU1FICYmIHBhc3N3b3JkID09PSBBRE1JTl9QQVNTV09SRCkgewogICAgY29uc3QgdG9rZW4gPSBjcmVhdGVTZXNzaW9uKHVzZXJuYW1lKTsKICAgIHJlcy5zZXRIZWFkZXIoJ1NldC1Db29raWUnLCBgZnJhbWJlX3Nlc3Npb249JHt0b2tlbn07IFBhdGg9LzsgSHR0cE9ubHk7IFNhbWVTaXRlPVN0cmljdDsgTWF4LUFnZT0ke1NFU1NJT05fVFRMIC8gMTAwMH1gKTsKICAgIGxvZygnQWRtaW4gbG9naW46ICcgKyB1c2VybmFtZSk7CiAgICByZXR1cm4gcmVzLmpzb24oeyBvazogdHJ1ZSB9KTsKICB9CiAgbG9nKCdGYWlsZWQgbG9naW4gYXR0ZW1wdDogJyArICh1c2VybmFtZSB8fCAnKGVtcHR5KScpKTsKICByZXR1cm4gcmVzLnN0YXR1cyg0MDEpLmpzb24oeyBvazogZmFsc2UsIGVycm9yOiAnSW52YWxpZCBjcmVkZW50aWFscycgfSk7Cn0pOwoKYXBwLnBvc3QoJy9hcGkvYXV0aC9sb2dvdXQnLCAocmVxLCByZXMpID0+IHsKICBjb25zdCBjb29raWUgPSByZXEuaGVhZGVycy5jb29raWUgfHwgJyc7CiAgY29uc3QgbWF0Y2ggPSBjb29raWUubWF0Y2goL2ZyYW1iZV9zZXNzaW9uPShbYS1mMC05XSspLyk7CiAgaWYgKG1hdGNoKSBzZXNzaW9ucy5kZWxldGUobWF0Y2hbMV0pOwogIHJlcy5zZXRIZWFkZXIoJ1NldC1Db29raWUnLCAnZnJhbWJlX3Nlc3Npb249OyBQYXRoPS87IEh0dHBPbmx5OyBNYXgtQWdlPTAnKTsKICByZXMuanNvbih7IG9rOiB0cnVlIH0pOwp9KTsKCi8vIC0tLSBMb2dpbiBwYWdlIChzZXJ2ZWQgd2l0aG91dCBhdXRoKSAtLS0KYXBwLmdldCgnL2FkbWluL2xvZ2luJywgKF9yZXEsIHJlcykgPT4gewogIGlmICghQVVUSF9FTkFCTEVEKSByZXR1cm4gcmVzLnJlZGlyZWN0KCcvYWRtaW4nKTsKICByZXMuc2VuZEZpbGUocGF0aC5qb2luKF9fZGlybmFtZSwgJ3B1YmxpYycsICdhZG1pbicsICdsb2dpbi5odG1sJykpOwp9KTsKCi8vIC0tLSBTdGF0aWMgZmlsZXMgKG5vbi1hZG1pbiBwYWdlcyBkb24ndCByZXF1aXJlIGF1dGgpIC0tLQphcHAudXNlKGV4cHJlc3Muc3RhdGljKHBhdGguam9pbihfX2Rpcm5hbWUsICdwdWJsaWMnKSwgeyBzZXRIZWFkZXJzOiAocmVzLCBmcCkgPT4geyBpZiAoZnAuZW5kc1dpdGgoJy5odG1sJykgfHwgZnAuZW5kc1dpdGgoJy5qcycpIHx8IGZwLmVuZHNXaXRoKCcuY3NzJykpIHsgcmVzLnNldEhlYWRlcignQ2FjaGUtQ29udHJvbCcsICduby1jYWNoZSwgbm8tc3RvcmUsIG11c3QtcmV2YWxpZGF0ZScpOyByZXMuc2V0SGVhZGVyKCdQcmFnbWEnLCAnbm8tY2FjaGUnKTsgcmVzLnNldEhlYWRlcignRXhwaXJlcycsICcwJyk7IH0gfSB9KSk7CgpmdW5jdGlvbiBtYXBBc3NldChhKSB7IHJldHVybiB7IGlkOiBhLmlkLCB0eXBlOiBhLnR5cGUsIG9yaWdpbmFsRmlsZU5hbWU6IGEub3JpZ2luYWxGaWxlTmFtZSwgZmlsZUNyZWF0ZWRBdDogYS5maWxlQ3JlYXRlZEF0LCBpc0Zhdm9yaXRlOiBhLmlzRmF2b3JpdGUsIGV4aWZJbmZvOiBhLmV4aWZJbmZvID8geyBtYWtlOiBhLmV4aWZJbmZvLm1ha2UsIG1vZGVsOiBhLmV4aWZJbmZvLm1vZGVsLCBjaXR5OiBhLmV4aWZJbmZvLmNpdHksIHN0YXRlOiBhLmV4aWZJbmZvLnN0YXRlLCBjb3VudHJ5OiBhLmV4aWZJbmZvLmNvdW50cnksIGRlc2NyaXB0aW9uOiBhLmV4aWZJbmZvLmRlc2NyaXB0aW9uLCBkYXRlVGltZU9yaWdpbmFsOiBhLmV4aWZJbmZvLmRhdGVUaW1lT3JpZ2luYWwgfSA6IG51bGwgfTsgfQpmdW5jdGlvbiBmaWx0ZXJBc3NldHMoYXNzZXRzKSB7IHJldHVybiBJTkNMVURFX1ZJREVPUyA/IGFzc2V0cy5maWx0ZXIoYSA9PiBhLnR5cGUgPT09ICdJTUFHRScgfHwgYS50eXBlID09PSAnVklERU8nKSA6IGFzc2V0cy5maWx0ZXIoYSA9PiBhLnR5cGUgPT09ICdJTUFHRScpOyB9CgphcHAuZ2V0KCcvYXBpL2NvbmZpZycsIChfcmVxLCByZXMpID0+IHsgcmVzLmpzb24oeyB2ZXJzaW9uOiBWRVJTSU9OLCBzbGlkZXNob3dJbnRlcnZhbDogU0xJREVTSE9XX0lOVEVSVkFMLCB0cmFuc2l0aW9uRHVyYXRpb246IFRSQU5TSVRJT05fRFVSQVRJT04sIHNob3dDbG9jazogU0hPV19DTE9DSywgc2hvd0RhdGU6IFNIT1dfREFURSwgc2hvd0V4aWY6IFNIT1dfRVhJRiwgc2hvd1Byb2dyZXNzOiBTSE9XX1BST0dSRVNTLCBpbWFnZUZpdDogSU1BR0VfRklULCBiYWNrZ3JvdW5kQmx1cjogQkFDS0dST1VORF9CTFVSLCBzaHVmZmxlOiBTSFVGRkxFLCBhbGJ1bUlkOiBBTEJVTV9JRCwgc2hvd0Zhdm9yaXRlc09ubHk6IFNIT1dfRkFWT1JJVEVTX09OTFksIHJlZnJlc2hJbnRlcnZhbDogUkVGUkVTSF9JTlRFUlZBTCwgaW5jbHVkZVZpZGVvczogSU5DTFVERV9WSURFT1MsIGNvbm5lY3RlZDogISFBUElfS0VZLCBhdXRoRW5hYmxlZDogQVVUSF9FTkFCTEVEIH0pOyB9KTsKYXBwLmdldCgnL2FwaS9zZXJ2ZXItaW5mbycsIGFzeW5jIChfcmVxLCByZXMpID0+IHsgdHJ5IHsgY29uc3QgciA9IGF3YWl0IGZldGNoKGAke0lNTUlDSF9VUkx9L2FwaS9zZXJ2ZXIvdmVyc2lvbmAsIHsgaGVhZGVyczogaW1taWNoSGVhZGVycygpIH0pOyBpZiAoIXIub2spIHRocm93IG5ldyBFcnJvcihgSW1taWNoIHJldHVybmVkICR7ci5zdGF0dXN9YCk7IGNvbnN0IHYgPSBhd2FpdCByLmpzb24oKTsgbG9nKCdJbW1pY2ggT0sgdicgKyB2Lm1ham9yICsgJy4nICsgdi5taW5vciArICcuJyArIHYucGF0Y2gpOyByZXMuanNvbih7IG9rOiB0cnVlLCB2ZXJzaW9uOiB2IH0pOyB9IGNhdGNoIChlKSB7IGxvZ0VycignSW1taWNoIGZhaWxlZDogJyArIGUubWVzc2FnZSk7IHJlcy5zdGF0dXMoNTAyKS5qc29uKHsgb2s6IGZhbHNlLCBlcnJvcjogZS5tZXNzYWdlIH0pOyB9IH0pOwphcHAuZ2V0KCcvYXBpL2FsYnVtcycsIGFzeW5jIChfcmVxLCByZXMpID0+IHsgdHJ5IHsgY29uc3QgciA9IGF3YWl0IGZldGNoKGAke0lNTUlDSF9VUkx9L2FwaS9hbGJ1bXNgLCB7IGhlYWRlcnM6IGltbWljaEhlYWRlcnMoKSB9KTsgaWYgKCFyLm9rKSB0aHJvdyBuZXcgRXJyb3IoYCR7ci5zdGF0dXN9YCk7IGNvbnN0IGEgPSBhd2FpdCByLmpzb24oKTsgbG9nKCdMaXN0ZWQgJyArIGEubGVuZ3RoICsgJyBhbGJ1bXMnKTsgcmVzLmpzb24oYS5tYXAoeCA9PiAoeyBpZDogeC5pZCwgYWxidW1OYW1lOiB4LmFsYnVtTmFtZSwgYXNzZXRDb3VudDogeC5hc3NldENvdW50LCBhbGJ1bVRodW1ibmFpbEFzc2V0SWQ6IHguYWxidW1UaHVtYm5haWxBc3NldElkLCB1cGRhdGVkQXQ6IHgudXBkYXRlZEF0IH0pKSk7IH0gY2F0Y2ggKGUpIHsgbG9nRXJyKCdBbGJ1bXM6ICcgKyBlLm1lc3NhZ2UpOyByZXMuc3RhdHVzKDUwMikuanNvbih7IGVycm9yOiBlLm1lc3NhZ2UgfSk7IH0gfSk7CmFwcC5nZXQoJy9hcGkvYWxidW1zLzppZCcsIGFzeW5jIChyZXEsIHJlcykgPT4geyB0cnkgeyBjb25zdCByID0gYXdhaXQgZmV0Y2goYCR7SU1NSUNIX1VSTH0vYXBpL2FsYnVtcy8ke3JlcS5wYXJhbXMuaWR9YCwgeyBoZWFkZXJzOiBpbW1pY2hIZWFkZXJzKCkgfSk7IGlmICghci5vaykgdGhyb3cgbmV3IEVycm9yKGAke3Iuc3RhdHVzfWApOyBjb25zdCBhbCA9IGF3YWl0IHIuanNvbigpOyBjb25zdCBhID0gZmlsdGVyQXNzZXRzKGFsLmFzc2V0cyB8fCBbXSkubWFwKG1hcEFzc2V0KTsgbG9nKCdBbGJ1bSAiJyArIGFsLmFsYnVtTmFtZSArICciOiAnICsgYS5sZW5ndGggKyAnIGFzc2V0cycpOyByZXMuanNvbih7IGlkOiBhbC5pZCwgYWxidW1OYW1lOiBhbC5hbGJ1bU5hbWUsIGFzc2V0Q291bnQ6IGEubGVuZ3RoLCBhc3NldHM6IGEgfSk7IH0gY2F0Y2ggKGUpIHsgbG9nRXJyKCdBbGJ1bTogJyArIGUubWVzc2FnZSk7IHJlcy5zdGF0dXMoNTAyKS5qc29uKHsgZXJyb3I6IGUubWVzc2FnZSB9KTsgfSB9KTsKYXBwLmdldCgnL2FwaS9wZW9wbGUnLCBhc3luYyAoX3JlcSwgcmVzKSA9PiB7IHRyeSB7IGNvbnN0IHIgPSBhd2FpdCBmZXRjaChgJHtJTU1JQ0hfVVJMfS9hcGkvcGVvcGxlYCwgeyBoZWFkZXJzOiBpbW1pY2hIZWFkZXJzKCkgfSk7IGlmICghci5vaykgdGhyb3cgbmV3IEVycm9yKGAke3Iuc3RhdHVzfWApOyBjb25zdCBkID0gYXdhaXQgci5qc29uKCk7IHJlcy5qc29uKChkLnBlb3BsZSB8fCBkIHx8IFtdKS5tYXAocCA9PiAoeyBpZDogcC5pZCwgbmFtZTogcC5uYW1lLCB0aHVtYm5haWxQYXRoOiBwLnRodW1ibmFpbFBhdGggfSkpKTsgfSBjYXRjaCAoZSkgeyByZXMuc3RhdHVzKDUwMikuanNvbih7IGVycm9yOiBlLm1lc3NhZ2UgfSk7IH0gfSk7CmFwcC5nZXQoJy9hcGkvcGVvcGxlLzppZCcsIGFzeW5jIChyZXEsIHJlcykgPT4geyB0cnkgeyBjb25zdCByID0gYXdhaXQgZmV0Y2goYCR7SU1NSUNIX1VSTH0vYXBpL3Blb3BsZS8ke3JlcS5wYXJhbXMuaWR9L2Fzc2V0c2AsIHsgaGVhZGVyczogaW1taWNoSGVhZGVycygpIH0pOyBpZiAoIXIub2spIHRocm93IG5ldyBFcnJvcihgJHtyLnN0YXR1c31gKTsgY29uc3QgcmF3ID0gYXdhaXQgci5qc29uKCk7IHJlcy5qc29uKGZpbHRlckFzc2V0cyhBcnJheS5pc0FycmF5KHJhdykgPyByYXcgOiBbXSkubWFwKG1hcEFzc2V0KSk7IH0gY2F0Y2ggKGUpIHsgcmVzLnN0YXR1cyg1MDIpLmpzb24oeyBlcnJvcjogZS5tZXNzYWdlIH0pOyB9IH0pOwphcHAuZ2V0KCcvYXBpL3Blb3BsZS86aWQvdGh1bWJuYWlsJywgYXN5bmMgKHJlcSwgcmVzKSA9PiB7IHRyeSB7IGNvbnN0IHIgPSBhd2FpdCBmZXRjaChgJHtJTU1JQ0hfVVJMfS9hcGkvcGVvcGxlLyR7cmVxLnBhcmFtcy5pZH0vdGh1bWJuYWlsYCwgeyBoZWFkZXJzOiB7ICd4LWFwaS1rZXknOiBBUElfS0VZIH0gfSk7IGlmICghci5vaykgdGhyb3cgbmV3IEVycm9yKGAke3Iuc3RhdHVzfWApOyByZXMuc2V0KCdDb250ZW50LVR5cGUnLCByLmhlYWRlcnMuZ2V0KCdjb250ZW50LXR5cGUnKSB8fCAnaW1hZ2UvanBlZycpOyByZXMuc2V0KCdDYWNoZS1Db250cm9sJywgJ3B1YmxpYywgbWF4LWFnZT04NjQwMCcpOyByLmJvZHkucGlwZShyZXMpOyB9IGNhdGNoIChlKSB7IHJlcy5zdGF0dXMoNTAyKS5qc29uKHsgZXJyb3I6IGUubWVzc2FnZSB9KTsgfSB9KTsKYXBwLmdldCgnL2FwaS9hc3NldHMvcmFuZG9tJywgYXN5bmMgKHJlcSwgcmVzKSA9PiB7IHRyeSB7IGNvbnN0IGMgPSBNYXRoLm1pbihwYXJzZUludChyZXEucXVlcnkuY291bnQsIDEwKSB8fCA1MCwgMjUwKTsgY29uc3QgciA9IGF3YWl0IGZldGNoKGAke0lNTUlDSF9VUkx9L2FwaS9hc3NldHMvcmFuZG9tP2NvdW50PSR7Y31gLCB7IGhlYWRlcnM6IGltbWljaEhlYWRlcnMoKSB9KTsgaWYgKCFyLm9rKSB0aHJvdyBuZXcgRXJyb3IoYCR7ci5zdGF0dXN9YCk7IHJlcy5qc29uKGZpbHRlckFzc2V0cyhhd2FpdCByLmpzb24oKSkubWFwKG1hcEFzc2V0KSk7IH0gY2F0Y2ggKGUpIHsgcmVzLnN0YXR1cyg1MDIpLmpzb24oeyBlcnJvcjogZS5tZXNzYWdlIH0pOyB9IH0pOwphcHAuZ2V0KCcvYXBpL2Fzc2V0cy9mYXZvcml0ZXMnLCBhc3luYyAoX3JlcSwgcmVzKSA9PiB7IHRyeSB7IGNvbnN0IHIgPSBhd2FpdCBmZXRjaChgJHtJTU1JQ0hfVVJMfS9hcGkvc2VhcmNoL21ldGFkYXRhYCwgeyBtZXRob2Q6ICdQT1NUJywgaGVhZGVyczogaW1taWNoSGVhZGVycygpLCBib2R5OiBKU09OLnN0cmluZ2lmeSh7IGlzRmF2b3JpdGU6IHRydWUsIHNpemU6IDI1MCwgcGFnZTogMSB9KSB9KTsgaWYgKCFyLm9rKSB0aHJvdyBuZXcgRXJyb3IoYCR7ci5zdGF0dXN9YCk7IGNvbnN0IGQgPSBhd2FpdCByLmpzb24oKTsgcmVzLmpzb24oZmlsdGVyQXNzZXRzKGQuYXNzZXRzPy5pdGVtcyB8fCBbXSkubWFwKGEgPT4gKHsgLi4ubWFwQXNzZXQoYSksIGlzRmF2b3JpdGU6IHRydWUgfSkpKTsgfSBjYXRjaCAoZSkgeyByZXMuc3RhdHVzKDUwMikuanNvbih7IGVycm9yOiBlLm1lc3NhZ2UgfSk7IH0gfSk7CmFwcC5nZXQoJy9hcGkvYXNzZXRzLzppZC90aHVtYm5haWwnLCBhc3luYyAocmVxLCByZXMpID0+IHsgdHJ5IHsgY29uc3QgciA9IGF3YWl0IGZldGNoKGAke0lNTUlDSF9VUkx9L2FwaS9hc3NldHMvJHtyZXEucGFyYW1zLmlkfS90aHVtYm5haWw/c2l6ZT0ke3JlcS5xdWVyeS5zaXplIHx8ICdwcmV2aWV3J31gLCB7IGhlYWRlcnM6IHsgJ3gtYXBpLWtleSc6IEFQSV9LRVkgfSB9KTsgaWYgKCFyLm9rKSB0aHJvdyBuZXcgRXJyb3IoYCR7ci5zdGF0dXN9YCk7IHJlcy5zZXQoJ0NvbnRlbnQtVHlwZScsIHIuaGVhZGVycy5nZXQoJ2NvbnRlbnQtdHlwZScpIHx8ICdpbWFnZS9qcGVnJyk7IHJlcy5zZXQoJ0NhY2hlLUNvbnRyb2wnLCAncHVibGljLCBtYXgtYWdlPTg2NDAwJyk7IHIuYm9keS5waXBlKHJlcyk7IH0gY2F0Y2ggKGUpIHsgcmVzLnN0YXR1cyg1MDIpLmpzb24oeyBlcnJvcjogZS5tZXNzYWdlIH0pOyB9IH0pOwphcHAuZ2V0KCcvYXBpL2Fzc2V0cy86aWQvdmlkZW8nLCBhc3luYyAocmVxLCByZXMpID0+IHsgdHJ5IHsgY29uc3QgciA9IGF3YWl0IGZldGNoKGAke0lNTUlDSF9VUkx9L2FwaS9hc3NldHMvJHtyZXEucGFyYW1zLmlkfS92aWRlby9wbGF5YmFja2AsIHsgaGVhZGVyczogeyAneC1hcGkta2V5JzogQVBJX0tFWSB9IH0pOyBpZiAoIXIub2spIHRocm93IG5ldyBFcnJvcihgJHtyLnN0YXR1c31gKTsgcmVzLnNldCgnQ29udGVudC1UeXBlJywgci5oZWFkZXJzLmdldCgnY29udGVudC10eXBlJykgfHwgJ3ZpZGVvL21wNCcpOyByZXMuc2V0KCdDYWNoZS1Db250cm9sJywgJ3B1YmxpYywgbWF4LWFnZT04NjQwMCcpOyBjb25zdCBjbCA9IHIuaGVhZGVycy5nZXQoJ2NvbnRlbnQtbGVuZ3RoJyk7IGlmIChjbCkgcmVzLnNldCgnQ29udGVudC1MZW5ndGgnLCBjbCk7IHIuYm9keS5waXBlKHJlcyk7IH0gY2F0Y2ggKGUpIHsgcmVzLnN0YXR1cyg1MDIpLmpzb24oeyBlcnJvcjogZS5tZXNzYWdlIH0pOyB9IH0pOwphcHAuZ2V0KCcvYXBpL2Fzc2V0cy86aWQvb3JpZ2luYWwnLCBhc3luYyAocmVxLCByZXMpID0+IHsgdHJ5IHsgY29uc3QgciA9IGF3YWl0IGZldGNoKGAke0lNTUlDSF9VUkx9L2FwaS9hc3NldHMvJHtyZXEucGFyYW1zLmlkfS9vcmlnaW5hbGAsIHsgaGVhZGVyczogeyAneC1hcGkta2V5JzogQVBJX0tFWSB9IH0pOyBpZiAoIXIub2spIHRocm93IG5ldyBFcnJvcihgJHtyLnN0YXR1c31gKTsgcmVzLnNldCgnQ29udGVudC1UeXBlJywgci5oZWFkZXJzLmdldCgnY29udGVudC10eXBlJykgfHwgJ2ltYWdlL2pwZWcnKTsgcmVzLnNldCgnQ2FjaGUtQ29udHJvbCcsICdwdWJsaWMsIG1heC1hZ2U9ODY0MDAnKTsgci5ib2R5LnBpcGUocmVzKTsgfSBjYXRjaCAoZSkgeyByZXMuc3RhdHVzKDUwMikuanNvbih7IGVycm9yOiBlLm1lc3NhZ2UgfSk7IH0gfSk7CgovLyAtLS0gUkVTVCBBUEk6IENsaWVudCBtYW5hZ2VtZW50ICh0b2tlbi1hdXRoZW50aWNhdGVkIGZvciBIb21lIEFzc2lzdGFudCBldGMuKSAtLS0KYXBwLmdldCgnL2FwaS9jbGllbnRzJywgcmVxdWlyZUFwaVRva2VuLCAoX3JlcSwgcmVzKSA9PiB7CiAgcmVzLmpzb24oeyBvazogdHJ1ZSwgY2xpZW50czogZ2V0Q2xpZW50TGlzdCgpIH0pOwp9KTsKCmFwcC5wb3N0KCcvYXBpL2NsaWVudHMvOmlkL2NvbW1hbmQnLCByZXF1aXJlQXBpVG9rZW4sIChyZXEsIHJlcykgPT4gewogIGNvbnN0IHsgaWQgfSA9IHJlcS5wYXJhbXM7CiAgY29uc3QgeyBhY3Rpb24sIHBheWxvYWQgfSA9IHJlcS5ib2R5IHx8IHt9OwoKICBpZiAoIWFjdGlvbikgcmV0dXJuIHJlcy5zdGF0dXMoNDAwKS5qc29uKHsgb2s6IGZhbHNlLCBlcnJvcjogJ01pc3NpbmcgYWN0aW9uJyB9KTsKCiAgY29uc3QgdGFyZ2V0ID0gY2xpZW50cy5nZXQoaWQpOwogIGlmICghdGFyZ2V0KSByZXR1cm4gcmVzLnN0YXR1cyg0MDQpLmpzb24oeyBvazogZmFsc2UsIGVycm9yOiAnQ2xpZW50IG5vdCBmb3VuZCcgfSk7CiAgaWYgKHRhcmdldC5yb2xlICE9PSAnZnJhbWUnKSByZXR1cm4gcmVzLnN0YXR1cyg0MDApLmpzb24oeyBvazogZmFsc2UsIGVycm9yOiAnVGFyZ2V0IGlzIG5vdCBhIGZyYW1lIGNsaWVudCcgfSk7CiAgaWYgKHRhcmdldC53cy5yZWFkeVN0YXRlICE9PSBXZWJTb2NrZXQuT1BFTikgcmV0dXJuIHJlcy5zdGF0dXMoNDEwKS5qc29uKHsgb2s6IGZhbHNlLCBlcnJvcjogJ0NsaWVudCBXZWJTb2NrZXQgbm90IGNvbm5lY3RlZCcgfSk7CgogIHRhcmdldC53cy5zZW5kKEpTT04uc3RyaW5naWZ5KHsgdHlwZTogJ2NvbW1hbmQnLCBhY3Rpb24sIHBheWxvYWQ6IHBheWxvYWQgfHwge30gfSkpOwogIGxvZygnUkVTVCBjb21tYW5kICcgKyBhY3Rpb24gKyAnIC0+ICcgKyBpZCk7CiAgcmVzLmpzb24oeyBvazogdHJ1ZSwgYWN0aW9uLCB0YXJnZXRJZDogaWQgfSk7Cn0pOwoKLy8gLS0tIEFkbWluIGRhc2hib2FyZCAoYXV0aC1wcm90ZWN0ZWQpIC0tLQphcHAuZ2V0KCcvYWRtaW4nLCByZXF1aXJlQWRtaW5BdXRoLCAoX3JlcSwgcmVzKSA9PiB7IHJlcy5zZW5kRmlsZShwYXRoLmpvaW4oX19kaXJuYW1lLCAncHVibGljJywgJ2FkbWluJywgJ2luZGV4Lmh0bWwnKSk7IH0pOwoKLy8gLS0tIENhdGNoLWFsbCBmb3IgZnJhbWUgU1BBIC0tLQphcHAuZ2V0KCcqJywgKF9yZXEsIHJlcykgPT4geyByZXMuc2VuZEZpbGUocGF0aC5qb2luKF9fZGlybmFtZSwgJ3B1YmxpYycsICdpbmRleC5odG1sJykpOyB9KTsKCnNlcnZlci5saXN0ZW4oUE9SVCwgJzAuMC4wLjAnLCAoKSA9PiB7CiAgbG9nKCctLS0gRnJhbWJlIHYnICsgVkVSU0lPTiArICcgLS0tJyk7CiAgbG9nKCdTZXJ2ZXIgbGlzdGVuaW5nIG9uIHBvcnQgJyArIFBPUlQpOwogIGxvZygnQWRtaW4gZGFzaGJvYXJkOiBodHRwOi8vMC4wLjAuMDonICsgUE9SVCArICcvYWRtaW4nKTsKICBsb2coJ1dlYlNvY2tldDogd3M6Ly8wLjAuMC4wOicgKyBQT1JUICsgJy93cycpOwogIGxvZygnSW1taWNoIFVSTDogJyArIElNTUlDSF9VUkwpOwogIGxvZygnQVBJIGtleTogJyArIChBUElfS0VZID8gJ2NvbmZpZ3VyZWQgKCcgKyBBUElfS0VZLnN1YnN0cmluZygwLCA4KSArICcuLi4pJyA6ICdOT1QgU0VUJykpOwogIGxvZygnQWRtaW4gYXV0aDogJyArIChBVVRIX0VOQUJMRUQgPyAnRU5BQkxFRCAodXNlcjogJyArIEFETUlOX1VTRVJOQU1FICsgJyknIDogJ0RJU0FCTEVEIChubyBBRE1JTl9QQVNTV09SRCBzZXQpJykpOwogIGxvZygnQVBJIHRva2VuOiAnICsgKEZSQU1CRV9BUElfVE9LRU4gPyAnY29uZmlndXJlZCAoJyArIEZSQU1CRV9BUElfVE9LRU4uc3Vic3RyaW5nKDAsIDgpICsgJy4uLiknIDogJ05PVCBTRVQgKFJFU1QgQVBJIG9wZW4pJykpOwogIGxvZygnU2xpZGVzaG93OiAnICsgU0xJREVTSE9XX0lOVEVSVkFMICsgJ3MgaW50ZXJ2YWwsIHJlZnJlc2ggZXZlcnkgJyArIFJFRlJFU0hfSU5URVJWQUwgKyAncycpOwogIGxvZygnVmlkZW9zOiAnICsgKElOQ0xVREVfVklERU9TID8gJ2VuYWJsZWQnIDogJ2Rpc2FibGVkJykpOwogIGxvZygnV2FpdGluZyBmb3IgY29ubmVjdGlvbnMuLi4nKTsKfSk7Cg== \ No newline at end of file +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...'); +}); From 39aff8c839d52d200bf7516f63e5b9ee228490e1 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 26 May 2026 15:15:59 +1000 Subject: [PATCH 16/30] fix: re-upload README with correct encoding --- README.md | 204 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 203 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fe58dc4..72aeb79 100644 --- a/README.md +++ b/README.md @@ -1 +1,203 @@ -IyBGcmFtYmUKCjxwIGFsaWduPSJjZW50ZXIiPgogIDxpbWcgc3JjPSJwdWJsaWMvaW1nL2ljb24ucG5nIiBhbHQ9IkZyYW1iZSIgd2lkdGg9IjE4MCI+CjwvcD4KCkEgbGlnaHR3ZWlnaHQsIHNlbGYtY29udGFpbmVkIERvY2tlciB3ZWIgYXBwbGljYXRpb24gdGhhdCBjb25uZWN0cyB0byB5b3VyIFtJbW1pY2hdKGh0dHBzOi8vaW1taWNoLmFwcC8pIHNlcnZlciBhbmQgZGlzcGxheXMgcGhvdG9zIGluIGEgYmVhdXRpZnVsIGZ1bGwtc2NyZWVuIHNsaWRlc2hvdyDigJQgcGVyZmVjdCBmb3IgdHVybmluZyBvbGQgdGFibGV0cywgc3BhcmUgc2NyZWVucywgYW5kIFJhc3BiZXJyeSBQaXMgaW50byBkaWdpdGFsIHBob3RvIGZyYW1lcy4KCiMjIOKcqCBGZWF0dXJlcwoKLSAqKkltbWljaCBBUEkgSW50ZWdyYXRpb24qKiDigJQgQ29ubmVjdHMgc2VjdXJlbHkgdmlhIEFQSSBrZXkgKGtlcHQgc2VydmVyLXNpZGUpCi0gKipBbGJ1bSBCcm93c2VyKiog4oCUIFNlbGVjdCBhbnkgYWxidW0sIHJhbmRvbSBwaG90b3MsIG9yIGZhdm9yaXRlcyBvbmx5Ci0gKipQZXJzb24gLyBGYWNlIFN1cHBvcnQqKiDigJQgRGlzcGxheSBwaG90b3Mgb2YgYSBzcGVjaWZpYyBwZXJzb24gdmlhIEltbWljaCdzIGZhY2UgcmVjb2duaXRpb24KLSAqKkFkbWluIERhc2hib2FyZCoqIOKAlCBSZWFsLXRpbWUgV2ViU29ja2V0LWJhc2VkIGNvbnRyb2wgcGFuZWwgZm9yIGFsbCBjb25uZWN0ZWQgZnJhbWVzCi0gKipBZG1pbiBBdXRoZW50aWNhdGlvbioqIOKAlCBPcHRpb25hbCB1c2VybmFtZS9wYXNzd29yZCBsb2dpbiB0byBwcm90ZWN0IHRoZSBhZG1pbiBkYXNoYm9hcmQKLSAqKlJFU1QgQVBJIHdpdGggVG9rZW4gQXV0aCoqIOKAlCBDb250cm9sIGZyYW1lcyBmcm9tIEhvbWUgQXNzaXN0YW50LCBzY3JpcHRzLCBvciBleHRlcm5hbCB0b29scwotICoqVVJMLUJhc2VkIFplcm8tVG91Y2ggTGF1bmNoKiog4oCUIFNraXAgdGhlIHNldHVwIHNjcmVlbiBlbnRpcmVseSB3aXRoIHF1ZXJ5IHBhcmFtZXRlcnMKLSAqKkF1dG8tUmVmcmVzaCoqIOKAlCBQZXJpb2RpY2FsbHkgY2hlY2tzIGZvciBuZXcgcGhvdG9zIGFkZGVkIHRvIHRoZSBzb3VyY2UgYWxidW0vcGVyc29uCi0gKipTbW9vdGggQ3Jvc3NmYWRlKiog4oCUIERvdWJsZS1idWZmZXJlZCBpbWFnZSB0cmFuc2l0aW9ucyB3aXRoIGNvbmZpZ3VyYWJsZSBkdXJhdGlvbgotICoqQmFja2dyb3VuZCBCbHVyKiog4oCUIEJsdXJyZWQgYmFja2Ryb3AgZmlsbHMgdGhlIHNwYWNlIGJlaGluZCBub24tY292ZXJpbmcgaW1hZ2VzCi0gKipDbG9jayAmIERhdGUgT3ZlcmxheSoqIOKAlCBBbHdheXMga25vdyB0aGUgdGltZSBhdCBhIGdsYW5jZQotICoqRVhJRiBJbmZvKiog4oCUIFNob3dzIHBob3RvIGxvY2F0aW9uLCBkYXRlLCBhbmQgY2FtZXJhIGluZm8KLSAqKlByb2dyZXNzIEJhcioqIOKAlCBTdWJ0bGUgaW5kaWNhdG9yIG9mIHRpbWUgdW50aWwgbmV4dCBwaG90bwotICoqVG91Y2ggQ29udHJvbHMqKiDigJQgVGFwIGxlZnQvcmlnaHQgZWRnZXMgdG8gbmF2aWdhdGUsIGNlbnRyZSB0byB0b2dnbGUgb3ZlcmxheQotICoqS2V5Ym9hcmQgQ29udHJvbHMqKiDigJQgQXJyb3cga2V5cywgU3BhY2UsIEYgKGZ1bGxzY3JlZW4pLCBJIChpbmZvKSwgRXNjIChleGl0KQotICoqU2NyZWVuIFdha2UgTG9jayoqIOKAlCBQcmV2ZW50cyBzY3JlZW4gc2xlZXAgb24gc3VwcG9ydGVkIGRldmljZXMKLSAqKlJlc3BvbnNpdmUqKiDigJQgV29ya3Mgb24gYW55IHNjcmVlbiBzaXplIGZyb20gcGhvbmUgdG8gVFYKLSAqKk9sZGVyIERldmljZSBGcmllbmRseSoqIOKAlCBWYW5pbGxhIEhUTUwvQ1NTL0pTLCBubyBoZWF2eSBmcmFtZXdvcmtzCi0gKipEb2NrZXIgQ29udGFpbmVyaXNlZCoqIOKAlCBTaW5nbGUgY29udGFpbmVyLCBtaW5pbWFsIGZvb3RwcmludAoKIyMg8J+agCBRdWljayBTdGFydAoKIyMjIDEuIEdldCB5b3VyIEltbWljaCBBUEkgS2V5CgoxLiBPcGVuIHlvdXIgSW1taWNoIHdlYiBpbnRlcmZhY2UKMi4gQ2xpY2sgeW91ciBwcm9maWxlIHBpY3R1cmUg4oaSICoqQWNjb3VudCBTZXR0aW5ncyoqIOKGkiAqKkFQSSBLZXlzKioKMy4gQ3JlYXRlIGEgbmV3IGtleSB3aXRoIGBhc3NldC5yZWFkYCBhbmQgYGFsYnVtLnJlYWRgIHBlcm1pc3Npb25zCgojIyMgMi4gUnVuIHdpdGggRG9ja2VyIENvbXBvc2UKCmBgYGJhc2gKZ2l0IGNsb25lIGh0dHBzOi8vZ2l0ZWEuaGlkZWF3YXlnYW1pbmcuY29tLmF1L2plc3Npa2l0dHkvZnJhbWJlLmdpdApjZCBmcmFtYmUKYGBgCgpFZGl0IGBkb2NrZXItY29tcG9zZS55bWxgIGFuZCBzZXQgeW91ciBgSU1NSUNIX1VSTGAgYW5kIGBJTU1JQ0hfQVBJX0tFWWAsIHRoZW46CgpgYGBiYXNoCmRvY2tlciBjb21wb3NlIHVwIC1kCmBgYAoKT3BlbiBgaHR0cDovL3lvdXItc2VydmVyOjMwMzBgIGluIGEgYnJvd3NlciBvbiB5b3VyIHRhYmxldC9zY3JlZW4uCgojIyMgMy4gUnVuIHdpdGggRG9ja2VyIGRpcmVjdGx5CgpgYGBiYXNoCmRvY2tlciBidWlsZCAtdCBmcmFtYmUgLgpkb2NrZXIgcnVuIC1kIFwKICAtLW5hbWUgZnJhbWJlIFwKICAtcCAzMDMwOjMwMDAgXAogIC1lIElNTUlDSF9VUkw9aHR0cDovL3lvdXItaW1taWNoLXNlcnZlcjoyMjgzIFwKICAtZSBJTU1JQ0hfQVBJX0tFWT15b3VyLWFwaS1rZXkgXAogIC0tcmVzdGFydCB1bmxlc3Mtc3RvcHBlZCBcCiAgZnJhbWJlCmBgYAoKIyMg8J+UkSBBdXRoZW50aWNhdGlvbgoKIyMjIEFkbWluIERhc2hib2FyZCBMb2dpbgoKUHJvdGVjdCB0aGUgYWRtaW4gZGFzaGJvYXJkIHdpdGggYSB1c2VybmFtZSBhbmQgcGFzc3dvcmQ6CgpgYGB5YW1sCmVudmlyb25tZW50OgogIC0gQURNSU5fVVNFUk5BTUU9YWRtaW4KICAtIEFETUlOX1BBU1NXT1JEPXlvdXItc2VjdXJlLXBhc3N3b3JkCmBgYAoKV2hlbiBgQURNSU5fUEFTU1dPUkRgIGlzIHNldCwgYWNjZXNzaW5nIGAvYWRtaW5gIHJlcXVpcmVzIHNpZ25pbmcgaW4uIFdoZW4gbm90IHNldCwgdGhlIGRhc2hib2FyZCBpcyBvcGVuICh1c2VmdWwgZm9yIHRydXN0ZWQgbG9jYWwgbmV0d29ya3MpLgoKIyMjIEFQSSBUb2tlbiBmb3IgRXh0ZXJuYWwgQWNjZXNzCgpFbmFibGUgdG9rZW4tYXV0aGVudGljYXRlZCBSRVNUIEFQSSBhY2Nlc3MgZm9yIEhvbWUgQXNzaXN0YW50LCBzY3JpcHRzLCBvciBvdGhlciBleHRlcm5hbCB0b29sczoKCmBgYHlhbWwKZW52aXJvbm1lbnQ6CiAgLSBGUkFNQkVfQVBJX1RPS0VOPXlvdXItc2VjcmV0LXRva2VuLWhlcmUKYGBgCgojIyDwn5SMIFJFU1QgQVBJCgpXaGVuIGBGUkFNQkVfQVBJX1RPS0VOYCBpcyBjb25maWd1cmVkLCB0aGUgZm9sbG93aW5nIGVuZHBvaW50cyBhcmUgYXZhaWxhYmxlOgoKIyMjIExpc3QgQ29ubmVjdGVkIEZyYW1lcwoKYGBgCkdFVCAvYXBpL2NsaWVudHMKQXV0aG9yaXphdGlvbjogQmVhcmVyIHlvdXItc2VjcmV0LXRva2VuLWhlcmUKYGBgCgpSZXR1cm5zIGFsbCBjb25uZWN0ZWQgZnJhbWUgY2xpZW50cyB3aXRoIHRoZWlyIHN0YXR1cywgSVAsIG5hbWUsIGFuZCBjb25maWcuCgojIyMgU2VuZCBDb21tYW5kIHRvIGEgRnJhbWUKCmBgYApQT1NUIC9hcGkvY2xpZW50cy86aWQvY29tbWFuZApBdXRob3JpemF0aW9uOiBCZWFyZXIgeW91ci1zZWNyZXQtdG9rZW4taGVyZQpDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL2pzb24KCnsKICAiYWN0aW9uIjogIm5leHQiLAogICJwYXlsb2FkIjoge30KfQpgYGAKCkF2YWlsYWJsZSBhY3Rpb25zOiBgc3RhcnRgLCBgc3RvcGAsIGBuZXh0YCwgYHByZXZgLCBgc2xlZXBgLCBgd2FrZWAsIGByZWZyZXNoYCwgYHNldFNvdXJjZWAsIGBzZXRDb25maWdgCgojIyMgSG9tZSBBc3Npc3RhbnQgRXhhbXBsZQoKYGBgeWFtbApyZXN0X2NvbW1hbmQ6CiAgZnJhbWJlX25leHRfcGhvdG86CiAgICB1cmw6ICJodHRwOi8vZnJhbWJlLXNlcnZlcjozMDMwL2FwaS9jbGllbnRzL3t7IGNsaWVudF9pZCB9fS9jb21tYW5kIgogICAgbWV0aG9kOiBQT1NUCiAgICBoZWFkZXJzOgogICAgICBBdXRob3JpemF0aW9uOiAiQmVhcmVyIHlvdXItc2VjcmV0LXRva2VuLWhlcmUiCiAgICAgIENvbnRlbnQtVHlwZTogImFwcGxpY2F0aW9uL2pzb24iCiAgICBwYXlsb2FkOiAneyJhY3Rpb24iOiAibmV4dCJ9JwpgYGAKCkF1dGhlbnRpY2F0aW9uIGNhbiBhbHNvIGJlIHByb3ZpZGVkIHZpYSBgeC1hcGktdG9rZW5gIGhlYWRlciBvciBgP3Rva2VuPWAgcXVlcnkgcGFyYW1ldGVyLgoKIyMg8J+UlyBaZXJvLVRvdWNoIFVSTCBQYXJhbWV0ZXJzCgpTa2lwIHRoZSBzZXR1cCBzY3JlZW4gZW50aXJlbHkgYnkgcGFzc2luZyBxdWVyeSBwYXJhbWV0ZXJzLiBUaGlzIGlzIGlkZWFsIGZvciBkZWRpY2F0ZWQgZnJhbWVzIOKAlCBqdXN0IGJvb2ttYXJrIHRoZSBVUkwgb24gZWFjaCB0YWJsZXQ6Cgp8IFVSTCB8IFdoYXQgaXQgc2hvd3MgfAp8LS0tfC0tLXwKfCBgaHR0cDovL3NlcnZlcjozMDMwLz9hbGJ1bT1BTEJVTV9VVUlEYCB8IFBob3RvcyBmcm9tIGEgc3BlY2lmaWMgYWxidW0gfAp8IGBodHRwOi8vc2VydmVyOjMwMzAvP3BlcnNvbj1QRVJTT05fVVVJRGAgfCBQaG90b3Mgb2YgYSBzcGVjaWZpYyBwZXJzb24gKGZhY2UgcmVjb2duaXRpb24pIHwKfCBgaHR0cDovL3NlcnZlcjozMDMwLz9mYXZvcml0ZXNgIHwgRmF2b3JpdGUgcGhvdG9zIG9ubHkgfAp8IGBodHRwOi8vc2VydmVyOjMwMzAvP3JhbmRvbWAgfCBSYW5kb20gcGhvdG9zIGZyb20gdGhlIGxpYnJhcnkgfAoKWW91IGNhbiBmaW5kIGFsYnVtIGFuZCBwZXJzb24gVVVJRHMgaW4gSW1taWNoJ3Mgd2ViIGludGVyZmFjZSBVUkwgYmFyIHdoZW4gdmlld2luZyBhbiBhbGJ1bSBvciBwZXJzb24uCgojIyDimpnvuI8gQ29uZmlndXJhdGlvbgoKQWxsIHNldHRpbmdzIGFyZSB2aWEgZW52aXJvbm1lbnQgdmFyaWFibGVzOgoKfCBWYXJpYWJsZSB8IERlZmF1bHQgfCBEZXNjcmlwdGlvbiB8CnwtLS18LS0tfC0tLXwKfCBgSU1NSUNIX1VSTGAgfCAqKHJlcXVpcmVkKSogfCBZb3VyIEltbWljaCBzZXJ2ZXIgVVJMIHwKfCBgSU1NSUNIX0FQSV9LRVlgIHwgKihyZXF1aXJlZCkqIHwgSW1taWNoIEFQSSBrZXkgfAp8IGBTTElERVNIT1dfSU5URVJWQUxgIHwgYDMwYCB8IFNlY29uZHMgYmV0d2VlbiBwaG90b3MgfAp8IGBUUkFOU0lUSU9OX0RVUkFUSU9OYCB8IGAyYCB8IENyb3NzZmFkZSBkdXJhdGlvbiBpbiBzZWNvbmRzIHwKfCBgSU1BR0VfRklUYCB8IGBjb250YWluYCB8IGBjb250YWluYCBvciBgY292ZXJgIHwKfCBgU0hVRkZMRWAgfCBgdHJ1ZWAgfCBSYW5kb21pc2UgcGhvdG8gb3JkZXIgfAp8IGBCQUNLR1JPVU5EX0JMVVJgIHwgYHRydWVgIHwgU2hvdyBibHVycmVkIGJhY2tkcm9wIHwKfCBgU0hPV19DTE9DS2AgfCBgdHJ1ZWAgfCBEaXNwbGF5IGNsb2NrIG92ZXJsYXkgfAp8IGBTSE9XX0RBVEVgIHwgYHRydWVgIHwgRGlzcGxheSBkYXRlIG92ZXJsYXkgfAp8IGBTSE9XX0VYSUZgIHwgYHRydWVgIHwgRGlzcGxheSBwaG90byBtZXRhZGF0YSB8CnwgYFNIT1dfUFJPR1JFU1NgIHwgYHRydWVgIHwgRGlzcGxheSBwcm9ncmVzcyBiYXIgfAp8IGBSRUZSRVNIX0lOVEVSVkFMYCB8IGAzMDBgIHwgU2Vjb25kcyBiZXR3ZWVuIHNvdXJjZSByZWZyZXNoIGNoZWNrcyAobmV3IHBob3RvcykgfAp8IGBBTEJVTV9JRGAgfCAqKGVtcHR5KSogfCBBdXRvLXN0YXJ0IHdpdGggc3BlY2lmaWMgYWxidW0gKGVudi1iYXNlZCkgfAp8IGBTSE9XX0ZBVk9SSVRFU19PTkxZYCB8IGBmYWxzZWAgfCBBdXRvLXN0YXJ0IHdpdGggZmF2b3JpdGVzIChlbnYtYmFzZWQpIHwKfCBgQURNSU5fVVNFUk5BTUVgIHwgYGFkbWluYCB8IEFkbWluIGRhc2hib2FyZCBsb2dpbiB1c2VybmFtZSB8CnwgYEFETUlOX1BBU1NXT1JEYCB8ICooZW1wdHkpKiB8IEFkbWluIGRhc2hib2FyZCBwYXNzd29yZCAobGVhdmUgZW1wdHkgdG8gZGlzYWJsZSBhdXRoKSB8CnwgYEZSQU1CRV9BUElfVE9LRU5gIHwgKihlbXB0eSkqIHwgQVBJIHRva2VuIGZvciBSRVNUIGVuZHBvaW50IGFjY2VzcyAobGVhdmUgZW1wdHkgZm9yIG9wZW4gYWNjZXNzKSB8CnwgYFBPUlRgIHwgYDMwMDBgIHwgSW50ZXJuYWwgc2VydmVyIHBvcnQgKERvY2tlciBtYXBzIGV4dGVybmFsbHkgdmlhIGNvbXBvc2UpIHwKCiMjIPCfjq4gQ29udHJvbHMKCiMjIyBUb3VjaCAvIE1vdXNlCi0gKipMZWZ0IDIwJSoqIG9mIHNjcmVlbiDigJQgUHJldmlvdXMgcGhvdG8KLSAqKkNlbnRyZSA2MCUqKiDigJQgVG9nZ2xlIG92ZXJsYXkgKGNsb2NrLCBpbmZvLCBjbG9zZSBidXR0b24pCi0gKipSaWdodCAyMCUqKiDigJQgTmV4dCBwaG90bwoKIyMjIEtleWJvYXJkCi0gYOKGkGAgLyBg4oaSYCDigJQgUHJldmlvdXMgLyBOZXh0IHBob3RvCi0gYFNwYWNlYCDigJQgTmV4dCBwaG90bwotIGBGYCDigJQgVG9nZ2xlIGZ1bGxzY3JlZW4KLSBgSWAg4oCUIFRvZ2dsZSBpbmZvIG92ZXJsYXkKLSBgRXNjYCDigJQgRXhpdCB0byBhbGJ1bSBzZWxlY3Rpb24KCiMjIPCfk7EgVGFibGV0IFNldHVwIFRpcHMKCjEuIE9wZW4gdGhlIGZyYW1lIFVSTCBpbiB5b3VyIHRhYmxldCdzIGJyb3dzZXIgKHVzZSBhIGA/YWxidW09YCBvciBgP3BlcnNvbj1gIFVSTCBmb3IgemVyby10b3VjaCkKMi4gQWRkIHRvIEhvbWUgU2NyZWVuIGZvciBhIGZ1bGwtc2NyZWVuIGFwcCBleHBlcmllbmNlCjMuIEVuYWJsZSBraW9zayBtb2RlIG9yIGd1aWRlZCBhY2Nlc3MgdG8gbG9jayB0byB0aGUgYXBwCjQuIERpc2FibGUgc2NyZWVuIHRpbWVvdXQgaW4geW91ciBkZXZpY2Ugc2V0dGluZ3MKCiMjIPCfj5fvuI8gQXJjaGl0ZWN0dXJlCgpgYGAK4pSM4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSQICAgICAgICAg4pSM4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSQICAgICAgICAg4pSM4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSQCuKUgiAgIEJyb3dzZXIgICAg4pSCICBIVFRQICAg4pSCICAgIEZyYW1iZSAgICDilIIgICBBUEkgICDilIIgICAgSW1taWNoICAgICDilIIK4pSCICAoVGFibGV0KSAgICDilILil4TilIDilIDilIDilIDilIDilIDilIDilIDilrrilIIgIChOb2RlLmpzKSAg4pSC4peE4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pa64pSCICAgU2VydmVyICAgICDilIIK4pSU4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSYICA6MzAzMCAg4pSU4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSYICA6MjI4MyAg4pSU4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSYCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIOKWsgogICAgICAgICAgICAgICAgICAgIOKUjOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUtOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUgOKUkAogICAgICAgICAgICAgICAgICAgIOKUgiAgUkVTVCBBUEkgLyBXUyAgICDilIIKICAgICAgICAgICAgICAgICAgICDilIIgIChIb21lIEFzc2lzdGFudCkg4pSCCiAgICAgICAgICAgICAgICAgICAg4pSU4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSA4pSYCmBgYAoKVGhlIE5vZGUuanMgYmFja2VuZCBhY3RzIGFzIGEgc2VjdXJlIHByb3h5IOKAlCB5b3VyIEltbWljaCBBUEkga2V5IG5ldmVyIHJlYWNoZXMgdGhlIGJyb3dzZXIuIFRoZSBmcm9udGVuZCBwZXJpb2RpY2FsbHkgcG9sbHMgdGhlIGJhY2tlbmQgZm9yIG5ldyBwaG90b3Mgc28gYWxidW1zIHN0YXkgdXAgdG8gZGF0ZSB3aXRob3V0IHJlc3RhcnRpbmcuCgojIyDwn5OLIFZlcnNpb24gSGlzdG9yeQoKLSAqKjEuNC4wKiog4oCUIEFkbWluIGxvZ2luICh1c2VybmFtZS9wYXNzd29yZCksIEFQSSB0b2tlbiBhdXRoIGZvciBleHRlcm5hbCBhY2Nlc3MgKEhvbWUgQXNzaXN0YW50KSwgUkVTVCBlbmRwb2ludHMgKGBHRVQgL2FwaS9jbGllbnRzYCwgYFBPU1QgL2FwaS9jbGllbnRzLzppZC9jb21tYW5kYCkKLSAqKjEuMy4wKiog4oCUIEFkbWluIGRhc2hib2FyZCB3aXRoIHJlYWwtdGltZSBXZWJTb2NrZXQgZnJhbWUgbWFuYWdlbWVudCwgdmlkZW8gc3VwcG9ydCwgcGVyc29uL2ZhY2Ugc3VwcG9ydAotICoqMS4yLjEqKiDigJQgRml4IHBvcnQgbWFwcGluZyAoMzAzMDozMDAwIGV4dGVybmFsOmludGVybmFsKSwgZml4IFVSTCBwYXJhbSBhdXRvLWxhdW5jaCBub3Qgc3RhcnRpbmcgc2xpZGVzaG93Ci0gKioxLjIuMCoqIOKAlCBVUkwgcGFyYW1zIChgP2FsYnVtPWAsIGA/cGVyc29uPWAsIGA/ZmF2b3JpdGVzYCwgYD9yYW5kb21gKSwgcGVyc29uL2ZhY2Ugc3VwcG9ydCwgcGVyaW9kaWMgYXV0by1yZWZyZXNoLCBhcHAgaWNvbiwgZGVmYXVsdCBwb3J0IGNoYW5nZWQgdG8gMzAzMAotICoqMS4xLjAqKiDigJQgUmVicmFuZCB0byBGcmFtYmUKLSAqKjEuMC4wKiog4oCUIEluaXRpYWwgcmVsZWFzZTogYWxidW0gYnJvd3Nlciwgc2xpZGVzaG93IHdpdGggY3Jvc3NmYWRlLCBjbG9jay9kYXRlL0VYSUYgb3ZlcmxheXMsIHRvdWNoICYga2V5Ym9hcmQgY29udHJvbHMsIERvY2tlciBkZXBsb3ltZW50CgojIyDwn5OEIExpY2Vuc2UKCk1JVAo= \ No newline at end of file +# Frambe + +

+ Frambe +

+ +A lightweight, self-contained Docker web application that connects to your [Immich](https://immich.app/) server and displays photos in a beautiful full-screen slideshow — perfect for turning old tablets, spare screens, and Raspberry Pis into digital photo frames. + +## ✨ Features + +- **Immich API Integration** — Connects securely via API key (kept server-side) +- **Album Browser** — Select any album, random photos, or favorites only +- **Person / Face Support** — Display photos of a specific person via Immich's face recognition +- **Admin Dashboard** — Real-time WebSocket-based control panel for all connected frames +- **Admin Authentication** — Optional username/password login to protect the admin dashboard +- **REST API with Token Auth** — Control frames from Home Assistant, scripts, or external tools +- **URL-Based Zero-Touch Launch** — Skip the setup screen entirely with query parameters +- **Auto-Refresh** — Periodically checks for new photos added to the source album/person +- **Smooth Crossfade** — Double-buffered image transitions with configurable duration +- **Background Blur** — Blurred backdrop fills the space behind non-covering images +- **Clock & Date Overlay** — Always know the time at a glance +- **EXIF Info** — Shows photo location, date, and camera info +- **Progress Bar** — Subtle indicator of time until next photo +- **Touch Controls** — Tap left/right edges to navigate, centre to toggle overlay +- **Keyboard Controls** — Arrow keys, Space, F (fullscreen), I (info), Esc (exit) +- **Screen Wake Lock** — Prevents screen sleep on supported devices +- **Responsive** — Works on any screen size from phone to TV +- **Older Device Friendly** — Vanilla HTML/CSS/JS, no heavy frameworks +- **Docker Containerised** — Single container, minimal footprint + +## 🚀 Quick Start + +### 1. Get your Immich API Key + +1. Open your Immich web interface +2. Click your profile picture → **Account Settings** → **API Keys** +3. Create a new key with `asset.read` and `album.read` permissions + +### 2. Run with Docker Compose + +```bash +git clone https://gitea.hideawaygaming.com.au/jessikitty/frambe.git +cd frambe +``` + +Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then: + +```bash +docker compose up -d +``` + +Open `http://your-server:3030` in a browser on your tablet/screen. + +### 3. Run with Docker directly + +```bash +docker build -t frambe . +docker run -d \ + --name frambe \ + -p 3030:3000 \ + -e IMMICH_URL=http://your-immich-server:2283 \ + -e IMMICH_API_KEY=your-api-key \ + --restart unless-stopped \ + frambe +``` + +## 🔑 Authentication + +### Admin Dashboard Login + +Protect the admin dashboard with a username and password: + +```yaml +environment: + - ADMIN_USERNAME=admin + - ADMIN_PASSWORD=your-secure-password +``` + +When `ADMIN_PASSWORD` is set, accessing `/admin` requires signing in. When not set, the dashboard is open (useful for trusted local networks). + +### API Token for External Access + +Enable token-authenticated REST API access for Home Assistant, scripts, or other external tools: + +```yaml +environment: + - FRAMBE_API_TOKEN=your-secret-token-here +``` + +## 🔌 REST API + +When `FRAMBE_API_TOKEN` is configured, the following endpoints are available: + +### List Connected Frames + +``` +GET /api/clients +Authorization: Bearer your-secret-token-here +``` + +Returns all connected frame clients with their status, IP, name, and config. + +### Send Command to a Frame + +``` +POST /api/clients/:id/command +Authorization: Bearer your-secret-token-here +Content-Type: application/json + +{ + "action": "next", + "payload": {} +} +``` + +Available actions: `start`, `stop`, `next`, `prev`, `sleep`, `wake`, `refresh`, `setSource`, `setConfig` + +### Home Assistant Example + +```yaml +rest_command: + frambe_next_photo: + url: "http://frambe-server:3030/api/clients/{{ client_id }}/command" + method: POST + headers: + Authorization: "Bearer your-secret-token-here" + Content-Type: "application/json" + payload: '{"action": "next"}' +``` + +Authentication can also be provided via `x-api-token` header or `?token=` query parameter. + +## 🔗 Zero-Touch URL Parameters + +Skip the setup screen entirely by passing query parameters. This is ideal for dedicated frames — just bookmark the URL on each tablet: + +| URL | What it shows | +|---|---| +| `http://server:3030/?album=ALBUM_UUID` | Photos from a specific album | +| `http://server:3030/?person=PERSON_UUID` | Photos of a specific person (face recognition) | +| `http://server:3030/?favorites` | Favorite photos only | +| `http://server:3030/?random` | Random photos from the library | + +You can find album and person UUIDs in Immich's web interface URL bar when viewing an album or person. + +## ⚙️ Configuration + +All settings are via environment variables: + +| Variable | Default | Description | +|---|---|---| +| `IMMICH_URL` | *(required)* | Your Immich server URL | +| `IMMICH_API_KEY` | *(required)* | Immich API key | +| `SLIDESHOW_INTERVAL` | `30` | Seconds between photos | +| `TRANSITION_DURATION` | `2` | Crossfade duration in seconds | +| `IMAGE_FIT` | `contain` | `contain` or `cover` | +| `SHUFFLE` | `true` | Randomise photo order | +| `BACKGROUND_BLUR` | `true` | Show blurred backdrop | +| `SHOW_CLOCK` | `true` | Display clock overlay | +| `SHOW_DATE` | `true` | Display date overlay | +| `SHOW_EXIF` | `true` | Display photo metadata | +| `SHOW_PROGRESS` | `true` | Display progress bar | +| `REFRESH_INTERVAL` | `300` | Seconds between source refresh checks (new photos) | +| `ALBUM_ID` | *(empty)* | Auto-start with specific album (env-based) | +| `SHOW_FAVORITES_ONLY` | `false` | Auto-start with favorites (env-based) | +| `ADMIN_USERNAME` | `admin` | Admin dashboard login username | +| `ADMIN_PASSWORD` | *(empty)* | Admin dashboard password (leave empty to disable auth) | +| `FRAMBE_API_TOKEN` | *(empty)* | API token for REST endpoint access (leave empty for open access) | +| `PORT` | `3000` | Internal server port (Docker maps externally via compose) | + +## 🎮 Controls + +### Touch / Mouse +- **Left 20%** of screen — Previous photo +- **Centre 60%** — Toggle overlay (clock, info, close button) +- **Right 20%** — Next photo + +### Keyboard +- `←` / `→` — Previous / Next photo +- `Space` — Next photo +- `F` — Toggle fullscreen +- `I` — Toggle info overlay +- `Esc` — Exit to album selection + +## 📱 Tablet Setup Tips + +1. Open the frame URL in your tablet's browser (use a `?album=` or `?person=` URL for zero-touch) +2. Add to Home Screen for a full-screen app experience +3. Enable kiosk mode or guided access to lock to the app +4. Disable screen timeout in your device settings + +## 📋 Version History + +- **1.4.0** — Admin login (username/password), API token auth for external access (Home Assistant), REST endpoints (`GET /api/clients`, `POST /api/clients/:id/command`) +- **1.3.0** — Admin dashboard with real-time WebSocket frame management, video support, person/face support +- **1.2.1** — Fix port mapping (3030:3000 external:internal), fix URL param auto-launch not starting slideshow +- **1.2.0** — URL params (`?album=`, `?person=`, `?favorites`, `?random`), person/face support, periodic auto-refresh, app icon, default port changed to 3030 +- **1.1.0** — Rebrand to Frambe +- **1.0.0** — Initial release: album browser, slideshow with crossfade, clock/date/EXIF overlays, touch & keyboard controls, Docker deployment + +## 📄 License + +MIT From 0c279ad00a1e78972c2326c7b2ed509a2a2d16a2 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 26 May 2026 16:28:11 +1000 Subject: [PATCH 17/30] docs: expanded README with deployment, upgrade, and versioning sections --- README.md | 234 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 221 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 72aeb79..f815a10 100644 --- a/README.md +++ b/README.md @@ -28,30 +28,49 @@ A lightweight, self-contained Docker web application that connects to your [Immi - **Older Device Friendly** — Vanilla HTML/CSS/JS, no heavy frameworks - **Docker Containerised** — Single container, minimal footprint -## 🚀 Quick Start +--- + +## 🚀 Deployment + +### Prerequisites + +- A running [Immich](https://immich.app/) server +- An Immich API key (see below) +- Docker and Docker Compose (recommended) — or Node.js 18+ for running from source ### 1. Get your Immich API Key 1. Open your Immich web interface 2. Click your profile picture → **Account Settings** → **API Keys** 3. Create a new key with `asset.read` and `album.read` permissions +4. Copy the key — you'll need it for the next step -### 2. Run with Docker Compose +### 2. Deploy with Docker Compose (recommended) ```bash git clone https://gitea.hideawaygaming.com.au/jessikitty/frambe.git cd frambe ``` -Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then: +Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`: + +```yaml +environment: + - IMMICH_URL=http://your-immich-server:2283 + - IMMICH_API_KEY=your-api-key-here +``` + +Then start the container: ```bash docker compose up -d ``` -Open `http://your-server:3030` in a browser on your tablet/screen. +Frambe is now running at `http://your-server:3030`. -### 3. Run with Docker directly +### 3. Deploy with Docker Run + +If you prefer not to use Compose: ```bash docker build -t frambe . @@ -64,6 +83,104 @@ docker run -d \ frambe ``` +### 4. Run from Source (development) + +```bash +git clone https://gitea.hideawaygaming.com.au/jessikitty/frambe.git +cd frambe +npm install +``` + +Create a `.env` file from the example: + +```bash +cp .env.example .env +``` + +Edit `.env` with your Immich URL and API key, then start the server: + +```bash +npm start +``` + +Frambe will be available at `http://localhost:3000`. + +### Port Mapping + +The internal server runs on port **3000**. The Docker Compose config maps this to external port **3030** by default. You can change this in `docker-compose.yml`: + +```yaml +ports: + - "8080:3000" # Access Frambe on port 8080 instead +``` + +--- + +## 🔄 Upgrading + +### Docker Compose (recommended) + +```bash +cd frambe +git pull +docker compose build +docker compose up -d +``` + +Your configuration in `docker-compose.yml` is preserved — only the application code is rebuilt. + +### Docker Run + +```bash +cd frambe +git pull +docker stop frambe +docker rm frambe +docker build -t frambe . +docker run -d \ + --name frambe \ + -p 3030:3000 \ + -e IMMICH_URL=http://your-immich-server:2283 \ + -e IMMICH_API_KEY=your-api-key \ + --restart unless-stopped \ + frambe +``` + +### From Source + +```bash +cd frambe +git pull +npm install +npm start +``` + +### Switching to a Specific Version + +Frambe uses git tags for releases. To pin to a specific version: + +```bash +git fetch --tags +git checkout v1.4.0 # Replace with desired version +docker compose build && docker compose up -d +``` + +To switch back to the latest: + +```bash +git checkout main +git pull +docker compose build && docker compose up -d +``` + +### Upgrade Notes + +- **All upgrades are non-destructive** — Frambe stores no persistent data on disk. All configuration is via environment variables. +- **No database migrations** — there is no database. Session tokens are in-memory and will reset on restart (users simply log in again). +- **Check the changelog below** before upgrading major versions for any new required environment variables. + +--- + ## 🔑 Authentication ### Admin Dashboard Login @@ -87,6 +204,8 @@ environment: - FRAMBE_API_TOKEN=your-secret-token-here ``` +--- + ## 🔌 REST API When `FRAMBE_API_TOKEN` is configured, the following endpoints are available: @@ -130,6 +249,8 @@ rest_command: Authentication can also be provided via `x-api-token` header or `?token=` query parameter. +--- + ## 🔗 Zero-Touch URL Parameters Skip the setup screen entirely by passing query parameters. This is ideal for dedicated frames — just bookmark the URL on each tablet: @@ -143,9 +264,11 @@ Skip the setup screen entirely by passing query parameters. This is ideal for de You can find album and person UUIDs in Immich's web interface URL bar when viewing an album or person. +--- + ## ⚙️ Configuration -All settings are via environment variables: +All settings are via environment variables. Set them in `docker-compose.yml`, pass with `docker run -e`, or put them in a `.env` file when running from source. | Variable | Default | Description | |---|---|---| @@ -160,6 +283,7 @@ All settings are via environment variables: | `SHOW_DATE` | `true` | Display date overlay | | `SHOW_EXIF` | `true` | Display photo metadata | | `SHOW_PROGRESS` | `true` | Display progress bar | +| `INCLUDE_VIDEOS` | `true` | Include video assets in slideshow | | `REFRESH_INTERVAL` | `300` | Seconds between source refresh checks (new photos) | | `ALBUM_ID` | *(empty)* | Auto-start with specific album (env-based) | | `SHOW_FAVORITES_ONLY` | `false` | Auto-start with favorites (env-based) | @@ -168,6 +292,8 @@ All settings are via environment variables: | `FRAMBE_API_TOKEN` | *(empty)* | API token for REST endpoint access (leave empty for open access) | | `PORT` | `3000` | Internal server port (Docker maps externally via compose) | +--- + ## 🎮 Controls ### Touch / Mouse @@ -182,6 +308,8 @@ All settings are via environment variables: - `I` — Toggle info overlay - `Esc` — Exit to album selection +--- + ## 📱 Tablet Setup Tips 1. Open the frame URL in your tablet's browser (use a `?album=` or `?person=` URL for zero-touch) @@ -189,14 +317,94 @@ All settings are via environment variables: 3. Enable kiosk mode or guided access to lock to the app 4. Disable screen timeout in your device settings -## 📋 Version History +--- -- **1.4.0** — Admin login (username/password), API token auth for external access (Home Assistant), REST endpoints (`GET /api/clients`, `POST /api/clients/:id/command`) -- **1.3.0** — Admin dashboard with real-time WebSocket frame management, video support, person/face support -- **1.2.1** — Fix port mapping (3030:3000 external:internal), fix URL param auto-launch not starting slideshow -- **1.2.0** — URL params (`?album=`, `?person=`, `?favorites`, `?random`), person/face support, periodic auto-refresh, app icon, default port changed to 3030 -- **1.1.0** — Rebrand to Frambe -- **1.0.0** — Initial release: album browser, slideshow with crossfade, clock/date/EXIF overlays, touch & keyboard controls, Docker deployment +## 🏗️ Architecture + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Browser │ HTTP │ Frambe │ API │ Immich │ +│ (Tablet) │◄────────►│ (Node.js) │◄────────►│ Server │ +└──────────────┘ :3030 └──────────────┘ :2283 └──────────────┘ + ▲ + ┌─────────┴─────────┐ + │ REST API / WS │ + │ (Home Assistant) │ + └───────────────────┘ +``` + +The Node.js backend acts as a secure proxy — your Immich API key never reaches the browser. The frontend periodically polls the backend for new photos so albums stay up to date without restarting. + +--- + +## 🏷️ Versioning + +Frambe follows [Semantic Versioning](https://semver.org/): + +- **Major** (`X.0.0`) — Large feature overhauls, breaking changes, or major UI redesigns +- **Minor** (`0.X.0`) — New features, functionality additions, or significant improvements +- **Patch** (`0.0.X`) — Bug fixes, small tweaks, and minor corrections + +### Branches + +| Branch | Purpose | +|---|---| +| `main` | Stable releases — production-ready code | +| `dev` | Development — latest features, may be unstable | + +### Changelog + +#### v1.4.0 — Admin Auth & REST API +- ✅ Admin dashboard login with username/password authentication (env-based) +- ✅ Session management with HttpOnly cookies (24-hour expiry, automatic cleanup) +- ✅ API token authentication for external access (Home Assistant, scripts, curl) +- ✅ REST endpoint: `GET /api/clients` — list all connected frames +- ✅ REST endpoint: `POST /api/clients/:id/command` — send commands to frames +- ✅ Multiple auth methods: Bearer token, `x-api-token` header, `?token=` query param +- ✅ Auth status endpoint: `GET /api/auth/status` +- ✅ Backwards compatible — auth is opt-in, disabled by default +- 🆕 New env vars: `ADMIN_USERNAME`, `ADMIN_PASSWORD`, `FRAMBE_API_TOKEN` + +#### v1.3.0 — Admin Dashboard & Video Support +- ✅ Real-time admin dashboard at `/admin` with WebSocket communication +- ✅ Live frame management: start, stop, next, prev, sleep, wake, refresh +- ✅ Remote source switching: change album, person, random, or favorites per frame +- ✅ Remote config: adjust slideshow interval, toggle clock/date/EXIF/progress per frame +- ✅ Frame naming and rename support (persists by IP) +- ✅ Video playback support in slideshow (with `INCLUDE_VIDEOS` toggle) +- ✅ Person / face recognition photo source via Immich's people API +- ✅ Connection status indicators and auto-reconnect + +#### v1.2.1 — Bug Fixes +- 🐛 Fixed port mapping (3030:3000 external:internal) +- 🐛 Fixed URL parameter auto-launch not starting the slideshow + +#### v1.2.0 — Zero-Touch Launch & Auto-Refresh +- ✅ URL query parameters for zero-touch launch (`?album=`, `?person=`, `?favorites`, `?random`) +- ✅ Person / face support — display photos of a specific person +- ✅ Periodic auto-refresh — new photos appear without restarting +- ✅ App icon for home screen bookmarks +- ✅ Default external port changed to 3030 +- 🆕 New env var: `REFRESH_INTERVAL` + +#### v1.1.0 — Rebrand +- ✅ Rebranded to Frambe + +#### v1.0.0 — Initial Release +- ✅ Album browser with Immich API integration +- ✅ Full-screen slideshow with smooth crossfade transitions +- ✅ Double-buffered image loading for seamless display +- ✅ Background blur behind non-covering images +- ✅ Clock, date, and EXIF metadata overlays +- ✅ Progress bar showing time until next photo +- ✅ Touch controls (left/centre/right tap zones) +- ✅ Keyboard controls (arrows, space, F, I, Esc) +- ✅ Screen wake lock to prevent display sleep +- ✅ Configurable via environment variables +- ✅ Docker containerised deployment +- ✅ Vanilla HTML/CSS/JS frontend — no frameworks, works on older devices + +--- ## 📄 License From 6f857913023ddab70922c81a3b17598d38660b8f Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 1 Jun 2026 23:00:24 +1000 Subject: [PATCH 18/30] feat: persistent frame registry with online/offline tracking, DELETE endpoint --- server.js | 205 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 145 insertions(+), 60 deletions(-) diff --git a/server.js b/server.js index 5604386..6c5a522 100644 --- a/server.js +++ b/server.js @@ -32,9 +32,8 @@ 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 +const SESSION_TTL = 24 * 60 * 60 * 1000; function createSession(username) { const token = crypto.randomBytes(32).toString('hex'); @@ -52,9 +51,8 @@ function validateSession(token) { } 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 +setInterval(cleanupSessions, 60 * 60 * 1000); -// --- Admin auth middleware (cookie-based for browser) --- function requireAdminAuth(req, res, next) { if (!AUTH_ENABLED) return next(); const cookie = req.headers.cookie || ''; @@ -65,16 +63,13 @@ function requireAdminAuth(req, res, next) { 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 (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]+)/); @@ -88,63 +83,149 @@ function immichHeaders() { return { 'x-api-key': API_KEY, 'Accept': 'application function log(msg) { console.log('[Frambe] ' + msg); } function logErr(msg) { console.error('[Frambe] ERROR: ' + msg); } -const clients = new Map(); -let clientNameStore = {}; +// --- Persistent frame registry (keyed by IP) --- +const frameRegistry = new Map(); +const adminSockets = new Set(); + 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; } + +function broadcastToAdmins(msg) { + const d = JSON.stringify(msg); + adminSockets.forEach(ws => { if (ws.readyState === WebSocket.OPEN) ws.send(d); }); +} + +function getClientList() { + const list = []; + frameRegistry.forEach((f, ip) => { + 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 || {} }); + }); + return list; +} + +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) => { 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 })); + const now = new Date().toISOString(); + let isAdmin = false; + + log('WebSocket connected: ' + ip); + ws.send(JSON.stringify({ type: 'welcome', ip })); + ws.on('message', raw => { try { - const msg = JSON.parse(raw); info.lastSeen = new Date().toISOString(); + const msg = JSON.parse(raw); 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() }); } + if (msg.role === 'admin') { + isAdmin = true; + adminSockets.add(ws); + 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; + 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 || {}, ws: ws }; + frameRegistry.set(ip, frame); + log('Frame registered: ' + ip); + } + 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 } }); + + 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; + broadcastToAdmins({ type: 'clientUpdate', clientId: frame.id, client: { id: frame.id, ip, name: frame.name, status: frame.status, lastSeen: frame.lastSeen, config: frame.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' })); + } + + 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 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() }); } + } + + 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', () => { log('WebSocket disconnected: ' + ip); clients.delete(clientId); broadcastToAdmins({ type: 'clientList', clients: getClientList() }); }); + + ws.on('close', () => { + if (isAdmin) { + adminSockets.delete(ws); + log('Admin disconnected: ' + ip); + } else { + 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(); }); app.use(express.json()); -// --- Auth endpoints --- -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) => { 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}`); + 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 }); } @@ -160,53 +241,57 @@ app.post('/api/auth/logout', (req, res) => { 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 }); } }); +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 && 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/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() }); -}); +// --- REST API: Client management --- +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); + const target = findFrameById(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.status === 'offline' || !target.ws) return res.status(410).json({ ok: false, error: 'Client is offline' }); 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')); }); +app.delete('/api/clients/:id', requireApiToken, (req, res) => { + const { id } = req.params; + const targetIp = findFrameIpById(id); + if (!targetIp) return res.status(404).json({ ok: false, error: 'Client not found' }); + const removed = frameRegistry.get(targetIp); + if (removed && removed.ws && removed.ws.readyState === WebSocket.OPEN) removed.ws.close(); + frameRegistry.delete(targetIp); + log('REST removed client: ' + targetIp); + res.json({ ok: true, removedId: id }); +}); -// --- Catch-all for frame SPA --- +app.get('/admin', requireAdminAuth, (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'admin', 'index.html')); }); app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); server.listen(PORT, '0.0.0.0', () => { From 0e2ec3eb372b1cc9ad649a0b19ca485bf9f57905 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 1 Jun 2026 23:02:08 +1000 Subject: [PATCH 19/30] feat: persistent client list with online/offline status, REST API reference panel with copy buttons --- public/admin/index.html | 124 +++++++++++++++++++++++++++++++++++----- 1 file changed, 109 insertions(+), 15 deletions(-) diff --git a/public/admin/index.html b/public/admin/index.html index 8efb640..2577f60 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -16,12 +16,16 @@ .status-dot.online { background: #4ade80; } .status-dot.sleeping { background: #fbbf24; } .status-dot.playing { background: #60a5fa; } + .status-dot.offline { background: #666; } .clients-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 1rem; } .client-card { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 1.25rem; transition: all 0.2s; } .client-card:hover { border-color: rgba(99,102,241,0.3); background: rgba(255,255,255,0.06); } + .client-card.offline { opacity: 0.5; } + .client-card.offline:hover { opacity: 0.7; } .client-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; } .client-name { font-size: 1.1rem; font-weight: 500; display: flex; align-items: center; gap: 0.5rem; } .client-ip { font-size: 0.75rem; color: #888; font-family: monospace; } + .client-meta { display: flex; align-items: center; gap: 0.5rem; } .client-status { font-size: 0.8rem; color: #aaa; text-transform: capitalize; } .name-input { background: transparent; border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; color: #fff; font-size: 0.9rem; padding: 4px 8px; width: 140px; } .name-input:focus { outline: none; border-color: #6366f1; } @@ -36,6 +40,7 @@ .btn.success:hover { background: rgba(34,197,94,0.3); } .btn.logout { background: rgba(239,68,68,0.1); border-color: rgba(239,68,68,0.3); color: #fca5a5; font-size: 0.75rem; padding: 4px 12px; } .btn.logout:hover { background: rgba(239,68,68,0.25); } + .btn.small { font-size: 0.7rem; padding: 3px 8px; } select { padding: 6px 10px; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; background: rgba(255,255,255,0.06); color: #e0e0e0; font-size: 0.8rem; cursor: pointer; max-width: 200px; } select:focus { outline: none; border-color: #6366f1; } option { background: #1a1a2e; color: #e0e0e0; } @@ -53,6 +58,22 @@ .ws-status.connected { background: rgba(34,197,94,0.15); color: #86efac; } .ws-status.disconnected { background: rgba(239,68,68,0.15); color: #fca5a5; } .divider { border: none; border-top: 1px solid rgba(255,255,255,0.06); margin: 0.5rem 0; } + .section-header { font-size: 1.2rem; font-weight: 300; margin: 2rem 0 1rem 0; padding-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.1); display: flex; align-items: center; justify-content: space-between; cursor: pointer; } + .section-header:hover { color: #fff; } + .section-header .toggle-arrow { font-size: 0.8rem; color: #666; transition: transform 0.2s; } + .section-header .toggle-arrow.open { transform: rotate(90deg); } + .api-ref { background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.06); border-radius: 12px; padding: 1.25rem; margin-bottom: 0.75rem; } + .api-ref h3 { font-size: 0.9rem; font-weight: 500; color: #a5b4fc; margin-bottom: 0.5rem; } + .api-ref .method { display: inline-block; font-size: 0.7rem; font-weight: 600; padding: 2px 6px; border-radius: 4px; margin-right: 6px; } + .api-ref .method.get { background: rgba(34,197,94,0.2); color: #86efac; } + .api-ref .method.post { background: rgba(59,130,246,0.2); color: #93c5fd; } + .api-ref .method.delete { background: rgba(239,68,68,0.2); color: #fca5a5; } + .api-ref .endpoint { font-family: monospace; font-size: 0.85rem; color: #ccc; } + .api-ref p { font-size: 0.8rem; color: #888; margin: 0.4rem 0; } + .api-ref pre { background: rgba(0,0,0,0.3); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 10px 12px; font-size: 0.75rem; color: #ccc; overflow-x: auto; margin-top: 0.5rem; white-space: pre-wrap; word-break: break-all; position: relative; } + .copy-btn { position: absolute; top: 6px; right: 6px; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); border-radius: 4px; color: #aaa; font-size: 0.65rem; padding: 2px 6px; cursor: pointer; } + .copy-btn:hover { background: rgba(255,255,255,0.15); color: #fff; } + .offline-msg { font-size: 0.8rem; color: #888; text-align: center; padding: 0.75rem; } @@ -67,10 +88,71 @@

No frames connected

Open Frambe on a tablet or screen to see it here

+
REST API Reference
+ + h+=''; + }); + g.innerHTML=h; +} +connect(); + From 7e54ba4923763045dec2932dfa25a3f73b236142 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 9 Jun 2026 15:12:42 +1000 Subject: [PATCH 23/30] feat: fetch shared albums from Immich API (v1.4.1) --- server.js | 359 ++++++++++++++++++++++-------------------------------- 1 file changed, 144 insertions(+), 215 deletions(-) diff --git a/server.js b/server.js index cca81e7..ae142e7 100644 --- a/server.js +++ b/server.js @@ -7,7 +7,7 @@ const sharp = require('sharp'); const { WebSocketServer, WebSocket } = require('ws'); require('dotenv').config(); -const VERSION = '1.4.0'; +const VERSION = '1.4.1'; const app = express(); const server = http.createServer(app); const PORT = process.env.PORT || 3000; @@ -79,181 +79,142 @@ 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' }); + 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.html'); } 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' }); + 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, 'Accept': 'application/json', 'Content-Type': 'application/json' }; } -function log(msg) { console.log('[Frambe] ' + msg); } -function logErr(msg) { console.error('[Frambe] ERROR: ' + msg); } +// --- Helpers --- +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); } -// --- Persistent frame registry (keyed by IP) --- -const frameRegistry = new Map(); -const adminSockets = new Set(); - -function getClientIp(req) { return req.headers['x-forwarded-for']?.split(',')[0].trim() || req.socket.remoteAddress || 'unknown'; } - -function broadcastToAdmins(msg) { - const d = JSON.stringify(msg); - adminSockets.forEach(ws => { if (ws.readyState === WebSocket.OPEN) ws.send(d); }); +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, + }; +} + +// --- WebSocket --- +const wss = new WebSocketServer({ server }); +const clients = new Map(); + function getClientList() { - const list = []; - frameRegistry.forEach((f, ip) => { - 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 }); - }); - return list; + 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, + })); } -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) => { - const ip = getClientIp(req); - const now = new Date().toISOString(); - let isAdmin = false; + 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) + ')'); - log('WebSocket connected: ' + ip); - ws.send(JSON.stringify({ type: 'welcome', ip })); + ws.send(JSON.stringify({ type: 'hello', clientId: id })); + broadcastAdminClients(); - ws.on('message', raw => { + ws.on('message', (raw) => { try { const msg = JSON.parse(raw); - switch (msg.type) { - case 'register': - if (msg.role === 'admin') { - isAdmin = true; - adminSockets.add(ws); - 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); } + 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) { /* ignore */ } }); ws.on('close', () => { - if (isAdmin) { - adminSockets.delete(ws); - log('Admin disconnected: ' + ip); - } else { - 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() }); - } - } + const c = clients.get(id); + if (c) { c.online = false; c.lastSeen = Date.now(); } + log('WS close: ' + id); + broadcastAdminClients(); }); }); -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.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.post('/api/auth/login', (req, res) => { - if (!AUTH_ENABLED) return res.json({ ok: true, message: 'Auth not enabled' }); - const { username, password } = req.body || {}; + 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 + '; Path=/; HttpOnly; SameSite=Strict; Max-Age=' + (SESSION_TTL / 1000)); - log('Admin login: ' + username); + res.setHeader('Set-Cookie', 'frambe_session=' + token + '; HttpOnly; Path=/; Max-Age=' + (SESSION_TTL / 1000)); return res.json({ ok: true }); } - log('Failed login attempt: ' + (username || '(empty)')); 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 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.setHeader('Set-Cookie', 'frambe_session=; HttpOnly; Path=/; Max-Age=0'); 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/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/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 }); } }); @@ -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/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 }); } }); -// Optimized: server-side resize + compress for low-powered frames app.get('/api/assets/:id/optimized', async (req, res) => { - try { - const w = Math.min(parseInt(req.query.w, 10) || IMAGE_MAX_WIDTH, 3840); - const q = Math.min(parseInt(req.query.q, 10) || IMAGE_QUALITY, 100); - const cacheKey = req.params.id + '_' + w + '_' + q; - 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 + ')'); + 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('Content-Length', optimized.length); 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); - } 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/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.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 = findFrameById(id); - if (!target) return res.status(404).json({ ok: false, error: 'Client not found' }); - if (target.status === 'offline' || !target.ws) return res.status(410).json({ ok: false, error: 'Client is offline' }); - 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 }); + 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' }); + log('Command "' + action + '" sent to ' + req.params.id); + res.json({ ok: true }); }); app.delete('/api/clients/:id', requireApiToken, (req, res) => { - const { id } = req.params; - const targetIp = findFrameIpById(id); - if (!targetIp) return res.status(404).json({ ok: false, error: 'Client not found' }); - const removed = frameRegistry.get(targetIp); - if (removed && removed.ws && removed.ws.readyState === WebSocket.OPEN) removed.ws.close(); - frameRegistry.delete(targetIp); - log('REST removed client: ' + targetIp); - res.json({ ok: true, removedId: id }); + 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('/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')); }); -server.listen(PORT, '0.0.0.0', () => { +// --- Start --- +server.listen(PORT, () => { 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('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...'); + 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')); }); From 914dd32ac9795a658f419d9958ca72fbf86989f0 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 9 Jun 2026 15:30:12 +1000 Subject: [PATCH 24/30] feat: show shared album badges in album picker (v1.4.1) --- public/js/app.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/public/js/app.js b/public/js/app.js index 1e99873..25d38e8 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -1,4 +1,4 @@ -// === Frambe v1.4.0 - Client with WebSocket Remote Control === +// === Frambe v1.4.1 - Client with WebSocket Remote Control === (function () { 'use strict'; var config = {}, assets = [], currentIndex = -1, slideshowTimer = null; @@ -24,7 +24,7 @@ async function autoLaunch(src,aid,pid){urlDriven=true;selectedSource=src;selectedAlbumId=aid||null;selectedPersonId=pid||null;console.log('[Frambe] Auto-launch: '+src);await doStartSlideshow();} async function init(){document.body.classList.add('setup-mode');connectWebSocket();try{config=await(await fetch('/api/config')).json();console.log('[Frambe] v'+(config.version||'?'));if(!config.connected){showError('API key not configured.');return;}var si=await(await fetch('/api/server-info')).json();if(!si.ok){showError('Cannot reach Immich: '+si.error);return;}$connectionStatus.textContent='Connected to Immich v'+si.version.major+'.'+si.version.minor+'.'+si.version.patch;$connectionStatus.classList.add('connected');var p=getUrlParams();if(p.album){await autoLaunch('album',p.album,null);return;}if(p.person){await autoLaunch('person',null,p.person);return;}if('favorites'in p){await autoLaunch('favorites',null,null);return;}if('random'in p){await autoLaunch('random',null,null);return;}if(config.albumId){await autoLaunch('album',config.albumId,null);return;}if(config.showFavoritesOnly){await autoLaunch('favorites',null,null);return;}await loadAlbums();}catch(err){showError('Init failed: '+err.message);}} function showError(msg){$setupContent.style.display='none';$setupError.style.display='block';$errorDetail.textContent=msg;} - async function loadAlbums(){try{var albums=await(await fetch('/api/albums')).json();if(!albums.length){$albumsList.innerHTML='

No albums found

';return;}var html='';for(var i=0;i';html+=thu?'':'
📁
';html+='
'+escapeHtml(a.albumName)+'
'+a.assetCount+' items
';}$albumsList.innerHTML=html;}catch(e){$albumsList.innerHTML='

Failed to load albums

';}} + async function loadAlbums(){try{var albums=await(await fetch('/api/albums')).json();if(!albums.length){$albumsList.innerHTML='

No albums found

';return;}var html='';for(var i=0;i';html+=thu?'':'
'+(a.shared?'🔗':'📁')+'
';html+='
'+escapeHtml(a.albumName)+(a.shared?' Shared':'')+'
'+a.assetCount+' items
';}$albumsList.innerHTML=html;}catch(e){$albumsList.innerHTML='

Failed to load albums

';}} window.selectSource=function(src){selectedSource=src;selectedAlbumId=null;selectedPersonId=null;document.getElementById('btn-all-photos').classList.toggle('selected',src==='random');document.getElementById('btn-favorites').classList.toggle('selected',src==='favorites');var items=document.querySelectorAll('.album-item');for(var i=0;i Loading…';try{await loadAssets();if(!assets.length){$btnStart.textContent='No photos found';setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},2000);sendStatus('idle');return;}$setupScreen.style.display='none';$slideshowScreen.style.display='block';document.body.classList.remove('setup-mode');isRunning=true;initPileCanvas();if(!config.showClock)$clock.style.display='none';if(!config.showDate)$dateDisplay.style.display='none';if(!config.showExif)$exifInfo.style.display='none';if(!config.showProgress)$progressBar.style.display='none';if(!config.backgroundBlur)$bgBlur.style.display='none';updateClock();setInterval(updateClock,1000);currentIndex=-1;showNextAsset();scheduleOverlayHide();startRefreshTimer();sendStatus('playing');}catch(err){$btnStart.textContent='Error: '+err.message;setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},3000);}} + async function doStartSlideshow(){if(!selectedSource)return;$btnStart.disabled=true;$btnStart.innerHTML=' Loading...';try{await loadAssets();if(!assets.length){$btnStart.textContent='No photos found';setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},2000);sendStatus('idle');return;}$setupScreen.style.display='none';$slideshowScreen.style.display='block';document.body.classList.remove('setup-mode');isRunning=true;initPileCanvas();if(!config.showClock)$clock.style.display='none';if(!config.showDate)$dateDisplay.style.display='none';if(!config.showExif)$exifInfo.style.display='none';if(!config.showProgress)$progressBar.style.display='none';if(!config.backgroundBlur)$bgBlur.style.display='none';updateClock();setInterval(updateClock,1000);currentIndex=-1;showNextAsset();scheduleOverlayHide();startRefreshTimer();sendStatus('playing');}catch(err){$btnStart.textContent='Error: '+err.message;setTimeout(function(){$btnStart.textContent='▶ Start Slideshow';$btnStart.disabled=false;},3000);}} window.startSlideshow=function(){doStartSlideshow();}; window.exitSlideshow=function(){if(urlDriven){window.location.href=window.location.pathname;return;}exitSlideshowInternal();sendStatus('idle');}; function showNextAsset(){currentIndex++;if(currentIndex>=assets.length){if(config.shuffle)shuffleArray(assets);currentIndex=0;}showAsset(currentIndex);} @@ -59,6 +59,5 @@ function escapeHtml(s){var d=document.createElement('div');d.appendChild(document.createTextNode(s));return d.innerHTML;} async function requestWakeLock(){try{if('wakeLock' in navigator)await navigator.wakeLock.request('screen');}catch(e){}} document.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible'&&isRunning)requestWakeLock();}); - function preventSleep(){try{var v=document.createElement('video');v.setAttribute('playsinline','');v.setAttribute('muted','');v.setAttribute('loop','');v.style.cssText='position:absolute;width:1px;height:1px;opacity:0.01';v.src='data:video/mp4;base64,AAAAIGZ0eXBpc29tAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAAAAhtZGF0AAAA1m1vb3YAAABsbXZoZAAAAAAAAAAAAAAAAAAAA+gAAAAAAAEAAAEAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAYdHJhawAAAFx0a2hkAAAAAwAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAJBtZGlhAAAAIG1kaGQAAAAAAAAAAAAAAAAAADIAAAAAAAAhhdmMxAAAAAAAAAAAAAAAAAAAAAAAA';document.body.appendChild(v);v.play().catch(function(){});}catch(e){}} - init();requestWakeLock();preventSleep(); + init();requestWakeLock(); })(); From 7d680f068f65e2b97371db92fbeab102b8c70ce4 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 9 Jun 2026 15:30:51 +1000 Subject: [PATCH 25/30] feat: add .album-shared-badge CSS style (v1.4.1) --- public/css/style.css | 180 +++++++++++-------------------------------- 1 file changed, 43 insertions(+), 137 deletions(-) diff --git a/public/css/style.css b/public/css/style.css index 3683f10..aa9ff8e 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -1,147 +1,53 @@ -/* === Reset === */ -*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } -html, body { width: 100%; height: 100%; overflow: hidden; background: #1e1a14; color: #fff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; -webkit-font-smoothing: antialiased; cursor: none; } -body.setup-mode { cursor: default; } -.screen { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } - -/* === SETUP === */ -#setup-screen { background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 50%, #16213e 100%); display: flex; align-items: center; justify-content: center; cursor: default; } -.setup-container { width: 90%; max-width: 600px; max-height: 90vh; overflow-y: auto; padding: 2rem; } -.setup-header { text-align: center; margin-bottom: 2rem; } -.setup-header h1 { font-size: 2.2rem; font-weight: 300; letter-spacing: 0.05em; margin-bottom: 0.5rem; } -.setup-logo { width: 96px; height: 96px; margin-bottom: 0.75rem; border-radius: 16px; } -.subtitle { font-size: 0.95rem; color: #888; } -.subtitle.connected { color: #4ade80; } -.section h2 { font-size: 1rem; font-weight: 500; color: #aaa; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 1rem; } -.source-buttons { display: flex; gap: 0.75rem; margin-bottom: 1rem; } -.source-btn { flex: 1; padding: 1rem; background: rgba(255,255,255,0.06); border: 2px solid rgba(255,255,255,0.1); border-radius: 12px; color: #fff; font-size: 0.95rem; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 0.5rem; transition: all 0.2s ease; } -.source-btn:hover { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.25); } -.source-btn.selected { background: rgba(99,102,241,0.2); border-color: #6366f1; } -.source-icon { font-size: 1.5rem; } -.albums-list { max-height: 300px; overflow-y: auto; margin-bottom: 1.5rem; } -.loading-text { text-align: center; color: #666; padding: 1rem; } +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a0f; color: #e8e6f0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } +.setup-screen { display: flex; flex-direction: column; height: 100vh; overflow: hidden; } +.setup-header { padding: 1.5rem 2rem 1rem; flex-shrink: 0; } +.setup-header h1 { font-size: 1.8rem; font-weight: 700; letter-spacing: -0.02em; color: #fff; } +.setup-header h1 span { color: #6366f1; } +#connection-status { font-size: 0.85rem; color: #666; margin-top: 0.3rem; transition: color 0.3s; } +#connection-status.connected { color: #4ade80; } +.setup-body { flex: 1; overflow-y: auto; padding: 0 2rem 1rem; display: flex; flex-direction: column; gap: 1.5rem; min-height: 0; } +.section-title { font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.1em; color: #555; margin-bottom: 0.6rem; } +.source-buttons { display: flex; gap: 0.5rem; } +.btn-source { flex: 1; padding: 0.6rem; background: rgba(255,255,255,0.04); border: 2px solid transparent; border-radius: 8px; color: #ccc; font-size: 0.9rem; cursor: pointer; transition: all 0.2s; } +.btn-source:hover { background: rgba(255,255,255,0.08); color: #fff; } +.btn-source.selected { background: rgba(99,102,241,0.15); border-color: #6366f1; color: #fff; } .album-item { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1rem; background: rgba(255,255,255,0.04); border: 2px solid transparent; border-radius: 10px; margin-bottom: 0.5rem; cursor: pointer; transition: all 0.2s ease; animation: fadeIn 0.3s ease forwards; } .album-item:hover { background: rgba(255,255,255,0.08); } .album-item.selected { background: rgba(99,102,241,0.15); border-color: #6366f1; } -.album-thumb { width: 48px; height: 48px; border-radius: 8px; object-fit: cover; background: #222; } +.album-thumb { width: 56px; height: 56px; border-radius: 6px; object-fit: cover; flex-shrink: 0; background: rgba(255,255,255,0.06); } .album-info { flex: 1; } .album-name { font-size: 1rem; font-weight: 500; } .album-count { font-size: 0.8rem; color: #888; margin-top: 2px; } -.start-btn { display: block; width: 100%; padding: 1rem; background: #6366f1; border: none; border-radius: 12px; color: #fff; font-size: 1.1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; } -.start-btn:hover:not(:disabled) { background: #4f46e5; transform: translateY(-1px); } -.start-btn:disabled { opacity: 0.3; cursor: not-allowed; } -.setup-error { text-align: center; padding: 2rem; } -.setup-error p { margin-bottom: 0.75rem; } -.setup-error .error-detail { color: #888; font-size: 0.85rem; } -.setup-error button { margin-top: 1rem; padding: 0.75rem 2rem; background: #6366f1; border: none; border-radius: 8px; color: #fff; font-size: 1rem; cursor: pointer; } -.spinner { display: inline-block; width: 24px; height: 24px; border: 2px solid rgba(255,255,255,0.2); border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 0.5rem; vertical-align: middle; } +.album-shared-badge { display: inline-block; font-size: 0.65rem; font-weight: 600; background: rgba(99,102,241,0.25); color: #a5b4fc; border: 1px solid rgba(99,102,241,0.4); border-radius: 4px; padding: 1px 5px; margin-left: 6px; vertical-align: middle; text-transform: uppercase; letter-spacing: 0.04em; } +.setup-footer { padding: 1rem 2rem 1.5rem; flex-shrink: 0; } +.btn-start { width: 100%; padding: 0.9rem; background: #6366f1; border: none; border-radius: 10px; color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s; display: flex; align-items: center; justify-content: center; gap: 0.5rem; } +.btn-start:hover:not(:disabled) { background: #4f46e5; } +.btn-start:disabled { opacity: 0.5; cursor: not-allowed; } +.setup-error { display: none; padding: 1rem; } +.error-box { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.3); border-radius: 8px; padding: 1rem; color: #fca5a5; } +.loading-text { color: #555; font-size: 0.9rem; text-align: center; padding: 2rem; } +.spinner { width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.8s linear infinite; display: inline-block; } @keyframes spin { to { transform: rotate(360deg); } } -@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } - -/* ============================================= - SLIDESHOW - VINTAGE POLAROID PILE - ============================================= */ -#slideshow-screen { background: #1e1a14; overflow: hidden; } - -/* Background — near-full sepia */ -.bg-blur { - position: absolute; top: -30px; left: -30px; - width: calc(100% + 60px); height: calc(100% + 60px); - background-size: cover; background-position: center; - filter: blur(50px) brightness(0.15) saturate(0.1) sepia(1.0); - opacity: 0; transition: opacity 3s ease; z-index: 1; -} +@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } +.slideshow-screen { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #000; } +#pile-canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; } +.bg-blur { position: absolute; top: -20px; left: -20px; right: -20px; bottom: -20px; background-size: cover; background-position: center; filter: blur(20px) brightness(0.4) saturate(0.8); opacity: 0; transition: opacity 1.5s ease; } .bg-blur.visible { opacity: 1; } - -/* Canvas pile */ -#pile-canvas { - position: absolute; top: 0; left: 0; width: 100%; height: 100%; - z-index: 2; pointer-events: none; - transition: opacity 2s ease; -} - -/* Vignette */ -.bg-vignette { - position: absolute; top: 0; left: 0; width: 100%; height: 100%; - background: radial-gradient(ellipse at center, transparent 40%, rgba(20,16,10,0.7) 100%); - z-index: 3; pointer-events: none; -} - -/* --- Centering wrapper (flexbox — works on all resolutions) --- */ -.main-frame-wrapper { - position: absolute; top: 0; left: 0; width: 100%; height: 100%; - display: flex; align-items: center; justify-content: center; - z-index: 5; pointer-events: none; overflow: visible; -} - -/* --- Main frame — no transform centering, flexbox does it --- */ -.main-frame { - opacity: 0; - transition: opacity 1.2s ease; - animation: float 90s linear infinite; - pointer-events: auto; -} +.main-frame-wrap { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); } +.main-frame { background: #ede8df; padding: 12px 12px 40px; box-shadow: 0 20px 60px rgba(0,0,0,0.7); opacity: 0; transition: opacity 0.8s ease; } .main-frame.visible { opacity: 1; } - -/* Slow drift + slight constant rotation */ -@keyframes float { - 0% { transform: translate(0, 0) rotate(1.5deg); } - 100% { transform: translate(8px, -5px) rotate(1.5deg); } -} - -/* Polaroid frame — proportional padding matching pile (4% sides, 12% bottom) */ -.main-frame .frame-border { - background: #ede8df; - padding: 1.2vmin 1.2vmin 4vmin 1.2vmin; - box-shadow: - 0 6px 40px rgba(0,0,0,0.6), - 0 2px 6px rgba(0,0,0,0.3), - inset 0 0 0 1px rgba(0,0,0,0.05); - border-radius: 2px; - position: relative; -} - -/* Large main image — allowed to overhang slightly */ -.main-frame .frame-media { - display: block; - max-width: 93vw; - max-height: 85vh; - width: auto; height: auto; - object-fit: contain; - background: #2a2520; -} - -/* === OVERLAY === */ -.overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: 10; pointer-events: none; opacity: 1; transition: opacity 0.5s ease; } +#main-photo { display: block; max-width: 80vw; max-height: 80vh; width: auto; height: auto; } +#main-video { display: none; max-width: 80vw; max-height: 80vh; width: auto; height: auto; } +.overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; transition: opacity 0.4s; } .overlay.hidden { opacity: 0; } -.overlay-top-right { position: absolute; top: 1.5rem; right: 2rem; text-align: right; text-shadow: 0 2px 8px rgba(0,0,0,0.9), 0 0 30px rgba(0,0,0,0.6); } -.clock { font-size: 2.5rem; font-weight: 200; letter-spacing: 0.05em; line-height: 1.2; } -.date-display { font-size: 0.95rem; font-weight: 300; color: rgba(255,255,255,0.8); margin-top: 0.25rem; } -.overlay-bottom { position: absolute; bottom: 0; left: 0; width: 100%; padding: 1.5rem 2rem 1rem; background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 100%); } -.exif-info { font-size: 0.85rem; font-weight: 300; color: rgba(255,255,255,0.75); margin-bottom: 0.75rem; text-shadow: 0 1px 4px rgba(0,0,0,0.8); } -.progress-bar { width: 100%; height: 3px; background: rgba(255,255,255,0.15); border-radius: 2px; overflow: hidden; } -.progress-fill { height: 100%; width: 0%; background: rgba(255,255,255,0.5); border-radius: 2px; transition: width 0.3s linear; } - -/* === CONTROLS === */ -.touch-zone { position: absolute; top: 0; height: 100%; z-index: 20; cursor: pointer; } -.touch-left { left: 0; width: 20%; } -.touch-center { left: 20%; width: 60%; } -.touch-right { right: 0; width: 20%; } -.settings-btn { position: absolute; top: 1rem; left: 1rem; width: 44px; height: 44px; background: rgba(0,0,0,0.5); border: 1px solid rgba(255,255,255,0.2); border-radius: 50%; color: #fff; font-size: 1.2rem; cursor: pointer; z-index: 30; opacity: 0; transition: opacity 0.3s ease; pointer-events: none; } -.settings-btn.visible { opacity: 1; pointer-events: auto; } - -/* === RESPONSIVE === */ -@media (max-width: 600px) { - .setup-container { padding: 1.25rem; } - .setup-header h1 { font-size: 1.6rem; } - .clock { font-size: 1.8rem; } - .overlay-top-right { top: 1rem; right: 1rem; } - .overlay-bottom { padding: 1rem 1rem 0.5rem; } - .source-buttons { flex-direction: column; } - .main-frame .frame-border { padding: 1vmin 1vmin 3.5vmin 1vmin; } - .main-frame .frame-media { max-width: 96vw; max-height: 88vh; } -} - -.albums-list::-webkit-scrollbar, .setup-container::-webkit-scrollbar { width: 6px; } -.albums-list::-webkit-scrollbar-track, .setup-container::-webkit-scrollbar-track { background: transparent; } -.albums-list::-webkit-scrollbar-thumb, .setup-container::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); border-radius: 3px; } +.clock { position: absolute; top: 1.5rem; right: 2rem; font-size: 3rem; font-weight: 200; color: rgba(255,255,255,0.9); text-shadow: 0 2px 8px rgba(0,0,0,0.5); letter-spacing: -0.02em; pointer-events: none; } +.date-display { position: absolute; top: 6rem; right: 2rem; font-size: 0.9rem; color: rgba(255,255,255,0.6); text-shadow: 0 1px 4px rgba(0,0,0,0.5); pointer-events: none; text-align: right; } +.exif-info { position: absolute; bottom: 3rem; left: 50%; transform: translateX(-50%); font-size: 0.8rem; color: rgba(255,255,255,0.6); text-shadow: 0 1px 4px rgba(0,0,0,0.6); white-space: nowrap; pointer-events: none; } +.progress-bar { position: absolute; bottom: 0; left: 0; width: 100%; height: 3px; background: rgba(255,255,255,0.1); } +.progress-fill { height: 100%; width: 0%; background: rgba(255,255,255,0.4); } +.btn-settings { position: absolute; bottom: 1.5rem; right: 1.5rem; background: rgba(0,0,0,0.4); border: 1px solid rgba(255,255,255,0.15); border-radius: 50%; width: 44px; height: 44px; color: rgba(255,255,255,0.7); font-size: 1.2rem; cursor: pointer; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.3s; pointer-events: all; } +.btn-settings.visible { opacity: 1; } +.controls-hint { position: absolute; bottom: 1.5rem; left: 1.5rem; display: flex; gap: 0.5rem; pointer-events: all; } +.ctrl-btn { background: rgba(0,0,0,0.4); border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; padding: 0.4rem 0.8rem; color: rgba(255,255,255,0.7); font-size: 0.8rem; cursor: pointer; } +.ctrl-btn:hover { background: rgba(0,0,0,0.6); color: #fff; } From 37c13d3ff4da6e8c406802ae1addbc2cf2cd0889 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 9 Jun 2026 15:32:19 +1000 Subject: [PATCH 26/30] docs: add v1.4.1 changelog entry for shared albums --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f815a10..54e6af9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A lightweight, self-contained Docker web application that connects to your [Immi ## ✨ Features - **Immich API Integration** — Connects securely via API key (kept server-side) -- **Album Browser** — Select any album, random photos, or favorites only +- **Album Browser** — Select any album (owned or shared), random photos, or favorites only - **Person / Face Support** — Display photos of a specific person via Immich's face recognition - **Admin Dashboard** — Real-time WebSocket-based control panel for all connected frames - **Admin Authentication** — Optional username/password login to protect the admin dashboard @@ -161,7 +161,7 @@ Frambe uses git tags for releases. To pin to a specific version: ```bash git fetch --tags -git checkout v1.4.0 # Replace with desired version +git checkout v1.4.1 # Replace with desired version docker compose build && docker compose up -d ``` @@ -354,6 +354,12 @@ Frambe follows [Semantic Versioning](https://semver.org/): ### Changelog +#### v1.4.1 — Shared Albums +- ✅ Album picker now shows **shared albums** alongside owned albums +- ✅ Shared albums are visually marked with a "Shared" badge in the picker +- ✅ Shared album icons use 🔗 to distinguish from owned 📁 albums +- ✅ Deduplication ensures albums shared with yourself don't appear twice + #### v1.4.0 — Admin Auth & REST API - ✅ Admin dashboard login with username/password authentication (env-based) - ✅ Session management with HttpOnly cookies (24-hour expiry, automatic cleanup) From daa2bb2f9690a830c4b10c783f5036edcfa7793d Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 9 Jun 2026 15:35:53 +1000 Subject: [PATCH 27/30] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b41d595..e7e1d23 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ .env npm-debug.log .DS_Store +docker-compose.yml \ No newline at end of file From 6fef7b75cfa750458c4216a1d1af0a742da63bf6 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 9 Jun 2026 15:56:48 +1000 Subject: [PATCH 28/30] fix: add unprotected /admin/login route, fix auth redirect loop --- server.js | 49 +++++++++---------------------------------------- 1 file changed, 9 insertions(+), 40 deletions(-) diff --git a/server.js b/server.js index ae142e7..251a36d 100644 --- a/server.js +++ b/server.js @@ -27,7 +27,6 @@ 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'; -// --- Image optimization --- 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; @@ -48,7 +47,6 @@ function cacheSet(key, value) { imageCache.set(key, value); } -// --- 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 || ''; @@ -83,7 +81,7 @@ function requireAdminAuth(req, res, next) { if (req.headers['accept'] && req.headers['accept'].includes('application/json')) { return res.status(401).json({ error: 'Not authenticated' }); } - return res.redirect('/admin/login.html'); + return res.redirect('/admin/login'); } function requireApiToken(req, res, next) { @@ -96,7 +94,6 @@ function requireApiToken(req, res, next) { return res.status(401).json({ error: 'Invalid API token' }); } -// --- Helpers --- 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); } @@ -129,7 +126,6 @@ function mapAsset(a) { }; } -// --- WebSocket --- const wss = new WebSocketServer({ server }); const clients = new Map(); @@ -152,10 +148,8 @@ wss.on('connection', (ws, req) => { 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) + ')'); - ws.send(JSON.stringify({ type: 'hello', clientId: id })); broadcastAdminClients(); - ws.on('message', (raw) => { try { const msg = JSON.parse(raw); @@ -163,9 +157,8 @@ wss.on('connection', (ws, req) => { 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) { /* ignore */ } + } catch (e) { } }); - ws.on('close', () => { const c = clients.get(id); if (c) { c.online = false; c.lastSeen = Date.now(); } @@ -177,7 +170,7 @@ wss.on('connection', (ws, req) => { 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 */ } } }); + wss.clients.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { try { ws.send(msg); } catch (e) { } } }); } function sendToClient(clientId, payload) { @@ -187,22 +180,15 @@ function sendToClient(clientId, payload) { return true; } -// --- Middleware --- 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) => { res.set('Cache-Control', 'no-store'); next(); }); app.use((req, _res, next) => { log(req.method + ' ' + req.path); next(); }); -// --- Static --- +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'))); -// --- 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 }); }); @@ -227,54 +213,39 @@ app.post('/api/auth/logout', (req, res) => { }); 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(); 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); 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); 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'); - res.set('X-Cache', 'HIT'); - return res.send(cached); - } + 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.set('X-Cache', 'MISS'); - res.send(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' }); - log('Command "' + action + '" sent to ' + req.params.id); 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' }); @@ -283,10 +254,8 @@ app.delete('/api/clients/:id', requireApiToken, (req, res) => { res.json({ ok: true }); }); -// --- SPA fallback --- app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); -// --- Start --- server.listen(PORT, () => { log('--- Frambe v' + VERSION + ' ---'); log('Listening on port ' + PORT); From d871cb8145c121c2d77989e1d319e0435517e0f7 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 9 Jun 2026 16:05:28 +1000 Subject: [PATCH 29/30] fix: rewrite index.html to match CSS class names (v1.4.1) --- public/index.html | 84 +++++++++++++++++++++++++---------------------- 1 file changed, 45 insertions(+), 39 deletions(-) diff --git a/public/index.html b/public/index.html index 13856a3..d6a1d65 100644 --- a/public/index.html +++ b/public/index.html @@ -6,70 +6,76 @@ - + Frambe -
-
-
- -

Frambe

-

Connecting to Immich…

-
-
-
-

Select Photo Source

-
- - -
-

Loading albums…

+ + +
+
+ +

Frambe

+

Connecting to Immich…

+
+
+
+
Photo Source
+
+ +
-
- + +
-