12 Commits

9 changed files with 175 additions and 57 deletions
+2 -2
View File
@@ -22,5 +22,5 @@ SHOW_PROGRESS=true
# ALBUM_ID= # ALBUM_ID=
# SHOW_FAVORITES_ONLY=false # SHOW_FAVORITES_ONLY=false
# Server # Server (internal port — Docker maps externally via docker-compose)
PORT=3030 PORT=3000
+2 -2
View File
@@ -11,10 +11,10 @@ RUN npm install --production && npm cache clean --force
COPY server.js ./ COPY server.js ./
COPY public/ ./public/ COPY public/ ./public/
EXPOSE 3030 EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3030/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
+34 -12
View File
@@ -1,4 +1,8 @@
# 🖼️ Frambe # Frambe
<p align="center">
<img src="public/img/icon.png" alt="Frambe" width="180">
</p>
A lightweight, self-contained Docker web application that connects to your [Immich](https://immich.app/) server and displays photos in a beautiful full-screen slideshow — perfect for turning old tablets, spare screens, and Raspberry Pis into digital photo frames. A lightweight, self-contained Docker web application that connects to your [Immich](https://immich.app/) server and displays photos in a beautiful full-screen slideshow — perfect for turning old tablets, spare screens, and Raspberry Pis into digital photo frames.
@@ -6,6 +10,9 @@ A lightweight, self-contained Docker web application that connects to your [Immi
- **Immich API Integration** — Connects securely via API key (kept server-side) - **Immich API Integration** — Connects securely via API key (kept server-side)
- **Album Browser** — Select any album, random photos, or favorites only - **Album Browser** — Select any album, random photos, or favorites only
- **Person / Face Support** — Display photos of a specific person via Immich's face recognition
- **URL-Based Zero-Touch Launch** — Skip the setup screen entirely with query parameters
- **Auto-Refresh** — Periodically checks for new photos added to the source album/person
- **Smooth Crossfade** — Double-buffered image transitions with configurable duration - **Smooth Crossfade** — Double-buffered image transitions with configurable duration
- **Background Blur** — Blurred backdrop fills the space behind non-covering images - **Background Blur** — Blurred backdrop fills the space behind non-covering images
- **Clock & Date Overlay** — Always know the time at a glance - **Clock & Date Overlay** — Always know the time at a glance
@@ -14,7 +21,6 @@ A lightweight, self-contained Docker web application that connects to your [Immi
- **Touch Controls** — Tap left/right edges to navigate, centre to toggle overlay - **Touch Controls** — Tap left/right edges to navigate, centre to toggle overlay
- **Keyboard Controls** — Arrow keys, Space, F (fullscreen), I (info), Esc (exit) - **Keyboard Controls** — Arrow keys, Space, F (fullscreen), I (info), Esc (exit)
- **Screen Wake Lock** — Prevents screen sleep on supported devices - **Screen Wake Lock** — Prevents screen sleep on supported devices
- **Auto-Start** — Configure an album ID or favorites-only to skip the setup screen
- **Responsive** — Works on any screen size from phone to TV - **Responsive** — Works on any screen size from phone to TV
- **Older Device Friendly** — Vanilla HTML/CSS/JS, no heavy frameworks - **Older Device Friendly** — Vanilla HTML/CSS/JS, no heavy frameworks
- **Docker Containerised** — Single container, minimal footprint - **Docker Containerised** — Single container, minimal footprint
@@ -30,8 +36,8 @@ A lightweight, self-contained Docker web application that connects to your [Immi
### 2. Run with Docker Compose ### 2. Run with Docker Compose
```bash ```bash
git clone https://gitea.hideawaygaming.com.au/jessikitty/immich-frame.git git clone https://gitea.hideawaygaming.com.au/jessikitty/frambe.git
cd immich-frame cd frambe
``` ```
Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then: Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then:
@@ -40,7 +46,7 @@ Edit `docker-compose.yml` and set your `IMMICH_URL` and `IMMICH_API_KEY`, then:
docker compose up -d docker compose up -d
``` ```
Open `http://your-server:3000` in a browser on your tablet/screen. Open `http://your-server:3030` in a browser on your tablet/screen.
### 3. Run with Docker directly ### 3. Run with Docker directly
@@ -48,13 +54,26 @@ Open `http://your-server:3000` in a browser on your tablet/screen.
docker build -t frambe . docker build -t frambe .
docker run -d \ docker run -d \
--name frambe \ --name frambe \
-p 3000:3000 \ -p 3030:3000 \
-e IMMICH_URL=http://your-immich-server:2283 \ -e IMMICH_URL=http://your-immich-server:2283 \
-e IMMICH_API_KEY=your-api-key \ -e IMMICH_API_KEY=your-api-key \
--restart unless-stopped \ --restart unless-stopped \
frambe frambe
``` ```
## 🔗 Zero-Touch URL Parameters
Skip the setup screen entirely by passing query parameters. This is ideal for dedicated frames — just bookmark the URL on each tablet:
| URL | What it shows |
|---|---|
| `http://server:3030/?album=ALBUM_UUID` | Photos from a specific album |
| `http://server:3030/?person=PERSON_UUID` | Photos of a specific person (face recognition) |
| `http://server:3030/?favorites` | Favorite photos only |
| `http://server:3030/?random` | Random photos from the library |
You can find album and person UUIDs in Immich's web interface URL bar when viewing an album or person.
## ⚙️ Configuration ## ⚙️ Configuration
All settings are via environment variables: All settings are via environment variables:
@@ -72,9 +91,10 @@ All settings are via environment variables:
| `SHOW_DATE` | `true` | Display date overlay | | `SHOW_DATE` | `true` | Display date overlay |
| `SHOW_EXIF` | `true` | Display photo metadata | | `SHOW_EXIF` | `true` | Display photo metadata |
| `SHOW_PROGRESS` | `true` | Display progress bar | | `SHOW_PROGRESS` | `true` | Display progress bar |
| `ALBUM_ID` | *(empty)* | Auto-start with specific album | | `REFRESH_INTERVAL` | `300` | Seconds between source refresh checks (new photos) |
| `SHOW_FAVORITES_ONLY` | `false` | Auto-start with favorites | | `ALBUM_ID` | *(empty)* | Auto-start with specific album (env-based) |
| `PORT` | `3000` | Server port | | `SHOW_FAVORITES_ONLY` | `false` | Auto-start with favorites (env-based) |
| `PORT` | `3000` | Internal server port (Docker maps externally via compose) |
## 🎮 Controls ## 🎮 Controls
@@ -92,7 +112,7 @@ All settings are via environment variables:
## 📱 Tablet Setup Tips ## 📱 Tablet Setup Tips
1. Open the frame URL in your tablet's browser 1. Open the frame URL in your tablet's browser (use a `?album=` or `?person=` URL for zero-touch)
2. Add to Home Screen for a full-screen app experience 2. Add to Home Screen for a full-screen app experience
3. Enable kiosk mode or guided access to lock to the app 3. Enable kiosk mode or guided access to lock to the app
4. Disable screen timeout in your device settings 4. Disable screen timeout in your device settings
@@ -103,13 +123,15 @@ All settings are via environment variables:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Browser │ HTTP │ Frambe │ API │ Immich │ │ Browser │ HTTP │ Frambe │ API │ Immich │
│ (Tablet) │◄───────►│ (Node.js) │◄───────►│ Server │ │ (Tablet) │◄───────►│ (Node.js) │◄───────►│ Server │
└──────────────┘ :3000 └──────────────┘ :2283 └──────────────┘ └──────────────┘ :3030 └──────────────┘ :2283 └──────────────┘
``` ```
The Node.js backend acts as a secure proxy — your Immich API key never reaches the browser. The Node.js backend acts as a secure proxy — your Immich API key never reaches the browser. The frontend periodically polls the backend for new photos so albums stay up to date without restarting.
## 📋 Version History ## 📋 Version History
- **1.2.1** — Fix port mapping (3030:3000 external:internal), fix URL param auto-launch not starting slideshow
- **1.2.0** — URL params (`?album=`, `?person=`, `?favorites`, `?random`), person/face support, periodic auto-refresh, app icon, default port changed to 3030
- **1.1.0** — Rebrand to Frambe - **1.1.0** — Rebrand to Frambe
- **1.0.0** — Initial release: album browser, slideshow with crossfade, clock/date/EXIF overlays, touch & keyboard controls, Docker deployment - **1.0.0** — Initial release: album browser, slideshow with crossfade, clock/date/EXIF overlays, touch & keyboard controls, Docker deployment
+1 -1
View File
@@ -6,7 +6,7 @@ services:
container_name: frambe container_name: frambe
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3030:3030" - "3030:3000"
environment: environment:
# REQUIRED # REQUIRED
- IMMICH_URL=http://your-immich-server:2283 - IMMICH_URL=http://your-immich-server:2283
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "frambe", "name": "frambe",
"version": "1.2.0", "version": "1.2.2",
"description": "Frambe — a lightweight digital photo frame web app for Immich", "description": "Frambe — a lightweight digital photo frame web app for Immich",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+73 -20
View File
@@ -22,6 +22,16 @@
return p; return p;
} }
// --- Auto-launch helper: sets source and immediately starts slideshow ---
async function autoLaunch(source, albumId, personId) {
urlDriven = true;
selectedSource = source;
selectedAlbumId = albumId || null;
selectedPersonId = personId || null;
console.log('Frambe: auto-launching source=' + source + (albumId ? ' album=' + albumId : '') + (personId ? ' person=' + personId : ''));
await doStartSlideshow();
}
async function init() { async function init() {
document.body.classList.add('setup-mode'); document.body.classList.add('setup-mode');
try { try {
@@ -31,13 +41,19 @@
if (!si.ok) { showError('Cannot reach Immich server: ' + si.error); return; } if (!si.ok) { showError('Cannot reach Immich server: ' + si.error); return; }
$connectionStatus.textContent = 'Connected to Immich v' + si.version.major + '.' + si.version.minor + '.' + si.version.patch; $connectionStatus.textContent = 'Connected to Immich v' + si.version.major + '.' + si.version.minor + '.' + si.version.patch;
$connectionStatus.classList.add('connected'); $connectionStatus.classList.add('connected');
// Check URL parameters for direct launch (zero-touch)
var params = getUrlParams(); var params = getUrlParams();
if (params.album) { urlDriven = true; selectedSource = 'album'; selectedAlbumId = params.album; $btnStart.disabled = false; startSlideshow(); return; } if (params.album) { await autoLaunch('album', params.album, null); return; }
if (params.person) { urlDriven = true; selectedSource = 'person'; selectedPersonId = params.person; $btnStart.disabled = false; startSlideshow(); return; } if (params.person) { await autoLaunch('person', null, params.person); return; }
if (params.favorites === '' || params.favorites === 'true' || params.favorites === '1') { urlDriven = true; selectedSource = 'favorites'; $btnStart.disabled = false; startSlideshow(); return; } if ('favorites' in params) { await autoLaunch('favorites', null, null); return; }
if (params.random === '' || params.random === 'true' || params.random === '1') { urlDriven = true; selectedSource = 'random'; $btnStart.disabled = false; startSlideshow(); return; } if ('random' in params) { await autoLaunch('random', null, null); return; }
if (config.albumId) { selectedSource = 'album'; selectedAlbumId = config.albumId; $btnStart.disabled = false; startSlideshow(); return; }
if (config.showFavoritesOnly) { selectedSource = 'favorites'; $btnStart.disabled = false; startSlideshow(); return; } // Check env-based auto-start
if (config.albumId) { await autoLaunch('album', config.albumId, null); return; }
if (config.showFavoritesOnly) { await autoLaunch('favorites', null, null); return; }
// No auto-start — show setup screen with album picker
await loadAlbums(); await loadAlbums();
} catch (err) { showError('Failed to initialize: ' + err.message); } } catch (err) { showError('Failed to initialize: ' + err.message); }
} }
@@ -77,11 +93,27 @@
}; };
async function loadAssets() { async function loadAssets() {
if (selectedSource === 'album' && selectedAlbumId) { var al = await (await fetch('/api/albums/' + selectedAlbumId)).json(); assets = al.assets || []; } var res;
else if (selectedSource === 'person' && selectedPersonId) { assets = await (await fetch('/api/people/' + selectedPersonId)).json(); } if (selectedSource === 'album' && selectedAlbumId) {
else if (selectedSource === 'favorites') { assets = await (await fetch('/api/assets/favorites')).json(); } res = await fetch('/api/albums/' + selectedAlbumId);
else { assets = await (await fetch('/api/assets/random?count=100')).json(); } if (!res.ok) throw new Error('Album fetch failed: ' + res.status);
var al = await res.json();
assets = al.assets || [];
} else if (selectedSource === 'person' && selectedPersonId) {
res = await fetch('/api/people/' + selectedPersonId);
if (!res.ok) throw new Error('Person fetch failed: ' + res.status);
assets = await res.json();
} else if (selectedSource === 'favorites') {
res = await fetch('/api/assets/favorites');
if (!res.ok) throw new Error('Favorites fetch failed: ' + res.status);
assets = await res.json();
} else {
res = await fetch('/api/assets/random?count=100');
if (!res.ok) throw new Error('Random fetch failed: ' + res.status);
assets = await res.json();
}
if (config.shuffle) shuffleArray(assets); if (config.shuffle) shuffleArray(assets);
console.log('Frambe: loaded ' + assets.length + ' photo(s) from ' + selectedSource);
} }
function startRefreshTimer() { function startRefreshTimer() {
@@ -101,24 +133,45 @@
}, (config.refreshInterval || 300) * 1000); }, (config.refreshInterval || 300) * 1000);
} }
window.startSlideshow = async function () { // --- Core slideshow start (used by both button click and auto-launch) ---
async function doStartSlideshow() {
if (!selectedSource) return; if (!selectedSource) return;
$btnStart.disabled = true; $btnStart.innerHTML = '<span class="spinner"></span> Loading…'; $btnStart.disabled = true; $btnStart.innerHTML = '<span class="spinner"></span> Loading…';
try { try {
await loadAssets(); await loadAssets();
if (!assets.length) { $btnStart.textContent = 'No photos found'; setTimeout(function () { $btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false; }, 2000); return; } if (!assets.length) {
$setupScreen.style.display = 'none'; $slideshowScreen.style.display = 'block'; $btnStart.textContent = 'No photos found';
document.body.classList.remove('setup-mode'); isRunning = true; setTimeout(function () { $btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false; }, 2000);
return;
}
// Switch to slideshow view
$setupScreen.style.display = 'none';
$slideshowScreen.style.display = 'block';
document.body.classList.remove('setup-mode');
isRunning = true;
var t = (config.transitionDuration || 2) * 1000; var t = (config.transitionDuration || 2) * 1000;
$layerA.style.transition = 'opacity ' + t + 'ms ease'; $layerB.style.transition = 'opacity ' + t + 'ms ease'; $layerA.style.transition = 'opacity ' + t + 'ms ease';
$layerB.style.transition = 'opacity ' + t + 'ms ease';
$bgBlur.style.transition = 'opacity ' + (t * 0.75) + 'ms ease'; $bgBlur.style.transition = 'opacity ' + (t * 0.75) + 'ms ease';
if (!config.showClock) $clock.style.display = 'none'; if (!config.showDate) $dateDisplay.style.display = 'none'; if (!config.showClock) $clock.style.display = 'none';
if (!config.showExif) $exifInfo.style.display = 'none'; if (!config.showProgress) $progressBar.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'; if (!config.backgroundBlur) $bgBlur.style.display = 'none';
updateClock(); setInterval(updateClock, 1000); updateClock(); setInterval(updateClock, 1000);
currentIndex = -1; showNextPhoto(); scheduleOverlayHide(); startRefreshTimer(); currentIndex = -1;
} catch (err) { $btnStart.textContent = 'Error: ' + err.message; setTimeout(function () { $btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false; }, 3000); } showNextPhoto();
}; scheduleOverlayHide();
startRefreshTimer();
} catch (err) {
console.error('Frambe: slideshow start failed', err);
$btnStart.textContent = 'Error: ' + err.message;
setTimeout(function () { $btnStart.textContent = '▶ Start Slideshow'; $btnStart.disabled = false; }, 3000);
}
}
// Exposed for the button onclick
window.startSlideshow = function () { doStartSlideshow(); };
window.exitSlideshow = function () { window.exitSlideshow = function () {
if (urlDriven) { window.location.href = window.location.pathname; return; } if (urlDriven) { window.location.href = window.location.pathname; return; }
+62 -19
View File
@@ -3,8 +3,9 @@ const fetch = require('node-fetch');
const path = require('path'); const path = require('path');
require('dotenv').config(); require('dotenv').config();
const VERSION = '1.2.2';
const app = express(); const app = express();
const PORT = process.env.PORT || 3030; const PORT = process.env.PORT || 3000;
const IMMICH_URL = (process.env.IMMICH_URL || 'http://localhost:2283').replace(/\/+$/, ''); const IMMICH_URL = (process.env.IMMICH_URL || 'http://localhost:2283').replace(/\/+$/, '');
const API_KEY = process.env.IMMICH_API_KEY || ''; const API_KEY = process.env.IMMICH_API_KEY || '';
@@ -25,11 +26,31 @@ function immichHeaders() {
return { 'x-api-key': API_KEY, 'Accept': 'application/json', 'Content-Type': 'application/json' }; return { 'x-api-key': API_KEY, 'Accept': 'application/json', 'Content-Type': 'application/json' };
} }
app.use(express.static(path.join(__dirname, 'public'))); function log(msg) { console.log('[Frambe] ' + msg); }
function logErr(msg) { console.error('[Frambe] ERROR: ' + msg); }
// --- Request logging for API calls ---
app.use('/api', (req, _res, next) => {
log('API ' + req.method + ' ' + req.originalUrl);
next();
});
// --- Static files with no-cache on HTML/JS/CSS (prevents stale browser cache) ---
app.use(express.static(path.join(__dirname, 'public'), {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html') || filePath.endsWith('.js') || filePath.endsWith('.css')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
}
}
}));
app.use(express.json()); app.use(express.json());
// --- API: Config ---
app.get('/api/config', (_req, res) => { app.get('/api/config', (_req, res) => {
res.json({ res.json({
version: VERSION,
slideshowInterval: SLIDESHOW_INTERVAL, transitionDuration: TRANSITION_DURATION, slideshowInterval: SLIDESHOW_INTERVAL, transitionDuration: TRANSITION_DURATION,
showClock: SHOW_CLOCK, showDate: SHOW_DATE, showExif: SHOW_EXIF, showProgress: SHOW_PROGRESS, showClock: SHOW_CLOCK, showDate: SHOW_DATE, showExif: SHOW_EXIF, showProgress: SHOW_PROGRESS,
imageFit: IMAGE_FIT, backgroundBlur: BACKGROUND_BLUR, shuffle: SHUFFLE, imageFit: IMAGE_FIT, backgroundBlur: BACKGROUND_BLUR, shuffle: SHUFFLE,
@@ -38,21 +59,29 @@ app.get('/api/config', (_req, res) => {
}); });
}); });
// --- API: Server info ---
app.get('/api/server-info', async (_req, res) => { app.get('/api/server-info', async (_req, res) => {
try { try {
const r = await fetch(`${IMMICH_URL}/api/server/version`, { headers: immichHeaders() }); const r = await fetch(`${IMMICH_URL}/api/server/version`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`); if (!r.ok) throw new Error(`Immich returned ${r.status}`);
res.json({ ok: true, version: await r.json() }); const v = await r.json();
} catch (err) { res.status(502).json({ ok: false, error: err.message }); } log('Immich connection OK, version ' + v.major + '.' + v.minor + '.' + v.patch);
res.json({ ok: true, version: v });
} catch (err) {
logErr('Immich connection failed: ' + err.message);
res.status(502).json({ ok: false, error: err.message });
}
}); });
// --- API: Albums ---
app.get('/api/albums', async (_req, res) => { app.get('/api/albums', async (_req, res) => {
try { try {
const r = await fetch(`${IMMICH_URL}/api/albums`, { headers: immichHeaders() }); const r = await fetch(`${IMMICH_URL}/api/albums`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`); if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const albums = await r.json(); const albums = await r.json();
log('Listed ' + albums.length + ' albums');
res.json(albums.map(a => ({ id: a.id, albumName: a.albumName, assetCount: a.assetCount, albumThumbnailAssetId: a.albumThumbnailAssetId, updatedAt: a.updatedAt }))); res.json(albums.map(a => ({ id: a.id, albumName: a.albumName, assetCount: a.assetCount, albumThumbnailAssetId: a.albumThumbnailAssetId, updatedAt: a.updatedAt })));
} catch (err) { res.status(502).json({ error: err.message }); } } catch (err) { logErr('Albums list failed: ' + err.message); res.status(502).json({ error: err.message }); }
}); });
function mapAsset(a) { function mapAsset(a) {
@@ -64,12 +93,14 @@ function mapAsset(a) {
app.get('/api/albums/:id', async (req, res) => { app.get('/api/albums/:id', async (req, res) => {
try { try {
log('Fetching album ' + req.params.id);
const r = await fetch(`${IMMICH_URL}/api/albums/${req.params.id}`, { headers: immichHeaders() }); const r = await fetch(`${IMMICH_URL}/api/albums/${req.params.id}`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`); if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const album = await r.json(); const album = await r.json();
const assets = (album.assets || []).filter(a => a.type === 'IMAGE').map(mapAsset); const assets = (album.assets || []).filter(a => a.type === 'IMAGE').map(mapAsset);
log('Album "' + album.albumName + '" returned ' + assets.length + ' images');
res.json({ id: album.id, albumName: album.albumName, assetCount: assets.length, assets }); res.json({ id: album.id, albumName: album.albumName, assetCount: assets.length, assets });
} catch (err) { res.status(502).json({ error: err.message }); } } catch (err) { logErr('Album fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
}); });
app.get('/api/people', async (_req, res) => { app.get('/api/people', async (_req, res) => {
@@ -77,17 +108,22 @@ app.get('/api/people', async (_req, res) => {
const r = await fetch(`${IMMICH_URL}/api/people`, { headers: immichHeaders() }); const r = await fetch(`${IMMICH_URL}/api/people`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`); if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const data = await r.json(); const data = await r.json();
res.json((data.people || data || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath }))); const people = (data.people || data || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath }));
} catch (err) { res.status(502).json({ error: err.message }); } log('Listed ' + people.length + ' people');
res.json(people);
} catch (err) { logErr('People list failed: ' + err.message); res.status(502).json({ error: err.message }); }
}); });
app.get('/api/people/:id', async (req, res) => { app.get('/api/people/:id', async (req, res) => {
try { try {
log('Fetching person ' + req.params.id);
const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/assets`, { headers: immichHeaders() }); const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/assets`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`); if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const assets = await r.json(); const assets = await r.json();
res.json((Array.isArray(assets) ? assets : []).filter(a => a.type === 'IMAGE').map(mapAsset)); const images = (Array.isArray(assets) ? assets : []).filter(a => a.type === 'IMAGE').map(mapAsset);
} catch (err) { res.status(502).json({ error: err.message }); } log('Person returned ' + images.length + ' images');
res.json(images);
} catch (err) { logErr('Person fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
}); });
app.get('/api/people/:id/thumbnail', async (req, res) => { app.get('/api/people/:id/thumbnail', async (req, res) => {
@@ -105,8 +141,10 @@ app.get('/api/assets/random', async (req, res) => {
const count = Math.min(parseInt(req.query.count, 10) || 50, 250); const count = Math.min(parseInt(req.query.count, 10) || 50, 250);
const r = await fetch(`${IMMICH_URL}/api/assets/random?count=${count}`, { headers: immichHeaders() }); const r = await fetch(`${IMMICH_URL}/api/assets/random?count=${count}`, { headers: immichHeaders() });
if (!r.ok) throw new Error(`Immich returned ${r.status}`); if (!r.ok) throw new Error(`Immich returned ${r.status}`);
res.json((await r.json()).filter(a => a.type === 'IMAGE').map(mapAsset)); const images = (await r.json()).filter(a => a.type === 'IMAGE').map(mapAsset);
} catch (err) { res.status(502).json({ error: err.message }); } log('Random returned ' + images.length + ' images');
res.json(images);
} catch (err) { logErr('Random fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
}); });
app.get('/api/assets/favorites', async (_req, res) => { app.get('/api/assets/favorites', async (_req, res) => {
@@ -114,8 +152,10 @@ app.get('/api/assets/favorites', async (_req, res) => {
const r = await fetch(`${IMMICH_URL}/api/search/metadata`, { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, type: 'IMAGE', size: 250, page: 1 }) }); const r = await fetch(`${IMMICH_URL}/api/search/metadata`, { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, type: 'IMAGE', size: 250, page: 1 }) });
if (!r.ok) throw new Error(`Immich returned ${r.status}`); if (!r.ok) throw new Error(`Immich returned ${r.status}`);
const data = await r.json(); const data = await r.json();
res.json((data.assets?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true }))); const images = (data.assets?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true }));
} catch (err) { res.status(502).json({ error: err.message }); } log('Favorites returned ' + images.length + ' images');
res.json(images);
} catch (err) { logErr('Favorites fetch failed: ' + err.message); res.status(502).json({ error: err.message }); }
}); });
app.get('/api/assets/:id/thumbnail', async (req, res) => { app.get('/api/assets/:id/thumbnail', async (req, res) => {
@@ -142,9 +182,12 @@ app.get('/api/assets/:id/original', async (req, res) => {
app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); });
app.listen(PORT, '0.0.0.0', () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`🖼️ Frambe running on http://0.0.0.0:${PORT}`); log('--- Frambe v' + VERSION + ' ---');
console.log(`📡 Immich server: ${IMMICH_URL}`); log('Server listening on port ' + PORT);
console.log(`🔑 API Key: ${API_KEY ? '***configured***' : '⚠️ NOT SET'}`); log('Immich URL: ' + IMMICH_URL);
if (ALBUM_ID) console.log(`📁 Default album: ${ALBUM_ID}`); log('API key: ' + (API_KEY ? 'configured (' + API_KEY.substring(0, 8) + '...)' : 'NOT SET'));
console.log(`⏱️ Slideshow: ${SLIDESHOW_INTERVAL}s | Refresh: ${REFRESH_INTERVAL}s`); log('Slideshow: ' + SLIDESHOW_INTERVAL + 's interval, ' + TRANSITION_DURATION + 's transition, refresh every ' + REFRESH_INTERVAL + 's');
if (ALBUM_ID) log('Default album: ' + ALBUM_ID);
if (SHOW_FAVORITES_ONLY) log('Auto-start: favorites only');
log('Waiting for requests...');
}); });