Add server.js
This commit is contained in:
parent
47ad07366d
commit
8caaeaf13c
1 changed files with 538 additions and 0 deletions
538
server.js
Normal file
538
server.js
Normal 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)`);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue