@torrent-tv/proxy 2.83.7 → 2.84.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.
@@ -1,137 +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
- export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager, sourceRegistry, torrentPool }) {
30
- const payload = getPayload(req.body);
31
- const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
32
- const fileIndex = Number(payload.fileIndex);
33
- const transcodeVideo = payload.transcodeVideo === true;
34
- const transcodeAudio = payload.transcodeAudio === true;
35
- const consumerId = typeof payload.consumerId === "string" ? payload.consumerId.trim() : "";
36
- const fileName = typeof payload.fileName === "string" ? payload.fileName.trim() : "";
37
- const targetWidth = Number(payload.targetWidth);
38
- const targetHeight = Number(payload.targetHeight);
39
- // Manual quality: the target box is a user-forced resolution, encoded exactly
40
- // (capped to source), with the realtime budget's auto-downscale + runtime
41
- // downswitch disabled for the session.
42
- const manualQuality = payload.manualQuality === true;
43
- // Whether this browser will take its audio from a separate rendition group in
44
- // the master playlist rather than muxed into the picture. It has to say so:
45
- // publishing renditions AND muxing the same audio would play it twice, while
46
- // a browser that does not know about them would get no sound at all.
47
- const audioRenditions = payload.audioRenditions === true;
48
- const startPositionSeconds = Number(payload.startPositionSeconds);
49
- const audioTrackIndex = Number(payload.audioTrackIndex);
50
- // Which container to produce. The browser knows what its media stack will
51
- // accept for the tracks it asked to be copied; an absent or unknown value
52
- // leaves the proxy's own `--segment-format` in charge.
53
- const segmentFormatId =
54
- typeof payload.segmentFormat === "string" ? payload.segmentFormat.trim() : "";
55
-
56
- if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
57
- return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
58
- }
59
-
60
- try {
61
- const session = await hlsSessionManager.createOrGetSession({
62
- sourceKey,
63
- fileIndex,
64
- transcodeVideo,
65
- transcodeAudio,
66
- consumerId,
67
- fileName,
68
- targetWidth: Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0,
69
- targetHeight: Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0,
70
- manualQuality,
71
- audioRenditions,
72
- startPositionSeconds:
73
- Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
74
- ? startPositionSeconds
75
- : 0,
76
- audioTrackIndex:
77
- Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0,
78
- segmentFormatId
79
- });
80
- // The index of quality variants, when this session has more than one to
81
- // offer. Its presence is what tells the browser it can change quality
82
- // without a new session: the player switches variants itself, appending the
83
- // new one after what is already buffered. Absent for a copied video, whose
84
- // segments are cut at the source's own keyframes and so cannot be spliced
85
- // with a re-encoded rung.
86
- const hasVariants = hlsSessionManager.buildMasterPlaylist(session.id) !== null;
87
- return reply.send({
88
- sessionId: session.id,
89
- playlistPath: `/transcode/${session.id}/index.m3u8`,
90
- ...(hasVariants
91
- ? {
92
- masterPath: `/transcode/${session.id}/master.m3u8`,
93
- // Which of the master's variants this session IS. The browser pins
94
- // the player to it, so loading the master costs nothing: an encoder
95
- // is already producing that height, and any other rung would be a
96
- // second cold start before the first frame.
97
- variantHeight: hlsSessionManager.liveOutputs.variantHeightOf(session)
98
- }
99
- : {}),
100
- // The heights this host will actually serve this file at, largest first
101
- // — the ladder minus every rung it cannot produce faster than it is
102
- // watched. The browser needs it whether or not a master exists: without
103
- // it, it fell back to a ladder of its own invention and offered rungs the
104
- // proxy had just refused, and picking one re-opened the session at a
105
- // height measured at a third of realtime.
106
- offeredHeights: hlsSessionManager.offeredHeights(session),
107
- // What this session's output will carry, stated rather than left to be
108
- // discovered. The browser checks what it actually got against this: a
109
- // track that never arrives is otherwise noticed only by its absence,
110
- // minutes later, as a black picture with working sound.
111
- tracks: hlsSessionManager.declaredTracks(session),
112
- // How far ahead of the viewer this proxy lets the encoder run, in seconds
113
- // of playback. The browser sizes its own forward buffer from it, so the
114
- // two sides agree by construction instead of each carrying a constant of
115
- // its own — which is how the browser came to hold thirty seconds while
116
- // two minutes stood produced on disk (roadmap item 4).
117
- lookaheadSeconds: hlsSessionManager.lookaheadSeconds
118
- });
119
- } catch (error) {
120
- if (error instanceof Error && error.code === "TRANSCODE_DISABLED") {
121
- return reply.code(409).send({ error: error.message });
122
- }
123
- const message = error instanceof Error ? error.message : String(error);
124
- // Say why on the proxy's own log, not only in the answer. This route
125
- // answered 500 for every viewer of proxy 2.9.101-2.9.102 (an undeclared
126
- // constant) and the addon log carried nothing but the data-channel layer's
127
- // bare "→ 500": the cause had to be recovered by replaying the request
128
- // against the live proxy. The stack is worth the two lines it costs — a
129
- // programming error here is invisible to the viewer, who only sees that
130
- // nothing plays.
131
- logger.error(
132
- `transcode-sessions: ${sourceKey}:${fileIndex} failed to prepare: ${message}\n` +
133
- `${error instanceof Error ? (error.stack ?? "") : ""}`
134
- );
135
- return reply.code(500).send({ error: `Failed to prepare transcode session: ${message}` });
136
- }
137
- }
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
+ export async function handleApiTranscodeSessionsPost(req, reply, { hlsSessionManager, sourceRegistry, torrentPool }) {
30
+ const payload = getPayload(req.body);
31
+ const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
32
+ const fileIndex = Number(payload.fileIndex);
33
+ const transcodeVideo = payload.transcodeVideo === true;
34
+ const transcodeAudio = payload.transcodeAudio === true;
35
+ const consumerId = typeof payload.consumerId === "string" ? payload.consumerId.trim() : "";
36
+ const fileName = typeof payload.fileName === "string" ? payload.fileName.trim() : "";
37
+ const targetWidth = Number(payload.targetWidth);
38
+ const targetHeight = Number(payload.targetHeight);
39
+ // The target box is produced exactly (capped to source), with the realtime
40
+ // budget's auto-downscale and runtime downswitch off for this session.
41
+ //
42
+ // It says nothing about WHO asked — a rung sets it whether the viewer picked
43
+ // that rung or the player moved itself onto one.
44
+ const exactSize = payload.exactSize === true;
45
+ // Whether this browser will take its audio from a separate rendition group in
46
+ // the master playlist rather than muxed into the picture. It has to say so:
47
+ // publishing renditions AND muxing the same audio would play it twice, while
48
+ // a browser that does not know about them would get no sound at all.
49
+ const audioRenditions = payload.audioRenditions === true;
50
+ const startPositionSeconds = Number(payload.startPositionSeconds);
51
+ const audioTrackIndex = Number(payload.audioTrackIndex);
52
+ // Which container to produce. The browser knows what its media stack will
53
+ // accept for the tracks it asked to be copied; an absent or unknown value
54
+ // leaves the proxy's own `--segment-format` in charge.
55
+ const segmentFormatId =
56
+ typeof payload.segmentFormat === "string" ? payload.segmentFormat.trim() : "";
57
+
58
+ if (!sourceKey || !Number.isInteger(fileIndex) || fileIndex < 0) {
59
+ return reply.code(400).send({ error: "sourceKey and valid fileIndex are required." });
60
+ }
61
+
62
+ try {
63
+ const session = await hlsSessionManager.createOrGetSession({
64
+ sourceKey,
65
+ fileIndex,
66
+ transcodeVideo,
67
+ transcodeAudio,
68
+ consumerId,
69
+ fileName,
70
+ targetWidth: Number.isInteger(targetWidth) && targetWidth > 0 ? targetWidth : 0,
71
+ targetHeight: Number.isInteger(targetHeight) && targetHeight > 0 ? targetHeight : 0,
72
+ exactSize,
73
+ audioRenditions,
74
+ startPositionSeconds:
75
+ Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
76
+ ? startPositionSeconds
77
+ : 0,
78
+ audioTrackIndex:
79
+ Number.isInteger(audioTrackIndex) && audioTrackIndex > 0 ? audioTrackIndex : 0,
80
+ segmentFormatId
81
+ });
82
+ // The index of quality variants, when this session has more than one to
83
+ // offer. Its presence is what tells the browser it can change quality
84
+ // without a new session: the player switches variants itself, appending the
85
+ // new one after what is already buffered. Absent for a copied video, whose
86
+ // segments are cut at the source's own keyframes and so cannot be spliced
87
+ // with a re-encoded rung.
88
+ const hasVariants = hlsSessionManager.buildMasterPlaylist(session.id) !== null;
89
+ return reply.send({
90
+ sessionId: session.id,
91
+ playlistPath: `/transcode/${session.id}/index.m3u8`,
92
+ ...(hasVariants
93
+ ? {
94
+ masterPath: `/transcode/${session.id}/master.m3u8`,
95
+ // Which of the master's variants this session IS. The browser pins
96
+ // the player to it, so loading the master costs nothing: an encoder
97
+ // is already producing that height, and any other rung would be a
98
+ // second cold start before the first frame.
99
+ variantHeight: hlsSessionManager.liveOutputs.variantHeightOf(session)
100
+ }
101
+ : {}),
102
+ // The heights this host will actually serve this file at, largest first
103
+ // — the ladder minus every rung it cannot produce faster than it is
104
+ // watched. The browser needs it whether or not a master exists: without
105
+ // it, it fell back to a ladder of its own invention and offered rungs the
106
+ // proxy had just refused, and picking one re-opened the session at a
107
+ // height measured at a third of realtime.
108
+ offeredHeights: hlsSessionManager.offeredHeights(session),
109
+ // What this session's output will carry, stated rather than left to be
110
+ // discovered. The browser checks what it actually got against this: a
111
+ // track that never arrives is otherwise noticed only by its absence,
112
+ // minutes later, as a black picture with working sound.
113
+ tracks: hlsSessionManager.declaredTracks(session),
114
+ // How far ahead of the viewer this proxy lets the encoder run, in seconds
115
+ // of playback. The browser sizes its own forward buffer from it, so the
116
+ // two sides agree by construction instead of each carrying a constant of
117
+ // its own — which is how the browser came to hold thirty seconds while
118
+ // two minutes stood produced on disk (roadmap item 4).
119
+ lookaheadSeconds: hlsSessionManager.lookaheadSeconds
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
+ }
package/server.js CHANGED
@@ -22,6 +22,8 @@ import { handleApiSourceStatsGet } from "./routes/api/sources/stats/get.js";
22
22
  import { handleApiSourceFilesGet } from "./routes/api/sources/files/get.js";
23
23
  import { handleApiSourceWarmPost } from "./routes/api/sources/warm/post.js";
24
24
  import { handleApiPlaybackPlanPost } from "./routes/api/playback-plan/post.js";
25
+ import { handleApiClientLogsPost } from "./routes/api/client-logs/post.js";
26
+ import { createClientLogFiles } from "./utils/client-log-file.js";
25
27
  import { handleApiSubtitlesGet } from "./routes/api/subtitles/get.js";
26
28
  import { handleApiTranscodeSessionsPost } from "./routes/api/transcode-sessions/post.js";
27
29
  import { handleApiTranscodeSessionsProgressGet } from "./routes/api/transcode-sessions/progress/get.js";
@@ -84,7 +86,11 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
84
86
  */
85
87
  export async function startProxyServer({
86
88
  host, port, transcodeAudio, ffmpegBin, maxDiskBytes, memoryBytes, segmentFormat, stateDir, onSubtitleCues,
87
- deliverySink = false
89
+ deliverySink = false,
90
+ // Where the proxy writes its own log. Its DIRECTORY is what matters here:
91
+ // the browser's half of every session is written beside it, so the two are
92
+ // on one durable disk and join by name.
93
+ logFile = ""
88
94
  }) {
89
95
  const app = Fastify({
90
96
  // No practical body-size limit — the proxy server is localhost-only and
@@ -346,6 +352,12 @@ export async function startProxyServer({
346
352
  app.post("/api/sources/:sourceKey/warm", async (req, reply) =>
347
353
  handleApiSourceWarmPost(req, reply, { sourceRegistry, torrentPool })
348
354
  );
355
+ // The browser's own log, kept beside the proxy's. See the route's own file
356
+ // for why it is here and not only on the registry server.
357
+ const clientLogs = logFile ? createClientLogFiles(logFile) : null;
358
+ app.post("/api/client-logs", async (req, reply) =>
359
+ handleApiClientLogsPost(req, reply, { clientLogs })
360
+ );
349
361
  app.post("/api/playback-plan", async (req, reply) =>
350
362
  handleApiPlaybackPlanPost(req, reply, { playbackPlanner })
351
363
  );
@@ -362,6 +362,13 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
362
362
  const snapshot = getTransportSnapshot?.(sessionId) ?? null;
363
363
  connection.unknown = snapshot ? 0 : connection.unknown + 1;
364
364
  if (connection.unknown >= TRANSPORT_UNKNOWN_HEARTBEATS) {
365
+ // THE SECOND DETECTOR, and it is the one that has to work: `onClosed`
366
+ // does not always come — a peer connection can die without it — and
367
+ // presence is the connection and nothing else, so nothing else will
368
+ // ever say these people have gone. Until now this ended the watch and
369
+ // left them registered for the life of the process, holding the
370
+ // outputs they were watching.
371
+ connectionGone(sessionId, "the transport stopped answering");
365
372
  stop();
366
373
  return;
367
374
  }