From 38bdcf25dcaf1414d18bb4c77ecaed58fdf89f62 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 14 Apr 2026 12:47:53 -0400 Subject: [PATCH] feat: add search-app proxy with WebSocket support and upload buffering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...I-Neo-2-drone-photo-auto-sync-to-Immich.md | 44 ++ ...-6 - Reconnect-Fold-6-to-Syncthing-mesh.md | 44 ++ docker-compose.yml | 33 +- search-app/Dockerfile | 10 + search-app/live-search.js | 652 ++++++++++++++++++ search-app/server.js | 197 ++++++ 6 files changed, 970 insertions(+), 10 deletions(-) create mode 100644 backlog/tasks/task-5 - Set-up-DJI-Neo-2-drone-photo-auto-sync-to-Immich.md create mode 100644 backlog/tasks/task-6 - Reconnect-Fold-6-to-Syncthing-mesh.md create mode 100644 search-app/Dockerfile create mode 100644 search-app/live-search.js create mode 100644 search-app/server.js 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('', SCRIPT_TAG + ''); + + 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`); +});