@torrent-tv/proxy 2.9.139 → 2.9.141
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/host-timings.json +1 -0
- package/package.json +1 -1
- package/services/hls-session-manager.js +133 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 2.9.141
|
|
2
|
+
|
|
3
|
+
- **New**: A held segment says whether the encoder is actually moving. The line already reported that the run was alive and at the right index and stopped there, which left the two possible causes indistinguishable: an encoder waiting on torrent pieces looks exactly like one that is encoding and has not finished. It now reports how much media the run has produced since it started and at what speed, and says outright when the position has not moved at all — which means the input is what is being waited for. Measured 2026-08-11: segment #675 was held with the run started at #675 and the encoder alive, nothing in the log could say why, and the browser then abandoned the session and built another — which is where the "second session after a seek" came from.
|
|
4
|
+
|
|
5
|
+
## 2.9.140
|
|
6
|
+
|
|
7
|
+
- **New**: What this host takes to produce a first segment is now derived from the startup benchmark, so a machine answers correctly on its very first run. Encoder detection already encodes `testsrc2` through the real HLS pipeline and records each preset's throughput in pixels per second; one segment is a known quantity of pixels, so the time follows by division. No coefficient is involved — it is a measurement of this machine taken minutes earlier, applied to a known amount of work. Until now there was no answer at all before the first session finished, and the browser filled the gap with an assumed rate of exactly one, which was wrong by a factor of four in both directions.
|
|
8
|
+
- **New**: Recorded medians survive a restart, in `host-timings.json` beside the proxy. Previously every restart went back to knowing nothing and the first viewer after it saw a figure with no measurement behind it.
|
|
9
|
+
- **New**: Both are logged together on every real measurement — `first-segment synthetic=Xms measured=Yms ratio=Z`. The intent is to stop carrying history: if the synthetic figure tracks the measured one, the file can go and every machine is right from its first second. A ratio that varies with content instead would say the synthetic figure needs the source's own character as an input, which the probe already has. Reasoning recorded in the meta roadmap.
|
|
10
|
+
|
|
1
11
|
## 2.9.139
|
|
2
12
|
|
|
3
13
|
- **Fix**: The proxy actually states which tracks its output will carry. It has been declaring `{video: false, audio: false}` for every session since the declaration was written, because it reads the codecs off the planner's media-info cache and that cache has only ever stored dimensions, duration, fps, start time and an HDR flag. Reading a field that is not there yields `undefined`, and `Boolean(undefined)` is `false`, so the promise was empty and silently so. Measured 2026-08-11 in the field: `declared tracks video=false audio=false`. Two things depended on it and both were disarmed — the browser could not tell "this file has no video" from "the video was lost on the way", and the init-segment guard computed a requirement of zero tracks and therefore accepted any header at all, including the audio-only one that leaves a session playing sound with no picture. The codecs are now stored where they are read.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"firstSegment":[3,1,4,11,3,1,3,17],"sessionCreate":[]}
|
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* immediately when all registered consumers release them.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { createReadStream, readdirSync } from "node:fs";
|
|
10
|
+
import { createReadStream, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { access, mkdir, readdir, readFile, rm, stat, unlink } from "node:fs/promises";
|
|
12
12
|
import { Readable } from "node:stream";
|
|
13
13
|
import os from "node:os";
|
|
@@ -15,6 +15,7 @@ import path from "node:path";
|
|
|
15
15
|
import { randomUUID } from "node:crypto";
|
|
16
16
|
import { spawn } from "node:child_process";
|
|
17
17
|
import { createRequire } from "node:module";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
18
19
|
import { logger } from "../utils/logger.js";
|
|
19
20
|
import { readKeyframeIndex } from "./container-index/index.js";
|
|
20
21
|
|
|
@@ -976,6 +977,9 @@ export class HlsSessionManager {
|
|
|
976
977
|
this.startupWaitMs = startupWaitMs;
|
|
977
978
|
this.localBaseUrl = buildHttpBaseUrl(localBindHost, localPort);
|
|
978
979
|
this.sessionsById = new Map();
|
|
980
|
+
// What this host learned last time it ran. Without it every restart shows
|
|
981
|
+
// the first viewer a figure with no measurement behind it.
|
|
982
|
+
this.#loadHostTimings();
|
|
979
983
|
// Container keyframe index per (source, file). Immutable per file, so one
|
|
980
984
|
// read serves every session, re-open and seek. Null means "this file has no
|
|
981
985
|
// readable index" and is cached too — no point retrying a scan that cannot
|
|
@@ -3234,6 +3238,115 @@ export class HlsSessionManager {
|
|
|
3234
3238
|
* @param {number} latencyMs
|
|
3235
3239
|
* @returns {void}
|
|
3236
3240
|
*/
|
|
3241
|
+
/**
|
|
3242
|
+
* What this host should take to produce a first segment, derived from the
|
|
3243
|
+
* startup benchmark rather than from any past session.
|
|
3244
|
+
*
|
|
3245
|
+
* The encoder detection already encodes `testsrc2` through the real HLS
|
|
3246
|
+
* pipeline and records each preset's throughput in pixels per second. One
|
|
3247
|
+
* segment is `segmentDurationSec x width x height x fps` pixels, so the time
|
|
3248
|
+
* to make it follows by division. No coefficient is involved: it is a
|
|
3249
|
+
* measurement of this machine taken minutes earlier, applied to a known
|
|
3250
|
+
* quantity of work.
|
|
3251
|
+
*
|
|
3252
|
+
* This is the answer we would LIKE to rely on exclusively — it needs no
|
|
3253
|
+
* history, so it is right on a machine's very first run, when nothing has
|
|
3254
|
+
* been recorded yet. Whether it is good enough to replace the recorded median
|
|
3255
|
+
* is what {@link #compareSyntheticWithMeasured} is for.
|
|
3256
|
+
*
|
|
3257
|
+
* @param {{ width?: number, height?: number, fps?: number }} [output]
|
|
3258
|
+
* @returns {number | null} Milliseconds, or null without a benchmark.
|
|
3259
|
+
*/
|
|
3260
|
+
/**
|
|
3261
|
+
* Where this host's recorded timings live: one file, always the same path,
|
|
3262
|
+
* beside the proxy's own installation.
|
|
3263
|
+
*
|
|
3264
|
+
* Kept so a proxy that has just restarted is not back to knowing nothing —
|
|
3265
|
+
* the browser was shown an assumed rate for the whole of the first wait after
|
|
3266
|
+
* every restart. It is meant to be TEMPORARY: if the synthetic figure tracks
|
|
3267
|
+
* the measured one closely enough (see #compareSyntheticWithMeasured) this
|
|
3268
|
+
* file can go, and every machine is then right from its first second without
|
|
3269
|
+
* carrying anything between runs.
|
|
3270
|
+
*
|
|
3271
|
+
* @returns {string}
|
|
3272
|
+
*/
|
|
3273
|
+
#hostTimingsPath() {
|
|
3274
|
+
return path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "host-timings.json");
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
/** Load them, if any were ever written. Never throws. */
|
|
3278
|
+
#loadHostTimings() {
|
|
3279
|
+
try {
|
|
3280
|
+
const raw = JSON.parse(readFileSync(this.#hostTimingsPath(), "utf8"));
|
|
3281
|
+
if (Array.isArray(raw?.firstSegment)) {
|
|
3282
|
+
this.#firstSegmentLatencies = raw.firstSegment.filter((value) => Number.isFinite(value) && value > 0);
|
|
3283
|
+
}
|
|
3284
|
+
if (Array.isArray(raw?.sessionCreate)) {
|
|
3285
|
+
this.#sessionCreateLatencies = raw.sessionCreate.filter((value) => Number.isFinite(value) && value > 0);
|
|
3286
|
+
}
|
|
3287
|
+
logger.info(
|
|
3288
|
+
`host timings loaded: first-segment ${this.expectedFirstSegmentMs() ?? "n/a"}ms, ` +
|
|
3289
|
+
`session-create ${this.expectedSessionCreateMs() ?? "n/a"}ms`
|
|
3290
|
+
);
|
|
3291
|
+
} catch {
|
|
3292
|
+
// No file yet, or it is unreadable. The synthetic figure answers instead.
|
|
3293
|
+
}
|
|
3294
|
+
}
|
|
3295
|
+
|
|
3296
|
+
/** Write them. Best effort: losing them costs a first estimate, nothing more. */
|
|
3297
|
+
#saveHostTimings() {
|
|
3298
|
+
try {
|
|
3299
|
+
writeFileSync(this.#hostTimingsPath(), JSON.stringify({
|
|
3300
|
+
firstSegment: this.#firstSegmentLatencies,
|
|
3301
|
+
sessionCreate: this.#sessionCreateLatencies
|
|
3302
|
+
}));
|
|
3303
|
+
} catch {
|
|
3304
|
+
// Read-only install, no permission — not worth failing a session over.
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
|
|
3308
|
+
syntheticFirstSegmentMs(output = {}) {
|
|
3309
|
+
const benchmark = this.softwarePresetBenchmark;
|
|
3310
|
+
if (!Array.isArray(benchmark) || benchmark.length === 0) {
|
|
3311
|
+
return null;
|
|
3312
|
+
}
|
|
3313
|
+
const width = Number.isFinite(output.width) && output.width > 0 ? output.width : 1920;
|
|
3314
|
+
const height = Number.isFinite(output.height) && output.height > 0 ? output.height : 1080;
|
|
3315
|
+
const fps = Number.isFinite(output.fps) && output.fps > 0 ? output.fps : TRANSCODE_FPS;
|
|
3316
|
+
// The preset actually chosen sits somewhere in the middle of the ladder;
|
|
3317
|
+
// the median entry is the representative one and involves no choice.
|
|
3318
|
+
const sorted = [...benchmark].sort((left, right) => left.pixelsPerSec - right.pixelsPerSec);
|
|
3319
|
+
const pixelsPerSec = sorted[Math.floor(sorted.length / 2)]?.pixelsPerSec;
|
|
3320
|
+
if (!Number.isFinite(pixelsPerSec) || pixelsPerSec <= 0) {
|
|
3321
|
+
return null;
|
|
3322
|
+
}
|
|
3323
|
+
const pixels = this.segmentDurationSec * width * height * fps;
|
|
3324
|
+
return (pixels / pixelsPerSec) * 1000;
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
/**
|
|
3328
|
+
* Say how the synthetic figure compares with what actually happened.
|
|
3329
|
+
*
|
|
3330
|
+
* The point is to learn whether the startup benchmark alone can carry the
|
|
3331
|
+
* estimate. If the two track each other, the recorded history can go and
|
|
3332
|
+
* every machine is right from its first second; if they do not, the log says
|
|
3333
|
+
* by how much and in which direction, which is the beginning of knowing why.
|
|
3334
|
+
*
|
|
3335
|
+
* @param {number} measuredMs
|
|
3336
|
+
* @returns {void}
|
|
3337
|
+
*/
|
|
3338
|
+
#compareSyntheticWithMeasured(measuredMs) {
|
|
3339
|
+
const synthetic = this.syntheticFirstSegmentMs();
|
|
3340
|
+
if (synthetic === null) {
|
|
3341
|
+
return;
|
|
3342
|
+
}
|
|
3343
|
+
const ratio = measuredMs / synthetic;
|
|
3344
|
+
logger.info(
|
|
3345
|
+
`first-segment synthetic=${Math.round(synthetic)}ms measured=${Math.round(measuredMs)}ms ` +
|
|
3346
|
+
`ratio=${ratio.toFixed(2)} (1.00 would mean the startup benchmark alone suffices)`
|
|
3347
|
+
);
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3237
3350
|
#rememberSessionCreateLatency(latencyMs) {
|
|
3238
3351
|
if (!Number.isFinite(latencyMs) || latencyMs <= 0) {
|
|
3239
3352
|
return;
|
|
@@ -3242,6 +3355,7 @@ export class HlsSessionManager {
|
|
|
3242
3355
|
if (this.#sessionCreateLatencies.length > FIRST_SEGMENT_SAMPLES) {
|
|
3243
3356
|
this.#sessionCreateLatencies.shift();
|
|
3244
3357
|
}
|
|
3358
|
+
this.#saveHostTimings();
|
|
3245
3359
|
}
|
|
3246
3360
|
|
|
3247
3361
|
/**
|
|
@@ -3262,10 +3376,12 @@ export class HlsSessionManager {
|
|
|
3262
3376
|
if (!Number.isFinite(latencyMs) || latencyMs <= 0) {
|
|
3263
3377
|
return;
|
|
3264
3378
|
}
|
|
3379
|
+
this.#compareSyntheticWithMeasured(latencyMs);
|
|
3265
3380
|
this.#firstSegmentLatencies.push(latencyMs);
|
|
3266
3381
|
if (this.#firstSegmentLatencies.length > FIRST_SEGMENT_SAMPLES) {
|
|
3267
3382
|
this.#firstSegmentLatencies.shift();
|
|
3268
3383
|
}
|
|
3384
|
+
this.#saveHostTimings();
|
|
3269
3385
|
}
|
|
3270
3386
|
|
|
3271
3387
|
/**
|
|
@@ -3276,7 +3392,10 @@ export class HlsSessionManager {
|
|
|
3276
3392
|
*/
|
|
3277
3393
|
expectedFirstSegmentMs() {
|
|
3278
3394
|
if (this.#firstSegmentLatencies.length === 0) {
|
|
3279
|
-
|
|
3395
|
+
// Nothing recorded yet — a machine's first run, or one whose history has
|
|
3396
|
+
// not been written. The startup benchmark answers without any history at
|
|
3397
|
+
// all, which is why the browser was showing an assumed rate here.
|
|
3398
|
+
return this.syntheticFirstSegmentMs();
|
|
3280
3399
|
}
|
|
3281
3400
|
const sorted = [...this.#firstSegmentLatencies].sort((left, right) => left - right);
|
|
3282
3401
|
return sorted[Math.floor(sorted.length / 2)];
|
|
@@ -3771,10 +3890,21 @@ export class HlsSessionManager {
|
|
|
3771
3890
|
}
|
|
3772
3891
|
session.holdExplainedAt.set(fileName, now);
|
|
3773
3892
|
const index = session.segmentFormat.segmentIndexFromName(fileName);
|
|
3893
|
+
// What the encoder has actually DONE since it restarted. "Alive at the right
|
|
3894
|
+
// index" was as far as the old line went, and it left the two possible
|
|
3895
|
+
// causes indistinguishable: an encoder waiting for torrent pieces looks
|
|
3896
|
+
// exactly like one that is encoding and simply has not finished. The
|
|
3897
|
+
// difference is whether its position has moved at all.
|
|
3898
|
+
const runStartSeconds = this.#segmentStartTime(session, session.encodeStartIndex ?? 0);
|
|
3899
|
+
const position = Number(session.progress?.processedSeconds);
|
|
3900
|
+
const produced = Number.isFinite(position) ? position - runStartSeconds : null;
|
|
3901
|
+
const speed = session.progress?.speed ?? "n/a";
|
|
3774
3902
|
logger.warn(
|
|
3775
3903
|
`transcode ${session.id} holding ${fileName}: ${reason} ` +
|
|
3776
3904
|
`(run from #${session.encodeStartIndex ?? "?"}, viewer at #${session.lastRequestedSegment ?? "?"}, ` +
|
|
3777
|
-
`encoder ${session.ffmpeg ? "alive" : "stopped"}, index #${index}
|
|
3905
|
+
`encoder ${session.ffmpeg ? "alive" : "stopped"}, index #${index}, ` +
|
|
3906
|
+
`produced ${produced === null ? "nothing yet — no position reported" : `${produced.toFixed(1)}s`} ` +
|
|
3907
|
+
`at ${speed}${produced !== null && produced <= 0 ? " — the encoder has not moved, so it is waiting on its input" : ""})`
|
|
3778
3908
|
);
|
|
3779
3909
|
}
|
|
3780
3910
|
|