diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx index 3f5141d..6cac27b 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -2722,6 +2722,8 @@ function GrowingSettingsCard() { growing_smb_mount: cfg.growing_smb_mount, growing_smb_username: cfg.growing_smb_username, growing_smb_vers: cfg.growing_smb_vers, + // UI edits the delay in HOURS; storage stays in seconds (the auto-promotion + // scanner reads growing_promote_after_seconds). Convert hours → seconds. growing_promote_after_seconds: cfg.growing_promote_after_seconds, }; if (clearPwd) body.growing_smb_password_clear = true; @@ -2775,8 +2777,22 @@ function GrowingSettingsCard() { set('growing_smb_url', e.target.value)} placeholder="smb://10.0.0.25/mam-growing" /> - - set('growing_promote_after_seconds', e.target.value)} placeholder="8" /> + + { + const secs = parseFloat(cfg.growing_promote_after_seconds); + return Number.isFinite(secs) ? +(secs / 3600).toFixed(2).replace(/\.?0+$/, '') : ''; + })()} + onChange={e => { + const hours = parseFloat(e.target.value); + set('growing_promote_after_seconds', Number.isFinite(hours) ? String(Math.round(hours * 3600)) : ''); + }} + placeholder="12" /> +
+ Growing clips left on the SMB share are uploaded to S3 automatically once they've + been idle this long. Set 0 to promote almost immediately. You can also right-click any + asset in the Library → "Move to S3" to promote it on demand. +
diff --git a/services/worker/src/index.js b/services/worker/src/index.js index ce0c82e..581ecfc 100644 --- a/services/worker/src/index.js +++ b/services/worker/src/index.js @@ -9,6 +9,7 @@ import { trimWorker } from './workers/trimWorker.js'; import { hlsWorker } from './workers/hls.js'; import { playoutStageWorker } from './workers/playout-stage.js'; import { promotionWorker } from './workers/promotion.js'; +import { startPromotionScanner } from './workers/promotion-scanner.js'; const parseRedisUrl = (url) => { const parsed = new URL(url); @@ -98,11 +99,22 @@ const workers = [ // playout-stage = S3 → /media volume + EBU R128 loudnorm. CPU/IO-bound; // colocate with workers that already have ffmpeg + the media mount. want('playout-stage') && createWorker('playout-stage', playoutStageWorker, { concurrency: 1 }), - // promotion = manual growing-files promotion (S3 upload + DB update + queue proxy) + // promotion = growing-files promotion (S3 upload + DB update + queue proxy). + // Triggered manually via POST /assets/:id/promote AND automatically by the + // promotion scanner below once a pending_migration asset has been idle for + // settings.growing_promote_after_seconds. want('promotion') && createWorker('promotion', promotionWorker, { concurrency: 1 }), ].filter(Boolean); console.log(`WORKER_QUEUES=${_wq || '(all)'}`); +// Auto-promotion scanner — only on promotion-capable workers, and only ONE +// instance is needed cluster-wide, but the scan is idempotent (status guard + +// stable jobId) so running it on every promotion worker is safe. +let _promotionScanner = null; +if (want('promotion')) { + _promotionScanner = startPromotionScanner(redisOptions); +} + // Filmstrip queue singleton — used by thumbnail worker to enqueue filmstrip jobs export const filmstripQueue = new Queue('filmstrip', { connection: redisOptions }); diff --git a/services/worker/src/workers/promotion-scanner.js b/services/worker/src/workers/promotion-scanner.js new file mode 100644 index 0000000..e27becb --- /dev/null +++ b/services/worker/src/workers/promotion-scanner.js @@ -0,0 +1,97 @@ +// Auto-promotion scanner. +// +// Growing-files recordings finish on the SMB share with status='pending_migration'. +// Promotion (SMB → S3 upload + proxy) is otherwise only triggered manually via +// POST /assets/:id/promote. This scanner closes that gap: on a fixed interval it +// finds pending_migration assets that have been idle longer than the operator- +// configured delay (settings.growing_promote_after_seconds) and enqueues a +// promotion job for each — so growing clips land in S3 automatically once the +// editor is done with the live file, without anyone clicking anything. +// +// "Idle" = assets.updated_at older than the delay. Capture stamps updated_at +// when it flips the asset to pending_migration on record stop, so the delay is +// measured from when the file stopped growing. +// +// Safe to run on every worker container: the UPDATE ... WHERE status = +// 'pending_migration' guard + BullMQ jobId dedupe (jobId = 'promote:') +// makes double-enqueue from multiple scanners idempotent. + +import { Queue } from 'bullmq'; +import { query } from '../db/client.js'; + +const DEFAULT_DELAY_SECONDS = 43200; // 12h fallback if the setting is unset/invalid +const SCAN_INTERVAL_MS = parseInt(process.env.PROMOTION_SCAN_INTERVAL_MS || '60000', 10); + +async function getPromoteDelaySeconds() { + try { + const r = await query( + `SELECT value FROM settings WHERE key = 'growing_promote_after_seconds'` + ); + if (r.rows.length === 0) return DEFAULT_DELAY_SECONDS; + const n = parseInt(r.rows[0].value, 10); + return Number.isFinite(n) && n >= 0 ? n : DEFAULT_DELAY_SECONDS; + } catch (err) { + console.warn('[promotion-scanner] could not read delay setting:', err.message); + return DEFAULT_DELAY_SECONDS; + } +} + +export function startPromotionScanner(redisOptions) { + const promotionQueue = new Queue('promotion', { connection: redisOptions }); + + const scanOnce = async () => { + try { + const delaySeconds = await getPromoteDelaySeconds(); + + // Find pending_migration assets idle longer than the delay. EXTRACT(EPOCH …) + // gives the age in seconds; compare against the configured threshold. + const r = await query( + `SELECT id, filename + FROM assets + WHERE status = 'pending_migration' + AND EXTRACT(EPOCH FROM (NOW() - updated_at)) >= $1 + ORDER BY updated_at ASC + LIMIT 25`, + [delaySeconds] + ); + + if (r.rows.length === 0) return; + + for (const asset of r.rows) { + // Flip to 'processing' first so a second scan tick won't re-pick it, and + // dedupe the job by a stable jobId so concurrent scanners coalesce. + const upd = await query( + `UPDATE assets SET status = 'processing', updated_at = NOW() + WHERE id = $1 AND status = 'pending_migration' + RETURNING id`, + [asset.id] + ); + if (upd.rows.length === 0) continue; // another scanner/operator beat us to it + + await promotionQueue.add( + 'promote', + { assetId: asset.id }, + { jobId: `promote:${asset.id}`, removeOnComplete: true, removeOnFail: 50 } + ); + console.log( + `[promotion-scanner] auto-promoting ${asset.filename} (${asset.id}) — idle ≥ ${delaySeconds}s` + ); + } + } catch (err) { + console.error('[promotion-scanner] scan failed:', err.message); + } + }; + + // Kick off and then run on an interval. Unref so it never keeps the process + // alive on its own during shutdown. + const timer = setInterval(scanOnce, SCAN_INTERVAL_MS); + timer.unref?.(); + // First scan shortly after boot (not instantly — let DB/redis settle). + setTimeout(scanOnce, 5000).unref?.(); + + console.log( + `[promotion-scanner] started — interval ${SCAN_INTERVAL_MS}ms (delay from settings.growing_promote_after_seconds)` + ); + + return { promotionQueue, stop: () => clearInterval(timer) }; +}