feat: port 3030, person API endpoints, refresh interval config, mapAsset helper

This commit is contained in:
2026-05-19 15:56:43 +10:00
parent ea2828d071
commit 00bfa926e4
+79 -178
View File
@@ -4,9 +4,8 @@ const path = require('path');
require('dotenv').config(); require('dotenv').config();
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3030;
// --- Configuration ---
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 || '';
const SLIDESHOW_INTERVAL = parseInt(process.env.SLIDESHOW_INTERVAL, 10) || 30; const SLIDESHOW_INTERVAL = parseInt(process.env.SLIDESHOW_INTERVAL, 10) || 30;
@@ -20,230 +19,132 @@ const BACKGROUND_BLUR = process.env.BACKGROUND_BLUR !== 'false';
const SHUFFLE = process.env.SHUFFLE !== 'false'; const SHUFFLE = process.env.SHUFFLE !== 'false';
const ALBUM_ID = process.env.ALBUM_ID || ''; 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;
// --- Shared headers for Immich API ---
function immichHeaders() { function immichHeaders() {
return { return { 'x-api-key': API_KEY, 'Accept': 'application/json', 'Content-Type': 'application/json' };
'x-api-key': API_KEY,
'Accept': 'application/json',
'Content-Type': 'application/json',
};
} }
// --- Middleware ---
app.use(express.static(path.join(__dirname, 'public'))); app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json()); app.use(express.json());
// --- API: Config endpoint (sends safe config to frontend) ---
app.get('/api/config', (_req, res) => { app.get('/api/config', (_req, res) => {
res.json({ res.json({
slideshowInterval: SLIDESHOW_INTERVAL, slideshowInterval: SLIDESHOW_INTERVAL, transitionDuration: TRANSITION_DURATION,
transitionDuration: TRANSITION_DURATION, showClock: SHOW_CLOCK, showDate: SHOW_DATE, showExif: SHOW_EXIF, showProgress: SHOW_PROGRESS,
showClock: SHOW_CLOCK, imageFit: IMAGE_FIT, backgroundBlur: BACKGROUND_BLUR, shuffle: SHUFFLE,
showDate: SHOW_DATE, albumId: ALBUM_ID, showFavoritesOnly: SHOW_FAVORITES_ONLY, refreshInterval: REFRESH_INTERVAL,
showExif: SHOW_EXIF,
showProgress: SHOW_PROGRESS,
imageFit: IMAGE_FIT,
backgroundBlur: BACKGROUND_BLUR,
shuffle: SHUFFLE,
albumId: ALBUM_ID,
showFavoritesOnly: SHOW_FAVORITES_ONLY,
connected: !!API_KEY, connected: !!API_KEY,
}); });
}); });
// --- API: Server info / connectivity check ---
app.get('/api/server-info', async (_req, res) => { app.get('/api/server-info', async (_req, res) => {
try { try {
const response = await fetch(`${IMMICH_URL}/api/server/version`, { const r = await fetch(`${IMMICH_URL}/api/server/version`, { headers: immichHeaders() });
headers: immichHeaders(), if (!r.ok) throw new Error(`Immich returned ${r.status}`);
}); res.json({ ok: true, version: await r.json() });
if (!response.ok) throw new Error(`Immich returned ${response.status}`); } catch (err) { res.status(502).json({ ok: false, error: err.message }); }
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) => { app.get('/api/albums', async (_req, res) => {
try { try {
const response = await fetch(`${IMMICH_URL}/api/albums`, { const r = await fetch(`${IMMICH_URL}/api/albums`, { headers: immichHeaders() });
headers: immichHeaders(), if (!r.ok) throw new Error(`Immich returned ${r.status}`);
}); const albums = await r.json();
if (!response.ok) throw new Error(`Immich returned ${response.status}`); res.json(albums.map(a => ({ id: a.id, albumName: a.albumName, assetCount: a.assetCount, albumThumbnailAssetId: a.albumThumbnailAssetId, updatedAt: a.updatedAt })));
const albums = await response.json(); } catch (err) { res.status(502).json({ error: err.message }); }
// 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 --- function mapAsset(a) {
return {
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,
};
}
app.get('/api/albums/:id', async (req, res) => { app.get('/api/albums/:id', async (req, res) => {
try { try {
const response = await fetch(`${IMMICH_URL}/api/albums/${req.params.id}`, { const r = await fetch(`${IMMICH_URL}/api/albums/${req.params.id}`, { headers: immichHeaders() });
headers: immichHeaders(), if (!r.ok) throw new Error(`Immich returned ${r.status}`);
}); const album = await r.json();
if (!response.ok) throw new Error(`Immich returned ${response.status}`); const assets = (album.assets || []).filter(a => a.type === 'IMAGE').map(mapAsset);
const album = await response.json(); res.json({ id: album.id, albumName: album.albumName, assetCount: assets.length, assets });
// Filter to images only, return simplified asset data } catch (err) { res.status(502).json({ error: err.message }); }
const assets = (album.assets || []) });
.filter((a) => a.type === 'IMAGE')
.map((a) => ({ app.get('/api/people', async (_req, res) => {
id: a.id, try {
originalFileName: a.originalFileName, const r = await fetch(`${IMMICH_URL}/api/people`, { headers: immichHeaders() });
fileCreatedAt: a.fileCreatedAt, if (!r.ok) throw new Error(`Immich returned ${r.status}`);
exifInfo: a.exifInfo const data = await r.json();
? { res.json((data.people || data || []).map(p => ({ id: p.id, name: p.name, thumbnailPath: p.thumbnailPath })));
make: a.exifInfo.make, } catch (err) { res.status(502).json({ error: err.message }); }
model: a.exifInfo.model, });
city: a.exifInfo.city,
state: a.exifInfo.state, app.get('/api/people/:id', async (req, res) => {
country: a.exifInfo.country, try {
description: a.exifInfo.description, const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/assets`, { headers: immichHeaders() });
dateTimeOriginal: a.exifInfo.dateTimeOriginal, if (!r.ok) throw new Error(`Immich returned ${r.status}`);
} const assets = await r.json();
: null, res.json((Array.isArray(assets) ? assets : []).filter(a => a.type === 'IMAGE').map(mapAsset));
})); } catch (err) { res.status(502).json({ error: err.message }); }
res.json({ });
id: album.id,
albumName: album.albumName, app.get('/api/people/:id/thumbnail', async (req, res) => {
assetCount: assets.length, try {
assets, const r = await fetch(`${IMMICH_URL}/api/people/${req.params.id}/thumbnail`, { headers: { 'x-api-key': API_KEY } });
}); if (!r.ok) throw new Error(`Immich returned ${r.status}`);
} catch (err) { res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg');
res.status(502).json({ error: err.message }); res.set('Cache-Control', 'public, max-age=86400');
} r.body.pipe(res);
} 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) => { app.get('/api/assets/random', async (req, res) => {
try { try {
const count = Math.min(parseInt(req.query.count, 10) || 50, 250); const count = Math.min(parseInt(req.query.count, 10) || 50, 250);
const response = await fetch(`${IMMICH_URL}/api/assets/random?count=${count}`, { const r = await fetch(`${IMMICH_URL}/api/assets/random?count=${count}`, { headers: immichHeaders() });
headers: immichHeaders(), if (!r.ok) throw new Error(`Immich returned ${r.status}`);
}); res.json((await r.json()).filter(a => a.type === 'IMAGE').map(mapAsset));
if (!response.ok) throw new Error(`Immich returned ${response.status}`); } catch (err) { res.status(502).json({ error: err.message }); }
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) => { app.get('/api/assets/favorites', async (_req, res) => {
try { try {
const body = JSON.stringify({ const r = await fetch(`${IMMICH_URL}/api/search/metadata`, { method: 'POST', headers: immichHeaders(), body: JSON.stringify({ isFavorite: true, type: 'IMAGE', size: 250, page: 1 }) });
isFavorite: true, if (!r.ok) throw new Error(`Immich returned ${r.status}`);
type: 'IMAGE', const data = await r.json();
size: 250, res.json((data.assets?.items || []).map(a => ({ ...mapAsset(a), isFavorite: true })));
page: 1, } catch (err) { res.status(502).json({ error: err.message }); }
});
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) => { app.get('/api/assets/:id/thumbnail', async (req, res) => {
try { try {
const size = req.query.size || 'preview'; // 'thumbnail' or 'preview' const size = req.query.size || 'preview';
const response = await fetch( const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/thumbnail?size=${size}`, { headers: { 'x-api-key': API_KEY } });
`${IMMICH_URL}/api/assets/${req.params.id}/thumbnail?size=${size}`, if (!r.ok) throw new Error(`Immich returned ${r.status}`);
{ headers: { 'x-api-key': API_KEY } } res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg');
);
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'); res.set('Cache-Control', 'public, max-age=86400');
response.body.pipe(res); r.body.pipe(res);
} catch (err) { } catch (err) { res.status(502).json({ error: err.message }); }
res.status(502).json({ error: err.message });
}
}); });
// --- API: Proxy full-size image ---
app.get('/api/assets/:id/original', async (req, res) => { app.get('/api/assets/:id/original', async (req, res) => {
try { try {
const response = await fetch( const r = await fetch(`${IMMICH_URL}/api/assets/${req.params.id}/original`, { headers: { 'x-api-key': API_KEY } });
`${IMMICH_URL}/api/assets/${req.params.id}/original`, if (!r.ok) throw new Error(`Immich returned ${r.status}`);
{ headers: { 'x-api-key': API_KEY } } res.set('Content-Type', r.headers.get('content-type') || 'image/jpeg');
);
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'); res.set('Cache-Control', 'public, max-age=86400');
response.body.pipe(res); r.body.pipe(res);
} catch (err) { } catch (err) { res.status(502).json({ error: err.message }); }
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')); });
app.get('*', (_req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// --- Start ---
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}`); console.log(`🖼️ Frambe running on http://0.0.0.0:${PORT}`);
console.log(`📡 Immich server: ${IMMICH_URL}`); console.log(`📡 Immich server: ${IMMICH_URL}`);
console.log(`🔑 API Key: ${API_KEY ? '***configured***' : '⚠️ NOT SET'}`); console.log(`🔑 API Key: ${API_KEY ? '***configured***' : '⚠️ NOT SET'}`);
if (ALBUM_ID) console.log(`📁 Default album: ${ALBUM_ID}`); if (ALBUM_ID) console.log(`📁 Default album: ${ALBUM_ID}`);
console.log(`⏱️ Slideshow interval: ${SLIDESHOW_INTERVAL}s`); console.log(`⏱️ Slideshow: ${SLIDESHOW_INTERVAL}s | Refresh: ${REFRESH_INTERVAL}s`);
}); });