22 Commits
Author SHA1 Message Date
jessikitty 55538e5cfb v1.6 Updates for Immich V3 API Changes 2026-07-14 21:30:42 +10:00
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
jessikitty 581f6ee87b Merge pull request 'v1.4.2 - Aged/faded pile photos + washed-out background' (#3) from dev into main
Reviewed-on: #3
2026-06-10 16:53:40 +10:00
jessikitty c2fd34f04c v1.4.2 - aged/faded effect on canvas pile photos 2026-06-10 06:33:06 +00:00
jessikitty c85b75996e v1.4.2 - aged/faded effect on canvas pile photos (desaturate + sepia + warm overlay) 2026-06-10 16:19:44 +10:00
jessikitty b8516630ab v1.4.1 - bump version 2026-06-10 16:02:11 +10:00
jessikitty e1488d2f5e v1.4.1 - reduce background blur saturation/brightness for less distracting backdrop 2026-06-10 16:01:59 +10:00
jessikitty 4bed4aa6da fix: proper WebSocket role routing, device IP shown in admin, sleep/wake working 2026-06-10 01:11:05 +00:00
9 changed files with 450 additions and 237 deletions
+13 -3
View File
@@ -1,6 +1,12 @@
# === Frambe Configuration === # === 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 # 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_URL=http://your-immich-server:2283
IMMICH_API_KEY=your-api-key-here IMMICH_API_KEY=your-api-key-here
@@ -22,14 +28,18 @@ SHOW_PROGRESS=true
# ALBUM_ID= # ALBUM_ID=
# SHOW_FAVORITES_ONLY=false # 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_USERNAME=admin
ADMIN_PASSWORD= ADMIN_PASSWORD=
# ADMIN_PASSWORD=changeme # ADMIN_PASSWORD=changeme
# API Token for external access (Home Assistant, scripts, etc.) # 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 # 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 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 .env
npm-debug.log npm-debug.log
.DS_Store .DS_Store
docker-compose.yml data/
+10 -1
View File
@@ -3,6 +3,10 @@ FROM node:18-alpine
LABEL maintainer="Frambe" LABEL maintainer="Frambe"
LABEL description="Frambe — lightweight digital photo frame for Immich" 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 WORKDIR /app
COPY package.json ./ 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 CMD wget -qO- http://localhost:3000/api/config || exit 1
RUN addgroup -g 1001 -S appgroup && \ 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 USER appuser
CMD ["node", "server.js"] CMD ["node", "server.js"]
+9 -28
View File
@@ -7,32 +7,13 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3030:3000" - "3030:3000"
environment: volumes:
# REQUIRED # Persists global settings (data/settings.json) across rebuilds.
- IMMICH_URL=http://your-immich-server:2283 - frambe_data:/app/data
- IMMICH_API_KEY=your-api-key-here # 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 volumes:
- SLIDESHOW_INTERVAL=30 frambe_data:
- 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
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "frambe", "name": "frambe",
"version": "1.4.0", "version": "1.6.0",
"description": "Frambe — a lightweight digital photo frame web app for Immich with admin dashboard", "description": "Frambe — a lightweight digital photo frame web app for Immich with admin dashboard",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
+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{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.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.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{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} .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} .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} select:focus{outline:none;border-color:#6366f1}
option{background:#1a1a2e;color:#e0e0e0} option{background:#1a1a2e;color:#e0e0e0}
input[type=range]{width:110px;accent-color:#6366f1} 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} .rval{font-size:.78rem;color:#999;min-width:28px}
.tgl{position:relative;width:36px;height:20px;cursor:pointer}.tgl input{display:none} .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} .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} .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{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} .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} .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} .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} .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{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} .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{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} .ep{font-family:monospace;font-size:.82rem;color:#bbb}
.api-card p{font-size:.75rem;color:#777;margin:.3rem 0} .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} 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{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 input:focus{outline:none;border-color:#6366f1}
.input-row .field{flex:1;min-width:140px} .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> </style>
</head> </head>
<body> <body>
@@ -85,17 +103,60 @@
<img src="/img/icon.png" alt="Frambe" onerror="this.style.display='none'"> <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><h1>Frambe Admin</h1><span class="version" id="ver">Connecting...</span></div>
<div class="header-right"> <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> <span class="ws-pill off" id="ws-pill">Disconnected</span>
<button class="btn logout" id="logout-btn" style="display:none" onclick="doLogout()">Logout</button> <button class="btn logout" id="logout-btn" style="display:none" onclick="doLogout()">Logout</button>
</div> </div>
</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 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 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 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 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 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>
<div class="modal-overlay" id="ha-modal"> <div class="modal-overlay" id="ha-modal">
<div class="modal"> <div class="modal">
<button class="modal-close" onclick="closeModal()">&times;</button> <button class="modal-close" onclick="closeModal()">&times;</button>
@@ -112,7 +173,7 @@
</div> </div>
</div> </div>
<script> <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(){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';} 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');}} 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:'; var p=location.protocol==='https:'?'wss:':'ws:';
ws=new WebSocket(p+'//'+location.host+'/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.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);}; 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 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 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 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)});} 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=''; 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();} 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=''; var h='';
ids.forEach(function(id){var c=D[id],off=c.status==='offline'; 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 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'+(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-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>'; if(off)h+='<button class="btn sm red" onclick="rem(\''+id+'\',\''+esc(c.name||c.ip)+'\')">Remove</button>';
h+='</div></div>'; h+='</div></div>';
h+='<div class="info-row">'; 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>'; 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 { } else {
h+='<div class="controls">'; 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>'; 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>';}); 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>';}); 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">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+='<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+='<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+='<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">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+='<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+='<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 class="crow"><button class="btn blue" onclick="openHaModal(\''+id+'\',\''+esc(c.name||c.ip)+'\')">Generate HA YAML</button></div>';
h+='</div>'; h+='</div>';
} }
+1 -1
View File
@@ -35,7 +35,7 @@ html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a0f; c
/* === SLIDESHOW SCREEN === */ /* === SLIDESHOW SCREEN === */
.slideshow-screen { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: #000; } .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; } #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.35) saturate(0.8); opacity: 0; transition: opacity 1.5s ease; } .bg-blur { position: absolute; top: -20px; left: -20px; right: -20px; bottom: -20px; background-size: cover; background-position: center; filter: blur(20px) brightness(0.25) saturate(0.4); opacity: 0; transition: opacity 1.5s ease; }
.bg-blur.visible { opacity: 1; } .bg-blur.visible { opacity: 1; }
.main-frame-wrap { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; pointer-events: none; } .main-frame-wrap { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; pointer-events: none; }
.main-frame { background: #ede8df; padding: 12px 12px 40px; box-shadow: 0 20px 60px rgba(0,0,0,0.7), 0 4px 12px rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.8s ease; animation: floatFrame 6s ease-in-out infinite; } .main-frame { background: #ede8df; padding: 12px 12px 40px; box-shadow: 0 20px 60px rgba(0,0,0,0.7), 0 4px 12px rgba(0,0,0,0.4); opacity: 0; transition: opacity 0.8s ease; animation: floatFrame 6s ease-in-out infinite; }
+31 -5
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 () { (function () {
'use strict'; 'use strict';
var config = {}, assets = [], currentIndex = -1, slideshowTimer = null; var config = {}, assets = [], currentIndex = -1, slideshowTimer = null;
@@ -6,13 +6,39 @@
var selectedPersonId = null, isRunning = false, refreshTimer = null, urlDriven = false; var selectedPersonId = null, isRunning = false, refreshTimer = null, urlDriven = false;
var currentVideoPlaying = false, pileCanvas, pileCtx; var currentVideoPlaying = false, pileCanvas, pileCtx;
var FRAME_PAD_RATIO = 0.03, FRAME_BOTTOM_RATIO = 0.10, FRAME_COLOR = '#ede8df'; 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'); 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 === // === 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 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()}));} 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 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 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 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 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');}
@@ -33,7 +59,7 @@
// === CANVAS PILE === // === 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 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 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 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.filter='saturate(0.45) sepia(0.25) brightness(0.88)';pileCtx.drawImage(img,-pw/2+pad,-th/2+pad,iw,ih);pileCtx.filter='none';pileCtx.fillStyle='rgba(140,110,60,0.35)';pileCtx.fillRect(-pw/2+pad,-th/2+pad,iw,ih);pileCtx.restore();if(a<1)requestAnimationFrame(draw);}requestAnimationFrame(draw);};img.src=src;}
// === SLIDESHOW === // === SLIDESHOW ===
async function doStartSlideshow(){if(!selectedSource)return;$btnStart.disabled=true;$btnStart.innerHTML='<span class="spinner"></span> 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='<span class="spinner"></span> 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);}}
+257 -190
View File
@@ -2,12 +2,13 @@ const express = require('express');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const path = require('path'); const path = require('path');
const http = require('http'); const http = require('http');
const fs = require('fs');
const crypto = require('crypto'); const crypto = require('crypto');
const sharp = require('sharp'); const sharp = require('sharp');
const { WebSocketServer, WebSocket } = require('ws'); const { WebSocketServer, WebSocket } = require('ws');
require('dotenv').config(); require('dotenv').config();
const VERSION = '1.4.1'; const VERSION = '1.6.0';
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
@@ -26,241 +27,307 @@ const ALBUM_ID = process.env.ALBUM_ID || '';
const SHOW_FAVORITES_ONLY = process.env.SHOW_FAVORITES_ONLY === 'true'; const SHOW_FAVORITES_ONLY = process.env.SHOW_FAVORITES_ONLY === 'true';
const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL, 10) || 300; const REFRESH_INTERVAL = parseInt(process.env.REFRESH_INTERVAL, 10) || 300;
const INCLUDE_VIDEOS = process.env.INCLUDE_VIDEOS !== 'false'; const INCLUDE_VIDEOS = process.env.INCLUDE_VIDEOS !== 'false';
const IMAGE_MAX_WIDTH = parseInt(process.env.IMAGE_MAX_WIDTH, 10) || 1920; const IMAGE_MAX_WIDTH = parseInt(process.env.IMAGE_MAX_WIDTH, 10) || 1920;
const IMAGE_QUALITY = parseInt(process.env.IMAGE_QUALITY, 10) || 80; const IMAGE_QUALITY = parseInt(process.env.IMAGE_QUALITY, 10) || 80;
const IMAGE_CACHE_MAX = parseInt(process.env.IMAGE_CACHE_MAX, 10) || 100; const IMAGE_CACHE_MAX = parseInt(process.env.IMAGE_CACHE_MAX, 10) || 100;
const imageCache = new Map(); const imageCache = new Map();
function cacheGet(key) { function cacheGet(key) { const e=imageCache.get(key);if(!e)return null;imageCache.delete(key);imageCache.set(key,e);return e; }
const entry = imageCache.get(key); function cacheSet(key,value) { if(imageCache.size>=IMAGE_CACHE_MAX){imageCache.delete(imageCache.keys().next().value);}imageCache.set(key,value); }
if (!entry) return null;
imageCache.delete(key);
imageCache.set(key, entry);
return entry;
}
function cacheSet(key, value) {
if (imageCache.size >= IMAGE_CACHE_MAX) {
const oldest = imageCache.keys().next().value;
imageCache.delete(oldest);
}
imageCache.set(key, value);
}
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin'; const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ''; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || '';
const FRAMBE_API_TOKEN = process.env.FRAMBE_API_TOKEN || ''; const FRAMBE_API_TOKEN = process.env.FRAMBE_API_TOKEN || '';
const AUTH_ENABLED = !!ADMIN_PASSWORD; const AUTH_ENABLED = !!ADMIN_PASSWORD;
const sessions = new Map(); const sessions = new Map();
const SESSION_TTL = 24 * 60 * 60 * 1000; const SESSION_TTL = 24 * 60 * 60 * 1000;
function createSession(username) { const token=crypto.randomBytes(32).toString('hex'),now=Date.now();sessions.set(token,{username,createdAt:now,expiresAt:now+SESSION_TTL});return token; }
function createSession(username) { function validateSession(token) { if(!token)return false;const s=sessions.get(token);if(!s)return false;if(Date.now()>s.expiresAt){sessions.delete(token);return false;}return true; }
const token = crypto.randomBytes(32).toString('hex'); function cleanupSessions() { const now=Date.now();sessions.forEach((s,t)=>{if(now>s.expiresAt)sessions.delete(t);}); }
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); setInterval(cleanupSessions, 60 * 60 * 1000);
function requireAdminAuth(req,res,next) { if(!AUTH_ENABLED)return next();const cookie=req.headers.cookie||'';const match=cookie.match(/frambe_session=([a-f0-9]+)/);if(match&&validateSession(match[1]))return next();if(req.headers['accept']&&req.headers['accept'].includes('application/json'))return res.status(401).json({error:'Not authenticated'});return res.redirect('/admin/login'); }
function requireApiToken(req,res,next) { if(!FRAMBE_API_TOKEN)return next();const auth=req.headers['authorization']||'',hdr=req.headers['x-api-token']||'',qry=req.query.token||'',provided=auth.startsWith('Bearer ')?auth.slice(7):(hdr||qry);if(provided===FRAMBE_API_TOKEN)return next();return res.status(401).json({error:'Invalid API token'}); }
function immichHeaders() { return {'x-api-key':API_KEY,'Content-Type':'application/json'}; }
function log(msg) { console.log('['+new Date().toISOString()+'] '+msg); }
function logErr(msg) { console.error('['+new Date().toISOString()+'] ERROR '+msg); }
function filterAssets(assets) { return assets.filter(a=>{if(a.isTrashed)return false;if(!INCLUDE_VIDEOS&&a.type==='VIDEO')return false;return true;}); }
// Immich v3: AssetResponseDto.duration is now a number in milliseconds (or null). Older v2 sent a "H:MM:SS.mmm" string. Normalise to ms number for the frontend, tolerating both.
function normDuration(d){if(d==null)return null;if(typeof d==='number')return d;if(typeof d==='string'){const p=d.split(':');if(p.length===3){const h=parseFloat(p[0])||0,m=parseFloat(p[1])||0,s=parseFloat(p[2])||0;return Math.round((h*3600+m*60+s)*1000);}const n=parseFloat(d);return isNaN(n)?null:n;}return null;}
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:normDuration(a.duration)}; }
// Immich v3: POST /search/metadata is the canonical way to page through assets (album assets, favorites, etc.).
// AlbumResponseDto no longer includes `assets`. We page metadata search, defaulting visibility to 'timeline'
// (v3 changed the default to "any", which would surface archived/hidden assets on frames).
async function searchMetadata(filter, maxPages) { const out=[]; let page=1; const mp=maxPages||10; while(page<=mp){ const body=Object.assign({size:250,page,visibility:'timeline'},filter); const r=await fetch(IMMICH_URL+'/api/search/metadata',{method:'POST',headers:immichHeaders(),body:JSON.stringify(body)}); if(!r.ok)throw new Error('search/metadata '+r.status); const d=await r.json(); const items=(d.assets&&Array.isArray(d.assets.items))?d.assets.items:[]; out.push(...items); const next=d.assets&&d.assets.nextPage; if(!next||items.length===0)break; page=typeof next==='number'?next:page+1; } return out; }
function requireAdminAuth(req, res, next) { // === GLOBAL SETTINGS (persisted to disk) ===
if (!AUTH_ENABLED) return next(); const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
const cookie = req.headers.cookie || ''; const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json');
const match = cookie.match(/frambe_session=([a-f0-9]+)/); const DEFAULT_SETTINGS = {
if (match && validateSession(match[1])) return next(); source: { source: 'random', albumId: null, personId: null },
if (req.headers['accept'] && req.headers['accept'].includes('application/json')) { slideshowInterval: SLIDESHOW_INTERVAL,
return res.status(401).json({ error: 'Not authenticated' }); showClock: SHOW_CLOCK,
} showDate: SHOW_DATE,
return res.redirect('/admin/login'); 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() {
function requireApiToken(req, res, next) { try { if(!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR,{recursive:true}); fs.writeFileSync(SETTINGS_FILE, JSON.stringify(globalSettings,null,2)); }
if (!FRAMBE_API_TOKEN) return next(); catch(e) { logErr('Settings save: ' + e.message); }
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' });
} }
loadSettings();
function immichHeaders() { return { 'x-api-key': API_KEY, 'Content-Type': 'application/json' }; } // === TIME HELPERS FOR SLEEP SCHEDULE ===
function log(msg) { console.log('[' + new Date().toISOString() + '] ' + msg); } 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 logErr(msg) { console.error('[' + new Date().toISOString() + '] ERROR ' + msg); } function inSleepWindow(sleepAt, wakeAt, nowMin) {
const s = parseHHMM(sleepAt), w = parseHHMM(wakeAt);
function filterAssets(assets) { if (s === null || w === null) return false;
return assets.filter(a => { if (s === w) return false;
if (a.isTrashed) return false; if (s < w) return nowMin >= s && nowMin < w;
if (!INCLUDE_VIDEOS && a.type === 'VIDEO') return false; return nowMin >= s || nowMin < w;
return true;
});
} }
function nowMinuteOfDay() { const d=new Date(); return d.getHours()*60+d.getMinutes(); }
function mapAsset(a) { function effectiveSleep(client) {
return { const cs = client && client.config && client.config.sleep;
id: a.id, if (cs && cs.override) return { enabled: !!cs.enabled, sleepAt: cs.sleepAt||globalSettings.sleep.sleepAt, wakeAt: cs.wakeAt||globalSettings.sleep.wakeAt };
type: a.type, return globalSettings.sleep;
fileCreatedAt: a.fileCreatedAt,
fileModifiedAt: a.fileModifiedAt,
isFavorite: a.isFavorite,
exifInfo: a.exifInfo ? {
dateTimeOriginal: a.exifInfo.dateTimeOriginal,
city: a.exifInfo.city,
state: a.exifInfo.state,
country: a.exifInfo.country,
make: a.exifInfo.make,
model: a.exifInfo.model,
} : null,
originalMimeType: a.originalMimeType,
duration: a.duration,
};
} }
const wss = new WebSocketServer({ server }); const wss = new WebSocketServer({ server });
const clients = new Map(); 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() { function getClientList() {
const now = Date.now(); const now = Date.now();
return Array.from(clients.values()).map(c => ({ return Array.from(clients.values()).map(c => ({
id: c.id, id: c.id, name: c.name||'', ip: c.ip, userAgent: c.userAgent,
userAgent: c.userAgent, fingerprint: c.fingerprint, persistentId: c.persistentId || null,
connectedAt: c.connectedAt, connectedAt: c.connectedAt, firstSeen: c.firstSeen, lastSeen: c.lastSeen,
firstSeen: c.firstSeen, status: c.status, online: (now - c.lastSeen) < ONLINE_THRESHOLD,
lastSeen: c.lastSeen, config: c.config||{}, source: c.source||null, serverControlled: c.serverControlled !== false,
status: c.status,
online: (now - c.lastSeen) < 35000,
})); }));
} }
function broadcastAdminClients() {
const msg = JSON.stringify({ type: 'clientList', clients: getClientList(), settings: globalSettings });
adminSockets.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { try { ws.send(msg); } catch(e) {} } });
}
function getClientIp(req) {
return (req.headers['x-forwarded-for']||'').split(',')[0].trim() || req.socket.remoteAddress || 'unknown';
}
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) => { wss.on('connection', (ws, req) => {
const id = crypto.randomBytes(8).toString('hex'); const id = crypto.randomBytes(8).toString('hex');
const now = Date.now(); const now = Date.now();
const ua = req.headers['user-agent'] || 'unknown'; const ua = req.headers['user-agent'] || 'unknown';
clients.set(id, { id, ws, userAgent: ua, connectedAt: now, firstSeen: now, lastSeen: now, status: 'connected' }); const ip = getClientIp(req);
log('WS connect: ' + id + ' (' + ua.substring(0, 60) + ')'); let isAdmin = false;
let boundId = id;
ws.send(JSON.stringify({ type: 'hello', clientId: id })); ws.send(JSON.stringify({ type: 'hello', clientId: id }));
broadcastAdminClients();
ws.on('message', (raw) => { ws.on('message', (raw) => {
try { try {
const msg = JSON.parse(raw); const msg = JSON.parse(raw);
const c = clients.get(id); if (msg.type === 'register') {
if (c) { c.lastSeen = Date.now(); c.status = msg.status || c.status; } if (msg.role === 'admin') {
if (msg.type === 'ping') { ws.send(JSON.stringify({ type: 'pong' })); broadcastAdminClients(); } isAdmin = true;
else if (msg.type === 'status') { if (c) c.status = msg.status; broadcastAdminClients(); } adminSockets.add(ws);
} catch (e) { } log('WS admin: ' + ip);
ws.send(JSON.stringify({ type: 'clientList', clients: getClientList(), settings: globalSettings }));
} else {
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(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 = 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);
} else {
ws.send(JSON.stringify({ type:'error', message:'Client not found or offline' }));
}
} else if (msg.type === 'renameClient') {
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) { 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', () => { ws.on('close', () => {
const c = clients.get(id); if (isAdmin) { adminSockets.delete(ws); log('WS admin left: ' + ip); }
if (c) { c.online = false; c.lastSeen = Date.now(); } else { const c=clients.get(boundId); if(c){c.status='offline';c.ws=null;c.lastSeen=Date.now();broadcastAdminClients();} log('WS frame left: '+boundId); }
log('WS close: ' + id);
broadcastAdminClients();
}); });
}); });
function broadcastAdminClients() { function applySettings(patch) {
const list = getClientList(); if (patch.source) globalSettings.source = Object.assign({}, globalSettings.source, patch.source);
const msg = JSON.stringify({ type: 'clients', clients: list }); if ('slideshowInterval' in patch) globalSettings.slideshowInterval = parseInt(patch.slideshowInterval,10)||globalSettings.slideshowInterval;
wss.clients.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { try { ws.send(msg); } catch (e) { } } }); ['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) { function sendToClient(clientId, payload) {
const c = clients.get(clientId); const c = clients.get(clientId) || Array.from(clients.values()).find(x => x.id === clientId);
if (!c || c.ws.readyState !== WebSocket.OPEN) return false; if (!c || !c.ws || c.ws.readyState !== WebSocket.OPEN) return false;
c.ws.send(JSON.stringify(payload)); c.ws.send(JSON.stringify(payload)); return true;
return true;
} }
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: false })); app.use(express.urlencoded({ extended: false }));
app.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); }); app.use((req,res,next)=>{res.set('Cache-Control','no-store');next();});
app.use((req, _res, next) => { log(req.method + ' ' + req.path); next(); }); app.use((req,_res,next)=>{log(req.method+' '+req.path);next();});
app.get('/admin/login',(_req,res)=>{if(!AUTH_ENABLED)return res.redirect('/admin');res.sendFile(path.join(__dirname,'public','admin','login.html'));});
app.use('/admin',requireAdminAuth,express.static(path.join(__dirname,'public/admin')));
app.use(express.static(path.join(__dirname,'public')));
app.use('/api',(req,_res,next)=>{log('API '+req.method+' '+req.originalUrl);next();});
app.get('/api/auth/status',(_req,res)=>{res.json({authEnabled:AUTH_ENABLED,apiTokenEnabled:!!FRAMBE_API_TOKEN});});
app.post('/api/auth/login',(req,res)=>{if(!AUTH_ENABLED)return res.json({ok:true});const{username,password}=req.body;if(username===ADMIN_USERNAME&&password===ADMIN_PASSWORD){const token=createSession(username);res.setHeader('Set-Cookie','frambe_session='+token+'; HttpOnly; Path=/; Max-Age='+(SESSION_TTL/1000));return res.json({ok:true});}return res.status(401).json({ok:false,error:'Invalid credentials'});});
app.post('/api/auth/logout',(req,res)=>{const cookie=req.headers.cookie||'',match=cookie.match(/frambe_session=([a-f0-9]+)/);if(match)sessions.delete(match[1]);res.setHeader('Set-Cookie','frambe_session=; HttpOnly; Path=/; Max-Age=0');res.json({ok:true});});
app.get('/api/config',(_req,res)=>{res.json({version:VERSION,slideshowInterval:SLIDESHOW_INTERVAL,transitionDuration:TRANSITION_DURATION,showClock:SHOW_CLOCK,showDate:SHOW_DATE,showExif:SHOW_EXIF,showProgress:SHOW_PROGRESS,imageFit:IMAGE_FIT,backgroundBlur:BACKGROUND_BLUR,shuffle:SHUFFLE,albumId:ALBUM_ID,showFavoritesOnly:SHOW_FAVORITES_ONLY,refreshInterval:REFRESH_INTERVAL,includeVideos:INCLUDE_VIDEOS,connected:!!API_KEY,authEnabled:AUTH_ENABLED,imageMaxWidth:IMAGE_MAX_WIDTH,imageQuality:IMAGE_QUALITY});});
app.get('/api/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?isOwned=true',{headers:immichHeaders()}),fetch(IMMICH_URL+'/api/albums?isShared=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();
// Immich v3: album assets are no longer embedded in AlbumResponseDto. Fetch via search/metadata.
let raw;if(Array.isArray(al.assets)&&al.assets.length){raw=al.assets;/* v2 fallback */}else{raw=await searchMetadata({albumIds:[req.params.id]});}
const a=filterAssets(raw).map(mapAsset);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{let raw;try{raw=await searchMetadata({personIds:[req.params.id]});}catch(_e){const r=await fetch(IMMICH_URL+'/api/people/'+req.params.id+'/assets',{headers:immichHeaders()});if(!r.ok)throw new Error(''+r.status);const j=await r.json();raw=Array.isArray(j)?j:[];}res.json(filterAssets(raw).map(mapAsset));}catch(e){logErr('Person: '+e.message);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);
// Immich v3: GET /assets/random removed. Use POST /search/random (returns a bare array).
const r=await fetch(IMMICH_URL+'/api/search/random',{method:'POST',headers:immichHeaders(),body:JSON.stringify({size:c,visibility:'timeline'})});if(!r.ok)throw new Error(''+r.status);const d=await r.json();const raw=Array.isArray(d)?d:(d.assets&&Array.isArray(d.assets.items)?d.assets.items:[]);res.json(filterAssets(raw).map(mapAsset));}catch(e){logErr('Random: '+e.message);res.status(502).json({error:e.message});}});
app.get('/api/assets/favorites',async(_req,res)=>{try{const raw=await searchMetadata({isFavorite:true},2);res.json(filterAssets(raw).map(a=>{var m=mapAsset(a);m.isFavorite=true;return m;}));}catch(e){logErr('Favorites: '+e.message);res.status(502).json({error:e.message});}});
app.get('/api/assets/:id/thumbnail',async(req,res)=>{try{const r=await fetch(IMMICH_URL+'/api/assets/'+req.params.id+'/thumbnail?size='+(req.query.size||'preview'),{headers:{'x-api-key':API_KEY}});if(!r.ok)throw new Error(''+r.status);res.set('Content-Type',r.headers.get('content-type')||'image/jpeg');res.set('Cache-Control','public, max-age=86400');r.body.pipe(res);}catch(e){res.status(502).json({error:e.message});}});
app.get('/api/assets/:id/optimized',async(req,res)=>{try{const id=req.params.id,ck=id+':'+IMAGE_MAX_WIDTH+':'+IMAGE_QUALITY,hit=cacheGet(ck);if(hit){res.set('Content-Type','image/jpeg');res.set('Cache-Control','public, max-age=86400');return res.send(hit);}const r=await fetch(IMMICH_URL+'/api/assets/'+id+'/thumbnail?size=preview',{headers:{'x-api-key':API_KEY}});if(!r.ok)throw new Error(''+r.status);const buf=await r.buffer(),opt=await sharp(buf).resize({width:IMAGE_MAX_WIDTH,withoutEnlargement:true}).jpeg({quality:IMAGE_QUALITY}).toBuffer();cacheSet(ck,opt);res.set('Content-Type','image/jpeg');res.set('Cache-Control','public, max-age=86400');res.send(opt);}catch(e){res.status(502).json({error:e.message});}});
app.get('/api/assets/:id/video',async(req,res)=>{try{const r=await fetch(IMMICH_URL+'/api/assets/'+req.params.id+'/video/playback',{headers:{'x-api-key':API_KEY}});if(!r.ok)throw new Error(''+r.status);res.set('Content-Type',r.headers.get('content-type')||'video/mp4');res.set('Cache-Control','public, max-age=86400');const cl=r.headers.get('content-length');if(cl)res.set('Content-Length',cl);r.body.pipe(res);}catch(e){res.status(502).json({error:e.message});}});
app.get('/api/assets/:id/original',async(req,res)=>{try{const r=await fetch(IMMICH_URL+'/api/assets/'+req.params.id+'/original',{headers:{'x-api-key':API_KEY}});if(!r.ok)throw new Error(''+r.status);res.set('Content-Type',r.headers.get('content-type')||'image/jpeg');res.set('Cache-Control','public, max-age=86400');r.body.pipe(res);}catch(e){res.status(502).json({error:e.message});}});
app.get('/api/clients',requireApiToken,(_req,res)=>{res.json({ok:true,clients:getClientList()});});
app.post('/api/clients/:id/command',requireApiToken,(req,res)=>{const{action,payload}=req.body;if(!action)return res.status(400).json({error:'action required'});const sent=sendToClient(req.params.id,{type:'command',action,payload:payload||{}});if(!sent)return res.status(404).json({error:'Client not found or offline'});res.json({ok:true});});
app.delete('/api/clients/:id',requireApiToken,(req,res)=>{const entry=Array.from(clients.entries()).find(([,c])=>c.id===req.params.id);if(!entry)return res.status(404).json({error:'Not found'});clients.delete(entry[0]);broadcastAdminClients();res.json({ok:true});});
app.get('*',(_req,res)=>{res.sendFile(path.join(__dirname,'public','index.html'));});
app.get('/admin/login', (_req, res) => { if (!AUTH_ENABLED) return res.redirect('/admin'); res.sendFile(path.join(__dirname, 'public', 'admin', 'login.html')); }); server.listen(PORT,()=>{
app.use('/admin', requireAdminAuth, express.static(path.join(__dirname, 'public/admin'))); log('--- Frambe v'+VERSION+' ---');
app.use(express.static(path.join(__dirname, 'public'))); 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'));
app.use('/api', (req, _res, next) => { log('API ' + req.method + ' ' + req.originalUrl); next(); }); log('Server time: '+new Date().toString());
log('Global sleep schedule: '+(globalSettings.sleep.enabled?(globalSettings.sleep.sleepAt+' -> '+globalSettings.sleep.wakeAt):'disabled'));
app.get('/api/auth/status', (_req, res) => { res.json({ authEnabled: AUTH_ENABLED, apiTokenEnabled: !!FRAMBE_API_TOKEN }); });
app.post('/api/auth/login', (req, res) => {
if (!AUTH_ENABLED) return res.json({ ok: true });
const { username, password } = req.body;
if (username === ADMIN_USERNAME && password === ADMIN_PASSWORD) {
const token = createSession(username);
res.setHeader('Set-Cookie', 'frambe_session=' + token + '; HttpOnly; Path=/; Max-Age=' + (SESSION_TTL / 1000));
return res.json({ ok: true });
}
return res.status(401).json({ ok: false, error: 'Invalid credentials' });
});
app.post('/api/auth/logout', (req, res) => {
const cookie = req.headers.cookie || '';
const match = cookie.match(/frambe_session=([a-f0-9]+)/);
if (match) sessions.delete(match[1]);
res.setHeader('Set-Cookie', 'frambe_session=; HttpOnly; Path=/; Max-Age=0');
res.json({ ok: true });
});
app.get('/api/config', (_req, res) => { res.json({ version: VERSION, slideshowInterval: SLIDESHOW_INTERVAL, transitionDuration: TRANSITION_DURATION, showClock: SHOW_CLOCK, showDate: SHOW_DATE, showExif: SHOW_EXIF, showProgress: SHOW_PROGRESS, imageFit: IMAGE_FIT, backgroundBlur: BACKGROUND_BLUR, shuffle: SHUFFLE, albumId: ALBUM_ID, showFavoritesOnly: SHOW_FAVORITES_ONLY, refreshInterval: REFRESH_INTERVAL, includeVideos: INCLUDE_VIDEOS, connected: !!API_KEY, authEnabled: AUTH_ENABLED, imageMaxWidth: IMAGE_MAX_WIDTH, imageQuality: IMAGE_QUALITY }); });
app.get('/api/server-info', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/server/version', { headers: immichHeaders() }); if (!r.ok) throw new Error('Immich returned ' + r.status); const v = await r.json(); res.json({ ok: true, version: v }); } catch (e) { res.status(502).json({ ok: false, error: e.message }); } });
app.get('/api/albums', async (_req, res) => { try { const [rOwn, rShared] = await Promise.all([fetch(IMMICH_URL + '/api/albums', { headers: immichHeaders() }), fetch(IMMICH_URL + '/api/albums?shared=true', { headers: immichHeaders() })]); if (!rOwn.ok) throw new Error('Own albums: ' + rOwn.status); const aOwn = await rOwn.json(); const sharedRaw = rShared.ok ? await rShared.json() : []; const seen = new Set(); const result = []; for (const x of aOwn) { if (!seen.has(x.id)) { seen.add(x.id); result.push({ id: x.id, albumName: x.albumName, assetCount: x.assetCount, albumThumbnailAssetId: x.albumThumbnailAssetId, updatedAt: x.updatedAt, shared: false }); } } for (const x of (Array.isArray(sharedRaw) ? sharedRaw : [])) { if (!seen.has(x.id)) { seen.add(x.id); result.push({ id: x.id, albumName: x.albumName, assetCount: x.assetCount, albumThumbnailAssetId: x.albumThumbnailAssetId, updatedAt: x.updatedAt, shared: true }); } } log('Listed ' + result.length + ' albums (' + (result.length - aOwn.length) + ' shared)'); res.json(result); } catch (e) { logErr('Albums: ' + e.message); res.status(502).json({ error: e.message }); } });
app.get('/api/albums/:id', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/albums/' + req.params.id, { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const al = await r.json(); const a = filterAssets(al.assets || []).map(mapAsset); res.json({ id: al.id, albumName: al.albumName, assetCount: a.length, assets: a }); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/people', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/people', { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const d = await r.json(); res.json((d.people || d || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath }))); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/people/:id', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/people/' + req.params.id + '/assets', { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); const raw = await r.json(); res.json(filterAssets(Array.isArray(raw) ? raw : []).map(mapAsset)); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/people/:id/thumbnail', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/people/' + req.params.id + '/thumbnail', { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/random', async (req, res) => { try { const c = Math.min(parseInt(req.query.count, 10) || 50, 250); const r = await fetch(IMMICH_URL + '/api/assets/random?count=' + c, { headers: immichHeaders() }); if (!r.ok) throw new Error('' + r.status); res.json(filterAssets(await r.json()).map(mapAsset)); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/favorites', async (_req, res) => { try { const r = await fetch(IMMICH_URL + '/api/search/metadata', { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, size: 250, page: 1 }) }); if (!r.ok) throw new Error('' + r.status); const d = await r.json(); res.json(filterAssets(d.assets && d.assets.items ? d.assets.items : []).map(a => { var m = mapAsset(a); m.isFavorite = true; return m; })); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/:id/thumbnail', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/assets/' + req.params.id + '/thumbnail?size=' + (req.query.size || 'preview'), { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/:id/optimized', async (req, res) => {
const id = req.params.id;
const cacheKey = id + ':' + IMAGE_MAX_WIDTH + ':' + IMAGE_QUALITY;
const cached = cacheGet(cacheKey);
if (cached) { res.set('Content-Type', 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); return res.send(cached); }
try {
const r = await fetch(IMMICH_URL + '/api/assets/' + id + '/thumbnail?size=preview', { headers: { 'x-api-key': API_KEY } });
if (!r.ok) throw new Error('' + r.status);
const buf = await r.buffer();
const optimized = await sharp(buf).resize({ width: IMAGE_MAX_WIDTH, withoutEnlargement: true }).jpeg({ quality: IMAGE_QUALITY }).toBuffer();
cacheSet(cacheKey, optimized);
res.set('Content-Type', 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); res.send(optimized);
} catch (e) { res.status(502).json({ error: e.message }); }
});
app.get('/api/assets/:id/video', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/assets/' + req.params.id + '/video/playback', { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'video/mp4'); res.set('Cache-Control', 'public, max-age=86400'); const cl = r.headers.get('content-length'); if (cl) res.set('Content-Length', cl); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/assets/:id/original', async (req, res) => { try { const r = await fetch(IMMICH_URL + '/api/assets/' + req.params.id + '/original', { headers: { 'x-api-key': API_KEY } }); if (!r.ok) throw new Error('' + r.status); res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg'); res.set('Cache-Control', 'public, max-age=86400'); r.body.pipe(res); } catch (e) { res.status(502).json({ error: e.message }); } });
app.get('/api/clients', requireApiToken, (_req, res) => { res.json({ ok: true, clients: getClientList() }); });
app.post('/api/clients/:id/command', requireApiToken, (req, res) => {
const { action, payload } = req.body;
if (!action) return res.status(400).json({ error: 'action required' });
const sent = sendToClient(req.params.id, { type: 'command', action, payload: payload || {} });
if (!sent) return res.status(404).json({ error: 'Client not found or offline' });
res.json({ ok: true });
});
app.delete('/api/clients/:id', requireApiToken, (req, res) => {
const c = clients.get(req.params.id);
if (!c) return res.status(404).json({ error: 'Not found' });
clients.delete(req.params.id);
broadcastAdminClients();
res.json({ ok: true });
});
app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); });
server.listen(PORT, () => {
log('--- Frambe v' + VERSION + ' ---');
log('Listening on port ' + PORT);
log('Immich: ' + IMMICH_URL);
log('API key: ' + (API_KEY ? 'set' : 'NOT SET'));
log('Auth: ' + (AUTH_ENABLED ? 'enabled' : 'disabled'));
log('API token: ' + (FRAMBE_API_TOKEN ? 'set' : 'not set'));
}); });