@torrent-tv/proxy 2.87.6 → 2.87.8

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.
@@ -9,7 +9,7 @@
9
9
  import { spawn } from "node:child_process";
10
10
  import { logger } from "../../utils/logger.js";
11
11
  import { Container } from "./container/Container.js";
12
- import { buildAudioInventory } from "./audio-inventory.js";
12
+ import { buildAudioInventory, enrichAudioInventoryFromSidecar } from "./audio-inventory.js";
13
13
  import {
14
14
  parseFfmpegDurationSeconds,
15
15
  parseFfmpegStartTimeSeconds,
@@ -35,7 +35,8 @@ const BODY_PREFETCH_BYTES = 16 * 1024 * 1024;
35
35
  * Not a measurement, and nothing is derived from it: it is the point past which
36
36
  * holding the viewer costs more than the language and flags being waited for —
37
37
  * which the folder name supplies anyway, from the torrent's file list, at no
38
- * cost. The reading itself carries on in the worker and is kept there.
38
+ * cost. The read continues asynchronously after this response and is shared
39
+ * with the refresh request, so a later answer can use the same container read.
39
40
  */
40
41
  const SIDECAR_HEADER_WAIT_MS = 3_000;
41
42
 
@@ -276,6 +277,7 @@ function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
276
277
  * @property {number} durationSeconds - Total media duration in seconds (0 if unknown).
277
278
  * @property {number} videoWidth - Source coded width (0 if unknown).
278
279
  * @property {number} videoHeight - Source coded height (0 if unknown).
280
+ * @property {boolean} audioTracksPending - At least one sidecar header timed out and can be refreshed later.
279
281
  */
280
282
 
281
283
  /**
@@ -292,7 +294,7 @@ function buildDirectUrl(localBaseUrl, sourceKey, fileIndex) {
292
294
  * a torrent file. Plans are cached per (sourceKey, fileIndex) pair.
293
295
  *
294
296
  * @param {PlaybackPlannerOptions} options
295
- * @returns {{ getPlan: (params: { sourceKey: string, fileIndex: number, userAgent?: string }) => Promise<PlaybackPlan> }}
297
+ * @returns {{ getPlan: (params: { sourceKey: string, fileIndex: number, userAgent?: string }) => Promise<PlaybackPlan>, refreshAudioTracks: (params: { sourceKey: string, fileIndex: number }) => Promise<{ audioTracks: object[], pending: boolean }> }}
296
298
  */
297
299
  export function createPlaybackPlanner({
298
300
  ffmpegBin,
@@ -330,6 +332,8 @@ export function createPlaybackPlanner({
330
332
  }) {
331
333
  /** @type {Map<string, PlaybackPlan>} */
332
334
  const cache = new Map();
335
+ const pendingSidecarHeaders = new Map();
336
+ const declaredAudioReads = new Map();
333
337
  /**
334
338
  * Full media info parsed from the SAME probe that produced the plan, cached
335
339
  * under the same key so a transcode session can reuse it instead of running
@@ -415,8 +419,9 @@ export function createPlaybackPlanner({
415
419
  * own tracks and the ones shipped as separate files beside it.
416
420
  *
417
421
  * Built here, in the plan, because the plan is what the viewer's menu is drawn
418
- * from — so the offer is complete the moment a file is opened, with nothing
419
- * arriving late and nothing measured while the viewer waits. It is also what
422
+ * from — so every playable track gets a stable number before the file opens.
423
+ * Sidecar header fields may arrive later; they enrich an existing numbered
424
+ * entry without changing the inventory length. It is also what
420
425
  * the master playlist's rendition group is built from, so the number in the
421
426
  * menu and the number in the `a/<n>/` address are the same number by
422
427
  * construction rather than by agreement.
@@ -425,67 +430,72 @@ export function createPlaybackPlanner({
425
430
  * @param {object} torrent
426
431
  * @param {number} fileIndex
427
432
  * @param {object[]} bannerAudioTracks - The probe's own audio streams.
428
- * @returns {Promise<import("./audio-inventory.js").AudioInventoryEntry[]>}
433
+ * @returns {Promise<{ audioTracks: import("./audio-inventory.js").AudioInventoryEntry[], pendingFileIndexes: number[] }>}
429
434
  */
430
- async function buildInventory(sourceKey, torrent, fileIndex, bannerAudioTracks) {
431
- const banner = Array.isArray(bannerAudioTracks) ? bannerAudioTracks : [];
432
- /**
433
- * Read a file's declared audio tracks, or give up quickly.
434
- *
435
- * The plan is on the path to the first frame, and reading a sidecar's header
436
- * waits on the swarm: that file has usually had nothing downloaded when this
437
- * runs, and a header that never arrives would hold the plan — and the
438
- * viewer — for the whole of the read's own patience. What a timeout costs is
439
- * small and deliberate: the track is still offered, still numbered and still
440
- * playable, only without the language and flags its own header would have
441
- * given. The language the viewer actually sees is read from the FOLDER the
442
- * release put it in, which is in the torrent's file list and needs no bytes
443
- * at all.
444
- *
445
- * @param {number} wantedFileIndex
446
- * @param {string} label
447
- * @returns {Promise<object[]>}
448
- */
449
- const declaredAudioOf = async (wantedFileIndex, label) => {
450
- if (typeof declaredTracksOf !== "function") {
451
- return [];
452
- }
453
- let timer = null;
454
- try {
455
- return await Promise.race([
456
- declaredTracksOf({ sourceKey, fileIndex: wantedFileIndex }).then((tracks) => tracks
435
+ async function declaredAudioOf(sourceKey, wantedFileIndex, label) {
436
+ if (typeof declaredTracksOf !== "function") {
437
+ return { tracks: [], complete: false, timedOut: false };
438
+ }
439
+ const readKey = `${sourceKey}:${wantedFileIndex}`;
440
+ let readPromise = declaredAudioReads.get(readKey);
441
+ if (!readPromise) {
442
+ readPromise = Promise.resolve()
443
+ .then(() => declaredTracksOf({ sourceKey, fileIndex: wantedFileIndex }))
444
+ .then((tracks) => ({
445
+ tracks: tracks
457
446
  .filter((track) => track?.type === "audio")
458
- .sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0))),
459
- new Promise((resolve) => {
460
- timer = setTimeout(() => resolve(null), SIDECAR_HEADER_WAIT_MS);
461
- timer.unref?.();
462
- })
463
- ]).then((tracks) => {
464
- if (tracks === null) {
465
- logger.info(
466
- `audio tracks: "${label}" did not answer within ` +
467
- `${SIDECAR_HEADER_WAIT_MS / 1000}s — offered without what its header would say`
468
- );
469
- return [];
470
- }
471
- return Array.isArray(tracks) ? tracks : [];
447
+ .sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0)),
448
+ complete: Array.isArray(tracks) && tracks.length > 0
449
+ }))
450
+ .catch((error) => {
451
+ logger.info(`audio tracks: "${label}" could not be read (${error?.message ?? error})`);
452
+ return { tracks: [], complete: false };
472
453
  });
473
- } catch (error) {
474
- logger.info(`audio tracks: "${label}" could not be read (${error?.message ?? error})`);
475
- return [];
476
- } finally {
477
- if (timer !== null) {
478
- clearTimeout(timer);
454
+ declaredAudioReads.set(readKey, readPromise);
455
+ readPromise.then(
456
+ () => { if (declaredAudioReads.get(readKey) === readPromise) declaredAudioReads.delete(readKey); },
457
+ () => { if (declaredAudioReads.get(readKey) === readPromise) declaredAudioReads.delete(readKey); }
458
+ );
459
+ }
460
+ let timer = null;
461
+ try {
462
+ return await Promise.race([
463
+ readPromise,
464
+ new Promise((resolve) => {
465
+ timer = setTimeout(() => resolve(null), SIDECAR_HEADER_WAIT_MS);
466
+ timer.unref?.();
467
+ })
468
+ ]).then((tracks) => {
469
+ if (tracks === null) {
470
+ logger.info(
471
+ `audio tracks: "${label}" did not answer within ${SIDECAR_HEADER_WAIT_MS / 1000}s — ` +
472
+ "offered without what its header would say"
473
+ );
474
+ return { tracks: [], complete: false, timedOut: true };
479
475
  }
480
- }
481
- };
476
+ return {
477
+ tracks: Array.isArray(tracks?.tracks) ? tracks.tracks : [],
478
+ complete: tracks?.complete === true,
479
+ timedOut: false
480
+ };
481
+ });
482
+ } catch (error) {
483
+ logger.info(`audio tracks: "${label}" could not be read (${error?.message ?? error})`);
484
+ return { tracks: [], complete: false, timedOut: false };
485
+ } finally {
486
+ if (timer !== null) clearTimeout(timer);
487
+ }
488
+ }
489
+
490
+ async function buildInventory(sourceKey, torrent, fileIndex, bannerAudioTracks) {
491
+ const banner = Array.isArray(bannerAudioTracks) ? bannerAudioTracks : [];
482
492
  // The picture's own tracks: ffmpeg numbers them, the container declares what
483
493
  // they are. Both readings, lined up and checked — see `audio-inventory.js`.
484
494
  let embedded = banner.map((track) => ({ ...track, declaresDefault: false }));
485
495
  if (banner.length > 0) {
486
496
  // The picture's head is already downloaded — the codec probe just read it
487
497
  // — so this is a parse and not a wait, but it is bounded like the rest.
488
- const declared = await declaredAudioOf(fileIndex, "the picture");
498
+ const { tracks: declared } = await declaredAudioOf(sourceKey, fileIndex, "the picture");
489
499
  const merged = Container.mergeAudioFlags(banner, declared);
490
500
  embedded = merged.tracks;
491
501
  logger.info(
@@ -505,7 +515,9 @@ export function createPlaybackPlanner({
505
515
  file,
506
516
  // A bare elementary stream — `.ac3`, `.dts`, `.mp3` — has no table to
507
517
  // read, so nothing is asked of the swarm for it at all.
508
- tracks: file.declaresTracks ? await declaredAudioOf(file.fileIndex, file.name) : []
518
+ ...(file.declaresTracks
519
+ ? await declaredAudioOf(sourceKey, file.fileIndex, file.name)
520
+ : { tracks: [], timedOut: false })
509
521
  }))
510
522
  );
511
523
  const inventory = buildAudioInventory({ embedded, videoFileIndex: fileIndex, sidecars });
@@ -521,7 +533,61 @@ export function createPlaybackPlanner({
521
533
  .join(" ")
522
534
  );
523
535
  }
524
- return inventory;
536
+ return {
537
+ audioTracks: inventory,
538
+ pendingFileIndexes: sidecars.filter((sidecar) => sidecar.timedOut).map((sidecar) => sidecar.file.fileIndex)
539
+ };
540
+ }
541
+
542
+ async function refreshAudioTracks({ sourceKey, fileIndex }) {
543
+ const cacheKey = `${sourceKey}:${fileIndex}`;
544
+ const plan = cache.get(cacheKey);
545
+ if (!plan) {
546
+ return { audioTracks: Array.isArray(plan?.audioTracks) ? plan.audioTracks : [], pending: false };
547
+ }
548
+ const pendingIndexes = new Set(pendingSidecarHeaders.get(cacheKey) ?? []);
549
+ if (pendingIndexes.size === 0 || typeof declaredTracksOf !== "function") {
550
+ return { audioTracks: plan.audioTracks, pending: false };
551
+ }
552
+ const sourceRecord = sourceRegistry.get(sourceKey);
553
+ if (!sourceRecord) {
554
+ const error = new Error("Source key was not found.");
555
+ error.code = "SOURCE_NOT_FOUND";
556
+ throw error;
557
+ }
558
+ const torrent = await torrentPool.getTorrent(sourceRecord.sourceType, sourceRecord.source);
559
+ const sidecarFiles = sidecarsOf(torrent, fileIndex).audio;
560
+ for (const sidecar of sidecarFiles) {
561
+ if (!pendingIndexes.has(sidecar.fileIndex)) continue;
562
+ const read = await declaredAudioOf(sourceKey, sidecar.fileIndex, sidecar.name);
563
+ if (read.timedOut) continue;
564
+ if (read.tracks.length === 0) {
565
+ if (read.complete) pendingIndexes.delete(sidecar.fileIndex);
566
+ continue;
567
+ }
568
+ const audio = read.tracks
569
+ .filter((track) => track?.type === "audio")
570
+ .sort((left, right) => (left.declaredIndex ?? 0) - (right.declaredIndex ?? 0));
571
+ if (audio.length === 0) continue;
572
+ if (!enrichAudioInventoryFromSidecar(plan.audioTracks, sidecar, audio[0])) {
573
+ pendingIndexes.delete(sidecar.fileIndex);
574
+ continue;
575
+ }
576
+ // Keep the published inventory length and every address stable for this
577
+ // session. A late header enriches the offered first track; adding tracks
578
+ // would require replacing the HLS master playlist already loaded by the
579
+ // browser.
580
+ pendingIndexes.delete(sidecar.fileIndex);
581
+ if (audio.length > 1) {
582
+ logger.info(
583
+ `audio tracks: late header for "${sidecar.name}" describes ${audio.length} tracks; ` +
584
+ "the active inventory keeps its published track count"
585
+ );
586
+ }
587
+ }
588
+ pendingSidecarHeaders.set(cacheKey, [...pendingIndexes]);
589
+ plan.audioTracksPending = pendingIndexes.size > 0;
590
+ return { audioTracks: plan.audioTracks, pending: pendingIndexes.size > 0 };
525
591
  }
526
592
 
527
593
  function withHostTimings(plan) {
@@ -589,6 +655,8 @@ export function createPlaybackPlanner({
589
655
  return Array.isArray(plan?.audioTracks) ? plan.audioTracks : [];
590
656
  },
591
657
 
658
+ refreshAudioTracks,
659
+
592
660
  getCachedMediaInfo({ sourceKey, fileIndex }) {
593
661
  return mediaInfoCache.get(`${sourceKey}:${fileIndex}`) ?? null;
594
662
  },
@@ -657,6 +725,7 @@ export function createPlaybackPlanner({
657
725
  videoWidth: 0,
658
726
  videoHeight: 0,
659
727
  audioTracks: [],
728
+ audioTracksPending: false,
660
729
  subtitleTracks: []
661
730
  };
662
731
  cache.set(cacheKey, plan);
@@ -750,6 +819,7 @@ export function createPlaybackPlanner({
750
819
  );
751
820
  }
752
821
  const sidecars = sidecarsOf(torrent, fileIndex);
822
+ const inventory = await buildInventory(sourceKey, torrent, fileIndex, audioTracks ?? []);
753
823
  const plan = {
754
824
  mode: requiresTranscode ? "hls" : "direct",
755
825
  directUrl,
@@ -765,7 +835,8 @@ export function createPlaybackPlanner({
765
835
  // Full track inventory for the browser's audio/subtitle menus. The audio
766
836
  // half spans the picture's own tracks AND the soundtracks shipped as
767
837
  // files beside it, under one numbering — see `buildInventory`.
768
- audioTracks: await buildInventory(sourceKey, torrent, fileIndex, audioTracks ?? []),
838
+ audioTracks: inventory.audioTracks,
839
+ audioTracksPending: inventory.pendingFileIndexes.length > 0,
769
840
  subtitleTracks: await withContainerDefaults(sourceKey, torrent, fileIndex, subtitleTracks ?? []),
770
841
  // The files BESIDE this picture that belong to it, and what each one's
771
842
  // own path says about the track in it. Both answers are made here, by
@@ -816,6 +887,7 @@ export function createPlaybackPlanner({
816
887
  // prioritised by the prefetch above).
817
888
  if (codecsDetected) {
818
889
  cache.set(cacheKey, plan);
890
+ pendingSidecarHeaders.set(cacheKey, inventory.pendingFileIndexes);
819
891
  // Cache the full media info from THIS probe's banner (same helpers the
820
892
  // session manager uses) so createSession can skip its own probe.
821
893
  mediaInfoCache.set(cacheKey, {
@@ -1,36 +1,40 @@
1
- /**
2
- * @file Playback controller — interface layer over playback planning.
3
- *
4
- * Thin adapter between HTTP/routes and the playback plan. Parses nothing
5
- * itself. Exists so routes depend on a controller contract, not on service
6
- * internals.
7
- *
8
- * It used to hold a `ContainerOrchestrator` as well, and that was the main
9
- * thread's own instance of a module singleton the torrent worker also loads —
10
- * so it held no container, was asked nothing, and made it look as though a
11
- * container could be consulted here. It cannot: the file's bytes are in the
12
- * worker, and an object with methods does not cross a thread. What crosses is a
13
- * message.
14
- */
15
-
16
- export class PlaybackController {
17
- /**
18
- * @param {object} deps
19
- * @param {import("../../torrent/torrent-pool.js").TorrentPool} deps.torrentPool
20
- * @param {ReturnType<import("../../../store/source-registry.js").createSourceRegistry>} deps.sourceRegistry
21
- * @param {string} deps.ffmpegBin
22
- * @param {string} deps.localBaseUrl
23
- * @param {ReturnType<import("../../media/playback-planner.js").createPlaybackPlanner>} deps.playbackPlanner
24
- */
25
- constructor({ torrentPool, sourceRegistry, ffmpegBin, localBaseUrl, playbackPlanner }) {
26
- this.torrentPool = torrentPool;
27
- this.sourceRegistry = sourceRegistry;
28
- this.ffmpegBin = ffmpegBin;
29
- this.localBaseUrl = localBaseUrl;
30
- this.playbackPlanner = playbackPlanner;
31
- }
32
-
33
- async getPlan(params) {
34
- return this.playbackPlanner.getPlan(params);
35
- }
36
- }
1
+ /**
2
+ * @file Playback controller — interface layer over playback planning.
3
+ *
4
+ * Thin adapter between HTTP/routes and the playback plan. Parses nothing
5
+ * itself. Exists so routes depend on a controller contract, not on service
6
+ * internals.
7
+ *
8
+ * It used to hold a `ContainerOrchestrator` as well, and that was the main
9
+ * thread's own instance of a module singleton the torrent worker also loads —
10
+ * so it held no container, was asked nothing, and made it look as though a
11
+ * container could be consulted here. It cannot: the file's bytes are in the
12
+ * worker, and an object with methods does not cross a thread. What crosses is a
13
+ * message.
14
+ */
15
+
16
+ export class PlaybackController {
17
+ /**
18
+ * @param {object} deps
19
+ * @param {import("../../torrent/torrent-pool.js").TorrentPool} deps.torrentPool
20
+ * @param {ReturnType<import("../../../store/source-registry.js").createSourceRegistry>} deps.sourceRegistry
21
+ * @param {string} deps.ffmpegBin
22
+ * @param {string} deps.localBaseUrl
23
+ * @param {ReturnType<import("../../media/playback-planner.js").createPlaybackPlanner>} deps.playbackPlanner
24
+ */
25
+ constructor({ torrentPool, sourceRegistry, ffmpegBin, localBaseUrl, playbackPlanner }) {
26
+ this.torrentPool = torrentPool;
27
+ this.sourceRegistry = sourceRegistry;
28
+ this.ffmpegBin = ffmpegBin;
29
+ this.localBaseUrl = localBaseUrl;
30
+ this.playbackPlanner = playbackPlanner;
31
+ }
32
+
33
+ async getPlan(params) {
34
+ return this.playbackPlanner.getPlan(params);
35
+ }
36
+
37
+ async refreshAudioTracks(params) {
38
+ return this.playbackPlanner.refreshAudioTracks(params);
39
+ }
40
+ }