openvibers/openvibe.media Commits

Queue an object.pack after every finished CMAF cut (#31)

OP OpenVibers committed 5 Oct 2026 · d5deebbfc3 · 8 files, +104 −19

* Queue an object.pack after every finished CMAF cut

**Files changed**
- `server/objects/timeline-queue.js:34` — added `queuePack(appId, objectId)`: enqueues `object.pack` (`dedupeActive`, key `object.pack:<id>`, `createdBy: 'system:timeline'`, worker kick), no-op null with `MEDIA_HLS_ENABLED` off or a missing/non-media object; exported it and widened the module doc.
- `server/jobs/cmaf.js:194` — after a fully successful run, `queuePack(job.app_id, src.id)` is called; a queue error is logged and never fails the cut. Placed after the commit **and** the durability-failure check, so the `TimelineChanged` retry path and an `upload_failed` attempt (whose retry will re-cut) never consume the pack idempotency key. Header doc updated.
- `test/timeline-queue.test.js` — `queuePack` unit block: a cut queues exactly one `object.pack` with the right creator/key/app, a later call returns the same job (dedupe), and flag-off/non-media/missing queue nothing; header notes it.
- `test/media-timeline.test.js:155` — a real finished `object.cmaf` queues exactly one `object.pack`, a cmaf rerun doesn't add another, and the private cut's auto-queued pack actually packs (`packs: 1`); the later explicit private pack now asserts `packs: 0` (already packed) since the auto job wins deterministically.
- `README.md:114` — added `storage.move.cleanup` to the `jobs/` `types:` list after `storage.orphans.scan`.
- `.env.example:139` — `MEDIA_HLS_ENABLED` comment now names the `hls_url` field of `GET …/download?format=json` alongside the job and HLS routes.
- `docs/media-fabric.md` — F3.3 bullet documents that a finished `object.cmaf` queues `object.pack` by itself; removed "queuing `object.pack` after the cut by itself" from the still-open F3 list.
- `STATUS.json` — F3.3 feature line notes the auto-queue; `updated` → `2026-10-04`.

**Test command**
`ov test test/timeline-queue.test.js test/media-timeline.test.js` → `2 passed in 36 s` (media-timeline 22.7 s, timeline-queue 12.2 s). Not re-run after two cosmetic-only follow-ups (a comment reflow and `let`→`const`), per the "run once" rule.

**Notes / couldn't do**
- The brief's route `GET /o/:id/download?format=json` doesn't exist — the JSON download handler is on the tenant router (`/api/v2/:app/objects/:id/download?format=json`); the public `/o` router has no `/download`. I used the repo's own shorthand `GET …/download?format=json` in the `.env.example` comment so the surface is named correctly.
- `queuePack` checks only the two stated no-ops (flag off, non-media); it deliberately doesn't gate on a timeline existing, since the only caller invokes it post-commit.
- No commit/push/deploy/install performed, as instructed.

* Re-pack after a later cut: drop the pack idempotency key (review)

## Changes

**Fix**
- `server/objects/timeline-queue.js:42` — dropped `idempotencyKey: \`object.pack:${obj.id}\`` from `queuePack`; `dedupeActive` (type+object+params hash, queued/running) now joins an active pack and lets a finished one be superseded.
- `server/objects/timeline-queue.js:12` — header reworded: "a rerun while it is active joins it and a later cut queues a new one".

**Docs (rerun wording)**
- `docs/media-fabric.md:137` — F3.3: `dedupeActive` only; rerun joins an active pack, later cut queues a new one (removed `object.pack:<id>`).
- `STATUS.json:41` — same reword in the F3.3 feature line.
- `server/jobs/cmaf.js:196` — pack-queue comment no longer cites an idempotency key (stale reference to the removed key).
- `README.md` — no pack-rerun wording, so untouched (as the brief allowed).

**Tests**
- `test/timeline-queue.test.js:125,127` — active-join case kept: asserts the pack carries no idempotency key and a second `queuePack` while it is queued returns the same job ("an active pack is joined, not duplicated"); header at `:5` reworded.
- `test/media-timeline.test.js:160` — first auto-pack has `idempotency_key: null`.
- `test/media-timeline.test.js:204-208` — new case: the first pack runs to completion (no provider, packs nothing), then the cmaf rerun is **not** joined and a second `object.pack` job is created.
- `test/media-timeline.test.js:228-235,262-269` — pause the worker around the durable re-cut so the cut's own queued pack can't race the unpacked-row assertions, then run that queued pack explicitly and assert it packs the durable segments (proves the "no-provider first pack blocks the real pack" failure is gone), then resume the worker.

## Test results
`ov test test/media-timeline.test.js test/timeline-queue.test.js` → **2 passed in 9 s** (media-timeline 5.2 s, timeline-queue 3.6 s). Also `ov test test/media-timeline.test.js --repeat 3` → **3 of 3 passed** (checked for the worker-pause determinism).

Changes

ddiff --git a/.env.example b/.env.example
iindex cd64a99..b68de91 100644
---- a/.env.example
++++ b/.env.example
@@@ -137,8 +137,9 @@ MEDIA_CAPABILITY_GATE=report
#MEDIA_MOVE_CLEANUP_MINUTES=60 # storage.move.cleanup: alert on objects whose placement move failed 3 times (0 = never)
# ── Segment-native video (docs/media-fabric.md §3, F3.1) ─────
--# 1 = the object.cmaf job (CMAF segments of a finished video, indexed in media_timeline) and the HLS routes
--# /o/:id/master.m3u8, /o/:id/source/… ; off (default): the job is refused and the routes 404, nothing else changes.
++# 1 = the object.cmaf job (CMAF segments of a finished video, indexed in media_timeline), the HLS routes
++# /o/:id/master.m3u8, /o/:id/source/… and the hls_url field of GET …/download?format=json ; off (default):
++# the job is refused and the routes 404, nothing else changes.
#MEDIA_HLS_ENABLED=0
# Lifetime in seconds of a signed HLS playlist (download?format=json → hls_url of a private or sandbox object; its own
# signature purpose, accepted only by the HLS routes). Default 21600 (6 h), clamped to 60..43200.
ddiff --git a/README.md b/README.md
iindex 24181d0..f67d02d 100644
---- a/README.md
++++ b/README.md
@@@ -111,7 +111,8 @@ server/
object.pack (durable segments packed into ~60 s chunk objects; MEDIA_HLS_ENABLED),
vod.finalize (orphans and failed finalizes, with backoff),
vod.duration.reconcile (stored vs measured durations, local and B2/R2),
-- storage.orphans.scan (monthly storage orphan report, service-wide, report only)
++ storage.orphans.scan (monthly storage orphan report, service-wide, report only),
++ storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)
client-ip.js trust proxy = loopback; req.ip is the only client address
(openvibe-shared v2.5.0, openvibe-contracts v0.79.0, openvibe-sdk v0.21.2: pinned release tarballs, installed by npm)
scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
ddiff --git a/STATUS.json b/STATUS.json
iindex 58f0b3a..7d7c9bb 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -38,7 +38,7 @@
"tiering on demand (F2.5): the object sweep promotes and demotes on the hour's Valkey reads against per-class hysteresis bands, one MGET per page, falling back to the daily PostgreSQL counts for a whole sweep when Valkey fails",
"placement constraints (F2.6): sweep moves refused while their provider fails its class (breaker or probe), and a per-class monthly R2 storage ceiling (maxHotUsdPerMonth) applied in value order, each explained in the decision's inputs",
"segment-native video (F3.1, behind MEDIA_HLS_ENABLED): the media_timeline index, the object.cmaf job (CMAF/fMP4 segments of a finished video on its own keyframes, sha256 and durability per row, idempotent) and HLS playlists written from the timeline at /o/:id/master.m3u8 under the object's access checks",
-- "packing after finalize (F3.3, behind MEDIA_HLS_ENABLED): the object.pack job concatenates durable segments into ~60 s chunk objects verified by sha256, each media_timeline row naming its byte range; the segment route serves a packed segment as a ranged read of its chunk; the per-segment keys are deleted once the chunk is durable; segment providers come from the placement router"
++ "packing after finalize (F3.3, behind MEDIA_HLS_ENABLED): the object.pack job concatenates durable segments into ~60 s chunk objects verified by sha256, each media_timeline row naming its byte range; the segment route serves a packed segment as a ranged read of its chunk; the per-segment keys are deleted once the chunk is durable; segment providers come from the placement router; a finished object.cmaf queues object.pack by itself (dedupeActive: a rerun joins an active pack, a later cut queues a new one)"
],
-- "updated": "2026-10-03"
++ "updated": "2026-10-04"
}
ddiff --git a/docs/media-fabric.md b/docs/media-fabric.md
iindex 088ab06..2f15f51 100644
---- a/docs/media-fabric.md
++++ b/docs/media-fabric.md
@@@ -133,7 +133,9 @@ med_xyz/{captions/*.vtt, storyboard.webp+.vtt, waveform, metadata}
durable chunk, since a redirect cannot carry the range) under the same check and signature; the `EXT-X-BYTERANGE`
form over chunk URIs is not used, so URIs, signed playlists and caches stay as they were. Purge, VOD delete, holds
and the orphan report see a chunk through the rows that name it (each location deleted once, every row kept while its
-- chunk's delete fails). All of it behind `MEDIA_HLS_ENABLED`; no schema change (F3.1's columns carry it).
++ chunk's delete fails). All of it behind `MEDIA_HLS_ENABLED`; no schema change (F3.1's columns carry it). A finished
++ `object.cmaf` queues it by itself (`objects/timeline-queue.js`; `dedupeActive`, so a rerun while a pack is active joins
++ it and a later cut queues a new one), so packing follows the cut without an operator.
- **F3.c, shipped: the timeline queued by itself, signed playlists, the MP4 fallback.** A recording's finalize queues
`object.cmaf` for its object once it is ready (`server/objects/timeline-queue.js`; `dedupeActive` and the idempotency
key `object.cmaf:<id>`, so a second finalize joins the same job; a queue error is logged, never fails the finalize),
@@@ -149,7 +151,7 @@ med_xyz/{captions/*.vtt, storyboard.webp+.vtt, waveform, metadata}
- **F3.5, shipped: virtual and materialized clips over the timeline (§4).**
**Still open in F3:** the growing live/DVR playlist written as OpenRe segments (F3.2), write-behind durability for
live segments and its upload-lag metric, a materialized clip that references the source's chunks (chunk reference
-- counts, boundary-only re-encode), sprites and captions on it, queuing `object.pack` after the cut by itself, segment-bucket demand, and Live's player
++ counts, boundary-only re-encode), sprites and captions on it, segment-bucket demand, and Live's player
moving to HLS. Renditions are F4.
## 4. Clips reuse the source
ddiff --git a/server/jobs/cmaf.js b/server/jobs/cmaf.js
iindex 2fe3b27..24ea0c3 100644
---- a/server/jobs/cmaf.js
++++ b/server/jobs/cmaf.js
@@@ -5,7 +5,8 @@
* no re-encode) into fragmented-MP4 segments cut on the source's own keyframes (variable duration), init.mp4 plus
* 000001.m4s, …, stored under the object's `source/` prefix and indexed in media_timeline (objects/timeline.js), one
* row per segment with its times, size, sha256 and durability. The source object is never touched: its bytes,
-- * locations, visibility, metadata and lifecycle stay as they are.
++ * locations, visibility, metadata and lifecycle stay as they are. Once the rows are committed, `object.pack` is queued
++ * (objects/timeline-queue.js) so packing follows the cut by itself.
*
* params { segment_seconds } the target segment length, 1-10 (4): a segment ends at the first keyframe after it
* result { source_id, rendition: 'source', segments, duration_ms, bytes, durable, local_only,
@@@ -191,6 +192,12 @@ async function run(job, ctx) {
throw new JobError('upload_failed', `${failed.length} segment(s) not durable yet (${failed[0].slice(0, 200)}); a retry uploads only those`, { retryAfterS: 600 });
}
const segs = rows.filter((x) => x.seq > 0);
++ // Packing follows the cut by itself (F3): once the rows and their bytes are committed, object.pack is
++ // queued for the object (dedupeActive joins an active pack; a later cut queues a new one,
++ // objects/timeline-queue.js). A queue error never fails the cut (the timeline is already published); a run
++ // whose upload failed is left to its retry.
++ try { await require('../objects/timeline-queue').queuePack(job.app_id, src.id); }
++ catch (err) { console.warn(`[Cmaf] ${job.id}: pack queue failed: ${err.message}`); }
return {
source_id: src.id, rendition, segments: segs.length, duration_ms: segs[segs.length - 1].end_ms,
bytes: rows.reduce((a, x) => a + x.byte_length, 0),
ddiff --git a/server/objects/timeline-queue.js b/server/objects/timeline-queue.js
iindex 2080682..f64b2fe 100644
---- a/server/objects/timeline-queue.js
++++ b/server/objects/timeline-queue.js
@@@ -1,11 +1,16 @@
/**
-- * OpenVibe.Media — queue the timeline of a finished video (docs/media-fabric.md §3, F3.c)
++ * OpenVibe.Media — queue a finished video's timeline and its packing (docs/media-fabric.md §3, F3)
*
* queueCmaf(appId, objectId) enqueues `object.cmaf` for a ready media object that has no source timeline yet: from the
* finalize of a recording (server/vod/finalize.js), and lazily from GET …/download?format=json so a VOD older than F3.1
* gets one on its first request. Nothing happens (null) with MEDIA_HLS_ENABLED off, for a missing, unready or non-media
* object, or once the timeline exists. One job per object: dedupeActive joins a queued or running cut, and the
* idempotency key `object.cmaf:<id>` answers a later call with the same job. → the job id, or null.
++ *
++ * queuePack(appId, objectId) enqueues `object.pack` once a cut's rows are committed (server/jobs/cmaf.js), so packing
++ * follows the cut by itself instead of an operator's hand. Nothing happens (null) with MEDIA_HLS_ENABLED off or for a
++ * missing or non-media object. One job at a time per object: dedupeActive joins a queued or running pack, so a rerun
++ * while it is active joins it and a later cut queues a new one. → the job id, or null.
*/
'use strict';
@@@ -26,4 +31,18 @@ async function queueCmaf(appId, objectId, { segment_seconds = null } = {}) {
return r.job ? r.job.id : null;
}
--module.exports = { queueCmaf };
++async function queuePack(appId, objectId) {
++ if (!config.hls.enabled || !appId || !objectId) return null;
++ const obj = await require('./model').getObject(objectId);
++ if (!obj || !require('../jobs/derive').isMediaObject(obj)) return null;
++ const queue = require('../jobs/queue');
++ const params = require('../jobs/pack').spec.validate({ appId, obj, params: {} });
++ const r = await queue.enqueue({
++ appId, type: 'object.pack', objectId: obj.id, params,
++ dedupeActive: true, createdBy: 'system:timeline',
++ });
++ if (r.created) require('../jobs/worker').kick();
++ return r.job ? r.job.id : null;
++}
++
++module.exports = { queueCmaf, queuePack };
ddiff --git a/test/media-timeline.test.js b/test/media-timeline.test.js
iindex 173f09f..71d4314 100644
---- a/test/media-timeline.test.js
++++ b/test/media-timeline.test.js
@@@ -7,6 +7,8 @@
// F3.3: object.pack concatenates the durable segments into a chunk, each row naming its byte range; a packed segment is
// served as a ranged read of the chunk (from this node or the durable copy) with the original bytes; a rerun packs
// nothing; the per-segment copies are gone; the durable provider comes from the placement router; private stays private.
++// A finished object.cmaf queues object.pack by itself: a rerun while a pack is active joins it, a later cut queues a
++// new one.
const assert = require('assert');
const fs = require('fs');
const os = require('os');
@@@ -151,6 +153,13 @@ const { spawn, spawnSync } = require('child_process');
assert.strictEqual(JSON.stringify([await model.getObject(pub), await model.listLocations(pub)]), before, 'the source is untouched');
assert.strictEqual(fs.readdirSync(path.join(process.env.OBJECTS_PATH, '.jobs')).length, 0, 'the work directory is removed');
++ // ── The finished cut queues object.pack by itself, once (the worker runs it; no provider yet, so it packs nothing) ──
++ const packJobs = async (objectId) => (await queue.list('live', { type: 'object.pack', objectId })).jobs;
++ const autoPack = await packJobs(pub);
++ assert.strictEqual(autoPack.length, 1, 'a finished object.cmaf queues exactly one object.pack');
++ assert.deepStrictEqual([autoPack[0].created_by, autoPack[0].idempotency_key], ['system:timeline', null], 'no idempotency key: dedupeActive is what joins an active pack');
++ assert.ok(await waitFor(async () => ['succeeded', 'failed'].includes((await queue.get(autoPack[0].id)).status)), 'the queued pack runs');
++
// ── The playlists come from the rows ──
const master = await get(`/o/${pub}/master.m3u8`);
assert.strictEqual(master.status, 200);
@@@ -193,6 +202,12 @@ const { spawn, spawnSync } = require('child_process');
assert.strictEqual(j2.result.uploaded, 0);
assert.strictEqual(JSON.stringify(await timeline.list(pub)), JSON.stringify(rows), 'the rows are as they were (job_id, updated_at too)');
assert.deepStrictEqual(rows.map((r) => fs.statSync(r.local_path).mtimeMs), mtimes, 'no segment rewritten');
++ // The first pack finished, so the rerun is not joined: a later cut queues a new object.pack.
++ const secondPack = await packJobs(pub);
++ assert.strictEqual(secondPack.length, 2, 'a cut after the pack finished queues a new object.pack');
++ assert.ok(secondPack.some((p) => p.id === autoPack[0].id), 'the finished pack is kept');
++ const rerunPack = secondPack.find((p) => p.id !== autoPack[0].id);
++ assert.ok(await waitFor(async () => ['succeeded', 'failed'].includes((await queue.get(rerunPack.id)).status)), 'the rerun\'s pack runs too');
// ── F3.3: durable segments (stubbed B2/R2), the provider chosen by the placement router ──
const pack = require('../server/jobs/pack');
@@@ -210,7 +225,13 @@ const { spawn, spawnSync } = require('child_process');
const realRoute = router.route;
const purposes = [];
router.route = async (opts) => { purposes.push(opts.purpose); return realRoute(opts); };
-- const j3 = await runJob(pub, { segment_seconds: 2 });
++ // Pause the worker: the cut's own object.pack (queued below) must stay queued while the unpacked rows are
++ // asserted, and it is run explicitly at the packing step.
++ worker.stop();
++ const j3r = await call('POST', '/api/v2/live/jobs', { body: { type: 'object.cmaf', object_id: pub, params: { segment_seconds: 2 } } });
++ assert.strictEqual(j3r.status, 202, JSON.stringify(j3r.body));
++ assert.ok(await worker.runNow(j3r.body.job.id), 'the cut runs directly while the worker is paused');
++ const j3 = queue.jobPublic(await queue.get(j3r.body.job.id));
assert.strictEqual(j3.status, 'succeeded', JSON.stringify(j3.error));
assert.deepStrictEqual([j3.result.uploaded, j3.result.durable, j3.result.rows.updated], [5, 5, 5], JSON.stringify(j3.result));
assert.ok(purposes.includes('durable'), 'object.cmaf asks the placement router for its provider');
@@@ -236,10 +257,17 @@ const { spawn, spawnSync } = require('child_process');
assert.strictEqual(JSON.stringify(await timeline.list(pub)), JSON.stringify(durableRows));
assert.strictEqual(fs.existsSync(path.join(process.env.OBJECTS_PATH, '.jobs', 'mjob_PACKABORT')), false);
-- // ── Packing: one chunk, each row its byte range, the per-segment copies deleted ──
-- const jp = await runJob(pub, { target_seconds: 10 }, 'object.pack');
++ // ── Packing: the pack the cut queued (still queued while the worker was paused) makes one chunk, each row its
++ // byte range, the per-segment copies deleted ──
++ const cutPack = (await packJobs(pub))[0];
++ assert.ok(cutPack && cutPack.status === 'queued' && cutPack.idempotency_key == null, 'the cut queued a pack of its own');
++ assert.ok(await worker.runNow(cutPack.id), 'the cut\'s pack runs');
++ const jp = queue.jobPublic(await queue.get(cutPack.id));
assert.strictEqual(jp.status, 'succeeded', JSON.stringify(jp.error));
assert.deepStrictEqual([jp.result.packs, jp.result.segments, jp.result.removed_segments, jp.result.left_behind, jp.result.provider], [1, 4, 4, 0, 'b2'], JSON.stringify(jp.result));
++ // Resume the worker for the rerun cases below.
++ await worker.start();
++ worker.kick();
const packed = await timeline.list(pub);
assert.strictEqual(JSON.stringify(packed[0]), JSON.stringify(durableRows[0]), 'the init segment is not packed');
const chunkId = packed[1].packed_object_id;
@@@ -294,6 +322,12 @@ const { spawn, spawnSync } = require('child_process');
// ── A private object: anonymous readers get nothing; the signed playlist carries its signature on ──
assert.strictEqual((await runJob(priv)).status, 'succeeded');
++ // The finished cut queued object.pack by itself; a durable provider is up now, so it packs.
++ const autoPriv = await packJobs(priv);
++ assert.strictEqual(autoPriv.length, 1, 'the private cut queues one object.pack');
++ assert.ok(await waitFor(async () => ['succeeded', 'failed'].includes((await queue.get(autoPriv[0].id)).status)), 'the queued pack runs');
++ const autoPrivDone = queue.jobPublic(await queue.get(autoPriv[0].id));
++ assert.deepStrictEqual([autoPrivDone.status, autoPrivDone.result.packs], ['succeeded', 1], JSON.stringify(autoPrivDone));
for (const p of ['master.m3u8', 'source/index.m3u8', 'source/init.mp4', 'source/000001.m4s']) assert.strictEqual((await get(`/o/${priv}/${p}`)).status, 404, `anonymous ${p}`);
const dl = await call('GET', `/api/v2/live/objects/${priv}/download?format=json`);
assert.strictEqual(dl.status, 200);
@@@ -308,7 +342,7 @@ const { spawn, spawnSync } = require('child_process');
assert.strictEqual((await get(`/o/${priv}/source/000001.m4s${signed.search}`)).status, 200);
assert.strictEqual(sm.headers.get('access-control-allow-origin'), null, 'no CORS on a private playlist, as on GET /o/:id');
const jpp = await runJob(priv, { target_seconds: 10 }, 'object.pack');
-- assert.deepStrictEqual([jpp.status, jpp.result.packs], ['succeeded', 1], JSON.stringify(jpp));
++ assert.deepStrictEqual([jpp.status, jpp.result.packs], ['succeeded', 0], 'already packed by the cut: the rerun packs nothing');
const privRows = await timeline.list(priv);
assert.ok(privRows[1].packed_object_id);
assert.strictEqual((await get(`/o/${priv}/source/000002.m4s`)).status, 404, 'a packed private segment needs the signature');
ddiff --git a/test/timeline-queue.test.js b/test/timeline-queue.test.js
iindex 5e3ceac..f3094ae 100644
---- a/test/timeline-queue.test.js
++++ b/test/timeline-queue.test.js
@@@ -1,10 +1,12 @@
'use strict';
// The timeline queued by itself and signed playlists (F3.c; docs/media-fabric.md §3): a finalized VOD queues exactly one
// object.cmaf (a second finalize joins it); nothing is queued with MEDIA_HLS_ENABLED off, for a non-media object or once
--// a timeline exists; GET …/download?format=json queues it lazily for an older VOD. A playlist token (purpose 'hls',
--// MEDIA_HLS_PLAYLIST_TTL_S, clamped to 12 h) outlives the 1 h download token, is accepted only by the HLS routes and is
--// carried onto the segment URIs; a download signature still opens the HLS routes. format=mp4 queues object.remux once
--// while the remux variant is missing and answers the variant's signed URL once it exists.
++// a timeline exists; GET …/download?format=json queues it lazily for an older VOD. queuePack queues one object.pack for
++// a cut (a later call while it is active joins it; a later cut after it finished queues a new one); nothing is queued
++// with MEDIA_HLS_ENABLED off or for a non-media object. A playlist token (purpose 'hls', MEDIA_HLS_PLAYLIST_TTL_S,
++// clamped to 12 h) outlives the 1 h download token, is accepted only by
++// the HLS routes and is carried onto the segment URIs; a download signature still opens the HLS routes. format=mp4
++// queues object.remux once while the remux variant is missing and answers the variant's signed URL once it exists.
const assert = require('assert');
const fs = require('fs');
const os = require('os');
@@@ -27,7 +29,7 @@ const express = require('express');
const model = require('../server/objects/model');
const timeline = require('../server/objects/timeline');
const signing = require('../server/objects/signing');
-- const { queueCmaf } = require('../server/objects/timeline-queue');
++ const { queueCmaf, queuePack } = require('../server/objects/timeline-queue');
const tools = require('../server/vod/media-tools');
const thumbService = require('../server/thumbnails/thumbnail-service');
const vodStorage = require('../server/vod/vod-storage');
@@@ -109,6 +111,25 @@ const express = require('express');
assert.strictEqual((await jobs('object.cmaf', cut)).length, 0, 'a timeline already there: nothing queued');
console.log('✅ nothing queued with the flag off, for a non-media or missing object, or once the timeline exists');
++ // ── queuePack: a cut queues one object.pack, a re-run joins it; flag off or non-media queue nothing ──
++ const packOff = await media();
++ config.hls.enabled = false;
++ assert.strictEqual(await queuePack('live', packOff), null);
++ config.hls.enabled = true;
++ assert.strictEqual((await jobs('object.pack', packOff)).length, 0, 'flag off: nothing queued');
++ const packId = await queuePack('live', cut);
++ assert.ok(packId && /^mjob_/.test(packId), 'a cut queues one object.pack');
++ const packJobs = await jobs('object.pack', cut);
++ assert.strictEqual(packJobs.length, 1);
++ assert.deepStrictEqual([packJobs[0].status, packJobs[0].created_by, packJobs[0].idempotency_key, packJobs[0].app_id],
++ ['queued', 'system:timeline', null, 'live'], 'no idempotency key: dedupeActive is what joins an active pack');
++ assert.strictEqual(await queuePack('live', cut), packId, 'a later call while it is active answers the same job');
++ assert.strictEqual((await jobs('object.pack', cut)).length, 1, 'an active pack is joined, not duplicated');
++ assert.strictEqual(await queuePack('live', pdf), null);
++ assert.strictEqual(await queuePack('live', 'med_NOPE'), null);
++ assert.strictEqual((await jobs('object.pack', pdf)).length, 0, 'a non-media or missing object: nothing queued');
++ console.log('✅ queuePack: a cut queues one object.pack, a re-run joins it; flag off or non-media queues nothing');
++
// ── Playlist tokens: own purpose, outlive the download token, up to the configured TTL ──
const pl = new URL(signing.signedPlaylistUrl(cut).url);
assert.strictEqual(pl.pathname, `/o/${cut}/master.m3u8`);