feat: add Express server with Immich API proxy and config endpoints
This commit is contained in:
@@ -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`);
|
||||
});
|
||||
Reference in New Issue
Block a user