Code-review feedback: ON CONFLICT (id) only catches id collisions; a pre-existing
'dev' username would trigger a unique_violation on the username index and roll
back the migration, hard-failing the mam-api boot. Switch to bare ON CONFLICT
DO NOTHING so any unique conflict is no-op-safe.
- remove requireAuth from all route files
- delete auth.js, tokens.js, users.js routes
- delete auth middleware
- remove session middleware and all auth deps from index.js
- delete login.html and auth-guard.js from web-ui
Scope (locked in via planning Q&A):
- Identity: local accounts only (PG users table) + existing bearer
tokens for headless callers.
- Transport: httpOnly cookie session for browser, Bearer for API.
- RBAC: admin / editor / viewer roles, plus an orthogonal
is_client flag for external (agency, talent, customer) accounts.
- Bootstrap: ADMIN_BOOTSTRAP_USER + ADMIN_BOOTSTRAP_PASSWORD env
seed the first admin on a clean install. Set ADMIN_BOOTSTRAP_RESET
to force-reset the named user (break-glass).
- Rate limit: in-memory, 10 fails per 15min per (IP, username).
- Password policy: \u22658 chars, mixed case, digit, symbol; small
blocklist of common passwords; cannot equal username.
- Self-service: change own display name + password. Everything
else (role, is_client, other-user mgmt) is admin only.
- Audit log: append-only table, indexed by actor + event_type +
created_at, populated by every auth/admin event.
Files added:
- services/mam-api/src/db/migrations/022-auth-rework.sql
users.is_client + last_login_at + failed_attempts; audit_log
table with FK to users (ON DELETE SET NULL).
- services/mam-api/src/middleware/audit.js
Fire-and-forget audit() helper. Caller never awaits, failure
logs but never throws — auditing cannot break the request
that triggered it.
- services/mam-api/src/middleware/passwordPolicy.js
Shared checkPassword(pw, { username }) used by setup, user
create/update, and self-service password change.
- services/mam-api/src/tasks/bootstrapAdmin.js
Runs after migrations. No-ops unless ADMIN_BOOTSTRAP_USER +
ADMIN_BOOTSTRAP_PASSWORD are set AND (users table empty OR
ADMIN_BOOTSTRAP_RESET=true).
- services/mam-api/src/routes/audit.js
Admin-only GET /audit (paginated, filter by event_type /
actor / target / date) and GET /audit/event-types.
- services/web-ui/public/modal-account-settings.jsx
Profile + Password tabs. Triggered by sidebar user button.
Files rewritten:
- services/mam-api/src/routes/auth.js
- POST /login: regenerate(), no manual save(); audit success/
fail/lockout; updates last_login_at + failed_attempts.
- POST /logout: destroys session, audits logout.
- GET /me: returns is_client + last_login_at. Synthetic admin
when AUTH_ENABLED=false.
- GET /setup-status: drives login.html UI state.
- POST /setup: blocked once any user exists; password policy.
- POST /password: self-service. Requires current pw, runs
policy, audits, invalidates other sessions implicitly via
users.js if changed by admin.
- PATCH /me: self-service display_name update.
- services/mam-api/src/routes/users.js
- is_client field in create/update/list/get.
- Guardrails: cannot delete or demote last admin, cannot
delete self, admins cannot be flagged is_client.
- Password change invalidates all sessions for that user
(DELETE FROM sessions WHERE sess->>'userId' = id).
- Audit on every mutation.
- Password policy enforced.
- services/mam-api/src/middleware/auth.js
- requireAuth now exposes req.user.is_client.
- New requireRole(["admin","editor"], { rejectClients: true })
helper. Applied to cluster, sdk, capture routes (infra).
- Synthetic user when AUTH_ENABLED=false has is_client=false.
- services/mam-api/src/index.js
- Loads bootstrap admin after migrations.
- Wires /api/v1/audit.
- Cleans up an earlier comment block.
- services/web-ui/public/login.html
- Password hint added next to setup-mode password field.
- services/web-ui/public/shell.jsx
- Sidebar user footer is a button that opens AccountSettings.
- CLIENT badge next to role when is_client=true.
- Nav filters: clients lose ingest tree + jobs + editor;
viewers lose ingest + editor; only admins see the Admin
section. Power button hidden when synthetic user.
- services/web-ui/public/screens-admin.jsx
- Users table: new Client column with inline toggle.
- InviteUserModal: Client checkbox + password hint, gated
off when role=admin.
- Last login column replaces Created in primary view.
- CSV export includes client + last_login.
- services/web-ui/public/data.jsx
- ZAMPP_DATA.ME carries is_client + display_name.
- services/web-ui/public/index.html
- Loads dist/modal-account-settings.js.
- services/web-ui/public/styles-rest.css
- .user-row grid widened to 6 columns.
- docker-compose.yml
- Plumbs SESSION_COOKIE_SECURE + ADMIN_BOOTSTRAP_* env vars.
Deploy:
cd /opt/wild-dragon
git pull origin main
# In .env:
# AUTH_ENABLED=true
# SESSION_SECRET=<openssl rand -hex 48>
# ADMIN_BOOTSTRAP_USER=admin
# ADMIN_BOOTSTRAP_PASSWORD=<strong>
docker compose build mam-api web-ui
docker compose up -d --force-recreate --no-deps mam-api web-ui
Auth work is parked until after ship. While AUTH_ENABLED=false:
- login.html now auto-redirects to / on load (no one should ever see
the login screen while auth is off; it was confusing).
- sidebar power button is hidden entirely when /auth/me returns a
synthetic user, so there's no broken-feeling no-op control.
- Removed connect-pg-simple createTableIfMissing flag in case
v9.0.1's handling of that option was responsible for the recent
boot 502 (the schema is created by migration 021 anyway).
The /auth/login + session.regenerate() + cookie fix from c34a721
stays in place — when we re-enable auth it'll work end-to-end. The
sessions table from migration 021 stays. Operator action to restore
auth later: set AUTH_ENABLED=true + SESSION_SECRET=<random> in the
mam-api environment and restart.
Login was returning 200 + correct user JSON + writing a row to the
sessions table, but emitting zero Set-Cookie headers. Root cause:
session.regenerate() → set fields → session.save() → res.json()
Calling session.save() manually writes the store but bypasses
express-session's res.end() hook, which is the only path that adds
the Set-Cookie header to the response. The cookie was never sent to
the browser even though the session existed server-side — hence the
redirect loop.
Fix: remove the manual save(). Set the session fields and call
res.json() directly inside regenerate()'s callback; express-session
handles store write + Set-Cookie automatically on res.end().
The redirect loop after successful login was almost certainly the
`sessions` table never being created. `schema.sql` defines it but
only runs on first-init via the postgres entrypoint; instances
bootstrapped via mam-api's own migration loop never got the table.
express-session's `req.session.save()` then failed silently and the
cookie pointed at a sid that wasn't in the store — every subsequent
request looked like a brand-new visitor.
- New migration 021-ensure-sessions-table.sql (idempotent).
- connect-pg-simple now configured with `createTableIfMissing: true`
as belt-and-braces.
- `POST /auth/login` now explicitly waits for session.save() and
surfaces both regenerate() and save() errors instead of treating
them as 'success'. Logs sid + req.secure + req.protocol so we can
confirm trust-proxy is doing the right thing behind NPM.
Three concrete issues kept the login flow broken on dragonflight.live:
1. mam-api trusted no proxy headers, so behind nginx/Cloudflare the
session cookie's `secure` flag and the rate-limiter's IP keying
both saw the wrong values. Now sets `app.set('trust proxy', 1)`.
2. Session config was tied to NODE_ENV and lacked sameSite/name. Now:
- SESSION_COOKIE_SECURE env (default: true when AUTH_ENABLED) so a
site behind HTTPS gets Secure cookies regardless of NODE_ENV.
- `sameSite: 'lax'` for predictable post-login redirects.
- Renamed to `df.sid` so it's obvious in DevTools.
- `rolling: true` extends the 7-day TTL on active use.
- SESSION_SECRET is now required when AUTH_ENABLED=true; the
server refuses to start with a dev default in prod.
3. login.html silently showed the sign-in panel even when no users
exist or auth is off:
- New GET /auth/setup-status reports {needs_setup, user_count,
auth_enabled}.
- login.html calls it on load and auto-flips into setup mode when
needs_setup is true, or shows an explicit "auth is off" flash
when auth_enabled is false (the previous symptom: logout button
did nothing because /auth/me returned a synthetic admin no matter
what).
- Added a `.flash.info` style for the new neutral notice.
4. Sidebar logout used to call /auth/logout then `window.location
.reload()`. With auth off that reload landed back on the synthetic-
admin app and looked like nothing happened. It now redirects to
/login.html in all states so the operator sees feedback (and the
server-side messaging about auth being off) instead of a no-op.
Deploy notes for zampp1:
- Set AUTH_ENABLED=true and a random SESSION_SECRET in the
mam-api environment (e.g. /opt/wild-dragon/.env).
- Restart mam-api.
- First load of /login.html will auto-route to the setup form so
you can create the first admin.
RustFS returns empty bodies for ranged GETs whose start offset is past
~5.9 MB on single-file proxy MP4s. HEAD reports correct size, full GET
(`bytes=0-`) works, but `bytes=8179166-` comes back 206 + correct
Content-Range header with zero bytes. Confirmed via direct S3 probe
against broadcastmgmt.cloud/dragonmam (see scratch tests).
Workaround in mam-api `GET /api/v1/assets/:id/video` until the proxy
worker emits HLS (planned v1.2.1):
- HEAD the object first to learn total size (also gives ETag /
Last-Modified for conditional requests).
- No-Range / unparseable-Range / pre-EOF requests \u2192 plain pipe.
- Parsed `bytes=N-M` requests below RUSTFS_RANGE_SAFE_START
(default 5_500_000) \u2192 direct ranged GET, RustFS handles fine.
- Anything reaching into the broken zone \u2192 stream from offset 0,
drop bytes below start, stop at end. Memory stays flat; extra
bandwidth = (end+1 - requested-size) per seek.
- Genuinely out-of-range \u2192 416 with Cache-Control: no-store so the
browser doesn't poison its cache.
Also stashes (not yet wired up) the HLS pieces we'll need for the
follow-up: `segmentToHls` ffmpeg helper + `uploadDirectoryToS3`
worker s3 helper. Harmless additions; not referenced by any code path
yet.
Confirmed against the affected asset (a72aaa03-...): bytes=0-100k +
50% +100k native pass-through; 70% +100k and near-EOF previously hung
the browser, now stream correctly via the stitched path.
Refs #143.
Frontend / UX / a11y
- Sidebar collapse/expand toggle with localStorage persistence (#142)
- Settings sections wrap inputs in <form> with Enter-to-submit + native
validation; password autocomplete=new-password (#141, #138)
- Asset thumbnails get descriptive alt text (#140)
- Production deploy now precompiles JSX via esbuild and loads the
production React UMD instead of dev builds + in-browser Babel (#139,
#122)
- Search wrapper gets role=search; global search input gets aria-label,
role=combobox, aria-controls/aria-expanded/aria-activedescendant
wiring (#137, #135)
- Dashboard and Library no longer share the same nav icon (#136)
- Sidebar collapses off-canvas with a topbar menu button below 768 px;
mobile default is collapsed (#134)
- --text-3 bumped to #8B92A0 for WCAG AA contrast on --bg-0 (#133)
- Schedule and Library routes were rendering empty inside the .main
flex container — switched to flex:1 + min-height:0 (#131, #132,
editor + asset detail get the same fix)
- Jobs nav badge now polls /jobs?status=active every 10 s and reflects
the live count (#130, #113)
- aria-label sweep on every icon-only button (#126)
- Premiere panel release list moved to window.PREMIERE_RELEASES in
data.jsx; Editor + Settings read from the same source (#125)
- Typo setPgMclips → setPgmClips (#124)
- Stray console.error / console.warn calls gated behind
window.DF_LOG.{warn,error} (#123)
- Hardcoded /api/v1 paths route through window.ZAMPP_API_PREFIX (#115)
- Schedule rows no longer crash on null recorder_id (#117)
- EditorKeyboard guards against document.activeElement === null (#116)
- Unmount-safe timers for PasswordResetModal, Containers, Editor (#111)
- Player seek clamps below totalMs, server-side range clamping +
uncached 416 on EOF, client-side EOF-stall watchdog (#143)
- Duration badge overlap fix on narrow asset cards (#52)
Backend / security / reliability
- GET /recorders fixed N+1: single LATERAL JOIN for live_asset_id;
Docker inspects bounded to actually-recording rows (#121)
- Upload disk-storage (multer.diskStorage) streams parts to S3 instead
of buffering 500 MB in RAM (#120)
- /assets list clamps limit to MAX_LIMIT=500 to prevent OOM (#119)
- SDK upload archive listing + post-extract sanitize block zip-slip /
tar-slip and symlink escapes (#118)
- Migrations track applied state in schema_migrations, run in a
transaction, and exit non-zero on failure (#107)
- node-agent BMD_COUNT override uses BMD_DEVICE_PREFIX; filesystem
detection wins (#109, #127)
- GPU_COUNT override now merges with nvidia-smi enrichment (#108)
- /cluster/heartbeat requires a node-bound token or admin user;
tokens carry bound_hostname (#106)
- /recorders/:id/start error responses no longer echo the Docker
create payload — env vars stay out of client responses (#105)
- /recorders/probe restricts schemes (srt/rtmp/rtsp/udp/rtp), blocks
private + loopback hosts for non-admins, denies common service
ports (#104)
- Scheduler tick guarded by a Postgres advisory lock; pending/running
rows claimed via UPDATE...RETURNING + FOR UPDATE SKIP LOCKED to
survive multi-node deploys (#103)
- UUID validateUuid('id') param middleware on every /:id route (#102)
- Error handler scrubs Postgres error messages and 5xx detail (#101)
- Graceful SIGTERM/SIGINT shutdown — stops scheduler, drains the HTTP
server, ends the pool, 25 s force-exit watchdog (#100)
- AMPP sync moved from fire-and-forget to a persisted retry queue
(ampp_sync_status / attempts / next_attempt_at + scheduler retry
loop with exponential backoff) (#77)
Migrations
- 019: api_tokens.bound_hostname (#106)
- 020: assets.ampp_sync_status + retry bookkeeping (#77)
Other
- Defer #92 Growing-files per-upload toggle, #80 Audio tab, #57
Dashboard redesign, #56 Editor SPA polish phase 3, #114 S3
migration tool to v1.3
Adds per-port video signal state to the admin Cluster panel:
- New GET /cluster/devices/blackmagic/signal endpoint joins recorders by
node_id+device_index and queries each active capture container's
/capture/status (local: http://recorder-<id>:3001, remote: api_url/
sidecar/<container_id>/status). Returns receiving/connecting/lost/
error/idle/no-recorder per port plus framesReceived and currentFps.
- bmd-card.js render() now accepts portSignals (Map or object) and
overlays a colored dot on each BNC connector with pulse animation
for receiving/connecting states.
- screens-admin.jsx Cluster panel polls the new endpoint every 5s,
feeds the signal map into both the port chips (now show
RECEIVING/CONNECTING/LOST + fps) and the BMD SVG card diagram
rendered below them via a new BmdCardPanel component.
- styles-fixes.css adds bmd-card-* styles for the SVG diagram and
bmd-port-signal --pulse animation.
mam-api /video endpoint:
- S3 InvalidRange (httpStatusCode 416) was being caught and returned as 500
via next(err), which the video element treats as a fatal load error and
freezes the player. Now we catch the specific 416 case, do a no-range
HEAD-equivalent to learn the real file size, and return proper 416 with
Content-Range: bytes */<total> so the browser can recover.
screens-asset.jsx — player health + buffer visualization:
- New states: playerState ('idle'|'loading'|'playing'|'paused'|'seeking'|
'waiting'|'stalled'|'error'), playerError, buffered (array of {start,end}
ms from HTMLMediaElement.buffered), stallStart, stallElapsedMs
- Wired video element events: onProgress, onWaiting, onStalled, onPlaying,
onCanPlay, onCanPlayThrough, onSeeking, onSeeked, onError
- onError captures MediaError code+message into a console.error and the
on-screen badge so freeze causes are now visible
- Status badge overlay (top-right of player): shows SEEKING / BUFFERING /
STALLED / ERROR + elapsed seconds since the stall began
- PlaybackBar renders buffered ranges as translucent grey segments so you
can see what the browser has loaded vs. what's still pending — makes
seek-related freezes immediately obvious
S3 at broadcastmgmt.cloud (RustFS/openresty) returns 403 on range
requests that include an Origin header on presigned URLs. The HMAC
signature only covers 'host' in X-Amz-SignedHeaders, so the browser's
cross-origin Origin header breaks signature validation.
Reverted: /stream and /video no longer redirect to signed S3 URLs.
Fixed: /video now pipes through Node with:
Cache-Control: private, max-age=3600
ETag and Last-Modified forwarded from S3
This means the browser caches video segments for 1h. On seek the
browser checks its cache first — only uncached byte ranges hit the
server. Combined with the 1.5Mbps proxy (was 4Mbps), seeks should
be responsive for clips under ~10 minutes.
- GET /assets/:id/stream now returns a signed S3 URL directly (4h TTL)
instead of pointing to the /video pipe endpoint. Browser streams
directly from S3 — no Node.js bottleneck, S3 handles range requests
natively for smooth seeking.
- GET /assets/:id/video now redirects (302) to a signed S3 URL.
Belt-and-suspenders: any code still calling /video gets redirected.
- proxy.js: default bitrate changed from 10Mbps to 1.5Mbps, audio
default from 192kbps to 128kbps. DB settings already updated to
1.5Mbps. Cuts proxy file size ~6x for the same quality content.
Existing proxies need re-generation at new bitrate.
Root causes found:
1. Scheduler crashing every 15s: assets table has no error_message column.
Fix: remove error_message from UPDATE in scheduler.js (#66 regression).
2. Clip freezing: client-side filmstrip seek loop runs on main thread,
seeks same proxy the player is streaming → both stall → freeze.
Fix: replace browser seek loop entirely with server-side FFmpeg worker.
3. No dedicated filmstrip worker: filmstrip was never pre-built server-side.
Changes:
- services/mam-api/src/db/migrations/018-add-filmstrip-s3-key.sql
Add filmstrip_s3_key TEXT column to assets table
- services/worker/src/workers/filmstrip.js (new)
BullMQ worker: downloads proxy, runs FFmpeg fps filter to extract
28 evenly-spaced JPEG frames, base64-encodes them, uploads JSON
array to S3 at filmstrips/<assetId>.json, stores key in DB
- services/worker/src/workers/thumbnail.js
Queue filmstrip job automatically after thumbnail completes
- services/worker/src/index.js
Register filmstrip worker (concurrency=2), export filmstripQueue
singleton, close it on SIGTERM
- services/mam-api/src/routes/assets.js
- filmstripQueue added
- POST /reprocess?type=filmstrip now supported
- GET /:id/filmstrip returns signed S3 URL for JSON frames
- services/mam-api/src/routes/jobs.js
filmstrip queue visible in Jobs UI
- services/web-ui/public/screens-asset.jsx
Replace browser seek loop with fetch of /assets/:id/filmstrip
→ fetch S3 JSON → render frames. Zero browser-side video seeking.
Right-click and Files tab re-generate via API endpoint.
Bug fixes:
- #91: dockerApi() 10s socket timeout (Docker daemon hang)
- #77: await syncToAmpp() with .catch() — no longer fire-and-forget
- #75: migration 016 — add 'proxy','import' to job_type enum; add 'completed' to job_status
- #73: BullMQ orphan job cleanup on hard asset delete
- #70: batch-trim jobs table gets expires_at; trim-status auto-expires stale rows
- #66: scheduler tick marks stale live assets (>2h) as error
- #63: migration 017 — partial unique index prevents concurrent live asset overwrite
- #61: recorders.js uses getS3Bucket() not stale process.env.S3_BUCKET
- #60: already fixed (copy nulls proxy/thumbnail keys, requeues proxy)
- #40: already fixed (All projects clears openProject)
- #64: already fixed (sourceType/needsProxy handled)
- #90: GET /jobs now includes DB jobs table (trim jobs visible in UI)
- #74: nginx Content-Type header preserved; multer 500MB file size limit
- #68: GET /upload returns in-progress ingesting assets
- #58: /stream and /video endpoints fall back to original file for all video types
- #55: recorder poll .catch() logs auth errors cleanly; redirect stops interval
- #52: thumb-status and thumb-duration moved inside position:relative wrapper
- #50: ProjectCard gets onContextMenu handler with rename/delete menu
- #49: project context menu dismisses on contextmenu + scroll events
Features:
- #93: POST /assets/:id/reprocess?type=proxy|thumbnail — force re-queue any asset
Asset ⋯ menu now shows 'Re-generate proxy' and 'Re-generate thumbnail' buttons
UI:
- Logo: brightness(0) invert(1) filter applied consistently in sidebar, launcher,
and login — white logo pops on dark UI; inline style removed from login.html
- Fix 500 error when creating bins: missing updated_at column on bins table
(migration 013 adds the column, schema.sql updated)
- Add drag-and-drop support for moving asset cards/list rows onto bin rail items
with visual droppable highlight
- Add right-click context menu on project rail items (Rename/Delete)
- Expose RenameProjectModal via window so Library screen can reuse it
- Bins context menu already existed — was hidden by the 500 error
Add 'trim' to job_type enum, create temp_segments table with
expiry/job/asset indexes, and add conform_source_sequence_id
to assets for lineage tracking.
Closes#33
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.
- mam-api: add GET /api/v1/assets/:id/video streaming proxy that fetches
from RustFS/S3 and pipes to browser with range-request support, bypassing
direct S3 access from Chrome
- mam-api: fix /stream route to return /video proxy URL for both proxy and
original-mp4 assets; return null cleanly for non-playable sources
- s3/client: set requestChecksumCalculation/responseChecksumValidation to
WHEN_REQUIRED to suppress x-amz-checksum-mode header on signed URLs
- editor: fix loadSourceAsset to set state.sourceAsset even when no proxy
exists (info toast instead of bail-out) so Insert/Overwrite still work
- editor: add drag-and-drop from media panel to timeline — items are now
draggable, timeline container accepts drops and calls Timeline.addClip
with the asset at playhead position
- editor: add tl-drag-over CSS highlight on timeline during drag
sequences.js had the same `if (rem >= DROP)` bug as timecode.js — any
frame ≥ 4 in the first non-drop minute of each 10-minute group would
produce a timecode offset by one minute. EDL files exported from the
editor would have wrong in/out points for nearly every event.
Applies the FRAMES_FIRST_MIN (3600) boundary check fix, matching the
correction already made to services/web-ui/public/js/timecode.js.
Assets stuck in status='error' had no recovery path without manual DB
edits. Adds a retry endpoint that re-dispatches the proxy job, which
chains into thumbnail generation automatically and restores the asset
to 'processing' → 'ready' without operator intervention.
Both /init and /simple were keying originals as
`originals/${projectId}/${filename}`. Two uploads of the same filename
into the same project would share a key — the second upload would silently
overwrite the first file in S3 while both assets remained in the DB with
the same original_s3_key.
Changed to `originals/${assetId}/${filename}` (matching the proxies/
convention) so every asset has its own unique S3 prefix.
Token CRUD endpoints had no authentication guard. Without it,
unauthenticated requests could reach the handler — GET would return
empty results silently, and POST could attempt to insert a token with
user_id = NULL. All other route files in this codebase apply
requireAuth explicitly; tokens.js was simply missing it.
Dynamic `(await import('uuid')).v4()` inside the /start route handler
re-imports the module every call (though Node caches it). uuidv4 is
already imported at the top of the file.
Allows updating name, source_type, source_config, recording_codec,
recording_resolution, proxy_enabled, proxy_codec, proxy_resolution,
and project_id. Blocked while the recorder is actively recording.
The POST /conform route was inserting into the jobs table with non-existent
columns (project_id, metadata) and an invalid enum value ('pending'). Since
GET /jobs reads entirely from BullMQ, the DB insert was both incorrect and
redundant. Now we just enqueue the BullMQ job and return its ID.