openvibers/openvibe.media Commits
Queue an object.pack after every finished CMAF cut (#31)
Changes
@@@ -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.@@@ -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)@@@ -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"}@@@ -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, holdsand 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 idempotencykey `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 forlive 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 playermoving to HLS. Renditions are F4.## 4. Clips reuse the source@@@ -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),@@@ -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 };@@@ -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');@@@ -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`);
@@ -137,8 +137,9 @@ MEDIA_CAPABILITY_GATE=report
@@ -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)
#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) ─────
# ── 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
#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
# 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.
# signature purpose, accepted only by the HLS routes). Default 21600 (6 h), clamped to 60..43200.
@@ -111,7 +111,8 @@ server/
@@ -111,7 +111,8 @@ server/
object.pack (durable segments packed into ~60 s chunk objects; MEDIA_HLS_ENABLED),
object.pack (durable segments packed into ~60 s chunk objects; MEDIA_HLS_ENABLED),
vod.finalize (orphans and failed finalizes, with backoff),
vod.finalize (orphans and failed finalizes, with backoff),
vod.duration.reconcile (stored vs measured durations, local and B2/R2),
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
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)
(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)
scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
@@ -38,7 +38,7 @@
@@ -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",
"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",
"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",
"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"
}
}
@@ -133,7 +133,9 @@ med_xyz/{captions/*.vtt, storyboard.webp+.vtt, waveform, metadata}
@@ -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`
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
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
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
- **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
`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),
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}
@@ -149,7 +151,7 @@ med_xyz/{captions/*.vtt, storyboard.webp+.vtt, waveform, metadata}
- **F3.5, shipped: virtual and materialized clips over the timeline (§4).**
- **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
**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
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.
moving to HLS. Renditions are F4.
## 4. Clips reuse the source
## 4. Clips reuse the source
@@ -5,7 +5,8 @@
@@ -5,7 +5,8 @@
* no re-encode) into fragmented-MP4 segments cut on the source's own keyframes (variable duration), init.mp4 plus
* 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
* 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,
* 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
* 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,
* result { source_id, rendition: 'source', segments, duration_ms, bytes, durable, local_only,
@@ -191,6 +192,12 @@ async function run(job, ctx) {
@@ -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 });
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);
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 {
return {
source_id: src.id, rendition, segments: segs.length, duration_ms: segs[segs.length - 1].end_ms,
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),
bytes: rows.reduce((a, x) => a + x.byte_length, 0),
@@ -1,11 +1,16 @@
@@ -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
* 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
* 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
* 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
* 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.
* 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';
'use strict';
@@ -26,4 +31,18 @@ async function queueCmaf(appId, objectId, { segment_seconds = null } = {}) {
@@ -26,4 +31,18 @@ async function queueCmaf(appId, objectId, { segment_seconds = null } = {}) {
return r.job ? r.job.id : 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 };
@@ -7,6 +7,8 @@
@@ -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
// 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
// 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.
// 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 assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
@@ -151,6 +153,13 @@ const { spawn, spawnSync } = require('child_process');
@@ -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(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');
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 ──
// ── The playlists come from the rows ──
const master = await get(`/o/${pub}/master.m3u8`);
const master = await get(`/o/${pub}/master.m3u8`);
assert.strictEqual(master.status, 200);
assert.strictEqual(master.status, 200);
@@ -193,6 +202,12 @@ const { spawn, spawnSync } = require('child_process');
@@ -193,6 +202,12 @@ const { spawn, spawnSync } = require('child_process');
assert.strictEqual(j2.result.uploaded, 0);
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.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');
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 ──
// ── F3.3: durable segments (stubbed B2/R2), the provider chosen by the placement router ──
const pack = require('../server/jobs/pack');
const pack = require('../server/jobs/pack');
@@ -210,7 +225,13 @@ const { spawn, spawnSync } = require('child_process');
@@ -210,7 +225,13 @@ const { spawn, spawnSync } = require('child_process');
const realRoute = router.route;
const realRoute = router.route;
const purposes = [];
const purposes = [];
router.route = async (opts) => { purposes.push(opts.purpose); return realRoute(opts); };
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.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.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');
assert.ok(purposes.includes('durable'), 'object.cmaf asks the placement router for its provider');
@@ -236,10 +257,17 @@ const { spawn, spawnSync } = require('child_process');
@@ -236,10 +257,17 @@ const { spawn, spawnSync } = require('child_process');
assert.strictEqual(JSON.stringify(await timeline.list(pub)), JSON.stringify(durableRows));
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);
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.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));
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);
const packed = await timeline.list(pub);
assert.strictEqual(JSON.stringify(packed[0]), JSON.stringify(durableRows[0]), 'the init segment is not packed');
assert.strictEqual(JSON.stringify(packed[0]), JSON.stringify(durableRows[0]), 'the init segment is not packed');
const chunkId = packed[1].packed_object_id;
const chunkId = packed[1].packed_object_id;
@@ -294,6 +322,12 @@ const { spawn, spawnSync } = require('child_process');
@@ -294,6 +322,12 @@ const { spawn, spawnSync } = require('child_process');
// ── A private object: anonymous readers get nothing; the signed playlist carries its signature on ──
// ── A private object: anonymous readers get nothing; the signed playlist carries its signature on ──
assert.strictEqual((await runJob(priv)).status, 'succeeded');
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}`);
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`);
const dl = await call('GET', `/api/v2/live/objects/${priv}/download?format=json`);
assert.strictEqual(dl.status, 200);
assert.strictEqual(dl.status, 200);
@@ -308,7 +342,7 @@ const { spawn, spawnSync } = require('child_process');
@@ -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((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');
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');
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);
const privRows = await timeline.list(priv);
assert.ok(privRows[1].packed_object_id);
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');
assert.strictEqual((await get(`/o/${priv}/source/000002.m4s`)).status, 404, 'a packed private segment needs the signature');
@@ -1,10 +1,12 @@
@@ -1,10 +1,12 @@
'use strict';
'use strict';
// The timeline queued by itself and signed playlists (F3.c; docs/media-fabric.md §3): a finalized VOD queues exactly one
// 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
// 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 assert = require('assert');
const fs = require('fs');
const fs = require('fs');
const os = require('os');
const os = require('os');
@@ -27,7 +29,7 @@ const express = require('express');
@@ -27,7 +29,7 @@ const express = require('express');
const model = require('../server/objects/model');
const model = require('../server/objects/model');
const timeline = require('../server/objects/timeline');
const timeline = require('../server/objects/timeline');
const signing = require('../server/objects/signing');
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 tools = require('../server/vod/media-tools');
const thumbService = require('../server/thumbnails/thumbnail-service');
const thumbService = require('../server/thumbnails/thumbnail-service');
const vodStorage = require('../server/vod/vod-storage');
const vodStorage = require('../server/vod/vod-storage');
@@ -109,6 +111,25 @@ const express = require('express');
@@ -109,6 +111,25 @@ const express = require('express');
assert.strictEqual((await jobs('object.cmaf', cut)).length, 0, 'a timeline already there: nothing queued');
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');
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 ──
// ── Playlist tokens: own purpose, outlive the download token, up to the configured TTL ──
const pl = new URL(signing.signedPlaylistUrl(cut).url);
const pl = new URL(signing.signedPlaylistUrl(cut).url);
assert.strictEqual(pl.pathname, `/o/${cut}/master.m3u8`);
assert.strictEqual(pl.pathname, `/o/${cut}/master.m3u8`);