// data.jsx — API client; populates window.ZAMPP_DATA from real endpoints const API = '/api/v1'; window.ZAMPP_DATA = { PROJECTS: [], ASSETS: [], RECORDERS: [], JOBS: [], NODES: [], USERS: [], BINS: [], COMMENTS: [], ME: null, }; async function apiFetch(path, opts = {}) { const res = await fetch(API + path, { credentials: 'include', ...opts, headers: { ...(opts.headers || {}), 'Content-Type': 'application/json' }, }); // 401 from any API call means there's no live session. Bounce to the // login screen instead of leaving the app in a half-loaded state. // While AUTH_ENABLED=false the server returns a synthetic /auth/me with // 200 so this branch never fires; flipping AUTH_ENABLED=true is what // activates the redirect end-to-end. if (res.status === 401 && !location.pathname.endsWith('/login.html')) { location.replace('/login.html'); throw new Error('Unauthenticated — redirecting to login'); } if (!res.ok) throw new Error(res.status + ' ' + res.statusText); return res.json(); } function fmtDuration(ms) { if (!ms) return '—'; const s = Math.round(ms / 1000); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; if (h > 0) return h + ':' + String(m).padStart(2, '0') + ':' + String(sec).padStart(2, '0'); return m + ':' + String(sec).padStart(2, '0'); } function fmtSize(bytes) { if (!bytes) return '—'; if (bytes >= 1e12) return (bytes / 1e12).toFixed(1) + ' TB'; if (bytes >= 1e9) return (bytes / 1e9).toFixed(1) + ' GB'; if (bytes >= 1e6) return Math.round(bytes / 1e6) + ' MB'; return Math.round(bytes / 1e3) + ' KB'; } function fmtRelative(iso) { if (!iso) return '—'; const diff = (Date.now() - new Date(iso)) / 1000; if (diff < 60) return 'just now'; if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; return Math.floor(diff / 86400) + 'd ago'; } const PROJECT_COLORS = ['#5B7CFA', '#2DD4A8', '#FF5B5B', '#F5A623', '#B57CFA', '#6B7280']; window.PROJECT_COLORS = PROJECT_COLORS; function normalizeAsset(a, projectMap) { return { ...a, name: a.display_name || a.filename || 'Untitled', type: a.media_type || 'video', duration: fmtDuration(a.duration_ms), size: fmtSize(a.file_size), res: a.resolution || '—', updated: fmtRelative(a.updated_at), project: (projectMap && projectMap[a.project_id]) || '', comments: 0, progress: 0, tc: a.start_tc || null, seed: a.id ? Array.from(a.id.replace(/-/g, '')).reduce((acc, c) => acc + c.charCodeAt(0), 0) % 6 : 1, }; } function normalizeRecorder(r) { let elapsed = '—'; if (r.status === 'recording' && r.started_at) { const s = Math.floor((Date.now() - new Date(r.started_at)) / 1000); elapsed = String(Math.floor(s / 3600)).padStart(2, '0') + ':' + String(Math.floor((s % 3600) / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0'); } const cfg = r.source_config || {}; return { ...r, source: r.source_type || '—', url: cfg.url || cfg.address || cfg.srt_url || cfg.rtmp_url || r.source_type || '—', codec: r.recording_codec || '—', res: r.recording_resolution || '—', node: r.node_id ? r.node_id.slice(0, 8) : 'primary', elapsed, bitrate: '—', health: 100, audio: false, }; } function normalizeJob(j) { const statusMap = { waiting: 'queued', active: 'running', completed: 'done', failed: 'failed' }; const kindMap = { proxy: 'Proxy', thumbnail: 'Thumbnail', conform: 'Conform', transcode: 'Transcode' }; const meta = j.metadata || {}; return { ...j, status: statusMap[j.status] || j.status, kind: kindMap[j.type] || j.type || 'Job', asset: j.asset_name || meta.filename || '—', eta: '—', node: meta.node || '—', priority: meta.priority || 'normal', error: j.error || null, progress: j.progress || 0, }; } async function refreshAssets() { const raw = await apiFetch('/assets?limit=500'); const list = Array.isArray(raw) ? raw : (raw.assets || []); const projectMap = {}; (window.ZAMPP_DATA.PROJECTS || []).forEach(function(p) { projectMap[p.id] = p.name; }); const normalized = list.map(function(a) { return normalizeAsset(a, projectMap); }); window.ZAMPP_DATA.ASSETS = normalized; if (window.ZAMPP_DATA.PROJECTS && window.ZAMPP_DATA.PROJECTS.length) { const counts = {}; list.forEach(function(a) { if (a.project_id) counts[a.project_id] = (counts[a.project_id] || 0) + 1; }); window.ZAMPP_DATA.PROJECTS = window.ZAMPP_DATA.PROJECTS.map(function(p) { return { ...p, assets: counts[p.id] || 0 }; }); } return normalized; } async function loadData() { const [projectsR, assetsR, recordersR, jobsR, clusterR, usersR, binsR, meR] = await Promise.allSettled([ apiFetch('/projects'), apiFetch('/assets?limit=500'), apiFetch('/recorders'), apiFetch('/jobs'), apiFetch('/cluster'), apiFetch('/users'), apiFetch('/bins'), apiFetch('/auth/me'), ]); const projectMap = {}; if (projectsR.status === 'fulfilled') { window.ZAMPP_DATA.PROJECTS = (projectsR.value || []).map((p, i) => ({ ...p, color: PROJECT_COLORS[i % PROJECT_COLORS.length], assets: 0, updated: fmtRelative(p.updated_at), })); window.ZAMPP_DATA.PROJECTS.forEach(p => { projectMap[p.id] = p.name; }); } if (assetsR.status === 'fulfilled') { const raw = assetsR.value; const list = Array.isArray(raw) ? raw : (raw.assets || []); window.ZAMPP_DATA.ASSETS = list.map(a => normalizeAsset(a, projectMap)); const counts = {}; list.forEach(a => { if (a.project_id) counts[a.project_id] = (counts[a.project_id] || 0) + 1; }); window.ZAMPP_DATA.PROJECTS = window.ZAMPP_DATA.PROJECTS.map(p => ({ ...p, assets: counts[p.id] || 0 })); } if (recordersR.status === 'fulfilled') { window.ZAMPP_DATA.RECORDERS = (recordersR.value || []).map(normalizeRecorder); } if (jobsR.status === 'fulfilled') { window.ZAMPP_DATA.JOBS = (jobsR.value || []).map(normalizeJob); } if (clusterR.status === 'fulfilled') { window.ZAMPP_DATA.NODES = clusterR.value || []; } if (usersR.status === 'fulfilled') { window.ZAMPP_DATA.USERS = (usersR.value || []).map(u => ({ ...u, name: u.display_name || u.username || u.email || 'Unknown', initials: (u.display_name || u.username || '?').slice(0, 2).toUpperCase(), role: u.role || 'viewer', joined: fmtRelative(u.created_at), lastSeen: fmtRelative(u.last_seen || u.updated_at), })); } if (binsR.status === 'fulfilled') { window.ZAMPP_DATA.BINS = (binsR.value || []).map(b => ({ ...b, count: b.asset_count || 0, icon: b.type || 'grid', })); } if (meR.status === 'fulfilled' && meR.value) { const me = meR.value; const label = me.display_name || me.username || 'User'; window.ZAMPP_DATA.ME = { id: me.id, username: me.username, name: label, initials: label.slice(0, 2).toUpperCase(), role: me.role || 'viewer', // True when the server returned a synthetic user (AUTH_ENABLED=false). // Surfaced as a small "auth off" hint in the sidebar so the operator // understands why the corner shows the OS user instead of a login. synthetic: !!me.synthetic, }; } } // ── Sequence API ──────────────────────────────────────────────── async function getSequences(projectId) { return apiFetch('/sequences?project_id=' + projectId); } async function createSequence(data) { return apiFetch('/sequences', { method: 'POST', body: JSON.stringify(data), }); } async function getSequence(sequenceId) { return apiFetch('/sequences/' + sequenceId); } async function updateSequence(sequenceId, data) { return apiFetch('/sequences/' + sequenceId, { method: 'PUT', body: JSON.stringify(data), }); } async function deleteSequence(sequenceId) { return apiFetch('/sequences/' + sequenceId, { method: 'DELETE' }); } async function syncSequenceClips(sequenceId, clips) { return apiFetch('/sequences/' + sequenceId + '/clips', { method: 'PUT', body: JSON.stringify(clips), }); } async function exportSequenceEDL(sequenceId, filename) { const res = await fetch(API + '/sequences/' + sequenceId + '/export/edl', { method: 'POST', credentials: 'include', }); if (!res.ok) throw new Error('EDL export failed: ' + res.status); const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename || 'sequence.edl'; a.click(); URL.revokeObjectURL(url); } window.ZAMPP_API = { fetch: apiFetch, loadData, refreshAssets, fmtDuration, fmtSize, fmtRelative, getSequences, createSequence, getSequence, updateSequence, deleteSequence, syncSequenceClips, exportSequenceEDL, }; // Library re-renders after mutations: expose normalizeAsset so the screen // can re-fetch /assets and produce rows with the same shape as the boot load. window.normalizeAsset = normalizeAsset;