feat: Add custom heatmap visualization for photo locations

- Add heatmap-app with Node.js server and Leaflet.js frontend
- Implement API proxy to handle CORS for internal Immich server
- Add pagination support for fetching all geotagged photos (84k+)
- Support click-to-view photos in selected area with gallery sidebar
- Integrate with docker-compose and Traefik for heatmap.jeffemmett.com
- Add backlog tasks for heatmap work and Power Tools v2.4 tracking

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-01-02 18:32:02 +01:00
parent 189fde225d
commit f14c5fdaf9
6 changed files with 784 additions and 20 deletions

View File

@ -0,0 +1,50 @@
---
id: task-3
title: Immich Heatmap Integration
status: Done
assignee: []
created_date: '2026-01-02 13:53'
labels:
- immich
- heatmap
- infrastructure
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Custom heatmap solution built for Immich photo library visualization. Allows clicking on map areas to view photos from that location in a gallery sidebar.
## What was done:
- Updated Immich to v2.4.1
- Enabled map/reverse geocoding in admin settings
- Built custom heatmap app (Node.js + Leaflet.js)
- Deployed at https://heatmap.jeffemmett.com
- Proxies API requests to Immich internal server to avoid CORS issues
## Power Tools Status:
⚠️ Immich Power Tools geo-heatmap feature is currently INCOMPATIBLE with Immich v2.4.x due to database schema changes (assetId column renamed). Awaiting Power Tools update for v2.4 compatibility.
## Custom Heatmap Features:
- Color gradient heatmap (blue→cyan→lime→yellow→red)
- Click anywhere to see photos from that area
- Gallery sidebar with thumbnails
- Lightbox view with prev/next navigation
- "Open in Immich" link for full photo view
- 84,162 photos with GPS data available
## Files:
- /opt/immich/heatmap-app/server.js - Node.js proxy server
- /opt/immich/heatmap-app/index.html - Frontend app
- /opt/immich/docker-compose.yml - Added heatmap service
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Heatmap displays photo locations
- [ ] #2 Click on area shows photos in sidebar
- [ ] #3 Photos can be viewed in lightbox
- [ ] #4 Open in Immich link works
<!-- AC:END -->

View File

@ -0,0 +1,39 @@
---
id: task-4
title: Update Immich Power Tools for v2.4 compatibility
status: To Do
assignee: []
created_date: '2026-01-02 13:54'
labels:
- immich
- power-tools
- dependency
- blocked
dependencies: []
priority: low
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Immich Power Tools geo-heatmap feature is currently broken with Immich v2.4.x due to database schema changes.
## Issue:
- Power Tools queries fail with: `column "assetId" does not exist`
- Database schema changed in Immich v2.4 (column renamed to different case or structure)
- Power Tools latest version (v0.19.0 from Oct 2025) predates Immich v2.4.1 (Dec 2025)
## Action Required:
- Monitor https://github.com/varun-raj/immich-power-tools/releases for v2.4 compatible release
- Once available, update Power Tools container and test geo-heatmap feature
## Workaround:
Custom heatmap solution deployed at https://heatmap.jeffemmett.com as alternative
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Power Tools releases v2.4 compatible version
- [ ] #2 Update container to new version
- [ ] #3 Geo-heatmap feature works without errors
<!-- AC:END -->

View File

@ -1,42 +1,36 @@
#
# WARNING: To install Immich, follow our guide: https://docs.immich.app/install/docker-compose
#
# Make sure to use the docker-compose.yml of the current release:
#
# https://github.com/immich-app/immich/releases/latest/download/docker-compose.yml
#
# The compose file on main may not be compatible with the latest release.
services:
immich-server:
container_name: immich_server
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
# extends:
# file: hwaccel.transcoding.yml
# service: cpu # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
volumes:
# Do not edit the next line. If you want to change the media storage location on your system, edit the value of UPLOAD_LOCATION in the .env file
- ${UPLOAD_LOCATION}:/data
- /etc/localtime:/etc/localtime:ro
env_file:
- .env
ports:
- '2283:2283'
- "2283:2283"
depends_on:
- redis
- database
restart: always
healthcheck:
disable: false
networks:
- default
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.immich.rule=Host(`photos.jeffemmett.com`)"
- "traefik.http.routers.immich.entrypoints=web"
- "traefik.http.services.immich.loadbalancer.server.port=2283"
- "traefik.docker.network=traefik-public"
immich-machine-learning:
container_name: immich_machine_learning
# For hardware acceleration, add one of -[armnn, cuda, rocm, openvino, rknn] to the image tag.
# Example tag: ${IMMICH_VERSION:-release}-cuda
image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
# extends: # uncomment this section for hardware acceleration - see https://docs.immich.app/features/ml-hardware-acceleration
# file: hwaccel.ml.yml
# service: cpu # set to one of [armnn, cuda, rocm, openvino, openvino-wsl, rknn] for accelerated inference - use the `-wsl` version for WSL2 where applicable
volumes:
- model-cache:/cache
env_file:
@ -59,14 +53,62 @@ services:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
POSTGRES_INITDB_ARGS: '--data-checksums'
# Uncomment the DB_STORAGE_TYPE: 'HDD' var if your database isn't stored on SSDs
# DB_STORAGE_TYPE: 'HDD'
POSTGRES_INITDB_ARGS: "--data-checksums"
volumes:
# Do not edit the next line. If you want to change the database storage location on your system, edit the value of DB_DATA_LOCATION in the .env file
- ${DB_DATA_LOCATION}:/var/lib/postgresql/data
shm_size: 128mb
restart: always
power-tools:
container_name: immich_power_tools
image: ghcr.io/varun-raj/immich-power-tools:latest
ports:
- "8001:3000"
environment:
- IMMICH_URL=http://immich-server:2283
- IMMICH_API_KEY=${IMMICH_API_KEY}
- DB_HOST=database
- DB_PORT=5432
- DB_USERNAME=${DB_USERNAME}
- DB_PASSWORD=${DB_PASSWORD}
- DB_DATABASE_NAME=${DB_DATABASE_NAME}
- HOSTNAME=0.0.0.0
depends_on:
- immich-server
- database
restart: always
networks:
- default
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.immich-tools.rule=Host(`photos-tools.jeffemmett.com`)"
- "traefik.http.routers.immich-tools.entrypoints=web"
- "traefik.http.services.immich-tools.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik-public"
heatmap:
container_name: immich_heatmap
build: ./heatmap-app
environment:
- IMMICH_URL=http://immich-server:2283
- IMMICH_PUBLIC_URL=https://photos.jeffemmett.com
depends_on:
- immich-server
restart: always
networks:
- default
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.immich-heatmap.rule=Host(`heatmap.jeffemmett.com`)"
- "traefik.http.routers.immich-heatmap.entrypoints=web"
- "traefik.http.services.immich-heatmap.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik-public"
volumes:
model-cache:
networks:
traefik-public:
external: true

10
heatmap-app/Dockerfile Normal file
View File

@ -0,0 +1,10 @@
FROM node:20-alpine
WORKDIR /app
COPY server.js .
COPY index.html .
EXPOSE 3000
CMD ["node", "server.js"]

517
heatmap-app/index.html Normal file
View File

@ -0,0 +1,517 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Immich Photo Heatmap</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
#app { display: flex; height: 100vh; }
#map-container { flex: 1; position: relative; }
#map { height: 100%; width: 100%; }
#sidebar {
width: 0;
background: #1a1a2e;
color: white;
overflow-y: auto;
transition: width 0.3s ease;
display: flex;
flex-direction: column;
}
#sidebar.open { width: 400px; }
#sidebar-header {
padding: 16px;
background: #16213e;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 10;
}
#sidebar-header h2 { font-size: 16px; font-weight: 500; }
#close-sidebar {
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
padding: 4px 8px;
}
#photo-count { font-size: 12px; color: #888; margin-top: 4px; }
#gallery {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
padding: 8px;
flex: 1;
}
.photo-thumb {
aspect-ratio: 1;
background-size: cover;
background-position: center;
border-radius: 4px;
cursor: pointer;
transition: transform 0.2s;
}
.photo-thumb:hover { transform: scale(1.05); }
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0,0,0,0.8);
color: white;
padding: 20px 40px;
border-radius: 8px;
z-index: 1000;
}
#controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 8px;
}
.control-btn {
background: white;
border: 2px solid rgba(0,0,0,0.2);
border-radius: 4px;
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
}
.control-btn:hover { background: #f0f0f0; }
#stats {
position: absolute;
bottom: 20px;
left: 20px;
background: rgba(0,0,0,0.8);
color: white;
padding: 12px 16px;
border-radius: 8px;
z-index: 1000;
font-size: 13px;
}
#lightbox {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.95);
z-index: 2000;
justify-content: center;
align-items: center;
}
#lightbox.open { display: flex; }
#lightbox img {
max-width: 90%;
max-height: 90%;
object-fit: contain;
}
#lightbox-close {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
color: white;
font-size: 36px;
cursor: pointer;
}
#lightbox-nav {
position: absolute;
bottom: 20px;
display: flex;
gap: 20px;
}
#lightbox-nav button {
background: rgba(255,255,255,0.2);
border: none;
color: white;
padding: 10px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
#lightbox-nav button:hover { background: rgba(255,255,255,0.3); }
#open-immich {
position: absolute;
bottom: 20px;
right: 20px;
background: #4263eb;
border: none;
color: white;
padding: 10px 16px;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
font-size: 14px;
}
@media (max-width: 768px) {
#sidebar.open { width: 100%; position: absolute; right: 0; z-index: 1001; height: 60%; bottom: 0; }
}
</style>
</head>
<body>
<div id="app">
<div id="map-container">
<div id="map"></div>
<div id="loading">Loading photo locations...</div>
<div id="controls">
<button class="control-btn" onclick="resetView()">Reset View</button>
<button class="control-btn" onclick="toggleHeatmap()">Toggle Heatmap</button>
</div>
<div id="stats"></div>
</div>
<div id="sidebar">
<div id="sidebar-header">
<div>
<h2>Photos in this area</h2>
<div id="photo-count">0 photos</div>
</div>
<button id="close-sidebar" onclick="closeSidebar()">&times;</button>
</div>
<div id="gallery"></div>
</div>
</div>
<div id="lightbox">
<button id="lightbox-close" onclick="closeLightbox()">&times;</button>
<img id="lightbox-img" src="" alt="">
<a id="open-immich" href="#" target="_blank">Open in Immich</a>
<div id="lightbox-nav">
<button onclick="prevPhoto()">← Previous</button>
<button onclick="nextPhoto()">Next →</button>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
<script>
// Configuration - will be injected by server or use defaults
const CONFIG = {
immichUrl: '', // Empty - all API calls proxied through this server
immichPublicUrl: 'https://photos.jeffemmett.com', // For "Open in Immich" links
apiKey: '' // User will enter this
};
let map, heatLayer, markerLayer;
let allPhotos = [];
let currentAreaPhotos = [];
let currentPhotoIndex = 0;
let heatmapVisible = true;
// Initialize map
function initMap() {
map = L.map('map').setView([0, 0], 2);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors'
}).addTo(map);
markerLayer = L.layerGroup().addTo(map);
// Click handler for map
map.on('click', onMapClick);
}
// Fetch photo locations from Immich
async function fetchPhotos() {
const loading = document.getElementById('loading');
// Check for API key in URL or localStorage
const urlParams = new URLSearchParams(window.location.search);
const apiKey = urlParams.get('apiKey') || localStorage.getItem('immichApiKey');
if (!apiKey) {
loading.innerHTML = `
<div style="text-align: center;">
<h3>Enter your Immich API Key</h3>
<p style="margin: 10px 0; font-size: 12px; color: #888;">
Get it from Immich → Account Settings → API Keys
</p>
<input type="text" id="api-key-input" placeholder="Your API key"
style="width: 100%; padding: 10px; margin: 10px 0; border: 1px solid #444; border-radius: 4px; background: #333; color: white;">
<button onclick="saveApiKey()"
style="padding: 10px 20px; background: #4263eb; color: white; border: none; border-radius: 4px; cursor: pointer;">
Load Photos
</button>
</div>
`;
return;
}
try {
CONFIG.apiKey = apiKey;
loading.textContent = 'Fetching photo locations...';
// Fetch all assets with location data via pagination (API max is 1000 per page)
let page = 1;
let hasMore = true;
const pageSize = 1000;
while (hasMore) {
loading.textContent = `Fetching photos... (page ${page}, ${allPhotos.length} with GPS so far)`;
const response = await fetch(`${CONFIG.immichUrl}/api/search/metadata`, {
method: 'POST',
headers: {
'x-api-key': CONFIG.apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
withExif: true,
size: pageSize,
page: page
})
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
const items = data.assets?.items || [];
// Filter for photos with GPS and add to collection
const photosWithGps = items.filter(a => a.exifInfo?.latitude && a.exifInfo?.longitude);
allPhotos = allPhotos.concat(photosWithGps);
// Check if there are more pages
hasMore = data.assets?.nextPage != null && items.length === pageSize;
page++;
// Safety limit to prevent infinite loops
if (page > 200) {
console.warn('Reached page limit, stopping pagination');
break;
}
}
loading.style.display = 'none';
renderHeatmap();
updateStats();
} catch (error) {
console.error('Error:', error);
loading.innerHTML = `
<div style="color: #ff6b6b;">
Error loading photos: ${error.message}<br>
<button onclick="localStorage.removeItem('immichApiKey'); location.reload()"
style="margin-top: 10px; padding: 8px 16px; background: #333; color: white; border: none; border-radius: 4px; cursor: pointer;">
Try Different API Key
</button>
</div>
`;
}
}
function saveApiKey() {
const key = document.getElementById('api-key-input').value.trim();
if (key) {
localStorage.setItem('immichApiKey', key);
location.reload();
}
}
function renderHeatmap() {
// Prepare heatmap data: [lat, lng, intensity]
const heatData = allPhotos.map(photo => [
photo.exifInfo.latitude,
photo.exifInfo.longitude,
0.5 // Base intensity
]);
if (heatLayer) {
map.removeLayer(heatLayer);
}
heatLayer = L.heatLayer(heatData, {
radius: 25,
blur: 15,
maxZoom: 17,
gradient: {
0.0: 'blue',
0.25: 'cyan',
0.5: 'lime',
0.75: 'yellow',
1.0: 'red'
}
}).addTo(map);
// Fit bounds to show all photos
if (heatData.length > 0) {
const bounds = L.latLngBounds(heatData.map(d => [d[0], d[1]]));
map.fitBounds(bounds, { padding: [50, 50] });
}
}
function updateStats() {
document.getElementById('stats').innerHTML = `
<strong>${allPhotos.length.toLocaleString()}</strong> photos with location data
`;
}
function onMapClick(e) {
const clickRadius = getClickRadius();
const clickLat = e.latlng.lat;
const clickLng = e.latlng.lng;
// Find photos within click radius
currentAreaPhotos = allPhotos.filter(photo => {
const distance = getDistance(
clickLat, clickLng,
photo.exifInfo.latitude, photo.exifInfo.longitude
);
return distance <= clickRadius;
});
if (currentAreaPhotos.length > 0) {
openSidebar();
renderGallery();
// Show selection circle
markerLayer.clearLayers();
L.circle([clickLat, clickLng], {
radius: clickRadius * 1000, // Convert km to m
color: '#4263eb',
fillColor: '#4263eb',
fillOpacity: 0.1
}).addTo(markerLayer);
}
}
function getClickRadius() {
// Radius in km based on zoom level
const zoom = map.getZoom();
if (zoom >= 15) return 0.5;
if (zoom >= 12) return 2;
if (zoom >= 9) return 10;
if (zoom >= 6) return 50;
return 200;
}
function getDistance(lat1, lon1, lat2, lon2) {
// Haversine formula - returns distance in km
const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
function openSidebar() {
document.getElementById('sidebar').classList.add('open');
}
function closeSidebar() {
document.getElementById('sidebar').classList.remove('open');
markerLayer.clearLayers();
}
function renderGallery() {
const gallery = document.getElementById('gallery');
document.getElementById('photo-count').textContent = `${currentAreaPhotos.length} photos`;
gallery.innerHTML = currentAreaPhotos.map((photo, idx) => `
<div class="photo-thumb"
style="background-image: url('${CONFIG.immichUrl}/api/assets/${photo.id}/thumbnail?size=thumbnail&key=${CONFIG.apiKey}')"
onclick="openLightbox(${idx})">
</div>
`).join('');
}
function openLightbox(index) {
currentPhotoIndex = index;
const photo = currentAreaPhotos[index];
document.getElementById('lightbox-img').src =
`${CONFIG.immichUrl}/api/assets/${photo.id}/thumbnail?size=preview&key=${CONFIG.apiKey}`;
document.getElementById('open-immich').href =
`${CONFIG.immichPublicUrl}/photos/${photo.id}`;
document.getElementById('lightbox').classList.add('open');
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('open');
}
function prevPhoto() {
currentPhotoIndex = (currentPhotoIndex - 1 + currentAreaPhotos.length) % currentAreaPhotos.length;
openLightbox(currentPhotoIndex);
}
function nextPhoto() {
currentPhotoIndex = (currentPhotoIndex + 1) % currentAreaPhotos.length;
openLightbox(currentPhotoIndex);
}
function resetView() {
if (allPhotos.length > 0) {
const bounds = L.latLngBounds(allPhotos.map(p => [p.exifInfo.latitude, p.exifInfo.longitude]));
map.fitBounds(bounds, { padding: [50, 50] });
}
closeSidebar();
}
function toggleHeatmap() {
heatmapVisible = !heatmapVisible;
if (heatmapVisible) {
heatLayer.addTo(map);
} else {
map.removeLayer(heatLayer);
}
}
// Keyboard navigation
document.addEventListener('keydown', (e) => {
if (document.getElementById('lightbox').classList.contains('open')) {
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') prevPhoto();
if (e.key === 'ArrowRight') nextPhoto();
} else if (e.key === 'Escape') {
closeSidebar();
}
});
// Initialize
initMap();
fetchPhotos();
</script>
</body>
</html>

106
heatmap-app/server.js Normal file
View File

@ -0,0 +1,106 @@
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const PORT = process.env.PORT || 3000;
const IMMICH_INTERNAL_URL = process.env.IMMICH_URL || 'http://immich-server:2283';
const IMMICH_PUBLIC_URL = process.env.IMMICH_PUBLIC_URL || 'https://photos.jeffemmett.com';
const server = http.createServer((req, res) => {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, x-api-key');
if (req.method === 'OPTIONS') {
res.writeHead(204);
res.end();
return;
}
// Config endpoint
if (req.url === '/api/config') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
immichUrl: '' // Empty - we'll proxy through this server
}));
return;
}
// Proxy Immich API requests
if (req.url.startsWith('/api/')) {
const apiKey = req.headers['x-api-key'];
const targetUrl = IMMICH_INTERNAL_URL + req.url;
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
const urlParts = new URL(targetUrl);
const options = {
hostname: urlParts.hostname,
port: urlParts.port || 80,
path: urlParts.pathname + urlParts.search,
method: req.method,
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey
}
};
const proxyReq = http.request(options, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
});
proxyReq.on('error', (e) => {
console.error('Proxy error:', e.message);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: e.message }));
});
if (body) {
proxyReq.write(body);
}
proxyReq.end();
});
return;
}
// Serve static files
let filePath = req.url === '/' ? '/index.html' : req.url.split('?')[0];
filePath = path.join(__dirname, filePath);
const extname = path.extname(filePath);
const contentTypes = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.ico': 'image/x-icon'
};
const contentType = contentTypes[extname] || 'application/octet-stream';
fs.readFile(filePath, (err, content) => {
if (err) {
if (err.code === 'ENOENT') {
res.writeHead(404);
res.end('Not found');
} else {
res.writeHead(500);
res.end('Server error');
}
return;
}
res.writeHead(200, { 'Content-Type': contentType });
res.end(content);
});
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`Immich Heatmap server running on port ${PORT}`);
console.log(`Proxying to: ${IMMICH_INTERNAL_URL}`);
});