feat: add search-app proxy with WebSocket support and upload buffering
Adds immich-proxy (search-app) with live search injection, WebSocket proxying for socket.io, and 10GB upload buffering via Traefik middleware. Moves Traefik routing from immich-server to proxy. Updates valkey 8→9. Adds backlog tasks for drone sync and Syncthing reconnection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9281400c61
commit
38bdcf25dc
|
|
@ -0,0 +1,44 @@
|
||||||
|
---
|
||||||
|
id: TASK-5
|
||||||
|
title: Set up DJI Neo 2 drone photo auto-sync to Immich
|
||||||
|
status: To Do
|
||||||
|
assignee:
|
||||||
|
- '@jeff'
|
||||||
|
created_date: '2026-04-03 22:00'
|
||||||
|
labels:
|
||||||
|
- immich
|
||||||
|
- mobile
|
||||||
|
- dji
|
||||||
|
- photos
|
||||||
|
dependencies: []
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
Configure the Immich mobile app on Jeff's phone (Samsung Fold 6) to auto-backup the DJI Fly app's media folder. This ensures all drone footage from the DJI Neo 2 automatically syncs to the Immich photo library on Netcup.
|
||||||
|
|
||||||
|
DJI has no public cloud API for consumer drones, so the workflow is:
|
||||||
|
1. QuickTransfer drone footage from Neo 2 to phone via DJI Fly app
|
||||||
|
2. Immich mobile app auto-backs up the DJI media folder in the background
|
||||||
|
|
||||||
|
## Steps (on phone)
|
||||||
|
1. Install Immich app from Play Store (if not already installed)
|
||||||
|
2. Sign in with server URL: https://photos.jeffemmett.com
|
||||||
|
3. Go to: Backup → Backup Albums → Select albums
|
||||||
|
4. Find and enable the DJI album (typically `DCIM/DJI` or `Pictures/DJI`)
|
||||||
|
5. Enable background backup (Backup settings):
|
||||||
|
- Toggle on "Background backup"
|
||||||
|
- Toggle on "Require WiFi" (drone footage is large)
|
||||||
|
- Optionally toggle "Require charging"
|
||||||
|
6. Test by QuickTransferring a clip from the drone and confirming it appears in Immich
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [ ] #1 Immich mobile app installed and signed in on Fold 6
|
||||||
|
- [ ] #2 DJI media folder selected in Immich backup albums
|
||||||
|
- [ ] #3 Background backup enabled with WiFi-only setting
|
||||||
|
- [ ] #4 Test drone footage appears in Immich after QuickTransfer to phone
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
---
|
||||||
|
id: TASK-6
|
||||||
|
title: Reconnect Fold 6 to Syncthing mesh
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- '@jeff'
|
||||||
|
created_date: '2026-04-03 22:01'
|
||||||
|
updated_date: '2026-04-04 22:09'
|
||||||
|
labels:
|
||||||
|
- syncthing
|
||||||
|
- mobile
|
||||||
|
- security
|
||||||
|
dependencies: []
|
||||||
|
priority: high
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
The Samsung Fold 6 (device ID EYJ3PAK...) has never connected to the Syncthing mesh — it was configured on the server but never set up on the phone.
|
||||||
|
|
||||||
|
## Steps (on phone)
|
||||||
|
1. Install Syncthing from Play Store
|
||||||
|
2. Open app → Settings → Show Device ID → copy it
|
||||||
|
3. Go to https://sync.jeffemmett.com (Syncthing web UI on Netcup)
|
||||||
|
4. Check if the existing Fold6 device ID matches. If different (new phone), remove old device and add the new one
|
||||||
|
5. On the phone, add Netcup as remote device with ID: `NQ3HMIZ-TIZ3VTZ-NOHPCPP-EW2KGOM-BYZATAI-FJ6QFV3-OZE7CCN-FPIX4QA`
|
||||||
|
6. Accept the shared folders when prompted (KeePass, Tmux Resurrect)
|
||||||
|
7. Verify KeePass vault syncs to phone
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 Syncthing app installed and running on Fold 6
|
||||||
|
- [x] #2 Device connected and visible in Netcup Syncthing dashboard
|
||||||
|
- [x] #3 KeePass vault synced to phone
|
||||||
|
- [x] #4 At least 1 successful sync cycle completed
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Completed 2026-04-04. Fold 6 reconnected to Syncthing mesh.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
@ -18,15 +18,6 @@ services:
|
||||||
restart: always
|
restart: always
|
||||||
healthcheck:
|
healthcheck:
|
||||||
disable: false
|
disable: false
|
||||||
networks:
|
|
||||||
- default
|
|
||||||
- traefik-public
|
|
||||||
labels:
|
|
||||||
- "traefik.enable=true"
|
|
||||||
- "traefik.http.routers.immich.rule=Host(`demo.rphotos.online`) || 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:
|
immich-machine-learning:
|
||||||
container_name: immich_machine_learning
|
container_name: immich_machine_learning
|
||||||
|
|
@ -41,7 +32,7 @@ services:
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: immich_redis
|
container_name: immich_redis
|
||||||
image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
|
image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: redis-cli ping || exit 1
|
test: redis-cli ping || exit 1
|
||||||
restart: always
|
restart: always
|
||||||
|
|
@ -87,6 +78,28 @@ services:
|
||||||
- "traefik.http.services.immich-tools.loadbalancer.server.port=3000"
|
- "traefik.http.services.immich-tools.loadbalancer.server.port=3000"
|
||||||
- "traefik.docker.network=traefik-public"
|
- "traefik.docker.network=traefik-public"
|
||||||
|
|
||||||
|
immich-proxy:
|
||||||
|
container_name: immich_proxy
|
||||||
|
build: ./search-app
|
||||||
|
environment:
|
||||||
|
- IMMICH_URL=http://immich-server:2283
|
||||||
|
depends_on:
|
||||||
|
- immich-server
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- traefik-public
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.immich.rule=Host(`demo.rphotos.online`) || Host(`photos.jeffemmett.com`)"
|
||||||
|
- "traefik.http.routers.immich.entrypoints=web"
|
||||||
|
- "traefik.http.services.immich.loadbalancer.server.port=3000"
|
||||||
|
- "traefik.docker.network=traefik-public"
|
||||||
|
# Allow large file uploads (10GB limit)
|
||||||
|
- "traefik.http.middlewares.immich-body-size.buffering.maxRequestBodyBytes=10737418240"
|
||||||
|
- "traefik.http.middlewares.immich-body-size.buffering.memRequestBodyBytes=2097152"
|
||||||
|
- "traefik.http.routers.immich.middlewares=immich-body-size"
|
||||||
|
|
||||||
heatmap:
|
heatmap:
|
||||||
container_name: immich_heatmap
|
container_name: immich_heatmap
|
||||||
build: ./heatmap-app
|
build: ./heatmap-app
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY server.js .
|
||||||
|
COPY live-search.js .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
|
|
@ -0,0 +1,652 @@
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// --- Config ---
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
const MIN_CHARS = 2;
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
const LEAFLET_CSS = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||||
|
const LEAFLET_JS = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
|
||||||
|
const LEAFLET_HEAT_JS = 'https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js';
|
||||||
|
|
||||||
|
// --- State ---
|
||||||
|
let panel = null;
|
||||||
|
let debounceTimer = null;
|
||||||
|
let abortCtrl = null;
|
||||||
|
let currentQuery = '';
|
||||||
|
let currentItems = [];
|
||||||
|
let currentView = 'grid'; // 'grid' or 'map'
|
||||||
|
let mapInstance = null;
|
||||||
|
let mapMarkers = null;
|
||||||
|
let heatLayer = null;
|
||||||
|
let leafletReady = false;
|
||||||
|
let leafletLoading = false;
|
||||||
|
let hookedInputs = new WeakSet();
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
function getAuthHeaders() {
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('auth') ||
|
||||||
|
localStorage.getItem('immich-auth') ||
|
||||||
|
sessionStorage.getItem('auth');
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const token = parsed.accessToken || parsed.token || parsed.userToken;
|
||||||
|
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Leaflet lazy loader ---
|
||||||
|
function loadLeaflet() {
|
||||||
|
if (leafletReady) return Promise.resolve();
|
||||||
|
if (leafletLoading) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const check = setInterval(() => { if (leafletReady) { clearInterval(check); resolve(); } }, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
leafletLoading = true;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Load CSS
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'stylesheet';
|
||||||
|
link.href = LEAFLET_CSS;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
|
||||||
|
// Load JS
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = LEAFLET_JS;
|
||||||
|
script.onload = () => {
|
||||||
|
// Load heat plugin after Leaflet
|
||||||
|
const heat = document.createElement('script');
|
||||||
|
heat.src = LEAFLET_HEAT_JS;
|
||||||
|
heat.onload = () => { leafletReady = true; resolve(); };
|
||||||
|
heat.onerror = reject;
|
||||||
|
document.head.appendChild(heat);
|
||||||
|
};
|
||||||
|
script.onerror = reject;
|
||||||
|
document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Create the results panel ---
|
||||||
|
function getOrCreatePanel() {
|
||||||
|
if (panel) return panel;
|
||||||
|
|
||||||
|
panel = document.createElement('div');
|
||||||
|
panel.id = 'live-search-panel';
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="ls-header">
|
||||||
|
<div class="ls-status"></div>
|
||||||
|
<div class="ls-controls">
|
||||||
|
<button class="ls-toggle" data-view="grid" title="Grid view">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ls-toggle" data-view="map" title="Map view">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21c-4-4-8-7.5-8-11a8 8 0 1 1 16 0c0 3.5-4 7-8 11z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ls-close" title="Close">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ls-body">
|
||||||
|
<div class="ls-grid"></div>
|
||||||
|
<div class="ls-map-wrap" style="display:none"><div class="ls-map" id="ls-map"></div></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(panel);
|
||||||
|
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
#live-search-panel {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
z-index: 99999; background: rgba(0,0,0,0.88);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
display: none; flex-direction: column;
|
||||||
|
}
|
||||||
|
#live-search-panel.active { display: flex; }
|
||||||
|
|
||||||
|
#live-search-panel .ls-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 72px 20px 8px; flex-shrink: 0;
|
||||||
|
max-width: 1440px; width: 100%; margin: 0 auto;
|
||||||
|
}
|
||||||
|
#live-search-panel .ls-status {
|
||||||
|
color: #aaa; font-size: 14px; min-height: 20px;
|
||||||
|
}
|
||||||
|
#live-search-panel .ls-controls { display: flex; gap: 6px; align-items: center; }
|
||||||
|
#live-search-panel .ls-toggle, #live-search-panel .ls-close {
|
||||||
|
background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2);
|
||||||
|
color: #ccc; border-radius: 6px; padding: 6px 10px; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
#live-search-panel .ls-toggle:hover, #live-search-panel .ls-close:hover {
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
#live-search-panel .ls-toggle.active {
|
||||||
|
background: #e94560; border-color: #e94560; color: #fff;
|
||||||
|
}
|
||||||
|
#live-search-panel .ls-close { font-size: 20px; padding: 4px 10px; }
|
||||||
|
|
||||||
|
#live-search-panel .ls-body {
|
||||||
|
flex: 1; overflow-y: auto; padding: 0 20px 20px;
|
||||||
|
max-width: 1440px; width: 100%; margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#live-search-panel .ls-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
#live-search-panel .ls-card {
|
||||||
|
position: relative; aspect-ratio: 1; border-radius: 6px;
|
||||||
|
overflow: hidden; cursor: pointer; background: #222;
|
||||||
|
transition: transform 0.12s;
|
||||||
|
}
|
||||||
|
#live-search-panel .ls-card:hover { transform: scale(1.04); z-index: 2; }
|
||||||
|
#live-search-panel .ls-card img {
|
||||||
|
width: 100%; height: 100%; object-fit: cover;
|
||||||
|
opacity: 0; transition: opacity 0.25s;
|
||||||
|
}
|
||||||
|
#live-search-panel .ls-card img.loaded { opacity: 1; }
|
||||||
|
#live-search-panel .ls-card .ls-badge {
|
||||||
|
position: absolute; top: 6px; right: 6px;
|
||||||
|
background: rgba(0,0,0,0.7); color: #fff; font-size: 10px;
|
||||||
|
padding: 2px 5px; border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#live-search-panel .ls-map-wrap {
|
||||||
|
height: 100%; min-height: 500px; border-radius: 8px; overflow: hidden;
|
||||||
|
}
|
||||||
|
#live-search-panel .ls-map { height: 100%; width: 100%; }
|
||||||
|
#live-search-panel .ls-map-wrap .ls-map-loading {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
height: 100%; color: #aaa; font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map popup thumbnail grid */
|
||||||
|
.ls-popup-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(3, 60px); gap: 3px;
|
||||||
|
max-height: 200px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.ls-popup-grid img {
|
||||||
|
width: 60px; height: 60px; object-fit: cover; border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ls-popup-grid img:hover { opacity: 0.8; }
|
||||||
|
.ls-popup-title {
|
||||||
|
font-weight: 600; margin-bottom: 6px; font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#live-search-panel .ls-spinner {
|
||||||
|
display: inline-block; width: 14px; height: 14px;
|
||||||
|
border: 2px solid #666; border-top-color: #e94560;
|
||||||
|
border-radius: 50%; animation: lsSpin 0.5s linear infinite;
|
||||||
|
vertical-align: middle; margin-right: 6px;
|
||||||
|
}
|
||||||
|
@keyframes lsSpin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
#live-search-panel .ls-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
}
|
||||||
|
#live-search-panel .ls-header { padding: 64px 10px 8px; }
|
||||||
|
#live-search-panel .ls-body { padding: 0 10px 10px; }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
// Event listeners
|
||||||
|
panel.querySelector('.ls-close').addEventListener('click', hidePanel);
|
||||||
|
panel.querySelectorAll('.ls-toggle').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => switchView(btn.dataset.view));
|
||||||
|
});
|
||||||
|
panel.addEventListener('click', (e) => {
|
||||||
|
if (e.target === panel) hidePanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
updateToggleButtons();
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPanel() {
|
||||||
|
getOrCreatePanel().classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hidePanel() {
|
||||||
|
if (panel) {
|
||||||
|
panel.classList.remove('active');
|
||||||
|
panel.querySelector('.ls-grid').innerHTML = '';
|
||||||
|
panel.querySelector('.ls-status').textContent = '';
|
||||||
|
}
|
||||||
|
currentQuery = '';
|
||||||
|
currentItems = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchView(view) {
|
||||||
|
currentView = view;
|
||||||
|
updateToggleButtons();
|
||||||
|
|
||||||
|
const gridEl = panel.querySelector('.ls-grid');
|
||||||
|
const mapWrap = panel.querySelector('.ls-map-wrap');
|
||||||
|
const bodyEl = panel.querySelector('.ls-body');
|
||||||
|
|
||||||
|
if (view === 'grid') {
|
||||||
|
gridEl.style.display = '';
|
||||||
|
mapWrap.style.display = 'none';
|
||||||
|
bodyEl.style.overflowY = 'auto';
|
||||||
|
renderGridItems(currentItems);
|
||||||
|
} else {
|
||||||
|
gridEl.style.display = 'none';
|
||||||
|
mapWrap.style.display = '';
|
||||||
|
bodyEl.style.overflowY = 'hidden';
|
||||||
|
showMap(currentItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateToggleButtons() {
|
||||||
|
if (!panel) return;
|
||||||
|
panel.querySelectorAll('.ls-toggle').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.view === currentView);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Search ---
|
||||||
|
async function doSearch(query) {
|
||||||
|
if (abortCtrl) abortCtrl.abort();
|
||||||
|
abortCtrl = new AbortController();
|
||||||
|
currentQuery = query;
|
||||||
|
|
||||||
|
const statusEl = getOrCreatePanel().querySelector('.ls-status');
|
||||||
|
const gridEl = getOrCreatePanel().querySelector('.ls-grid');
|
||||||
|
gridEl.innerHTML = '';
|
||||||
|
|
||||||
|
statusEl.innerHTML = '<span class="ls-spinner"></span> Searching...';
|
||||||
|
showPanel();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = getAuthHeaders();
|
||||||
|
|
||||||
|
// Run CLIP smart search and metadata location search in parallel
|
||||||
|
const [smartResp, metaResp] = await Promise.all([
|
||||||
|
fetch('/api/search/smart', {
|
||||||
|
method: 'POST', headers, credentials: 'include',
|
||||||
|
body: JSON.stringify({ query, size: PAGE_SIZE }),
|
||||||
|
signal: abortCtrl.signal
|
||||||
|
}),
|
||||||
|
fetch('/api/search/metadata', {
|
||||||
|
method: 'POST', headers, credentials: 'include',
|
||||||
|
body: JSON.stringify({ query, size: PAGE_SIZE, withExif: true }),
|
||||||
|
signal: abortCtrl.signal
|
||||||
|
}).catch(() => null) // metadata search is optional
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!smartResp.ok) {
|
||||||
|
statusEl.textContent = 'Search error (' + smartResp.status + ')';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const smartData = await smartResp.json();
|
||||||
|
let items = smartData.assets?.items || [];
|
||||||
|
|
||||||
|
// Merge metadata results (prioritize unique assets with GPS)
|
||||||
|
if (metaResp && metaResp.ok) {
|
||||||
|
const metaData = await metaResp.json();
|
||||||
|
const metaItems = metaData.assets?.items || [];
|
||||||
|
const seenIds = new Set(items.map(i => i.id));
|
||||||
|
for (const mi of metaItems) {
|
||||||
|
if (!seenIds.has(mi.id)) {
|
||||||
|
items.push(mi);
|
||||||
|
seenIds.add(mi.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentItems = items;
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
statusEl.textContent = 'No results for "' + query + '"';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count items with GPS for status
|
||||||
|
const withGps = items.filter(i => i.exifInfo?.latitude).length;
|
||||||
|
let statusText = items.length + ' result' + (items.length !== 1 ? 's' : '');
|
||||||
|
if (withGps > 0) statusText += ' (' + withGps + ' with location)';
|
||||||
|
statusEl.textContent = statusText;
|
||||||
|
|
||||||
|
if (currentView === 'grid') {
|
||||||
|
renderGridItems(items);
|
||||||
|
} else {
|
||||||
|
showMap(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background: enrich smart search results with GPS data
|
||||||
|
enrichWithGps(items);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name !== 'AbortError') {
|
||||||
|
statusEl.textContent = 'Search failed';
|
||||||
|
console.error('[live-search]', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch GPS data for items that don't have exifInfo yet
|
||||||
|
async function enrichWithGps(items) {
|
||||||
|
const needGps = items.filter(i => !i.exifInfo).map(i => i.id);
|
||||||
|
if (needGps.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = getAuthHeaders();
|
||||||
|
const resp = await fetch('/api/custom/batch-exif', {
|
||||||
|
method: 'POST', headers, credentials: 'include',
|
||||||
|
body: JSON.stringify({ ids: needGps })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const gpsData = await resp.json();
|
||||||
|
|
||||||
|
// Merge GPS data back into items
|
||||||
|
const gpsMap = new Map(gpsData.map(g => [g.id, g]));
|
||||||
|
let updated = false;
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.exifInfo && gpsMap.has(item.id)) {
|
||||||
|
const g = gpsMap.get(item.id);
|
||||||
|
item.exifInfo = {
|
||||||
|
latitude: g.latitude, longitude: g.longitude,
|
||||||
|
city: g.city, state: g.state, country: g.country
|
||||||
|
};
|
||||||
|
if (g.latitude) updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
// Update status with new GPS count
|
||||||
|
const withGps = items.filter(i => i.exifInfo?.latitude).length;
|
||||||
|
const statusEl = panel.querySelector('.ls-status');
|
||||||
|
let statusText = items.length + ' result' + (items.length !== 1 ? 's' : '');
|
||||||
|
if (withGps > 0) statusText += ' (' + withGps + ' with location)';
|
||||||
|
statusEl.textContent = statusText;
|
||||||
|
|
||||||
|
// If map is showing, refresh it
|
||||||
|
if (currentView === 'map') showMap(items);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[live-search] GPS enrichment failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGridItems(items) {
|
||||||
|
const grid = panel.querySelector('.ls-grid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
for (const item of items) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'ls-card';
|
||||||
|
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.loading = 'lazy';
|
||||||
|
img.src = '/api/assets/' + item.id + '/thumbnail?size=thumbnail';
|
||||||
|
img.alt = item.originalFileName || '';
|
||||||
|
img.onload = () => img.classList.add('loaded');
|
||||||
|
card.appendChild(img);
|
||||||
|
|
||||||
|
if (item.type === 'VIDEO') {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'ls-badge';
|
||||||
|
badge.textContent = 'VIDEO';
|
||||||
|
card.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show location badge if available
|
||||||
|
const city = item.exifInfo?.city;
|
||||||
|
if (city) {
|
||||||
|
const locBadge = document.createElement('span');
|
||||||
|
locBadge.className = 'ls-badge';
|
||||||
|
locBadge.style.cssText = 'top:auto;bottom:6px;left:6px;right:auto;max-width:90%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;';
|
||||||
|
locBadge.textContent = city;
|
||||||
|
card.appendChild(locBadge);
|
||||||
|
}
|
||||||
|
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
hidePanel();
|
||||||
|
window.location.href = '/photos/' + item.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.appendChild(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Map View ---
|
||||||
|
async function showMap(items) {
|
||||||
|
const mapWrap = panel.querySelector('.ls-map-wrap');
|
||||||
|
const mapEl = panel.querySelector('.ls-map');
|
||||||
|
|
||||||
|
// Filter items with GPS
|
||||||
|
const geoItems = items.filter(i => i.exifInfo?.latitude && i.exifInfo?.longitude);
|
||||||
|
|
||||||
|
if (geoItems.length === 0) {
|
||||||
|
mapEl.innerHTML = '<div class="ls-map-loading">No location data available for these results.<br>Try a location-specific search like a city name.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy-load Leaflet
|
||||||
|
if (!leafletReady) {
|
||||||
|
mapEl.innerHTML = '<div class="ls-map-loading"><span class="ls-spinner"></span> Loading map...</div>';
|
||||||
|
await loadLeaflet();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear previous map
|
||||||
|
if (mapInstance) {
|
||||||
|
mapInstance.remove();
|
||||||
|
mapInstance = null;
|
||||||
|
}
|
||||||
|
mapEl.innerHTML = '';
|
||||||
|
|
||||||
|
// Create map
|
||||||
|
const L = window.L;
|
||||||
|
mapInstance = L.map(mapEl, { zoomControl: true });
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap',
|
||||||
|
maxZoom: 19
|
||||||
|
}).addTo(mapInstance);
|
||||||
|
|
||||||
|
// Group photos by location (cluster nearby within ~100m)
|
||||||
|
const clusters = clusterByLocation(geoItems, 0.001);
|
||||||
|
|
||||||
|
// Add markers
|
||||||
|
const markers = [];
|
||||||
|
for (const cluster of clusters) {
|
||||||
|
const lat = cluster.center[0];
|
||||||
|
const lng = cluster.center[1];
|
||||||
|
const photos = cluster.items;
|
||||||
|
|
||||||
|
const label = photos[0].exifInfo?.city || photos[0].exifInfo?.country || '';
|
||||||
|
const count = photos.length;
|
||||||
|
|
||||||
|
const marker = L.circleMarker([lat, lng], {
|
||||||
|
radius: Math.min(8 + Math.sqrt(count) * 3, 30),
|
||||||
|
fillColor: '#e94560',
|
||||||
|
fillOpacity: 0.8,
|
||||||
|
color: '#fff',
|
||||||
|
weight: 2
|
||||||
|
}).addTo(mapInstance);
|
||||||
|
|
||||||
|
// Build popup
|
||||||
|
const popupContent = document.createElement('div');
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'ls-popup-title';
|
||||||
|
title.textContent = (label ? label + ' — ' : '') + count + ' photo' + (count > 1 ? 's' : '');
|
||||||
|
popupContent.appendChild(title);
|
||||||
|
|
||||||
|
const thumbGrid = document.createElement('div');
|
||||||
|
thumbGrid.className = 'ls-popup-grid';
|
||||||
|
for (const photo of photos.slice(0, 9)) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = '/api/assets/' + photo.id + '/thumbnail?size=thumbnail';
|
||||||
|
img.alt = photo.originalFileName || '';
|
||||||
|
img.addEventListener('click', () => {
|
||||||
|
hidePanel();
|
||||||
|
window.location.href = '/photos/' + photo.id;
|
||||||
|
});
|
||||||
|
thumbGrid.appendChild(img);
|
||||||
|
}
|
||||||
|
if (count > 9) {
|
||||||
|
const more = document.createElement('div');
|
||||||
|
more.style.cssText = 'grid-column: 1/-1; text-align:center; font-size:12px; color:#666; padding:4px;';
|
||||||
|
more.textContent = '+ ' + (count - 9) + ' more';
|
||||||
|
thumbGrid.appendChild(more);
|
||||||
|
}
|
||||||
|
popupContent.appendChild(thumbGrid);
|
||||||
|
|
||||||
|
marker.bindPopup(popupContent, { maxWidth: 220, minWidth: 200 });
|
||||||
|
|
||||||
|
// Show count label
|
||||||
|
if (count > 1) {
|
||||||
|
marker.bindTooltip(String(count), {
|
||||||
|
permanent: true, direction: 'center',
|
||||||
|
className: 'ls-marker-label'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
markers.push(marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add heatmap layer
|
||||||
|
const heatData = geoItems.map(i => [i.exifInfo.latitude, i.exifInfo.longitude, 0.5]);
|
||||||
|
if (heatData.length > 3) {
|
||||||
|
heatLayer = L.heatLayer(heatData, {
|
||||||
|
radius: 25, blur: 15, maxZoom: 15,
|
||||||
|
gradient: { 0: 'blue', 0.25: 'cyan', 0.5: 'lime', 0.75: 'yellow', 1: 'red' }
|
||||||
|
}).addTo(mapInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fit bounds
|
||||||
|
const bounds = L.latLngBounds(geoItems.map(i => [i.exifInfo.latitude, i.exifInfo.longitude]));
|
||||||
|
mapInstance.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 });
|
||||||
|
|
||||||
|
// Fix map sizing (Leaflet needs this after dynamic show)
|
||||||
|
setTimeout(() => mapInstance.invalidateSize(), 100);
|
||||||
|
|
||||||
|
// Add custom label style
|
||||||
|
if (!document.getElementById('ls-marker-styles')) {
|
||||||
|
const s = document.createElement('style');
|
||||||
|
s.id = 'ls-marker-styles';
|
||||||
|
s.textContent = `
|
||||||
|
.ls-marker-label {
|
||||||
|
background: rgba(0,0,0,0.7) !important; border: none !important;
|
||||||
|
color: #fff !important; font-size: 11px !important; font-weight: 600 !important;
|
||||||
|
padding: 2px 5px !important; border-radius: 10px !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
.ls-marker-label::before { display: none !important; }
|
||||||
|
`;
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cluster nearby photos by GPS proximity
|
||||||
|
function clusterByLocation(items, threshold) {
|
||||||
|
const clusters = [];
|
||||||
|
const used = new Set();
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (used.has(i)) continue;
|
||||||
|
const a = items[i];
|
||||||
|
const cluster = [a];
|
||||||
|
used.add(i);
|
||||||
|
|
||||||
|
for (let j = i + 1; j < items.length; j++) {
|
||||||
|
if (used.has(j)) continue;
|
||||||
|
const b = items[j];
|
||||||
|
if (Math.abs(a.exifInfo.latitude - b.exifInfo.latitude) < threshold &&
|
||||||
|
Math.abs(a.exifInfo.longitude - b.exifInfo.longitude) < threshold) {
|
||||||
|
cluster.push(b);
|
||||||
|
used.add(j);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lat = cluster.reduce((s, p) => s + p.exifInfo.latitude, 0) / cluster.length;
|
||||||
|
const lng = cluster.reduce((s, p) => s + p.exifInfo.longitude, 0) / cluster.length;
|
||||||
|
clusters.push({ center: [lat, lng], items: cluster });
|
||||||
|
}
|
||||||
|
|
||||||
|
return clusters;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Hook into search inputs ---
|
||||||
|
function hookInput(input) {
|
||||||
|
if (hookedInputs.has(input)) return;
|
||||||
|
hookedInputs.add(input);
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
const q = input.value.trim();
|
||||||
|
|
||||||
|
if (q.length < MIN_CHARS) {
|
||||||
|
hidePanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debounceTimer = setTimeout(() => doSearch(q), DEBOUNCE_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && panel?.classList.contains('active')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
hidePanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSearchInput(el) {
|
||||||
|
if (el.tagName !== 'INPUT') return false;
|
||||||
|
if (el.type === 'search') return true;
|
||||||
|
const ph = (el.placeholder || '').toLowerCase();
|
||||||
|
if (ph.includes('search')) return true;
|
||||||
|
if (el.getAttribute('role') === 'searchbox') return true;
|
||||||
|
const parent = el.closest('[data-testid*="search"], [class*="search-bar"], [id*="search"]');
|
||||||
|
if (parent) return true;
|
||||||
|
const aria = (el.getAttribute('aria-label') || '').toLowerCase();
|
||||||
|
if (aria.includes('search')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanForSearchInputs(root) {
|
||||||
|
const inputs = (root || document).querySelectorAll('input');
|
||||||
|
for (const input of inputs) {
|
||||||
|
if (isSearchInput(input)) hookInput(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial scan
|
||||||
|
scanForSearchInputs();
|
||||||
|
|
||||||
|
// Watch for dynamically added inputs (SPA navigation)
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
for (const m of mutations) {
|
||||||
|
for (const node of m.addedNodes) {
|
||||||
|
if (node.nodeType !== 1) continue;
|
||||||
|
if (node.tagName === 'INPUT' && isSearchInput(node)) {
|
||||||
|
hookInput(node);
|
||||||
|
} else if (node.querySelectorAll) {
|
||||||
|
scanForSearchInputs(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
// Global Escape
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && panel?.classList.contains('active')) {
|
||||||
|
hidePanel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[live-search] Immich live search with map view loaded');
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
const http = require('http');
|
||||||
|
const net = require('net');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const IMMICH_URL = process.env.IMMICH_URL || 'http://immich-server:2283';
|
||||||
|
const immichParsed = new URL(IMMICH_URL);
|
||||||
|
|
||||||
|
// Timeouts for large uploads (10 minutes)
|
||||||
|
const UPLOAD_TIMEOUT = 10 * 60 * 1000;
|
||||||
|
|
||||||
|
// Load the custom JS to inject
|
||||||
|
const customJS = fs.readFileSync(path.join(__dirname, 'live-search.js'), 'utf8');
|
||||||
|
const SCRIPT_TAG = `<script>${customJS}</script>`;
|
||||||
|
|
||||||
|
// Helper: make an internal request to Immich (for small JSON requests only)
|
||||||
|
function immichRequest(method, apiPath, headers, body) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = new URL(apiPath, IMMICH_URL);
|
||||||
|
const opts = {
|
||||||
|
hostname: url.hostname,
|
||||||
|
port: url.port || 80,
|
||||||
|
path: url.pathname + url.search,
|
||||||
|
method,
|
||||||
|
headers: { ...headers, host: url.host }
|
||||||
|
};
|
||||||
|
delete opts.headers['accept-encoding'];
|
||||||
|
|
||||||
|
const req = http.request(opts, (res) => {
|
||||||
|
let chunks = [];
|
||||||
|
res.on('data', c => chunks.push(c));
|
||||||
|
res.on('end', () => {
|
||||||
|
const data = Buffer.concat(chunks).toString('utf8');
|
||||||
|
resolve({ status: res.statusCode, headers: res.headers, body: data });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
if (body) req.write(body);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
// --- Custom endpoint: batch fetch exif (needs buffered body) ---
|
||||||
|
if (req.url === '/api/custom/batch-exif' && req.method === 'POST') {
|
||||||
|
let bodyChunks = [];
|
||||||
|
req.on('data', chunk => bodyChunks.push(chunk));
|
||||||
|
req.on('end', async () => {
|
||||||
|
const body = Buffer.concat(bodyChunks);
|
||||||
|
try {
|
||||||
|
const { ids } = JSON.parse(body.toString());
|
||||||
|
const authHeader = req.headers['authorization'] || '';
|
||||||
|
const apiKey = req.headers['x-api-key'] || '';
|
||||||
|
const hdrs = { 'Content-Type': 'application/json' };
|
||||||
|
if (authHeader) hdrs['Authorization'] = authHeader;
|
||||||
|
if (apiKey) hdrs['x-api-key'] = apiKey;
|
||||||
|
|
||||||
|
const results = await Promise.all(ids.map(async (id) => {
|
||||||
|
try {
|
||||||
|
const r = await immichRequest('GET', `/api/assets/${id}`, hdrs);
|
||||||
|
if (r.status === 200) {
|
||||||
|
const asset = JSON.parse(r.body);
|
||||||
|
const exif = asset.exifInfo || {};
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
latitude: exif.latitude || null,
|
||||||
|
longitude: exif.longitude || null,
|
||||||
|
city: exif.city || null,
|
||||||
|
state: exif.state || null,
|
||||||
|
country: exif.country || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return { id, latitude: null, longitude: null };
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(results));
|
||||||
|
} catch (e) {
|
||||||
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ error: e.message }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Standard streaming proxy ---
|
||||||
|
const targetUrl = new URL(req.url, IMMICH_URL);
|
||||||
|
const headers = { ...req.headers };
|
||||||
|
headers.host = targetUrl.host;
|
||||||
|
delete headers['accept-encoding'];
|
||||||
|
|
||||||
|
const proxyOpts = {
|
||||||
|
hostname: targetUrl.hostname,
|
||||||
|
port: targetUrl.port || 80,
|
||||||
|
path: req.url,
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
timeout: UPLOAD_TIMEOUT
|
||||||
|
};
|
||||||
|
|
||||||
|
const proxyReq = http.request(proxyOpts, (proxyRes) => {
|
||||||
|
const contentType = proxyRes.headers['content-type'] || '';
|
||||||
|
const isHTML = contentType.includes('text/html');
|
||||||
|
|
||||||
|
if (isHTML) {
|
||||||
|
// Buffer HTML only to inject script tag
|
||||||
|
let htmlChunks = [];
|
||||||
|
proxyRes.on('data', chunk => htmlChunks.push(chunk));
|
||||||
|
proxyRes.on('end', () => {
|
||||||
|
let html = Buffer.concat(htmlChunks).toString('utf8');
|
||||||
|
html = html.replace('</body>', SCRIPT_TAG + '</body>');
|
||||||
|
|
||||||
|
const resHeaders = { ...proxyRes.headers };
|
||||||
|
delete resHeaders['content-length'];
|
||||||
|
delete resHeaders['content-encoding'];
|
||||||
|
resHeaders['content-type'] = 'text/html; charset=utf-8';
|
||||||
|
|
||||||
|
res.writeHead(proxyRes.statusCode, resHeaders);
|
||||||
|
res.end(html);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Stream non-HTML responses directly (videos, images, API JSON)
|
||||||
|
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
||||||
|
proxyRes.pipe(res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proxyReq.on('error', (e) => {
|
||||||
|
console.error('Proxy error:', e.message);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(502, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Bad Gateway');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proxyReq.on('timeout', () => {
|
||||||
|
console.error('Proxy request timed out');
|
||||||
|
proxyReq.destroy();
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(504, { 'Content-Type': 'text/plain' });
|
||||||
|
res.end('Gateway Timeout');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stream request body directly to Immich (no buffering)
|
||||||
|
req.pipe(proxyReq);
|
||||||
|
});
|
||||||
|
|
||||||
|
// WebSocket proxy — handle HTTP upgrade events
|
||||||
|
server.on('upgrade', (req, clientSocket, head) => {
|
||||||
|
const targetHost = immichParsed.hostname;
|
||||||
|
const targetPort = immichParsed.port || 80;
|
||||||
|
|
||||||
|
const proxySocket = net.connect(targetPort, targetHost, () => {
|
||||||
|
// Reconstruct the HTTP upgrade request to forward
|
||||||
|
const reqHeaders = [`${req.method} ${req.url} HTTP/1.1`];
|
||||||
|
for (const [key, value] of Object.entries(req.headers)) {
|
||||||
|
if (key.toLowerCase() === 'host') {
|
||||||
|
reqHeaders.push(`Host: ${targetHost}:${targetPort}`);
|
||||||
|
} else {
|
||||||
|
reqHeaders.push(`${key}: ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reqHeaders.push('', '');
|
||||||
|
|
||||||
|
proxySocket.write(reqHeaders.join('\r\n'));
|
||||||
|
if (head.length > 0) proxySocket.write(head);
|
||||||
|
|
||||||
|
// Bidirectional pipe
|
||||||
|
proxySocket.pipe(clientSocket);
|
||||||
|
clientSocket.pipe(proxySocket);
|
||||||
|
});
|
||||||
|
|
||||||
|
proxySocket.on('error', (e) => {
|
||||||
|
console.error('WebSocket proxy error:', e.message);
|
||||||
|
clientSocket.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
clientSocket.on('error', (e) => {
|
||||||
|
console.error('WebSocket client error:', e.message);
|
||||||
|
proxySocket.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Increase server timeouts for large uploads
|
||||||
|
server.timeout = UPLOAD_TIMEOUT;
|
||||||
|
server.requestTimeout = UPLOAD_TIMEOUT;
|
||||||
|
server.headersTimeout = UPLOAD_TIMEOUT + 1000;
|
||||||
|
server.keepAliveTimeout = UPLOAD_TIMEOUT;
|
||||||
|
|
||||||
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`Immich proxy with live search running on port ${PORT}`);
|
||||||
|
console.log(`Proxying to: ${IMMICH_URL}`);
|
||||||
|
console.log(`Upload timeout: ${UPLOAD_TIMEOUT / 1000}s`);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue