From 8caaeaf13c93527167626d647f1f84510b75bdce Mon Sep 17 00:00:00 2001 From: zgaetano Date: Tue, 31 Mar 2026 15:29:49 -0400 Subject: [PATCH] Add server.js --- server.js | 538 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 server.js diff --git a/server.js b/server.js new file mode 100644 index 0000000..9d53aab --- /dev/null +++ b/server.js @@ -0,0 +1,538 @@ +/** + * AME Remote Job Manager — Express Server + * + * Manages Adobe Media Encoder jobs via watch folder integration. + * Editors upload .prproj files, the server remaps .gves paths to high-res UNC paths, + * and delivers the remapped file to AME's watch folder for rendering. + */ + +const express = require('express'); +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); +const { remapPrproj, analyzePrproj } = require('./prproj-remapper'); +const { readAMELogs, watchAMELog } = require('./ame-log-parser'); + +const app = express(); +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'public'))); + +// ─── Configuration ───────────────────────────────────────────────── + +const PORT = process.env.PORT || 3100; +const WATCH_FOLDER = process.env.WATCH_FOLDER || '/watch'; +const OUTPUT_FOLDER = process.env.OUTPUT_FOLDER || '/output'; +const DATA_DIR = process.env.DATA_DIR || '/data'; +const UPLOAD_TEMP = process.env.UPLOAD_TEMP || '/tmp/uploads'; +const AUTH_USER = process.env.AUTH_USER || 'admin'; +const AUTH_PASS = process.env.AUTH_PASS || 'password'; +const POLL_INTERVAL_MS = parseInt(process.env.POLL_INTERVAL_MS) || 5000; +// Job timeout: if a file has been in the watch folder longer than this, mark as stuck +const JOB_TIMEOUT_MS = parseInt(process.env.JOB_TIMEOUT_MS) || 3600000; // 1 hour default +// AME log directory (mount the folder containing AMEEncodingLog.txt) +const AME_LOG_DIR = process.env.AME_LOG_DIR || '/ame-logs'; + +// ─── Data Store ──────────────────────────────────────────────────── + +const DB_FILE = path.join(DATA_DIR, 'jobs.json'); +const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json'); +const SETTINGS_FILE = path.join(DATA_DIR, 'settings.json'); + +function ensureDir(dir) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +} + +function loadDb() { + try { + return JSON.parse(fs.readFileSync(DB_FILE, 'utf-8')); + } catch { + return { jobs: [] }; + } +} + +function saveDb(db) { + ensureDir(DATA_DIR); + fs.writeFileSync(DB_FILE, JSON.stringify(db, null, 2)); +} + +function loadSessions() { + try { + return JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf-8')); + } catch { + return {}; + } +} + +function saveSessions(sessions) { + ensureDir(DATA_DIR); + fs.writeFileSync(SESSIONS_FILE, JSON.stringify(sessions)); +} + +function loadSettings() { + try { + return JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf-8')); + } catch { + return { + watchFolder: WATCH_FOLDER, + outputFolder: OUTPUT_FOLDER, + ameLogDir: AME_LOG_DIR, + smbWatchPath: '', + smbUsername: '', + smbPassword: '', + smbDomain: '', + smbNotes: '' + }; + } +} + +function saveSettings(settings) { + ensureDir(DATA_DIR); + fs.writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)); +} + +function getActiveWatchFolder() { + const s = loadSettings(); + return s.watchFolder || WATCH_FOLDER; +} + +function getActiveAmeLogDir() { + const s = loadSettings(); + return s.ameLogDir || AME_LOG_DIR; +} + +// ─── Session Auth (matches s3-uploader pattern) ──────────────────── + +const sessions = loadSessions(); + +function generateSessionId() { + return crypto.randomBytes(32).toString('hex'); +} + +function requireAuth(req, res, next) { + const sessionId = req.headers['x-session-id']; + if (sessionId && sessions[sessionId]) { + sessions[sessionId].lastAccess = Date.now(); + req.username = sessions[sessionId].username; + return next(); + } + res.status(401).json({ error: 'Unauthorized' }); +} + +// Login +app.post('/api/login', (req, res) => { + const { username, password } = req.body; + if (username === AUTH_USER && password === AUTH_PASS) { + const sessionId = generateSessionId(); + sessions[sessionId] = { username, created: Date.now(), lastAccess: Date.now() }; + saveSessions(sessions); + return res.json({ sessionId, username }); + } + res.status(401).json({ error: 'Invalid credentials' }); +}); + +// Logout +app.post('/api/logout', (req, res) => { + const sessionId = req.headers['x-session-id']; + if (sessionId) { + delete sessions[sessionId]; + saveSessions(sessions); + } + res.json({ ok: true }); +}); + +// ─── Settings API ────────────────────────────────────────────────── + +/** + * GET /api/settings — Get current configurable settings + */ +app.get('/api/settings', requireAuth, (req, res) => { + const settings = loadSettings(); + res.json({ + watchFolder: settings.watchFolder || WATCH_FOLDER, + outputFolder: settings.outputFolder || OUTPUT_FOLDER, + ameLogDir: settings.ameLogDir || AME_LOG_DIR, + smbWatchPath: settings.smbWatchPath || '', + smbUsername: settings.smbUsername || '', + // Return password masked — UI shows whether one is saved without exposing it + smbPasswordSet: !!(settings.smbPassword), + smbDomain: settings.smbDomain || '', + smbNotes: settings.smbNotes || '', + defaultWatchFolder: WATCH_FOLDER, + defaultOutputFolder: OUTPUT_FOLDER, + defaultAmeLogDir: AME_LOG_DIR + }); +}); + +/** + * POST /api/settings — Update configurable settings + */ +app.post('/api/settings', requireAuth, (req, res) => { + const current = loadSettings(); + const { watchFolder, outputFolder, ameLogDir, smbWatchPath, smbUsername, smbPassword, smbDomain, smbNotes } = req.body; + + const updated = { + ...current, + ...(watchFolder !== undefined && { watchFolder }), + ...(outputFolder !== undefined && { outputFolder }), + ...(ameLogDir !== undefined && { ameLogDir }), + ...(smbWatchPath !== undefined && { smbWatchPath }), + ...(smbUsername !== undefined && { smbUsername }), + // Only update password if a non-empty value was sent (empty = keep existing) + ...(smbPassword !== undefined && smbPassword !== '' && { smbPassword }), + ...(smbDomain !== undefined && { smbDomain }), + ...(smbNotes !== undefined && { smbNotes }) + }; + + saveSettings(updated); + console.log('Settings updated (password redacted):', { ...updated, smbPassword: updated.smbPassword ? '***' : '' }); + + // Return same masked response as GET + res.json({ + ok: true, + settings: { + watchFolder: updated.watchFolder, + outputFolder: updated.outputFolder, + ameLogDir: updated.ameLogDir, + smbWatchPath: updated.smbWatchPath || '', + smbUsername: updated.smbUsername || '', + smbPasswordSet: !!(updated.smbPassword), + smbDomain: updated.smbDomain || '', + smbNotes: updated.smbNotes || '' + } + }); +}); + +// ─── Multer upload config ────────────────────────────────────────── + +ensureDir(UPLOAD_TEMP); + +const upload = multer({ + dest: UPLOAD_TEMP, + limits: { fileSize: 500 * 1024 * 1024 }, // 500MB max + fileFilter: (req, file, cb) => { + if (path.extname(file.originalname).toLowerCase() === '.prproj') { + cb(null, true); + } else { + cb(new Error('Only .prproj files are accepted')); + } + } +}); + +// ─── API Routes ──────────────────────────────────────────────────── + +/** + * POST /api/jobs — Upload a .prproj and create a new job + */ +app.post('/api/jobs', requireAuth, upload.single('prproj'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No file uploaded' }); + } + + const db = loadDb(); + const jobId = crypto.randomUUID(); + const originalName = req.file.originalname; + const uploadedPath = req.file.path; + + // Read the uploaded file + const prprojBuffer = fs.readFileSync(uploadedPath); + + // Analyze first to show what we found + let analysis; + try { + analysis = await analyzePrproj(prprojBuffer); + } catch (err) { + // Clean up temp file + fs.unlinkSync(uploadedPath); + return res.status(400).json({ + error: 'Failed to parse .prproj file', + details: err.message + }); + } + + // Perform the remap + let remapResult; + try { + remapResult = await remapPrproj(prprojBuffer); + } catch (err) { + fs.unlinkSync(uploadedPath); + return res.status(500).json({ + error: 'Failed to remap .prproj file', + details: err.message + }); + } + + // Write remapped file to watch folder (use settings-driven path) + const currentWatchFolder = getActiveWatchFolder(); + const outputFilename = `${path.basename(originalName, '.prproj')}_${jobId.substring(0, 8)}.prproj`; + const watchFolderPath = path.join(currentWatchFolder, outputFilename); + + ensureDir(currentWatchFolder); + fs.writeFileSync(watchFolderPath, remapResult.buffer); + + // Clean up temp file + fs.unlinkSync(uploadedPath); + + // Create job record + const job = { + id: jobId, + originalFilename: originalName, + remappedFilename: outputFilename, + submittedBy: req.username, + submittedAt: new Date().toISOString(), + status: 'queued', // queued → encoding → complete → error + statusUpdatedAt: new Date().toISOString(), + analysis, + remapReport: remapResult.report, + watchFolderPath: outputFilename, + outputFiles: [], + error: null + }; + + db.jobs.unshift(job); + saveDb(db); + + res.json({ job }); + } catch (err) { + console.error('Job submission error:', err); + res.status(500).json({ error: err.message }); + } +}); + +/** + * POST /api/jobs/analyze — Analyze a .prproj without submitting (dry run) + */ +app.post('/api/jobs/analyze', requireAuth, upload.single('prproj'), async (req, res) => { + try { + if (!req.file) { + return res.status(400).json({ error: 'No file uploaded' }); + } + + const prprojBuffer = fs.readFileSync(req.file.path); + fs.unlinkSync(req.file.path); + + const analysis = await analyzePrproj(prprojBuffer); + res.json({ analysis }); + } catch (err) { + if (req.file && fs.existsSync(req.file.path)) { + fs.unlinkSync(req.file.path); + } + res.status(500).json({ error: err.message }); + } +}); + +/** + * GET /api/jobs — List all jobs + */ +app.get('/api/jobs', requireAuth, (req, res) => { + const db = loadDb(); + res.json({ jobs: db.jobs }); +}); + +/** + * GET /api/jobs/:id — Get a single job + */ +app.get('/api/jobs/:id', requireAuth, (req, res) => { + const db = loadDb(); + const job = db.jobs.find(j => j.id === req.params.id); + if (!job) return res.status(404).json({ error: 'Job not found' }); + res.json({ job }); +}); + +/** + * DELETE /api/jobs/:id — Delete a job record + */ +app.delete('/api/jobs/:id', requireAuth, (req, res) => { + const db = loadDb(); + const idx = db.jobs.findIndex(j => j.id === req.params.id); + if (idx === -1) return res.status(404).json({ error: 'Job not found' }); + db.jobs.splice(idx, 1); + saveDb(db); + res.json({ ok: true }); +}); + +/** + * GET /api/status — System status (watch folder, output folder, job counts) + */ +app.get('/api/status', requireAuth, (req, res) => { + const db = loadDb(); + const currentWatchFolder = getActiveWatchFolder(); + const watchFolderExists = fs.existsSync(currentWatchFolder); + const outputFolderExists = fs.existsSync(OUTPUT_FOLDER); + + let watchFolderFiles = []; + let outputFolderFiles = []; + + if (watchFolderExists) { + try { + watchFolderFiles = fs.readdirSync(currentWatchFolder).filter(f => !f.startsWith('.')); + } catch {} + } + + if (outputFolderExists) { + try { + outputFolderFiles = fs.readdirSync(OUTPUT_FOLDER).filter(f => !f.startsWith('.')); + } catch {} + } + + const counts = { + queued: db.jobs.filter(j => j.status === 'queued').length, + encoding: db.jobs.filter(j => j.status === 'encoding').length, + complete: db.jobs.filter(j => j.status === 'complete').length, + error: db.jobs.filter(j => j.status === 'error').length, + total: db.jobs.length + }; + + // AME log stats + const activeAmeLogDir = getActiveAmeLogDir(); + const ameLogs = readAMELogs(activeAmeLogDir); + + res.json({ + watchFolder: { exists: watchFolderExists, path: currentWatchFolder, files: watchFolderFiles }, + outputFolder: { exists: outputFolderExists, path: OUTPUT_FOLDER, files: outputFolderFiles }, + counts, + ame: { + logDir: activeAmeLogDir, + logDirExists: fs.existsSync(activeAmeLogDir), + encodingLog: { + exists: ameLogs.encodingLog.exists, + lastModified: ameLogs.encodingLog.lastModified, + entryCount: ameLogs.encodingLog.entries.length + }, + errorLog: { + exists: ameLogs.errorLog.exists, + lastModified: ameLogs.errorLog.lastModified, + entryCount: ameLogs.errorLog.entries.length + }, + stats: ameLogs.stats + } + }); +}); + +/** + * GET /api/ame/logs — Full AME log data with recent entries + */ +app.get('/api/ame/logs', requireAuth, (req, res) => { + const ameLogs = readAMELogs(getActiveAmeLogDir()); + res.json(ameLogs); +}); + +// ─── Watch Folder Monitor ────────────────────────────────────────── + +/** + * Polls the watch folder and output folder to infer job status. + * - File in watch folder → queued (AME hasn't picked it up yet) + * - File disappeared from watch folder → encoding (AME is working on it) + * - New file in output folder → complete + * - File gone from watch folder + no output + timeout exceeded → error/stuck + */ +function pollFolders() { + const db = loadDb(); + let changed = false; + + // Get current watch folder contents + const currentWatchFolder = getActiveWatchFolder(); + let watchFiles = new Set(); + try { + if (fs.existsSync(currentWatchFolder)) { + watchFiles = new Set(fs.readdirSync(currentWatchFolder).filter(f => !f.startsWith('.'))); + } + } catch {} + + // Get current output folder contents + let outputFiles = []; + try { + if (fs.existsSync(OUTPUT_FOLDER)) { + outputFiles = fs.readdirSync(OUTPUT_FOLDER).filter(f => !f.startsWith('.')); + } + } catch {} + + for (const job of db.jobs) { + if (job.status === 'complete' || job.status === 'error') continue; + + const inWatchFolder = watchFiles.has(job.remappedFilename); + + if (job.status === 'queued' && !inWatchFolder) { + // File disappeared from watch folder — AME picked it up + job.status = 'encoding'; + job.statusUpdatedAt = new Date().toISOString(); + changed = true; + } + + if (job.status === 'encoding') { + // Check if any new output files appeared that match this job + // AME typically names output based on sequence name or project name + const jobBaseName = path.basename(job.remappedFilename, '.prproj').toLowerCase(); + const matchingOutputs = outputFiles.filter(f => + f.toLowerCase().includes(jobBaseName.split('_')[0]) // Match on original project name prefix + ); + + // Also check for any new files that appeared since the job started encoding + if (matchingOutputs.length > 0) { + job.status = 'complete'; + job.statusUpdatedAt = new Date().toISOString(); + job.outputFiles = matchingOutputs; + changed = true; + } else { + // Check for timeout + const elapsed = Date.now() - new Date(job.statusUpdatedAt).getTime(); + if (elapsed > JOB_TIMEOUT_MS) { + job.status = 'error'; + job.statusUpdatedAt = new Date().toISOString(); + job.error = `Job timed out after ${Math.round(JOB_TIMEOUT_MS / 60000)} minutes with no output detected`; + changed = true; + } + } + } + + if (job.status === 'queued') { + // Check if file has been sitting in watch folder too long + const elapsed = Date.now() - new Date(job.submittedAt).getTime(); + if (elapsed > JOB_TIMEOUT_MS && inWatchFolder) { + job.status = 'error'; + job.statusUpdatedAt = new Date().toISOString(); + job.error = 'AME did not pick up the file from the watch folder within the timeout period'; + changed = true; + } + } + } + + if (changed) { + saveDb(db); + } +} + +// ─── Start Server ────────────────────────────────────────────────── + +ensureDir(DATA_DIR); +ensureDir(UPLOAD_TEMP); + +app.listen(PORT, () => { + console.log(`AME Remote Job Manager running on port ${PORT}`); + console.log(` Watch folder: ${WATCH_FOLDER}`); + console.log(` Output folder: ${OUTPUT_FOLDER}`); + console.log(` AME log dir: ${AME_LOG_DIR}`); + console.log(` Data dir: ${DATA_DIR}`); + + // Start polling folders + setInterval(pollFolders, POLL_INTERVAL_MS); + console.log(` Polling every ${POLL_INTERVAL_MS / 1000}s`); + + // Start watching AME logs for real-time updates + if (fs.existsSync(AME_LOG_DIR)) { + watchAMELog(AME_LOG_DIR, ({ type, entries }) => { + console.log(`AME ${type} log updated: ${entries.length} new entries`); + for (const entry of entries) { + if (entry.sourceFile) { + console.log(` ${type}: ${entry.sourceFile} ${entry.encodingTime ? '(' + entry.encodingTime + ')' : ''}`); + } + } + }); + console.log(` Watching AME logs for changes`); + } else { + console.log(` AME log dir not found: ${AME_LOG_DIR} (mount it to enable AME stats)`); + } +});