diff --git a/server.js b/server.js new file mode 100644 index 0000000..33d813b --- /dev/null +++ b/server.js @@ -0,0 +1,249 @@ +const express = require('express'); +const fetch = require('node-fetch'); +const path = require('path'); +require('dotenv').config(); + +const app = express(); +const PORT = process.env.PORT || 3000; + +// --- Configuration --- +const IMMICH_URL = (process.env.IMMICH_URL || 'http://localhost:2283').replace(/\/+$/, ''); +const API_KEY = process.env.IMMICH_API_KEY || ''; +const SLIDESHOW_INTERVAL = parseInt(process.env.SLIDESHOW_INTERVAL, 10) || 30; +const TRANSITION_DURATION = parseInt(process.env.TRANSITION_DURATION, 10) || 2; +const SHOW_CLOCK = process.env.SHOW_CLOCK !== 'false'; +const SHOW_DATE = process.env.SHOW_DATE !== 'false'; +const SHOW_EXIF = process.env.SHOW_EXIF !== 'false'; +const SHOW_PROGRESS = process.env.SHOW_PROGRESS !== 'false'; +const IMAGE_FIT = process.env.IMAGE_FIT || 'contain'; +const BACKGROUND_BLUR = process.env.BACKGROUND_BLUR !== 'false'; +const SHUFFLE = process.env.SHUFFLE !== 'false'; +const ALBUM_ID = process.env.ALBUM_ID || ''; +const SHOW_FAVORITES_ONLY = process.env.SHOW_FAVORITES_ONLY === 'true'; + +// --- Shared headers for Immich API --- +function immichHeaders() { + return { + 'x-api-key': API_KEY, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }; +} + +// --- Middleware --- +app.use(express.static(path.join(__dirname, 'public'))); +app.use(express.json()); + +// --- API: Config endpoint (sends safe config to frontend) --- +app.get('/api/config', (_req, res) => { + res.json({ + 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, + connected: !!API_KEY, + }); +}); + +// --- API: Server info / connectivity check --- +app.get('/api/server-info', async (_req, res) => { + try { + const response = await fetch(`${IMMICH_URL}/api/server/version`, { + headers: immichHeaders(), + }); + if (!response.ok) throw new Error(`Immich returned ${response.status}`); + const data = await response.json(); + res.json({ ok: true, version: data }); + } catch (err) { + res.status(502).json({ ok: false, error: err.message }); + } +}); + +// --- API: List albums --- +app.get('/api/albums', async (_req, res) => { + try { + const response = await fetch(`${IMMICH_URL}/api/albums`, { + headers: immichHeaders(), + }); + if (!response.ok) throw new Error(`Immich returned ${response.status}`); + const albums = await response.json(); + // Return simplified album list + const simplified = albums.map((a) => ({ + id: a.id, + albumName: a.albumName, + assetCount: a.assetCount, + albumThumbnailAssetId: a.albumThumbnailAssetId, + updatedAt: a.updatedAt, + })); + res.json(simplified); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +// --- API: Get album assets --- +app.get('/api/albums/:id', async (req, res) => { + try { + const response = await fetch(`${IMMICH_URL}/api/albums/${req.params.id}`, { + headers: immichHeaders(), + }); + if (!response.ok) throw new Error(`Immich returned ${response.status}`); + const album = await response.json(); + // Filter to images only, return simplified asset data + const assets = (album.assets || []) + .filter((a) => a.type === 'IMAGE') + .map((a) => ({ + id: a.id, + originalFileName: a.originalFileName, + fileCreatedAt: a.fileCreatedAt, + exifInfo: a.exifInfo + ? { + make: a.exifInfo.make, + model: a.exifInfo.model, + city: a.exifInfo.city, + state: a.exifInfo.state, + country: a.exifInfo.country, + description: a.exifInfo.description, + dateTimeOriginal: a.exifInfo.dateTimeOriginal, + } + : null, + })); + res.json({ + id: album.id, + albumName: album.albumName, + assetCount: assets.length, + assets, + }); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +// --- API: Get random assets (when no album selected) --- +app.get('/api/assets/random', async (req, res) => { + try { + const count = Math.min(parseInt(req.query.count, 10) || 50, 250); + const response = await fetch(`${IMMICH_URL}/api/assets/random?count=${count}`, { + headers: immichHeaders(), + }); + if (!response.ok) throw new Error(`Immich returned ${response.status}`); + const assets = await response.json(); + const images = assets + .filter((a) => a.type === 'IMAGE') + .map((a) => ({ + id: a.id, + originalFileName: a.originalFileName, + fileCreatedAt: a.fileCreatedAt, + isFavorite: a.isFavorite, + exifInfo: a.exifInfo + ? { + make: a.exifInfo.make, + model: a.exifInfo.model, + city: a.exifInfo.city, + state: a.exifInfo.state, + country: a.exifInfo.country, + description: a.exifInfo.description, + dateTimeOriginal: a.exifInfo.dateTimeOriginal, + } + : null, + })); + res.json(images); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +// --- API: Get favorites --- +app.get('/api/assets/favorites', async (_req, res) => { + try { + const body = JSON.stringify({ + isFavorite: true, + type: 'IMAGE', + size: 250, + page: 1, + }); + const response = await fetch(`${IMMICH_URL}/api/search/metadata`, { + method: 'POST', + headers: immichHeaders(), + body, + }); + if (!response.ok) throw new Error(`Immich returned ${response.status}`); + const data = await response.json(); + const images = (data.assets?.items || []).map((a) => ({ + id: a.id, + originalFileName: a.originalFileName, + fileCreatedAt: a.fileCreatedAt, + isFavorite: true, + exifInfo: a.exifInfo + ? { + make: a.exifInfo.make, + model: a.exifInfo.model, + city: a.exifInfo.city, + state: a.exifInfo.state, + country: a.exifInfo.country, + description: a.exifInfo.description, + dateTimeOriginal: a.exifInfo.dateTimeOriginal, + } + : null, + })); + res.json(images); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +// --- API: Proxy image (thumbnail) --- +app.get('/api/assets/:id/thumbnail', async (req, res) => { + try { + const size = req.query.size || 'preview'; // 'thumbnail' or 'preview' + const response = await fetch( + `${IMMICH_URL}/api/assets/${req.params.id}/thumbnail?size=${size}`, + { headers: { 'x-api-key': API_KEY } } + ); + if (!response.ok) throw new Error(`Immich returned ${response.status}`); + const contentType = response.headers.get('content-type'); + res.set('Content-Type', contentType || 'image/jpeg'); + res.set('Cache-Control', 'public, max-age=86400'); + response.body.pipe(res); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +// --- API: Proxy full-size image --- +app.get('/api/assets/:id/original', async (req, res) => { + try { + const response = await fetch( + `${IMMICH_URL}/api/assets/${req.params.id}/original`, + { headers: { 'x-api-key': API_KEY } } + ); + if (!response.ok) throw new Error(`Immich returned ${response.status}`); + const contentType = response.headers.get('content-type'); + res.set('Content-Type', contentType || 'image/jpeg'); + res.set('Cache-Control', 'public, max-age=86400'); + response.body.pipe(res); + } catch (err) { + res.status(502).json({ error: err.message }); + } +}); + +// --- Fallback: serve index.html for SPA --- +app.get('*', (_req, res) => { + res.sendFile(path.join(__dirname, 'public', 'index.html')); +}); + +// --- Start --- +app.listen(PORT, '0.0.0.0', () => { + console.log(`🖼️ ImmichFrame running on http://0.0.0.0:${PORT}`); + console.log(`📡 Immich server: ${IMMICH_URL}`); + console.log(`🔑 API Key: ${API_KEY ? '***configured***' : '⚠️ NOT SET'}`); + if (ALBUM_ID) console.log(`📁 Default album: ${ALBUM_ID}`); + console.log(`⏱️ Slideshow interval: ${SLIDESHOW_INTERVAL}s`); +});