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;