File: services/web-ui/public/js/api.js

// 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 };
  }
}


fix: prevent JS injection via token name in confirmRevoke onclick

Token names containing single quotes (e.g. "O'Brien's key") broke the
onclick attribute string by closing the JS string literal early.
Apply JSON.stringify+esc pattern so name is safely embedded as a
JSON string literal instead of a raw single-quoted string.

<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Tokens — Wild Dragon</title>
  <link rel="stylesheet" href="css/common.css">

      Z-AMPP

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
        Home

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
        Library

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
        Projects

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
        Ingest

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
        Recorders

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
        Capture

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
        Jobs

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
        Editor

Admin

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
        Users

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
        Tokens

?

—

        <button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
        </button>

        API Tokens

        <button class="btn btn-primary btn-sm" onclick="openNewTokenPanel()">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
          New token
        </button>

          API tokens let scripts and integrations authenticate as you without using your password.
          Tokens are shown once at creation — store them securely.
          Use Authorization: Bearer <token> in your requests.

          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><circle cx="8" cy="8" r="6.5"/><path d="M5 8l2 2 4-4"/></svg>
          Token created

          ⚠ This is the only time this token will be shown. Copy it now.

        <button class="btn btn-secondary btn-sm copy-btn" onclick="copyToken()">Copy to clipboard</button>

Loading…

          <svg viewBox="0 0 40 40" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="15" cy="25" r="8"/><path d="M21 19l10-10M28 10l3 3M30 8l2 2"/></svg>

No tokens yet

Create a token to authenticate API requests without a password.

          <button class="btn btn-primary btn-sm" onclick="openNewTokenPanel()">New token</button>

    New API token
    <button class="btn btn-ghost btn-sm" onclick="closeNewTokenPanel()" style="padding:0;width:28px;height:28px;">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
    </button>

      Token name
      <input type="text" id="tokenName" placeholder="e.g. Premiere Plugin, CI/CD Script">

A label to help you remember what this token is for.

      Expiry
      <select id="tokenExpiry">
        <option value="">No expiry</option>
        <option value="30">30 days</option>
        <option value="90">90 days</option>
        <option value="365">1 year</option>
      </select>

    <button class="btn btn-ghost" onclick="closeNewTokenPanel()">Cancel</button>
    <button class="btn btn-primary" id="createTokenBtn" onclick="createNewToken()">Create token</button>


we are going to be working on https://forge.wilddragon.net/zgaetano/wild-dragon  Main branch, I am going to bed. I would like you to continue to test, and develop new features for this system.


OK | commit:9f7cb91 | Updated services/web-ui/public/api-tokens.html


Good. The api-tokens.html fix was committed. Now I need to:
1. Fix capture.html - update `api.js?v=5` to `api.js?v=6`
2. `formatDuration` IS defined in api.js (line that returns HH:MM:SS format), so capture.html's `renderRecent` call is fine.

Also, I noticed the api-tokens.html was already loading `api.js` without a version query string. Let me check... looking at the original file again:
```html

<script src="js/api.js"></script>
```
No version string! I updated it to `?v=6` in my fix. Good.

Now let me fix capture.html by updating the api.js version from 5 to 6, then I'll fetch recorders.html and player.html to check those files in parallel.


Good — `formatDuration` is defined in `api.js` so `capture.html`'s call is safe. Now fixing the version ref and fetching the remaining unreviewed pages in parallel.


fix: bump api.js cache-buster to v=6 in capture.html

<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <title>Capture — Z-AMPP</title>
  <link rel="stylesheet" href="css/common.css">

      Z-AMPP

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
        Home

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
        Library

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
        Projects

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
        Ingest

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
        Recorders

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
        Capture

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
        Jobs

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
        Editor

Admin

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
        Users

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
        Tokens

?

—

        <button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
        </button>

        Capture
        /
        Direct SDI

        Loading devices…

          <svg viewBox="0 0 40 40" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="10" width="34" height="22" rx="2"/><path d="M13 32v4M27 32v4M9 36h22"/><path d="M16 19l4-3 4 3-1.5 5h-5L16 19z" stroke-opacity="0.5"/></svg>

No SDI devices found

This machine has no DeckLink cards installed. To ingest live streams via SRT or RTMP, use the Recorders page instead.

          Go to Recorders
          <button class="btn btn-ghost btn-sm" onclick="initCapture()">Retry</button>

Elapsed

00:00:00:00

              Ready

            <button class="record-btn" id="recordBtn" onclick="toggleRecord()" aria-label="Start recording" title="Start/stop recording">

            </button>

Record

            Select device and project to begin

Source

              Device
              <select id="deviceSelect"></select>

Destination

              Project
              <select id="projectSelect">
                <option value="">Select project…</option>
              </select>

              Bin
              <select id="binSelect">
                <option value="">Project root</option>
              </select>

              Clip name
              <input type="text" id="clipName" placeholder="Auto-generated if blank">

Recent captures

No recent captures


OK | commit:f1e0453 | Updated services/web-ui/public/capture.html


File: services/web-ui/public/recorders.html

<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <title>Recorders — Z-AMPP</title>
  <link rel="stylesheet" href="css/common.css?v=3">

      Z-AMPP

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
        Home

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
        Library

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
        Projects

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
        Ingest

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
        Recorders

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
        Capture

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
        Jobs

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
        Editor

Admin

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
        Users

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
        Tokens

?

—

        <button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
        </button>

        Recorders

        <button class="btn btn-primary btn-sm" id="newRecorderBtn">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
          New recorder
        </button>

          <svg viewBox="0 0 40 40" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="9" width="26" height="22" rx="2"/><path d="M28 16l10-5v18l-10-5"/><circle cx="15" cy="20" r="4"/></svg>

No recorders yet

Create a recorder to ingest live streams via SRT, RTMP, or SDI.

          <button class="btn btn-primary btn-sm" onclick="openPanel()">New recorder</button>

    New recorder
    <button class="btn btn-ghost btn-sm" id="closePanelBtn" style="padding:0;width:28px;height:28px;">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
    </button>

      Recorder name
      <input type="text" id="recName" placeholder="e.g. Studio A SRT">

      Source type

        <button class="source-type-btn active" data-type="srt" onclick="setSourceType('srt')">SRT</button>
        <button class="source-type-btn" data-type="rtmp" onclick="setSourceType('rtmp')">RTMP</button>
        <button class="source-type-btn" data-type="sdi" onclick="setSourceType('sdi')">SDI</button>

Recording settings

        Codec
        <select id="recCodec">
          <option value="prores_hq">ProRes HQ</option>
          <option value="prores_422">ProRes 422</option>
          <option value="prores_lt">ProRes LT</option>
          <option value="h264">H.264</option>
          <option value="dnxhd">DNxHD</option>
        </select>

        Resolution
        <select id="recResolution">
          <option value="native">Native (source)</option>
          <option value="1920x1080">1920×1080</option>
          <option value="1280x720">1280×720</option>
          <option value="3840x2160">3840×2160</option>
        </select>

        <input type="checkbox" id="proxyToggle">

        Generate proxy on stop

        Proxy codec
        <select id="proxyCodec">
          <option value="h264">H.264</option>
          <option value="h265">H.265</option>
        </select>

        Proxy bitrate
        <select id="proxyBitrate">
          <option value="2000k">2 Mbps</option>
          <option value="4000k">4 Mbps</option>
          <option value="8000k">8 Mbps</option>
        </select>

Destination

        Project
        <select id="recProject">
          <option value="">None (manual assignment)</option>
        </select>

        Bin
        <select id="recBin">
          <option value="">Project root</option>
        </select>

    <button class="btn btn-ghost" onclick="closePanel()">Cancel</button>
    <button class="btn btn-secondary" id="probeBtn">Probe source</button>
    <button class="btn btn-primary" id="saveRecorderBtn">Create recorder</button>


File: services/web-ui/public/player.html

<!DOCTYPE html>

    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Wild Dragon - Asset Player</title>
    <link rel="stylesheet" href="/css/common.css">

D

                WILD DRAGON

Assets

Capture

Projects

                    <button class="back-button" onclick="goBack()">
                        ← Back to Assets
                    </button>

                                <video class="video-player" id="videoPlayer" controls></video>

File Information

Filename

—

Format

—

Codec

—

Resolution

—

Framerate

—

Duration

—

File Size

—

Status

—

Captured

—

Tags

                                <input
                                    type="text"
                                    id="newTagInput"
                                    class="form-input"
                                    placeholder="Add tag..."
                                    style="font-size: 0.85rem;"
                                >
                                <button class="btn btn-secondary btn-sm" style="width: 100%; margin-top: var(--spacing-sm);" onclick="addTag()">Add Tag</button>

Notes

                                <textarea id="notesInput" class="metadata-textarea" placeholder="Add notes about this asset..."></textarea>

                                    <button class="btn btn-primary btn-sm" style="flex: 1;" onclick="saveMetadata()">Save</button>
                                    <button class="btn btn-secondary btn-sm" style="flex: 1;" onclick="resetMetadata()">Reset</button>

                Connected


Now let me review both `recorders.html` and `player.html` for bugs.

**recorders.html** review:

1. `esc()` usage - OK throughout
2. `onclick` attributes - Let me check for JS injection issues:
   - `openEditPanel('${rec.id}')` - IDs are UUIDs from DB, safe
   - `handleDeleteRecorder('${rec.id}')` - same, safe
   - `handleStop('${rec.id}')` - safe
   - `handleStart('${rec.id}')` - safe
   - All recorder names rendered via `${esc(rec.name)}` in display, not in onclick - safe
   - Project and bin names are also properly escaped - safe
   - Probe result uses `esc()` for device names and stream info - safe

3. `renderProbeResult` has a potential issue where error messages from the API response get inserted directly into innerHTML without escaping. Since the response comes from our own server rather than user input, it's not a traditional XSS vulnerability, but it should still be escaped to be safe.

4. In `renderProbeOk`, I'm checking how video codec information and frame rate data are being rendered...

Actually, looking at the numeric fields like width, height, sample rate, and channel count—these come from FFprobe output through the API, so they're typically integers and not user-controlled. While they could technically be escaped for consistency, the risk is minimal since they're numeric values.

5. For the signal badge display, I need to examine how the error message gets set in the title attribute...

Writing security analysis... I'm noticing a critical XSS vulnerability in the tag display where user-inputted tag values are inserted directly into the DOM without escaping. I'm also questioning whether `player.html` is even being used in the current UI since it doesn't appear to be linked from anywhere in the new design. The main bugs to fix are the unescaped error message in `renderProbeResult` on recorders.html and the tag XSS vulnerability in player.html, though player.html is a legacy orphaned page that also needs a UI update. Let me focus on the recorders.html fix first, where the error output needs proper escaping in the innerHTML assignment.

For player.html, I'm replacing the unsafe innerHTML approach in `renderTags` with DOM methods...

Actually, I realize `esc()` isn't available in player.html, so I need to either add that utility function or use `textContent` and `appendChild` to safely build the tag elements instead of relying on string concatenation.

Looking at the navigation across the site, player.html doesn't appear to be linked anywhere, but it's still accessible via direct URL so I should patch the XSS vulnerability anyway. I'm going to fix both the tag rendering in player.html and the probe error message in recorders.html, then bump the version.

Now I'm thinking about what features would be most valuable to add next. The system could benefit from bulk file uploads, better search and filtering on assets, export options beyond EDL, scheduled recording, bulk metadata editing, nested bins, and improved dashboard stats. Let me check what backend routes I haven't reviewed yet to understand the current feature state better.

Actually, I should implement those two security fixes first. For the recorders page, I need to escape the error message in the probe result display to prevent XSS. For the player, I'll fix the tag rendering by using proper DOM methods instead of innerHTML, and update the API version accordingly.

Since I can't use partial file edits and recorders.html is quite large, I'll need to send the complete file with just that one line changed where the error gets escaped. Let me locate the exact spot in the renderProbeResult function and make the update. I need to escape the error message to prevent XSS vulnerabilities, so I'll wrap `(d.error || 'Unknown error')` with the `esc()` function. Since these are large files, I'll need to send the complete updated content for both recorders.html and player.html, making sure to apply the necessary security fixes across them.

For the tags rendering, I'm building the DOM elements dynamically by creating badge containers with the tag text and a remove button, then appending them to the tags list. I also need to bump the api.js version from 5 to 6 in the script reference.

Now thinking about what features would be most impactful to add next. The system already has a solid foundation with assets, projects, sequences, and editing capabilities. I'm considering several options: a storyboard or contact sheet export for quick footage review, tag autocomplete to suggest existing tags while editing, waveform visualization for audio content, the ability to duplicate sequences, and watermarking functionality. The storyboard export and tag autocomplete seem like they'd provide the most immediate value to users.

Looking at what worker capabilities already exist—proxy generation, thumbnails, and potentially other processing tasks—I should focus on features that leverage what's already built. A library view toggle between grid and list would make browsing large collections much more efficient, and an asset detail side panel would improve the workflow when reviewing and tagging items directly from the library. I've now gone through all the backend routes, so I need to focus on the frontend files that still need review. The main ones left are index.html, upload.html, projects.html, and users.html—the last two were apparently fixed for injection vulnerabilities in a previous session, but I should still check them along with the others I haven't looked at yet.

I've got fixes to make in recorders.html and player.html, so let me tackle those first before diving into the library page and upload form. After I handle those targeted fixes, I want to think about what features would add the most value—things like project filtering in the asset library, an export conform job option, or quick inline editing for asset notes and tags would all be useful for a broadcast MAM system.


**recorders.html** is clean except for one issue: `d.error` is placed directly into `innerHTML` without escaping in `renderProbeResult`. **player.html** is a legacy page (old CSS class names, no sidebar) with an XSS bug in `renderTags` — tag values go directly into `innerHTML`. I'll fix both and fetch the remaining unreviewed pages at the same time.


File: services/web-ui/public/index.html

<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <title>Library — Z-AMPP</title>
  <link rel="stylesheet" href="css/common.css">

AMPP Safe

Z-AMPP — Media Asset Management

      Z-AMPP

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
        Home

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
        Library

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
        Projects

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
        Ingest

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
        Recorders

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
        Capture

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
        Jobs

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
        Editor

Admin

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
        Users

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
        Tokens

?

—

        <button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
        </button>

        Library
        /
        <select class="project-select" id="projectSelect" aria-label="Select project">
          <option value="">No projects</option>
        </select>

        <button class="btn btn-ghost btn-sm" id="newProjectBtn" title="New project">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
          New project
        </button>
        <button class="btn btn-primary btn-sm" id="uploadBtn">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
          Upload
        </button>

          Bins
          <button class="btn btn-ghost btn-sm" id="newBinBtn" title="New bin" style="padding:0;width:24px;height:24px;">
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
          </button>

            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="14" height="10" rx="1"/><path d="M1 4l3-3h8l3 3"/></svg>
            All assets

            0 assets

            <input class="search-input" id="searchInput" type="text" placeholder="Search assets…" aria-label="Search assets">

              <svg viewBox="0 0 40 40" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="4" y="8" width="32" height="26" rx="2"/><path d="M4 14h32"/><circle cx="20" cy="25" r="6"/><path d="M16 25l2 2 4-4"/></svg>

No assets yet

Upload media to this project or select a different bin.

              <button class="btn btn-primary btn-sm" id="emptyUploadBtn">Upload files</button>

Loading assets

Drop to upload

    New project
    <button class="btn btn-ghost btn-sm" id="closeProjectPanel" style="padding:0;width:28px;height:28px;">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
    </button>

      Project name
      <input type="text" id="newProjectName" placeholder="e.g. Evening News 2026-05">

      Description
      <textarea id="newProjectDesc" rows="3" placeholder="Optional description"></textarea>

    <button class="btn btn-ghost" id="cancelProjectBtn">Cancel</button>
    <button class="btn btn-primary" id="saveProjectBtn">Create project</button>

    New bin
    <button class="btn btn-ghost btn-sm" id="closeBinPanel" style="padding:0;width:28px;height:28px;">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
    </button>

      Bin name
      <input type="text" id="newBinName" placeholder="e.g. Interviews">

    <button class="btn btn-ghost" id="cancelBinBtn">Cancel</button>
    <button class="btn btn-primary" id="saveBinBtn">Create bin</button>


File: services/web-ui/public/upload.html

<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <title>Ingest — Z-AMPP</title>
  <link rel="stylesheet" href="css/common.css">

      Z-AMPP

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
        Home

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
        Library

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
        Projects

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
        Ingest

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
        Recorders

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
        Capture

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
        Jobs

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
        Editor

Admin

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
        Users

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
        Tokens

?

—

        <button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
        </button>

        Ingest

        <button class="btn btn-ghost btn-sm" id="clearDoneBtn" style="display:none;">Clear completed</button>

            Project
            <select id="projectSel">
              <option value="">Select project…</option>
            </select>

            Bin
            <select id="binSel">
              <option value="">No bin (project root)</option>
            </select>

          <input type="file" id="fileInput" multiple accept="video/*,audio/*,image/*" aria-label="Select files to upload">

            <svg viewBox="0 0 36 36" fill="none" stroke="currentColor" stroke-width="1.5">
              <path d="M18 26V10M11 17l7-7 7 7"/>
              <path d="M4 28h28"/>
              <rect x="2" y="4" width="32" height="26" rx="2" stroke-opacity="0.25"/>
            </svg>

Drop files here or click to browse

Video, audio, and image files — up to 5 GB each

          Uploading…

          0%

            Queue


fix: XSS in renderTags and stale api.js version in player.html

Tag values were inserted into innerHTML unsanitized — a tag containing
HTML would execute as markup. Switch to DOM-only construction for the
tag badges. Also bump api.js cache-buster to v=6.

<!DOCTYPE html>

    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Wild Dragon - Asset Player</title>
    <link rel="stylesheet" href="/css/common.css">

D

                WILD DRAGON

Assets

Capture

Projects

                    <button class="back-button" onclick="goBack()">
                        ← Back to Assets
                    </button>

                                <video class="video-player" id="videoPlayer" controls></video>

File Information

Filename

—

Format

—

Codec

—

Resolution

—

Framerate

—

Duration

—

File Size

—

Status

—

Captured

—

Tags

                                <input
                                    type="text"
                                    id="newTagInput"
                                    class="form-input"
                                    placeholder="Add tag..."
                                    style="font-size: 0.85rem;"
                                >
                                <button class="btn btn-secondary btn-sm" style="width: 100%; margin-top: var(--spacing-sm);" onclick="addTag()">Add Tag</button>

Notes

                                <textarea id="notesInput" class="metadata-textarea" placeholder="Add notes about this asset..."></textarea>

                                    <button class="btn btn-primary btn-sm" style="flex: 1;" onclick="saveMetadata()">Save</button>
                                    <button class="btn btn-secondary btn-sm" style="flex: 1;" onclick="resetMetadata()">Reset</button>

                Connected


OK | commit:280fc9d | Updated services/web-ui/public/player.html


Good. Now let me review both `index.html` and `upload.html`.

**index.html** review:

1. Uses `escHtml()` (note: different name than `esc()` used in other pages) - defined at bottom:
   ```js
   function escHtml(s) {
     if (!s) return '';
     return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
   }
   ```
   Same implementation, different name.

2. Checking the `renderAssets()` card HTML for injection vulnerabilities - the onclick handlers reference asset IDs which are UUIDs from the database so they're safe, and all user-controlled content like display names and filenames are properly escaped with `escHtml()`.

3. The `renderBins()` function also properly escapes bin names in the HTML.

4. The project select dropdown escapes project names when building the option elements.

5. The api.js file is versioned correctly.

6. There's a local `formatDuration` function in index.html that's separate from the one in api.js - the duplication is fine since it formats differently for shorter durations.

7. Still reviewing...

The `loadThumb` function safely uses asset IDs from the dataset, and `openInEditor` is called with UUIDs. The `statusBadgeClass` function maps status values to CSS classes with a safe default fallback, so even unexpected status values won't cause injection issues. Overall, index.html appears secure with no vulnerabilities found.

Now moving to upload.html, which uses the same `esc()` function for escaping and has the api.js file versioned.

Wait, I found a bug—the api.js version should be v=6, not v=5. The rest of upload.html looks good with proper escaping for file names and error messages, plus static SVG icons for file types.

So I've identified two fixes needed: the recorders.html file needs `esc()` applied to the error field in `renderProbeResult`, and upload.html needs the api.js version bumped from v=5 to v=6. Let me locate the specific line in recorders.html that needs updating.

Actually, since both files are large, I'll need to resubmit them entirely with these fixes applied. Let me tackle both in parallel—the upload.html change is straightforward, just updating that one version parameter, while recorders.html requires wrapping the error output with the escape function.

Now I'm thinking about what features would add the most value. The library already has solid fundamentals with bins, projects, and drag-to-upload, and the upload system handles both multipart and simple uploads. The recorders support multiple input types with live monitoring, the editor has timeline capabilities with EDL export, and capture can handle SDI with timecode. But there are some gaps worth addressing: a status filter would make it much faster to find processing or errored assets instead of relying on text search, a slide-in detail panel would give better access to metadata and editing without modal dialogs, bulk reprocessing for failed assets would be useful, and I should consider how the library scales when there are hundreds of assets loaded at once.

Looking at the actual API capabilities, the `getAssets` endpoint only supports search, project filtering, and bin filtering—there's no status, date range, or sort parameters yet. I should start by adding status filter buttons to the library interface and extending the backend to support filtering by status. I'd also want to add real asset counts by status on the dashboard so it's actually showing meaningful data instead of placeholder numbers.

But thinking about what would be most useful right now, a **list/grid view toggle** would be genuinely practical—grid view for browsing thumbnails, list view as a compact table with columns for name, duration, status, size, and date. This is purely frontend work with no backend changes needed, and it naturally sets up for future column sorting. I'll add a toggle button to the asset toolbar and implement the list view layout with a thumbnail, name, status badge, duration, size, date, and action buttons in each row.

Writing the list view layout...

Actually, **sorting** would be even more impactful—broadcast operators need to find the latest footage quickly. I can add a sort dropdown for Name, Date ascending/descending, Duration, Size, and Status, then sort the assets array before rendering on the frontend. On the backend, I'll accept `sort_by` and `sort_order` query parameters and add an ORDER BY clause to the database query. Let me tackle the pending bug fixes first, then implement the sorting feature.

For the new feature, I'm thinking **status filter plus sort** would be most useful for broadcast MAM workflows. I'd add filter chips on the frontend (All, Processing, Ready, Error) alongside a sort dropdown, and extend the backend assets route to accept a `status` filter parameter. Looking at how the API currently works, filters are passed as query parameters through URLSearchParams, and the backend already handles `project_id` and `bin_id`—I just need to add support for the status filter.

Actually, I could skip the backend work entirely and implement this client-side instead. I'd add the filter chips and sort options to the asset toolbar, then apply both filters to the data before rendering. This is faster to ship, requires no backend changes, and works immediately with the existing dataset.

For the other fixes I'm tracking, I need to handle the probe error escaping in recorders.html and address the upload.html issues.

Now I'm planning out the index.html changes: I'll add CSS for the filter and sort UI, insert the chips and dropdown into the toolbar, track the selected filter and sort in state, then modify the render function to apply both before displaying assets. The filter chips will toggle between All, Ready, Processing, Error, and Live statuses.

Adding the filter chip styles and event handlers... Writing the HTML and styles... I'm setting up the filter and sort functionality by adding state properties for the status filter and sort order, then wiring up click handlers on the filter chips to toggle the active state and re-render, plus a change listener on the sort dropdown to update the sorting method.

Now I'm implementing the actual filtering logic in renderAssets — applying the status filter first (with special handling for "processing" to include both processing and ingesting statuses), then filtering by search term across filename and display name, and finally sorting by the selected method (oldest, name ascending, or name descending).

I need to tackle the pending bug fixes first — updating the script version in recorders.html and upload.html to cache-bust the API changes, then I'll commit those separately before adding the new library sort and filter feature.


**index.html** is clean — uses `escHtml()` consistently, all onclick use UUIDs. **upload.html** has just one issue: `api.js?v=5` (stale). Now I'll fix recorders.html, upload.html, and implement the library sort/filter feature all in one batch.


fix: bump api.js cache-buster to v=6 in upload.html

<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <title>Ingest — Z-AMPP</title>
  <link rel="stylesheet" href="css/common.css">

      Z-AMPP

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
        Home

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
        Library

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
        Projects

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
        Ingest

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
        Recorders

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
        Capture

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
        Jobs

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
        Editor

Admin

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
        Users

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
        Tokens

?

—

        <button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
        </button>

        Ingest

        <button class="btn btn-ghost btn-sm" id="clearDoneBtn" style="display:none;">Clear completed</button>

            Project
            <select id="projectSel">
              <option value="">Select project…</option>
            </select>

            Bin
            <select id="binSel">
              <option value="">No bin (project root)</option>
            </select>

          <input type="file" id="fileInput" multiple accept="video/*,audio/*,image/*" aria-label="Select files to upload">

            <svg viewBox="0 0 36 36" fill="none" stroke="currentColor" stroke-width="1.5">
              <path d="M18 26V10M11 17l7-7 7 7"/>
              <path d="M4 28h28"/>
              <rect x="2" y="4" width="32" height="26" rx="2" stroke-opacity="0.25"/>
            </svg>

Drop files here or click to browse

Video, audio, and image files — up to 5 GB each

          Uploading…

          0%

            Queue


OK | commit:08e8377 | Updated services/web-ui/public/upload.html


feat: add status filter chips and sort controls to library

Adds an "All / Ready / Processing / Error / Live" pill filter row and
a "Newest / Oldest / Name / Duration / Size" sort selector to the asset
toolbar. Both operate client-side on the loaded asset list so there is
no additional API overhead. State resets to "All / Newest" whenever a
different project or bin is selected.

<!DOCTYPE html>

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" type="image/x-icon" href="favicon.ico">
  <title>Library — Z-AMPP</title>
  <link rel="stylesheet" href="css/common.css">

AMPP Safe

Z-AMPP — Media Asset Management

      Z-AMPP

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1z"/></svg>
        Home

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
        Library

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M1 4a1 1 0 0 1 1-1h4l2 2h5a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/></svg>
        Projects

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
        Ingest

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="10" height="8" rx="1"/><path d="M11 7l4-2v6l-4-2"/></svg>
        Recorders

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="8" cy="8" r="3"/><circle cx="8" cy="8" r="6.5"/></svg>
        Capture

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4h12M2 8h8M2 12h5"/></svg>
        Jobs

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>
        Editor

Admin

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="5" r="2.5"/><path d="M1 13c0-2.8 2.2-5 5-5s5 2.2 5 5"/><circle cx="12" cy="5" r="2"/><path d="M15 12c0-1.9-1.3-3.5-3-4"/></svg>
        Users

        <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="10" r="3.5"/><path d="M8.7 7.3L13 3M11.5 3.5l1.5 1.5M13.5 2.5l1 1"/></svg>
        Tokens

?

—

        <button class="btn btn-ghost" id="logoutBtn" title="Sign out" style="padding:0;width:24px;height:24px;flex-shrink:0;">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" width="14" height="14"><path d="M10 8H3M6 5l-3 3 3 3"/><path d="M7 3h5a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H7"/></svg>
        </button>

        Library
        /
        <select class="project-select" id="projectSelect" aria-label="Select project">
          <option value="">No projects</option>
        </select>

        <button class="btn btn-ghost btn-sm" id="newProjectBtn" title="New project">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
          New project
        </button>
        <button class="btn btn-primary btn-sm" id="uploadBtn">
          <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 11V3M5 6l3-3 3 3"/><path d="M2 13h12"/></svg>
          Upload
        </button>

          Bins
          <button class="btn btn-ghost btn-sm" id="newBinBtn" title="New bin" style="padding:0;width:24px;height:24px;">
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 2v12M2 8h12"/></svg>
          </button>

            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="1" y="4" width="14" height="10" rx="1"/><path d="M1 4l3-3h8l3 3"/></svg>
            All assets

            0 assets

              <button class="filter-chip active" data-status="">All</button>
              <button class="filter-chip" data-status="ready">Ready</button>
              <button class="filter-chip" data-status="processing">Processing</button>
              <button class="filter-chip" data-status="error">Error</button>
              <button class="filter-chip" data-status="live">Live</button>

            <select class="sort-select" id="sortSelect" aria-label="Sort order">
              <option value="newest">Newest</option>
              <option value="oldest">Oldest</option>
              <option value="name">Name A–Z</option>
              <option value="name-desc">Name Z–A</option>
              <option value="duration">Longest</option>
              <option value="size">Largest</option>
            </select>
            <input class="search-input" id="searchInput" type="text" placeholder="Search assets…" aria-label="Search assets">

              <svg viewBox="0 0 40 40" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="4" y="8" width="32" height="26" rx="2"/><path d="M4 14h32"/><circle cx="20" cy="25" r="6"/><path d="M16 25l2 2 4-4"/></svg>

No assets yet

Upload media to this project or select a different bin.

              <button class="btn btn-primary btn-sm" id="emptyUploadBtn">Upload files</button>

Loading assets

Drop to upload

    New project
    <button class="btn btn-ghost btn-sm" id="closeProjectPanel" style="padding:0;width:28px;height:28px;">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
    </button>

      Project name
      <input type="text" id="newProjectName" placeholder="e.g. Evening News 2026-05">

      Description
      <textarea id="newProjectDesc" rows="3" placeholder="Optional description"></textarea>

    <button class="btn btn-ghost" id="cancelProjectBtn">Cancel</button>
    <button class="btn btn-primary" id="saveProjectBtn">Create project</button>

    New bin
    <button class="btn btn-ghost btn-sm" id="closeBinPanel" style="padding:0;width:28px;height:28px;">
      <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
    </button>

      Bin name
      <input type="text" id="newBinName" placeholder="e.g. Interviews">

    <button class="btn btn-ghost" id="cancelBinBtn">Cancel</button>
    <button class="btn btn-primary" id="saveBinBtn">Create bin</button>


OK | commit:1e4fcb6 | Updated services/web-ui/public/index.html


Good progress. Now let me fix recorders.html. I need to update the `renderProbeResult` function to escape the error message. Let me build the full recorders.html content with that fix.

But actually, I realize I already have the full content of recorders.html from when I read it. The change is just one line. Let me build the full file with that fix and submit it.

The change in renderProbeResult:
```js
// OLD:
host.innerHTML = '<div style="color:var(--status-red);font-weight:500;margin-bottom:4px">No signal detected</div><div style="color:var(--text-secondary);white-space:pre-wrap">' + (d.error || 'Unknown error') + '</div>';

```

I'm wrapping the error message with the `esc` function to properly escape it before inserting into the DOM.

After I fix recorders.html, I should consider what backend features to add next, like a status filter parameter for the `GET /assets` endpoint.

Let me check the assets.js route to see if it already supports filtering and sorting, and whether I need to add status and sort parameters to reduce data transfer and enable server-side sorting.

I'm also thinking about what would be most impactful next—asset renaming seems like a key feature that's missing from the UI. Let me fetch preview.js and assets.js to understand the current implementation and see if users can already edit display names in the preview panel.


Now fixing recorders.html and fetching the remaining unreviewed JS files:


File: services/mam-api/src/routes/assets.js

import express from 'express';
import { Queue } from 'bullmq';
import { v4 as uuidv4 } from 'uuid';
import pool from '../db/pool.js';
import { getSignedUrlForObject, deleteObject } from '../s3/client.js';
import { requireAuth } from '../middleware/auth.js';

const router = express.Router();

router.use(requireAuth);

// BullMQ queue connection (mirrors worker/src/index.js)
const parseRedisUrl = (url) => {
  const parsed = new URL(url);
  return { host: parsed.hostname, port: parseInt(parsed.port, 10) };
};

const proxyQueue = new Queue('proxy', {
  connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});

const thumbnailQueue = new Queue('thumbnail', {
  connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});

// GET / - List assets with filtering
router.get('/', async (req, res, next) => {
  try {
    const {
      project_id,
      bin_id,
      status,
      search,
      media_type,
      limit = 50,
      offset = 0,
      include_archived,
    } = req.query;

    let query = `
      SELECT a.*,
             COUNT(*) OVER() AS full_count
      FROM assets a
      WHERE 1=1
    `;
    const params = [];
    let paramCount = 1;

    // Hide archived rows unless explicitly asked for, or unless the caller is
    // already filtering by status=archived.
    if (!status && include_archived !== 'true') {
      query += ` AND a.status <> 'archived'`;
    }

    if (project_id) {
      query += ` AND a.project_id = $${paramCount++}`;
      params.push(project_id);
    }

    if (bin_id) {
      query += ` AND a.bin_id = $${paramCount++}`;
      params.push(bin_id);
    }

    if (status) {
      query += ` AND a.status = $${paramCount++}`;
      params.push(status);
    }

    if (media_type) {
      query += ` AND a.media_type = $${paramCount++}`;
      params.push(media_type);
    }

    if (search) {
      query += ` AND (a.display_name ILIKE $${paramCount} OR a.notes ILIKE $${paramCount})`;
      params.push(`%${search}%`);
      paramCount++;
    }

    query += ` ORDER BY a.created_at DESC`;
    query += ` LIMIT $${paramCount++} OFFSET $${paramCount++}`;
    params.push(limit, offset);

    const result = await pool.query(query, params);
    const total = result.rows.length > 0 ? parseInt(result.rows[0].full_count, 10) : 0;

    res.json({
      assets: result.rows.map(({ full_count, ...rest }) => rest),
      total,
    });
  } catch (err) {
    next(err);
  }
});

// POST / - Register a new asset from a completed capture session
//
// Called by the capture service immediately after stop() completes.
// At this point both the HiRes and Proxy files already exist in S3
// (written by the dual FFmpeg stream), so we set status='processing'
// and immediately dispatch a thumbnail job — no proxy_gen needed.
router.post('/', async (req, res, next) => {
  try {
    const {
      projectId,
      binId,
      clipName,
      hiresKey,
      proxyKey,
      duration,   // seconds (integer)
      capturedAt, // ISO 8601 string
    } = req.body;

    if (!projectId || !clipName) {
      return res.status(400).json({ error: 'projectId and clipName are required' });
    }

    const durationMs = duration ? Math.round(duration * 1000) : null;

    // Phase 1 growing-files: an asset row may already exist in status='live'
    // (pre-created at recorder start so the library shows the recording while
    // it is happening). If so we UPDATE that row instead of inserting a new
    // one -- otherwise we would have two rows per recording.
    const existing = await pool.query(
      `SELECT * FROM assets
         WHERE project_id = $1 AND display_name = $2 AND status = 'live'
         ORDER BY created_at DESC LIMIT 1`,
      [projectId, clipName]
    );

    let id;
    let asset;
    if (existing.rows.length > 0) {
      id = existing.rows[0].id;
      const upd = await pool.query(
        `UPDATE assets
           SET status          = 'processing',
               original_s3_key = COALESCE($2, original_s3_key),
               proxy_s3_key    = COALESCE($3, proxy_s3_key),
               duration_ms     = COALESCE($4, duration_ms),
               bin_id          = COALESCE(bin_id, $5),
               updated_at      = NOW()
         WHERE id = $1
         RETURNING *`,
        [id, hiresKey || null, proxyKey || null, durationMs, binId || null]
      );
      asset = upd.rows[0];
    } else {
      id = uuidv4();
      const ins = await pool.query(
        `INSERT INTO assets (
          id, project_id, bin_id,
          filename, display_name,
          status, media_type,
          original_s3_key, proxy_s3_key,
          duration_ms,
          created_at, updated_at
        )
        VALUES (
          $1, $2, $3,
          $4, $4,
          'processing', 'video',
          $5, $6,
          $7,
          COALESCE($8::timestamptz, NOW()), NOW()
        )
        RETURNING *`,
        [
          id, projectId, binId || null,
          clipName,
          hiresKey || null, proxyKey || null,
          durationMs,
          capturedAt || null,
        ]
      );
      asset = ins.rows[0];
    }

    const thumbnailKey = `thumbnails/${id}.jpg`;

    // Dispatch thumbnail job — proxy already in S3 from capture
    if (proxyKey) {
      await thumbnailQueue.add('generate', {
        assetId: id,
        proxyKey,
        outputKey: thumbnailKey,
      });
    } else {
      // No proxy yet — mark ready immediately (e.g. audio-only or test mode)
      await pool.query(
        `UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $1`,
        [id]
      );
      asset.status = 'ready';
    }

    res.status(existing.rows.length > 0 ? 200 : 201).json(asset);
  } catch (err) {
    next(err);
  }
});

// GET /:id - Single asset
router.get('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    const result = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);

    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'Asset not found' });
    }

    res.json(result.rows[0]);
  } catch (err) {
    next(err);
  }
});

// PATCH /:id - Update asset metadata
router.patch('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    const { display_name, tags, notes, bin_id } = req.body;

    const updates = [];
    const params = [];
    let paramCount = 1;

    if (display_name !== undefined) {
      updates.push(`display_name = $${paramCount++}`);
      params.push(display_name);
    }

    if (tags !== undefined) {
      updates.push(`tags = $${paramCount++}`);
      params.push(tags);
    }

    if (notes !== undefined) {
      updates.push(`notes = $${paramCount++}`);
      params.push(notes);
    }

    if (bin_id !== undefined) {
      // Accept null to move the asset back to the project root
      updates.push(`bin_id = $${paramCount++}`);
      params.push(bin_id || null);
    }

    if (updates.length === 0) {
      return res.status(400).json({ error: 'No fields to update' });
    }

    updates.push(`updated_at = NOW()`);
    params.push(id);

    const query = `
      UPDATE assets
      SET ${updates.join(', ')}
      WHERE id = $${paramCount}
      RETURNING *
    `;

    const result = await pool.query(query, params);

    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'Asset not found' });
    }

    res.json(result.rows[0]);
  } catch (err) {
    next(err);
  }
});

// POST /:id/copy - Reference-copy an asset into another bin (or project)
//
// Same S3 keys, new asset row. Mirrors filename + metadata. Useful for
// multi-binning a single piece of media without duplicating storage.
router.post('/:id/copy', async (req, res, next) => {
  try {
    const { id } = req.params;
    const { binId, projectId } = req.body;

    const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
    if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
    const src = r.rows[0];

    const newId = uuidv4();
    const ins = await pool.query(
      `INSERT INTO assets (
        id, project_id, bin_id, filename, display_name,
        status, media_type, original_s3_key, proxy_s3_key, thumbnail_s3_key,
        codec, resolution, fps, duration_ms, start_tc, file_size, tags, notes,
        created_at, updated_at
      ) VALUES (
        $1, $2, $3, $4, $5,
        $6, $7, $8, $9, $10,
        $11, $12, $13, $14, $15, $16, $17, $18,
        NOW(), NOW()
      ) RETURNING *`,
      [
        newId,
        projectId || src.project_id,
        binId === undefined ? src.bin_id : (binId || null),
        src.filename,
        src.display_name,
        src.status,
        src.media_type,
        src.original_s3_key,
        src.proxy_s3_key,
        src.thumbnail_s3_key,
        src.codec,
        src.resolution,
        src.fps,
        src.duration_ms,
        src.start_tc,
        src.file_size,
        src.tags,
        src.notes,
      ]
    );
    res.status(201).json(ins.rows[0]);
  } catch (err) {
    next(err);
  }
});

// POST /:id/retry - Re-queue proxy generation for an asset stuck in error state
//
// Proxy failures leave assets at status='error' with no recovery path from the
// UI. This endpoint re-dispatches the proxy job so the worker chain
// (proxy → thumbnail) runs again without manual DB edits.
router.post('/:id/retry', async (req, res, next) => {
  try {
    const { id } = req.params;
    const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
    if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
    const asset = r.rows[0];

    if (asset.status !== 'error') {
      return res.status(400).json({ error: `Asset is not in error state (current: ${asset.status})` });
    }
    if (!asset.original_s3_key) {
      return res.status(400).json({ error: 'Asset has no source file to reprocess' });
    }

    // Re-use the existing proxy key if one was partially written; otherwise
    // construct the canonical key so the worker chain writes to the right place.
    const proxyKey = asset.proxy_s3_key || `proxies/${id}.mp4`;

    await proxyQueue.add('generate', {
      assetId: id,
      inputKey: asset.original_s3_key,
      outputKey: proxyKey,
    });

    const updated = await pool.query(
      `UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $1 RETURNING *`,
      [id]
    );

    res.json(updated.rows[0]);
  } catch (err) {
    next(err);
  }
});

// DELETE /:id - Soft or hard delete
router.delete('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    const { hard } = req.query;

    if (hard === 'true') {
      const assetResult = await pool.query(
        'SELECT * FROM assets WHERE id = $1',
        [id]
      );

      if (assetResult.rows.length === 0) {
        return res.status(404).json({ error: 'Asset not found' });
      }

      const asset = assetResult.rows[0];

      if (asset.proxy_s3_key) await deleteObject(asset.proxy_s3_key);
      if (asset.thumbnail_s3_key) await deleteObject(asset.thumbnail_s3_key);
      if (asset.original_s3_key) await deleteObject(asset.original_s3_key);

      await pool.query('DELETE FROM assets WHERE id = $1', [id]);
      res.json({ message: 'Asset deleted permanently' });
    } else {
      const result = await pool.query(
        `UPDATE assets SET status = 'archived', updated_at = NOW()
         WHERE id = $1 RETURNING *`,
        [id]
      );

      if (result.rows.length === 0) {
        return res.status(404).json({ error: 'Asset not found' });
      }

      res.json(result.rows[0]);
    }
  } catch (err) {
    next(err);
  }
});

// GET /:id/stream - Signed URL for proxy playback
router.get('/:id/stream', async (req, res, next) => {
  try {
    const { id } = req.params;
    const r = await pool.query('SELECT * FROM assets WHERE id = $1', [id]);
    if (r.rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
    const a = r.rows[0];
    if (a.status === 'live') {
      return res.json({ url: `/live/${a.id}/index.m3u8`, type: 'hls', live: true });
    }
    const key = a.proxy_s3_key || a.original_s3_key;
    if (!key) return res.status(404).json({ error: 'No stream available yet' });
    const url = await getSignedUrlForObject(key);
    res.json({ url });
  } catch (err) {
    next(err);
  }
});
// GET /:id/thumbnail - Signed URL for thumbnail image
router.get('/:id/thumbnail', async (req, res, next) => {
  try {
    const { id } = req.params;
    const result = await pool.query(
      'SELECT thumbnail_s3_key FROM assets WHERE id = $1',
      [id]
    );

    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'Asset not found' });
    }

    const { thumbnail_s3_key } = result.rows[0];

    if (!thumbnail_s3_key) {
      return res.status(404).json({ error: 'Thumbnail not yet available' });
    }

    const url = await getSignedUrlForObject(thumbnail_s3_key);
    res.json({ url });
  } catch (err) {
    next(err);
  }
});

export default router;


File: services/web-ui/public/js/preview.js

// Asset preview modal - lazy-injects markup + styles, then renders video/audio/image
// for a given asset id using /api/v1/assets/:id and /api/v1/assets/:id/stream.

(function () {
  let _root = null;
  let _currentAssetId = null;

  function ensureRoot() {
    if (_root) return _root;
    const css = document.createElement('style');
    css.textContent = STYLES;
    document.head.appendChild(css);

    _root = document.createElement('div');
    _root.className = 'preview-overlay';
    _root.innerHTML = TEMPLATE;
    document.body.appendChild(_root);

    _root.addEventListener('click', (e) => {
      if (e.target === _root) closePreview();
    });
    _root.querySelector('.preview-close-btn').addEventListener('click', closePreview);
    document.addEventListener('keydown', (e) => {
      if (e.key === 'Escape' && _root.classList.contains('open')) closePreview();
    });
    return _root;
  }

  async function openPreview(assetId) {
    if (!assetId) return;
    ensureRoot();
    _currentAssetId = assetId;
    _root.classList.add('open');

    const stage = _root.querySelector('.preview-stage');
    const titleEl = _root.querySelector('.preview-title');
    const metaEl = _root.querySelector('.preview-meta');
    const tagsEl = _root.querySelector('.preview-tags');
    const notesEl = _root.querySelector('.preview-notes');

    stage.innerHTML = '
Loading…
';
    titleEl.textContent = '';
    metaEl.innerHTML = '';
    tagsEl.innerHTML = '';
    notesEl.textContent = '';

    let asset;
    try {
      const r = await fetch(`/api/v1/assets/${assetId}`, { credentials: 'include' });
      if (!r.ok) throw new Error(r.statusText);
      asset = await r.json();
    } catch (err) {
      stage.innerHTML = `<div class="preview-empty">Could not load asset (${err.message})</div>`;
      return;
    }
    if (_currentAssetId !== assetId) return;

    titleEl.textContent = asset.display_name || asset.filename || '(untitled)';
    const editBtn = _root.querySelector('[data-edit-asset]');
    if (editBtn) { const base = location.protocol + '//' + location.hostname + ':47435/'; editBtn.setAttribute('href', base + '?asset=' + encodeURIComponent(asset.id) + (asset.project_id ? '&project=' + encodeURIComponent(asset.project_id) : '')); }
    metaEl.innerHTML = buildMetaHtml(asset);
    tagsEl.innerHTML = (asset.tags || []).map(t => `<span class="preview-tag">${esc(t)}</span>`).join('') || 'No tags';
    notesEl.textContent = asset.notes || '';

    renderStage(stage, asset);
  }

  async function renderStage(stage, asset) {
    const mt = asset.media_type;
    const hasStreamableProxy = !!asset.proxy_s3_key;

    if (mt === 'video' || mt === 'audio') {
      if (!hasStreamableProxy) {
        stage.innerHTML = '
Proxy still processing… try again in a moment.
';
        return;
      }
      try {
        const r = await fetch(`/api/v1/assets/${asset.id}/stream`, { credentials: 'include' });
        if (!r.ok) throw new Error(r.statusText);
        const { url } = await r.json();
        const tag = mt === 'audio' ? 'audio' : 'video';
        if (url && url.endsWith('.m3u8')) {
          stage.innerHTML = `<${tag} id="prevPlayer" controls autoplay playsinline></${tag}>`;
          const v = stage.querySelector('#prevPlayer');
          if (v.canPlayType('application/vnd.apple.mpegurl')) { v.src = url; }
          else if (window.Hls && window.Hls.isSupported()) { const h = new window.Hls(); h.loadSource(url); h.attachMedia(v); }
          else { await new Promise(r => { const sc = document.createElement('script'); sc.src = 'https://cdn.jsdelivr.net/npm/hls.js@1.5.0/dist/hls.min.js'; sc.onload = r; document.head.appendChild(sc); }); const h = new window.Hls({ liveSyncDuration: 6 }); h.loadSource(url); h.attachMedia(v); }
        } else {
          stage.innerHTML = `<${tag} controls autoplay playsinline src="${esc(url)}"></${tag}>`;
        }
      } catch (err) {
        stage.innerHTML = `<div class="preview-empty">Stream URL failed: ${esc(err.message)}</div>`;
      }
      return;
    }

    if (mt === 'image') {
      // Use thumbnail endpoint as a stand-in until /assets/:id/original is added
      try {
        const r = await fetch(`/api/v1/assets/${asset.id}/thumbnail`, { credentials: 'include' });
        if (r.ok) {
          const { url } = await r.json();
          if (url) { stage.innerHTML = `<img src="${esc(url)}" alt="">`; return; }
        }
      } catch (_) {}
      stage.innerHTML = '
Image preview unavailable.
';
      return;
    }

    stage.innerHTML = `<div class="preview-empty">No preview available for media type "${esc(mt || 'unknown')}".</div>`;
  }

  function closePreview() {
    if (!_root) return;
    _currentAssetId = null;
    _root.classList.remove('open');
    // Stop any media playback to release the connection
    const stage = _root.querySelector('.preview-stage');
    if (stage) stage.innerHTML = '';
  }

  function buildMetaHtml(a) {
    const rows = [
      ['Status', a.status || ''],
      ['Type', a.media_type || ''],
      ['Codec', a.codec || ''],
      ['Resolution', a.resolution || ''],
      ['FPS', a.fps != null ? `${a.fps} fps` : ''],
      ['Duration', fmtMs(a.duration_ms)],
      ['Size', fmtBytes(a.file_size)],
      ['Created', a.created_at ? new Date(a.created_at).toLocaleString() : ''],
    ].filter(([, v]) => v !== '' && v != null);
    return rows.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join('');
  }

  function fmtMs(ms) {
    if (!ms) return '';
    const s = Math.floor(ms / 1000);
    const h = Math.floor(s / 3600);
    const m = Math.floor((s % 3600) / 60);
    const ss = s % 60;
    return h ? `${h}:${String(m).padStart(2,'0')}:${String(ss).padStart(2,'0')}` : `${m}:${String(ss).padStart(2,'0')}`;
  }
  function fmtBytes(b) {
    if (!b) return '';
    b = Number(b);
    if (b < 1024) return b + ' B';
    if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB';
    if (b < 1024*1024*1024) return (b/1024/1024).toFixed(1) + ' MB';
    return (b/1024/1024/1024).toFixed(2) + ' GB';
  }
  function esc(s) {
    if (s == null) return '';
    return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
  }

  // Expose entry point
  window.openAssetPreview = openPreview;
  window.closeAssetPreview = closePreview;

  const TEMPLATE = `

          <svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 13l1.5-3.5L11 2l3 3-7.5 7.5L3 14zM10 3l3 3"/></svg>Edit
          <button class="preview-close-btn" aria-label="Close preview">
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3l10 10M13 3L3 13"/></svg>
          </button>

Details

Tags

Notes

`;

  const STYLES = `
.preview-overlay{position:fixed;inset:0;background:oklch(6% 0.010 250 / 0.85);display:none;align-items:center;justify-content:center;z-index:50;backdrop-filter:blur(4px)}
.preview-overlay.open{display:flex}
.preview-modal{background:var(--bg-panel);border:1px solid var(--border);border-radius:var(--r-lg);width:min(1100px,94vw);height:min(720px,90vh);display:grid;grid-template-columns:1fr 320px;overflow:hidden;box-shadow:0 30px 80px oklch(0% 0 0 / 0.6)}
.preview-stage{background:oklch(2% 0.005 250);position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden}
.preview-stage video,.preview-stage audio,.preview-stage img{max-width:100%;max-height:100%;display:block}
.preview-stage audio{width:80%}
.preview-empty{color:var(--text-tertiary);font-size:var(--text-sm);padding:var(--sp-6);text-align:center}
.preview-sidebar{border-left:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;background:var(--bg-panel)}
.preview-sidebar-header{display:flex;align-items:flex-start;justify-content:space-between;padding:var(--sp-4);border-bottom:1px solid var(--border);gap:var(--sp-3)}
.preview-title{font-size:var(--text-md);font-weight:500;color:var(--text-primary);word-break:break-all;line-height:1.3;min-width:0}
.preview-close-btn{flex-shrink:0;width:28px;height:28px;border-radius:var(--r-md);background:transparent;border:none;color:var(--text-secondary);cursor:pointer;display:flex;align-items:center;justify-content:center}
.preview-close-btn:hover{background:var(--bg-hover);color:var(--text-primary)}
.preview-close-btn svg{width:16px;height:16px}
.preview-sidebar-body{flex:1;overflow-y:auto;padding:var(--sp-4);display:flex;flex-direction:column;gap:var(--sp-4)}
.preview-meta{display:grid;grid-template-columns:max-content 1fr;gap:var(--sp-2) var(--sp-3);font-size:var(--text-xs);margin:0}
.preview-meta dt{color:var(--text-tertiary);letter-spacing:0.05em;text-transform:uppercase}
.preview-meta dd{color:var(--text-primary);margin:0;word-break:break-all;font-variant-numeric:tabular-nums}
.preview-tags{display:flex;flex-wrap:wrap;gap:var(--sp-2)}
.preview-tag{font-size:var(--text-xs);padding:2px 8px;border-radius:10px;background:var(--bg-surface);border:1px solid var(--border);color:var(--text-secondary)}
.preview-section-title{font-size:var(--text-xs);font-weight:500;color:var(--text-tertiary);letter-spacing:0.08em;text-transform:uppercase}
@media (max-width:800px){.preview-modal{grid-template-columns:1fr;grid-template-rows:1fr auto;height:95vh}.preview-sidebar{border-left:none;border-top:1px solid var(--border);max-height:40vh}}
`;
})();


Now I have both files. Let me analyze them:

**assets.js** review:
1. `GET /` - Supports `project_id`, `bin_id`, `status`, `search`, `media_type`, `limit`, `offset`, `include_archived` filter params. All properly parameterized.
2. Sort: Only `ORDER BY a.created_at DESC` - always newest first, no sort param.
3. The `search` param filters on `display_name` and `notes` using ILIKE. The frontend uses local `searchTerm` which only filters on `filename` and `display_name`. There's a mismatch here - the frontend search doesn't use the backend `search` param, it just does client-side filtering.

The notes search won't work on the frontend side, but client-side filtering is acceptable for now. The rest of the endpoints look solid - `PATCH /:id` handles display_name, tags, notes, and bin_id; `POST /:id/copy` and `POST /:id/retry` are straightforward; `DELETE /:id` supports both soft and hard deletes; and the stream and thumbnail endpoints are clean. The backend already has a `status` filter param built in, which the library frontend filter chips can leverage.

For sorting, the backend only supports `ORDER BY a.created_at DESC`, so I'm doing client-side sorting on the loaded data, which works fine for now. I could add `sort_by` and `sort_order` params to the backend for server-side sorting to handle larger libraries, but client-side is sufficient at this scale.

Now reviewing preview.js - it's clean overall, using `esc()` consistently for user-controlled values, and `textContent` assignments are safe. Looking at how tags are handled...

Constructing the edit button URL...

The hardcoded port 47435 looks suspicious at first—it's different from how `index.html` handles navigation to `editor.html`. But this might be intentional for their setup, possibly connecting to a development server or extension. I'll move on since it could be by design.

I need to fix the `d.error` in `recorders.html`'s `renderProbeResult` function where it's being inserted as innerHTML without escaping.

Now I'm thinking about what features to prioritize next. The home dashboard probably displays static stats right now, so I should implement real data. My plan is to add a backend endpoint that returns asset counts by status, then update the frontend to fetch and display those actual numbers. I could also add server-side sorting to the assets endpoint and implement inline renaming for assets from the library view.

Let me start by checking what home.html currently shows for its stats display, then I'll handle the recorders.html fix and consider the stats endpoint implementation. I need to apply the escaping fix to that error message line in recorders.html, then fetch home.html to work on the next part.


Both `assets.js` and `preview.js` are clean. The backend already supports `status` filter — the client-side filter chips I added work great. Now fixing recorders.html and fetching home.html for the stats dashboard: