eagle-viewer/server.js

246 lines
8.3 KiB
JavaScript

#!/usr/bin/env node
/**
* Eagle Library Viewer
*
* Strategy:
* - 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');
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<relative_dir_id>\n<json>
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 ---
let libraryCache = {};
// --- Thumbnail: disk cache path helper ---
function thumbCachePath(library, imageId) {
return path.join(CACHE_DIR, `${library}__${imageId}.png`);
}
// --- 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', (req, res) => {
try {
const name = req.params.name + '.library';
console.log(`Loading library: ${name} …`);
const t0 = Date.now();
const data = loadLibrary(name);
libraryCache[name] = data;
console.log(`Loaded ${data.images.length} images in ${Date.now() - t0}ms`);
res.json({ folders: data.meta.folders, imageCount: data.images.length, images: data.images });
} 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`);
});