diff --git a/backlog/tasks/task-5 - Set-up-DJI-Neo-2-drone-photo-auto-sync-to-Immich.md b/backlog/tasks/task-5 - Set-up-DJI-Neo-2-drone-photo-auto-sync-to-Immich.md
new file mode 100644
index 0000000..04f45b8
--- /dev/null
+++ b/backlog/tasks/task-5 - Set-up-DJI-Neo-2-drone-photo-auto-sync-to-Immich.md
@@ -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
+
+
+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
+
+
+## Acceptance Criteria
+
+- [ ] #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
+
diff --git a/backlog/tasks/task-6 - Reconnect-Fold-6-to-Syncthing-mesh.md b/backlog/tasks/task-6 - Reconnect-Fold-6-to-Syncthing-mesh.md
new file mode 100644
index 0000000..864f2a6
--- /dev/null
+++ b/backlog/tasks/task-6 - Reconnect-Fold-6-to-Syncthing-mesh.md
@@ -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
+
+
+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
+
+
+## Acceptance Criteria
+
+- [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
+
+
+## Implementation Notes
+
+
+Completed 2026-04-04. Fold 6 reconnected to Syncthing mesh.
+
diff --git a/docker-compose.yml b/docker-compose.yml
index 6985fe6..70aee85 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -18,15 +18,6 @@ services:
restart: always
healthcheck:
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:
container_name: immich_machine_learning
@@ -41,7 +32,7 @@ services:
redis:
container_name: immich_redis
- image: docker.io/valkey/valkey:8@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa
+ image: docker.io/valkey/valkey:9@sha256:3eeb09785cd61ec8e3be35f8804c8892080f3ca21934d628abc24ee4ed1698f6
healthcheck:
test: redis-cli ping || exit 1
restart: always
@@ -87,6 +78,28 @@ services:
- "traefik.http.services.immich-tools.loadbalancer.server.port=3000"
- "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:
container_name: immich_heatmap
build: ./heatmap-app
diff --git a/search-app/Dockerfile b/search-app/Dockerfile
new file mode 100644
index 0000000..e495db7
--- /dev/null
+++ b/search-app/Dockerfile
@@ -0,0 +1,10 @@
+FROM node:20-alpine
+
+WORKDIR /app
+
+COPY server.js .
+COPY live-search.js .
+
+EXPOSE 3000
+
+CMD ["node", "server.js"]
diff --git a/search-app/live-search.js b/search-app/live-search.js
new file mode 100644
index 0000000..c41344e
--- /dev/null
+++ b/search-app/live-search.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 = `
+
+
+ `;
+ 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 = ' 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 = 'No location data available for these results.
Try a location-specific search like a city name.
';
+ return;
+ }
+
+ // Lazy-load Leaflet
+ if (!leafletReady) {
+ mapEl.innerHTML = ' Loading map...
';
+ 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');
+})();
diff --git a/search-app/server.js b/search-app/server.js
new file mode 100644
index 0000000..d1486b6
--- /dev/null
+++ b/search-app/server.js
@@ -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 = ``;
+
+// 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('