@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.
- package/CHANGELOG.md +17 -0
- package/docs/container-architecture.md +66 -0
- package/package.json +1 -1
- package/routes/api/sources/warm/post.js +19 -0
- package/routes/stream/get.js +18 -1
- package/server.js +25 -1
- package/services/container/AviContainer.js +36 -0
- package/services/container/Container.js +41 -0
- package/services/container/MatroskaContainer.js +186 -1
- package/services/container/Mp4Container.js +158 -29
- package/services/container-index/ebml-reader.js +30 -0
- package/services/hls-session-manager.js +167 -37
- package/services/orchestrators/ContainerOrchestrator.js +22 -0
- package/services/piece-store/shared-piece-store.js +1512 -1502
- package/services/torrent-pool.js +46 -0
- package/services/torrent-worker/client.js +21 -0
- package/services/torrent-worker/container-tracks.js +134 -0
- package/services/torrent-worker/fastest-wires.js +29 -6
- package/services/torrent-worker/piece-reader.js +11 -4
- package/services/torrent-worker/pool-adapter.js +35 -0
- package/services/torrent-worker/protocol.js +12 -0
- package/services/torrent-worker/worker.js +45 -1
- package/test/container-media-info.test.js +228 -0
- package/test/piece-store-reservations.test.js +31 -8
- package/test/resume-warm.test.js +39 -0
- package/test/tail-duplication.test.js +48 -4
|
@@ -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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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)
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
//
|
|
1018
|
-
//
|
|
1019
|
-
//
|
|
1020
|
-
//
|
|
1021
|
-
//
|
|
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 &&
|
|
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
|
-
?
|
|
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 =
|
|
5200
|
-
?
|
|
5201
|
-
: (Number.isFinite(session.
|
|
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
|
-
?
|
|
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
|
-
*
|
|
9239
|
-
*
|
|
9240
|
-
*
|
|
9241
|
-
*
|
|
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 {
|
|
9328
|
+
* @returns {void}
|
|
9246
9329
|
*/
|
|
9247
|
-
|
|
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
|
-
|
|
9253
|
-
|
|
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.
|
|
9271
|
-
|
|
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>}
|