@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.
- package/CHANGELOG.md +8 -0
- package/docs/browser-proxy-contract.md +101 -94
- package/package.json +1 -1
- package/routes/api/playback-plan/audio-tracks/post.js +29 -0
- package/server.js +4 -0
- package/services/media/audio-inventory.js +245 -212
- package/services/media/playback-planner.js +131 -59
- package/services/server/controllers/PlaybackController.js +40 -36
- package/services/transport/data-channel-handler.js +1369 -1356
- package/test/audio-inventory.test.js +212 -176
- package/test/data-channel-viewer-lifecycle.test.js +92 -0
|
@@ -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
|
|
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
|
|
419
|
-
*
|
|
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
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
})
|
|
463
|
-
|
|
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
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
+
}
|