#!/usr/bin/env node /** * Eagle Library Viewer * * Strategy: * - SSH (server-side find+cat) for all NAS access — metadata and thumbnails * - Disk-based cache at CACHE_DIR for both metadata and thumbnails (survives container restarts) * - In-memory library metadata cache (hot path after disk cache is warm) */ const express = require('express'); const { execFileSync } = require('child_process'); const fs = require('fs'); const fsp = require('fs/promises'); const path = require('path'); const app = express(); const PORT = process.env.PORT || 3737; // SSH config const SSH_HOST = process.env.SSH_HOST || 'synologynas.local'; const SSH_USER = process.env.SSH_USER || 'steve'; const SSH_PASS = process.env.SSH_PASS || 'cheong'; const NAS_BASE = process.env.NAS_BASE || '/volume1/steve/Eagle_Libraries'; // Disk-based thumbnail cache dir (mount a Docker volume here for persistence) const CACHE_DIR = process.env.CACHE_DIR || path.join(__dirname, '.thumb-cache'); // Ensure cache dir exists fs.mkdirSync(CACHE_DIR, { recursive: true }); // --- SSH helper (single-shot exec) --- function sshExec(remoteCmd) { return execFileSync('sshpass', [ '-p', SSH_PASS, 'ssh', '-o', 'StrictHostKeyChecking=no', '-o', 'ConnectTimeout=15', `${SSH_USER}@${SSH_HOST}`, remoteCmd, ], { maxBuffer: 100 * 1024 * 1024 }); } // --- Load library metadata via SSH (one round-trip) --- function loadLibrary(libName) { const nasLibPath = `${NAS_BASE}/${libName}`; // 1. library metadata.json const metaBuf = sshExec(`cat "${nasLibPath}/metadata.json"`); const meta = JSON.parse(metaBuf.toString()); // 2. All image metadata.json in one shot // Format: ===SEP===\n\n const allMetaBuf = sshExec( `find "${nasLibPath}/images" -name 'metadata.json' -not -path '*/@eaDir/*' | sort | while read f; do ` + `dir=$(dirname "$f"); id=$(basename "$dir" .info); ` + `echo "===SEP==="; echo "$id"; cat "$f"; echo; done` ); // 3. All thumbnail paths in one shot const thumbBuf = sshExec( `find "${nasLibPath}/images" -name '*_thumbnail*' -not -path '*/@eaDir/*'` ); // Build thumbnail index: imageId -> relative path within the .info dir const thumbIndex = {}; thumbBuf.toString().split('\n').map(s => s.trim()).filter(Boolean).forEach(p => { const m = p.match(/\/([A-Z0-9]+)\.info\/(.+)$/); if (m) thumbIndex[m[1]] = m[2]; }); // Parse image records const images = []; const chunks = allMetaBuf.toString().split('===SEP===\n').filter(s => s.trim()); chunks.forEach(chunk => { const nl = chunk.indexOf('\n'); if (nl === -1) return; const imageId = chunk.slice(0, nl).trim(); const jsonStr = chunk.slice(nl + 1).trim(); if (!jsonStr || !imageId) return; try { const img = JSON.parse(jsonStr); img._dir = imageId + '.info'; img._thumbnail = thumbIndex[imageId] || null; images.push(img); } catch { /* skip malformed */ } }); return { meta, images }; } // --- List libraries via SSH --- function listLibraries() { const buf = sshExec(`ls "${NAS_BASE}"`); return buf.toString().split('\n').map(s => s.trim()) .filter(e => e.endsWith('.library') && !e.startsWith('@') && !e.startsWith('._')) .map(e => e.replace('.library', '')); } // --- In-memory library metadata cache (populated from disk cache on hit) --- let libraryCache = {}; // --- Disk cache path helpers --- function thumbCachePath(library, imageId) { return path.join(CACHE_DIR, `${library}__${imageId}.png`); } function metaCachePath(libName) { return path.join(CACHE_DIR, `meta__${libName}.json`); } // --- Routes --- app.get('/api/libraries', (req, res) => { try { res.json(listLibraries()); } catch (e) { res.status(500).json({ error: e.message }); } }); app.get('/api/library/:name', async (req, res) => { try { const name = req.params.name + '.library'; // 1. In-memory cache (fastest — already loaded this session) if (libraryCache[name]) { const data = libraryCache[name]; return res.json({ folders: data.meta.folders, imageCount: data.images.length, images: data.images, cached: 'memory' }); } // 2. Disk cache (fast — survives restarts) const diskPath = metaCachePath(name); try { const raw = await fsp.readFile(diskPath, 'utf8'); const data = JSON.parse(raw); libraryCache[name] = data; // also warm the in-memory cache console.log(`Library ${name} loaded from disk cache (${data.images.length} images)`); return res.json({ folders: data.meta.folders, imageCount: data.images.length, images: data.images, cached: 'disk' }); } catch { /* cache miss — fall through to SSH */ } // 3. SSH fetch from NAS (slow — first time only) console.log(`Loading library: ${name} from NAS via SSH…`); const t0 = Date.now(); const data = loadLibrary(name); libraryCache[name] = data; console.log(`Loaded ${data.images.length} images in ${Date.now() - t0}ms — writing to disk cache`); // Write to disk cache (fire-and-forget) fsp.writeFile(diskPath, JSON.stringify(data)).catch(err => console.warn(`Failed to write metadata cache for ${name}:`, err.message) ); res.json({ folders: data.meta.folders, imageCount: data.images.length, images: data.images, cached: 'miss' }); } catch (e) { console.error(e); res.status(500).json({ error: e.message }); } }); // Force a fresh SSH fetch and update the disk cache (call this when library contents change) app.post('/api/library/:name/refresh', async (req, res) => { try { const name = req.params.name + '.library'; console.log(`Refreshing library: ${name} …`); const t0 = Date.now(); const data = loadLibrary(name); libraryCache[name] = data; await fsp.writeFile(metaCachePath(name), JSON.stringify(data)); console.log(`Refreshed ${data.images.length} images in ${Date.now() - t0}ms`); res.json({ ok: true, imageCount: data.images.length, ms: Date.now() - t0 }); } catch (e) { console.error(e); res.status(500).json({ error: e.message }); } }); app.get('/api/thumbnail/:library/:imageId', async (req, res) => { const { library, imageId } = req.params; const cachePath = thumbCachePath(library, imageId); // 1. Serve from disk cache if available try { const buf = await fsp.readFile(cachePath); res.set('Content-Type', 'image/png'); res.set('Cache-Control', 'public, max-age=86400'); res.set('X-Cache', 'HIT'); return res.send(buf); } catch { /* cache miss — fall through */ } // 2. Fetch from NAS via SSH const libName = library + '.library'; const img = libraryCache[libName]?.images?.find(i => i.id === imageId); if (!img?._thumbnail) return res.status(404).send('No thumbnail'); try { const nasThumbPath = `${NAS_BASE}/${libName}/images/${img._dir}/${img._thumbnail}`; const buf = sshExec(`cat "${nasThumbPath}"`); // Write to disk cache (fire-and-forget — don't block the response) fsp.writeFile(cachePath, buf).catch(err => console.warn(`Failed to write thumb cache for ${imageId}:`, err.message) ); res.set('Content-Type', 'image/png'); res.set('Cache-Control', 'public, max-age=86400'); res.set('X-Cache', 'MISS'); res.send(buf); } catch (e) { res.status(404).send('Thumbnail not found: ' + e.message); } }); app.get('/api/file/:library/:imageId', async (req, res) => { const { library, imageId } = req.params; const libName = library + '.library'; const img = libraryCache[libName]?.images?.find(i => i.id === imageId); if (!img) return res.status(404).send('Not found — load library first'); try { const nasLibPath = `${NAS_BASE}/${libName}/images/${img._dir}`; // Find original file via SSH (not thumbnail, not metadata.json) const lsBuf = sshExec(`ls "${nasLibPath}"`); const files = lsBuf.toString().split('\n').map(s => s.trim()).filter(Boolean); const orig = files.find(f => !f.includes('_thumbnail') && f !== 'metadata.json' && !f.startsWith('@') && !f.startsWith('._') ); if (!orig) return res.status(404).send('Original file not found'); const ext = img.ext || 'bin'; const nasFilePath = `${nasLibPath}/${orig}`; res.set('Content-Type', mimeType(ext)); res.set('Content-Disposition', `inline; filename="${img.name}.${ext}"`); // Stream large files (video) via SSH cat with range support const range = req.headers.range; if (range && (ext === 'mp4' || ext === 'mov' || ext === 'm4v')) { // Get file size const sizeBuf = sshExec(`stat -c%s "${nasFilePath}"`); const fileSize = parseInt(sizeBuf.toString().trim(), 10); const [startStr, endStr] = range.replace(/bytes=/, '').split('-'); const start = parseInt(startStr, 10); const end = endStr ? parseInt(endStr, 10) : fileSize - 1; const chunkSize = end - start + 1; res.status(206); res.set('Content-Range', `bytes ${start}-${end}/${fileSize}`); res.set('Accept-Ranges', 'bytes'); res.set('Content-Length', chunkSize); // Use dd over SSH to stream the byte range const { spawn } = require('child_process'); const proc = spawn('sshpass', [ '-p', SSH_PASS, 'ssh', '-o', 'StrictHostKeyChecking=no', `${SSH_USER}@${SSH_HOST}`, `dd if="${nasFilePath}" bs=1 skip=${start} count=${chunkSize} 2>/dev/null`, ]); proc.stdout.pipe(res); proc.stderr.on('data', () => {}); // suppress dd stats } else { // Small files or non-video: read fully via SSH cat const buf = sshExec(`cat "${nasFilePath}"`); res.set('Content-Length', buf.length); res.set('Accept-Ranges', 'bytes'); res.send(buf); } } catch (e) { res.status(500).send(e.message); } }); function mimeType(ext) { const map = { mp4: 'video/mp4', mov: 'video/quicktime', m4v: 'video/mp4', avi: 'video/x-msvideo', jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', svg: 'image/svg+xml', pdf: 'application/pdf', webp: 'image/webp', mp3: 'audio/mpeg', m4a: 'audio/mp4', }; return map[(ext || '').toLowerCase()] || 'application/octet-stream'; } app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'index.html')); }); app.listen(PORT, () => { console.log(`\nEagle Viewer → http://localhost:${PORT}`); console.log(`Thumbnail cache: ${CACHE_DIR}\n`); });