Three problems blocked the end-to-end flow:
1) Library always rendered empty because /assets returns {assets,total} but
index.html (and capture.html) assumed r.data was an array. Fixed in
api.js by unwrapping r.data.assets centrally; total is kept on r.total.
2) SRT/RTMP caller mode pulled audio only. ffmpeg opened the network input
before the H264 SPS arrived, marked the video stream as pix_fmt=none,
and silently dropped it from the stream map. Added -probesize 32M
-analyzeduration 10M -fflags +genpts and explicit -map 0✌️0?/0🅰️0? so
each track survives independently of when it appears.
3) Hitting Record gave no feedback about whether a stream was actually
arriving. capture-manager now parses ffmpeg progress lines (frame=...
fps=...) and tracks framesReceived, currentFps, lastFrameAt, lastError.
getStatus() returns a derived signal enum (connecting | receiving |
lost | error | stopped). The recorder controller gives each spawned
container a stable network alias `recorder-<id>` and the GET
/recorders/:id/status endpoint proxies the live capture status through.
recorders.html polls that every 2s and renders the badge under each
active card with the running frame/fps counter or the ffmpeg error.
Also:
* recorders.html: dropped the listener-mode UI entirely. All new recorders
are caller-mode (pull). The MAM is no longer offered as an RTMP/SRT
server. Legacy listener records still render but read-only.
437 lines
12 KiB
JavaScript
437 lines
12 KiB
JavaScript
// Wild Dragon MAM - API Helper Module
|
|
|
|
const API_BASE = '/api/v1';
|
|
const CAPTURE_BASE = '/capture';
|
|
|
|
// Session ID for the active capture — set on start, cleared on stop
|
|
let _captureSessionId = null;
|
|
|
|
/**
|
|
* Wrapper around fetch with JSON parsing and error handling
|
|
*/
|
|
async function api(path, options = {}) {
|
|
const url = `${API_BASE}${path}`;
|
|
const config = {
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...options.headers,
|
|
},
|
|
...options,
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(url, config);
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ message: response.statusText }));
|
|
throw new Error(error.message || `API Error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return { success: true, data };
|
|
} catch (error) {
|
|
console.error(`API Error on ${path}:`, error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wrapper around fetch for capture endpoints (goes directly to capture service via nginx)
|
|
*/
|
|
async function captureApi(path, options = {}) {
|
|
const url = `${CAPTURE_BASE}${path}`;
|
|
const config = {
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
...options.headers,
|
|
},
|
|
...options,
|
|
};
|
|
|
|
try {
|
|
const response = await fetch(url, config);
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json().catch(() => ({ message: response.statusText }));
|
|
throw new Error(error.message || `Capture API Error: ${response.status}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
return { success: true, data };
|
|
} catch (error) {
|
|
console.error(`Capture API Error on ${path}:`, error);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// ASSET API CALLS
|
|
// ============================================================
|
|
|
|
async function getAssets(filters = {}) {
|
|
const params = new URLSearchParams(filters);
|
|
const path = `/assets${params.toString() ? '?' + params.toString() : ''}`;
|
|
const r = await api(path);
|
|
// API returns { assets, total }. Callers expect r.data to be the array,
|
|
// so unwrap here — but preserve total as a sibling for any caller that wants it.
|
|
if (r.success && r.data && typeof r.data === 'object' && Array.isArray(r.data.assets)) {
|
|
r.total = r.data.total;
|
|
r.data = r.data.assets;
|
|
}
|
|
return r;
|
|
}
|
|
|
|
async function getAsset(assetId) {
|
|
return api(`/assets/${assetId}`);
|
|
}
|
|
|
|
async function getAssetStreamUrl(assetId) {
|
|
return api(`/assets/${assetId}/stream`);
|
|
}
|
|
|
|
async function updateAsset(assetId, data) {
|
|
return api(`/assets/${assetId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async function searchAssets(query) {
|
|
return api(`/assets?q=${encodeURIComponent(query)}`);
|
|
}
|
|
|
|
async function getAssetMetadata(assetId) {
|
|
return api(`/assets/${assetId}`);
|
|
}
|
|
|
|
// ============================================================
|
|
// PROJECT API CALLS
|
|
// ============================================================
|
|
|
|
async function getProjects() {
|
|
return api('/projects');
|
|
}
|
|
|
|
async function getProject(projectId) {
|
|
return api(`/projects/${projectId}`);
|
|
}
|
|
|
|
async function createProject(name, description = '') {
|
|
return api('/projects', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ name, description }),
|
|
});
|
|
}
|
|
|
|
async function updateProject(projectId, data) {
|
|
return api(`/projects/${projectId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async function deleteProject(projectId) {
|
|
return api(`/projects/${projectId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// BIN API CALLS
|
|
// Bins are mounted at /api/v1/bins with project_id as query param
|
|
// ============================================================
|
|
|
|
async function getBins(projectId) {
|
|
return api(`/bins?project_id=${projectId}`);
|
|
}
|
|
|
|
async function getBin(projectId, binId) {
|
|
return api(`/bins/${binId}`);
|
|
}
|
|
|
|
async function createBin(projectId, name, description = '') {
|
|
return api('/bins', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ project_id: projectId, name }),
|
|
});
|
|
}
|
|
|
|
async function updateBin(projectId, binId, data) {
|
|
return api(`/bins/${binId}`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify(data),
|
|
});
|
|
}
|
|
|
|
async function deleteBin(projectId, binId) {
|
|
return api(`/bins/${binId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
}
|
|
|
|
// ============================================================
|
|
// CAPTURE API CALLS
|
|
// Routes: GET /capture/devices, GET /capture/status,
|
|
// POST /capture/start, POST /capture/stop
|
|
// ============================================================
|
|
|
|
/**
|
|
* Get list of available capture devices.
|
|
* Normalises capture service response ({index, name}) to {id, name, interface}.
|
|
*/
|
|
async function getCaptureDevices() {
|
|
const result = await captureApi('/devices');
|
|
if (result.success && result.data) {
|
|
const raw = Array.isArray(result.data)
|
|
? result.data
|
|
: (result.data.devices || []);
|
|
result.data = raw.map(d => ({
|
|
id: d.index !== undefined ? d.index : d.id,
|
|
name: d.name,
|
|
interface: d.interface || 'DeckLink',
|
|
}));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Get overall capture service status */
|
|
async function getCaptureStatus() {
|
|
return captureApi('/status');
|
|
}
|
|
|
|
/** Get current recording state (alias for getCaptureStatus) */
|
|
async function getRecordingStatus() {
|
|
return captureApi('/status');
|
|
}
|
|
|
|
/**
|
|
* Start recording.
|
|
* @param {number} deviceIndex - Device index from getCaptureDevices
|
|
* @param {string} projectId
|
|
* @param {string|null} binId
|
|
* @param {string} clipName
|
|
*/
|
|
async function startRecording(deviceIndex, projectId, binId, clipName) {
|
|
const result = await captureApi('/start', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
device: parseInt(deviceIndex, 10),
|
|
project_id: projectId,
|
|
bin_id: binId || null,
|
|
clip_name: clipName,
|
|
}),
|
|
});
|
|
if (result.success && result.data && result.data.sessionId) {
|
|
_captureSessionId = result.data.sessionId;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Stop the active recording.
|
|
* Uses the session ID stored from the most recent startRecording call,
|
|
* falling back to the current status if no local state exists.
|
|
*/
|
|
async function stopRecording() {
|
|
let sessionId = _captureSessionId;
|
|
|
|
if (!sessionId) {
|
|
// Try to recover session_id from live status
|
|
const statusResult = await captureApi('/status');
|
|
if (statusResult.success && statusResult.data && statusResult.data.sessionId) {
|
|
sessionId = statusResult.data.sessionId;
|
|
}
|
|
}
|
|
|
|
const result = await captureApi('/stop', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ session_id: sessionId }),
|
|
});
|
|
|
|
if (result.success) {
|
|
_captureSessionId = null;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get recent captures — uses assets API ordered by created_at desc.
|
|
*/
|
|
async function getRecentCaptures(limit = 10) {
|
|
const r = await api(`/assets?limit=${limit}`);
|
|
if (r.success && r.data && typeof r.data === 'object' && Array.isArray(r.data.assets)) {
|
|
r.total = r.data.total;
|
|
r.data = r.data.assets;
|
|
}
|
|
return r;
|
|
}
|
|
|
|
/** Not available in current capture service — returns empty */
|
|
async function getRecordingTimecode() {
|
|
return { success: true, data: { timecode: null } };
|
|
}
|
|
|
|
// ============================================================
|
|
// UTILITY FUNCTIONS
|
|
// ============================================================
|
|
|
|
function formatFileSize(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
|
|
}
|
|
|
|
function formatDuration(seconds) {
|
|
if (!seconds || seconds < 0) return '00:00:00';
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return [hours, minutes, secs].map(v => v.toString().padStart(2, '0')).join(':');
|
|
}
|
|
|
|
function getStatusLabel(status) {
|
|
const labels = {
|
|
ingesting: 'Ingesting',
|
|
processing: 'Processing',
|
|
ready: 'Ready',
|
|
error: 'Error',
|
|
archived: 'Archived',
|
|
};
|
|
return labels[status] || status;
|
|
}
|
|
|
|
function getStatusBadgeClass(status) {
|
|
return `badge-${status}`;
|
|
}
|
|
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => { clearTimeout(timeout); func(...args); };
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
function throttle(func, limit) {
|
|
let inThrottle;
|
|
return function(...args) {
|
|
if (!inThrottle) {
|
|
func.apply(this, args);
|
|
inThrottle = true;
|
|
setTimeout(() => inThrottle = false, limit);
|
|
}
|
|
};
|
|
}
|
|
|
|
// ============================================================
|
|
// UPLOAD API CALLS
|
|
// Routes expect camelCase field names
|
|
// ============================================================
|
|
|
|
/**
|
|
* Initialize a multipart upload.
|
|
* Returns { assetId, uploadId, key }
|
|
*/
|
|
async function initUpload(data) {
|
|
const body = {
|
|
filename: data.filename,
|
|
fileSize: data.file_size || data.fileSize,
|
|
contentType: data.content_type || data.contentType,
|
|
projectId: data.project_id || data.projectId,
|
|
binId: data.bin_id || data.binId || null,
|
|
tags: data.tags || null,
|
|
};
|
|
return api('/upload/init', { method: 'POST', body: JSON.stringify(body) });
|
|
}
|
|
|
|
/**
|
|
* Complete a multipart upload.
|
|
* @param {object} data - { uploadId, key, assetId, parts: [{partNumber, ETag}] }
|
|
*/
|
|
async function completeUpload(data) {
|
|
const body = {
|
|
uploadId: data.upload_id || data.uploadId,
|
|
key: data.key,
|
|
assetId: data.asset_id || data.assetId,
|
|
parts: (data.parts || []).map(p => ({
|
|
partNumber: p.part_number || p.partNumber,
|
|
ETag: p.etag || p.ETag,
|
|
})),
|
|
};
|
|
return api('/upload/complete', { method: 'POST', body: JSON.stringify(body) });
|
|
}
|
|
|
|
/** Abort an ongoing multipart upload */
|
|
async function abortUpload(data) {
|
|
const body = {
|
|
uploadId: data.upload_id || data.uploadId,
|
|
key: data.key,
|
|
assetId: data.asset_id || data.assetId,
|
|
};
|
|
return api('/upload/abort', { method: 'POST', body: JSON.stringify(body) });
|
|
}
|
|
|
|
/** Upload a file part (FormData, no JSON content-type) */
|
|
async function uploadPart(formData) {
|
|
try {
|
|
const response = await fetch('/api/v1/upload/part', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: formData,
|
|
});
|
|
if (!response.ok) throw new Error(`Upload part failed: ${response.status}`);
|
|
const data = await response.json();
|
|
return { success: true, data };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
/** Simple upload for small files (under 50MB) */
|
|
async function simpleUpload(formData) {
|
|
try {
|
|
const response = await fetch('/api/v1/upload/simple', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
body: formData,
|
|
});
|
|
if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
|
|
const data = await response.json();
|
|
return { success: true, data };
|
|
} catch (error) {
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// RECORDER API CALLS
|
|
// ============================================================
|
|
|
|
async function getRecorders() {
|
|
return api('/recorders');
|
|
}
|
|
|
|
async function createRecorder(data) {
|
|
return api('/recorders', { method: 'POST', body: JSON.stringify(data) });
|
|
}
|
|
|
|
async function startRecorder(id) {
|
|
return api(`/recorders/${id}/start`, { method: 'POST' });
|
|
}
|
|
|
|
async function stopRecorder(id) {
|
|
return api(`/recorders/${id}/stop`, { method: 'POST' });
|
|
}
|
|
|
|
async function deleteRecorder(id) {
|
|
return api(`/recorders/${id}`, { method: 'DELETE' });
|
|
}
|
|
|
|
async function getRecorderStatus(id) {
|
|
return api(`/recorders/${id}/status`);
|
|
}
|