@torrent-tv/proxy 2.9.139 → 2.9.140
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 +6 -0
- package/host-timings.json +1 -0
- package/package.json +1 -1
- package/services/hls-session-manager.js +121 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## 2.9.140
|
|
2
|
+
|
|
3
|
+
- **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.
|
|
4
|
+
- **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.
|
|
5
|
+
- **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.
|
|
6
|
+
|
|
1
7
|
## 2.9.139
|
|
2
8
|
|
|
3
9
|
- **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],"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)];
|