- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding with explicit 'applied to every ingested file' wording, expose CPU codec/preset options when GPU is off - New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api; BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast staging-only pending FFmpeg patches - mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete); Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk - proxy worker now reads proxy-encoding settings from DB on every job, builds args for libx264 / NVENC / VAAPI, falls back to libx264 on hardware-encode failure - settings GET /s3 falls back to S3_* env vars when DB is empty so the UI reflects what's actually wired (fixes 'not configured' false alarm)
328 lines
12 KiB
JavaScript
328 lines
12 KiB
JavaScript
import express from 'express';
|
|
import pool from '../db/pool.js';
|
|
import { requireAuth } from '../middleware/auth.js';
|
|
import { getAmppConfig } from '../ampp/client.js';
|
|
import { rebuildS3Client, buildTestClient, testS3Connection, getS3Bucket } from '../s3/client.js';
|
|
|
|
const router = express.Router();
|
|
router.use(requireAuth);
|
|
|
|
// ── S3 / Object Storage ───────────────────────────────────────────────────────
|
|
|
|
router.get('/s3', async (req, res, next) => {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT key, value FROM settings
|
|
WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')`
|
|
);
|
|
// Env defaults — surface what's actually wired so the UI doesn't show
|
|
// "not configured" when the container was started with S3_* env vars.
|
|
const envEndpoint = process.env.S3_ENDPOINT || '';
|
|
const envBucket = process.env.S3_BUCKET || getS3Bucket();
|
|
const envAccessKey = process.env.S3_ACCESS_KEY || '';
|
|
const envSecretKey = process.env.S3_SECRET_KEY || '';
|
|
const envRegion = process.env.S3_REGION || 'us-east-1';
|
|
|
|
const out = {
|
|
s3_endpoint: envEndpoint,
|
|
s3_bucket: envBucket,
|
|
s3_access_key: envAccessKey,
|
|
s3_secret_key_exists: !!envSecretKey,
|
|
s3_region: envRegion,
|
|
source: envEndpoint ? 'env' : 'unset',
|
|
};
|
|
for (const { key, value } of result.rows) {
|
|
if (key === 's3_secret_key') {
|
|
if (value) { out.s3_secret_key_exists = true; out.source = 'db'; }
|
|
} else if (value) {
|
|
out[key] = value;
|
|
out.source = 'db';
|
|
}
|
|
}
|
|
res.json(out);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
router.put('/s3', async (req, res, next) => {
|
|
try {
|
|
const { s3_endpoint, s3_bucket, s3_access_key, s3_secret_key, s3_region } = req.body;
|
|
|
|
if (!s3_endpoint) return res.status(400).json({ error: 's3_endpoint is required' });
|
|
if (!s3_bucket) return res.status(400).json({ error: 's3_bucket is required' });
|
|
|
|
const keys = { s3_endpoint, s3_bucket, s3_region: s3_region || 'us-east-1' };
|
|
if (s3_access_key) keys.s3_access_key = s3_access_key;
|
|
if (s3_secret_key) keys.s3_secret_key = s3_secret_key;
|
|
|
|
for (const [key, value] of Object.entries(keys)) {
|
|
await pool.query(
|
|
`INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
|
|
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
|
|
[key, value.trim()]
|
|
);
|
|
}
|
|
|
|
// Fetch the full current config (including any previously saved secret)
|
|
const saved = await pool.query(
|
|
`SELECT key, value FROM settings
|
|
WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')
|
|
AND value IS NOT NULL AND value <> ''`
|
|
);
|
|
const cfg = {};
|
|
for (const { key, value } of saved.rows) {
|
|
switch (key) {
|
|
case 's3_endpoint': cfg.endpoint = value; break;
|
|
case 's3_bucket': cfg.bucket = value; break;
|
|
case 's3_access_key': cfg.accessKey = value; break;
|
|
case 's3_secret_key': cfg.secretKey = value; break;
|
|
case 's3_region': cfg.region = value; break;
|
|
}
|
|
}
|
|
rebuildS3Client(cfg);
|
|
|
|
res.json({ message: 'S3 settings saved and applied' });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// Test with the values the browser just typed (before saving), or with saved
|
|
// creds if the fields are left blank. Secret is sent only if user typed it.
|
|
router.post('/s3/test', async (req, res, next) => {
|
|
try {
|
|
const { s3_endpoint, s3_bucket, s3_access_key, s3_secret_key, s3_region } = req.body;
|
|
|
|
// Merge submitted values with anything already saved in the DB
|
|
const saved = await pool.query(
|
|
`SELECT key, value FROM settings
|
|
WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region')
|
|
AND value IS NOT NULL AND value <> ''`
|
|
);
|
|
const fromDb = {};
|
|
for (const { key, value } of saved.rows) fromDb[key] = value;
|
|
|
|
const cfg = {
|
|
endpoint: (s3_endpoint || fromDb.s3_endpoint || '').trim(),
|
|
bucket: (s3_bucket || fromDb.s3_bucket || getS3Bucket()).trim(),
|
|
accessKey: (s3_access_key || fromDb.s3_access_key || '').trim(),
|
|
secretKey: (s3_secret_key || fromDb.s3_secret_key || '').trim(),
|
|
region: (s3_region || fromDb.s3_region || 'us-east-1').trim(),
|
|
};
|
|
|
|
if (!cfg.endpoint) return res.status(400).json({ error: 'S3 endpoint is required' });
|
|
if (!cfg.bucket) return res.status(400).json({ error: 'S3 bucket is required' });
|
|
|
|
const client = buildTestClient(cfg);
|
|
const result = await testS3Connection(client, cfg.bucket);
|
|
res.json(result);
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── AMPP integration ───────────────────────────────────────────────────────────
|
|
|
|
router.get('/ampp', async (req, res, next) => {
|
|
try {
|
|
const result = await pool.query(
|
|
"SELECT key, value FROM settings WHERE key IN ('ampp_base_url', 'ampp_token')"
|
|
);
|
|
const out = {};
|
|
for (const row of result.rows) {
|
|
if (row.key === 'ampp_token') {
|
|
out.ampp_token_exists = true;
|
|
} else {
|
|
out[row.key] = row.value;
|
|
}
|
|
}
|
|
res.json(out);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
router.put('/ampp', async (req, res, next) => {
|
|
try {
|
|
const { ampp_base_url, ampp_token } = req.body;
|
|
if (!ampp_base_url) return res.status(400).json({ error: 'ampp_base_url is required' });
|
|
const baseUrl = ampp_base_url.trim().replace(/\/$/, '');
|
|
await pool.query(
|
|
`INSERT INTO settings (key, value, updated_at) VALUES ('ampp_base_url', $1, NOW())
|
|
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
|
|
[baseUrl]
|
|
);
|
|
if (ampp_token) {
|
|
await pool.query(
|
|
`INSERT INTO settings (key, value, updated_at) VALUES ('ampp_token', $1, NOW())
|
|
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
|
|
[ampp_token.trim()]
|
|
);
|
|
}
|
|
res.json({ message: 'AMPP settings saved' });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
router.post('/ampp/test', async (req, res, next) => {
|
|
try {
|
|
const config = await getAmppConfig();
|
|
if (!config) return res.status(400).json({ error: 'AMPP credentials not configured' });
|
|
const testUrl = `${config.ampp_base_url}/api/v1/store/folder/folders?limit=1`;
|
|
const testRes = await fetch(testUrl, {
|
|
headers: { Authorization: `Bearer ${config.ampp_token}`, 'Content-Type': 'application/json' },
|
|
});
|
|
if (!testRes.ok) return res.status(400).json({ error: `AMPP returned HTTP ${testRes.status}` });
|
|
res.json({ message: 'AMPP connection successful' });
|
|
} catch (err) {
|
|
res.status(400).json({ error: `Connection failed: ${err.message}` });
|
|
}
|
|
});
|
|
|
|
// ── Hardware inventory ────────────────────────────────────────────────────────
|
|
|
|
router.get('/hardware', async (req, res, next) => {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT id, hostname, role, ip_address, capabilities,
|
|
EXTRACT(EPOCH FROM (NOW() - last_seen))::int AS stale_seconds
|
|
FROM cluster_nodes
|
|
ORDER BY role, hostname`
|
|
);
|
|
const nodes = result.rows.map(n => ({
|
|
id: n.id,
|
|
hostname: n.hostname,
|
|
role: n.role,
|
|
ip_address: n.ip_address,
|
|
online: n.stale_seconds < 120,
|
|
capabilities: n.capabilities || {},
|
|
}));
|
|
res.json({ nodes });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// ── GPU / Transcoding ─────────────────────────────────────────────────────────
|
|
|
|
router.get('/transcoding', async (req, res, next) => {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT key, value FROM settings
|
|
WHERE key IN ('gpu_transcode_enabled','gpu_codec','gpu_preset','gpu_bitrate_mbps','gpu_node',
|
|
'gpu_extension','gpu_framerate','gpu_rc_mode','gpu_audio_codec','gpu_audio_bitrate_kbps')`
|
|
);
|
|
const out = {
|
|
gpu_transcode_enabled: 'false',
|
|
gpu_codec: 'h264_nvenc',
|
|
gpu_preset: 'p4',
|
|
gpu_bitrate_mbps: '8',
|
|
gpu_node: '',
|
|
gpu_extension: 'mp4',
|
|
gpu_framerate: 'passthrough',
|
|
gpu_rc_mode: 'cbr',
|
|
gpu_audio_codec: 'aac',
|
|
gpu_audio_bitrate_kbps: '192',
|
|
};
|
|
for (const { key, value } of result.rows) out[key] = value;
|
|
res.json(out);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
router.put('/transcoding', async (req, res, next) => {
|
|
try {
|
|
const allowed = [
|
|
'gpu_transcode_enabled', 'gpu_codec', 'gpu_preset', 'gpu_bitrate_mbps', 'gpu_node',
|
|
'gpu_extension', 'gpu_framerate', 'gpu_rc_mode', 'gpu_audio_codec', 'gpu_audio_bitrate_kbps',
|
|
];
|
|
for (const key of allowed) {
|
|
if (req.body[key] !== undefined) {
|
|
await pool.query(
|
|
`INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
|
|
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
|
|
[key, String(req.body[key])]
|
|
);
|
|
}
|
|
}
|
|
res.json({ message: 'Transcoding settings saved' });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// ── Growing files (SMB landing zone) ─────────────────────────────────────────
|
|
// Lets capture write its master output to a fast local SMB share instead of
|
|
// streaming directly to S3. Premiere can mount the share and edit the file
|
|
// while it's still being written; the promotion worker later moves the
|
|
// finalized file to S3 and flips the asset to status='ready'.
|
|
|
|
const GROWING_KEYS = ['growing_enabled', 'growing_path', 'growing_smb_url', 'growing_promote_after_seconds'];
|
|
|
|
router.get('/growing', async (req, res, next) => {
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT key, value FROM settings WHERE key = ANY($1)`,
|
|
[GROWING_KEYS]
|
|
);
|
|
const out = {
|
|
growing_enabled: 'false',
|
|
growing_path: '/growing',
|
|
growing_smb_url: '',
|
|
growing_promote_after_seconds: '8',
|
|
};
|
|
for (const { key, value } of result.rows) out[key] = value;
|
|
res.json(out);
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
router.put('/growing', async (req, res, next) => {
|
|
try {
|
|
for (const key of GROWING_KEYS) {
|
|
if (req.body[key] !== undefined) {
|
|
await pool.query(
|
|
`INSERT INTO settings (key, value, updated_at) VALUES ($1, $2, NOW())
|
|
ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()`,
|
|
[key, String(req.body[key])]
|
|
);
|
|
}
|
|
}
|
|
res.json({ message: 'Growing-files settings saved' });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// ── Capture service routing ───────────────────────────────────────────────────
|
|
|
|
router.get('/capture-service', async (req, res, next) => {
|
|
try {
|
|
const result = await pool.query(
|
|
"SELECT value FROM settings WHERE key = 'capture_service_url'"
|
|
);
|
|
res.json({ capture_service_url: result.rows[0]?.value || '' });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
router.put('/capture-service', async (req, res, next) => {
|
|
try {
|
|
const url = (req.body.capture_service_url || '').trim().replace(/\/$/, '');
|
|
await pool.query(
|
|
`INSERT INTO settings (key, value, updated_at) VALUES ('capture_service_url', $1, NOW())
|
|
ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()`,
|
|
[url]
|
|
);
|
|
res.json({ message: 'Capture service URL saved' });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
export default router;
|