dockerize: switch to SSH-only, add disk thumbnail cache, add Dockerfile
This commit is contained in:
parent
4932f397b9
commit
8aa50dd5e2
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.thumb-cache
|
||||||
|
*.sh
|
||||||
|
*.py
|
||||||
|
readme.md
|
||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@ -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"]
|
||||||
111
server.js
111
server.js
@ -1,10 +1,11 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* Eagle Library Viewer - POC Server
|
* Eagle Library Viewer
|
||||||
*
|
*
|
||||||
* Strategy:
|
* Strategy:
|
||||||
* - SSH (server-side find+cat) for bulk metadata load — fast (~1s for 370 files)
|
* - SSH (server-side find+cat) for all NAS access — metadata and thumbnails
|
||||||
* - Local SMB mount for serving thumbnails/files — avoids base64 overhead
|
* - 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 express = require('express');
|
||||||
@ -14,15 +15,19 @@ const fsp = require('fs/promises');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = 3737;
|
const PORT = process.env.PORT || 3737;
|
||||||
|
|
||||||
// SMB mount (for serving files)
|
// SSH config
|
||||||
const SMB_BASE = '/run/user/1000/gvfs/smb-share:server=synologynas.local,share=steve/Eagle_Libraries';
|
const SSH_HOST = process.env.SSH_HOST || 'synologynas.local';
|
||||||
// SSH config (for bulk metadata reads)
|
const SSH_USER = process.env.SSH_USER || 'steve';
|
||||||
const SSH_HOST = 'synologynas.local';
|
const SSH_PASS = process.env.SSH_PASS || 'cheong';
|
||||||
const SSH_USER = 'steve';
|
const NAS_BASE = process.env.NAS_BASE || '/volume1/steve/Eagle_Libraries';
|
||||||
const SSH_PASS = 'cheong';
|
|
||||||
const 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) ---
|
// --- SSH helper (single-shot exec) ---
|
||||||
function sshExec(remoteCmd) {
|
function sshExec(remoteCmd) {
|
||||||
@ -59,7 +64,7 @@ function loadLibrary(libName) {
|
|||||||
const thumbIndex = {};
|
const thumbIndex = {};
|
||||||
thumbBuf.toString().split('\n').map(s => s.trim()).filter(Boolean).forEach(p => {
|
thumbBuf.toString().split('\n').map(s => s.trim()).filter(Boolean).forEach(p => {
|
||||||
const m = p.match(/\/([A-Z0-9]+)\.info\/(.+)$/);
|
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
|
// Parse image records
|
||||||
@ -90,9 +95,13 @@ function listLibraries() {
|
|||||||
.map(e => e.replace('.library', ''));
|
.map(e => e.replace('.library', ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Cache ---
|
// --- In-memory library metadata cache ---
|
||||||
let libraryCache = {};
|
let libraryCache = {};
|
||||||
const thumbCache = {};
|
|
||||||
|
// --- Thumbnail: disk cache path helper ---
|
||||||
|
function thumbCachePath(library, imageId) {
|
||||||
|
return path.join(CACHE_DIR, `${library}__${imageId}.png`);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Routes ---
|
// --- Routes ---
|
||||||
|
|
||||||
@ -121,23 +130,34 @@ app.get('/api/library/:name', (req, res) => {
|
|||||||
|
|
||||||
app.get('/api/thumbnail/:library/:imageId', async (req, res) => {
|
app.get('/api/thumbnail/:library/:imageId', async (req, res) => {
|
||||||
const { library, imageId } = req.params;
|
const { library, imageId } = req.params;
|
||||||
const cacheKey = `${library}/${imageId}`;
|
const cachePath = thumbCachePath(library, imageId);
|
||||||
if (thumbCache[cacheKey]) {
|
|
||||||
|
// 1. Serve from disk cache if available
|
||||||
|
try {
|
||||||
|
const buf = await fsp.readFile(cachePath);
|
||||||
res.set('Content-Type', 'image/png');
|
res.set('Content-Type', 'image/png');
|
||||||
res.set('Cache-Control', 'public, max-age=86400');
|
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 libName = library + '.library';
|
||||||
const img = libraryCache[libName]?.images?.find(i => i.id === imageId);
|
const img = libraryCache[libName]?.images?.find(i => i.id === imageId);
|
||||||
if (!img?._thumbnail) return res.status(404).send('No thumbnail');
|
if (!img?._thumbnail) return res.status(404).send('No thumbnail');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const thumbPath = path.join(SMB_BASE, libName, 'images', img._dir, img._thumbnail);
|
const nasThumbPath = `${NAS_BASE}/${libName}/images/${img._dir}/${img._thumbnail}`;
|
||||||
const buf = await fsp.readFile(thumbPath);
|
const buf = sshExec(`cat "${nasThumbPath}"`);
|
||||||
thumbCache[cacheKey] = buf;
|
|
||||||
|
// 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('Content-Type', 'image/png');
|
||||||
res.set('Cache-Control', 'public, max-age=86400');
|
res.set('Cache-Control', 'public, max-age=86400');
|
||||||
|
res.set('X-Cache', 'MISS');
|
||||||
res.send(buf);
|
res.send(buf);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(404).send('Thumbnail not found: ' + e.message);
|
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');
|
if (!img) return res.status(404).send('Not found — load library first');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find original file in the .info dir (not thumbnail, not metadata.json)
|
const nasLibPath = `${NAS_BASE}/${libName}/images/${img._dir}`;
|
||||||
const imgDir = path.join(SMB_BASE, libName, 'images', img._dir);
|
|
||||||
const files = await fsp.readdir(imgDir);
|
// 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 =>
|
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');
|
if (!orig) return res.status(404).send('Original file not found');
|
||||||
|
|
||||||
const ext = img.ext || 'bin';
|
const ext = img.ext || 'bin';
|
||||||
const filePath = path.join(imgDir, orig);
|
const nasFilePath = `${nasLibPath}/${orig}`;
|
||||||
|
|
||||||
res.set('Content-Type', mimeType(ext));
|
res.set('Content-Type', mimeType(ext));
|
||||||
res.set('Content-Disposition', `inline; filename="${img.name}.${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;
|
const range = req.headers.range;
|
||||||
if (range && (ext === 'mp4' || ext === 'mov' || ext === 'm4v')) {
|
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 [startStr, endStr] = range.replace(/bytes=/, '').split('-');
|
||||||
const start = parseInt(startStr, 10);
|
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;
|
const chunkSize = end - start + 1;
|
||||||
|
|
||||||
res.status(206);
|
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('Accept-Ranges', 'bytes');
|
||||||
res.set('Content-Length', chunkSize);
|
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 {
|
} 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');
|
res.set('Accept-Ranges', 'bytes');
|
||||||
fs.createReadStream(filePath).pipe(res);
|
res.send(buf);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(500).send(e.message);
|
res.status(500).send(e.message);
|
||||||
@ -190,7 +229,8 @@ function mimeType(ext) {
|
|||||||
const map = {
|
const map = {
|
||||||
mp4: 'video/mp4', mov: 'video/quicktime', m4v: 'video/mp4', avi: 'video/x-msvideo',
|
mp4: 'video/mp4', mov: 'video/quicktime', m4v: 'video/mp4', avi: 'video/x-msvideo',
|
||||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
|
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';
|
return map[(ext || '').toLowerCase()] || 'application/octet-stream';
|
||||||
}
|
}
|
||||||
@ -200,5 +240,6 @@ app.get('/', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
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`);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user