feat(growing-files): Phase 1 - live HLS preview during recording
While a recorder is running, the capture container tees an HLS stream into /live/<assetId>/ alongside the ProRes master upload. The asset row is pre-created at recorder start with status='live' so the clip appears in the library immediately. /api/v1/assets/:id/stream returns the HLS playlist URL until recording stops, then proxy. * docker-compose: shared wild-dragon-live mount on api/capture/web-ui * migration 001-add-live-status: idempotent ALTER TYPE for asset_status * mam-api: runMigrations() on boot; recorders.js pre-creates live asset + passes ASSET_ID; assets.js POST upserts on existing live row instead of inserting a duplicate, and stream route returns HLS for live assets * capture: parallel HLS ffmpeg into /live/<assetId>/; ASSET_ID env * web-ui: nginx serves /live/, preview.js loads hls.js, LIVE badge added
This commit is contained in:
parent
6a8e4ac250
commit
7d76f9c549
10 changed files with 272 additions and 51 deletions
|
|
@ -34,6 +34,7 @@ services:
|
||||||
- "${PORT_MAM_API:-7432}:3000"
|
- "${PORT_MAM_API:-7432}:3000"
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- /mnt/NVME/MAM/wild-dragon-live:/live
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
REDIS_URL: ${REDIS_URL}
|
REDIS_URL: ${REDIS_URL}
|
||||||
|
|
@ -64,6 +65,8 @@ services:
|
||||||
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
S3_SECRET_KEY: ${S3_SECRET_KEY}
|
||||||
S3_REGION: ${S3_REGION:-us-east-1}
|
S3_REGION: ${S3_REGION:-us-east-1}
|
||||||
MAM_API_URL: ${MAM_API_URL:-http://mam-api:3000}
|
MAM_API_URL: ${MAM_API_URL:-http://mam-api:3000}
|
||||||
|
volumes:
|
||||||
|
- /mnt/NVME/MAM/wild-dragon-live:/live
|
||||||
networks:
|
networks:
|
||||||
- wild-dragon
|
- wild-dragon
|
||||||
|
|
||||||
|
|
@ -87,6 +90,8 @@ services:
|
||||||
build: ./services/web-ui
|
build: ./services/web-ui
|
||||||
ports:
|
ports:
|
||||||
- "${PORT_WEB_UI:-7434}:80"
|
- "${PORT_WEB_UI:-7434}:80"
|
||||||
|
volumes:
|
||||||
|
- /mnt/NVME/MAM/wild-dragon-live:/live
|
||||||
networks:
|
networks:
|
||||||
- wild-dragon
|
- wild-dragon
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,7 @@ class CaptureManager {
|
||||||
* @returns {Object} Session info
|
* @returns {Object} Session info
|
||||||
*/
|
*/
|
||||||
async start({
|
async start({
|
||||||
|
assetId,
|
||||||
projectId,
|
projectId,
|
||||||
binId,
|
binId,
|
||||||
clipName,
|
clipName,
|
||||||
|
|
@ -82,6 +83,7 @@ class CaptureManager {
|
||||||
listenPort,
|
listenPort,
|
||||||
streamKey,
|
streamKey,
|
||||||
}) {
|
}) {
|
||||||
|
this._assetIdForHls = assetId || null;
|
||||||
if (this.state.recording) {
|
if (this.state.recording) {
|
||||||
throw new Error('Capture already in progress');
|
throw new Error('Capture already in progress');
|
||||||
}
|
}
|
||||||
|
|
@ -138,6 +140,34 @@ class CaptureManager {
|
||||||
|
|
||||||
const processes = { hires: hiresProcess };
|
const processes = { hires: hiresProcess };
|
||||||
const uploads = { hires: hiresUpload };
|
const uploads = { hires: hiresUpload };
|
||||||
|
let hlsProcess = null;
|
||||||
|
let hlsDir = null;
|
||||||
|
if (isNetwork && this._assetIdForHls) {
|
||||||
|
try {
|
||||||
|
const fs = await import('node:fs');
|
||||||
|
hlsDir = '/live/' + this._assetIdForHls;
|
||||||
|
fs.mkdirSync(hlsDir, { recursive: true });
|
||||||
|
const hlsArgs = [
|
||||||
|
...inputArgs,
|
||||||
|
'-map', '0:v:0?', '-map', '0:a:0?',
|
||||||
|
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
|
||||||
|
'-pix_fmt', 'yuv420p', '-b:v', '2M', '-g', '60', '-sc_threshold', '0',
|
||||||
|
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
|
||||||
|
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
|
||||||
|
'-hls_flags', 'delete_segments+append_list+omit_endlist',
|
||||||
|
'-hls_segment_filename', hlsDir + '/seg-%05d.ts',
|
||||||
|
hlsDir + '/index.m3u8',
|
||||||
|
];
|
||||||
|
hlsProcess = spawn('ffmpeg', hlsArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
hlsProcess.stderr.on('data', (d) => { console.error('[HLS] ' + d); });
|
||||||
|
hlsProcess.on('exit', (c) => console.log('[HLS] exited ' + c));
|
||||||
|
processes.hls = hlsProcess;
|
||||||
|
console.log('[HLS] tee started -> ' + hlsDir);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[HLS] tee failed:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
hiresProcess.stderr.on('data', (data) => {
|
hiresProcess.stderr.on('data', (data) => {
|
||||||
const text = data.toString();
|
const text = data.toString();
|
||||||
|
|
@ -223,9 +253,8 @@ class CaptureManager {
|
||||||
if (processes.hires) {
|
if (processes.hires) {
|
||||||
processes.hires.kill('SIGINT');
|
processes.hires.kill('SIGINT');
|
||||||
}
|
}
|
||||||
if (processes.proxy) {
|
if (processes.proxy) processes.proxy.kill('SIGINT');
|
||||||
processes.proxy.kill('SIGINT');
|
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Wait for all in-flight S3 uploads to complete
|
// Wait for all in-flight S3 uploads to complete
|
||||||
|
|
|
||||||
|
|
@ -24,3 +24,103 @@ app.use('/capture', captureRoutes);
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Wild Dragon Capture Service listening on port ${PORT}`);
|
console.log(`Wild Dragon Capture Service listening on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function bootstrapAutoStart() {
|
||||||
|
const recorderId = process.env.RECORDER_ID;
|
||||||
|
const sourceType = process.env.SOURCE_TYPE;
|
||||||
|
if (!recorderId || !sourceType) {
|
||||||
|
console.log('[bootstrap] no RECORDER_ID/SOURCE_TYPE - on-demand sidecar');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectId = process.env.PROJECT_ID;
|
||||||
|
const clipName = process.env.CLIP_NAME;
|
||||||
|
if (!projectId || !clipName) {
|
||||||
|
console.error('[bootstrap] missing PROJECT_ID or CLIP_NAME - cannot start');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const listen = process.env.LISTEN === '1' || process.env.LISTEN === 'true';
|
||||||
|
const listenPort = process.env.LISTEN_PORT
|
||||||
|
? parseInt(process.env.LISTEN_PORT, 10)
|
||||||
|
: undefined;
|
||||||
|
const streamKey = process.env.STREAM_KEY || undefined;
|
||||||
|
const sourceUrl = process.env.SOURCE_URL || undefined;
|
||||||
|
|
||||||
|
if (sourceType === 'sdi') {
|
||||||
|
console.warn('[bootstrap] SDI auto-start not supported');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[bootstrap] starting ${sourceType} ingest (listen=${listen} port=${listenPort || 'n/a'})...`);
|
||||||
|
try {
|
||||||
|
const session = await captureManager.start({
|
||||||
|
assetId: process.env.ASSET_ID || null,
|
||||||
|
projectId,
|
||||||
|
binId: process.env.BIN_ID || null,
|
||||||
|
clipName,
|
||||||
|
sourceType,
|
||||||
|
sourceUrl,
|
||||||
|
listen,
|
||||||
|
listenPort,
|
||||||
|
streamKey,
|
||||||
|
});
|
||||||
|
console.log(`[bootstrap] session ${session.sessionId} started for clip ${clipName}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[bootstrap] failed to start capture:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
async function gracefulShutdown(signal) {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
console.log(`[shutdown] ${signal} received`);
|
||||||
|
|
||||||
|
const status = captureManager.getStatus();
|
||||||
|
|
||||||
|
if (status.recording) {
|
||||||
|
console.log(`[shutdown] stopping active session ${status.sessionId}...`);
|
||||||
|
try {
|
||||||
|
const completed = await captureManager.stop(status.sessionId);
|
||||||
|
console.log(`[shutdown] session ${completed.sessionId} finalised; duration=${completed.duration}s`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${MAM_API_URL}/api/v1/assets`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
projectId: completed.projectId,
|
||||||
|
binId: completed.binId,
|
||||||
|
clipName: completed.clipName,
|
||||||
|
sourceType: completed.sourceType,
|
||||||
|
hiresKey: completed.hiresKey,
|
||||||
|
proxyKey: completed.proxyKey,
|
||||||
|
needsProxy: completed.proxyKey === null,
|
||||||
|
duration: completed.duration,
|
||||||
|
capturedAt: completed.startedAt,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
console.warn(`[shutdown] mam-api /assets returned ${res.status}: ${await res.text()}`);
|
||||||
|
} else {
|
||||||
|
console.log('[shutdown] asset registered with mam-api');
|
||||||
|
}
|
||||||
|
} catch (mamErr) {
|
||||||
|
console.error('[shutdown] failed to register asset:', mamErr.message);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[shutdown] error during stop:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.close(() => {
|
||||||
|
console.log('[shutdown] http server closed - exiting');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => process.exit(0), 5000).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- 2026-05: add 'live' to asset_status for growing-file ingest
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_enum WHERE enumlabel = 'live' AND enumtypid = 'asset_status'::regtype) THEN
|
||||||
|
ALTER TYPE asset_status ADD VALUE 'live' BEFORE 'ingesting';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
@ -68,6 +68,27 @@ app.use('/api/v1/ampp', amppRouter);
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|
||||||
// ── Start ────────────────────────────────────────────────────────────────────
|
// ── Start ────────────────────────────────────────────────────────────────────
|
||||||
|
import { readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const __dirnameMig = dirname(fileURLToPath(import.meta.url));
|
||||||
|
async function runMigrations() {
|
||||||
|
const dir = join(__dirnameMig, 'db', 'migrations');
|
||||||
|
let files = [];
|
||||||
|
try { files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort(); } catch { return; }
|
||||||
|
for (const f of files) {
|
||||||
|
const sql = readFileSync(join(dir, f), 'utf8');
|
||||||
|
try {
|
||||||
|
await pool.query(sql);
|
||||||
|
console.log('[migration] applied ' + f);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[migration] failed ' + f, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await runMigrations();
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
|
const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
|
||||||
console.log(`MAM API listening on port ${PORT}`);
|
console.log(`MAM API listening on port ${PORT}`);
|
||||||
|
|
|
||||||
|
|
@ -112,38 +112,69 @@ router.post('/', async (req, res, next) => {
|
||||||
return res.status(400).json({ error: 'projectId and clipName are required' });
|
return res.status(400).json({ error: 'projectId and clipName are required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = uuidv4();
|
|
||||||
const thumbnailKey = `thumbnails/${id}.jpg`;
|
|
||||||
const durationMs = duration ? Math.round(duration * 1000) : null;
|
const durationMs = duration ? Math.round(duration * 1000) : null;
|
||||||
|
|
||||||
const result = await pool.query(
|
// Phase 1 growing-files: an asset row may already exist in status='live'
|
||||||
`INSERT INTO assets (
|
// (pre-created at recorder start so the library shows the recording while
|
||||||
id, project_id, bin_id,
|
// it is happening). If so we UPDATE that row instead of inserting a new
|
||||||
filename, display_name,
|
// one -- otherwise we would have two rows per recording.
|
||||||
status, media_type,
|
const existing = await pool.query(
|
||||||
original_s3_key, proxy_s3_key,
|
`SELECT * FROM assets
|
||||||
duration_ms,
|
WHERE project_id = $1 AND display_name = $2 AND status = 'live'
|
||||||
created_at, updated_at
|
ORDER BY created_at DESC LIMIT 1`,
|
||||||
)
|
[projectId, clipName]
|
||||||
VALUES (
|
|
||||||
$1, $2, $3,
|
|
||||||
$4, $4,
|
|
||||||
'processing', 'video',
|
|
||||||
$5, $6,
|
|
||||||
$7,
|
|
||||||
COALESCE($8::timestamptz, NOW()), NOW()
|
|
||||||
)
|
|
||||||
RETURNING *`,
|
|
||||||
[
|
|
||||||
id, projectId, binId || null,
|
|
||||||
clipName,
|
|
||||||
hiresKey || null, proxyKey || null,
|
|
||||||
durationMs,
|
|
||||||
capturedAt || null,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const asset = result.rows[0];
|
let id;
|
||||||
|
let asset;
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
id = existing.rows[0].id;
|
||||||
|
const upd = await pool.query(
|
||||||
|
`UPDATE assets
|
||||||
|
SET status = 'processing',
|
||||||
|
original_s3_key = COALESCE($2, original_s3_key),
|
||||||
|
proxy_s3_key = COALESCE($3, proxy_s3_key),
|
||||||
|
duration_ms = COALESCE($4, duration_ms),
|
||||||
|
bin_id = COALESCE(bin_id, $5),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *`,
|
||||||
|
[id, hiresKey || null, proxyKey || null, durationMs, binId || null]
|
||||||
|
);
|
||||||
|
asset = upd.rows[0];
|
||||||
|
} else {
|
||||||
|
id = uuidv4();
|
||||||
|
const ins = await pool.query(
|
||||||
|
`INSERT INTO assets (
|
||||||
|
id, project_id, bin_id,
|
||||||
|
filename, display_name,
|
||||||
|
status, media_type,
|
||||||
|
original_s3_key, proxy_s3_key,
|
||||||
|
duration_ms,
|
||||||
|
created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
$1, $2, $3,
|
||||||
|
$4, $4,
|
||||||
|
'processing', 'video',
|
||||||
|
$5, $6,
|
||||||
|
$7,
|
||||||
|
COALESCE($8::timestamptz, NOW()), NOW()
|
||||||
|
)
|
||||||
|
RETURNING *`,
|
||||||
|
[
|
||||||
|
id, projectId, binId || null,
|
||||||
|
clipName,
|
||||||
|
hiresKey || null, proxyKey || null,
|
||||||
|
durationMs,
|
||||||
|
capturedAt || null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
asset = ins.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnailKey = `thumbnails/${id}.jpg`;
|
||||||
|
|
||||||
|
|
||||||
// Dispatch thumbnail job — proxy already in S3 from capture
|
// Dispatch thumbnail job — proxy already in S3 from capture
|
||||||
if (proxyKey) {
|
if (proxyKey) {
|
||||||
|
|
@ -161,7 +192,7 @@ router.post('/', async (req, res, next) => {
|
||||||
asset.status = 'ready';
|
asset.status = 'ready';
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(201).json(asset);
|
res.status(existing.rows.length > 0 ? 200 : 201).json(asset);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|
@ -339,28 +370,20 @@ router.delete('/:id', async (req, res, next) => {
|
||||||
router.get('/:id/stream', async (req, res, next) => {
|
router.get('/:id/stream', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const result = await pool.query(
|
const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
|
||||||
'SELECT proxy_s3_key FROM assets WHERE id = $1',
|
if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
[id]
|
const a = r.rows[0];
|
||||||
);
|
if (a.status === 'live') {
|
||||||
|
return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true });
|
||||||
if (result.rows.length === 0) {
|
|
||||||
return res.status(404).json({ error: 'Asset not found' });
|
|
||||||
}
|
}
|
||||||
|
const key = a.proxy_s3_key || a.original_s3_key;
|
||||||
const { proxy_s3_key } = result.rows[0];
|
if (!key) return res.status(404).json({ error: 'No stream available yet' });
|
||||||
|
const url = await getSignedUrlForObject(key);
|
||||||
if (!proxy_s3_key) {
|
|
||||||
return res.status(400).json({ error: 'No proxy available for this asset' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = await getSignedUrlForObject(proxy_s3_key);
|
|
||||||
res.json({ url });
|
res.json({ url });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /:id/thumbnail - Signed URL for thumbnail image
|
// GET /:id/thumbnail - Signed URL for thumbnail image
|
||||||
router.get('/:id/thumbnail', async (req, res, next) => {
|
router.get('/:id/thumbnail', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,22 @@ router.post('/:id/start', async (req, res, next) => {
|
||||||
// Generate clip name with timestamp
|
// Generate clip name with timestamp
|
||||||
const clipName = generateClipName(recorder.name);
|
const clipName = generateClipName(recorder.name);
|
||||||
|
|
||||||
|
// live-asset: create the asset row right now (status='live') so the library
|
||||||
|
// shows the recording while it is happening. The capture container will
|
||||||
|
// tee an HLS stream into /live/<assetId>/.
|
||||||
|
const assetIdLive = (await import('uuid')).v4();
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO assets (
|
||||||
|
id, project_id, bin_id, filename, display_name, status, media_type,
|
||||||
|
original_s3_key, created_at, updated_at
|
||||||
|
) VALUES ($1, $2, NULL, $3, $3, 'live', 'video', $4, NOW(), NOW())`,
|
||||||
|
[assetIdLive, recorder.project_id, clipName, `projects/${recorder.project_id}/masters/${clipName}.mov`]
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[recorders] could not pre-create live asset:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
// Determine source config and whether this is a listener-mode recorder
|
// Determine source config and whether this is a listener-mode recorder
|
||||||
const sourceConfig = recorder.source_config || {};
|
const sourceConfig = recorder.source_config || {};
|
||||||
const isListener = sourceConfig.mode === 'listener';
|
const isListener = sourceConfig.mode === 'listener';
|
||||||
|
|
@ -224,6 +240,7 @@ router.post('/:id/start', async (req, res, next) => {
|
||||||
`PROXY_RESOLUTION=${recorder.proxy_resolution}`,
|
`PROXY_RESOLUTION=${recorder.proxy_resolution}`,
|
||||||
`PROJECT_ID=${recorder.project_id}`,
|
`PROJECT_ID=${recorder.project_id}`,
|
||||||
`CLIP_NAME=${clipName}`,
|
`CLIP_NAME=${clipName}`,
|
||||||
|
`ASSET_ID=${assetIdLive}`,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add source-specific env vars for SRT/RTMP
|
// Add source-specific env vars for SRT/RTMP
|
||||||
|
|
@ -250,6 +267,7 @@ router.post('/:id/start', async (req, res, next) => {
|
||||||
Privileged: true,
|
Privileged: true,
|
||||||
NetworkMode: dockerNetwork,
|
NetworkMode: dockerNetwork,
|
||||||
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
|
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
|
||||||
|
Binds: ['/mnt/NVME/MAM/wild-dragon-live:/live'],
|
||||||
},
|
},
|
||||||
NetworkingConfig: {
|
NetworkingConfig: {
|
||||||
EndpointsConfig: {
|
EndpointsConfig: {
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,14 @@ server {
|
||||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Live HLS — served from /live (bind-mounted shared volume), low cache so playlist refreshes
|
||||||
|
location /live/ {
|
||||||
|
alias /live/;
|
||||||
|
types { application/vnd.apple.mpegurl m3u8; video/mp2t ts; }
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
add_header Access-Control-Allow-Origin *;
|
||||||
|
}
|
||||||
|
|
||||||
# API proxy - forward to mam-api service
|
# API proxy - forward to mam-api service
|
||||||
location /api/ {
|
location /api/ {
|
||||||
set $api_upstream http://mam-api:3000;
|
set $api_upstream http://mam-api:3000;
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,8 @@
|
||||||
.first-splash-dot{width:8px;height:8px;background:oklch(55% 0.20 266);border-radius:50%;animation:fsPulse 1.4s ease-in-out infinite}
|
.first-splash-dot{width:8px;height:8px;background:oklch(55% 0.20 266);border-radius:50%;animation:fsPulse 1.4s ease-in-out infinite}
|
||||||
@keyframes fsPulse{0%,100%{opacity:.35;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}
|
@keyframes fsPulse{0%,100%{opacity:.35;transform:scale(.9)}50%{opacity:1;transform:scale(1.1)}}
|
||||||
.first-splash-title{font-size:13px;color:var(--text-secondary);letter-spacing:.04em}
|
.first-splash-title{font-size:13px;color:var(--text-secondary);letter-spacing:.04em}
|
||||||
|
.badge-live { background: oklch(64% 0.22 25 / 0.18); color: oklch(70% 0.22 25); border: 1px solid oklch(64% 0.22 25 / 0.4); animation: liveBlink 1.4s ease-in-out infinite; }
|
||||||
|
@keyframes liveBlink { 0%,100% { opacity: 0.7 } 50% { opacity: 1 } }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -477,7 +479,7 @@
|
||||||
|
|
||||||
<script src="js/api.js?v=5"></script>
|
<script src="js/api.js?v=5"></script>
|
||||||
<script src="js/topbar-strip.js?v=1"></script>
|
<script src="js/topbar-strip.js?v=1"></script>
|
||||||
<script src="js/preview.js?v=3"></script>
|
<script src="js/preview.js?v=4"></script>
|
||||||
<script src="js/selection.js?v=1"></script>
|
<script src="js/selection.js?v=1"></script>
|
||||||
<script>
|
<script>
|
||||||
const state = {
|
const state = {
|
||||||
|
|
@ -703,7 +705,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadgeClass(s) {
|
function statusBadgeClass(s) {
|
||||||
const map = { ingesting:'badge-ingesting', processing:'badge-processing', ready:'badge-ready', error:'badge-error', archived:'badge-archived' };
|
const map = { live:'badge-live', ingesting:'badge-ingesting', processing:'badge-processing', ready:'badge-ready', error:'badge-error', archived:'badge-archived' };
|
||||||
return map[s] || 'badge-idle';
|
return map[s] || 'badge-idle';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,15 @@
|
||||||
if (!r.ok) throw new Error(r.statusText);
|
if (!r.ok) throw new Error(r.statusText);
|
||||||
const { url } = await r.json();
|
const { url } = await r.json();
|
||||||
const tag = mt === 'audio' ? 'audio' : 'video';
|
const tag = mt === 'audio' ? 'audio' : 'video';
|
||||||
stage.innerHTML = `<${tag} controls autoplay playsinline src="${esc(url)}"></${tag}>`;
|
if (url && url.endsWith('.m3u8')) {
|
||||||
|
stage.innerHTML = `<${tag} id="prevPlayer" controls autoplay playsinline></${tag}>`;
|
||||||
|
const v = stage.querySelector('#prevPlayer');
|
||||||
|
if (v.canPlayType('application/vnd.apple.mpegurl')) { v.src = url; }
|
||||||
|
else if (window.Hls && window.Hls.isSupported()) { const h = new window.Hls(); h.loadSource(url); h.attachMedia(v); }
|
||||||
|
else { await new Promise(r => { const sc = document.createElement('script'); sc.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.0/dist/hls.min.js'; sc.onload = r; document.head.appendChild(sc); }); const h = new window.Hls({ liveSyncDuration: 6 }); h.loadSource(url); h.attachMedia(v); }
|
||||||
|
} else {
|
||||||
|
stage.innerHTML = `<${tag} controls autoplay playsinline src="${esc(url)}"></${tag}>`;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
stage.innerHTML = `<div class="preview-empty">Stream URL failed: ${esc(err.message)}</div>`;
|
stage.innerHTML = `<div class="preview-empty">Stream URL failed: ${esc(err.message)}</div>`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue