Add server.js

This commit is contained in:
zgaetano 2026-03-31 15:29:49 -04:00
parent 47ad07366d
commit 8caaeaf13c

538
server.js Normal file
View file

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