From 31ca9990757a4d5056cbaf1adc27a1ba594bef85 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Sat, 16 May 2026 00:31:58 -0400 Subject: [PATCH] fix(api.js): correct capture paths, bin routes, device normalisation, upload camelCase, session tracking --- services/web-ui/public/js/api.js | 270 ++++++++++++++----------------- 1 file changed, 124 insertions(+), 146 deletions(-) diff --git a/services/web-ui/public/js/api.js b/services/web-ui/public/js/api.js index d5747e9..619c09f 100644 --- a/services/web-ui/public/js/api.js +++ b/services/web-ui/public/js/api.js @@ -3,12 +3,16 @@ 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, @@ -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 = {}) { const url = `${CAPTURE_BASE}${path}`; const config = { + credentials: 'include', headers: { 'Content-Type': 'application/json', ...options.headers, @@ -65,32 +70,20 @@ async function captureApi(path, options = {}) { // ASSET API CALLS // ============================================================ -/** - * Get all assets with optional filtering - */ async function getAssets(filters = {}) { const params = new URLSearchParams(filters); const path = `/assets${params.toString() ? '?' + params.toString() : ''}`; return api(path); } -/** - * Get a single asset by ID - */ async function getAsset(assetId) { return api(`/assets/${assetId}`); } -/** - * Get signed streaming URL for an asset - */ async function getAssetStreamUrl(assetId) { return api(`/assets/${assetId}/stream`); } -/** - * Update asset metadata (tags, notes, etc) - */ async function updateAsset(assetId, data) { return api(`/assets/${assetId}`, { method: 'PATCH', @@ -98,41 +91,26 @@ async function updateAsset(assetId, data) { }); } -/** - * Search assets by 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) { - return api(`/assets/${assetId}/metadata`); + return api(`/assets/${assetId}`); } // ============================================================ // PROJECT API CALLS // ============================================================ -/** - * Get all projects - */ async function getProjects() { return api('/projects'); } -/** - * Get a single project by ID - */ async function getProject(projectId) { return api(`/projects/${projectId}`); } -/** - * Create a new project - */ async function createProject(name, description = '') { return api('/projects', { method: 'POST', @@ -140,9 +118,6 @@ async function createProject(name, description = '') { }); } -/** - * Update project metadata - */ async function updateProject(projectId, data) { return api(`/projects/${projectId}`, { method: 'PATCH', @@ -150,9 +125,6 @@ async function updateProject(projectId, data) { }); } -/** - * Delete a project - */ async function deleteProject(projectId) { return api(`/projects/${projectId}`, { method: 'DELETE', @@ -161,181 +133,178 @@ async function deleteProject(projectId) { // ============================================================ // 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) { - return api(`/projects/${projectId}/bins`); + return api(`/bins?project_id=${projectId}`); } -/** - * Get a single bin by ID - */ 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 = '') { - return api(`/projects/${projectId}/bins`, { + return api('/bins', { method: 'POST', - body: JSON.stringify({ name, description }), + body: JSON.stringify({ project_id: projectId, name }), }); } -/** - * Update bin metadata - */ async function updateBin(projectId, binId, data) { - return api(`/projects/${projectId}/bins/${binId}`, { + return api(`/bins/${binId}`, { method: 'PATCH', body: JSON.stringify(data), }); } -/** - * Delete a bin - */ async function deleteBin(projectId, binId) { - return api(`/projects/${projectId}/bins/${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 + * Get list of available capture devices. + * Normalises capture service response ({index, name}) to {id, name, interface}. */ 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 capture status - */ +/** Get overall capture service status */ async function getCaptureStatus() { return captureApi('/status'); } -/** - * Get current recording state - */ +/** Get current recording state (alias for getCaptureStatus) */ 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) { - return captureApi('/recording/start', { +async function startRecording(deviceIndex, projectId, binId, clipName) { + const result = await captureApi('/start', { method: 'POST', body: JSON.stringify({ - device_id: deviceId, + device: parseInt(deviceIndex, 10), project_id: projectId, - bin_id: binId, - clip_name: clipName, + bin_id: binId || null, + 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() { - 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', + 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) { - return captureApi(`/sessions?limit=${limit}`); + return api(`/assets?limit=${limit}`); } -/** - * Get timecode for current recording - */ +/** Not available in current capture service — returns empty */ async function getRecordingTimecode() { - return captureApi('/recording/timecode'); + return { success: true, data: { timecode: null } }; } // ============================================================ // UTILITY FUNCTIONS // ============================================================ -/** - * Format bytes to human-readable size - */ function formatFileSize(bytes) { if (bytes === 0) return '0 B'; 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)); return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; } -/** - * Format duration in seconds to HH:MM:SS - */ function formatDuration(seconds) { 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 secs = Math.floor(seconds % 60); - return [hours, minutes, secs] - .map(v => v.toString().padStart(2, '0')) - .join(':'); + const secs = Math.floor(seconds % 60); + return [hours, minutes, secs].map(v => v.toString().padStart(2, '0')).join(':'); } -/** - * Get status badge text from status code - */ function getStatusLabel(status) { const labels = { - ingesting: 'Ingesting', + ingesting: 'Ingesting', processing: 'Processing', - ready: 'Ready', - error: 'Error', - archived: 'Archived', + ready: 'Ready', + error: 'Error', + archived: 'Archived', }; return labels[status] || status; } -/** - * Get status badge CSS class from status code - */ function getStatusBadgeClass(status) { return `badge-${status}`; } -/** - * Debounce function for search - */ function debounce(func, wait) { let timeout; return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; + const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } -/** - * Throttle function for scroll/resize events - */ function throttle(func, limit) { let inThrottle; return function(...args) { @@ -349,35 +318,60 @@ function throttle(func, limit) { // ============================================================ // UPLOAD API CALLS +// Routes expect camelCase field names // ============================================================ /** - * Initialize a multipart upload + * Initialize a multipart upload. + * Returns { assetId, uploadId, key } */ 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) { - 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 upload - */ +/** Abort an ongoing multipart upload */ 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) { 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}`); const data = await response.json(); 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) { 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}`); const data = await response.json(); return { success: true, data }; @@ -404,44 +400,26 @@ async function simpleUpload(formData) { // RECORDER API CALLS // ============================================================ -/** - * Get all recorder instances - */ async function getRecorders() { return api('/recorders'); } -/** - * Create a new recorder instance - */ async function createRecorder(data) { return api('/recorders', { method: 'POST', body: JSON.stringify(data) }); } -/** - * Start a recorder by ID - */ async function startRecorder(id) { return api(`/recorders/${id}/start`, { method: 'POST' }); } -/** - * Stop a recorder by ID - */ async function stopRecorder(id) { return api(`/recorders/${id}/stop`, { method: 'POST' }); } -/** - * Delete a recorder by ID - */ async function deleteRecorder(id) { return api(`/recorders/${id}`, { method: 'DELETE' }); } -/** - * Get live status for a recorder - */ async function getRecorderStatus(id) { return api(`/recorders/${id}/status`); }