@torrent-tv/proxy 2.13.0 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +756 -742
- package/package.json +1 -1
- package/routes/api/transcode-sessions/post.js +6 -0
- package/routes/transcode/audio-file/get.js +45 -0
- package/server.js +8 -1
- package/services/hls-session-manager.js +517 -55
- package/services/playback-planner.js +14 -0
- package/test/decode-cost.test.js +347 -401
- package/test/quality-variants.test.js +166 -0
|
@@ -133,6 +133,57 @@ test("the master offers every rung, the session's own height among them", async
|
|
|
133
133
|
);
|
|
134
134
|
});
|
|
135
135
|
|
|
136
|
+
test("audio is published once for the file, and every rung points at it", async (t) => {
|
|
137
|
+
const { manager, base, dirPath } = await managerWithBase();
|
|
138
|
+
t.after(async () => {
|
|
139
|
+
await manager.disposeAll();
|
|
140
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
141
|
+
});
|
|
142
|
+
// The inventory the plan already probed — the same list the browser's audio
|
|
143
|
+
// menu is built from.
|
|
144
|
+
manager.getCachedAudioTracks = () => [
|
|
145
|
+
{ index: 0, language: "rus", title: "Дубляж", isDefault: true },
|
|
146
|
+
{ index: 1, language: "eng", title: "", isDefault: false }
|
|
147
|
+
];
|
|
148
|
+
// Settled at creation in production; set here directly, since this test
|
|
149
|
+
// builds its session by hand.
|
|
150
|
+
base.audioSeparate = true;
|
|
151
|
+
base.audioTrackIndex = 1;
|
|
152
|
+
|
|
153
|
+
const master = manager.buildMasterPlaylist(BASE_ID);
|
|
154
|
+
|
|
155
|
+
assert.match(
|
|
156
|
+
master,
|
|
157
|
+
/#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="Дубляж",LANGUAGE="ru",AUTOSELECT=YES,DEFAULT=NO,URI="a\/0\/index\.m3u8"/,
|
|
158
|
+
"a track with a title is named by it"
|
|
159
|
+
);
|
|
160
|
+
assert.match(
|
|
161
|
+
master,
|
|
162
|
+
/#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud",NAME="eng",LANGUAGE="en",AUTOSELECT=YES,DEFAULT=YES,URI="a\/1\/index\.m3u8"/,
|
|
163
|
+
"the track the session was created with is the default one, and an untitled track is named by its language"
|
|
164
|
+
);
|
|
165
|
+
const streams = [...master.matchAll(/^#EXT-X-STREAM-INF:.*$/gm)].map((match) => match[0]);
|
|
166
|
+
assert.equal(streams.length, 7, "every rung");
|
|
167
|
+
assert.ok(
|
|
168
|
+
streams.every((line) => line.includes('AUDIO="aud"')),
|
|
169
|
+
"each rung plays with the shared audio rather than carrying its own"
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("a session that did not ask for renditions gets audio in its stream, as before", async (t) => {
|
|
174
|
+
const { manager, dirPath } = await managerWithBase();
|
|
175
|
+
t.after(async () => {
|
|
176
|
+
await manager.disposeAll();
|
|
177
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
178
|
+
});
|
|
179
|
+
manager.getCachedAudioTracks = () => [{ index: 0, language: "rus", title: "", isDefault: true }];
|
|
180
|
+
|
|
181
|
+
const master = manager.buildMasterPlaylist(BASE_ID);
|
|
182
|
+
|
|
183
|
+
assert.ok(!master.includes("EXT-X-MEDIA"), "a browser that does not know about renditions is not sent any");
|
|
184
|
+
assert.ok(!master.includes("AUDIO="), "and its rungs still carry their own audio");
|
|
185
|
+
});
|
|
186
|
+
|
|
136
187
|
test("a copied video is offered variants when its cut grid is real", async (t) => {
|
|
137
188
|
const { manager, base, dirPath } = await managerWithBase();
|
|
138
189
|
t.after(async () => {
|
|
@@ -310,6 +361,78 @@ test("warming a rung prepares it without taking the encoder from the one on scre
|
|
|
310
361
|
assert.deepEqual(encoder.signals, [], "stopping it here is what would put the spinner back");
|
|
311
362
|
});
|
|
312
363
|
|
|
364
|
+
test("a rung warmed at the playhead survives the switch that lands just ahead of it", async (t) => {
|
|
365
|
+
const { manager, base, dirPath } = await managerWithBase();
|
|
366
|
+
t.after(async () => {
|
|
367
|
+
await manager.disposeAll();
|
|
368
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
369
|
+
});
|
|
370
|
+
const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
|
|
371
|
+
variant.variantHeight = 540;
|
|
372
|
+
variant.variantBases = new Set([BASE_ID]);
|
|
373
|
+
manager.sessionsById.set(VARIANT_ID, variant);
|
|
374
|
+
base.variants = new Map([[540, VARIANT_ID]]);
|
|
375
|
+
base.ffmpeg = fakeEncoder();
|
|
376
|
+
|
|
377
|
+
// Warmed AT THE PLAYHEAD (240 s = segment #60), which is what the browser
|
|
378
|
+
// sends from server 0.10.0 onwards, and the run is alive and has produced a
|
|
379
|
+
// few segments past it.
|
|
380
|
+
await manager.prepareVariant(BASE_ID, 540, 240);
|
|
381
|
+
variant.encodeStartIndex = 59;
|
|
382
|
+
variant.ffmpeg = fakeEncoder();
|
|
383
|
+
variant.progress = { ...variant.progress, processedSeconds: 268 };
|
|
384
|
+
variant.seekTarget = null;
|
|
385
|
+
variant.seekSettleTimer = null;
|
|
386
|
+
|
|
387
|
+
// hls.js flushes from the fragment after the one holding
|
|
388
|
+
// `currentTime + fetchdelay`, so its first request for the new rung is the
|
|
389
|
+
// playhead plus up to one fragment — here #61 against a run that began at
|
|
390
|
+
// #59. Warming at the END OF THE BUFFER instead put the run tens of seconds
|
|
391
|
+
// AHEAD of this request, which the proxy then read as a seek backwards:
|
|
392
|
+
// measured 2026-08-14, that killed a run holding 21.8 s of encoded output.
|
|
393
|
+
await manager.resolveVariantFile(BASE_ID, 540, "segment-00061.mp4");
|
|
394
|
+
|
|
395
|
+
assert.equal(base.activeVariantId, VARIANT_ID, "the viewer has moved to this rung");
|
|
396
|
+
assert.equal(
|
|
397
|
+
variant.seekTarget,
|
|
398
|
+
null,
|
|
399
|
+
"the request is inside the warmed run, so nothing is repositioned and the warm-up is kept"
|
|
400
|
+
);
|
|
401
|
+
assert.equal(variant.encodeStartIndex, 59, "the run still begins where it was warmed");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("a rung warmed PAST the switch is repositioned, which is what warming late costs", async (t) => {
|
|
405
|
+
const { manager, base, dirPath } = await managerWithBase();
|
|
406
|
+
t.after(async () => {
|
|
407
|
+
await manager.disposeAll();
|
|
408
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
409
|
+
});
|
|
410
|
+
const variant = fakeSession({ id: VARIANT_ID, encodeHeight: 540, dirPath });
|
|
411
|
+
variant.variantHeight = 540;
|
|
412
|
+
variant.variantBases = new Set([BASE_ID]);
|
|
413
|
+
manager.sessionsById.set(VARIANT_ID, variant);
|
|
414
|
+
base.variants = new Map([[540, VARIANT_ID]]);
|
|
415
|
+
base.ffmpeg = fakeEncoder();
|
|
416
|
+
|
|
417
|
+
// The same session, warmed where the BUFFER ended rather than where the
|
|
418
|
+
// picture was — 60 s further on, which is an ordinary cushion. This is what
|
|
419
|
+
// server 0.9.3 sent and 0.11.0 stopped sending.
|
|
420
|
+
await manager.prepareVariant(BASE_ID, 540, 300);
|
|
421
|
+
variant.encodeStartIndex = 74;
|
|
422
|
+
variant.ffmpeg = fakeEncoder();
|
|
423
|
+
variant.progress = { ...variant.progress, processedSeconds: 310 };
|
|
424
|
+
variant.seekTarget = null;
|
|
425
|
+
variant.seekSettleTimer = null;
|
|
426
|
+
|
|
427
|
+
// hls.js still lands near the playhead, so the request is far BEHIND the
|
|
428
|
+
// warmed run: the proxy reads it as a seek backwards and starts again, and
|
|
429
|
+
// everything the warm-up produced is thrown away. Measured in the field
|
|
430
|
+
// 2026-08-14 as 21.8 s of encoded output destroyed by the act of using it.
|
|
431
|
+
await manager.resolveVariantFile(BASE_ID, 540, "segment-00061.mp4");
|
|
432
|
+
|
|
433
|
+
assert.equal(variant.seekTarget, 60, "the run is moved back to where the player actually asked");
|
|
434
|
+
});
|
|
435
|
+
|
|
313
436
|
test("the rung on screen fetching its own segments does not cancel a warm-up", async (t) => {
|
|
314
437
|
const { manager, base, dirPath } = await managerWithBase();
|
|
315
438
|
t.after(async () => {
|
|
@@ -471,3 +594,46 @@ test("the cut grid follows the grid asked for, not who produces the frames", ()
|
|
|
471
594
|
"no keyframes means no keyframe grid, however the caller asks"
|
|
472
595
|
);
|
|
473
596
|
});
|
|
597
|
+
|
|
598
|
+
test("a rung served by copy stays offered while a re-encoded rung is on screen", async (t) => {
|
|
599
|
+
const { manager, base, dirPath } = await managerWithBase();
|
|
600
|
+
t.after(async () => {
|
|
601
|
+
await manager.disposeAll();
|
|
602
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
603
|
+
});
|
|
604
|
+
// The field case of 2026-08-15: a 1080p source served by COPY, the viewer on
|
|
605
|
+
// 240p, and a host too weak to re-encode anything above it.
|
|
606
|
+
base.transcodeVideo = false;
|
|
607
|
+
base.encodeHeight = 1080;
|
|
608
|
+
base.variantHeight = 1080;
|
|
609
|
+
// Enough to re-encode 240p (1.67x combined) and nowhere near enough for
|
|
610
|
+
// 720p (0.45x) — the field's own shape, where the rung the viewer picked was
|
|
611
|
+
// offered and everything between it and the copy was not.
|
|
612
|
+
manager.softwarePresetBenchmark = [{ preset: "ultrafast", pixelsPerSec: 12_000_000 }];
|
|
613
|
+
manager.decodeCostModel = { pixelTerm: 0.00793, bitrateTerm: 0, constantTerm: 0 };
|
|
614
|
+
base.sourceDecode = { megapixelsPerSecond: 49.766, megabitsPerSecond: 8 };
|
|
615
|
+
|
|
616
|
+
const watching = fakeSession({ id: VARIANT_ID, encodeHeight: 240, dirPath });
|
|
617
|
+
watching.variantHeight = 240;
|
|
618
|
+
watching.transcodeVideo = true;
|
|
619
|
+
// The rung knows the source as well as the base does. Without this it prices
|
|
620
|
+
// nothing at all — every height comes back "sustainable" for want of a
|
|
621
|
+
// measurement — and the assertion below would hold for the wrong reason.
|
|
622
|
+
watching.sourceDecode = base.sourceDecode;
|
|
623
|
+
watching.variantBases = new Set([BASE_ID]);
|
|
624
|
+
manager.sessionsById.set(VARIANT_ID, watching);
|
|
625
|
+
base.variants = new Map([[240, VARIANT_ID]]);
|
|
626
|
+
base.activeVariantId = VARIANT_ID;
|
|
627
|
+
|
|
628
|
+
const offered = manager.offeredHeights(watching);
|
|
629
|
+
|
|
630
|
+
assert.ok(
|
|
631
|
+
offered.includes(1080),
|
|
632
|
+
"the height the source is COPIED at costs no encoder, so no measurement of this host can withdraw it"
|
|
633
|
+
);
|
|
634
|
+
assert.deepEqual(
|
|
635
|
+
offered,
|
|
636
|
+
manager.offeredHeights(base),
|
|
637
|
+
"one answer for the family: a rung asked while watching another must not disagree with the base"
|
|
638
|
+
);
|
|
639
|
+
});
|