@torrent-tv/proxy 2.72.1 → 2.73.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.
@@ -22,6 +22,57 @@ import { AudioTrack } from "../tracks/AudioTrack.js";
22
22
  import { TextSubtitleTrack, TEXT_FORMATS_MP4 } from "../tracks/TextSubtitleTrack.js";
23
23
  import { ImageSubtitleTrack } from "../tracks/ImageSubtitleTrack.js";
24
24
 
25
+ /**
26
+ * One box header, per ISO/IEC 14496-12 §4.2.
27
+ *
28
+ * @param {Buffer} buf
29
+ * @param {number} off
30
+ * @returns {{ type: string, size: number, dataOffset: number, end: number } | null}
31
+ */
32
+ function readBox(buf, off) {
33
+ if (off + 8 > buf.length) return null;
34
+ let sz = buf.readUInt32BE(off);
35
+ const tp = buf.toString("latin1", off + 4, off + 8);
36
+ let hb = 8;
37
+ if (sz === 1) { if (off + 16 > buf.length) return null; sz = Number(buf.readBigUInt64BE(off + 8)); hb = 16; }
38
+ if (sz < hb) return null;
39
+ return { type: tp, size: sz, dataOffset: off + hb, end: off + sz };
40
+ }
41
+
42
+ /**
43
+ * Every direct child box of the given type.
44
+ *
45
+ * @param {Buffer} buf
46
+ * @param {number} start
47
+ * @param {number} end
48
+ * @param {string} type
49
+ * @returns {Array<{ type: string, size: number, dataOffset: number, end: number }>}
50
+ */
51
+ function childrenOf(buf, start, end, type) {
52
+ const out = [];
53
+ let p = start;
54
+ while (p + 8 <= end) {
55
+ const b = readBox(buf, p);
56
+ if (!b) break;
57
+ if (b.type === type) out.push(b);
58
+ p = b.end;
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /**
64
+ * The first direct child box of the given type, or null.
65
+ *
66
+ * @param {Buffer} buf
67
+ * @param {number} s
68
+ * @param {number} e
69
+ * @param {string} t
70
+ * @returns {{ type: string, size: number, dataOffset: number, end: number } | null}
71
+ */
72
+ function childOf(buf, s, e, t) {
73
+ return childrenOf(buf, s, e, t)[0] ?? null;
74
+ }
75
+
25
76
  export class Mp4Container extends Container {
26
77
  get formatName() {
27
78
  return "mp4";
@@ -79,12 +130,21 @@ export class Mp4Container extends Container {
79
130
  return tracks;
80
131
  }
81
132
 
82
- async #readVideoAudioTracks() {
83
- // Lightweight: scan moov for trak with hdlr vide/soun. We reuse readMp4SubtitlePlan's findMoov
84
- // by reimplementing a minimal header walk here.
133
+ /**
134
+ * The `moov` box, read whole.
135
+ *
136
+ * Held on the instance because every question this class answers is inside
137
+ * it, and the box can be tens of megabytes off a torrent — reading it once
138
+ * per file is the difference between one fetch and one per question.
139
+ *
140
+ * @returns {Promise<{ moov: Buffer, header: number } | null>}
141
+ */
142
+ async #moovBuffer() {
143
+ if (this.moovHeld !== undefined) {
144
+ return this.moovHeld;
145
+ }
85
146
  const PROBE = 64;
86
147
  const MAX_MOOV = 32 * 1024 * 1024;
87
- // find moov offset
88
148
  let at = 0;
89
149
  let moovBox = null;
90
150
  while (at < this.fileSize) {
@@ -102,37 +162,106 @@ export class Mp4Container extends Container {
102
162
  if (type === "moov") { moovBox = { offset: at, size, header }; break; }
103
163
  at += size;
104
164
  }
105
- if (!moovBox || moovBox.size > MAX_MOOV) return [];
165
+ if (!moovBox || moovBox.size > MAX_MOOV) {
166
+ this.moovHeld = null;
167
+ return null;
168
+ }
106
169
  const moov = await this.readRange(moovBox.offset, Math.min(this.fileSize - 1, moovBox.offset + moovBox.size - 1));
107
- if (!moov) return [];
170
+ this.moovHeld = moov ? { moov, header: moovBox.header } : null;
171
+ return this.moovHeld;
172
+ }
173
+
174
+ /**
175
+ * Duration from `mvhd` and the presentation offset from the first track's
176
+ * edit list, per ISO/IEC 14496-12 §8.2.2 and §8.6.6.
177
+ *
178
+ * An edit entry whose `media_time` is -1 is an EMPTY edit: it presents
179
+ * nothing for `segment_duration`, which shifts everything after it later by
180
+ * that much. That shift is what a player reports as the file's start, and it
181
+ * is the only way an MP4 states one — a file without such an edit begins at
182
+ * zero, which is a declaration, not an absence.
183
+ *
184
+ * @returns {Promise<import("./Container.js").ContainerMediaInfo>}
185
+ */
186
+ async readMediaInfo() {
187
+ if (this.mediaInfo) {
188
+ return this.mediaInfo;
189
+ }
190
+ /** @type {import("./Container.js").ContainerMediaInfo} */
191
+ const info = { format: this.formatName, durationSeconds: null, startTimeSeconds: null };
192
+ this.mediaInfo = info;
193
+ const held = await this.#moovBuffer();
194
+ if (!held) {
195
+ return info;
196
+ }
197
+ const { moov, header } = held;
198
+ const mvhd = childOf(moov, header, moov.length, "mvhd");
199
+ let movieTimescale = 0;
200
+ if (mvhd) {
201
+ const version = moov[mvhd.dataOffset];
202
+ // version 0: creation(4) modification(4) timescale(4) duration(4)
203
+ // version 1: creation(8) modification(8) timescale(4) duration(8)
204
+ const at = version === 1 ? mvhd.dataOffset + 20 : mvhd.dataOffset + 12;
205
+ if (at + 8 <= moov.length) {
206
+ movieTimescale = moov.readUInt32BE(at);
207
+ const duration = version === 1 ? Number(moov.readBigUInt64BE(at + 4)) : moov.readUInt32BE(at + 4);
208
+ if (movieTimescale > 0 && duration > 0) {
209
+ info.durationSeconds = duration / movieTimescale;
210
+ }
211
+ }
212
+ }
213
+ info.startTimeSeconds = movieTimescale > 0
214
+ ? Mp4Container.#emptyEditSeconds(moov, header, movieTimescale)
215
+ : null;
216
+ return info;
217
+ }
218
+
219
+ /**
220
+ * The presentation shift of the first empty edit, in seconds; 0 when no track
221
+ * declares one.
222
+ *
223
+ * @param {Buffer} moov
224
+ * @param {number} header
225
+ * @param {number} movieTimescale
226
+ * @returns {number}
227
+ */
228
+ static #emptyEditSeconds(moov, header, movieTimescale) {
229
+ let shift = 0;
230
+ for (const trak of childrenOf(moov, header, moov.length, "trak")) {
231
+ const edts = childOf(moov, trak.dataOffset, trak.end, "edts");
232
+ const elst = edts && childOf(moov, edts.dataOffset, edts.end, "elst");
233
+ if (!elst) {
234
+ continue;
235
+ }
236
+ const version = moov[elst.dataOffset];
237
+ const count = moov.readUInt32BE(elst.dataOffset + 4);
238
+ if (count < 1) {
239
+ continue;
240
+ }
241
+ const entry = elst.dataOffset + 8;
242
+ const segmentDuration = version === 1
243
+ ? Number(moov.readBigUInt64BE(entry))
244
+ : moov.readUInt32BE(entry);
245
+ const mediaTime = version === 1
246
+ ? Number(moov.readBigInt64BE(entry + 8))
247
+ : moov.readInt32BE(entry + 4);
248
+ if (mediaTime === -1 && segmentDuration > 0) {
249
+ shift = Math.max(shift, segmentDuration / movieTimescale);
250
+ }
251
+ }
252
+ return shift;
253
+ }
254
+
255
+ async #readVideoAudioTracks() {
256
+ const held = await this.#moovBuffer();
257
+ if (!held) return [];
258
+ const { moov, header: moovHeader } = held;
108
259
 
109
260
  const result = [];
110
261
  let videoIdx = -1;
111
262
  let audioIdx = -1;
112
- // iterate trak boxes inside moov
113
- const readBox = (buf, off) => {
114
- if (off + 8 > buf.length) return null;
115
- let sz = buf.readUInt32BE(off);
116
- const tp = buf.toString("latin1", off + 4, off + 8);
117
- let hb = 8;
118
- if (sz === 1) { if (off + 16 > buf.length) return null; sz = Number(buf.readBigUInt64BE(off + 8)); hb = 16; }
119
- if (sz < hb) return null;
120
- return { type: tp, size: sz, dataOffset: off + hb, end: off + sz };
121
- };
122
- const childrenOf = (buf, start, end, type) => {
123
- const out = [];
124
- let p = start;
125
- while (p + 8 <= end) {
126
- const b = readBox(buf, p);
127
- if (!b) break;
128
- if (b.type === type) out.push(b);
129
- p = b.end;
130
- }
131
- return out;
132
- };
133
- const childOf = (buf, s, e, t) => childrenOf(buf, s, e, t)[0] ?? null;
134
263
 
135
- const moovContentStart = moovBox.header;
264
+ const moovContentStart = moovHeader;
136
265
  const moovEnd = moov.length;
137
266
  for (const trak of childrenOf(moov, moovContentStart, moovEnd, "trak")) {
138
267
  const mdia = childOf(moov, trak.dataOffset, trak.end, "mdia");
@@ -101,6 +101,36 @@ export function readUint(buffer, offset, size) {
101
101
  return value;
102
102
  }
103
103
 
104
+ /**
105
+ * An EBML float, which RFC 9559 §11.3.3 allows to be 4 or 8 bytes, big-endian
106
+ * IEEE 754, and 0 bytes for the value zero.
107
+ *
108
+ * Needed because Matroska writes `Duration` as a float, not as an integer:
109
+ * reading it with {@link readUint} yields the bit pattern rather than the
110
+ * number. Any other size is malformed and answered with null rather than a
111
+ * guess.
112
+ *
113
+ * @param {Buffer} buffer
114
+ * @param {number} offset
115
+ * @param {number} size
116
+ * @returns {number | null}
117
+ */
118
+ export function readFloat(buffer, offset, size) {
119
+ if (size === 0) {
120
+ return 0;
121
+ }
122
+ if (offset + size > buffer.length) {
123
+ return null;
124
+ }
125
+ if (size === 4) {
126
+ return buffer.readFloatBE(offset);
127
+ }
128
+ if (size === 8) {
129
+ return buffer.readDoubleBE(offset);
130
+ }
131
+ return null;
132
+ }
133
+
104
134
  /**
105
135
  * Depth-first search for the first element with `id`, descending only into the
106
136
  * container ids listed in `descendInto`.
@@ -974,7 +974,7 @@ function computeProgressMetrics(processedSeconds, totalSeconds, startPositionSec
974
974
  * @param {string | URL} inputUrl - URL of the stream to probe.
975
975
  * @returns {Promise<{ durationSeconds: number | null, width: number | null, height: number | null, fps: number | null, startTime: number, isHdr: boolean }>}
976
976
  */
977
- async function probeInputMediaInfo(ffmpegBin, inputUrl, { expectVideo = true } = {}) {
977
+ async function probeInputMediaInfo(ffmpegBin, inputUrl) {
978
978
  return new Promise((resolve) => {
979
979
  const ffmpeg = spawn(ffmpegBin, ["-hide_banner", "-loglevel", "info", "-i", inputUrl, "-f", "null", "-"], {
980
980
  stdio: ["ignore", "ignore", "pipe"],
@@ -1014,14 +1014,15 @@ async function probeInputMediaInfo(ffmpegBin, inputUrl, { expectVideo = true } =
1014
1014
  // before any decoding. Bail as soon as both are present instead of letting
1015
1015
  // `-f null -` decode the whole stream until the 8 s timeout.
1016
1016
  //
1017
- // A file with NO picture never prints that stream line, so waiting for it
1018
- // means waiting out the whole timeout every time, on the path where a
1019
- // viewer is changing soundtrack and the browser refuses a switch that is
1020
- // not ready in time. `Duration:` carries the start time this is asked for,
1021
- // and it is the last thing such a file has to say about itself.
1017
+ // This probe asks about the PICTURE and nothing else now: a file with no
1018
+ // video track is read by the container layer, which answers from 64 KB of
1019
+ // header. It used to take an `expectVideo: false` for exactly that case,
1020
+ // and the branch cost 8121 ms of every cold start (2026-09-03) because
1021
+ // the exit still waited for a parsed DURATION, which a partly downloaded
1022
+ // file prints as `N/A`.
1022
1023
  const duration = parseFfmpegDurationSeconds(stderr);
1023
1024
  const dims = parseFfmpegVideoDimensions(stderr);
1024
- if (duration != null && (dims.width != null || !expectVideo)) {
1025
+ if (duration != null && dims.width != null) {
1025
1026
  clearTimeout(timeoutId);
1026
1027
  if (!ffmpeg.killed) {
1027
1028
  ffmpeg.kill("SIGTERM");
@@ -1757,6 +1758,7 @@ export class HlsSessionManager {
1757
1758
  tonemapSupported = false,
1758
1759
  getCachedMediaInfo = null,
1759
1760
  getCachedAudioTracks = null,
1761
+ getContainerMediaInfo = null,
1760
1762
  fetchWholeFile = null,
1761
1763
  segmentFormatId = undefined,
1762
1764
  stateDir = "",
@@ -1777,6 +1779,13 @@ export class HlsSessionManager {
1777
1779
  // The file's audio tracks, for the master playlist's rendition group. Same
1778
1780
  // inventory the browser's audio menu is built from.
1779
1781
  this.getCachedAudioTracks = typeof getCachedAudioTracks === "function" ? getCachedAudioTracks : null;
1782
+ // What a file declares about itself, read by the container layer from the
1783
+ // same header its track table comes from: format, duration, and where its
1784
+ // own timeline begins. The last of those is why this exists — a soundtrack
1785
+ // shipped as its own file has a timeline of its own, and asking ffmpeg for
1786
+ // it meant reading a header this proxy had already read.
1787
+ this.getContainerMediaInfo =
1788
+ typeof getContainerMediaInfo === "function" ? getContainerMediaInfo : null;
1780
1789
  // Fetch one whole file of a source, as a bounded read rather than a
1781
1790
  // selection. Used to pull a soundtrack that ships beside the picture onto
1782
1791
  // the disk while the swarm has capacity to spare — see
@@ -2004,6 +2013,11 @@ export class HlsSessionManager {
2004
2013
  const sessionDir = createSessionDirPath(sessionId);
2005
2014
  const inputUrl = new URL("/stream", `${this.localBaseUrl}/`);
2006
2015
  inputUrl.searchParams.set("sourceKey", sourceKey);
2016
+ // Whose read this is. The stream route counts the bytes it delivers against
2017
+ // this session, and that count is what tells a waiting browser the proxy is
2018
+ // alive while nothing has been encoded yet — the encoder's own progress
2019
+ // cannot move before its first frame is decoded.
2020
+ inputUrl.searchParams.set("session", sessionId);
2007
2021
  // A soundtrack shipped as its own file is encoded FROM that file, and an
2008
2022
  // audio rendition carries nothing else — so it reads the sidecar directly and
2009
2023
  // needs no second input at all. The muxed case, where a browser takes its
@@ -2019,6 +2033,7 @@ export class HlsSessionManager {
2019
2033
  if (!readsSidecarAlone && audioSource.isSidecar) {
2020
2034
  audioInputUrl = new URL("/stream", `${this.localBaseUrl}/`);
2021
2035
  audioInputUrl.searchParams.set("sourceKey", sourceKey);
2036
+ audioInputUrl.searchParams.set("session", sessionId);
2022
2037
  audioInputUrl.searchParams.set("fileIndex", String(audioSource.fileIndex));
2023
2038
  }
2024
2039
 
@@ -2069,9 +2084,25 @@ export class HlsSessionManager {
2069
2084
  // the sound sits at a fixed offset from it for the whole film. Measured from
2070
2085
  // the file rather than assumed to be zero, because assuming it is exactly
2071
2086
  // the fault being avoided.
2087
+ //
2088
+ // NOT awaited. Creating a session used to stop here until the answer came
2089
+ // back, and on a cold start the answer needs the sidecar's header off the
2090
+ // swarm — 8121 ms of every session created, measured three times out of
2091
+ // three on 2026-09-03. What is known now is used now; the reading runs
2092
+ // behind, and `#startEncodeRun` takes the freshest value at spawn time, the
2093
+ // same way it already re-reads `session.keyframeTimes`.
2094
+ //
2095
+ // Unknown means "no difference between the two timelines", not "the
2096
+ // soundtrack starts at zero". The shift exists to correct a difference
2097
+ // between two containers; asserting one that has not been read is inventing
2098
+ // a number, while assuming none leaves the sound exactly where a release
2099
+ // remuxed from a single source puts it.
2072
2100
  const audioFileStartTime = audioSource.isSidecar
2073
- ? await this.#sidecarStartTimeSeconds(sourceKey, audioSource.fileIndex)
2101
+ ? (this.#sidecarStartTimeNow(sourceKey, audioSource.fileIndex) ?? sourceStartTime)
2074
2102
  : sourceStartTime;
2103
+ if (audioSource.isSidecar) {
2104
+ this.#warmSidecarStartTime(sourceKey, audioSource.fileIndex);
2105
+ }
2075
2106
  const inputStartTime = readsSidecarAlone ? audioFileStartTime : sourceStartTime;
2076
2107
  // Tone-map an HDR source to SDR only when re-encoding video on the software
2077
2108
  // path and this ffmpeg has the filters. Hardware encoders keep their own
@@ -2388,6 +2419,11 @@ export class HlsSessionManager {
2388
2419
  // from a separate file.
2389
2420
  inputStartTime,
2390
2421
  audioFileStartTime,
2422
+ // Whether this session's ONE input is the sidecar itself, which is what an
2423
+ // audio rendition of a separately shipped soundtrack is. Stored rather
2424
+ // than re-derived, because the derivation needs `audioOnly` and the
2425
+ // sidecar test together and was already written two different ways.
2426
+ readsSidecarAlone,
2391
2427
  // What this session's output carries. `audioOnly` is a rendition — one
2392
2428
  // audio track, no picture; `videoOnly` is a stream whose audio the viewer
2393
2429
  // takes from such a rendition. Neither is set on the ordinary muxed
@@ -5194,11 +5230,26 @@ export class HlsSessionManager {
5194
5230
  const startSeconds = Number.isFinite(positionSecondsOverride)
5195
5231
  ? positionSecondsOverride
5196
5232
  : this.runStartTimeFor(session, safeIndex);
5233
+ // Where a sidecar soundtrack's own timeline begins, taken FRESH: the
5234
+ // session may have been created before that file's header could be read,
5235
+ // and this run is the first moment the answer matters. Same shape as
5236
+ // `session.keyframeTimes`, which is likewise read again on every call so a
5237
+ // background reading that has since finished is picked up.
5238
+ const hasSidecarSound =
5239
+ Number.isInteger(session.audioFileIndex) && session.audioFileIndex !== session.fileIndex;
5240
+ const sidecarStartNow = hasSidecarSound
5241
+ ? this.#sidecarStartTimeNow(session.sourceKey, session.audioFileIndex)
5242
+ : null;
5243
+ const audioFileStartTime = sidecarStartNow !== null
5244
+ ? sidecarStartNow
5245
+ : (Number.isFinite(session.audioFileStartTime) ? session.audioFileStartTime : 0);
5197
5246
  // The start time of the file this run READS, which is the picture's own for
5198
5247
  // every session except one whose soundtrack is a separate file.
5199
- const sourceStartTime = Number.isFinite(session.inputStartTime)
5200
- ? session.inputStartTime
5201
- : (Number.isFinite(session.sourceStartTime) ? session.sourceStartTime : 0);
5248
+ const sourceStartTime = session.readsSidecarAlone === true && sidecarStartNow !== null
5249
+ ? sidecarStartNow
5250
+ : (Number.isFinite(session.inputStartTime)
5251
+ ? session.inputStartTime
5252
+ : (Number.isFinite(session.sourceStartTime) ? session.sourceStartTime : 0));
5202
5253
  // Cut where this session's grid says, whoever is producing the frames. The
5203
5254
  // times are measured from the start of THIS run; the same list serves as
5204
5255
  // the cut points and, when re-encoding, as the keyframes to force — one
@@ -5336,7 +5387,7 @@ export class HlsSessionManager {
5336
5387
  // files begin at their own container start time, and those need not be the
5337
5388
  // same number; the difference is what keeps the two aligned.
5338
5389
  const audioTimelineShift = audioInputUrl
5339
- ? (Number.isFinite(session.audioFileStartTime) ? session.audioFileStartTime : 0) - sourceStartTime
5390
+ ? audioFileStartTime - sourceStartTime
5340
5391
  : 0;
5341
5392
  /**
5342
5393
  * Add the second input, if there is one, with its own seek.
@@ -9235,40 +9286,82 @@ export class HlsSessionManager {
9235
9286
  /**
9236
9287
  * Where a sidecar soundtrack's own timeline begins, in seconds.
9237
9288
  *
9238
- * One short probe of that file, kept for as long as the process lives: a
9239
- * container's start time is a property of the file and cannot change. It is
9240
- * asked only when such a track is actually chosen, so a torrent whose extra
9241
- * soundtracks nobody plays costs nothing for them.
9289
+ * What has already been read, without reading anything. The answer to
9290
+ * "is it known yet", which is what a caller who must not wait needs.
9291
+ *
9292
+ * @param {string} sourceKey
9293
+ * @param {number} fileIndex - The SIDECAR's file.
9294
+ * @returns {number | null} Null when nobody has read it yet.
9295
+ */
9296
+ #sidecarStartTimeNow(sourceKey, fileIndex) {
9297
+ if (!(this.sidecarStartTimes instanceof Map)) {
9298
+ this.sidecarStartTimes = new Map();
9299
+ }
9300
+ const held = this.sidecarStartTimes.get(`${sourceKey}:${fileIndex}`);
9301
+ return Number.isFinite(held) ? held : null;
9302
+ }
9303
+
9304
+ /**
9305
+ * Start reading where a sidecar soundtrack's timeline begins, if nobody has.
9306
+ *
9307
+ * Read by the container layer from the file's own header — the same 64 KB,
9308
+ * the same reader and the same per-file cache the audio menu's track list
9309
+ * comes from. A container states this, so it is read from the container and
9310
+ * not measured from the media.
9311
+ *
9312
+ * Until 2.73.0 the session spawned an ffmpeg against the proxy's own
9313
+ * `/stream` for it and waited up to eight seconds for the banner. Field
9314
+ * 2026-09-03: that read cost 8121 ms of a cold start, three times out of
9315
+ * three, while the container layer had read the same header of the same file
9316
+ * in 8 ms in the same second. The eight seconds were not even spent on the
9317
+ * answer — the early exit was gated on a DURATION, and a partly downloaded
9318
+ * file prints `Duration: N/A` with the start time on that very line.
9319
+ *
9320
+ * Runs behind whoever asked, so no viewer waits for it. Once per file per
9321
+ * process: a container's start time is a property of the file and cannot
9322
+ * change. A reading that comes back without an answer is NOT remembered — the
9323
+ * file may simply not have been downloaded far enough yet, and the next
9324
+ * session asks again.
9242
9325
  *
9243
9326
  * @param {string} sourceKey
9244
9327
  * @param {number} fileIndex - The SIDECAR's file.
9245
- * @returns {Promise<number>}
9328
+ * @returns {void}
9246
9329
  */
9247
- async #sidecarStartTimeSeconds(sourceKey, fileIndex) {
9330
+ #warmSidecarStartTime(sourceKey, fileIndex) {
9331
+ if (typeof this.getContainerMediaInfo !== "function") {
9332
+ return;
9333
+ }
9248
9334
  if (!(this.sidecarStartTimes instanceof Map)) {
9249
9335
  this.sidecarStartTimes = new Map();
9250
9336
  }
9337
+ if (!(this.sidecarStartTimeReads instanceof Set)) {
9338
+ this.sidecarStartTimeReads = new Set();
9339
+ }
9251
9340
  const key = `${sourceKey}:${fileIndex}`;
9252
- const held = this.sidecarStartTimes.get(key);
9253
- if (Number.isFinite(held)) {
9254
- return held;
9255
- }
9256
- const url = new URL("/stream", `${this.localBaseUrl}/`);
9257
- url.searchParams.set("sourceKey", sourceKey);
9258
- url.searchParams.set("fileIndex", String(fileIndex));
9259
- let startTime = 0;
9260
- try {
9261
- const info = await probeInputMediaInfo(this.ffmpegBin, url.toString(), { expectVideo: false });
9262
- startTime = Number.isFinite(info?.startTime) ? info.startTime : 0;
9263
- } catch (error) {
9264
- logger.info(
9265
- `transcode: the start time of soundtrack file ${fileIndex} could not be probed ` +
9266
- `(${error instanceof Error ? error.message : String(error)}) — taken as 0`
9267
- );
9268
- startTime = 0;
9341
+ if (this.sidecarStartTimes.has(key) || this.sidecarStartTimeReads.has(key)) {
9342
+ return;
9269
9343
  }
9270
- this.sidecarStartTimes.set(key, startTime);
9271
- return startTime;
9344
+ this.sidecarStartTimeReads.add(key);
9345
+ void Promise.resolve(this.getContainerMediaInfo({ sourceKey, fileIndex }))
9346
+ .then((info) => {
9347
+ if (info && Number.isFinite(info.startTimeSeconds)) {
9348
+ this.sidecarStartTimes.set(key, info.startTimeSeconds);
9349
+ logger.info(
9350
+ `transcode: soundtrack file ${fileIndex}'s own timeline starts at ` +
9351
+ `${info.startTimeSeconds.toFixed(6)}s, read from its header`
9352
+ );
9353
+ }
9354
+ })
9355
+ .catch((error) => {
9356
+ logger.info(
9357
+ `transcode: the start of soundtrack file ${fileIndex}'s timeline could not be read ` +
9358
+ `(${error instanceof Error ? error.message : String(error)}) — the two timelines are ` +
9359
+ "taken to agree until it can be"
9360
+ );
9361
+ })
9362
+ .finally(() => {
9363
+ this.sidecarStartTimeReads.delete(key);
9364
+ });
9272
9365
  }
9273
9366
 
9274
9367
  /**
@@ -10292,6 +10385,28 @@ export class HlsSessionManager {
10292
10385
  * @param {string} sessionId
10293
10386
  * @returns {Promise<object | null>}
10294
10387
  */
10388
+ /**
10389
+ * Count bytes the swarm has delivered to one session's own input read.
10390
+ *
10391
+ * Called by the `/stream` route for every fragment it writes to an encoder.
10392
+ * Cheap on purpose — one addition, no clock, no log — because it runs per
10393
+ * fragment on the path that feeds ffmpeg.
10394
+ *
10395
+ * @param {string} sessionId
10396
+ * @param {number} bytes
10397
+ * @returns {void}
10398
+ */
10399
+ noteInputBytes(sessionId, bytes) {
10400
+ if (!sessionId || !(bytes > 0)) {
10401
+ return;
10402
+ }
10403
+ const session = this.sessionsById.get(sessionId);
10404
+ if (!session) {
10405
+ return;
10406
+ }
10407
+ session.inputBytes = (session.inputBytes ?? 0) + bytes;
10408
+ }
10409
+
10295
10410
  async getSessionProgress(sessionId) {
10296
10411
  if (!isSafeSessionId(sessionId)) {
10297
10412
  return null;
@@ -10344,6 +10459,21 @@ export class HlsSessionManager {
10344
10459
  worstSupplyWaitSec: session.supplyFigures?.worstWaitSec
10345
10460
  })?.seconds ?? null,
10346
10461
  processedSeconds: session.progress.processedSeconds,
10462
+ // Bytes this session's own reads have received from the swarm.
10463
+ //
10464
+ // The second proof that a session is alive, and the only one available
10465
+ // before its first frame exists: `processedSeconds` cannot move until the
10466
+ // decoder has a frame, so on a cold start it stands at the start position
10467
+ // for as long as the first piece takes to arrive. Field 2026-09-03 — one
10468
+ // piece took 46.3 s while the swarm delivered 55.9 MB across the torrent,
10469
+ // `processedSeconds` frozen at 171.3 throughout, and the browser declared
10470
+ // the proxy dead 0.4 s before the piece landed.
10471
+ //
10472
+ // Counted per SESSION and not per torrent, deliberately: in that same
10473
+ // episode the torrent received 55.9 MB while the picture's own reads
10474
+ // received 4.5 MB of it, so a torrent-wide figure would have called a
10475
+ // starved session healthy.
10476
+ inputBytes: session.inputBytes ?? 0,
10347
10477
  startPositionSeconds: session.progress.startPositionSeconds ?? 0,
10348
10478
  totalSeconds: session.progress.totalSeconds,
10349
10479
  percent: session.progress.percent,
@@ -61,6 +61,28 @@ export class ContainerOrchestrator {
61
61
  }
62
62
  }
63
63
 
64
+ /**
65
+ * What the file declares about itself as a whole — format, duration, and
66
+ * where its own timeline begins.
67
+ *
68
+ * Read once per file, like the track table beside it, and from the same 64 KB
69
+ * of header. A `null` field means the container does not declare it, which is
70
+ * a final answer about the container.
71
+ *
72
+ * @param {object} params - same as getContainer
73
+ * @returns {Promise<import("../container/Container.js").ContainerMediaInfo|null>}
74
+ */
75
+ async getMediaInfo(params) {
76
+ const container = await this.getContainer(params);
77
+ if (!container) return null;
78
+ try {
79
+ return await container.readMediaInfo();
80
+ } catch (e) {
81
+ logger.warn(`container: readMediaInfo failed for "${params.label}": ${e?.message ?? e}`);
82
+ return null;
83
+ }
84
+ }
85
+
64
86
  /**
65
87
  * @param {object} params - same as getContainer
66
88
  * @returns {Promise<{times:number[],tolerance:number}|null>}