/** * 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)`); } });