2026-04-07 21:58:27 -04:00
import express from 'express' ;
2026-05-15 21:24:02 -04:00
import { Queue } from 'bullmq' ;
import { v4 as uuidv4 } from 'uuid' ;
2026-05-23 10:28:42 -04:00
import { promises as fs } from 'node:fs' ;
import path from 'node:path' ;
2026-04-07 21:58:27 -04:00
import pool from '../db/pool.js' ;
2026-05-20 15:49:40 -04:00
import { getSignedUrlForObject , deleteObject , s3Client , getS3Bucket } from '../s3/client.js' ;
chore: 1.2 ship-prep sweep — close 38 issues
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
2026-05-26 22:06:14 -04:00
import { GetObjectCommand , HeadObjectCommand } from '@aws-sdk/client-s3' ;
import { validateUuid } from '../middleware/errors.js' ;
feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.
- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
"Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
service-token-needs-admin/grants requirement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:37:36 -04:00
import { assertProjectAccess , accessibleProjectIds } from '../auth/authz.js' ;
import { requireAdmin } from '../middleware/auth.js' ;
2026-04-07 21:58:27 -04:00
const router = express . Router ( ) ;
feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.
- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
"Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
service-token-needs-admin/grants requirement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:37:36 -04:00
// Every /:id asset route is scoped to the asset's project. The param handler
// validates the UUID, resolves the owning project_id, and asserts at least
// 'view' access (the baseline for touching an asset at all). Mutating routes
// additionally assert 'edit' via req.assetProjectId. A missing asset is a clean
// 404 here rather than leaking existence to users without access.
router . param ( 'id' , async ( req , res , next ) => {
validateUuid ( 'id' ) ( req , res , ( ) => { } ) ;
if ( res . headersSent ) return ;
try {
const { rows } = await pool . query ( 'SELECT project_id FROM assets WHERE id = $1' , [ req . params . id ] ) ;
if ( rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'Asset not found' } ) ;
req . assetProjectId = rows [ 0 ] . project _id ;
await assertProjectAccess ( req . user , req . assetProjectId , 'view' ) ;
next ( ) ;
} catch ( err ) { next ( err ) ; }
} ) ;
// Route-level guard for mutating /:id endpoints — escalates the param handler's
// 'view' baseline to 'edit'. Reuses req.assetProjectId (already resolved).
async function requireAssetEdit ( req , res , next ) {
try {
await assertProjectAccess ( req . user , req . assetProjectId , 'edit' ) ;
next ( ) ;
} catch ( err ) { next ( err ) ; }
}
2026-04-07 21:58:27 -04:00
2026-05-15 21:24:02 -04:00
// 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 ) } ;
} ;
2026-05-19 00:17:00 -04:00
const proxyQueue = new Queue ( 'proxy' , {
connection : parseRedisUrl ( process . env . REDIS _URL || 'redis://queue:6379' ) ,
} ) ;
2026-05-15 21:24:02 -04:00
const thumbnailQueue = new Queue ( 'thumbnail' , {
connection : parseRedisUrl ( process . env . REDIS _URL || 'redis://queue:6379' ) ,
} ) ;
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
const trimQueue = new Queue ( 'trim' , {
connection : parseRedisUrl ( process . env . REDIS _URL || 'redis://queue:6379' ) ,
} ) ;
2026-05-26 12:39:44 -04:00
const filmstripQueue = new Queue ( 'filmstrip' , {
connection : parseRedisUrl ( process . env . REDIS _URL || 'redis://queue:6379' ) ,
} ) ;
2026-05-29 16:18:15 -04:00
const hlsQueue = new Queue ( 'hls' , {
connection : parseRedisUrl ( process . env . REDIS _URL || 'redis://queue:6379' ) ,
} ) ;
2026-04-07 21:58:27 -04:00
// GET / - List assets with filtering
router . get ( '/' , async ( req , res , next ) => {
try {
const {
project _id ,
bin _id ,
status ,
search ,
media _type ,
chore: 1.2 ship-prep sweep — close 38 issues
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
2026-05-26 22:06:14 -04:00
limit : rawLimit = 50 ,
offset : rawOffset = 0 ,
2026-05-17 12:55:36 -04:00
include _archived ,
2026-04-07 21:58:27 -04:00
} = req . query ;
chore: 1.2 ship-prep sweep — close 38 issues
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
2026-05-26 22:06:14 -04:00
// Issue #119 — clamp pagination so an attacker (or a buggy client) can't
// request ?limit=999999999 and OOM the API while it serialises rows.
const MAX _LIMIT = 500 ;
const limit = Math . max ( 1 , Math . min ( MAX _LIMIT , parseInt ( rawLimit , 10 ) || 50 ) ) ;
const offset = Math . max ( 0 , parseInt ( rawOffset , 10 ) || 0 ) ;
2026-04-07 21:58:27 -04:00
let query = `
SELECT a . * ,
COUNT ( * ) OVER ( ) AS full _count
FROM assets a
WHERE 1 = 1
` ;
const params = [ ] ;
let paramCount = 1 ;
feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.
- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
"Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
service-token-needs-admin/grants requirement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:37:36 -04:00
// Scope to projects the caller can access (admins are unfiltered). Without
// this, a granted user would see every asset across every project.
const access = await accessibleProjectIds ( req . user ) ;
if ( ! access . all ) {
if ( access . ids . size === 0 ) return res . json ( { assets : [ ] , total : 0 } ) ;
query += ` AND a.project_id = ANY( $ ${ paramCount ++ } ::uuid[]) ` ;
params . push ( [ ... access . ids ] ) ;
}
2026-05-25 19:29:23 -04:00
// Exclude archived unless explicitly requested — independent of status filter
if ( include _archived !== 'true' ) {
2026-05-17 12:55:36 -04:00
query += ` AND a.status <> 'archived' ` ;
}
2026-04-07 21:58:27 -04:00
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 ) {
2026-05-19 23:10:51 -04:00
query += ` AND (a.display_name ILIKE $ ${ paramCount } OR a.filename ILIKE $ ${ paramCount } OR a.notes ILIKE $ ${ paramCount } ) ` ;
2026-04-07 21:58:27 -04:00
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 ) ;
2026-05-15 21:24:02 -04:00
const total = result . rows . length > 0 ? parseInt ( result . rows [ 0 ] . full _count , 10 ) : 0 ;
2026-04-07 21:58:27 -04:00
res . json ( {
2026-05-18 23:44:14 -04:00
assets : result . rows . map ( ( { full _count , ... rest } ) => rest ) ,
2026-04-07 21:58:27 -04:00
total ,
} ) ;
} catch ( err ) {
next ( err ) ;
}
} ) ;
2026-05-15 21:24:02 -04:00
// POST / - Register a new asset from a completed capture session
router . post ( '/' , async ( req , res , next ) => {
try {
const {
projectId ,
binId ,
clipName ,
hiresKey ,
proxyKey ,
2026-05-20 15:49:40 -04:00
duration ,
capturedAt ,
2026-05-26 10:10:44 -04:00
sourceType , // Bug #64: was ignored — now used to set media_type
needsProxy , // Bug #64: was ignored — now controls proxy queue logic
2026-06-02 11:21:05 -04:00
status , // 'live' when recording starts, 'processing' (default) when stopped
2026-05-15 21:24:02 -04:00
} = req . body ;
if ( ! projectId || ! clipName ) {
return res . status ( 400 ) . json ( { error : 'projectId and clipName are required' } ) ;
}
feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.
- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
"Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
service-token-needs-admin/grants requirement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:37:36 -04:00
// Registering an asset writes into a project — require edit access there.
await assertProjectAccess ( req . user , projectId , 'edit' ) ;
2026-05-25 19:29:23 -04:00
const durationNum = duration !== undefined && duration !== null ? Number ( duration ) : null ;
if ( durationNum !== null && ! Number . isFinite ( durationNum ) ) {
return res . status ( 400 ) . json ( { error : 'duration must be a finite number (seconds)' } ) ;
}
const durationMs = durationNum !== null ? Math . round ( durationNum * 1000 ) : null ;
2026-05-15 21:24:02 -04:00
2026-05-18 07:29:50 -04:00
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 ]
2026-05-15 21:24:02 -04:00
) ;
2026-05-26 10:10:44 -04:00
// Bug #63: refuse to overwrite a live asset — return 409 with the existing row
if ( existing . rows . length > 0 ) {
return res . status ( 409 ) . json ( {
error : 'A live asset with this name already exists' ,
asset : existing . rows [ 0 ] ,
} ) ;
}
2026-05-18 07:29:50 -04:00
let id ;
let asset ;
2026-05-26 10:10:44 -04:00
{
2026-05-18 07:29:50 -04:00
id = uuidv4 ( ) ;
2026-05-26 10:10:44 -04:00
const mediaType = ( sourceType === 'audio' ) ? 'audio' : 'video' ;
2026-06-02 11:21:05 -04:00
const assetStatus = status || 'processing' ;
2026-05-18 07:29:50 -04:00
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 ,
2026-06-02 11:21:05 -04:00
$10 , $9 ,
2026-05-18 07:29:50 -04:00
$5 , $6 ,
$7 ,
COALESCE ( $8 : : timestamptz , NOW ( ) ) , NOW ( )
)
RETURNING * ` ,
[
id , projectId , binId || null ,
clipName ,
hiresKey || null , proxyKey || null ,
durationMs ,
capturedAt || null ,
2026-05-26 10:10:44 -04:00
mediaType ,
2026-06-02 11:21:05 -04:00
assetStatus ,
2026-05-18 07:29:50 -04:00
]
) ;
asset = ins . rows [ 0 ] ;
}
const thumbnailKey = ` thumbnails/ ${ id } .jpg ` ;
2026-06-02 11:21:05 -04:00
// Skip proxy/thumbnail queue for live assets - they'll be processed after recording stops
if ( assetStatus === 'live' ) {
// Live assets stay in 'live' status until recording finishes
} else if ( needsProxy === false && proxyKey ) {
2026-05-26 10:10:44 -04:00
await pool . query ( ` UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $ 1 ` , [ id ] ) ;
asset . status = 'ready' ;
} else if ( proxyKey ) {
2026-05-25 19:29:23 -04:00
await thumbnailQueue . add ( 'generate' , { assetId : id , proxyKey , outputKey : thumbnailKey } ) ;
2026-05-22 23:41:03 -04:00
} else if ( asset . original _s3 _key ) {
const generatedProxyKey = ` proxies/ ${ id } .mp4 ` ;
await proxyQueue . add ( 'generate' , {
2026-05-25 19:29:23 -04:00
assetId : id , inputKey : asset . original _s3 _key , outputKey : generatedProxyKey ,
2026-05-22 23:41:03 -04:00
} ) ;
console . log ( ` [assets] queued proxy for ${ id } ( ${ asset . display _name } ) ` ) ;
2026-05-15 21:24:02 -04:00
} else {
2026-05-25 19:29:23 -04:00
await pool . query ( ` UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $ 1 ` , [ id ] ) ;
2026-05-15 21:24:02 -04:00
asset . status = 'ready' ;
}
2026-05-26 10:10:44 -04:00
res . status ( 201 ) . json ( asset ) ;
2026-05-15 21:24:02 -04:00
} catch ( err ) {
2026-05-26 10:10:44 -04:00
// Unique constraint violation from the partial index (migration 017) — two
// concurrent captures raced through the SELECT before either INSERT landed.
if ( err . code === '23505' && err . constraint === 'idx_assets_live_unique' ) {
return res . status ( 409 ) . json ( { error : 'A live asset with this name already exists (concurrent capture race)' } ) ;
}
2026-05-15 21:24:02 -04:00
next ( err ) ;
}
2026-05-26 11:05:50 -04:00
} ) ;
2026-05-15 21:24:02 -04:00
feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.
- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
"Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
service-token-needs-admin/grants requirement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:37:36 -04:00
// POST /cleanup-live — cross-project maintenance, admin only.
router . post ( '/cleanup-live' , requireAdmin , async ( req , res , next ) => {
2026-05-19 23:10:51 -04:00
try {
const maxAgeHours = Math . max ( 1 , parseInt ( req . query . max _age _hours || '4' , 10 ) ) ;
const result = await pool . query (
2026-05-25 19:29:23 -04:00
` UPDATE assets SET status = 'error', updated_at = NOW()
WHERE status = 'live' AND created _at < NOW ( ) - ( $1 * INTERVAL '1 hour' )
2026-05-19 23:10:51 -04:00
RETURNING id , display _name , project _id , created _at ` ,
[ maxAgeHours ]
) ;
res . json ( { cleaned : result . rowCount , assets : result . rows } ) ;
2026-05-25 19:29:23 -04:00
} catch ( err ) { next ( err ) ; }
2026-05-19 23:10:51 -04:00
} ) ;
feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.
- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
"Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
service-token-needs-admin/grants requirement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:37:36 -04:00
// POST /cleanup-live-orphans — cross-project maintenance, admin only.
router . post ( '/cleanup-live-orphans' , requireAdmin , async ( _req , res , next ) => {
2026-05-23 10:28:42 -04:00
try {
const liveRoot = process . env . LIVE _DIR || '/live' ;
let entries ;
try {
entries = await fs . readdir ( liveRoot , { withFileTypes : true } ) ;
} catch ( err ) {
if ( err . code === 'ENOENT' ) return res . json ( { reaped : 0 , kept : 0 , dirs : [ ] } ) ;
throw err ;
}
const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i ;
2026-05-25 19:29:23 -04:00
const dirIds = entries . filter ( e => e . isDirectory ( ) && uuidRe . test ( e . name ) ) . map ( e => e . name ) ;
2026-05-23 10:28:42 -04:00
if ( dirIds . length === 0 ) return res . json ( { reaped : 0 , kept : 0 , dirs : [ ] } ) ;
2026-05-25 19:29:23 -04:00
const known = await pool . query ( 'SELECT id FROM assets WHERE id = ANY($1::uuid[])' , [ dirIds ] ) ;
2026-05-23 10:28:42 -04:00
const keep = new Set ( known . rows . map ( r => r . id ) ) ;
2026-05-25 19:29:23 -04:00
const reaped = [ ] , kept = [ ] ;
2026-05-23 10:28:42 -04:00
for ( const id of dirIds ) {
if ( keep . has ( id ) ) { kept . push ( id ) ; continue ; }
const fullPath = path . join ( liveRoot , id ) ;
try {
await fs . rm ( fullPath , { recursive : true , force : true } ) ;
reaped . push ( id ) ;
console . log ( ` [assets] reaped orphan live dir ${ fullPath } ` ) ;
} catch ( err ) {
console . warn ( ` [assets] failed to reap ${ fullPath } : ${ err . message } ` ) ;
}
}
res . json ( { reaped : reaped . length , kept : kept . length , dirs : reaped } ) ;
2026-05-25 19:29:23 -04:00
} catch ( err ) { next ( err ) ; }
2026-05-23 10:28:42 -04:00
} ) ;
2026-05-20 15:49:40 -04:00
// GET /:id
2026-04-07 21:58:27 -04:00
router . get ( '/:id' , async ( req , res , next ) => {
try {
const { id } = req . params ;
const result = await pool . query ( 'SELECT * FROM assets WHERE id = $1' , [ id ] ) ;
2026-05-20 15:49:40 -04:00
if ( result . rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'Asset not found' } ) ;
2026-04-07 21:58:27 -04:00
res . json ( result . rows [ 0 ] ) ;
2026-05-25 19:29:23 -04:00
} catch ( err ) { next ( err ) ; }
2026-04-07 21:58:27 -04:00
} ) ;
2026-05-20 15:49:40 -04:00
// PATCH /:id
feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.
- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
"Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
service-token-needs-admin/grants requirement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:37:36 -04:00
router . patch ( '/:id' , requireAssetEdit , async ( req , res , next ) => {
2026-04-07 21:58:27 -04:00
try {
const { id } = req . params ;
2026-05-17 14:48:23 -04:00
const { display _name , tags , notes , bin _id } = req . body ;
2026-05-30 08:52:29 -04:00
// bin_id must reference a bin in the asset's OWN project — otherwise an
// editor in project A could stuff their asset into project B's bin tree.
// Null/empty clears the bin, which is always allowed.
if ( bin _id ) {
const bin = await pool . query ( 'SELECT project_id FROM bins WHERE id = $1' , [ bin _id ] ) ;
if ( bin . rows . length === 0 ) return res . status ( 400 ) . json ( { error : 'bin_id not found' } ) ;
if ( bin . rows [ 0 ] . project _id !== req . assetProjectId ) {
return res . status ( 400 ) . json ( { error : 'bin_id belongs to a different project' } ) ;
}
}
2026-05-25 19:29:23 -04:00
const updates = [ ] , params = [ ] ;
2026-04-07 21:58:27 -04:00
let paramCount = 1 ;
2026-05-20 15:49:40 -04:00
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 ) { updates . push ( ` bin_id = $ ${ paramCount ++ } ` ) ; params . push ( bin _id || null ) ; }
if ( updates . length === 0 ) return res . status ( 400 ) . json ( { error : 'No fields to update' } ) ;
2026-04-07 21:58:27 -04:00
updates . push ( ` updated_at = NOW() ` ) ;
params . push ( id ) ;
2026-05-20 15:49:40 -04:00
const result = await pool . query (
2026-05-25 19:29:23 -04:00
` UPDATE assets SET ${ updates . join ( ', ' ) } WHERE id = $ ${ paramCount } RETURNING * ` , params
2026-05-20 15:49:40 -04:00
) ;
if ( result . rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'Asset not found' } ) ;
2026-04-07 21:58:27 -04:00
res . json ( result . rows [ 0 ] ) ;
2026-05-25 19:29:23 -04:00
} catch ( err ) { next ( err ) ; }
2026-04-07 21:58:27 -04:00
} ) ;
2026-05-20 15:49:40 -04:00
// POST /:id/copy
feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.
- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
"Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
service-token-needs-admin/grants requirement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:37:36 -04:00
router . post ( '/:id/copy' , requireAssetEdit , async ( req , res , next ) => {
2026-05-17 14:48:23 -04:00
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 ] ;
2026-05-30 08:52:29 -04:00
// Destination project defaults to source's. If the caller overrides it,
// assert edit on the target — without this, an editor in project A could
// clone any asset they can see into project B with no grant on B.
const destProjectId = projectId || src . project _id ;
if ( projectId && projectId !== src . project _id ) {
await assertProjectAccess ( req . user , destProjectId , 'edit' ) ;
}
// Destination bin (if any) must belong to the destination project — same
// class of bug as the PATCH bin_id hole.
const destBinId = binId === undefined ? src . bin _id : ( binId || null ) ;
if ( destBinId ) {
const bin = await pool . query ( 'SELECT project_id FROM bins WHERE id = $1' , [ destBinId ] ) ;
if ( bin . rows . length === 0 ) return res . status ( 400 ) . json ( { error : 'binId not found' } ) ;
if ( bin . rows [ 0 ] . project _id !== destProjectId ) {
return res . status ( 400 ) . json ( { error : 'binId belongs to a different project than the destination' } ) ;
}
}
2026-05-17 14:48:23 -04:00
const newId = uuidv4 ( ) ;
2026-05-26 10:10:44 -04:00
// Bug #60: null out proxy_s3_key and thumbnail_s3_key on the copy to avoid
// sharing S3 objects with the source. Set status to 'processing' so the copy
// gets its own proxy generated. Re-queue proxy generation below if source exists.
2026-05-17 14:48:23 -04:00
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 (
2026-05-26 10:10:44 -04:00
$1 , $2 , $3 , $4 , $5 , $6 , $7 , $8 , NULL , NULL , $9 , $10 , $11 , $12 , $13 , $14 , $15 , $16 , NOW ( ) , NOW ( )
2026-05-17 14:48:23 -04:00
) RETURNING * ` ,
[
2026-05-30 08:52:29 -04:00
newId , destProjectId ,
destBinId ,
2026-05-26 10:10:44 -04:00
src . filename , src . display _name , 'processing' , src . media _type ,
src . original _s3 _key ,
2026-05-20 15:49:40 -04:00
src . codec , src . resolution , src . fps , src . duration _ms , src . start _tc ,
src . file _size , src . tags , src . notes ,
2026-05-17 14:48:23 -04:00
]
) ;
2026-05-26 10:10:44 -04:00
const copy = ins . rows [ 0 ] ;
// Re-queue proxy generation from original_s3_key so the copy gets its own proxy
if ( copy . original _s3 _key ) {
const newProxyKey = ` proxies/ ${ newId } .mp4 ` ;
await proxyQueue . add ( 'generate' , {
assetId : newId , inputKey : copy . original _s3 _key , outputKey : newProxyKey ,
} ) ;
console . log ( ` [assets] queued proxy for copy ${ newId } from ${ newProxyKey } ` ) ;
} else {
// No source to transcode from — mark ready immediately
await pool . query ( ` UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $ 1 ` , [ newId ] ) ;
copy . status = 'ready' ;
}
res . status ( 201 ) . json ( copy ) ;
2026-05-25 19:29:23 -04:00
} catch ( err ) { next ( err ) ; }
2026-05-17 14:48:23 -04:00
} ) ;
2026-05-25 19:29:23 -04:00
// POST /:id/mark-empty
feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.
- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
"Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
service-token-needs-admin/grants requirement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:37:36 -04:00
router . post ( '/:id/mark-empty' , requireAssetEdit , async ( req , res , next ) => {
2026-05-22 23:52:30 -04:00
try {
const { id } = req . params ;
2026-05-26 10:10:44 -04:00
// Bug #66: first check the asset exists and what status it is in
const check = await pool . query ( ` SELECT id, status FROM assets WHERE id = $ 1 ` , [ id ] ) ;
if ( check . rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'Asset not found' } ) ;
const current = check . rows [ 0 ] . status ;
// Already terminal — nothing to do, return 200 with skipped flag
if ( current === 'error' || current === 'ready' ) {
return res . status ( 200 ) . json ( { id , skipped : true } ) ;
}
// Allow update for 'live' or 'processing' (race condition on shutdown)
if ( current === 'live' || current === 'processing' ) {
await pool . query (
` UPDATE assets
SET status = 'error' ,
notes = COALESCE ( notes || E '\\n' , '' ) || 'Recording produced no frames — source never connected.' ,
updated _at = NOW ( )
WHERE id = $1 ` ,
[ id ]
) ;
return res . json ( { id } ) ;
}
// Any other status (e.g. 'archived') is incompatible
return res . status ( 409 ) . json ( {
error : ` Cannot mark-empty an asset with status ' ${ current } ' ` ,
status : current ,
} ) ;
2026-05-22 23:52:30 -04:00
} catch ( err ) { next ( err ) ; }
} ) ;
2026-05-28 23:20:02 -04:00
// POST /:id/finalize
// Capture sidecar calls this on a SUCCESSFUL recording stop to finalise the
// pre-created 'live' asset (created at recorder start, id passed as ASSET_ID).
// Previously the sidecar did POST / to create a NEW asset, which collided with
// the existing live row -> 409 -> asset stuck 'live', no jobs. Finalising by id
// flips it out of 'live', records duration + S3 keys, and kicks off the
// proxy -> thumbnail -> filmstrip job chain.
feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.
- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
"Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
service-token-needs-admin/grants requirement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:37:36 -04:00
router . post ( '/:id/finalize' , requireAssetEdit , async ( req , res , next ) => {
2026-05-28 23:20:02 -04:00
try {
const { id } = req . params ;
const { hiresKey , proxyKey , duration } = req . body ;
const check = await pool . query ( ` SELECT * FROM assets WHERE id = $ 1 ` , [ id ] ) ;
if ( check . rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'Asset not found' } ) ;
const current = check . rows [ 0 ] . status ;
// Already terminal — idempotent no-op (handles shutdown retries).
if ( current === 'ready' || current === 'error' ) {
return res . status ( 200 ) . json ( { ... check . rows [ 0 ] , skipped : true } ) ;
}
const durationNum = duration !== undefined && duration !== null ? Number ( duration ) : null ;
const durationMs = ( durationNum !== null && Number . isFinite ( durationNum ) ) ? Math . round ( durationNum * 1000 ) : null ;
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 ) ,
updated _at = NOW ( )
WHERE id = $1
RETURNING * ` ,
[ id , hiresKey || null , proxyKey || null , durationMs ]
) ;
const asset = upd . rows [ 0 ] ;
const thumbnailKey = ` thumbnails/ ${ id } .jpg ` ;
if ( asset . proxy _s3 _key ) {
// Proxy already produced by the capture sidecar — just build the
// thumbnail (which then chains filmstrip). Worker flips status->ready.
await thumbnailQueue . add ( 'generate' , { assetId : id , proxyKey : asset . proxy _s3 _key , outputKey : thumbnailKey } ) ;
console . log ( ` [assets] finalize ${ id } : queued thumbnail (proxy present) ` ) ;
} else if ( asset . original _s3 _key ) {
// No proxy yet — generate it from the hi-res master. The proxy worker
// chains thumbnail -> filmstrip on completion.
const generatedProxyKey = ` proxies/ ${ id } .mp4 ` ;
await proxyQueue . add ( 'generate' , { assetId : id , inputKey : asset . original _s3 _key , outputKey : generatedProxyKey } ) ;
console . log ( ` [assets] finalize ${ id } : queued proxy from master ` ) ;
} else {
await pool . query ( ` UPDATE assets SET status = 'ready', updated_at = NOW() WHERE id = $ 1 ` , [ id ] ) ;
asset . status = 'ready' ;
}
console . log ( ` [assets] finalized live asset ${ id } ( ${ asset . display _name } ) -> ${ asset . status } ` ) ;
res . json ( asset ) ;
} catch ( err ) { next ( err ) ; }
} ) ;
2026-06-02 11:21:05 -04:00
// POST /:id/live-thumbnail — set the poster thumbnail for a still-live asset.
// The capture sidecar extracts the first video frame from the first HLS segment
// (where the segment physically exists) and uploads it to S3, then calls this to
// record the key. This replaces the "connecting…" placeholder in the library with
// a real frame while recording is still in progress. Only touches thumbnail_s3_key
// — does NOT change status (the asset stays 'live' until the recording stops).
router . post ( '/:id/live-thumbnail' , requireAssetEdit , async ( req , res , next ) => {
try {
const { id } = req . params ;
const { thumbnailKey } = req . body ;
if ( ! thumbnailKey ) return res . status ( 400 ) . json ( { error : 'thumbnailKey is required' } ) ;
const upd = await pool . query (
` UPDATE assets SET thumbnail_s3_key = $ 2, updated_at = NOW()
WHERE id = $1 AND status = 'live'
RETURNING id , thumbnail _s3 _key ` ,
[ id , thumbnailKey ]
) ;
if ( upd . rows . length === 0 ) {
// Asset already finalized or gone — harmless, the post-stop thumbnail job covers it.
return res . status ( 200 ) . json ( { skipped : true } ) ;
}
res . json ( upd . rows [ 0 ] ) ;
} catch ( err ) { next ( err ) ; }
} ) ;
2026-05-25 19:29:23 -04:00
// POST /:id/generate-proxy
feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.
- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
"Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
service-token-needs-admin/grants requirement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:37:36 -04:00
router . post ( '/:id/generate-proxy' , requireAssetEdit , async ( req , res , next ) => {
2026-05-22 23:41:03 -04:00
try {
const { id } = req . params ;
const r = await pool . query ( 'SELECT * FROM assets WHERE id = $1' , [ id ] ) ;
if ( r . rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'Asset not found' } ) ;
const asset = r . rows [ 0 ] ;
if ( ! asset . original _s3 _key ) return res . status ( 400 ) . json ( { error : 'Asset has no hi-res source to proxy from' } ) ;
const proxyKey = asset . proxy _s3 _key || ` proxies/ ${ id } .mp4 ` ;
await proxyQueue . add ( 'generate' , { assetId : id , inputKey : asset . original _s3 _key , outputKey : proxyKey } ) ;
const updated = await pool . query (
2026-05-25 19:29:23 -04:00
` UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $ 1 RETURNING * ` , [ id ]
2026-05-22 23:41:03 -04:00
) ;
res . json ( updated . rows [ 0 ] ) ;
} catch ( err ) { next ( err ) ; }
} ) ;
feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.
- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
"Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
service-token-needs-admin/grants requirement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:37:36 -04:00
// POST /backfill-proxies — cross-project maintenance, admin only.
router . post ( '/backfill-proxies' , requireAdmin , async ( _req , res , next ) => {
2026-05-22 23:41:03 -04:00
try {
const targets = await pool . query (
` SELECT id, original_s3_key FROM assets
2026-05-25 19:29:23 -04:00
WHERE status = 'ready' AND original _s3 _key IS NOT NULL
2026-05-22 23:41:03 -04:00
AND ( proxy _s3 _key IS NULL OR proxy _s3 _key = '' )
ORDER BY created _at DESC `
) ;
for ( const asset of targets . rows ) {
const proxyKey = ` proxies/ ${ asset . id } .mp4 ` ;
2026-05-25 19:29:23 -04:00
await proxyQueue . add ( 'generate' , { assetId : asset . id , inputKey : asset . original _s3 _key , outputKey : proxyKey } ) ;
2026-05-22 23:41:03 -04:00
}
if ( targets . rows . length > 0 ) {
await pool . query (
2026-05-25 19:29:23 -04:00
` UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = ANY( $ 1) ` ,
2026-05-22 23:41:03 -04:00
[ targets . rows . map ( r => r . id ) ]
) ;
}
res . json ( { queued : targets . rows . length } ) ;
} catch ( err ) { next ( err ) ; }
} ) ;
2026-05-26 12:39:44 -04:00
// POST /:id/reprocess?type=proxy|thumbnail|filmstrip
// Force-requeue a processing job regardless of current asset status.
feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.
- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
"Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
service-token-needs-admin/grants requirement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:37:36 -04:00
router . post ( '/:id/reprocess' , requireAssetEdit , async ( req , res , next ) => {
2026-05-26 10:10:44 -04:00
try {
const { id } = req . params ;
const type = req . query . type || 'proxy' ;
2026-05-29 16:18:15 -04:00
if ( ! [ 'proxy' , 'thumbnail' , 'filmstrip' , 'hls' ] . includes ( type ) ) {
return res . status ( 400 ) . json ( { error : 'type must be "proxy", "thumbnail", "filmstrip", or "hls"' } ) ;
2026-05-26 10:10:44 -04:00
}
const r = await pool . query ( 'SELECT * FROM assets WHERE id = $1' , [ id ] ) ;
if ( r . rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'Asset not found' } ) ;
const asset = r . rows [ 0 ] ;
if ( type === 'proxy' ) {
2026-05-26 12:39:44 -04:00
if ( ! asset . original _s3 _key ) return res . status ( 400 ) . json ( { error : 'Asset has no source file' } ) ;
2026-05-26 10:10:44 -04:00
const proxyKey = ` proxies/ ${ id } .mp4 ` ;
await proxyQueue . add ( 'generate' , { assetId : id , inputKey : asset . original _s3 _key , outputKey : proxyKey } ) ;
await pool . query ( ` UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $ 1 ` , [ id ] ) ;
return res . json ( { queued : 'proxy' , assetId : id } ) ;
}
if ( type === 'thumbnail' ) {
2026-05-26 12:39:44 -04:00
if ( ! asset . proxy _s3 _key ) return res . status ( 400 ) . json ( { error : 'Asset has no proxy' } ) ;
2026-05-26 10:10:44 -04:00
const thumbnailKey = ` thumbnails/ ${ id } .jpg ` ;
2026-05-26 12:39:44 -04:00
await thumbnailQueue . add ( 'generate' , { assetId : id , proxyKey : asset . proxy _s3 _key , outputKey : thumbnailKey } ) ;
2026-05-26 10:10:44 -04:00
return res . json ( { queued : 'thumbnail' , assetId : id } ) ;
}
2026-05-26 12:39:44 -04:00
if ( type === 'filmstrip' ) {
if ( ! asset . proxy _s3 _key ) return res . status ( 400 ) . json ( { error : 'Asset has no proxy — generate proxy first' } ) ;
await filmstripQueue . add ( 'generate' , { assetId : id , proxyKey : asset . proxy _s3 _key } ) ;
return res . json ( { queued : 'filmstrip' , assetId : id } ) ;
}
2026-05-29 16:18:15 -04:00
if ( type === 'hls' ) {
// Backfill: remux the existing proxy MP4 into an HLS rendition (no re-encode).
if ( ! asset . proxy _s3 _key ) return res . status ( 400 ) . json ( { error : 'Asset has no proxy — generate proxy first' } ) ;
await hlsQueue . add ( 'generate' , { assetId : id , proxyKey : asset . proxy _s3 _key } ) ;
return res . json ( { queued : 'hls' , assetId : id } ) ;
}
2026-05-26 12:39:44 -04:00
} catch ( err ) { next ( err ) ; }
} ) ;
// GET /:id/filmstrip — returns signed URL to the pre-built filmstrip JSON
router . get ( '/:id/filmstrip' , async ( req , res , next ) => {
try {
const { id } = req . params ;
const r = await pool . query ( 'SELECT filmstrip_s3_key FROM assets WHERE id = $1' , [ id ] ) ;
if ( r . rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'Asset not found' } ) ;
const { filmstrip _s3 _key } = r . rows [ 0 ] ;
if ( ! filmstrip _s3 _key ) return res . json ( { url : null , ready : false } ) ;
const url = await getSignedUrlForObject ( filmstrip _s3 _key ) ;
res . json ( { url , ready : true } ) ;
2026-05-26 10:10:44 -04:00
} catch ( err ) { next ( err ) ; }
} ) ;
2026-05-25 19:29:23 -04:00
// POST /:id/retry
feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.
- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
"Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
service-token-needs-admin/grants requirement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:37:36 -04:00
router . post ( '/:id/retry' , requireAssetEdit , async ( req , res , next ) => {
2026-05-19 00:17:00 -04:00
try {
const { id } = req . params ;
const r = await pool . query ( 'SELECT * FROM assets WHERE id = $1' , [ id ] ) ;
if ( r . rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'Asset not found' } ) ;
const asset = r . rows [ 0 ] ;
2026-05-25 19:29:23 -04:00
if ( ! asset . original _s3 _key ) return res . status ( 400 ) . json ( { error : 'Asset has no source file to reprocess' } ) ;
const canRetry = asset . status === 'error' || ! asset . proxy _s3 _key ;
if ( ! canRetry ) return res . status ( 400 ) . json ( { error : ` Nothing to retry — asset is ${ asset . status } and already has a proxy. ` } ) ;
2026-05-19 00:17:00 -04:00
const proxyKey = asset . proxy _s3 _key || ` proxies/ ${ id } .mp4 ` ;
2026-05-20 15:49:40 -04:00
await proxyQueue . add ( 'generate' , { assetId : id , inputKey : asset . original _s3 _key , outputKey : proxyKey } ) ;
2026-05-19 00:17:00 -04:00
const updated = await pool . query (
2026-05-25 19:29:23 -04:00
` UPDATE assets SET status = 'processing', updated_at = NOW() WHERE id = $ 1 RETURNING * ` , [ id ]
2026-05-19 00:17:00 -04:00
) ;
res . json ( updated . rows [ 0 ] ) ;
2026-05-25 19:29:23 -04:00
} catch ( err ) { next ( err ) ; }
2026-05-19 00:17:00 -04:00
} ) ;
2026-05-20 15:49:40 -04:00
// DELETE /:id
feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.
- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
"Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
service-token-needs-admin/grants requirement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:37:36 -04:00
router . delete ( '/:id' , requireAssetEdit , async ( req , res , next ) => {
2026-04-07 21:58:27 -04:00
try {
const { id } = req . params ;
const { hard } = req . query ;
if ( hard === 'true' ) {
2026-05-20 15:49:40 -04:00
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' } ) ;
2026-04-07 21:58:27 -04:00
const asset = assetResult . rows [ 0 ] ;
2026-05-26 10:10:44 -04:00
// Remove any pending/waiting BullMQ jobs for this asset before deleting
// the row — prevents workers from receiving jobs for a non-existent asset.
for ( const queue of [ proxyQueue , thumbnailQueue ] ) {
try {
const waiting = await queue . getJobs ( [ 'waiting' , 'delayed' , 'prioritized' ] ) ;
for ( const job of waiting ) {
if ( job . data ? . assetId === id ) await job . remove ( ) ;
}
} catch ( e ) {
console . warn ( ` [assets] BullMQ cleanup failed for asset ${ id } : ` , e . message ) ;
}
}
2026-05-25 19:29:23 -04:00
const s3Errors = [ ] ;
for ( const key of [ asset . proxy _s3 _key , asset . thumbnail _s3 _key , asset . original _s3 _key ] ) {
if ( ! key ) continue ;
try { await deleteObject ( key ) ; }
catch ( e ) { s3Errors . push ( { key , error : e . message } ) ; console . warn ( ` [assets] s3 delete failed for ${ key } : ` , e . message ) ; }
}
2026-04-07 21:58:27 -04:00
await pool . query ( 'DELETE FROM assets WHERE id = $1' , [ id ] ) ;
2026-05-25 19:29:23 -04:00
res . json ( { message : 'Asset deleted permanently' , ... ( s3Errors . length ? { s3Errors } : { } ) } ) ;
2026-04-07 21:58:27 -04:00
} else {
const result = await pool . query (
2026-05-25 19:29:23 -04:00
` UPDATE assets SET status = 'archived', updated_at = NOW() WHERE id = $ 1 RETURNING * ` , [ id ]
2026-04-07 21:58:27 -04:00
) ;
2026-05-20 15:49:40 -04:00
if ( result . rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'Asset not found' } ) ;
2026-04-07 21:58:27 -04:00
res . json ( result . rows [ 0 ] ) ;
}
2026-05-25 19:29:23 -04:00
} catch ( err ) { next ( err ) ; }
2026-04-07 21:58:27 -04:00
} ) ;
2026-05-20 15:49:40 -04:00
// GET /:id/stream
2026-04-07 21:58:27 -04:00
router . get ( '/:id/stream' , async ( req , res , next ) => {
try {
const { id } = req . params ;
2026-05-18 07:29:50 -04:00
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 ] ;
2026-05-20 15:49:40 -04:00
if ( a . status === 'live' ) return res . json ( { url : ` /live/ ${ a . id } /index.m3u8 ` , type : 'hls' , live : true } ) ;
2026-05-29 21:44:52 -04:00
// `url` is the directly-downloadable MP4 proxy; `hls_url` is the HLS
// rendition for in-browser playback (whole-file segment GETs avoid the
// RustFS ranged-GET stitching the MP4 path needs). The Premiere plugin
// downloads `url` to a file and imports it, so `url` must NOT be the
// .m3u8 playlist — Premiere can't import a playlist ("unsupported
// compression type"). The web player prefers `hls_url` when present.
2026-05-29 16:18:15 -04:00
if ( a . hls _s3 _key ) {
2026-05-29 21:44:52 -04:00
return res . json ( {
url : ` /api/v1/assets/ ${ id } /video ` ,
type : 'mp4' ,
source : a . proxy _s3 _key ? 'proxy' : 'original' ,
hls _url : ` /api/v1/assets/ ${ id } /hls/playlist.m3u8 ` ,
} ) ;
2026-05-29 16:18:15 -04:00
}
2026-05-26 10:10:44 -04:00
const VIDEO _EXTS = [ '.mp4' , '.mov' , '.mxf' , '.ts' , '.m4v' , '.mkv' , '.avi' , '.webm' ] ;
2026-05-26 12:57:37 -04:00
const key = a . proxy _s3 _key ||
( a . original _s3 _key && VIDEO _EXTS . some ( ext => a . original _s3 _key . toLowerCase ( ) . endsWith ( ext ) )
? a . original _s3 _key : null ) ;
if ( key ) {
2026-05-26 13:40:02 -04:00
return res . json ( { url : ` /api/v1/assets/ ${ id } /video ` , type : 'mp4' , source : a . proxy _s3 _key ? 'proxy' : 'original' } ) ;
2026-05-26 10:10:44 -04:00
}
2026-05-25 19:29:23 -04:00
return res . json ( { url : null , type : null , reason : 'no_proxy' , has _source : ! ! a . original _s3 _key } ) ;
} catch ( err ) { next ( err ) ; }
2026-04-07 21:58:27 -04:00
} ) ;
2026-05-19 22:47:33 -04:00
2026-05-29 16:18:15 -04:00
// GET /:id/hls/:file — serve an HLS rendition file (playlist / init / segment).
// Whole-object passthrough from S3: no Range handling, so this sidesteps the
// RustFS ranged-GET bug entirely (every segment is a small, complete GET).
// :file is strictly validated to prevent path traversal into the bucket.
const HLS _FILE _RE = /^(playlist\.m3u8|init\.mp4|segment_\d+\.m4s)$/ ;
router . get ( '/:id/hls/:file' , async ( req , res , next ) => {
try {
const { id , file } = req . params ;
if ( ! HLS _FILE _RE . test ( file ) ) return res . status ( 400 ) . json ( { error : 'Invalid HLS file' } ) ;
const r = await pool . query ( 'SELECT hls_s3_key FROM assets WHERE id = $1' , [ id ] ) ;
if ( r . rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'Asset not found' } ) ;
const playlistKey = r . rows [ 0 ] . hls _s3 _key ;
if ( ! playlistKey ) return res . status ( 404 ) . json ( { error : 'No HLS rendition for this asset' } ) ;
// Derive the prefix from the stored playlist key (hls/<id>/playlist.m3u8)
// and request the specific file under it.
const prefix = playlistKey . replace ( /\/[^/]+$/ , '' ) ;
const key = ` ${ prefix } / ${ file } ` ;
const isPlaylist = file . endsWith ( '.m3u8' ) ;
const s3Res = await s3Client . send ( new GetObjectCommand ( { Bucket : getS3Bucket ( ) , Key : key } ) ) ;
res . writeHead ( 200 , {
'Content-Type' : isPlaylist ? 'application/vnd.apple.mpegurl' : 'video/mp4' ,
'Cache-Control' : isPlaylist ? 'no-cache' : 'private, max-age=3600' ,
... ( s3Res . ContentLength ? { 'Content-Length' : String ( s3Res . ContentLength ) } : { } ) ,
} ) ;
s3Res . Body . pipe ( res ) ;
} catch ( err ) {
if ( err && ( err . name === 'NoSuchKey' || err . $metadata ? . httpStatusCode === 404 ) ) {
return res . status ( 404 ) . json ( { error : 'HLS file not found' } ) ;
}
next ( err ) ;
}
} ) ;
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
// GET /:id/live-path
router . get ( '/:id/live-path' , async ( req , res , next ) => {
try {
const { id } = req . params ;
2026-05-25 19:29:23 -04:00
const a = await pool . query ( 'SELECT id, project_id, display_name, status FROM assets WHERE id = $1' , [ id ] ) ;
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
if ( a . rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'Asset not found' } ) ;
const asset = a . rows [ 0 ] ;
2026-05-25 19:29:23 -04:00
if ( asset . status !== 'live' ) return res . status ( 409 ) . json ( { error : 'Asset is not currently growing' , status : asset . status } ) ;
2026-05-31 15:05:06 -04:00
// Growing-files mode is now per-recorder (recorders.growing_enabled), so we
// no longer gate on the removed global `growing_enabled` setting. A
// status='live' asset already proves a growing recorder is producing this
// file; we only need the editor-facing SMB URL to build the UNC path.
const s = await pool . query ( ` SELECT key, value FROM settings WHERE key = 'growing_smb_url' ` ) ;
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
const cfg = { } ;
for ( const { key , value } of s . rows ) cfg [ key ] = value ;
2026-05-31 15:05:06 -04:00
if ( ! cfg . growing _smb _url ) return res . status ( 409 ) . json ( { error : 'No SMB URL configured — set the editor SMB URL in Settings → Storage' } ) ;
2026-05-31 22:13:01 -04:00
// The growing master is ALWAYS MXF OP1a (XDCAM HD422) on the share, regardless
// of the recorder's configured finalized container — that is the format
// Premiere supports for edit-while-record growing files (incremental index
// segments written into body partitions, readable with no footer). The file
// on the share is `<clip>.mxf`. Keep this in lock-step with GROWING_EXT in
2026-05-31 19:41:28 -04:00
// services/capture/src/capture-manager.js.
2026-05-31 22:13:01 -04:00
const ext = 'mxf' ;
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
const smbRoot = cfg . growing _smb _url . replace ( /\/+$/ , '' ) ;
2026-05-25 19:29:23 -04:00
const winPath = smbRoot . replace ( /^smb:\/\// , '\\\\' ) . replace ( /\//g , '\\' ) + ` \\ ${ asset . project _id } \\ ${ asset . display _name } . ${ ext } ` ;
const posix = smbRoot . replace ( /^smb:\/\// , '//' ) + ` / ${ asset . project _id } / ${ asset . display _name } . ${ ext } ` ;
res . json ( { smb _url : ` ${ smbRoot } / ${ asset . project _id } / ${ asset . display _name } . ${ ext } ` , win _path : winPath , posix _path : posix , project _id : asset . project _id , display _name : asset . display _name , ext } ) ;
} catch ( err ) { next ( err ) ; }
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
} ) ;
2026-05-20 15:49:40 -04:00
// GET /:id/video
2026-05-26 13:40:02 -04:00
// Proxies the S3 object through Node with proper cache headers.
// Direct S3 redirect doesn't work because broadcastmgmt.cloud (RustFS/openresty)
// rejects range requests that include an Origin header on presigned URLs — the
// signature only covers 'host', so adding Origin breaks signature validation.
// Instead we pipe through Node with:
// - ETag + Last-Modified for conditional requests (304 on repeat visits)
// - Cache-Control: private, max-age=3600 so the browser caches segments
// and doesn't re-fetch them on every seek within a session
2026-05-26 22:38:42 -04:00
// Issue #143 — RustFS returns empty bodies for ranged GETs whose start offset
// is past ~5.9 MB on single-file proxy MP4s. Confirmed via direct S3 probe:
// HEAD reports correct size, full GET (`bytes=0-`) works perfectly, but
// `bytes=8179166-` returns 206 + the right Content-Range header and a zero-
// byte body. A streaming GET from 0 reads cleanly *through* the broken zone.
//
// Workaround until the proxy worker emits HLS (planned v1.2.1): stream the
// proxy from offset 0, skip bytes the client didn't ask for, stop after the
// requested end. Browser sees a normal 206 + Content-Range. Mem stays flat;
// extra RustFS-to-mam-api bandwidth = (end+1 - actual-range) per seek.
//
// Small head-of-file ranges below RUSTFS_RANGE_SAFE_START are handled by a
// direct ranged GET — saves the streaming-from-0 cost on the common case of
// initial moov + first-segment fetch.
async function * stitchedS3Stream ( key , startByte , endByte ) {
// Yields buffers covering exactly [startByte, endByte] inclusive.
//
// RustFS only mis-serves a ranged GET when the *start* offset of the
// request is past ~5.8 MB. So we pull the object in 4 MB windows whose
// START offsets always stay below the broken threshold:
// - We anchor every chunk's start at a multiple of RUSTFS_SAFE_CHUNK
// (0, 4 MB, 8 MB, …).
// - Wait — that puts later starts past the threshold.
// Instead: skip directly to the chunk containing `startByte`, but request
// it as `bytes=anchorStart-end` where anchorStart < threshold. Since the
// bug only bites when the *request start* offset is large, we never issue
// a single GET whose Range start is past the broken zone — we instead
// exploit that a low-offset GET that *continues past* the threshold reads
// cleanly (confirmed by the bytes=0- full-GET probe).
//
// Practically: one GET from 0 that streams up through endByte, dropping
// the bytes below startByte as they arrive. Memory stays flat; we pay
// (endByte+1) bytes of RustFS-to-mam-api bandwidth per request.
const res = await s3Client . send ( new GetObjectCommand ( {
Bucket : getS3Bucket ( ) ,
Key : key ,
Range : ` bytes=0- ${ endByte } ` ,
} ) ) ;
let consumed = 0 ; // bytes seen so far from S3
let totalEmitted = 0 ;
for await ( const buf of res . Body ) {
const bufStart = consumed ; // file offset of buf[0]
const bufEnd = consumed + buf . length - 1 ;
consumed += buf . length ;
if ( bufEnd < startByte ) continue ; // entirely before window
const sliceFrom = Math . max ( 0 , startByte - bufStart ) ;
const sliceTo = Math . min ( buf . length , endByte - bufStart + 1 ) ;
if ( sliceTo > sliceFrom ) {
yield buf . subarray ( sliceFrom , sliceTo ) ;
totalEmitted += sliceTo - sliceFrom ;
}
if ( bufEnd >= endByte ) break ;
}
if ( totalEmitted === 0 ) {
throw new Error ( ` RustFS returned empty body for ${ key } bytes=0- ${ endByte } ` ) ;
}
}
2026-05-19 22:47:33 -04:00
router . get ( '/:id/video' , async ( req , res , next ) => {
try {
const { id } = req . params ;
const r = await pool . query ( 'SELECT proxy_s3_key, original_s3_key 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 ] ;
2026-05-26 10:10:44 -04:00
const VIDEO _EXTS = [ '.mp4' , '.mov' , '.mxf' , '.ts' , '.m4v' , '.mkv' , '.avi' , '.webm' ] ;
const origIsVideo = a . original _s3 _key && VIDEO _EXTS . some ( ext => a . original _s3 _key . toLowerCase ( ) . endsWith ( ext ) ) ;
const key = a . proxy _s3 _key || ( origIsVideo ? a . original _s3 _key : null ) ;
2026-05-19 22:47:33 -04:00
if ( ! key ) return res . status ( 404 ) . json ( { error : 'No browser-playable source' } ) ;
fix(player): buffer indicator + 416 instead of 500 on out-of-range S3
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
2026-05-26 16:25:40 -04:00
2026-05-26 22:38:42 -04:00
// HEAD the object to learn the true size.
chore: 1.2 ship-prep sweep — close 38 issues
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
2026-05-26 22:06:14 -04:00
let totalSize = 0 ;
2026-05-26 22:38:42 -04:00
let etag , lastModified ;
fix(player): buffer indicator + 416 instead of 500 on out-of-range S3
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
2026-05-26 16:25:40 -04:00
try {
chore: 1.2 ship-prep sweep — close 38 issues
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
2026-05-26 22:06:14 -04:00
const head = await s3Client . send ( new HeadObjectCommand ( { Bucket : getS3Bucket ( ) , Key : key } ) ) ;
2026-05-26 22:38:42 -04:00
totalSize = head . ContentLength || 0 ;
etag = head . ETag ;
lastModified = head . LastModified ;
chore: 1.2 ship-prep sweep — close 38 issues
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
2026-05-26 22:06:14 -04:00
} catch ( _ ) {
2026-05-26 22:38:42 -04:00
// HEAD failed — fall back to a plain GET (no range).
chore: 1.2 ship-prep sweep — close 38 issues
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
2026-05-26 22:06:14 -04:00
}
const rangeHeader = req . headers . range ;
2026-05-26 22:38:42 -04:00
// No Range header → stream the whole object. RustFS handles `bytes=0-`
// and "no Range" fine; this is the fast path.
if ( ! rangeHeader || totalSize === 0 ) {
const s3Res = await s3Client . send ( new GetObjectCommand ( { Bucket : getS3Bucket ( ) , Key : key } ) ) ;
const headers = {
'Content-Type' : 'video/mp4' ,
'Accept-Ranges' : 'bytes' ,
'Cache-Control' : 'private, max-age=3600' ,
} ;
if ( s3Res . ContentLength ) headers [ 'Content-Length' ] = String ( s3Res . ContentLength ) ;
if ( s3Res . ETag ) headers [ 'ETag' ] = s3Res . ETag ;
if ( s3Res . LastModified ) headers [ 'Last-Modified' ] = s3Res . LastModified . toUTCString ( ) ;
res . writeHead ( 200 , headers ) ;
s3Res . Body . pipe ( res ) ;
return ;
chore: 1.2 ship-prep sweep — close 38 issues
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
2026-05-26 22:06:14 -04:00
}
2026-05-26 22:38:42 -04:00
// Parse `bytes=START-END` / `bytes=START-`. Ignore multi-range.
const m = /^bytes=(\d+)-(\d*)$/ . exec ( rangeHeader . trim ( ) ) ;
if ( ! m ) {
// Unparseable Range — fall back to full body, browser will cope.
const s3Res = await s3Client . send ( new GetObjectCommand ( { Bucket : getS3Bucket ( ) , Key : key } ) ) ;
res . writeHead ( 200 , { 'Content-Type' : 'video/mp4' , 'Accept-Ranges' : 'bytes' } ) ;
s3Res . Body . pipe ( res ) ;
return ;
}
chore: 1.2 ship-prep sweep — close 38 issues
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
2026-05-26 22:06:14 -04:00
2026-05-26 22:38:42 -04:00
let start = parseInt ( m [ 1 ] , 10 ) ;
let end = m [ 2 ] === '' ? totalSize - 1 : parseInt ( m [ 2 ] , 10 ) ;
if ( ! Number . isFinite ( start ) || start < 0 ) start = 0 ;
if ( ! Number . isFinite ( end ) || end >= totalSize ) end = totalSize - 1 ;
if ( start >= totalSize ) {
res . writeHead ( 416 , {
'Content-Type' : 'text/plain' ,
'Content-Length' : '0' ,
'Content-Range' : ` bytes */ ${ totalSize } ` ,
'Accept-Ranges' : 'bytes' ,
'Cache-Control' : 'no-store' ,
} ) ;
return res . end ( ) ;
fix(player): buffer indicator + 416 instead of 500 on out-of-range S3
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
2026-05-26 16:25:40 -04:00
}
2026-05-26 22:38:42 -04:00
if ( start > end ) start = end ;
fix(player): buffer indicator + 416 instead of 500 on out-of-range S3
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
2026-05-26 16:25:40 -04:00
2026-05-26 22:38:42 -04:00
const contentLength = end - start + 1 ;
2026-05-26 13:40:02 -04:00
const headers = {
2026-05-26 22:38:42 -04:00
'Content-Type' : 'video/mp4' ,
'Accept-Ranges' : 'bytes' ,
'Cache-Control' : 'private, max-age=3600' ,
'Content-Range' : ` bytes ${ start } - ${ end } / ${ totalSize } ` ,
'Content-Length' : String ( contentLength ) ,
2026-05-26 13:40:02 -04:00
} ;
2026-05-26 22:38:42 -04:00
if ( etag ) headers [ 'ETag' ] = etag ;
if ( lastModified ) headers [ 'Last-Modified' ] = lastModified . toUTCString ( ) ;
// For small head-of-file ranges (entirely below the broken threshold)
// a direct ranged GET works and saves the streaming-from-0 cost.
const RUSTFS _RANGE _SAFE _START = parseInt ( process . env . RUSTFS _RANGE _SAFE _START || String ( 5_500_000 ) , 10 ) ;
if ( start < RUSTFS _RANGE _SAFE _START && end < RUSTFS _RANGE _SAFE _START ) {
const s3Res = await s3Client . send ( new GetObjectCommand ( {
Bucket : getS3Bucket ( ) , Key : key , Range : ` bytes= ${ start } - ${ end } ` ,
} ) ) ;
res . writeHead ( 206 , headers ) ;
s3Res . Body . pipe ( res ) ;
return ;
}
// Otherwise: stream from offset 0, drop bytes below `start`, stop at
// `end`. Browser sees a normal 206; mam-api stays memory-flat.
res . writeHead ( 206 , headers ) ;
try {
for await ( const buf of stitchedS3Stream ( key , start , end ) ) {
// res.write returns false when backpressure builds — pause and wait.
if ( ! res . write ( buf ) ) {
await new Promise ( r => res . once ( 'drain' , r ) ) ;
}
if ( res . destroyed ) return ;
}
res . end ( ) ;
} catch ( err ) {
console . error ( ` [video] stitch failed for ${ key } : ` , err . message ) ;
if ( ! res . headersSent ) {
res . writeHead ( 500 , { 'Content-Type' : 'text/plain' , 'Cache-Control' : 'no-store' } ) ;
res . end ( 'Upstream storage error' ) ;
} else {
res . destroy ( err ) ;
}
}
2026-05-19 22:47:33 -04:00
} catch ( err ) { next ( err ) ; }
} ) ;
2026-05-20 15:49:40 -04:00
// GET /:id/hires
2026-05-20 00:34:18 -04:00
router . get ( '/:id/hires' , async ( req , res , next ) => {
try {
const { id } = req . params ;
2026-05-25 19:29:23 -04:00
const r = await pool . query ( 'SELECT original_s3_key, filename, display_name, file_size FROM assets WHERE id = $1' , [ id ] ) ;
2026-05-20 00:34:18 -04:00
if ( r . rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'Asset not found' } ) ;
const a = r . rows [ 0 ] ;
2026-05-20 15:49:40 -04:00
if ( ! a . original _s3 _key ) return res . status ( 404 ) . json ( { error : 'No hi-res source available' } ) ;
2026-05-25 19:29:23 -04:00
const url = await getSignedUrlForObject ( a . original _s3 _key ) ;
2026-05-20 00:34:18 -04:00
const parts = a . original _s3 _key . split ( '.' ) ;
2026-05-25 19:29:23 -04:00
const ext = ( parts . length > 1 ? parts [ parts . length - 1 ] : 'mxf' ) . toLowerCase ( ) ;
const base = ( a . display _name || a . filename || id ) . replace ( /[^\w.-]/g , '_' ) . substring ( 0 , 100 ) ;
2026-05-20 00:34:18 -04:00
res . json ( { url , filename : ` ${ base } . ${ ext } ` , ext , file _size : a . file _size || null , type : 'hires' } ) ;
} catch ( err ) { next ( err ) ; }
} ) ;
2026-05-20 15:49:40 -04:00
// GET /:id/thumbnail
2026-04-07 21:58:27 -04:00
router . get ( '/:id/thumbnail' , async ( req , res , next ) => {
try {
const { id } = req . params ;
2026-05-20 15:49:40 -04:00
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' } ) ;
2026-04-07 21:58:27 -04:00
const { thumbnail _s3 _key } = result . rows [ 0 ] ;
2026-05-20 15:49:40 -04:00
if ( ! thumbnail _s3 _key ) return res . status ( 404 ) . json ( { error : 'Thumbnail not yet available' } ) ;
2026-04-07 21:58:27 -04:00
const url = await getSignedUrlForObject ( thumbnail _s3 _key ) ;
2026-05-19 23:44:17 -04:00
if ( req . query . redirect === '1' ) return res . redirect ( 302 , url ) ;
2026-04-07 21:58:27 -04:00
res . json ( { url } ) ;
2026-05-25 19:29:23 -04:00
} catch ( err ) { next ( err ) ; }
2026-04-07 21:58:27 -04:00
} ) ;
2026-05-25 19:29:23 -04:00
// POST /batch-trim
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
router . post ( '/batch-trim' , async ( req , res , next ) => {
try {
const { clips } = req . body ;
2026-05-25 19:29:23 -04:00
if ( ! Array . isArray ( clips ) || clips . length === 0 ) return res . status ( 400 ) . json ( { error : 'clips array is required and must be non-empty' } ) ;
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
for ( const c of clips ) {
if ( ! c . assetId || ! c . filename ||
2026-05-25 19:29:23 -04:00
! Number . isFinite ( Number ( c . sourceInFrames ) ) || ! Number . isFinite ( Number ( c . sourceOutFrames ) ) ||
! Number . isFinite ( Number ( c . timelineInFrames ) ) || ! Number . isFinite ( Number ( c . timelineOutFrames ) ) ||
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
! Number . isInteger ( Number ( c . trackIndex ) ) || Number ( c . trackIndex ) < 0 ) {
2026-05-25 19:29:23 -04:00
return res . status ( 400 ) . json ( { error : 'Each clip must have assetId, filename, sourceInFrames, sourceOutFrames, timelineInFrames, timelineOutFrames, and a non-negative integer trackIndex' } ) ;
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
}
}
feat(mam-api,web-ui): per-project RBAC (v2 auth layer)
Adds per-project access control on top of the flat v1 auth. admin keeps
global access; editor/viewer are scoped to projects granted to them (direct
or via group) at view (read-only) or edit (read-write) level.
- migration 026: project_access table + access_level enum
- src/auth/authz.js: central isAdmin/accessibleProjectIds/projectLevel/
assertProjectAccess
- requireAdmin middleware; admin-gate /users, /auth/users, /groups
- enforce scoping on projects, assets, bins (list filter + per-resource
view/edit + create checks); gate bulk asset maintenance + batch-trim
- grant API: GET/POST/DELETE /projects/:id/access
- web-ui: hide admin nav for non-admins, admin-route bounce, project
"Manage access" modal, rewrite Policies tab
- tests: authz, project-access, assets-access (node:test, skip w/o DB)
- deferred routers carry TODO(authz) markers; .env.example documents the
service-token-needs-admin/grants requirement
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 22:37:36 -04:00
// Authorize every source asset's project (edit) before queuing any work.
const trimAssetIds = [ ... new Set ( clips . map ( c => c . assetId ) ) ] ;
const owning = await pool . query ( 'SELECT id, project_id FROM assets WHERE id = ANY($1::uuid[])' , [ trimAssetIds ] ) ;
const projById = new Map ( owning . rows . map ( r => [ r . id , r . project _id ] ) ) ;
for ( const aid of trimAssetIds ) {
const pid = projById . get ( aid ) ;
if ( ! pid ) return res . status ( 404 ) . json ( { error : 'Asset not found: ' + aid } ) ;
await assertProjectAccess ( req . user , pid , 'edit' ) ;
}
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
const jobId = uuidv4 ( ) ;
const expiresAt = new Date ( Date . now ( ) + 24 * 60 * 60 * 1000 ) ;
2026-05-26 10:10:44 -04:00
await pool . query (
` INSERT INTO jobs (id, type, status, payload, expires_at) VALUES ( $ 1, $ 2, $ 3, $ 4, $ 5) ` ,
[ jobId , 'trim' , 'queued' , JSON . stringify ( { clips } ) , expiresAt ]
) ;
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
const clipResults = [ ] ;
for ( const c of clips ) {
const clipInstanceId = uuidv4 ( ) ;
2026-05-25 19:29:23 -04:00
await trimQueue . add ( 'trim-clip' , { jobId , clipInstanceId , assetId : c . assetId , filename : c . filename , sourceInFrames : c . sourceInFrames , sourceOutFrames : c . sourceOutFrames , timelineInFrames : c . timelineInFrames , timelineOutFrames : c . timelineOutFrames , trackIndex : c . trackIndex } ) ;
await pool . query ( ` INSERT INTO temp_segments (job_id, clip_instance_id, asset_id, s3_key, expires_at) VALUES ( $ 1, $ 2, $ 3,'', $ 4) ` , [ jobId , clipInstanceId , c . assetId , expiresAt ] ) ;
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
clipResults . push ( { clipInstanceId , status : 'queued' } ) ;
}
res . status ( 201 ) . json ( { jobId , clips : clipResults } ) ;
2026-05-25 19:29:23 -04:00
} catch ( err ) { next ( err ) ; }
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
} ) ;
2026-05-25 19:29:23 -04:00
// GET /trim-status/:jobId
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
router . get ( '/trim-status/:jobId' , async ( req , res , next ) => {
try {
const { jobId } = req . params ;
const jobResult = await pool . query ( 'SELECT * FROM jobs WHERE id = $1' , [ jobId ] ) ;
2026-05-25 19:29:23 -04:00
if ( jobResult . rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'Trim job not found' } ) ;
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
const job = jobResult . rows [ 0 ] ;
2026-05-26 10:10:44 -04:00
// Auto-expire: delete stale jobs rows (and their temp_segments) past TTL
if ( job . expires _at && new Date ( job . expires _at ) < new Date ( ) ) {
await pool . query ( 'DELETE FROM temp_segments WHERE job_id = $1' , [ jobId ] ) ;
await pool . query ( 'DELETE FROM jobs WHERE id = $1' , [ jobId ] ) ;
return res . status ( 404 ) . json ( { error : 'Trim job expired' } ) ;
}
2026-05-25 19:29:23 -04:00
const segResult = await pool . query ( ` SELECT clip_instance_id, asset_id, s3_key, expires_at FROM temp_segments WHERE job_id = $ 1 ORDER BY created_at ` , [ jobId ] ) ;
const clips = segResult . rows . map ( row => ( { clipInstanceId : row . clip _instance _id , assetId : row . asset _id , s3Key : row . s3 _key || null , status : row . s3 _key ? 'completed' : job . status , expiresAt : row . expires _at } ) ) ;
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
res . json ( { jobId , status : job . status , clips } ) ;
2026-05-25 19:29:23 -04:00
} catch ( err ) { next ( err ) ; }
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
} ) ;
2026-05-25 19:29:23 -04:00
// GET /temp-segment-url/:clipInstanceId
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
router . get ( '/temp-segment-url/:clipInstanceId' , async ( req , res , next ) => {
try {
const { clipInstanceId } = req . params ;
2026-05-25 19:29:23 -04:00
const result = await pool . query ( 'SELECT s3_key FROM temp_segments WHERE clip_instance_id = $1 AND expires_at > NOW()' , [ clipInstanceId ] ) ;
if ( result . rows . length === 0 ) return res . status ( 404 ) . json ( { error : 'Temp segment not found or expired' } ) ;
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
const { s3 _key } = result . rows [ 0 ] ;
2026-05-25 19:29:23 -04:00
if ( ! s3 _key ) return res . status ( 404 ) . json ( { error : 'Segment not yet processed' } ) ;
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
const url = await getSignedUrlForObject ( s3 _key ) ;
res . json ( { url , s3Key : s3 _key } ) ;
2026-05-25 19:29:23 -04:00
} catch ( err ) { next ( err ) ; }
feat: implement advanced features (conform, auto-relink, GUI redesign, docs, tests)
- #30 FCP XML Export & Conform: slide panel UI, preset system, FCP XML generation,
conform job submission with progress polling via BullMQ
- #31 Hi-Res Auto-Relink: clip list with checkboxes, batch-trim server endpoint,
trimWorker with frame-accurate FFmpeg trimming, auto-relink in Premiere via
ExtendScript, temp segment signed URL endpoint
- #32 GUI Redesign: complete rewrite with Wild Dragon OKLCH design tokens
(accent oklch(45% 0.20 266)), slide panels, preset cards, chip components
- #34 Cleanup Task: existing task validated and properly registered
- #35 Testing: comprehensive 33-scenario E2E test plan
- #36 Documentation: advanced features guide with workflows, troubleshooting,
presets table, and architecture overview
- #24 PR merge: verified mergeable
All server endpoints, worker queues, and ExtendScript functions wired together
2026-05-24 13:19:24 -04:00
} ) ;
feat(audio-tab): full audio track inspector with meters, mute/solo, faders
Issue #80 — replaces the stub AudioTab (two static waveforms) with a
broadcast-ops-grade audio panel:
- DB: add audio_metadata JSONB column to assets (migration 022)
- Worker: getMediaInfo now extracts per-stream audio metadata
(codec, channels, channel_layout, sample_rate, bit_depth, bit_rate,
language, title, disposition)
- Worker: proxy job persists audio_metadata into the assets row
- API: new GET /assets/:id/audio returns structured track list
- Frontend AudioTab: per-track rows with:
- Track name/index with language badge
- SVG waveform per track (color-coded)
- L/R level meters via Web Audio API AnalyserNode
- Per-track metadata row (codec, layout, sample rate, bit depth, bitrate)
- Mute / Solo buttons with proper solo-logic
- Per-track volume fader
- Master section with summed L/R meters and master fader
- MetadataTab: show audio track summary when audio_metadata present
- CSS: full audio-tab layout, responsive collapse at 900px
2026-05-27 00:53:52 -04:00
// GET /:id/audio — return audio metadata and a signed URL for audio extraction
router . get ( '/:id/audio' , async ( req , res , next ) => {
try {
const { id } = req . params ;
const r = await pool . query (
'SELECT id, media_type, proxy_s3_key, original_s3_key, audio_metadata, duration_ms 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 ] ;
const audioMeta = a . audio _metadata || null ;
if ( ! audioMeta || ! Array . isArray ( audioMeta ) || audioMeta . length === 0 ) {
return res . json ( { tracks : [ ] , hasAudio : false } ) ;
}
res . json ( {
tracks : audioMeta ,
hasAudio : true ,
durationMs : a . duration _ms || null ,
} ) ;
} catch ( err ) { next ( err ) ; }
} ) ;
2026-04-07 21:58:27 -04:00
export default router ;