// 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}`); } async function deleteAsset(assetId, { hard = false } = {}) { const qs = hard ? '?hard=true' : ''; return api(`/assets/${assetId}${qs}`, { method: 'DELETE' }); } async function retryAsset(assetId) { return api(`/assets/${assetId}/retry`, { method: 'POST' }); } async function moveAsset(assetId, binId) { return api(`/assets/${assetId}`, { method: 'PATCH', body: JSON.stringify({ bin_id: binId || null }), }); } async function copyAsset(assetId, { binId, projectId } = {}) { return api(`/assets/${assetId}/copy`, { method: 'POST', body: JSON.stringify({ binId: binId || null, projectId: projectId || null }), }); } // ============================================================ // 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 // ============================================================ 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 // ============================================================ 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; } async function getCaptureStatus() { return captureApi('/status'); } async function getRecordingStatus() { return captureApi('/status'); } 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; } async function stopRecording() { let sessionId = _captureSessionId; if (!sessionId) { 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; } 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; } 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 // ============================================================ 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) }); } 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) }); } 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) }); } 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 }; } } 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 patchRecorder(id, data) { return api(`/recorders/${id}`, { method: 'PATCH', 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`); } // ============================================================ // USERS API CALLS (admin) // ============================================================ async function getUsers() { return api('/users'); } async function createUser(data) { return api('/users', { method: 'POST', body: JSON.stringify(data) }); } async function updateUser(id, data) { return api(`/users/${id}`, { method: 'PATCH', body: JSON.stringify(data) }); } async function deleteUser(id) { return api(`/users/${id}`, { method: 'DELETE' }); } // ============================================================ // GROUPS API CALLS (admin) // ============================================================ async function getGroups() { return api('/groups'); } async function createGroup(data) { return api('/groups', { method: 'POST', body: JSON.stringify(data) }); } async function updateGroup(id, data) { return api(`/groups/${id}`, { method: 'PATCH', body: JSON.stringify(data) }); } async function deleteGroup(id) { return api(`/groups/${id}`, { method: 'DELETE' }); } async function getGroupMembers(id) { return api(`/groups/${id}/members`); } async function addGroupMember(groupId, userId) { return api(`/groups/${groupId}/members`, { method: 'POST', body: JSON.stringify({ user_id: userId }), }); } async function removeGroupMember(groupId, userId) { return api(`/groups/${groupId}/members/${userId}`, { method: 'DELETE' }); } // ============================================================ // TOKENS API CALLS // ============================================================ async function getTokens() { return api('/tokens'); } async function createToken(data) { return api('/tokens', { method: 'POST', body: JSON.stringify(data) }); } async function revokeToken(id) { return api(`/tokens/${id}`, { method: 'DELETE' }); } // ============================================================ // SEQUENCE API CALLS // ============================================================ async function getSequences(projectId) { return api(`/sequences?project_id=${projectId}`); } async function createSequence(data) { return api('/sequences', { method: 'POST', body: JSON.stringify(data), }); } async function getSequence(sequenceId) { return api(`/sequences/${sequenceId}`); } async function updateSequence(sequenceId, data) { return api(`/sequences/${sequenceId}`, { method: 'PUT', body: JSON.stringify(data), }); } async function deleteSequence(sequenceId) { return api(`/sequences/${sequenceId}`, { method: 'DELETE' }); } /** * Replace all clips in a sequence. * @param {string} sequenceId * @param {Array<{asset_id, track, timeline_in_frames, timeline_out_frames, source_in_frames, source_out_frames}>} clips */ async function syncSequenceClips(sequenceId, clips) { return api(`/sequences/${sequenceId}/clips`, { method: 'PUT', body: JSON.stringify(clips), }); } /** * Download EDL for the V1 track of a sequence. * Triggers a file download in the browser. */ async function exportSequenceEDL(sequenceId, filename) { try { const response = await fetch(`${API_BASE}/sequences/${sequenceId}/export/edl`, { method: 'POST', credentials: 'include', }); if (!response.ok) throw new Error(`EDL export failed: ${response.status}`); const blob = await response.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename || 'sequence.edl'; a.click(); URL.revokeObjectURL(url); return { success: true }; } catch (error) { return { success: false, error: error.message }; } }