@torrent-tv/proxy 2.9.135 → 2.9.137

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 CHANGED
@@ -1,4 +1,12 @@
1
- ## 2.9.132
1
+ ## 2.9.137
2
+
3
+ - **New**: A session states the track set its output will carry, and sends it to the browser. The proxy knows the set exactly — it chose it: the command maps at most one video and at most one audio, each optional, and subtitles never enter the HLS output. That statement is now used twice, which is the point of making it: the init segment is checked against it here, and the browser checks what it actually received against the same statement (server 0.8.161). A track lost between the encoder and the element was previously noticed only by its absence, minutes later, as a black picture with working sound.
4
+
5
+ ## 2.9.136
6
+
7
+ - **Fix**: What a complete init segment must describe is now taken from what the proxy DECLARES it will output, not from a count. 2.9.135 required two tracks, which is a guess — wrong for a film with no soundtrack, and meaningless for a source carrying several dubs, subtitles or a cover-art video stream. The output does not inherit the source's track list: the command maps at most one video and at most one audio, each optional, and subtitles never enter the HLS output at all. So the proxy knows the output's set exactly, because it chose it — the probe says which kinds exist, the mapping says how many are taken. Deriving the figure from produced pieces instead reads correctly only once a piece carrying every track exists, and the moment that matters is the one before that: an early piece written before the video was muxed sets the requirement to one and waves through precisely the header this exists to reject. Pieces remain as a floor, since a piece carrying more than declared is evidence, and evidence outranks a declaration.
8
+
9
+ ## 2.9.135
2
10
 
3
11
  - **Fix**: A session no longer plays sound with no picture at all. The init segment — the header that tells the browser which tracks exist — is lifted out of the first self-contained piece and then cached for the WHOLE session, because the player fetches `#EXT-X-MAP` once and never again. A piece written before the video track had been muxed declares audio alone, and the browser then has no video source buffer for the rest of the session however much video arrives afterwards. Measured 2026-08-10 from the browser's own counters: sixty-five seconds of playing sound with `videoWidth=0`, `totalVideoFrames=0` and `readyState=4` — an element perfectly satisfied, with no picture in it. A header short of a track is now passed over and the next piece tried; if no piece carries the full set the richest one found is served and the shortfall is logged, so a source that genuinely lacks a stream still plays while the other possibility stays visible.
4
12
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.135",
3
+ "version": "2.9.137",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -1,134 +1,139 @@
1
- /**
2
- * Create or return an existing HLS transcode session for a torrent file.
3
- *
4
- * POST /api/transcode-sessions
5
- *
6
- * @param {import("fastify").FastifyRequest} req
7
- * @param {import("fastify").FastifyReply} reply
8
- * @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager, sourceRegistry: object, torrentPool: object }} deps
9
- * @returns {Promise<void>}
10
- */
11
-
12
- import { logger } from "../../../utils/logger.js";
13
-
14
- /**
15
- * Extract a plain object from the request body, guarding against
16
- * non-object payloads (arrays, primitives, null).
17
- *
18
- * @param {unknown} body
19
- * @returns {Record<string, unknown>}
20
- */
21
- function getPayload(body) {
22
- if (body && typeof body === "object" && !Array.isArray(body)) {
23
- return body;
24
- }
25
- return {};
26
- }
27
-
28
- /**
29
- * Claim the source's file so the pool cannot clean it up under a live session.
30
- *
31
- * Asynchronous underneath — the torrent lives on another thread — but the
32
- * caller needs a release function immediately, so the claim is chased and the
33
- * release waits for it.
34
- *
35
- * @param {{ sourceRegistry: object, torrentPool: object, sourceKey: string, fileIndex: number }} params
36
- * @returns {() => void}
37
- */
38
- function holdSource({ sourceRegistry, torrentPool, sourceKey, fileIndex }) {
39
- let release = null;
40
- let releasedEarly = false;
41
- const record = sourceRegistry?.get?.(sourceKey);
42
- if (!record) {
43
- return () => {};
44
- }
45
- void Promise.resolve(torrentPool.getTorrent(record.sourceType, record.source))
46
- .then((torrent) => {
47
- release = torrentPool.acquireFile(torrent, fileIndex);
48
- if (releasedEarly) {
49
- release();
50
- }
51
- })
52
- .catch(() => {});
53
- return () => {
54
- releasedEarly = true;
55
- if (typeof release === "function") {
56
- release();
57
- release = null;
58
- }
59
- };
60
- }
61
-
62
- export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager, sourceRegistry, torrentPool }) {
63
- const payload = getPayload(req.body);
64
- const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
65
- const fileIndex = Number(payload.fileIndex);
66
- const transcodeVideo = payload.transcodeVideo === true;
67
- const transcodeAudio = payload.transcodeAudio === true;
68
- const consumerId = typeof payload.consumerId === "string" ? payload.consumerId.trim() : "";
69
- const fileName = typeof payload.fileName === "string" ? payload.fileName.trim() : "";
70
- const targetWidth = Number(payload.targetWidth);
71
- const targetHeight = Number(payload.targetHeight);
72
- // Manual quality: the target box is a user-forced resolution, encoded exactly
73
- // (capped to source), with the realtime budget's auto-downscale + runtime
74
- // downswitch disabled for the session.
75
- const manualQuality = payload.manualQuality === true;
76
- const startPositionSeconds = Number(payload.startPositionSeconds);
77
- const audioTrackIndex = Number(payload.audioTrackIndex);
78
- // Which container to produce. The browser knows what its media stack will
79
- // accept for the tracks it asked to be copied; an absent or unknown value
80
- // leaves the proxy's own `--segment-format` in charge.
81
- const segmentFormatId =
82
- typeof payload.segmentFormat === "string" ? payload.segmentFormat.trim() : "";
83
-
84
- if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
85
- return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
86
- }
87
-
88
- try {
89
- const session = await hlsSessionManager.createOrGetSession({
90
- sourceKey,
91
- fileIndex,
92
- transcodeVideo,
93
- transcodeAudio,
94
- consumerId,
95
- fileName,
96
- targetWidth: Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0,
97
- targetHeight: Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0,
98
- manualQuality,
99
- startPositionSeconds:
100
- Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
101
- ? startPositionSeconds
102
- : 0,
103
- audioTrackIndex:
104
- Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0,
105
- segmentFormatId,
106
- // Hold the torrent for as long as this session lives. Reads take a claim
107
- // only while they run, and a seek leaves a gap with no read at all — the
108
- // disk sweep caught that gap on 2026-08-06 and deleted the film being
109
- // watched.
110
- acquireSource: () => holdSource({ sourceRegistry, torrentPool, sourceKey, fileIndex })
111
- });
112
- return reply.send({
113
- sessionId: session.id,
114
- playlistPath: `/transcode/${session.id}/index.m3u8`
115
- });
116
- } catch (error) {
117
- if (error instanceof Error && error.code === "TRANSCODE_DISABLED") {
118
- return reply.code(409).send({ error: error.message });
119
- }
120
- const message = error instanceof Error ? error.message : String(error);
121
- // Say why on the proxy's own log, not only in the answer. This route
122
- // answered 500 for every viewer of proxy 2.9.101-2.9.102 (an undeclared
123
- // constant) and the addon log carried nothing but the data-channel layer's
124
- // bare "→ 500": the cause had to be recovered by replaying the request
125
- // against the live proxy. The stack is worth the two lines it costs — a
126
- // programming error here is invisible to the viewer, who only sees that
127
- // nothing plays.
128
- logger.error(
129
- `transcode-sessions: ${sourceKey}:${fileIndex} failed to prepare: ${message}\n` +
130
- `${error instanceof Error ? (error.stack ?? "") : ""}`
131
- );
132
- return reply.code(500).send({ error: `Failed to prepare transcode session: ${message}` });
133
- }
134
- }
1
+ /**
2
+ * Create or return an existing HLS transcode session for a torrent file.
3
+ *
4
+ * POST /api/transcode-sessions
5
+ *
6
+ * @param {import("fastify").FastifyRequest} req
7
+ * @param {import("fastify").FastifyReply} reply
8
+ * @param {{ hlsSessionManager: import("../../../services/hls-session-manager.js").HlsSessionManager, sourceRegistry: object, torrentPool: object }} deps
9
+ * @returns {Promise<void>}
10
+ */
11
+
12
+ import { logger } from "../../../utils/logger.js";
13
+
14
+ /**
15
+ * Extract a plain object from the request body, guarding against
16
+ * non-object payloads (arrays, primitives, null).
17
+ *
18
+ * @param {unknown} body
19
+ * @returns {Record<string, unknown>}
20
+ */
21
+ function getPayload(body) {
22
+ if (body && typeof body === "object" && !Array.isArray(body)) {
23
+ return body;
24
+ }
25
+ return {};
26
+ }
27
+
28
+ /**
29
+ * Claim the source's file so the pool cannot clean it up under a live session.
30
+ *
31
+ * Asynchronous underneath — the torrent lives on another thread — but the
32
+ * caller needs a release function immediately, so the claim is chased and the
33
+ * release waits for it.
34
+ *
35
+ * @param {{ sourceRegistry: object, torrentPool: object, sourceKey: string, fileIndex: number }} params
36
+ * @returns {() => void}
37
+ */
38
+ function holdSource({ sourceRegistry, torrentPool, sourceKey, fileIndex }) {
39
+ let release = null;
40
+ let releasedEarly = false;
41
+ const record = sourceRegistry?.get?.(sourceKey);
42
+ if (!record) {
43
+ return () => {};
44
+ }
45
+ void Promise.resolve(torrentPool.getTorrent(record.sourceType, record.source))
46
+ .then((torrent) => {
47
+ release = torrentPool.acquireFile(torrent, fileIndex);
48
+ if (releasedEarly) {
49
+ release();
50
+ }
51
+ })
52
+ .catch(() => {});
53
+ return () => {
54
+ releasedEarly = true;
55
+ if (typeof release === "function") {
56
+ release();
57
+ release = null;
58
+ }
59
+ };
60
+ }
61
+
62
+ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager, sourceRegistry, torrentPool }) {
63
+ const payload = getPayload(req.body);
64
+ const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
65
+ const fileIndex = Number(payload.fileIndex);
66
+ const transcodeVideo = payload.transcodeVideo === true;
67
+ const transcodeAudio = payload.transcodeAudio === true;
68
+ const consumerId = typeof payload.consumerId === "string" ? payload.consumerId.trim() : "";
69
+ const fileName = typeof payload.fileName === "string" ? payload.fileName.trim() : "";
70
+ const targetWidth = Number(payload.targetWidth);
71
+ const targetHeight = Number(payload.targetHeight);
72
+ // Manual quality: the target box is a user-forced resolution, encoded exactly
73
+ // (capped to source), with the realtime budget's auto-downscale + runtime
74
+ // downswitch disabled for the session.
75
+ const manualQuality = payload.manualQuality === true;
76
+ const startPositionSeconds = Number(payload.startPositionSeconds);
77
+ const audioTrackIndex = Number(payload.audioTrackIndex);
78
+ // Which container to produce. The browser knows what its media stack will
79
+ // accept for the tracks it asked to be copied; an absent or unknown value
80
+ // leaves the proxy's own `--segment-format` in charge.
81
+ const segmentFormatId =
82
+ typeof payload.segmentFormat === "string" ? payload.segmentFormat.trim() : "";
83
+
84
+ if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
85
+ return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
86
+ }
87
+
88
+ try {
89
+ const session = await hlsSessionManager.createOrGetSession({
90
+ sourceKey,
91
+ fileIndex,
92
+ transcodeVideo,
93
+ transcodeAudio,
94
+ consumerId,
95
+ fileName,
96
+ targetWidth: Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0,
97
+ targetHeight: Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0,
98
+ manualQuality,
99
+ startPositionSeconds:
100
+ Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
101
+ ? startPositionSeconds
102
+ : 0,
103
+ audioTrackIndex:
104
+ Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0,
105
+ segmentFormatId,
106
+ // Hold the torrent for as long as this session lives. Reads take a claim
107
+ // only while they run, and a seek leaves a gap with no read at all — the
108
+ // disk sweep caught that gap on 2026-08-06 and deleted the film being
109
+ // watched.
110
+ acquireSource: () => holdSource({ sourceRegistry, torrentPool, sourceKey, fileIndex })
111
+ });
112
+ return reply.send({
113
+ sessionId: session.id,
114
+ playlistPath: `/transcode/${session.id}/index.m3u8`,
115
+ // What this session's output will carry, stated rather than left to be
116
+ // discovered. The browser checks what it actually got against this: a
117
+ // track that never arrives is otherwise noticed only by its absence,
118
+ // minutes later, as a black picture with working sound.
119
+ tracks: hlsSessionManager.declaredTracks(session)
120
+ });
121
+ } catch (error) {
122
+ if (error instanceof Error && error.code === "TRANSCODE_DISABLED") {
123
+ return reply.code(409).send({ error: error.message });
124
+ }
125
+ const message = error instanceof Error ? error.message : String(error);
126
+ // Say why on the proxy's own log, not only in the answer. This route
127
+ // answered 500 for every viewer of proxy 2.9.101-2.9.102 (an undeclared
128
+ // constant) and the addon log carried nothing but the data-channel layer's
129
+ // bare "→ 500": the cause had to be recovered by replaying the request
130
+ // against the live proxy. The stack is worth the two lines it costs — a
131
+ // programming error here is invisible to the viewer, who only sees that
132
+ // nothing plays.
133
+ logger.error(
134
+ `transcode-sessions: ${sourceKey}:${fileIndex} failed to prepare: ${message}\n` +
135
+ `${error instanceof Error ? (error.stack ?? "") : ""}`
136
+ );
137
+ return reply.code(500).send({ error: `Failed to prepare transcode session: ${message}` });
138
+ }
139
+ }
@@ -1524,17 +1524,65 @@ export class HlsSessionManager {
1524
1524
  * @param {HlsSession} session
1525
1525
  * @returns {Promise<Buffer | null>}
1526
1526
  */
1527
+ /**
1528
+ * The track set this session's output will carry — what the proxy DECLARES,
1529
+ * and the one answer both sides must agree on.
1530
+ *
1531
+ * The source may hold any number of tracks: several dubs, subtitles, even a
1532
+ * cover-art video stream. The output does not inherit that list — the command
1533
+ * maps at most one video and at most one audio, each optional, and subtitles
1534
+ * never enter the HLS output at all (they are served separately as WebVTT).
1535
+ * So this is not an inference about the file; it is the proxy stating what it
1536
+ * chose to produce.
1537
+ *
1538
+ * Used in two places, and that is the point: the init segment is checked
1539
+ * against it here, and it is sent to the browser so the browser can check
1540
+ * what it actually received against the same statement. Without the second
1541
+ * check a missing track is only noticed by its absence, minutes later, as a
1542
+ * black picture with working sound.
1543
+ *
1544
+ * @param {HlsSession} session
1545
+ * @returns {{ video: boolean, audio: boolean }}
1546
+ */
1547
+ declaredTracks(session) {
1548
+ const probed = this.getCachedMediaInfo?.({
1549
+ sourceKey: session.sourceKey,
1550
+ fileIndex: session.fileIndex
1551
+ }) ?? null;
1552
+ return {
1553
+ video: Boolean(probed?.videoCodec),
1554
+ audio: Boolean(probed?.audioCodec)
1555
+ };
1556
+ }
1557
+
1527
1558
  async #initFromFirstSegment(session) {
1528
1559
  if (typeof session.segmentFormat.extractInit !== "function") {
1529
1560
  return null;
1530
1561
  }
1531
- // Both streams are mapped (`-map 0:v:0? -map 0:a:0?`), so a complete header
1532
- // declares two tracks. A source genuinely missing one is legitimate, and
1533
- // must not be left without an init for ever — hence the fallback below to
1534
- // the richest header found rather than a hard requirement.
1535
- const expectedTracks = 2;
1562
+ // How many tracks a complete header must declare is ANSWERED, not assumed.
1563
+ //
1564
+ // The probe already knows the source's stream list, and the output maps at
1565
+ // most one of each (`-map 0:v:0? -map 0:a:0?`), so the count follows from
1566
+ // what the source actually has. A film with no soundtrack expects one; an
1567
+ // ordinary file expects two; neither is a convention.
1568
+ //
1569
+ // Deriving it from the produced pieces instead — the first version of this
1570
+ // — reads correctly only once a piece carrying every track exists, and the
1571
+ // whole point is the moment BEFORE that: early pieces written before the
1572
+ // video was muxed would set the requirement to one and wave through exactly
1573
+ // the header this exists to reject. The pieces are still consulted, but
1574
+ // only as a floor: a piece carrying more than the probe led us to expect is
1575
+ // evidence, and evidence outranks the probe.
1576
+ const declared = this.declaredTracks(session);
1577
+ let expectedTracks = (declared.video ? 1 : 0) + (declared.audio ? 1 : 0);
1578
+ if (expectedTracks === 0) {
1579
+ // Nothing to consult. Fall back to the evidence, with its known lag.
1580
+ expectedTracks = 1;
1581
+ }
1536
1582
  let best = null;
1537
1583
  let bestTracks = 0;
1584
+ /** @type {Map<string, Buffer>} Pieces read once and used for both passes. */
1585
+ const pieces = new Map();
1538
1586
  let names;
1539
1587
  try {
1540
1588
  names = (this.#runDirs(session).flatMap((dir) => {
@@ -1545,13 +1593,32 @@ export class HlsSessionManager {
1545
1593
  } catch {
1546
1594
  return null;
1547
1595
  }
1596
+ // First pass: what do the produced pieces actually carry? The answer is the
1597
+ // requirement — no assumption about the source is involved.
1598
+ if (typeof session.segmentFormat.countSegmentTracks === "function") {
1599
+ for (const name of names) {
1600
+ try {
1601
+ const found = await this.#findProducedFile(session, name);
1602
+ if (!found) {
1603
+ continue;
1604
+ }
1605
+ const bytes = await readFile(found);
1606
+ pieces.set(name, bytes);
1607
+ expectedTracks = Math.max(expectedTracks, session.segmentFormat.countSegmentTracks(bytes));
1608
+ } catch {
1609
+ // Being written right now — it says nothing about the others.
1610
+ }
1611
+ }
1612
+ }
1613
+
1548
1614
  for (const name of names) {
1549
1615
  try {
1550
- const found = await this.#findProducedFile(session, name);
1616
+ const cached = pieces.get(name);
1617
+ const found = cached ? name : await this.#findProducedFile(session, name);
1551
1618
  if (!found) {
1552
1619
  continue;
1553
1620
  }
1554
- const init = session.segmentFormat.extractInit(await readFile(found));
1621
+ const init = session.segmentFormat.extractInit(cached ?? await readFile(found));
1555
1622
  if (init && init.length > 0) {
1556
1623
  return init;
1557
1624
  }
@@ -265,6 +265,22 @@ export const fmp4Format = {
265
265
  * @param {Buffer} initBytes
266
266
  * @returns {number}
267
267
  */
268
+ /**
269
+ * How many distinct tracks this piece's own fragments carry.
270
+ *
271
+ * Needs no knowledge of the source: whatever a produced piece contains, it
272
+ * contains. Comparing the richest piece against a candidate header is what
273
+ * lets an init be judged without assuming how many tracks a file "should"
274
+ * have — a film with no soundtrack and a file with two audio tracks are both
275
+ * answered correctly, and neither is guessed at.
276
+ *
277
+ * @param {Buffer} bytes
278
+ * @returns {number}
279
+ */
280
+ countSegmentTracks(bytes) {
281
+ return countFragmentTracks(bytes);
282
+ },
283
+
268
284
  countInitTracks(initBytes) {
269
285
  if (!initBytes || initBytes.length === 0) {
270
286
  return 0;