@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.139",
3
+ "version": "2.9.141",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -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
- return null;
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