fix(api.js): correct capture paths, bin routes, device normalisation, upload camelCase, session tracking

This commit is contained in:
Zac Gaetano 2026-05-16 00:31:58 -04:00
parent a9cc8caf42
commit 31ca999075

View file

@ -3,12 +3,16 @@
const API_BASE = '/api/v1'; const API_BASE = '/api/v1';
const CAPTURE_BASE = '/capture'; 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 * Wrapper around fetch with JSON parsing and error handling
*/ */
async function api(path, options = {}) { async function api(path, options = {}) {
const url = `${API_BASE}${path}`; const url = `${API_BASE}${path}`;
const config = { const config = {
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options.headers, ...options.headers,
@ -33,11 +37,12 @@ async function api(path, options = {}) {
} }
/** /**
* Wrapper around fetch for capture endpoints * Wrapper around fetch for capture endpoints (goes directly to capture service via nginx)
*/ */
async function captureApi(path, options = {}) { async function captureApi(path, options = {}) {
const url = `${CAPTURE_BASE}${path}`; const url = `${CAPTURE_BASE}${path}`;
const config = { const config = {
credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...options.headers, ...options.headers,
@ -65,32 +70,20 @@ async function captureApi(path, options = {}) {
// ASSET API CALLS // ASSET API CALLS
// ============================================================ // ============================================================
/**
* Get all assets with optional filtering
*/
async function getAssets(filters = {}) { async function getAssets(filters = {}) {
const params = new URLSearchParams(filters); const params = new URLSearchParams(filters);
const path = `/assets${params.toString() ? '?' + params.toString() : ''}`; const path = `/assets${params.toString() ? '?' + params.toString() : ''}`;
return api(path); return api(path);
} }
/**
* Get a single asset by ID
*/
async function getAsset(assetId) { async function getAsset(assetId) {
return api(`/assets/${assetId}`); return api(`/assets/${assetId}`);
} }
/**
* Get signed streaming URL for an asset
*/
async function getAssetStreamUrl(assetId) { async function getAssetStreamUrl(assetId) {
return api(`/assets/${assetId}/stream`); return api(`/assets/${assetId}/stream`);
} }
/**
* Update asset metadata (tags, notes, etc)
*/
async function updateAsset(assetId, data) { async function updateAsset(assetId, data) {
return api(`/assets/${assetId}`, { return api(`/assets/${assetId}`, {
method: 'PATCH', method: 'PATCH',
@ -98,41 +91,26 @@ async function updateAsset(assetId, data) {
}); });
} }
/**
* Search assets by query
*/
async function searchAssets(query) { async function searchAssets(query) {
return api(`/assets/search?q=${encodeURIComponent(query)}`); return api(`/assets?q=${encodeURIComponent(query)}`);
} }
/**
* Get asset status and metadata
*/
async function getAssetMetadata(assetId) { async function getAssetMetadata(assetId) {
return api(`/assets/${assetId}/metadata`); return api(`/assets/${assetId}`);
} }
// ============================================================ // ============================================================
// PROJECT API CALLS // PROJECT API CALLS
// ============================================================ // ============================================================
/**
* Get all projects
*/
async function getProjects() { async function getProjects() {
return api('/projects'); return api('/projects');
} }
/**
* Get a single project by ID
*/
async function getProject(projectId) { async function getProject(projectId) {
return api(`/projects/${projectId}`); return api(`/projects/${projectId}`);
} }
/**
* Create a new project
*/
async function createProject(name, description = '') { async function createProject(name, description = '') {
return api('/projects', { return api('/projects', {
method: 'POST', method: 'POST',
@ -140,9 +118,6 @@ async function createProject(name, description = '') {
}); });
} }
/**
* Update project metadata
*/
async function updateProject(projectId, data) { async function updateProject(projectId, data) {
return api(`/projects/${projectId}`, { return api(`/projects/${projectId}`, {
method: 'PATCH', method: 'PATCH',
@ -150,9 +125,6 @@ async function updateProject(projectId, data) {
}); });
} }
/**
* Delete a project
*/
async function deleteProject(projectId) { async function deleteProject(projectId) {
return api(`/projects/${projectId}`, { return api(`/projects/${projectId}`, {
method: 'DELETE', method: 'DELETE',
@ -161,181 +133,178 @@ async function deleteProject(projectId) {
// ============================================================ // ============================================================
// BIN API CALLS // BIN API CALLS
// Bins are mounted at /api/v1/bins with project_id as query param
// ============================================================ // ============================================================
/**
* Get all bins for a project
*/
async function getBins(projectId) { async function getBins(projectId) {
return api(`/projects/${projectId}/bins`); return api(`/bins?project_id=${projectId}`);
} }
/**
* Get a single bin by ID
*/
async function getBin(projectId, binId) { async function getBin(projectId, binId) {
return api(`/projects/${projectId}/bins/${binId}`); return api(`/bins/${binId}`);
} }
/**
* Create a new bin in a project
*/
async function createBin(projectId, name, description = '') { async function createBin(projectId, name, description = '') {
return api(`/projects/${projectId}/bins`, { return api('/bins', {
method: 'POST', method: 'POST',
body: JSON.stringify({ name, description }), body: JSON.stringify({ project_id: projectId, name }),
}); });
} }
/**
* Update bin metadata
*/
async function updateBin(projectId, binId, data) { async function updateBin(projectId, binId, data) {
return api(`/projects/${projectId}/bins/${binId}`, { return api(`/bins/${binId}`, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
} }
/**
* Delete a bin
*/
async function deleteBin(projectId, binId) { async function deleteBin(projectId, binId) {
return api(`/projects/${projectId}/bins/${binId}`, { return api(`/bins/${binId}`, {
method: 'DELETE', method: 'DELETE',
}); });
} }
// ============================================================ // ============================================================
// CAPTURE API CALLS // CAPTURE API CALLS
// Routes: GET /capture/devices, GET /capture/status,
// POST /capture/start, POST /capture/stop
// ============================================================ // ============================================================
/** /**
* Get list of available capture devices * Get list of available capture devices.
* Normalises capture service response ({index, name}) to {id, name, interface}.
*/ */
async function getCaptureDevices() { async function getCaptureDevices() {
return captureApi('/devices'); 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 */
* Get capture status
*/
async function getCaptureStatus() { async function getCaptureStatus() {
return captureApi('/status'); return captureApi('/status');
} }
/** /** Get current recording state (alias for getCaptureStatus) */
* Get current recording state
*/
async function getRecordingStatus() { async function getRecordingStatus() {
return captureApi('/recording/status'); return captureApi('/status');
} }
/** /**
* Start recording * Start recording.
* @param {number} deviceIndex - Device index from getCaptureDevices
* @param {string} projectId
* @param {string|null} binId
* @param {string} clipName
*/ */
async function startRecording(deviceId, projectId, binId, clipName) { async function startRecording(deviceIndex, projectId, binId, clipName) {
return captureApi('/recording/start', { const result = await captureApi('/start', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
device_id: deviceId, device: parseInt(deviceIndex, 10),
project_id: projectId, project_id: projectId,
bin_id: binId, bin_id: binId || null,
clip_name: clipName, clip_name: clipName,
}), }),
}); });
if (result.success && result.data && result.data.sessionId) {
_captureSessionId = result.data.sessionId;
}
return result;
} }
/** /**
* Stop recording * 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() { async function stopRecording() {
return captureApi('/recording/stop', { 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', method: 'POST',
body: JSON.stringify({ session_id: sessionId }),
}); });
if (result.success) {
_captureSessionId = null;
}
return result;
} }
/** /**
* Get recent capture sessions * Get recent captures uses assets API ordered by created_at desc.
*/ */
async function getRecentCaptures(limit = 10) { async function getRecentCaptures(limit = 10) {
return captureApi(`/sessions?limit=${limit}`); return api(`/assets?limit=${limit}`);
} }
/** /** Not available in current capture service — returns empty */
* Get timecode for current recording
*/
async function getRecordingTimecode() { async function getRecordingTimecode() {
return captureApi('/recording/timecode'); return { success: true, data: { timecode: null } };
} }
// ============================================================ // ============================================================
// UTILITY FUNCTIONS // UTILITY FUNCTIONS
// ============================================================ // ============================================================
/**
* Format bytes to human-readable size
*/
function formatFileSize(bytes) { function formatFileSize(bytes) {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
const k = 1024; const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB']; const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
} }
/**
* Format duration in seconds to HH:MM:SS
*/
function formatDuration(seconds) { function formatDuration(seconds) {
if (!seconds || seconds < 0) return '00:00:00'; if (!seconds || seconds < 0) return '00:00:00';
const hours = Math.floor(seconds / 3600); const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60); const secs = Math.floor(seconds % 60);
return [hours, minutes, secs] return [hours, minutes, secs].map(v => v.toString().padStart(2, '0')).join(':');
.map(v => v.toString().padStart(2, '0'))
.join(':');
} }
/**
* Get status badge text from status code
*/
function getStatusLabel(status) { function getStatusLabel(status) {
const labels = { const labels = {
ingesting: 'Ingesting', ingesting: 'Ingesting',
processing: 'Processing', processing: 'Processing',
ready: 'Ready', ready: 'Ready',
error: 'Error', error: 'Error',
archived: 'Archived', archived: 'Archived',
}; };
return labels[status] || status; return labels[status] || status;
} }
/**
* Get status badge CSS class from status code
*/
function getStatusBadgeClass(status) { function getStatusBadgeClass(status) {
return `badge-${status}`; return `badge-${status}`;
} }
/**
* Debounce function for search
*/
function debounce(func, wait) { function debounce(func, wait) {
let timeout; let timeout;
return function executedFunction(...args) { return function executedFunction(...args) {
const later = () => { const later = () => { clearTimeout(timeout); func(...args); };
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout); clearTimeout(timeout);
timeout = setTimeout(later, wait); timeout = setTimeout(later, wait);
}; };
} }
/**
* Throttle function for scroll/resize events
*/
function throttle(func, limit) { function throttle(func, limit) {
let inThrottle; let inThrottle;
return function(...args) { return function(...args) {
@ -349,35 +318,60 @@ function throttle(func, limit) {
// ============================================================ // ============================================================
// UPLOAD API CALLS // UPLOAD API CALLS
// Routes expect camelCase field names
// ============================================================ // ============================================================
/** /**
* Initialize a multipart upload * Initialize a multipart upload.
* Returns { assetId, uploadId, key }
*/ */
async function initUpload(data) { async function initUpload(data) {
return api('/upload/init', { method: 'POST', body: JSON.stringify(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 * Complete a multipart upload.
* @param {object} data - { uploadId, key, assetId, parts: [{partNumber, ETag}] }
*/ */
async function completeUpload(data) { async function completeUpload(data) {
return api('/upload/complete', { method: 'POST', body: JSON.stringify(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 */
* Abort an ongoing upload
*/
async function abortUpload(data) { async function abortUpload(data) {
return api('/upload/abort', { method: 'POST', body: JSON.stringify(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) */
* Upload a file part (FormData, no JSON content-type)
*/
async function uploadPart(formData) { async function uploadPart(formData) {
try { try {
const response = await fetch('/api/v1/upload/part', { method: 'POST', body: formData }); 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}`); if (!response.ok) throw new Error(`Upload part failed: ${response.status}`);
const data = await response.json(); const data = await response.json();
return { success: true, data }; return { success: true, data };
@ -386,12 +380,14 @@ async function uploadPart(formData) {
} }
} }
/** /** Simple upload for small files (under 50MB) */
* Simple upload for small files (under 50MB)
*/
async function simpleUpload(formData) { async function simpleUpload(formData) {
try { try {
const response = await fetch('/api/v1/upload/simple', { method: 'POST', body: formData }); const response = await fetch('/api/v1/upload/simple', {
method: 'POST',
credentials: 'include',
body: formData,
});
if (!response.ok) throw new Error(`Upload failed: ${response.status}`); if (!response.ok) throw new Error(`Upload failed: ${response.status}`);
const data = await response.json(); const data = await response.json();
return { success: true, data }; return { success: true, data };
@ -404,44 +400,26 @@ async function simpleUpload(formData) {
// RECORDER API CALLS // RECORDER API CALLS
// ============================================================ // ============================================================
/**
* Get all recorder instances
*/
async function getRecorders() { async function getRecorders() {
return api('/recorders'); return api('/recorders');
} }
/**
* Create a new recorder instance
*/
async function createRecorder(data) { async function createRecorder(data) {
return api('/recorders', { method: 'POST', body: JSON.stringify(data) }); return api('/recorders', { method: 'POST', body: JSON.stringify(data) });
} }
/**
* Start a recorder by ID
*/
async function startRecorder(id) { async function startRecorder(id) {
return api(`/recorders/${id}/start`, { method: 'POST' }); return api(`/recorders/${id}/start`, { method: 'POST' });
} }
/**
* Stop a recorder by ID
*/
async function stopRecorder(id) { async function stopRecorder(id) {
return api(`/recorders/${id}/stop`, { method: 'POST' }); return api(`/recorders/${id}/stop`, { method: 'POST' });
} }
/**
* Delete a recorder by ID
*/
async function deleteRecorder(id) { async function deleteRecorder(id) {
return api(`/recorders/${id}`, { method: 'DELETE' }); return api(`/recorders/${id}`, { method: 'DELETE' });
} }
/**
* Get live status for a recorder
*/
async function getRecorderStatus(id) { async function getRecorderStatus(id) {
return api(`/recorders/${id}/status`); return api(`/recorders/${id}/status`);
} }