// Dragonflight API client — v2.1.4 // UXP fetch() notes: // • redirect:'manual' is NOT a supported option — UXP auto-follows redirects // • Authorization is stripped by UXP on cross-origin redirects (security fix) // • For same-origin API calls: add Bearer header // • For off-origin URLs (S3 presigned): do NOT add Bearer (function () { const API = {}; const LS_URL = 'df.uxp.serverUrl'; const LS_TOKEN = 'df.uxp.apiToken'; API.state = { serverUrl: localStorage.getItem(LS_URL) || '', apiToken: localStorage.getItem(LS_TOKEN) || '', connected: false, }; API.save = function () { localStorage.setItem(LS_URL, API.state.serverUrl); localStorage.setItem(LS_TOKEN, API.state.apiToken); }; API.clear = function () { API.state.serverUrl = ''; API.state.apiToken = ''; API.state.connected = false; localStorage.removeItem(LS_URL); localStorage.removeItem(LS_TOKEN); }; function trimUrl(u) { return String(u || '').replace(/\/+$/, ''); } // Core request — adds Bearer only for same-server URLs. // No redirect option — UXP handles redirects automatically. API.request = async function (urlOrPath, opts) { opts = opts || {}; const isAbs = /^https?:\/\//i.test(urlOrPath); const base = trimUrl(API.state.serverUrl); const url = isAbs ? urlOrPath : (base + urlOrPath); const headers = Object.assign({}, opts.headers || {}); if (!isAbs || url.startsWith(base)) { if (API.state.apiToken) headers['Authorization'] = 'Bearer ' + API.state.apiToken; } return fetch(url, Object.assign({}, opts, { headers })); }; // Fetch an off-origin URL (e.g. presigned S3) — no auth header. API.requestExternal = async function (url) { return fetch(url); }; API.json = async function (pathOrUrl, opts) { const r = await API.request(pathOrUrl, opts); if (!r.ok) { const text = await r.text().catch(() => ''); throw new Error('HTTP ' + r.status + (text ? ' — ' + text.slice(0, 200) : '')); } return r.json(); }; // ── Auth ───────────────────────────────────────────────────────── API.connect = async function (serverUrl, apiToken) { API.state.serverUrl = trimUrl(serverUrl); API.state.apiToken = String(apiToken || '').trim(); if (!API.state.serverUrl || !API.state.apiToken) { throw new Error('Server URL and API token required'); } const me = await API.json('/api/v1/auth/me'); API.state.connected = true; API.save(); return me; }; API.disconnect = function () { API.clear(); }; // ── Assets ─────────────────────────────────────────────────────── API.listAssets = async function (query, projectId) { const p = new URLSearchParams({ limit: '60' }); if (query) p.set('q', query); if (projectId && projectId !== 'all') p.set('project_id', projectId); return API.json('/api/v1/assets?' + p.toString()); }; API.getAsset = async function (assetId) { return API.json('/api/v1/assets/' + assetId); }; // /stream → { url, type, source } (same-origin proxy video URL) API.getProxyUrl = async function (assetId) { const data = await API.json('/api/v1/assets/' + assetId + '/stream'); if (!data || !data.url) throw new Error('Asset has no proxy'); const u = data.url; const abs = /^https?:\/\//i.test(u) ? u : trimUrl(API.state.serverUrl) + u; return { url: abs, source: data.source || 'proxy' }; }; // /hires → { url, filename, ext, file_size, type } (presigned S3 URL) API.getHiresInfo = async function (assetId) { const data = await API.json('/api/v1/assets/' + assetId + '/hires'); if (!data || !data.url) throw new Error('Asset has no hi-res source'); return data; }; // /live-path → { win_path, posix_path, display_name } API.getLivePath = async function (assetId) { return API.json('/api/v1/assets/' + assetId + '/live-path'); }; // ── Projects ───────────────────────────────────────────────────── API.listProjects = async function () { const data = await API.json('/api/v1/projects'); return Array.isArray(data) ? data : []; }; // ── Sequences ──────────────────────────────────────────────────── API.listSequences = async function (projectId) { const p = new URLSearchParams(); if (projectId) p.set('project_id', projectId); const data = await API.json('/api/v1/sequences?' + p.toString()); return Array.isArray(data) ? data : []; }; API.createSequence = async function (projectId, name, frameRate, width, height) { return API.json('/api/v1/sequences', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: projectId, name, frame_rate: frameRate, width, height }), }); }; API.updateSequence = async function (seqId, fields) { return API.json('/api/v1/sequences/' + seqId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(fields), }); }; API.pushClips = async function (seqId, clips) { const r = await API.request('/api/v1/sequences/' + seqId + '/clips', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(clips), }); if (!r.ok) throw new Error('Clip push HTTP ' + r.status); return r.json(); }; API.startConform = async function (seqId, opts) { return API.json('/api/v1/sequences/' + seqId + '/conform', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(opts), }); }; API.getJob = async function (jobId) { return API.json('/api/v1/jobs/' + jobId); }; API.batchTrim = async function (clips) { return API.json('/api/v1/assets/batch-trim', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clips }), }); }; // Local Export trim job polling + segment retrieval. API.getTrimStatus = function (jobId) { return API.json('/api/v1/assets/trim-status/' + jobId); }; API.getTempSegmentUrl = function (clipInstanceId) { return API.json('/api/v1/assets/temp-segment-url/' + clipInstanceId); }; // ── Upload (ingest editor media into the MAM) ──────────────────── // Single-shot multipart form upload (server caps simple at <50 MB). API.uploadSimple = async function (blob, meta) { const fd = new FormData(); fd.append('file', blob, meta.filename); fd.append('filename', meta.filename); fd.append('projectId', meta.projectId); if (meta.binId) fd.append('binId', meta.binId); if (meta.contentType) fd.append('contentType', meta.contentType); const r = await API.request('/api/v1/upload/simple', { method: 'POST', body: fd }); if (!r.ok) throw new Error('Upload HTTP ' + r.status + ' — ' + (await r.text().catch(() => '')).slice(0, 160)); return r.json(); }; // Chunked multipart for large originals. API.uploadInit = function (meta) { return API.json('/api/v1/upload/init', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(meta), }); }; API.uploadPart = async function (blob, meta) { const fd = new FormData(); fd.append('file', blob, 'part-' + meta.partNumber); fd.append('uploadId', meta.uploadId); fd.append('key', meta.key); fd.append('partNumber', String(meta.partNumber)); const r = await API.request('/api/v1/upload/part', { method: 'POST', body: fd }); if (!r.ok) throw new Error('Upload part HTTP ' + r.status); return r.json(); }; API.uploadComplete = function (meta) { return API.json('/api/v1/upload/complete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(meta), }); }; API.uploadAbort = function (meta) { return API.json('/api/v1/upload/abort', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(meta), }).catch(() => {}); }; window.API = API; })();