diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..baa1e56 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.thumb-cache +*.sh +*.py +readme.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b0bcda9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM node:20-alpine + +# Install sshpass (needed for SSH-based NAS access) +RUN apk add --no-cache sshpass openssh-client + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY server.js index.html ./ + +# Thumbnail cache lives here — mount a volume for persistence +VOLUME ["/app/.thumb-cache"] + +ENV PORT=3737 +EXPOSE 3737 + +CMD ["node", "server.js"] diff --git a/server.js b/server.js index e1f3590..ffb77ea 100644 --- a/server.js +++ b/server.js @@ -1,10 +1,11 @@ #!/usr/bin/env node /** - * Eagle Library Viewer - POC Server + * Eagle Library Viewer * * Strategy: - * - SSH (server-side find+cat) for bulk metadata load — fast (~1s for 370 files) - * - Local SMB mount for serving thumbnails/files — avoids base64 overhead + * - SSH (server-side find+cat) for all NAS access — metadata and thumbnails + * - Disk-based thumbnail cache at CACHE_DIR (survives container restarts) + * - In-memory library metadata cache (reset on restart, cheap to rebuild) */ const express = require('express'); @@ -14,15 +15,19 @@ const fsp = require('fs/promises'); const path = require('path'); const app = express(); -const PORT = 3737; +const PORT = process.env.PORT || 3737; -// SMB mount (for serving files) -const SMB_BASE = '/run/user/1000/gvfs/smb-share:server=synologynas.local,share=steve/Eagle_Libraries'; -// SSH config (for bulk metadata reads) -const SSH_HOST = 'synologynas.local'; -const SSH_USER = 'steve'; -const SSH_PASS = 'cheong'; -const NAS_BASE = '/volume1/steve/Eagle_Libraries'; +// 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) { @@ -59,7 +64,7 @@ function loadLibrary(libName) { 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]; // e.g. "MAY4IUA38G8N5" -> "1_hashing welcome Structy_thumbnail.png" + if (m) thumbIndex[m[1]] = m[2]; }); // Parse image records @@ -90,9 +95,13 @@ function listLibraries() { .map(e => e.replace('.library', '')); } -// --- Cache --- +// --- In-memory library metadata cache --- let libraryCache = {}; -const thumbCache = {}; + +// --- Thumbnail: disk cache path helper --- +function thumbCachePath(library, imageId) { + return path.join(CACHE_DIR, `${library}__${imageId}.png`); +} // --- Routes --- @@ -121,23 +130,34 @@ app.get('/api/library/:name', (req, res) => { app.get('/api/thumbnail/:library/:imageId', async (req, res) => { const { library, imageId } = req.params; - const cacheKey = `${library}/${imageId}`; - if (thumbCache[cacheKey]) { + 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'); - return res.send(thumbCache[cacheKey]); - } + 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 thumbPath = path.join(SMB_BASE, libName, 'images', img._dir, img._thumbnail); - const buf = await fsp.readFile(thumbPath); - thumbCache[cacheKey] = buf; + 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); @@ -151,35 +171,54 @@ app.get('/api/file/:library/:imageId', async (req, res) => { if (!img) return res.status(404).send('Not found — load library first'); try { - // Find original file in the .info dir (not thumbnail, not metadata.json) - const imgDir = path.join(SMB_BASE, libName, 'images', img._dir); - const files = await fsp.readdir(imgDir); + 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.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 filePath = path.join(imgDir, orig); + const nasFilePath = `${nasLibPath}/${orig}`; + res.set('Content-Type', mimeType(ext)); res.set('Content-Disposition', `inline; filename="${img.name}.${ext}"`); - // Stream instead of readFile for large videos - const stat = await fsp.stat(filePath); + + // 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) : stat.size - 1; + const end = endStr ? parseInt(endStr, 10) : fileSize - 1; const chunkSize = end - start + 1; + res.status(206); - res.set('Content-Range', `bytes ${start}-${end}/${stat.size}`); + res.set('Content-Range', `bytes ${start}-${end}/${fileSize}`); res.set('Accept-Ranges', 'bytes'); res.set('Content-Length', chunkSize); - fs.createReadStream(filePath, { start, end }).pipe(res); + + // 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 { - res.set('Content-Length', stat.size); + // 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'); - fs.createReadStream(filePath).pipe(res); + res.send(buf); } } catch (e) { res.status(500).send(e.message); @@ -190,7 +229,8 @@ 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' + 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'; } @@ -200,5 +240,6 @@ app.get('/', (req, res) => { }); app.listen(PORT, () => { - console.log(`\nEagle Viewer → http://localhost:${PORT}\n`); + console.log(`\nEagle Viewer → http://localhost:${PORT}`); + console.log(`Thumbnail cache: ${CACHE_DIR}\n`); });