15 Commits

Author SHA1 Message Date
jessikitty 31c228b172 v1.5.2 - /api/time endpoint + TZ-aware boot log for sleep schedule 2026-06-15 10:35:33 +10:00
jessikitty 8c5a14c818 v1.5.2 - admin: live server clock + UTC warning, /api/time ref 2026-06-15 10:33:39 +10:00
jessikitty 2042648939 v1.5.2 - document TZ variable 2026-06-15 10:31:28 +10:00
jessikitty cd51cb6d69 v1.5.2 - add tzdata for TZ-aware sleep schedule 2026-06-15 10:31:16 +10:00
jessikitty ce6da1d714 v1.5.1 - version bump 2026-06-15 09:29:31 +10:00
jessikitty 9c0e5dafe8 v1.5.1 - refresh env template with volume/persistence note 2026-06-15 09:27:22 +10:00
jessikitty e444a457f5 v1.5.1 - create writable /app/data for settings volume 2026-06-15 09:27:11 +10:00
jessikitty b9389c683d v1.5.1 - secrets moved to .env, named volume for persistent settings 2026-06-15 09:27:02 +10:00
jessikitty 279a04fce9 v1.5.1 - untrack docker-compose.yml (now secret-free, env_file based) 2026-06-15 09:26:55 +10:00
jessikitty 5e1c9b6d42 v1.5.0 - admin: global settings panel, per-client control/sleep override, settings API ref 2026-06-15 08:54:41 +10:00
jessikitty b7f3dd4645 v1.5.0 - client: server-controlled defaults, persistent ID, hello/welcome fix, sleep handling 2026-06-15 08:52:20 +10:00
jessikitty 3cc236c3d9 v1.5.0 - global settings, sleep scheduler, robust client dedup/pruning, server-controlled defaults 2026-06-15 08:50:26 +10:00
jessikitty 69139a868a v1.5.0 - gitignore runtime data/ settings dir 2026-06-15 08:48:22 +10:00
jessikitty da51a4bc18 v1.4.3 - persistent device ID via localStorage so refresh reuses same client slot 2026-06-10 07:08:05 +00:00
jessikitty 10f9683e81 v1.4.3 - persistent device ID via localStorage so refresh reuses same client slot 2026-06-10 07:04:57 +00:00
7 changed files with 361 additions and 59 deletions
+13 -3
View File
@@ -1,6 +1,12 @@
# === Frambe Configuration ===
# Copy this file to .env and fill in your real values:
# cp .env.example .env
# .env is gitignored and holds your secrets. docker compose reads it automatically.
# REQUIRED
# Container timezone — the sleep schedule uses this clock. Find yours at
# https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
TZ=Australia/Melbourne
IMMICH_URL=http://your-immich-server:2283
IMMICH_API_KEY=your-api-key-here
@@ -22,14 +28,18 @@ SHOW_PROGRESS=true
# ALBUM_ID=
# SHOW_FAVORITES_ONLY=false
# Admin Authentication (optional — leave ADMIN_PASSWORD blank to disable)
# Admin Authentication (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
# 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)
# Server (internal port — Docker maps externally to 3030 via docker-compose)
PORT=3000
# NOTE: Global settings (default source, sleep schedule, etc.) are saved to
# /app/data/settings.json inside the container, backed by the named volume
# `frambe_data` in docker-compose.yml — they persist across rebuilds.
+1 -1
View File
@@ -2,4 +2,4 @@ node_modules/
.env
npm-debug.log
.DS_Store
docker-compose.yml
data/
+10 -1
View File
@@ -3,6 +3,10 @@ FROM node:18-alpine
LABEL maintainer="Frambe"
LABEL description="Frambe — lightweight digital photo frame for Immich"
# tzdata lets the TZ env var set the container's local time, which the
# sleep scheduler relies on. Without it Alpine defaults to UTC.
RUN apk add --no-cache tzdata
WORKDIR /app
COPY package.json ./
@@ -17,7 +21,12 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/api/config || exit 1
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
adduser -S appuser -u 1001 -G appgroup && \
mkdir -p /app/data && \
chown -R appuser:appgroup /app/data
VOLUME ["/app/data"]
USER appuser
CMD ["node", "server.js"]
+9 -29
View File
@@ -7,33 +7,13 @@ services:
restart: unless-stopped
ports:
- "3030:3000"
environment:
# REQUIRED
- IMMICH_URL=http://10.0.0.40:2283
- IMMICH_API_KEY=0G1iJ4ydmA0ghjMk1OTcdFhxUbhAgfti2higMKFmU
- ADMIN_USERNAME=jessikitty
- ADMIN_PASSWORD=23Pinkpr!ncesses
- FRAMBE_API_TOKEN=whosethatgirl-itsjess
volumes:
# Persists global settings (data/settings.json) across rebuilds.
- frambe_data:/app/data
# All configuration & secrets live in .env (see .env.example).
# .env is gitignored — copy .env.example to .env and fill in your values.
env_file:
- .env
# Slideshow
- SLIDESHOW_INTERVAL=300
- TRANSITION_DURATION=5
- 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=false
- SHOW_PROGRESS=true
# - 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
volumes:
frambe_data:
+127 -7
View File
@@ -25,6 +25,7 @@
.badge{font-size:.65rem;padding:2px 8px;border-radius:10px;text-transform:uppercase;font-weight:600;letter-spacing:.3px}
.badge.online{background:rgba(74,222,128,.15);color:#86efac} .badge.offline{background:rgba(255,255,255,.06);color:#777}
.badge.playing{background:rgba(96,165,250,.15);color:#93c5fd} .badge.sleeping{background:rgba(251,191,36,.15);color:#fcd34d}
.badge.auto{background:rgba(168,85,247,.15);color:#d8b4fe} .badge.manual{background:rgba(148,163,184,.15);color:#cbd5e1}
.name-input{background:transparent;border:1px solid rgba(255,255,255,.12);border-radius:6px;color:#fff;font-size:.85rem;padding:3px 7px;width:130px}
.name-input:focus{outline:none;border-color:#6366f1}
.info-row{font-size:.72rem;color:#666;margin-bottom:.75rem;display:flex;gap:1rem;flex-wrap:wrap}
@@ -43,6 +44,8 @@
select:focus{outline:none;border-color:#6366f1}
option{background:#1a1a2e;color:#e0e0e0}
input[type=range]{width:110px;accent-color:#6366f1}
input[type=time]{padding:4px 8px;border:1px solid rgba(255,255,255,.12);border-radius:7px;background:rgba(255,255,255,.05);color:#ddd;font-size:.78rem}
input[type=time]:focus{outline:none;border-color:#6366f1}
.rval{font-size:.78rem;color:#999;min-width:28px}
.tgl{position:relative;width:36px;height:20px;cursor:pointer}.tgl input{display:none}
.tgl-s{position:absolute;inset:0;background:rgba(255,255,255,.08);border-radius:10px;transition:.2s}
@@ -52,6 +55,9 @@
.empty h2{font-size:1.1rem;font-weight:400;margin-bottom:.4rem;color:#777}
.ws-pill{font-size:.72rem;padding:3px 9px;border-radius:20px}
.ws-pill.on{background:rgba(34,197,94,.12);color:#86efac}.ws-pill.off{background:rgba(239,68,68,.12);color:#fca5a5}
.srv-clock{font-size:.78rem;font-family:monospace;padding:3px 10px;border-radius:20px;background:rgba(255,255,255,.06);color:#cbd5e1;border:1px solid rgba(255,255,255,.1);white-space:nowrap}
.srv-clock .tz{color:#888;font-size:.68rem;margin-left:5px}
.srv-clock.warn{background:rgba(234,179,8,.12);border-color:rgba(234,179,8,.4);color:#fde68a}
.divider{border:none;border-top:1px solid rgba(255,255,255,.05);margin:.4rem 0}
.offline-msg{font-size:.78rem;color:#666;text-align:center;padding:.6rem}
.sec-head{font-size:1.15rem;font-weight:300;margin:2rem 0 .75rem;padding-top:1.25rem;border-top:1px solid rgba(255,255,255,.08);display:flex;align-items:center;justify-content:space-between;cursor:pointer}
@@ -60,7 +66,7 @@
.api-card{background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.05);border-radius:10px;padding:1rem;margin-bottom:.6rem}
.api-card h3{font-size:.85rem;font-weight:500;color:#a5b4fc;margin-bottom:.4rem}
.mtd{display:inline-block;font-size:.65rem;font-weight:700;padding:1px 5px;border-radius:3px;margin-right:5px}
.mtd.get{background:rgba(34,197,94,.18);color:#86efac}.mtd.post{background:rgba(59,130,246,.18);color:#93c5fd}.mtd.del{background:rgba(239,68,68,.18);color:#fca5a5}
.mtd.get{background:rgba(34,197,94,.18);color:#86efac}.mtd.post{background:rgba(59,130,246,.18);color:#93c5fd}.mtd.del{background:rgba(239,68,68,.18);color:#fca5a5}.mtd.put{background:rgba(234,179,8,.18);color:#fde68a}
.ep{font-family:monospace;font-size:.82rem;color:#bbb}
.api-card p{font-size:.75rem;color:#777;margin:.3rem 0}
pre{background:rgba(0,0,0,.25);border:1px solid rgba(255,255,255,.06);border-radius:7px;padding:8px 10px;font-size:.72rem;color:#bbb;overflow-x:auto;margin-top:.4rem;white-space:pre-wrap;word-break:break-all;position:relative}
@@ -78,6 +84,18 @@
.input-row input{padding:6px 10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);border-radius:6px;color:#ddd;font-size:.82rem;width:100%}
.input-row input:focus{outline:none;border-color:#6366f1}
.input-row .field{flex:1;min-width:140px}
/* Global settings panel */
.settings-panel{background:rgba(99,102,241,.05);border:1px solid rgba(99,102,241,.2);border-radius:12px;padding:1.25rem;margin-bottom:1rem}
.settings-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:1.25rem}
.settings-block h3{font-size:.82rem;font-weight:600;color:#a5b4fc;text-transform:uppercase;letter-spacing:.4px;margin-bottom:.6rem}
.srow{display:flex;align-items:center;gap:.6rem;margin-bottom:.55rem;flex-wrap:wrap}
.srow .clbl{min-width:80px}
.save-bar{display:flex;align-items:center;gap:1rem;margin-top:1rem;padding-top:1rem;border-top:1px solid rgba(255,255,255,.06)}
.save-msg{font-size:.78rem;color:#86efac;opacity:0;transition:opacity .3s}.save-msg.show{opacity:1}
.btn.save{background:rgba(99,102,241,.25);border-color:rgba(99,102,241,.5);color:#c7d2fe;font-weight:500;padding:7px 18px}
.btn.save:hover{background:rgba(99,102,241,.4)}
.sleep-fields{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}
.hint{font-size:.68rem;color:#666;margin-top:.3rem;line-height:1.4}
</style>
</head>
<body>
@@ -85,17 +103,60 @@
<img src="/img/icon.png" alt="Frambe" onerror="this.style.display='none'">
<div><h1>Frambe Admin</h1><span class="version" id="ver">Connecting...</span></div>
<div class="header-right">
<span class="srv-clock" id="srv-clock" title="Server local time — sleep schedule uses this clock">--:--</span>
<span class="ws-pill off" id="ws-pill">Disconnected</span>
<button class="btn logout" id="logout-btn" style="display:none" onclick="doLogout()">Logout</button>
</div>
</div>
<div class="grid" id="grid"><div class="empty"><h2>No frames seen yet</h2><p>Open Frambe on a tablet or screen to see it here</p></div></div>
<!-- Global Settings -->
<div class="sec-head" onclick="toggle('gs-sec','gs-arr')" style="margin-top:0;padding-top:0;border-top:none">Global Settings <span class="arr open" id="gs-arr">&#9654;</span></div>
<div id="gs-sec">
<div class="settings-panel">
<div class="settings-grid">
<div class="settings-block">
<h3>Default Photo Source</h3>
<div class="srow"><span class="clbl">Source</span>
<select id="gs-source"><option value="random">Random Photos</option><option value="favorites">Favorites</option></select>
</div>
<div class="hint">New frames (and any frame set to follow the server) start with this source automatically.</div>
</div>
<div class="settings-block">
<h3>Display Defaults</h3>
<div class="srow"><span class="clbl">Interval</span><input type="range" id="gs-interval" min="5" max="600" value="30" oninput="document.getElementById('gs-interval-v').textContent=this.value+'s'"><span class="rval" id="gs-interval-v">30s</span></div>
<div class="srow"><span class="clbl">Clock</span><label class="tgl"><input type="checkbox" id="gs-clock" checked><span class="tgl-s"></span></label>
<span class="clbl">Date</span><label class="tgl"><input type="checkbox" id="gs-date" checked><span class="tgl-s"></span></label>
<span class="clbl">EXIF</span><label class="tgl"><input type="checkbox" id="gs-exif"><span class="tgl-s"></span></label>
<span class="clbl">Bar</span><label class="tgl"><input type="checkbox" id="gs-progress" checked><span class="tgl-s"></span></label></div>
</div>
<div class="settings-block">
<h3>Sleep Schedule</h3>
<div class="srow"><span class="clbl">Enabled</span><label class="tgl"><input type="checkbox" id="gs-sleep-on"><span class="tgl-s"></span></label></div>
<div class="srow sleep-fields"><span class="clbl">Sleep at</span><input type="time" id="gs-sleep-at" value="23:00"><span class="clbl">Wake at</span><input type="time" id="gs-wake-at" value="06:00"></div>
<div class="hint">Frames sleep (black screen) during this window. Crosses midnight automatically. Uses the server's local time (shown top-right).</div>
</div>
</div>
<div class="save-bar">
<button class="btn save" onclick="saveSettings()">Save Global Settings</button>
<span class="save-msg" id="gs-saved">Saved &amp; applied to all server-controlled frames</span>
</div>
</div>
</div>
<div class="sec-head" onclick="toggle('frames-sec','frames-arr')">Frames <span class="arr open" id="frames-arr">&#9654;</span></div>
<div id="frames-sec">
<div class="grid" id="grid"><div class="empty"><h2>No frames seen yet</h2><p>Open Frambe on a tablet or screen to see it here</p></div></div>
</div>
<div class="sec-head" onclick="toggle('api-sec','api-arr')">REST API Reference <span class="arr" id="api-arr">&#9654;</span></div>
<div id="api-sec" style="display:none">
<div class="api-card"><h3><span class="mtd get">GET</span><span class="ep">/api/clients</span></h3><p>List all known frames with status, IP, name, and config.</p><pre id="c-list">curl -s -H "Authorization: Bearer YOUR_TOKEN" http://YOUR_HOST:3030/api/clients<span class="cpb" onclick="cc('c-list')">Copy</span></pre></div>
<div class="api-card"><h3><span class="mtd post">POST</span><span class="ep">/api/clients/:id/command</span></h3><p>Send a command to a frame. Actions: <code>start</code> <code>stop</code> <code>next</code> <code>prev</code> <code>sleep</code> <code>wake</code> <code>refresh</code> <code>setSource</code> <code>setConfig</code></p><pre id="c-cmd">curl -s -X POST -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" -d '{"action":"next"}' http://YOUR_HOST:3030/api/clients/CLIENT_ID/command<span class="cpb" onclick="cc('c-cmd')">Copy</span></pre></div>
<div class="api-card"><h3><span class="mtd del">DELETE</span><span class="ep">/api/clients/:id</span></h3><p>Remove a frame from the registry.</p><pre id="c-del">curl -s -X DELETE -H "Authorization: Bearer YOUR_TOKEN" http://YOUR_HOST:3030/api/clients/CLIENT_ID<span class="cpb" onclick="cc('c-del')">Copy</span></pre></div>
<div class="api-card"><h3><span class="mtd get">GET</span><span class="ep">/api/settings</span> &middot; <span class="mtd put">PUT</span><span class="ep">/api/settings</span></h3><p>Read or update global settings (default source, interval, display toggles, sleep schedule). PUT requires the API token.</p><pre id="c-set">curl -s -X PUT -H "Authorization: Bearer YOUR_TOKEN" -H "Content-Type: application/json" -d '{"sleep":{"enabled":true,"sleepAt":"23:00","wakeAt":"06:00"}}' http://YOUR_HOST:3030/api/settings<span class="cpb" onclick="cc('c-set')">Copy</span></pre></div>
<div class="api-card"><h3><span class="mtd get">GET</span><span class="ep">/api/time</span></h3><p>Server local time and timezone (the clock the sleep schedule uses).</p><pre id="c-time">curl -s http://YOUR_HOST:3030/api/time<span class="cpb" onclick="cc('c-time')">Copy</span></pre></div>
</div>
<div class="modal-overlay" id="ha-modal">
<div class="modal">
<button class="modal-close" onclick="closeModal()">&times;</button>
@@ -112,7 +173,7 @@
</div>
</div>
<script>
var ws=null,D={},albums=[],people=[],auth=false;
var ws=null,D={},albums=[],people=[],auth=false,settings=null,settingsDirty=false;
(async function(){try{var r=await(await fetch('/api/auth/status')).json();auth=r.authEnabled;if(auth)document.getElementById('logout-btn').style.display='';}catch(e){}})();
async function doLogout(){try{await fetch('/api/auth/logout',{method:'POST'});}catch(e){}location.href='/admin/login';}
function toggle(sid,aid){var s=document.getElementById(sid),a=document.getElementById(aid);if(s.style.display==='none'){s.style.display='block';a.classList.add('open');}else{s.style.display='none';a.classList.remove('open');}}
@@ -123,13 +184,63 @@ function connect(){
var p=location.protocol==='https:'?'wss:':'ws:';
ws=new WebSocket(p+'//'+location.host+'/ws');
ws.onopen=function(){document.getElementById('ws-pill').textContent='Connected';document.getElementById('ws-pill').className='ws-pill on';ws.send(JSON.stringify({type:'register',role:'admin'}));loadMeta();};
ws.onmessage=function(e){var m=JSON.parse(e.data);if(m.type==='clientList'){D={};m.clients.forEach(function(c){D[c.id]=c;});render();}else if(m.type==='clientUpdate'){D[m.clientId]=m.client;render();}};
ws.onmessage=function(e){var m=JSON.parse(e.data);if(m.type==='clientList'){D={};m.clients.forEach(function(c){D[c.id]=c;});if(m.settings)applySettingsToForm(m.settings);render();}else if(m.type==='settings'||m.type==='settingsSaved'){applySettingsToForm(m.settings);if(m.type==='settingsSaved')flashSaved();}else if(m.type==='clientUpdate'){D[m.clientId]=m.client;render();}};
ws.onclose=function(){document.getElementById('ws-pill').textContent='Disconnected';document.getElementById('ws-pill').className='ws-pill off';setTimeout(connect,3000);};
}
async function loadMeta(){try{var c=await(await fetch('/api/config')).json();document.getElementById('ver').textContent='v'+(c.version||'?');albums=await(await fetch('/api/albums')).json();people=await(await fetch('/api/people')).json();}catch(e){}}
async function loadMeta(){try{var c=await(await fetch('/api/config')).json();document.getElementById('ver').textContent='v'+(c.version||'?');albums=await(await fetch('/api/albums')).json();people=await(await fetch('/api/people')).json();populateGsSource();if(settings)applySettingsToForm(settings);}catch(e){}}
// === SERVER CLOCK ===
// Sleep schedule is evaluated in the server's local timezone, so show it here.
// Anchor to one /api/time fetch, then tick locally; re-sync periodically.
var srvOffsetMs=null,srvTz='';
async function syncServerTime(){try{var t=await(await fetch('/api/time')).json();srvOffsetMs=t.epoch-Date.now();srvTz=t.tz||('UTC'+(t.offsetMinutes>=0?'+':'')+(t.offsetMinutes/60));}catch(e){}}
function p2(n){return n<10?'0'+n:''+n;}
function tickServerClock(){if(srvOffsetMs===null)return;var d=new Date(Date.now()+srvOffsetMs);var el=document.getElementById('srv-clock');if(!el)return;var warn=(srvTz==='UTC'||srvTz==='Etc/UTC');el.innerHTML=p2(d.getUTCHours())+':'+p2(d.getUTCMinutes())+':'+p2(d.getUTCSeconds())+'<span class="tz">'+esc(srvTz)+(warn?' ⚠':'')+'</span>';el.className='srv-clock'+(warn?' warn':'');el.title=warn?'Server is on UTC — sleep times will be offset from your local time. Set TZ in docker-compose.':'Server local time — sleep schedule uses this clock';}
syncServerTime().then(tickServerClock);setInterval(tickServerClock,1000);setInterval(syncServerTime,300000);
// === GLOBAL SETTINGS FORM ===
function populateGsSource(){
var sel=document.getElementById('gs-source'),cur=sel.value;
var h='<option value="random">Random Photos</option><option value="favorites">Favorites</option>';
albums.forEach(function(a){h+='<option value="album:'+a.id+'">'+esc(a.albumName)+' ('+a.assetCount+')</option>';});
people.filter(function(p){return p.name;}).forEach(function(p){h+='<option value="person:'+p.id+'">'+esc(p.name)+'</option>';});
sel.innerHTML=h;sel.value=cur;
}
function sourceToValue(s){if(!s)return'random';if(s.source==='album'&&s.albumId)return'album:'+s.albumId;if(s.source==='person'&&s.personId)return'person:'+s.personId;return s.source||'random';}
function valueToSource(v){if(v==='random')return{source:'random',albumId:null,personId:null};if(v==='favorites')return{source:'favorites',albumId:null,personId:null};if(v.indexOf('album:')===0)return{source:'album',albumId:v.substring(6),personId:null};if(v.indexOf('person:')===0)return{source:'person',personId:v.substring(7),albumId:null};return{source:'random',albumId:null,personId:null};}
function applySettingsToForm(s){settings=s;if(!s)return;
var srcVal=sourceToValue(s.source);var sel=document.getElementById('gs-source');
if(!Array.prototype.some.call(sel.options,function(o){return o.value===srcVal;}))populateGsSource();
sel.value=srcVal;
document.getElementById('gs-interval').value=s.slideshowInterval||30;document.getElementById('gs-interval-v').textContent=(s.slideshowInterval||30)+'s';
document.getElementById('gs-clock').checked=s.showClock!==false;
document.getElementById('gs-date').checked=s.showDate!==false;
document.getElementById('gs-exif').checked=s.showExif!==false;
document.getElementById('gs-progress').checked=s.showProgress!==false;
document.getElementById('gs-sleep-on').checked=!!(s.sleep&&s.sleep.enabled);
document.getElementById('gs-sleep-at').value=(s.sleep&&s.sleep.sleepAt)||'23:00';
document.getElementById('gs-wake-at').value=(s.sleep&&s.sleep.wakeAt)||'06:00';
}
function saveSettings(){
var patch={source:valueToSource(document.getElementById('gs-source').value),
slideshowInterval:parseInt(document.getElementById('gs-interval').value,10),
showClock:document.getElementById('gs-clock').checked,
showDate:document.getElementById('gs-date').checked,
showExif:document.getElementById('gs-exif').checked,
showProgress:document.getElementById('gs-progress').checked,
sleep:{enabled:document.getElementById('gs-sleep-on').checked,
sleepAt:document.getElementById('gs-sleep-at').value,
wakeAt:document.getElementById('gs-wake-at').value}};
if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'updateSettings',settings:patch}));
}
function flashSaved(){var m=document.getElementById('gs-saved');m.classList.add('show');setTimeout(function(){m.classList.remove('show');},2500);}
function cmd(id,a,pl){if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'adminCommand',targetId:id,action:a,payload:pl||{}}));}
function ren(id,n){if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'renameClient',targetId:id,name:n}));}
function rem(id,n){if(confirm('Remove "'+(n||id)+'" from client list?'))if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'removeClient',targetId:id}));}
function setControl(id,v){if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'setClientControl',targetId:id,serverControlled:v}));}
function setSleep(id){var on=document.getElementById('cs-on-'+id).checked,sa=document.getElementById('cs-at-'+id).value,wa=document.getElementById('cs-wake-'+id).value;if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'setClientSleep',targetId:id,sleep:{override:true,enabled:on,sleepAt:sa,wakeAt:wa}}));}
function clearSleepOverride(id){if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'setClientSleep',targetId:id,sleep:{override:false}}));}
function src(id,v){if(!v)return;if(v==='random')cmd(id,'setSource',{source:'random'});else if(v==='favorites')cmd(id,'setSource',{source:'favorites'});else if(v.indexOf('album:')===0)cmd(id,'setSource',{source:'album',albumId:v.substring(6)});else if(v.indexOf('person:')===0)cmd(id,'setSource',{source:'person',personId:v.substring(7)});}
var haClientId='';
function openHaModal(id,name){haClientId=id;document.getElementById('ha-client-name').textContent='Generating for: '+(name||id);document.getElementById('ha-cid').value=id;document.getElementById('ha-host').value=location.host;document.getElementById('ha-modal').classList.add('open');genYaml();}
@@ -153,9 +264,10 @@ function render(){
var h='';
ids.forEach(function(id){var c=D[id],off=c.status==='offline';
var sc=off?'offline':c.status==='playing'?'playing':c.status==='sleeping'?'sleeping':'online';var cfg=c.config||{};
var auto=c.serverControlled!==false;var csleep=cfg.sleep||{};
h+='<div class="card'+(off?' offline':'')+'">';
h+='<div class="card-head"><div><div class="card-name"><span class="dot '+sc+'"></span><input class="name-input" value="'+esc(c.name||'')+'" placeholder="'+esc(c.ip)+'" onchange="ren(\''+id+'\',this.value)"/></div><div class="card-ip">'+esc(c.ip)+' &middot; ID: <strong>'+esc(id)+'</strong></div></div>';
h+='<div class="card-meta"><span class="badge '+sc+'">'+esc(c.status||'unknown')+'</span>';
h+='<div class="card-meta"><span class="badge '+sc+'">'+esc(c.status||'unknown')+'</span><span class="badge '+(auto?'auto':'manual')+'">'+(auto?'Server':'Manual')+'</span>';
if(off)h+='<button class="btn sm red" onclick="rem(\''+id+'\',\''+esc(c.name||c.ip)+'\')">Remove</button>';
h+='</div></div>';
h+='<div class="info-row">';
@@ -167,6 +279,8 @@ function render(){
h+='<div class="crow" style="justify-content:center"><button class="btn blue sm" onclick="openHaModal(\''+id+'\',\''+esc(c.name||c.ip)+'\')">Generate HA YAML</button></div>';
} else {
h+='<div class="controls">';
h+='<div class="crow"><span class="clbl">Control</span><label class="tgl"><input type="checkbox" '+(auto?'checked':'')+' onchange="setControl(\''+id+'\',this.checked)"><span class="tgl-s"></span></label><span class="clbl" style="min-width:auto">'+(auto?'Following server defaults':'Manual override')+'</span></div>';
h+='<hr class="divider">';
h+='<div class="crow"><span class="clbl">Source</span><select onchange="src(\''+id+'\',this.value)"><option value="">-- Select --</option><option value="random">Random Photos</option><option value="favorites">Favorites</option>';
albums.forEach(function(a){h+='<option value="album:'+a.id+'">'+esc(a.albumName)+' ('+a.assetCount+')</option>';});
people.filter(function(p){return p.name;}).forEach(function(p){h+='<option value="person:'+p.id+'">'+esc(p.name)+'</option>';});
@@ -174,11 +288,17 @@ function render(){
h+='<div class="crow"><span class="clbl">Playback</span><button class="btn grn" onclick="cmd(\''+id+'\',\'start\')">Start</button><button class="btn" onclick="cmd(\''+id+'\',\'stop\')">Stop</button><button class="btn" onclick="cmd(\''+id+'\',\'next\')">Next</button><button class="btn" onclick="cmd(\''+id+'\',\'prev\')">Prev</button></div>';
h+='<div class="crow"><span class="clbl">Power</span><button class="btn red" onclick="cmd(\''+id+'\',\'sleep\')">Sleep</button><button class="btn grn" onclick="cmd(\''+id+'\',\'wake\')">Wake</button><button class="btn" onclick="cmd(\''+id+'\',\'refresh\')">Refresh</button></div>';
h+='<hr class="divider">';
h+='<div class="crow"><span class="clbl">Interval</span><input type="range" min="5" max="120" value="'+(cfg.slideshowInterval||30)+'" oninput="this.nextElementSibling.textContent=this.value+\'s\'" onchange="cmd(\''+id+'\',\'setConfig\',{slideshowInterval:parseInt(this.value)})"><span class="rval">'+(cfg.slideshowInterval||30)+'s</span></div>';
h+='<div class="crow"><span class="clbl">Interval</span><input type="range" min="5" max="600" value="'+(cfg.slideshowInterval||30)+'" oninput="this.nextElementSibling.textContent=this.value+\'s\'" onchange="cmd(\''+id+'\',\'setConfig\',{slideshowInterval:parseInt(this.value)})"><span class="rval">'+(cfg.slideshowInterval||30)+'s</span></div>';
h+='<div class="crow"><span class="clbl">Clock</span><label class="tgl"><input type="checkbox" '+(cfg.showClock!==false?'checked':'')+' onchange="cmd(\''+id+'\',\'setConfig\',{showClock:this.checked})"><span class="tgl-s"></span></label>';
h+='<span class="clbl">Date</span><label class="tgl"><input type="checkbox" '+(cfg.showDate!==false?'checked':'')+' onchange="cmd(\''+id+'\',\'setConfig\',{showDate:this.checked})"><span class="tgl-s"></span></label>';
h+='<span class="clbl">EXIF</span><label class="tgl"><input type="checkbox" '+(cfg.showExif!==false?'checked':'')+' onchange="cmd(\''+id+'\',\'setConfig\',{showExif:this.checked})"><span class="tgl-s"></span></label></div>';
h+='<hr class="divider">';
h+='<div class="crow"><span class="clbl">Sleep</span><label class="tgl"><input type="checkbox" id="cs-on-'+id+'" '+(csleep.override&&csleep.enabled?'checked':'')+' onchange="setSleep(\''+id+'\')"><span class="tgl-s"></span></label>';
h+='<input type="time" id="cs-at-'+id+'" value="'+(csleep.sleepAt||'23:00')+'" onchange="setSleep(\''+id+'\')"><span class="clbl" style="min-width:auto">to</span><input type="time" id="cs-wake-'+id+'" value="'+(csleep.wakeAt||'06:00')+'" onchange="setSleep(\''+id+'\')"></div>';
h+='<div class="crow"><span class="clbl"></span><span style="font-size:.68rem;color:#666">'+(csleep.override?'Per-frame override active.':'Following global schedule.')+'</span>';
if(csleep.override)h+='<button class="btn sm" onclick="clearSleepOverride(\''+id+'\')">Use global</button>';
h+='</div>';
h+='<hr class="divider">';
h+='<div class="crow"><button class="btn blue" onclick="openHaModal(\''+id+'\',\''+esc(c.name||c.ip)+'\')">Generate HA YAML</button></div>';
h+='</div>';
}
+29 -4
View File
@@ -1,4 +1,4 @@
// === Frambe v1.4.1 - Client with WebSocket Remote Control ===
// === Frambe v1.5.0 - Client with WebSocket Remote Control + Server-Controlled Defaults ===
(function () {
'use strict';
var config = {}, assets = [], currentIndex = -1, slideshowTimer = null;
@@ -6,14 +6,39 @@
var selectedPersonId = null, isRunning = false, refreshTimer = null, urlDriven = false;
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 wsConn = null, clientId = null, isSleeping = false, serverControlled = true;
var persistentId = (function(){ var k='frambe_pid'; var v=localStorage.getItem(k); if(!v){ v='fp-'+Math.random().toString(36).substr(2,9)+'-'+Date.now().toString(36); localStorage.setItem(k,v); } return v; })();
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');
// === 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 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',persistentId:persistentId,status:isRunning?'playing':(isSleeping?'sleeping':'idle'),config:getCurrentConfig(),source:currentSourceDescriptor()}));};wsConn.onmessage=function(e){try{var msg=JSON.parse(e.data);if(msg.type==='hello'||msg.type==='welcome'){clientId=msg.clientId;console.log('[Frambe] Registered as '+clientId);}else if(msg.type==='command'){handleRemoteCommand(msg.action,msg.payload||{});}else if(msg.type==='serverConfig'){applyServerConfig(msg.config||{});}}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(),source:currentSourceDescriptor()}));}
function getCurrentConfig(){return{slideshowInterval:config.slideshowInterval,showClock:config.showClock,showDate:config.showDate,showExif:config.showExif,showProgress:config.showProgress};}
function currentSourceDescriptor(){if(!selectedSource)return null;return{source:selectedSource,albumId:selectedAlbumId||null,personId:selectedPersonId||null};}
// === SERVER-CONTROLLED DEFAULTS ===
// The server pushes a resolved config (default source, timers, display toggles, sleep
// schedule) for clients that haven't been manually overridden. Applying it here makes
// a freshly opened frame inherit the global settings without any local setup.
function applyServerConfig(sc){
console.log('[Frambe] Server config received');
// Display + timer defaults
if('slideshowInterval'in sc)config.slideshowInterval=sc.slideshowInterval;
if('showClock'in sc)config.showClock=sc.showClock;
if('showDate'in sc)config.showDate=sc.showDate;
if('showExif'in sc)config.showExif=sc.showExif;
if('showProgress'in sc)config.showProgress=sc.showProgress;
if(isRunning)applyConfigChange({slideshowInterval:config.slideshowInterval,showClock:config.showClock,showDate:config.showDate,showExif:config.showExif,showProgress:config.showProgress});
// Sleep schedule state — if the server says we are inside the sleep window, honour it.
if(sc.sleep){if(sc.sleep.sleeping&&!isSleeping)goToSleep();else if(sc.sleep.sleeping===false&&isSleeping)wakeUp();}
// Default photo source — only auto-start if nothing is playing yet and no URL/local choice was made.
if(sc.source&&sc.source.source&&!isRunning&&!urlDriven&&!selectedSource){
selectedSource=sc.source.source;selectedAlbumId=sc.source.albumId||null;selectedPersonId=sc.source.personId||null;
console.log('[Frambe] Auto-start from server default: '+selectedSource);
if(!(sc.sleep&&sc.sleep.sleeping))doStartSlideshow();
}
}
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');}
+172 -14
View File
@@ -2,12 +2,13 @@ const express = require('express');
const fetch = require('node-fetch');
const path = require('path');
const http = require('http');
const fs = require('fs');
const crypto = require('crypto');
const sharp = require('sharp');
const { WebSocketServer, WebSocket } = require('ws');
require('dotenv').config();
const VERSION = '1.4.1';
const VERSION = '1.5.2';
const app = express();
const server = http.createServer(app);
const PORT = process.env.PORT || 3000;
@@ -50,22 +51,83 @@ function logErr(msg) { console.error('['+new Date().toISOString()+'] ERROR '+msg
function filterAssets(assets) { return assets.filter(a=>{if(a.isTrashed)return false;if(!INCLUDE_VIDEOS&&a.type==='VIDEO')return false;return true;}); }
function mapAsset(a) { return {id:a.id,type:a.type,fileCreatedAt:a.fileCreatedAt,fileModifiedAt:a.fileModifiedAt,isFavorite:a.isFavorite,exifInfo:a.exifInfo?{dateTimeOriginal:a.exifInfo.dateTimeOriginal,city:a.exifInfo.city,state:a.exifInfo.state,country:a.exifInfo.country,make:a.exifInfo.make,model:a.exifInfo.model}:null,originalMimeType:a.originalMimeType,duration:a.duration}; }
// === GLOBAL SETTINGS (persisted to disk) ===
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json');
const DEFAULT_SETTINGS = {
source: { source: 'random', albumId: null, personId: null },
slideshowInterval: SLIDESHOW_INTERVAL,
showClock: SHOW_CLOCK,
showDate: SHOW_DATE,
showExif: SHOW_EXIF,
showProgress: SHOW_PROGRESS,
sleep: { enabled: false, sleepAt: '23:00', wakeAt: '06:00' },
};
let globalSettings = JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
function loadSettings() {
try {
if (fs.existsSync(SETTINGS_FILE)) {
const raw = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
globalSettings = Object.assign(JSON.parse(JSON.stringify(DEFAULT_SETTINGS)), raw);
globalSettings.source = Object.assign({}, DEFAULT_SETTINGS.source, raw.source || {});
globalSettings.sleep = Object.assign({}, DEFAULT_SETTINGS.sleep, raw.sleep || {});
log('Settings loaded from ' + SETTINGS_FILE);
} else { log('No settings file; using defaults'); }
} catch(e) { logErr('Settings load: ' + e.message); }
}
function saveSettings() {
try { if(!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR,{recursive:true}); fs.writeFileSync(SETTINGS_FILE, JSON.stringify(globalSettings,null,2)); }
catch(e) { logErr('Settings save: ' + e.message); }
}
loadSettings();
// === TIME HELPERS FOR SLEEP SCHEDULE ===
function parseHHMM(s) { if(!/^\d{1,2}:\d{2}$/.test(s||''))return null; const parts=s.split(':'),h=Number(parts[0]),m=Number(parts[1]); if(h>23||m>59)return null; return h*60+m; }
function inSleepWindow(sleepAt, wakeAt, nowMin) {
const s = parseHHMM(sleepAt), w = parseHHMM(wakeAt);
if (s === null || w === null) return false;
if (s === w) return false;
if (s < w) return nowMin >= s && nowMin < w;
return nowMin >= s || nowMin < w;
}
function nowMinuteOfDay() { const d=new Date(); return d.getHours()*60+d.getMinutes(); }
function effectiveSleep(client) {
const cs = client && client.config && client.config.sleep;
if (cs && cs.override) return { enabled: !!cs.enabled, sleepAt: cs.sleepAt||globalSettings.sleep.sleepAt, wakeAt: cs.wakeAt||globalSettings.sleep.wakeAt };
return globalSettings.sleep;
}
const wss = new WebSocketServer({ server });
const clients = new Map();
const adminSockets = new Set();
const CLIENT_TTL = parseInt(process.env.CLIENT_TTL, 10) || (7 * 24 * 60 * 60);
const ONLINE_THRESHOLD = 35000;
// === CLIENT FINGERPRINTING & DEDUP ===
function fingerprint(pid, ip, ua) {
if (pid) return 'pid:' + pid;
return 'sig:' + crypto.createHash('sha1').update((ip||'')+'|'+(ua||'')).digest('hex').slice(0, 16);
}
function findExisting(pid, ip, ua) {
const fp = fingerprint(pid, ip, ua);
for (const c of clients.values()) { if (c.fingerprint === fp) return c; }
if (pid) { const sig = fingerprint(null, ip, ua); for (const c of clients.values()) { if (c.fingerprint === sig) return c; } }
return null;
}
function getClientList() {
const now = Date.now();
return Array.from(clients.values()).map(c => ({
id: c.id, name: c.name||'', ip: c.ip, userAgent: c.userAgent,
fingerprint: c.fingerprint, persistentId: c.persistentId || null,
connectedAt: c.connectedAt, firstSeen: c.firstSeen, lastSeen: c.lastSeen,
status: c.status, online: (now - c.lastSeen) < 35000,
config: c.config||{}, source: c.source||null,
status: c.status, online: (now - c.lastSeen) < ONLINE_THRESHOLD,
config: c.config||{}, source: c.source||null, serverControlled: c.serverControlled !== false,
}));
}
function broadcastAdminClients() {
const msg = JSON.stringify({ type: 'clientList', clients: getClientList() });
const msg = JSON.stringify({ type: 'clientList', clients: getClientList(), settings: globalSettings });
adminSockets.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { try { ws.send(msg); } catch(e) {} } });
}
@@ -73,12 +135,57 @@ function getClientIp(req) {
return (req.headers['x-forwarded-for']||'').split(',')[0].trim() || req.socket.remoteAddress || 'unknown';
}
function resolvedConfigFor(client) {
const g = globalSettings;
const sleep = effectiveSleep(client);
return {
source: g.source,
slideshowInterval: g.slideshowInterval,
showClock: g.showClock, showDate: g.showDate, showExif: g.showExif, showProgress: g.showProgress,
sleep: { enabled: sleep.enabled, sleepAt: sleep.sleepAt, wakeAt: sleep.wakeAt,
sleeping: sleep.enabled && inSleepWindow(sleep.sleepAt, sleep.wakeAt, nowMinuteOfDay()) },
};
}
function pushServerConfig(client) {
if (!client || !client.ws || client.ws.readyState !== WebSocket.OPEN) return;
if (client.serverControlled === false) return;
try { client.ws.send(JSON.stringify({ type: 'serverConfig', config: resolvedConfigFor(client) })); } catch(e) {}
}
function pushServerConfigToAll() { clients.forEach(c => pushServerConfig(c)); }
// === SLEEP SCHEDULE TICK ===
let lastTickMinute = -1;
setInterval(() => {
const m = nowMinuteOfDay();
if (m === lastTickMinute) return;
lastTickMinute = m;
clients.forEach(c => {
if (!c.ws || c.ws.readyState !== WebSocket.OPEN) return;
const sleep = effectiveSleep(c);
if (!sleep.enabled) return;
const shouldSleep = inSleepWindow(sleep.sleepAt, sleep.wakeAt, m);
if (shouldSleep && c.status !== 'sleeping') { try { c.ws.send(JSON.stringify({type:'command',action:'sleep',payload:{scheduled:true}})); } catch(e){} log('Schedule: sleep -> '+c.id); }
else if (!shouldSleep && c.status === 'sleeping') { try { c.ws.send(JSON.stringify({type:'command',action:'wake',payload:{scheduled:true}})); } catch(e){} log('Schedule: wake -> '+c.id); }
});
}, 20000);
// === DEAD CLIENT PRUNING ===
setInterval(() => {
const now = Date.now(); let pruned = 0;
for (const [k,c] of clients.entries()) {
const offline = !c.ws || c.ws.readyState !== WebSocket.OPEN;
if (offline && (now - c.lastSeen) > CLIENT_TTL * 1000) { clients.delete(k); pruned++; }
}
if (pruned) { log('Pruned '+pruned+' dead client(s)'); broadcastAdminClients(); }
}, 60 * 60 * 1000);
wss.on('connection', (ws, req) => {
const id = crypto.randomBytes(8).toString('hex');
const now = Date.now();
const ua = req.headers['user-agent'] || 'unknown';
const ip = getClientIp(req);
let isAdmin = false;
let boundId = id;
ws.send(JSON.stringify({ type: 'hello', clientId: id }));
ws.on('message', (raw) => {
try {
@@ -88,19 +195,39 @@ wss.on('connection', (ws, req) => {
isAdmin = true;
adminSockets.add(ws);
log('WS admin: ' + ip);
ws.send(JSON.stringify({ type: 'clientList', clients: getClientList() }));
ws.send(JSON.stringify({ type: 'clientList', clients: getClientList(), settings: globalSettings }));
} else {
clients.set(id, { id, ws, name:'', ip, userAgent:ua, connectedAt:now, firstSeen:now, lastSeen:Date.now(), status:msg.status||'idle', config:msg.config||{}, source:msg.source||null });
log('WS frame: ' + id + ' (' + ip + ')');
const pid = msg.persistentId || null;
const fp = fingerprint(pid, ip, ua);
const existing = findExisting(pid, ip, ua);
const effectiveId = existing ? existing.id : id;
const firstName = existing ? existing.name : '';
const firstSeen = existing ? existing.firstSeen : now;
const priorConfig = existing ? (existing.config||{}) : {};
const priorSource = existing ? existing.source : null;
const sc = existing ? (existing.serverControlled !== false) : true;
for (const [k,c] of Array.from(clients.entries())) { if (c.id === effectiveId || c.fingerprint === fp) clients.delete(k); }
boundId = effectiveId;
clients.set(effectiveId, {
id: effectiveId, persistentId: pid, fingerprint: fp, ws,
name: firstName, ip, userAgent: ua,
connectedAt: now, firstSeen, lastSeen: Date.now(),
status: msg.status||'idle',
config: Object.assign({}, priorConfig, msg.config||{}),
source: msg.source!==undefined ? msg.source : priorSource,
serverControlled: sc,
});
log('WS frame: ' + effectiveId + (pid?' [pid]':' [sig]') + ' (' + ip + ')');
pushServerConfig(clients.get(effectiveId));
broadcastAdminClients();
}
} else if (msg.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong' }));
} else if (msg.type === 'status') {
const c = clients.get(id);
if (c) { c.status=msg.status||c.status; c.lastSeen=Date.now(); if(msg.config)c.config=msg.config; if(msg.source!==undefined)c.source=msg.source; broadcastAdminClients(); }
const c = clients.get(boundId);
if (c) { c.status=msg.status||c.status; c.lastSeen=Date.now(); if(msg.config)c.config=Object.assign({},c.config,msg.config); if(msg.source!==undefined)c.source=msg.source; broadcastAdminClients(); }
} else if (msg.type === 'adminCommand') {
const target = Array.from(clients.values()).find(c => c.id === msg.targetId);
const target = clients.get(msg.targetId) || Array.from(clients.values()).find(c => c.id === msg.targetId);
if (target && target.ws && target.ws.readyState === WebSocket.OPEN) {
target.ws.send(JSON.stringify({ type:'command', action:msg.action, payload:msg.payload||{} }));
log('Cmd "' + msg.action + '" -> ' + msg.targetId);
@@ -108,22 +235,48 @@ wss.on('connection', (ws, req) => {
ws.send(JSON.stringify({ type:'error', message:'Client not found or offline' }));
}
} else if (msg.type === 'renameClient') {
const target = Array.from(clients.values()).find(c => c.id === msg.targetId);
const target = clients.get(msg.targetId) || Array.from(clients.values()).find(c => c.id === msg.targetId);
if (target) { target.name = msg.name; broadcastAdminClients(); }
} else if (msg.type === 'removeClient') {
const entry = Array.from(clients.entries()).find(([,c]) => c.id === msg.targetId);
if (entry) { clients.delete(entry[0]); broadcastAdminClients(); }
if (entry) { try { if(entry[1].ws&&entry[1].ws.readyState===WebSocket.OPEN) entry[1].ws.close(); } catch(e){} clients.delete(entry[0]); broadcastAdminClients(); }
} else if (msg.type === 'setClientControl') {
const target = clients.get(msg.targetId) || Array.from(clients.values()).find(c => c.id === msg.targetId);
if (target) { target.serverControlled = !!msg.serverControlled; if(target.serverControlled) pushServerConfig(target); broadcastAdminClients(); }
} else if (msg.type === 'setClientSleep') {
const target = clients.get(msg.targetId) || Array.from(clients.values()).find(c => c.id === msg.targetId);
if (target) { target.config = Object.assign({}, target.config, { sleep: msg.sleep||{} }); pushServerConfig(target); broadcastAdminClients(); }
} else if (msg.type === 'updateSettings') {
applySettings(msg.settings || {});
ws.send(JSON.stringify({ type:'settingsSaved', settings: globalSettings }));
pushServerConfigToAll();
broadcastAdminClients();
log('Global settings updated');
} else if (msg.type === 'getSettings') {
ws.send(JSON.stringify({ type:'settings', settings: globalSettings }));
}
} catch(e) { logErr('WS: ' + e.message); }
});
ws.on('close', () => {
if (isAdmin) { adminSockets.delete(ws); log('WS admin left: ' + ip); }
else { const c=clients.get(id); if(c){c.status='offline';c.ws=null;c.lastSeen=Date.now();broadcastAdminClients();} log('WS frame left: '+id); }
else { const c=clients.get(boundId); if(c){c.status='offline';c.ws=null;c.lastSeen=Date.now();broadcastAdminClients();} log('WS frame left: '+boundId); }
});
});
function applySettings(patch) {
if (patch.source) globalSettings.source = Object.assign({}, globalSettings.source, patch.source);
if ('slideshowInterval' in patch) globalSettings.slideshowInterval = parseInt(patch.slideshowInterval,10)||globalSettings.slideshowInterval;
['showClock','showDate','showExif','showProgress'].forEach(k => { if (k in patch) globalSettings[k] = !!patch[k]; });
if (patch.sleep) globalSettings.sleep = Object.assign({}, globalSettings.sleep, {
enabled: 'enabled' in patch.sleep ? !!patch.sleep.enabled : globalSettings.sleep.enabled,
sleepAt: patch.sleep.sleepAt || globalSettings.sleep.sleepAt,
wakeAt: patch.sleep.wakeAt || globalSettings.sleep.wakeAt,
});
saveSettings();
}
function sendToClient(clientId, payload) {
const c = Array.from(clients.values()).find(x => x.id === clientId);
const c = clients.get(clientId) || Array.from(clients.values()).find(x => x.id === clientId);
if (!c || !c.ws || c.ws.readyState !== WebSocket.OPEN) return false;
c.ws.send(JSON.stringify(payload)); return true;
}
@@ -140,6 +293,9 @@ app.get('/api/auth/status',(_req,res)=>{res.json({authEnabled:AUTH_ENABLED,apiTo
app.post('/api/auth/login',(req,res)=>{if(!AUTH_ENABLED)return res.json({ok:true});const{username,password}=req.body;if(username===ADMIN_USERNAME&&password===ADMIN_PASSWORD){const token=createSession(username);res.setHeader('Set-Cookie','frambe_session='+token+'; HttpOnly; Path=/; Max-Age='+(SESSION_TTL/1000));return res.json({ok:true});}return res.status(401).json({ok:false,error:'Invalid credentials'});});
app.post('/api/auth/logout',(req,res)=>{const cookie=req.headers.cookie||'',match=cookie.match(/frambe_session=([a-f0-9]+)/);if(match)sessions.delete(match[1]);res.setHeader('Set-Cookie','frambe_session=; HttpOnly; Path=/; Max-Age=0');res.json({ok:true});});
app.get('/api/config',(_req,res)=>{res.json({version:VERSION,slideshowInterval:SLIDESHOW_INTERVAL,transitionDuration:TRANSITION_DURATION,showClock:SHOW_CLOCK,showDate:SHOW_DATE,showExif:SHOW_EXIF,showProgress:SHOW_PROGRESS,imageFit:IMAGE_FIT,backgroundBlur:BACKGROUND_BLUR,shuffle:SHUFFLE,albumId:ALBUM_ID,showFavoritesOnly:SHOW_FAVORITES_ONLY,refreshInterval:REFRESH_INTERVAL,includeVideos:INCLUDE_VIDEOS,connected:!!API_KEY,authEnabled:AUTH_ENABLED,imageMaxWidth:IMAGE_MAX_WIDTH,imageQuality:IMAGE_QUALITY});});
app.get('/api/settings',(_req,res)=>{res.json({ok:true,settings:globalSettings});});
app.get('/api/time',(_req,res)=>{const d=new Date();let tz='';try{tz=Intl.DateTimeFormat().resolvedOptions().timeZone||'';}catch(e){}res.json({ok:true,iso:d.toISOString(),epoch:d.getTime(),hours:d.getHours(),minutes:d.getMinutes(),minuteOfDay:d.getHours()*60+d.getMinutes(),tz:tz,offsetMinutes:-d.getTimezoneOffset()});});
app.put('/api/settings',requireApiToken,(req,res)=>{applySettings(req.body||{});pushServerConfigToAll();broadcastAdminClients();res.json({ok:true,settings:globalSettings});});
app.get('/api/server-info',async(_req,res)=>{try{const r=await fetch(IMMICH_URL+'/api/server/version',{headers:immichHeaders()});if(!r.ok)throw new Error(''+r.status);const v=await r.json();res.json({ok:true,version:v});}catch(e){res.status(502).json({ok:false,error:e.message});}});
app.get('/api/albums',async(_req,res)=>{try{const[rOwn,rShared]=await Promise.all([fetch(IMMICH_URL+'/api/albums',{headers:immichHeaders()}),fetch(IMMICH_URL+'/api/albums?shared=true',{headers:immichHeaders()})]);if(!rOwn.ok)throw new Error('Own: '+rOwn.status);const aOwn=await rOwn.json(),sharedRaw=rShared.ok?await rShared.json():[];const seen=new Set(),result=[];for(const x of aOwn){if(!seen.has(x.id)){seen.add(x.id);result.push({id:x.id,albumName:x.albumName,assetCount:x.assetCount,albumThumbnailAssetId:x.albumThumbnailAssetId,updatedAt:x.updatedAt,shared:false});}}for(const x of(Array.isArray(sharedRaw)?sharedRaw:[])){if(!seen.has(x.id)){seen.add(x.id);result.push({id:x.id,albumName:x.albumName,assetCount:x.assetCount,albumThumbnailAssetId:x.albumThumbnailAssetId,updatedAt:x.updatedAt,shared:true});}}log('Albums: '+result.length);res.json(result);}catch(e){logErr('Albums: '+e.message);res.status(502).json({error:e.message});}});
app.get('/api/albums/:id',async(req,res)=>{try{const r=await fetch(IMMICH_URL+'/api/albums/'+req.params.id,{headers:immichHeaders()});if(!r.ok)throw new Error(''+r.status);const al=await r.json(),a=filterAssets(al.assets||[]).map(mapAsset);res.json({id:al.id,albumName:al.albumName,assetCount:a.length,assets:a});}catch(e){res.status(502).json({error:e.message});}});
@@ -161,4 +317,6 @@ server.listen(PORT,()=>{
log('--- Frambe v'+VERSION+' ---');
log('Port: '+PORT+' | Immich: '+IMMICH_URL);
log('API key: '+(API_KEY?'set':'NOT SET')+' | Auth: '+(AUTH_ENABLED?'enabled':'disabled')+' | Token: '+(FRAMBE_API_TOKEN?'set':'not set'));
log('Server time: '+new Date().toString());
log('Global sleep schedule: '+(globalSettings.sleep.enabled?(globalSettings.sleep.sleepAt+' -> '+globalSettings.sleep.wakeAt):'disabled'));
});