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:
Jeff Emmett 2026-04-14 12:47:53 -04:00
parent 9281400c61
commit 38bdcf25dc
6 changed files with 970 additions and 10 deletions

View File

@ -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 -->

View File

@ -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 -->

View File

@ -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

10
search-app/Dockerfile Normal file
View File

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

652
search-app/live-search.js Normal file
View File

@ -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">&times;</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: '&copy; 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');
})();

197
search-app/server.js Normal file
View File

@ -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`);
});