@torrent-tv/proxy 2.9.136 → 2.9.138
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/package.json +1 -1
- package/routes/api/transcode-sessions/post.js +139 -134
- package/services/hls-session-manager.js +53 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.9.138
|
|
2
|
+
|
|
3
|
+
- **Fix**: The init segment is now required to declare every track before it is cached — the requirement was computed and then ignored. One pass worked out how many tracks a complete header must have; the next returned the FIRST header it found, whatever it declared. A piece written before the video was muxed therefore supplied an audio-only header, and that header is kept for the session's whole life, because the player fetches `#EXT-X-MAP` exactly once and never again. The browser then has no video source buffer however much video arrives afterwards. Measured 2026-08-11 on the field host: `videoWidth=0`, `totalVideoFrames=0`, `readyState=4` — an element perfectly content, playing sound, with no picture in it for as long as the session lasted. A header short of a track is now kept only as a fallback and used only if no complete one is found, which is also when the log says so.
|
|
4
|
+
|
|
5
|
+
## 2.9.137
|
|
6
|
+
|
|
7
|
+
- **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.
|
|
8
|
+
|
|
1
9
|
## 2.9.136
|
|
2
10
|
|
|
3
11
|
- **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.
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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,6 +1524,37 @@ 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;
|
|
@@ -1542,15 +1573,10 @@ export class HlsSessionManager {
|
|
|
1542
1573
|
// the header this exists to reject. The pieces are still consulted, but
|
|
1543
1574
|
// only as a floor: a piece carrying more than the probe led us to expect is
|
|
1544
1575
|
// evidence, and evidence outranks the probe.
|
|
1545
|
-
const
|
|
1546
|
-
|
|
1547
|
-
fileIndex: session.fileIndex
|
|
1548
|
-
}) ?? null;
|
|
1549
|
-
let expectedTracks = probed
|
|
1550
|
-
? (probed.videoCodec ? 1 : 0) + (probed.audioCodec ? 1 : 0)
|
|
1551
|
-
: 0;
|
|
1576
|
+
const declared = this.declaredTracks(session);
|
|
1577
|
+
let expectedTracks = (declared.video ? 1 : 0) + (declared.audio ? 1 : 0);
|
|
1552
1578
|
if (expectedTracks === 0) {
|
|
1553
|
-
//
|
|
1579
|
+
// Nothing to consult. Fall back to the evidence, with its known lag.
|
|
1554
1580
|
expectedTracks = 1;
|
|
1555
1581
|
}
|
|
1556
1582
|
let best = null;
|
|
@@ -1593,9 +1619,26 @@ export class HlsSessionManager {
|
|
|
1593
1619
|
continue;
|
|
1594
1620
|
}
|
|
1595
1621
|
const init = session.segmentFormat.extractInit(cached ?? await readFile(found));
|
|
1596
|
-
if (init
|
|
1622
|
+
if (!init || init.length === 0) {
|
|
1623
|
+
continue;
|
|
1624
|
+
}
|
|
1625
|
+
// The requirement computed above is APPLIED here. It was computed and
|
|
1626
|
+
// then ignored: this loop returned the first header it found, so a
|
|
1627
|
+
// piece written before the video was muxed supplied an audio-only
|
|
1628
|
+
// header — and that header is cached for the session's whole life,
|
|
1629
|
+
// because the player fetches `#EXT-X-MAP` once. Measured 2026-08-11:
|
|
1630
|
+
// `videoWidth=0`, `totalVideoFrames=0`, `readyState=4` — sound playing
|
|
1631
|
+
// and no picture, for as long as the session lasted.
|
|
1632
|
+
const tracks = typeof session.segmentFormat.countInitTracks === "function"
|
|
1633
|
+
? session.segmentFormat.countInitTracks(init)
|
|
1634
|
+
: expectedTracks;
|
|
1635
|
+
if (tracks >= expectedTracks) {
|
|
1597
1636
|
return init;
|
|
1598
1637
|
}
|
|
1638
|
+
if (tracks > bestTracks) {
|
|
1639
|
+
best = init;
|
|
1640
|
+
bestTracks = tracks;
|
|
1641
|
+
}
|
|
1599
1642
|
} catch {
|
|
1600
1643
|
// Being written right now — try the next one.
|
|
1601
1644
|
}
|
|
@@ -1609,6 +1652,7 @@ export class HlsSessionManager {
|
|
|
1609
1652
|
`transcode ${session.id} no piece declared ${expectedTracks} tracks; ` +
|
|
1610
1653
|
`serving an init with ${bestTracks}`
|
|
1611
1654
|
);
|
|
1655
|
+
return best;
|
|
1612
1656
|
}
|
|
1613
1657
|
return best;
|
|
1614
1658
|
}
|