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