205 lines
6.9 KiB
JavaScript
205 lines
6.9 KiB
JavaScript
|
|
#!/usr/bin/env node
|
||
|
|
/**
|
||
|
|
* Eagle Library Viewer - POC Server
|
||
|
|
*
|
||
|
|
* 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
|
||
|
|
*/
|
||
|
|
|
||
|
|
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 = 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 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]; // e.g. "MAY4IUA38G8N5" -> "1_hashing welcome Structy_thumbnail.png"
|
||
|
|
});
|
||
|
|
|
||
|
|
// 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', ''));
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Cache ---
|
||
|
|
let libraryCache = {};
|
||
|
|
const thumbCache = {};
|
||
|
|
|
||
|
|
// --- 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 cacheKey = `${library}/${imageId}`;
|
||
|
|
if (thumbCache[cacheKey]) {
|
||
|
|
res.set('Content-Type', 'image/png');
|
||
|
|
res.set('Cache-Control', 'public, max-age=86400');
|
||
|
|
return res.send(thumbCache[cacheKey]);
|
||
|
|
}
|
||
|
|
|
||
|
|
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;
|
||
|
|
res.set('Content-Type', 'image/png');
|
||
|
|
res.set('Cache-Control', 'public, max-age=86400');
|
||
|
|
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 {
|
||
|
|
// 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 orig = files.find(f =>
|
||
|
|
!f.includes('_thumbnail') && f !== 'metadata.json' && !f.startsWith('@')
|
||
|
|
);
|
||
|
|
if (!orig) return res.status(404).send('Original file not found');
|
||
|
|
|
||
|
|
const ext = img.ext || 'bin';
|
||
|
|
const filePath = path.join(imgDir, 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);
|
||
|
|
const range = req.headers.range;
|
||
|
|
if (range && (ext === 'mp4' || ext === 'mov' || ext === 'm4v')) {
|
||
|
|
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
|
||
|
|
const start = parseInt(startStr, 10);
|
||
|
|
const end = endStr ? parseInt(endStr, 10) : stat.size - 1;
|
||
|
|
const chunkSize = end - start + 1;
|
||
|
|
res.status(206);
|
||
|
|
res.set('Content-Range', `bytes ${start}-${end}/${stat.size}`);
|
||
|
|
res.set('Accept-Ranges', 'bytes');
|
||
|
|
res.set('Content-Length', chunkSize);
|
||
|
|
fs.createReadStream(filePath, { start, end }).pipe(res);
|
||
|
|
} else {
|
||
|
|
res.set('Content-Length', stat.size);
|
||
|
|
res.set('Accept-Ranges', 'bytes');
|
||
|
|
fs.createReadStream(filePath).pipe(res);
|
||
|
|
}
|
||
|
|
} 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'
|
||
|
|
};
|
||
|
|
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}\n`);
|
||
|
|
});
|