@torrent-tv/proxy 2.9.99 → 2.9.101
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 +10 -0
- package/package.json +1 -1
- package/server.js +3 -1
- package/services/hls-session-manager.js +84 -3
- package/services/playback-planner.js +27 -2
- package/services/torrent-worker/piece-reader.js +56 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 2.9.101
|
|
2
|
+
|
|
3
|
+
- **New**: The playback plan reports what this host takes to produce a session's first segment — the median of its last eight, measured from session-create to a servable segment (782-1518 ms on the field host). The browser needs it for the gap between "the file is downloaded" and "a segment exists", where until now it assumed the pipeline merely keeps up with realtime and therefore showed 15 s where 3.8 s were left. It is per-host, so a weak box and a fast one each answer for themselves.
|
|
4
|
+
|
|
5
|
+
## 2.9.100
|
|
6
|
+
|
|
7
|
+
- **New**: A long wait for a piece now says who was working on it. The open question about a seek is that a single 8 MiB piece takes 3.0-4.6 s while the swarm as a whole moves 4-6 MB/s, so only about 2 MB/s reaches the piece being waited for — and whether that is because few peers hold it, few are being asked, or each is slow could not be told apart from outside. The line now carries the rate achieved on that piece and, sampled at its peak while waiting, how many connected peers had it, how many were asked, and how many blocks were in flight.
|
|
8
|
+
- **New**: The keyframe index is read alongside the codec probe instead of after it. Both wait for the same tail of the file; measured 2026-08-04, a probe of 722-1206 ms was followed by an index read of 311-430 ms, all of it before the first segment could be produced. Started together the second is free. Fire and forget, sharing the cache a session would fill itself.
|
|
9
|
+
- **Chore**: Every encoder run is numbered in the log. A burst of seeks starts several runs within a second and every line about them carries the session id, which is the same for all of them — so the command that failed could not be told from the ones that succeeded around it. That is the state the unexplained `Cannot write moov atom before AC3 packets` was found in.
|
|
10
|
+
|
|
1
11
|
## 2.9.99
|
|
2
12
|
|
|
3
13
|
- **New**: A source can be told to start before anyone asks to play it — `POST /api/sources/:sourceKey/warm`. Everything a cold torrent must do first takes seconds and none of it depends on which file is wanted: announce to the trackers, connect to peers, be unchoked by them. Given a file index it also fetches the two pieces at that file's edges, which is what the codec probe reads and what took **6.7 s of the 10.3 s** before playback in the session measured 2026-08-04. All of it used to begin only once a file had been chosen, because it was buried inside the playback plan. The route returns as soon as the work is under way and reports a refusal rather than an error — nothing is broken if a warm-up does not happen, since the ordinary path still does all of it.
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -163,7 +163,9 @@ export async function startProxyServer({ host, port, transcodeAudio, ffmpegBin,
|
|
|
163
163
|
transcodeAudioEnabled: transcodeAudio,
|
|
164
164
|
localBaseUrl: hlsSessionManager.localBaseUrl,
|
|
165
165
|
sourceRegistry,
|
|
166
|
-
torrentPool
|
|
166
|
+
torrentPool,
|
|
167
|
+
warmKeyframeIndex: (params) => hlsSessionManager.warmKeyframeIndex(params),
|
|
168
|
+
expectedFirstSegmentMs: () => hlsSessionManager.expectedFirstSegmentMs()
|
|
167
169
|
});
|
|
168
170
|
|
|
169
171
|
app.get("/health", async (req, reply) => handleHealthGet(req, reply, { version }));
|
|
@@ -800,6 +800,14 @@ function normalizeLogFileName(fileName, fileIndex) {
|
|
|
800
800
|
* expired after {@link HlsSessionManagerOptions.sessionTtlMs} of idle time.
|
|
801
801
|
*/
|
|
802
802
|
export class HlsSessionManager {
|
|
803
|
+
/**
|
|
804
|
+
* Recent times from session-create to a servable first segment, in ms.
|
|
805
|
+
* See #rememberFirstSegmentLatency.
|
|
806
|
+
*
|
|
807
|
+
* @type {number[]}
|
|
808
|
+
*/
|
|
809
|
+
#firstSegmentLatencies = [];
|
|
810
|
+
|
|
803
811
|
/**
|
|
804
812
|
* @param {HlsSessionManagerOptions} options
|
|
805
813
|
*/
|
|
@@ -1379,6 +1387,29 @@ export class HlsSessionManager {
|
|
|
1379
1387
|
return null;
|
|
1380
1388
|
}
|
|
1381
1389
|
|
|
1390
|
+
/**
|
|
1391
|
+
* Read the file's keyframe index into the cache before a session needs it.
|
|
1392
|
+
*
|
|
1393
|
+
* The index lives at the END of a Matroska file, which is also where the
|
|
1394
|
+
* codec probe reads — both wait for the same piece to arrive, and they used
|
|
1395
|
+
* to do it one after the other: measured 2026-08-04, a probe of 722-1206 ms
|
|
1396
|
+
* followed by an index read of 311-430 ms, all of it before the first
|
|
1397
|
+
* segment. Started together, the second costs nothing.
|
|
1398
|
+
*
|
|
1399
|
+
* Never rejects and is never awaited by the caller: a session that finds
|
|
1400
|
+
* nothing cached simply reads it itself, as before.
|
|
1401
|
+
*
|
|
1402
|
+
* @param {{ sourceKey: string, fileIndex: number, inputUrl: URL, logName: string }} params
|
|
1403
|
+
* @returns {Promise<void>}
|
|
1404
|
+
*/
|
|
1405
|
+
async warmKeyframeIndex({ sourceKey, fileIndex, inputUrl, logName }) {
|
|
1406
|
+
try {
|
|
1407
|
+
await this.#readContainerKeyframes({ sourceKey, fileIndex, inputUrl, logName });
|
|
1408
|
+
} catch {
|
|
1409
|
+
// Best effort by construction.
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1382
1413
|
async #readContainerKeyframes({ sourceKey, fileIndex, inputUrl, logName }) {
|
|
1383
1414
|
const cacheKey = `${sourceKey}:${fileIndex}`;
|
|
1384
1415
|
if (this.keyframeIndexCache.has(cacheKey)) {
|
|
@@ -2165,7 +2196,15 @@ export class HlsSessionManager {
|
|
|
2165
2196
|
// were verified to handle a copied AC-3 track on this very host, so the
|
|
2166
2197
|
// arguments that run actually received are the missing evidence. One line
|
|
2167
2198
|
// per run, and a run happens at most every few seconds.
|
|
2168
|
-
|
|
2199
|
+
// Numbered, because a burst of seeks starts several runs in one second and
|
|
2200
|
+
// every line about them carries the SESSION id, which is the same for all.
|
|
2201
|
+
// Without a run number the command that failed cannot be told from the two
|
|
2202
|
+
// that succeeded around it — which is exactly the state the unexplained
|
|
2203
|
+
// `Cannot write moov atom before AC3 packets` was found in.
|
|
2204
|
+
session.runCounter = (session.runCounter ?? 0) + 1;
|
|
2205
|
+
const runLabel = `run#${session.runCounter}`;
|
|
2206
|
+
session.runLabel = runLabel;
|
|
2207
|
+
logger.info(`transcode ${session.id} ${runLabel} ffmpeg ${describeFfmpegArgs(args)}`);
|
|
2169
2208
|
|
|
2170
2209
|
const ffmpeg = spawn(this.ffmpegBin, args, {
|
|
2171
2210
|
cwd: session.dirPath,
|
|
@@ -2191,7 +2230,7 @@ export class HlsSessionManager {
|
|
|
2191
2230
|
session.budgetSlowSince = 0;
|
|
2192
2231
|
|
|
2193
2232
|
logger.info(
|
|
2194
|
-
`transcode ${session.id} encode-run from segment #${safeIndex} ` +
|
|
2233
|
+
`transcode ${session.id} ${session.runLabel} encode-run from segment #${safeIndex} ` +
|
|
2195
2234
|
`(${formatSeconds(startSeconds)}) "${session.fileName}"`
|
|
2196
2235
|
);
|
|
2197
2236
|
|
|
@@ -2379,7 +2418,9 @@ export class HlsSessionManager {
|
|
|
2379
2418
|
session.state = "failed";
|
|
2380
2419
|
session.progress.state = "failed";
|
|
2381
2420
|
session.progress.updatedAt = Date.now();
|
|
2382
|
-
logger.error(
|
|
2421
|
+
logger.error(
|
|
2422
|
+
`transcode ${session.id} ${session.runLabel ?? "run#?"} encode-run failed: ${session.lastError}`
|
|
2423
|
+
);
|
|
2383
2424
|
});
|
|
2384
2425
|
}
|
|
2385
2426
|
|
|
@@ -2694,6 +2735,45 @@ export class HlsSessionManager {
|
|
|
2694
2735
|
return session.requestSeqCounter;
|
|
2695
2736
|
}
|
|
2696
2737
|
|
|
2738
|
+
/**
|
|
2739
|
+
* Remember how long this host took to make a session's first segment.
|
|
2740
|
+
*
|
|
2741
|
+
* The browser has to answer "how long until playback" during the gap between
|
|
2742
|
+
* the file being downloaded and the first segment existing, and until now it
|
|
2743
|
+
* assumed the pipeline merely keeps up with realtime — which on the measured
|
|
2744
|
+
* session meant showing 15 s where 3.8 s were left, and showing it as a jump
|
|
2745
|
+
* UP from 5.5 s. This host knows the real figure because it has just done it
|
|
2746
|
+
* several times: 782 ms, 1052 ms, 1387 ms, 1518 ms on the sessions measured
|
|
2747
|
+
* 2026-08-04/05. A median of recent runs is a measurement, not an assumption,
|
|
2748
|
+
* and it is per-host, so a weak box and a fast one each get their own.
|
|
2749
|
+
*
|
|
2750
|
+
* @param {number} latencyMs
|
|
2751
|
+
* @returns {void}
|
|
2752
|
+
*/
|
|
2753
|
+
#rememberFirstSegmentLatency(latencyMs) {
|
|
2754
|
+
if (!Number.isFinite(latencyMs) || latencyMs <= 0) {
|
|
2755
|
+
return;
|
|
2756
|
+
}
|
|
2757
|
+
this.#firstSegmentLatencies.push(latencyMs);
|
|
2758
|
+
if (this.#firstSegmentLatencies.length > FIRST_SEGMENT_SAMPLES) {
|
|
2759
|
+
this.#firstSegmentLatencies.shift();
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
|
|
2763
|
+
/**
|
|
2764
|
+
* What this host typically takes to produce a session's first segment, in
|
|
2765
|
+
* milliseconds — the median of recent runs, or null before any has finished.
|
|
2766
|
+
*
|
|
2767
|
+
* @returns {number | null}
|
|
2768
|
+
*/
|
|
2769
|
+
expectedFirstSegmentMs() {
|
|
2770
|
+
if (this.#firstSegmentLatencies.length === 0) {
|
|
2771
|
+
return null;
|
|
2772
|
+
}
|
|
2773
|
+
const sorted = [...this.#firstSegmentLatencies].sort((left, right) => left - right);
|
|
2774
|
+
return sorted[Math.floor(sorted.length / 2)];
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2697
2777
|
/**
|
|
2698
2778
|
* How many times the viewer has moved since this session started.
|
|
2699
2779
|
*
|
|
@@ -2848,6 +2928,7 @@ export class HlsSessionManager {
|
|
|
2848
2928
|
// — the time from session-create entry to a playable first segment.
|
|
2849
2929
|
if (!isPlaylist && !session.firstSegmentLogged) {
|
|
2850
2930
|
session.firstSegmentLogged = true;
|
|
2931
|
+
this.#rememberFirstSegmentLatency(Date.now() - session.createEntryMs);
|
|
2851
2932
|
logger.info(
|
|
2852
2933
|
`cold-start ${sessionId.slice(0, 8)}: first-segment ready +${Date.now() - session.createEntryMs}ms`
|
|
2853
2934
|
);
|
|
@@ -263,7 +263,17 @@ export function createPlaybackPlanner({
|
|
|
263
263
|
transcodeAudioEnabled,
|
|
264
264
|
localBaseUrl,
|
|
265
265
|
sourceRegistry,
|
|
266
|
-
torrentPool
|
|
266
|
+
torrentPool,
|
|
267
|
+
// Optional. Reports what this host typically takes to produce a session's
|
|
268
|
+
// first segment. The browser needs it for the gap between "the file is
|
|
269
|
+
// downloaded" and "a segment exists": until now it assumed the pipeline
|
|
270
|
+
// merely keeps up with realtime, and showed 15 s where 3.8 s were left.
|
|
271
|
+
expectedFirstSegmentMs,
|
|
272
|
+
// Optional. Called once the file's edges are downloaded, so the keyframe
|
|
273
|
+
// index — which reads the same tail of the file — is fetched alongside the
|
|
274
|
+
// codec probe instead of after it. Late-bound to the HLS session manager,
|
|
275
|
+
// which owns the cache both of them share.
|
|
276
|
+
warmKeyframeIndex
|
|
267
277
|
}) {
|
|
268
278
|
/** @type {Map<string, PlaybackPlan>} */
|
|
269
279
|
const cache = new Map();
|
|
@@ -365,6 +375,17 @@ export function createPlaybackPlanner({
|
|
|
365
375
|
// file, and an unsupported codec like xvid gets copied → black video.
|
|
366
376
|
await torrentPool.prefetchFileEdges(torrent, fileIndex);
|
|
367
377
|
edgesReadyMs = Date.now() - planEntryMs;
|
|
378
|
+
// The keyframe index reads the tail of the file, which the probe has just
|
|
379
|
+
// waited for as well. Started here it overlaps the probe instead of
|
|
380
|
+
// following the whole plan — worth 311-430 ms of the time before the
|
|
381
|
+
// first segment. Fire and forget: the session reads it itself if this has
|
|
382
|
+
// not finished, and both share one cache entry.
|
|
383
|
+
warmKeyframeIndex?.({
|
|
384
|
+
sourceKey,
|
|
385
|
+
fileIndex,
|
|
386
|
+
inputUrl: new URL(directUrl),
|
|
387
|
+
logName: file.name
|
|
388
|
+
});
|
|
368
389
|
let probe = await probeStreamCodecs({ ffmpegBin, inputUrl: directUrl, userAgent });
|
|
369
390
|
const probeDeadline = Date.now() + Math.max(0, maxWaitMs);
|
|
370
391
|
let attempt = 0;
|
|
@@ -380,6 +401,7 @@ export function createPlaybackPlanner({
|
|
|
380
401
|
}
|
|
381
402
|
const { audioCodec, videoCodec, container, durationSeconds, videoWidth, videoHeight, audioTracks, subtitleTracks } = probe;
|
|
382
403
|
const codecsDetected = audioCodec.length > 0 || videoCodec.length > 0;
|
|
404
|
+
const firstSegmentMs = expectedFirstSegmentMs?.() ?? null;
|
|
383
405
|
logger.info(
|
|
384
406
|
`plan ${sourceKey.slice(0, 8)}:${fileIndex} torrent-ready=${torrentReadyMs}ms ` +
|
|
385
407
|
`file-edges=${edgesReadyMs - torrentReadyMs}ms probe=${Date.now() - planEntryMs - edgesReadyMs}ms ` +
|
|
@@ -405,7 +427,10 @@ export function createPlaybackPlanner({
|
|
|
405
427
|
videoHeight,
|
|
406
428
|
// Full track inventory for the browser's audio/subtitle menus.
|
|
407
429
|
audioTracks: audioTracks ?? [],
|
|
408
|
-
subtitleTracks: subtitleTracks ?? []
|
|
430
|
+
subtitleTracks: subtitleTracks ?? [],
|
|
431
|
+
// What this host has recently taken to make a session's first segment.
|
|
432
|
+
// Null until one has finished since startup.
|
|
433
|
+
expectedFirstSegmentMs: firstSegmentMs
|
|
409
434
|
};
|
|
410
435
|
// Only cache a plan whose codecs were actually detected. An empty probe is
|
|
411
436
|
// a "header not downloaded yet" signal, not a valid result — caching it
|
|
@@ -154,6 +154,41 @@ function clearCritical(torrent, { from, to }) {
|
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Who is working on the piece a reader is blocked on, right now.
|
|
159
|
+
*
|
|
160
|
+
* The open question about a seek: a single 8 MiB piece takes 3.0-4.6 s to
|
|
161
|
+
* arrive while the swarm as a whole is moving 4-6 MB/s, so roughly 2 MB/s is
|
|
162
|
+
* reaching the piece that is actually being waited for. Whether that is because
|
|
163
|
+
* few peers hold it, few are being asked, or each is slow cannot be told apart
|
|
164
|
+
* from the outside — these three counts tell them apart.
|
|
165
|
+
*
|
|
166
|
+
* `wire.requests` is what has been asked of that peer and not yet answered; a
|
|
167
|
+
* block is 16 KB, so `blocks x 16 KB` is the work in flight on this piece.
|
|
168
|
+
*
|
|
169
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
170
|
+
* @param {number} pieceIndex
|
|
171
|
+
* @returns {{ peers: number, holders: number, askedOf: number, blocks: number }}
|
|
172
|
+
*/
|
|
173
|
+
export function pieceSupply(torrent, pieceIndex) {
|
|
174
|
+
const wires = Array.isArray(torrent?.wires) ? torrent.wires : [];
|
|
175
|
+
let holders = 0;
|
|
176
|
+
let askedOf = 0;
|
|
177
|
+
let blocks = 0;
|
|
178
|
+
for (const wire of wires) {
|
|
179
|
+
if (wire?.peerPieces?.get?.(pieceIndex)) {
|
|
180
|
+
holders += 1;
|
|
181
|
+
}
|
|
182
|
+
const requests = Array.isArray(wire?.requests) ? wire.requests : [];
|
|
183
|
+
const forThisPiece = requests.filter((request) => request?.piece === pieceIndex).length;
|
|
184
|
+
if (forThisPiece > 0) {
|
|
185
|
+
askedOf += 1;
|
|
186
|
+
blocks += forThisPiece;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { peers: wires.length, holders, askedOf, blocks };
|
|
190
|
+
}
|
|
191
|
+
|
|
157
192
|
/**
|
|
158
193
|
* Wait until a piece has been downloaded and verified.
|
|
159
194
|
*
|
|
@@ -421,7 +456,20 @@ export async function* readFragments({
|
|
|
421
456
|
}
|
|
422
457
|
|
|
423
458
|
const waitStartedAt = Date.now();
|
|
424
|
-
|
|
459
|
+
// Sampled while waiting rather than after: once the piece lands, nothing
|
|
460
|
+
// is outstanding on it any more and every count reads zero.
|
|
461
|
+
let supply = null;
|
|
462
|
+
const supplyProbe = setInterval(() => {
|
|
463
|
+
const sample = pieceSupply(torrent, pieceIndex);
|
|
464
|
+
if (!supply || sample.blocks > supply.blocks) {
|
|
465
|
+
supply = sample;
|
|
466
|
+
}
|
|
467
|
+
}, 500);
|
|
468
|
+
try {
|
|
469
|
+
await whenPieceReady(torrent, pieceIndex, cancellation);
|
|
470
|
+
} finally {
|
|
471
|
+
clearInterval(supplyProbe);
|
|
472
|
+
}
|
|
425
473
|
// What a reader spent waiting for data, attributed to the exact piece. A
|
|
426
474
|
// seek's cost is dominated by the first segment after the encoder
|
|
427
475
|
// restarts (measured 9.2-9.4 s), and without this there is no way to say
|
|
@@ -429,10 +477,16 @@ export async function* readFragments({
|
|
|
429
477
|
// wait is long enough to matter, so ordinary sequential reading is silent.
|
|
430
478
|
const waitedMs = Date.now() - waitStartedAt;
|
|
431
479
|
if (waitedMs >= PIECE_WAIT_LOG_MS) {
|
|
480
|
+
const rateKbps = Math.round(pieceLength / 1024 / (waitedMs / 1000));
|
|
432
481
|
logger.info(
|
|
433
482
|
`piece-reader: waited ${waitedMs}ms for piece ${pieceIndex} ` +
|
|
434
483
|
`(${pieceIndex - firstPiece + 1} of ${lastPiece - firstPiece + 1} in a read from ` +
|
|
435
|
-
`${(start / 1024 / 1024).toFixed(0)}MB of "${file.name}")`
|
|
484
|
+
`${(start / 1024 / 1024).toFixed(0)}MB of "${file.name}") ` +
|
|
485
|
+
`— ${rateKbps}KB/s on this piece; ` +
|
|
486
|
+
(supply
|
|
487
|
+
? `${supply.holders}/${supply.peers} peers had it, ${supply.askedOf} were asked, ` +
|
|
488
|
+
`${supply.blocks} blocks (${Math.round((supply.blocks * 16384) / 1024)}KB) in flight at peak`
|
|
489
|
+
: "no sample taken")
|
|
436
490
|
);
|
|
437
491
|
}
|
|
438
492
|
|