Audit the Wild Dragon MAM codebase at https://forge.wilddragon.net/zgaetano/wild-dragon (main branch) for bugs and improvement opportunities. Use the forgejo tools to read files.

I've already fixed:
- duration_ms display in index.html
- broken conform route SQL in jobs.js
- conform worker dead DB calls
- added getMediaInfo/ffprobe to executor.js and proxy worker
- schema migrations 002 (groups/tokens) and 003 (editor/sequences)
- PATCH /recorders/:id endpoint
- Edit Recorder UI panel in recorders.html
- metadata display in editor.html media panel

Please look at these specific areas and report what you find:

1. **jobs.html** — how does it display job status? Does it handle all BullMQ states correctly? Any display bugs?
2. **services/mam-api/src/routes/assets.js** — is there any obvious missing functionality like bulk operations, filtering, or search gaps?
3. **services/mam-api/src/routes/jobs.js** — are there any remaining issues with the GET /jobs endpoint, job status mapping, or error handling?
4. **services/web-ui/public/index.html** — what does the library/asset grid look like? Any obvious bugs or UX gaps after the duration_ms fix?
5. **services/mam-api/src/routes/upload.js** — any issues with the upload flow?
6. **services/web-ui/public/upload.html** — any UX issues in the upload page?

Read each file fully and report:
- Concrete bugs (wrong field names, broken queries, etc.)
- Missing features that would be high-value and relatively simple to add
- Any inconsistencies between frontend and backend field names

Keep the report concise — 2-3 sentences per area, more detail only when you find a real bug.


I'll audit the Wild Dragon MAM codebase for you. Let me start by reading the specific files you've identified.


File: services/web-ui/public/jobs.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>Jobs — Wild Dragon MAM</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>

        Jobs

        <button class="btn btn-ghost btn-sm" id="btn-clear-done" onclick="clearCompleted()">
          Clear completed
        </button>

—

Total

—

Active

—

Completed

—

Failed

          <button class="tab-btn active" data-filter="all" onclick="setFilter('all', this)">
            All 0
          </button>
          <button class="tab-btn" data-filter="active" onclick="setFilter('active', this)">
            Active 0
          </button>
          <button class="tab-btn" data-filter="completed" onclick="setFilter('completed', this)">
            Completed 0
          </button>
          <button class="tab-btn" data-filter="failed" onclick="setFilter('failed', this)">
            Failed 0
          </button>

        <select class="form-select type-select" id="type-filter" onchange="loadJobs()">
          <option value="">All types</option>
          <option value="transcode">Transcode</option>
          <option value="proxy">Proxy</option>
          <option value="thumbnail">Thumbnail</option>
          <option value="conform">Conform</option>
          <option value="ingest">Ingest</option>
        </select>

          Live

    Job Detail
    <button class="btn btn-ghost btn-sm" onclick="closeDetail()" aria-label="Close" 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>


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 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,
      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);
  }
});

// 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/mam-api/src/routes/jobs.js

import express from 'express';
import pool from '../db/pool.js';
import { requireAuth } from '../middleware/auth.js';
import { Queue } from 'bullmq';

const router = express.Router();
router.use(requireAuth);

// ── Redis connection ──────────────────────────────────────────────────────────
const parseRedisUrl = (url) => {
  try {
    const parsed = new URL(url);
    return { host: parsed.hostname, port: parseInt(parsed.port, 10) || 6379 };
  } catch {
    return { host: 'localhost', port: 6379 };
  }
};

const redisConn = parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379');

const proxyQueue     = new Queue('proxy',     { connection: redisConn });
const thumbnailQueue = new Queue('thumbnail', { connection: redisConn });
const conformQueue   = new Queue('conform',   { connection: redisConn });

const QUEUES = [
  { queue: proxyQueue,     type: 'proxy'     },
  { queue: thumbnailQueue, type: 'thumbnail' },
  { queue: conformQueue,   type: 'conform'   },
];

// BullMQ state → API status mapping
const STATE_MAP = {
  waiting:  'waiting',
  active:   'active',
  completed:'completed',
  failed:   'failed',
  delayed:  'waiting',
  paused:   'waiting',
};

function normalizeJob(bullJob, type, apiStatus) {
  const isCompleted = apiStatus === 'completed';
  const isFailed    = apiStatus === 'failed';
  return {
    id:           `${type}:${bullJob.id}`,
    type,
    status:       apiStatus,
    progress:     typeof bullJob.progress === 'number' ? bullJob.progress : 0,
    asset_id:     bullJob.data?.assetId    || null,
    asset_name:   bullJob.data?.assetName  || null,
    created_at:   bullJob.timestamp   ? new Date(bullJob.timestamp).toISOString()   : null,
    started_at:   bullJob.processedOn ? new Date(bullJob.processedOn).toISOString() : null,
    completed_at: isCompleted && bullJob.finishedOn ? new Date(bullJob.finishedOn).toISOString() : null,
    failed_at:    isFailed    && bullJob.finishedOn ? new Date(bullJob.finishedOn).toISOString() : null,
    error:        bullJob.failedReason || null,
    metadata:     bullJob.data || {},
  };
}

async function getAllBullMQJobs() {
  const results = [];
  const allStates = ['waiting', 'active', 'completed', 'failed', 'delayed', 'paused'];
  for (const { queue, type } of QUEUES) {
    try {
      const jobs = await queue.getJobs(allStates, 0, 200);
      for (const job of jobs) {
        const state     = await job.getState();
        const apiStatus = STATE_MAP[state] || state;
        results.push(normalizeJob(job, type, apiStatus));
      }
    } catch {
      // queue may be unavailable – skip
    }
  }
  return results;
}

// ── GET / - List jobs (BullMQ queues) ────────────────────────────────────────
router.get('/', async (req, res, next) => {
  try {
    const { type, status, asset_id } = req.query;
    let jobs = await getAllBullMQJobs();

    if (type)     jobs = jobs.filter(j => j.type === type);
    if (status)   jobs = jobs.filter(j => j.status === status);
    if (asset_id) jobs = jobs.filter(j => j.asset_id === asset_id);

    jobs.sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0));
    res.json(jobs);
  } catch (err) {
    next(err);
  }
});

// ── GET /:id - Single job ─────────────────────────────────────────────────────
router.get('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    // id format: "type:bullId"  e.g. "proxy:1"
    const colonIdx = id.indexOf(':');
    const qType  = colonIdx > -1 ? id.slice(0, colonIdx) : null;
    const bullId = colonIdx > -1 ? id.slice(colonIdx + 1) : id;

    for (const { queue, type } of QUEUES) {
      if (qType && type !== qType) continue;
      try {
        const job = await queue.getJob(bullId);
        if (job) {
          const state = await job.getState();
          const apiStatus = STATE_MAP[state] || state;
          return res.json(normalizeJob(job, type, apiStatus));
        }
      } catch { /* try next queue */ }
    }
    res.status(404).json({ error: 'Job not found' });
  } catch (err) {
    next(err);
  }
});

// ── DELETE /:id - Remove a job ────────────────────────────────────────────────
router.delete('/:id', async (req, res, next) => {
  try {
    const { id } = req.params;
    const colonIdx = id.indexOf(':');
    const qType  = colonIdx > -1 ? id.slice(0, colonIdx) : null;
    const bullId = colonIdx > -1 ? id.slice(colonIdx + 1) : id;

    for (const { queue, type } of QUEUES) {
      if (qType && type !== qType) continue;
      try {
        const job = await queue.getJob(bullId);
        if (job) {
          await job.remove();
          return res.json({ success: true });
        }
      } catch { /* try next queue */ }
    }
    res.status(404).json({ error: 'Job not found' });
  } catch (err) {
    next(err);
  }
});

// ── POST /conform - Submit a conform (EDL export) job ────────────────────────
router.post('/conform', async (req, res, next) => {
  try {
    const { edl, project_id, output_format } = req.body;

    if (!edl || !project_id || !output_format) {
      return res.status(400).json({
        error: 'edl, project_id, and output_format are required',
      });
    }

    const bullJob = await conformQueue.add('conform-task', {
      edl,
      projectId:    project_id,
      outputFormat: output_format,
    });

    res.status(202).json({ id: `conform:${bullJob.id}`, status: 'queued' });
  } catch (err) {
    next(err);
  }
});

export default router;


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/mam-api/src/routes/upload.js

import express from 'express';
import multer from 'multer';
import { Queue } from 'bullmq';
import { v4 as uuidv4 } from 'uuid';
import pool from '../db/pool.js';
import { s3Client, uploadStream, deleteObject, S3_BUCKET } from '../s3/client.js';
import {
  CreateMultipartUploadCommand,
  UploadPartCommand,
  CompleteMultipartUploadCommand,
  AbortMultipartUploadCommand,
} from '@aws-sdk/client-s3';
import { getAmppConfig, ensureFolderPath } from '../ampp/client.js';

const router = express.Router();

const memoryStorage = multer.memoryStorage();
const upload = multer({ storage: memoryStorage });

const parseRedisUrl = (url) => {
  const parsed = new URL(url);
  return { host: parsed.hostname, port: parseInt(parsed.port, 10) };
};

// Only proxy queue needed here — proxy worker dispatches thumbnail once done
const proxyQueue = new Queue('proxy', {
  connection: parseRedisUrl(process.env.REDIS_URL || 'redis://queue:6379'),
});

// ---------------------------------------------------------------
// AMPP Sync Helpers
// ---------------------------------------------------------------

async function resolveBinPath(binId) {
  const segments = [];
  let currentId = binId;
  while (currentId) {
    const result = await pool.query(
      'SELECT id, name, parent_id FROM bins WHERE id = $1',
      [currentId]
    );
    if (result.rows.length === 0) break;
    const bin = result.rows[0];
    segments.unshift(bin.name);
    currentId = bin.parent_id;
  }
  return segments;
}

/**
 * Fire-and-forget: mirror asset's project/bin path into AMPP folder hierarchy.
 * Never throws — failures are logged but never surface to the caller.
 */
async function syncToAmpp(assetId, projectId, binId) {
  try {
    const config = await getAmppConfig();
    if (!config) return;

    const projResult = await pool.query(
      'SELECT name FROM projects WHERE id = $1',
      [projectId]
    );
    if (projResult.rows.length === 0) return;
    const projectName = projResult.rows[0].name;

    const segments = [projectName];
    if (binId) {
      const binSegments = await resolveBinPath(binId);
      segments.push(...binSegments);
    }

    const folderId = await ensureFolderPath(config, segments);
    if (!folderId) return;

    await pool.query(
      'UPDATE assets SET ampp_folder_id = $1, ampp_synced_at = NOW() WHERE id = $2',
      [folderId, assetId]
    );

    console.log(`[AMPP] asset ${assetId} → folder ${folderId} (${segments.join(' / ')})`);
  } catch (err) {
    console.error(`[AMPP] sync failed for asset ${assetId}:`, err.message);
  }
}

// Derive a media_type string from a MIME type
function mediaTypeFromMime(mime = '') {
  if (mime.startsWith('video')) return 'video';
  if (mime.startsWith('audio')) return 'audio';
  if (mime.startsWith('image')) return 'image';
  return 'document';
}

// POST /api/v1/upload/init - Initialize a multipart upload
router.post('/init', async (req, res, next) => {
  try {
    const { filename, fileSize, contentType, projectId, binId, tags } = req.body;

    if (!filename || !fileSize || !contentType || !projectId) {
      return res.status(400).json({
        error: 'Missing required fields: filename, fileSize, contentType, projectId',
      });
    }

    const assetId = uuidv4();
    const s3Key   = `originals/${projectId}/${filename}`;
    const tagsArray = tags ? (Array.isArray(tags) ? tags : [tags]) : [];

    await pool.query(
      `INSERT INTO assets (
        id, project_id, bin_id, filename, display_name, status,
        media_type, original_s3_key, file_size, tags, created_at, updated_at
      )
      VALUES ($1,$2,$3,$4,$4,'ingesting',$5,$6,$7,$8,NOW(),NOW())`,
      [
        assetId, projectId, binId || null, filename,
        mediaTypeFromMime(contentType),
        s3Key, fileSize,
        tagsArray.length > 0 ? tagsArray : null,
      ]
    );

    const multipartUpload = await s3Client.send(
      new CreateMultipartUploadCommand({
        Bucket: S3_BUCKET,
        Key: s3Key,
        ContentType: contentType,
      })
    );

    res.json({
      assetId,
      uploadId: multipartUpload.UploadId,
      key: s3Key,
    });
  } catch (err) {
    next(err);
  }
});

// POST /api/v1/upload/part - Upload a single part
router.post('/part', upload.single('file'), async (req, res, next) => {
  try {
    const { uploadId, key, partNumber } = req.body;

    if (!uploadId || !key || !partNumber || !req.file) {
      return res.status(400).json({
        error: 'Missing required fields: uploadId, key, partNumber, and file',
      });
    }

    const partUpload = await s3Client.send(
      new UploadPartCommand({
        Bucket: S3_BUCKET,
        Key: key,
        PartNumber: parseInt(partNumber, 10),
        UploadId: uploadId,
        Body: req.file.buffer,
      })
    );

    res.json({
      partNumber: parseInt(partNumber, 10),
      etag: partUpload.ETag,
    });
  } catch (err) {
    next(err);
  }
});

// POST /api/v1/upload/complete - Complete the multipart upload
router.post('/complete', async (req, res, next) => {
  try {
    const { uploadId, key, assetId, parts } = req.body;

    if (!uploadId || !key || !assetId || !parts || !Array.isArray(parts)) {
      return res.status(400).json({
        error: 'Missing required fields: uploadId, key, assetId, and parts array',
      });
    }

    await s3Client.send(
      new CompleteMultipartUploadCommand({
        Bucket: S3_BUCKET,
        Key: key,
        UploadId: uploadId,
        MultipartUpload: {
          // Accept both casings: api.js sends ETag (uppercase), defend against both
          Parts: parts.map(p => ({
            ETag: p.ETag || p.etag,
            PartNumber: p.partNumber || p.PartNumber,
          })),
        },
      })
    );

    // Original file in S3 — queue proxy generation
    // proxy worker will dispatch thumbnail once proxy is ready
    const result = await pool.query(
      `UPDATE assets
       SET status = 'processing', updated_at = NOW()
       WHERE id = $1 RETURNING *`,
      [assetId]
    );

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

    const asset = result.rows[0];

    await proxyQueue.add('generate', {
      assetId,
      inputKey:  key,
      outputKey: `proxies/${assetId}.mp4`,
    });

    // Sync AMPP folder — non-blocking
    syncToAmpp(asset.id, asset.project_id, asset.bin_id);

    res.json(asset);
  } catch (err) {
    next(err);
  }
});

// POST /api/v1/upload/abort - Abort upload
router.post('/abort', async (req, res, next) => {
  try {
    const { uploadId, key, assetId } = req.body;

    if (!uploadId || !key || !assetId) {
      return res.status(400).json({
        error: 'Missing required fields: uploadId, key, assetId',
      });
    }

    await s3Client.send(
      new AbortMultipartUploadCommand({ Bucket: S3_BUCKET, Key: key, UploadId: uploadId })
    );

    await pool.query('DELETE FROM assets WHERE id = $1', [assetId]);

    res.json({ message: 'Upload aborted and asset deleted' });
  } catch (err) {
    next(err);
  }
});

// POST /api/v1/upload/simple - Single-file upload for smaller files (<50 MB)
router.post('/simple', upload.single('file'), async (req, res, next) => {
  try {
    const { filename, projectId, binId, tags, contentType } = req.body;

    if (!filename || !projectId || !req.file) {
      return res.status(400).json({
        error: 'Missing required fields: filename, projectId, and file',
      });
    }

    const assetId   = uuidv4();
    const s3Key     = `originals/${projectId}/${filename}`;
    const mimeType  = contentType || req.file.mimetype;
    const tagsArray = tags ? (Array.isArray(tags) ? tags : [tags]) : [];

    await pool.query(
      `INSERT INTO assets (
        id, project_id, bin_id, filename, display_name, status,
        media_type, original_s3_key, file_size, tags, created_at, updated_at
      )
      VALUES ($1,$2,$3,$4,$4,'ingesting',$5,$6,$7,$8,NOW(),NOW())`,
      [
        assetId, projectId, binId || null, filename,
        mediaTypeFromMime(mimeType),
        s3Key, req.file.size,
        tagsArray.length > 0 ? tagsArray : null,
      ]
    );

    await uploadStream(s3Key, req.file.buffer, mimeType);

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

    const asset = result.rows[0];

    // Queue proxy — proxy worker dispatches thumbnail on completion
    await proxyQueue.add('generate', {
      assetId,
      inputKey:  s3Key,
      outputKey: `proxies/${assetId}.mp4`,
    });

    // Sync AMPP folder — non-blocking
    syncToAmpp(assetId, projectId, binId || null);

    res.json(asset);
  } catch (err) {
    next(err);
  }
});

export default router;


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


Now let me analyze each file carefully for the specific issues requested.

## Audit Report: Wild Dragon MAM Codebase

### 1. **jobs.html** — Job Status Display

The page correctly handles BullMQ states through the `STATE_MAP` defined in `/routes/jobs.js` (waiting, active, completed, failed, delayed, paused). The frontend displays these via the `statusBadge()` function with appropriate badge classes (active, waiting, completed, failed, delayed). The status filtering in tabs works correctly, treating both 'active' and 'waiting' as active jobs. No display bugs found; the implementation is solid.

### 2. **services/mam-api/src/routes/assets.js** — Missing Functionality

The assets endpoint lacks bulk operations (multi-delete, multi-move) and filtering is limited. The GET endpoint filters by project_id, bin_id, status, and media_type with basic ILIKE search on display_name/notes—no full-text search or advanced filters (tags, file_size range, date range). A high-value addition would be pagination metadata in the response (the code calculates `full_count` but doesn't return the limit/offset used). The endpoint is also missing an array operation for bulk archive/delete.

### 3. **services/mam-api/src/routes/jobs.js** — Remaining Issues

The mapping is sound overall, but there's a subtle inconsistency: when filtering by status in the GET endpoint, the code filters the normalized API status after fetching from BullMQ, which means a request for `?status=waiting` will find jobs in BullMQ states 'waiting', 'delayed', and 'paused' (all map to 'waiting'). This is correct behavior but could be clarified in comments. No SQL issues remain. Error handling is adequate with try-catch and graceful queue unavailability fallback.

### 4. **services/web-ui/public/index.html** — Library Grid & Duration Fix

The duration_ms fix is correctly applied in the card template: `${asset.duration_ms ? <span class="asset-duration">${formatDuration(asset.duration_ms / 1000)}</span> : ''}`. The grid itself is clean using CSS Grid (repeat(auto-fill, minmax(188px, 1fr))). One UX gap: the asset count doesn't update in real-time during bulk operations—it only refreshes after an operation completes. The thumbnail loading uses IntersectionObserver for lazy-loading, which is solid, but missing images silently disappear (no placeholder text when thumbnail_s3_key is missing but the image fails to load).

### 5. **services/mam-api/src/routes/upload.js** — Upload Flow Issues

The upload.js file looks correct overall, but there's a **field-name mismatch**: in the POST `/complete` endpoint, the code accepts either `ETag` or `etag` casing on parts: `ETag: p.ETag || p.etag`. The frontend (js/api.js) likely sends one casing; if that file sends lowercase `etag`, the fallback works, but this inconsistency is fragile. The AMPP sync is non-blocking (good), but on proxy queue dispatch, the code passes `inputKey` and `outputKey` while the proxy worker might expect different field names—verify the proxy worker's destructure pattern matches. The multipart completion doesn't verify the parts array isn't empty, which could cause silent S3 failures.

### 6. **services/web-ui/public/upload.html** — UX Issues

The upload page is well-designed, but a few gaps exist: no drag-to-upload overlay on the upload.html page itself (works on index.html), so users can't drag files directly onto the page. The binId parameter is optional in the context-bar but the API requires projectId—the form doesn't prevent submission without a project selected (relies on toast warning after enqueue). The multipart upload calculation uses a hardcoded 10 MB chunk size but never validates that the backend is configured for the same chunk size. The queue list shows file.size but not the media_type detected from the MIME type—user feedback would improve if we showed "📹 Video" vs "🎙 Audio" icons alongside the filename.

---

### Summary of Concrete Bugs Found:

1. **/upload.js** — Inconsistent ETag casing on S3 parts (fragile fallback pattern; should standardize)
2. **/upload.js** — Missing validation that parts array is non-empty before CompleteMultipartUploadCommand
3. **index.html** — Missing thumbnail "no image" fallback text when image fails to load
4. **upload.html** — No visual feedback preventing form submission without a project selected (early validation)

### High-Value Improvements:

- **assets.js**: Add bulk operations endpoint (POST /bulk-archive, POST /bulk-delete)
- **assets.js**: Return pagination metadata (limit, offset, total) in list response
- **upload.js**: Standardize ETag field names and validate parts array is non-empty
- **index.html**: Add error state display when thumbnail fails to load
- **upload.html**: Add project-required validation before allowing enqueue