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