DELETE /jobs/:id was throwing "404 not found" when the operator tried to
cancel a running job. BullMQ refuses job.remove() while a job is in the
active state; the route caught that error and fell through to the
404 branch, which was misleading because the job actually exists — the
queue was just refusing to drop it from under the worker.
Fix:
- Detect 'active' state explicitly and call moveToFailed(err, '0', false)
first. Token '0' bypasses the per-worker lock check (the operator-side
cancel doesn't hold the worker lock). That transitions active -> failed
and frees the queue's concurrency slot.
- If moveToFailed itself fails (lock owned by a live worker), fall back
to job.discard() so at least the result is thrown away.
- If remove() then fails (stalled, broken state), drop the job's Redis
key directly via queue.client. Last-resort obliteration.
- Stop swallowing getJob() errors — if Redis is sad, surface it via
next(err) instead of returning a misleading 404.
- Return { cancelled: true } when the job was active, so the client
can show "Cancelled" rather than "Removed" in any future toast.
While here: thumbnail jobs now run with concurrency 4 by default
(proxy 2, conform 1, import 1 unchanged). Every queue defaulted to
concurrency 1 before, so a single stalled job blocked the entire queue.
All three are overridable via PROXY_CONCURRENCY / THUMBNAIL_CONCURRENCY
/ CONFORM_CONCURRENCY env vars for nodes with more headroom.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Jobs screen only displayed an asset name when the enqueueing code
stuffed assetName into the BullMQ job data. YouTube imports did that;
upload-triggered proxy/thumbnail jobs didn't — so everything except
YouTube showed em dashes in the Asset column.
Fix it centrally: after we collect jobs from BullMQ, look up names
in one bulk SELECT against the assets table for any job that has an
assetId but no asset_name. Applies to /jobs, /jobs/:id, and the SSE
events stream. Lookup failures fall through silently rather than
500-ing the whole list.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds Ingest → YouTube. UI takes a URL + project, API enqueues a BullMQ
"import" job, worker shells out to yt-dlp, lands the MP4 in S3 at the
same originals/{assetId}/... path uploads use, then hands off to the
existing proxy queue. Imported assets share one lifecycle with uploads
from that point on.
Worker container picks up yt-dlp + python3 (apk on alpine, apt on the
GPU variant). The new 'import' queue is registered in jobs.js so it
appears in the Jobs SSE stream and retry/delete work for free.
Spec: docs/superpowers/specs/2026-05-23-youtube-importer-design.md
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- recorders: dispatch df:recorders-changed on create/start/stop/delete so the
list updates immediately instead of waiting for the 10s poll tick
- library: poll every 4s while any asset is live/processing (15s otherwise) and
listen for df:assets-changed so a stopped recorder's LIVE badge drops and
the thumbnail appears without a manual refresh
- auth: synthetic /auth/me (AUTH_ENABLED=false) now uses LOCAL_OPERATOR / USER /
USERNAME instead of hardcoding "Admin", and flags synthetic:true
- shell: Sidebar takes `me` as a prop, drops the misleading "Admin" fallback,
and surfaces an "auth off" hint when the response is synthetic
- jobs: replace the always-empty ETA column with a Time column that shows
queued/started/done/failed N ago (full timestamp on hover); widen column
- schedule: new month-calendar view (default) with events plotted on day cells
by status; clicking a day pre-fills the new-schedule modal with a 30-min
window on that day; List view kept behind a toggle
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes for issue #7 (HLS cleanup + orphan reaper) and the user's
"SRT clips ingest but won't play" complaint:
1) New POST /assets/cleanup-live-orphans — lists every directory under
/live/<uuid>/ and deletes the ones whose UUIDs don't match an asset
row. These accumulate when a recorder crashes mid-capture: the live
HLS dir is created but no asset is ever finalized in the DB, so the
files just sit on disk forever.
2) POST /assets/:id/retry now also works for assets that are 'ready'
or 'archived' but have no proxy_s3_key. The original behavior (only
re-queue when status='error') made it impossible to re-generate a
proxy for older recorder captures that landed without one — the
user could see a thumbnail in the library but the player would just
show "Preview not yet available" with no retry path.
- migration 010: asset_comments table (id, asset_id, user_id, body,
frame_ms, resolved, timestamps) with index on asset_id+created_at
- new routes mounted at /api/v1/assets/:assetId/comments — GET/POST/
PATCH/DELETE with author join (display_name + initials), nullable
user_id so comments still attach when AUTH_ENABLED is off
- Asset detail loads comments from the API on mount instead of the
empty ZAMPP_DATA.COMMENTS seed; addComment POSTs and merges the
returned row; resolved-toggle and delete are wired
- CommentsList: new trash-icon delete action per comment, helpful
empty-state copy ('Add one below to mark a frame'), tooltips on
the timestamp and resolved buttons
Now editor comments survive page reload, are visible to other users
via the same API, and pin reliably to frame_ms (integer) instead of
a parsed HH:MM:SS:FF string.
- Schedule: if start_at is more than a minute in the past, confirm()
before submitting (operator may want to fire immediately, but
shouldn't do it accidentally)
- Recorders: generateClipName now sanitizes the recorder name so the
S3 key / SMB path / ffmpeg arg stays clean — spaces become
underscores, anything outside [A-Za-z0-9._-] is dropped, capped at 40
- Asset detail: audio mute + fullscreen buttons now key off streamUrl
state (rather than videoRef.current which is null on first render)
so they reliably appear when a stream is available
Projects:
- Per-row 3-dot menu in list view: Open / Rename / Delete (PATCH + DELETE)
- ProjectCard's bottom bar now shows real ready/in-flight/error counts
for the project's assets instead of fake 70/20 segments
- After mutations, project list refreshes from /projects + recomputes
asset counts client-side
Bins:
- GET /api/v1/bins now returns every bin across every project when
no project_id is supplied; result rows include project_name + asset_count
- Asset right-click 'Move to bin' filters to bins in the same project as
the asset and surfaces project_name as a tooltip
Jobs:
- 'Retry all failed' button in the header appears when there are
failed jobs and POSTs /retry for each one in parallel
- Failed-row error message now clips with title= tooltip so 3KB
ffmpeg stderr doesn't blow out the row layout
window.PROJECT_COLORS exposed for cross-screen access.
Proxy failures ("moov atom not found"):
- root cause: failed/aborted SRT/RTMP recordings still uploaded 0-byte
(or ftyp-only ~1KB) objects to S3, which ffmpeg can't probe
- worker proxy.js now bails on inputs < 4 KiB with a clear message
before handing the file to ffmpeg
- capture-manager.stop() returns framesReceived + empty flag
- capture shutdown handler skips POST /assets entirely on empty
sessions, instead calls new POST /assets/:id/mark-empty to flip
the pre-created live asset to 'error' with a note
Library asset right-click menu:
- new AssetContextMenu component on screens-library.jsx; right-click
any asset in grid or list view to open
- actions: Open, Rename, Move to bin (lists up to 10 bins), Remove
from bin, Copy asset ID, Delete permanently (hard=true)
- viewport-aware positioning (won't clip past window edges)
- dismisses on outside click / contextmenu / scroll
- Library now refreshes via /assets after mutations; normalizeAsset
exposed on window so the re-fetch shape matches boot
- ctx-menu styles in styles-rest.css
- POST /api/v1/assets: when transitioning from 'live' to 'processing'
with a hi-res key but no proxy, queue a proxy job instead of just
flipping status='ready'. Recorder-captured clips now get a proxy
+ thumbnail like upload-path assets do
- POST /api/v1/recorders/:id/start now accepts { clipName } in the body;
operator-supplied name (sanitized to [A-Za-z0-9 ._-], capped at 80)
overrides the auto-generated <recorder>_<timestamp> fallback
- RecorderRow gets a 'Clip name (optional)' input visible when stopped;
Enter triggers Record, value sent on POST start, cleared on stop
- New POST /api/v1/assets/:id/generate-proxy and
POST /api/v1/assets/backfill-proxies for one-shot cleanup of pre-fix
clips that have a hi-res master but no proxy
- Home: new /api/v1/metrics/home endpoint buckets last 24h of assets,
jobs done/failed into hourly counts; sparklines now render real
time-series instead of decorative sine waves
- Home stat cards are now clickable (route to relevant page) and the
delta lines show real activity ("+N added in last 24h", "N completed")
- Home live-feed tiles use HlsPreview for recorders with a live_asset_id
- Users: row 3-dot menu is now a real popover with Rename / Reset
password / Delete actions wired to PATCH /users/:id and DELETE
- Users: role is now an inline <select> that PATCHes immediately
- Users: Created column replaces fake 'last active' (no last_login
tracking yet); group count is real
- Groups tab is now functional — list groups, create, expand to
show + manage members (add/remove), delete; backed by existing
/api/v1/groups CRUD
- Policies tab is now an honest 'coming soon' stub
- New icons: key, lock, edit; new .row-menu popover styles
- Settings: drop AMPP tab, rename GPU/Transcoding → Proxy encoding
with explicit 'applied to every ingested file' wording, expose
CPU codec/preset options when GPU is off
- New Capture SDKs tab (Settings): upload Blackmagic / AJA / Deltacast
SDK archives (.zip / .tar.gz) staged to /sdk/<vendor>/ inside mam-api;
BMD is fully wired into the FFmpeg build pipeline, AJA + Deltacast
staging-only pending FFmpeg patches
- mam-api: new /api/v1/sdk routes (multer upload, extract, list, delete);
Dockerfile gets unzip+tar; docker-compose mounts /mnt/NVME/MAM/sdk:/sdk
- proxy worker now reads proxy-encoding settings from DB on every job,
builds args for libx264 / NVENC / VAAPI, falls back to libx264 on
hardware-encode failure
- settings GET /s3 falls back to S3_* env vars when DB is empty so the
UI reflects what's actually wired (fixes 'not configured' false alarm)
Two bugs fixed:
1. SDI capture sidecar never had /dev/blackmagic bound — ffmpeg opened the
decklink input inside a container with no device nodes, so frame=0.
Fix: local spawns now push '/dev/blackmagic:/dev/blackmagic' onto Binds
when source_type='sdi'.
2. recorders.js always spawned sidecars against the local Docker socket
(zampp1), even when a recorder's node_id pointed at zampp2 (where the
card is). Fix: resolveNodeTarget() looks up the recorder's cluster node;
if it's a different hostname the sidecar is spawned via a new
POST /sidecar/start endpoint on the remote node-agent.
node-agent gains three new routes (all talk to the local Docker socket):
POST /sidecar/start — create + start container (host network,
privileged, /dev/blackmagic bind for sdi)
DELETE /sidecar/:id — stop + remove
GET /sidecar/:id/status — inspect + poll capture service
docker-compose.worker.yml: add /var/run/docker.sock and LIVE_DIR to
node-agent so it can spawn sidecars, and document build-capture prerequisite.: recorders.js
mam-api self-heartbeat now reads NODE_HOSTNAME so primary rows survive container restarts instead of resurrecting with the random container ID. test-cluster.sh rewritten to use jq (the python f-strings had a parse bug that silently passed the IP check) and limited the docker-bridge alarm to 172.17.x since the user LAN occupies 172.18.0.0/16.
POST/PATCH now persist all new codec columns via a whitelist. /start
forwards every codec setting to the capture container as an env var. The
live-asset created during /start now uses the recorder's container ext
(.mov vs .mp4 etc.) instead of always assuming .mov.
Heartbeat handler now overrides 172.x docker bridge IPs with the
request's source address when the request itself came from a real LAN.
Adds GET /devices/blackmagic that flattens every node's capabilities so
the recorder UI can show a card picker spanning the whole cluster.
Expands recorders with video bitrate, framerate, audio codec / bitrate
/ channels, container format, and a node_id/device_index pair so the UI
can pin SDI recorders to a specific node + DeckLink port instead of
relying on a flat "BM1/BM2" index. capture-manager.js consumes these via
env vars and builds ffmpeg args from them.
Migration 004 wrapped table creation in IF NOT EXISTS, so deploys with
a pre-existing cluster_nodes table never picked up the inline UNIQUE
constraint and accumulated duplicate hostnames on every container
restart. This migration purges older duplicates and adds the unique
index idempotently so the ON CONFLICT (hostname) upsert finally works.
node-postgres returns NUMERIC columns as strings by default. Add a
mapSeq() helper that parses frame_rate to a JS float before any response
is sent. Affected routes: GET /, POST /, PUT /:id, GET /:id.