@torrent-tv/proxy 2.64.6 → 2.64.8
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,12 @@
|
|
|
1
|
+
## 2.64.8
|
|
2
|
+
|
|
3
|
+
- **New**: A backward restart says what it costs. Nothing already written is lost — every run keeps its own directory and the session serves the union of all of them — so the price of moving the encoder back is not the files; it is work about to be done twice, because the new run walks forward through segments the old one had already finished and ffmpeg cannot know they exist, and it is the viewer in front, who has nothing produced ahead of them until the run gets back to where it already was. Neither had ever been counted. The line now says how far back it went, how many of the segments it will walk through are already on disk, and the running totals for the session. This is the reading roadmap item 64 needs before a session is allowed more than one concurrent run: if a viewer is behind the head twice a week, that design does not earn its complexity.
|
|
4
|
+
|
|
5
|
+
## 2.64.7
|
|
6
|
+
|
|
7
|
+
- **Fix**: A file opened at a position starts its SOUND at that position. The audio rendition's start is worked out from the picture's read head less the buffer the viewer reports holding — sound, because a read head is the furthest request of any viewer and the picture sits behind it by however deep that buffer is. At a cold open there is no report yet, and the fallback subtracted the WHOLE 120 s look-ahead from a buffer that does not exist: field 2026-08-31, a page opened at 588 s started its sound at 460 s, 131 seconds of film nobody would hear, and the segment the viewer needed took 38.8 s to appear against the picture's 8.4 s — the audio encoder healthy at 2.3-3.1x throughout, simply given a running start it did not need. The reading now says WHICH of its three sources answered (`viewerPositionSource`): a seek and a served segment are request edges and keep the subtraction, while the opening position is not an edge — nothing has been asked for since the session was made, and a browser that has just opened holds nothing by construction (`research/cold-open-audio-start-2026-08-31.md`).
|
|
8
|
+
- **Fix**: The first encode run is positioned from the position the viewer ASKED for, not from the figure rounded to ten seconds. That rounding exists to answer one question — whether two viewers share a session — and `Math.round` can move a position FORWARD: 588 s became 590 s, which falls in segment #85 while the viewer at 588 s is inside #84. The player asked for a segment behind the run, the run was restarted onto it, and the 4.5 s it had already produced were thrown away.
|
|
9
|
+
|
|
1
10
|
## 2.64.6
|
|
2
11
|
|
|
3
12
|
- **New**: The torrent worker reads its own memory once a SECOND, and writes a line only when something moved. A minute cannot see what kills it: three times — 2026-08-30 14:00 and 23:19, 2026-08-31 13:27 — the worker's own line read `heap=28-36MB`, and by the sample after next the thread had been terminated for reaching its heap ceiling, with the whole rise fitting inside a single sixty-second gap. The reading and the line are now separate cadences: taken every second, written when the heap has moved by 25 MB or when a quiet minute is up, so a healthy session costs the same one line a minute it costs today and a runaway is a curve rather than a step.
|
package/package.json
CHANGED
|
@@ -468,6 +468,10 @@ const BEHIND_HEAD_REPAIR_MS = 400;
|
|
|
468
468
|
// Generous against that measurement, and far short of the hundreds of segments
|
|
469
469
|
// a scan reaches.
|
|
470
470
|
const BEHIND_HEAD_REPAIR_MAX_SEGMENTS = 60;
|
|
471
|
+
// How far the accounting of a backward restart looks for work about to be done
|
|
472
|
+
// twice. It runs on the restart path and a session an hour in has thousands of
|
|
473
|
+
// segments; the figure is for a comparison, not an inventory.
|
|
474
|
+
const BACKWARD_RESTART_SCAN_SEGMENTS = 300;
|
|
471
475
|
// Hard cap on the total settle wait, measured from the first request of a
|
|
472
476
|
// burst, so a still-moving scrubber cannot delay a genuine seek forever.
|
|
473
477
|
const SEEK_SETTLE_MAX_MS = 1_000;
|
|
@@ -1257,6 +1261,34 @@ function headsOf(session) {
|
|
|
1257
1261
|
return session.consumerHeads;
|
|
1258
1262
|
}
|
|
1259
1263
|
|
|
1264
|
+
/**
|
|
1265
|
+
* WHICH of the three readings answered, which is a different question from what
|
|
1266
|
+
* the answer was.
|
|
1267
|
+
*
|
|
1268
|
+
* It matters for one thing: `openedAt` is not a request edge. The other two are
|
|
1269
|
+
* — a seek and a requested segment are both places a viewer has moved to while
|
|
1270
|
+
* holding a buffer, so the picture is behind them by however deep that buffer
|
|
1271
|
+
* is. `openedAt` is where a session was created and nothing has been asked for
|
|
1272
|
+
* since, so the picture is exactly there and there is nothing to subtract.
|
|
1273
|
+
* Reading the number without knowing which of the three it was is what started
|
|
1274
|
+
* a cold open's audio two minutes early on 2026-08-31.
|
|
1275
|
+
*
|
|
1276
|
+
* @param {{ seeked?: number, lastRequestedStart?: number | null, openedAt?: number }} readings
|
|
1277
|
+
* @returns {"seeked" | "requested" | "opened" | "none"}
|
|
1278
|
+
*/
|
|
1279
|
+
export function viewerPositionSource({ seeked, lastRequestedStart, openedAt }) {
|
|
1280
|
+
if (Number.isFinite(seeked) && seeked > 0) {
|
|
1281
|
+
return "seeked";
|
|
1282
|
+
}
|
|
1283
|
+
if (Number.isFinite(lastRequestedStart) && lastRequestedStart > 0) {
|
|
1284
|
+
return "requested";
|
|
1285
|
+
}
|
|
1286
|
+
if (Number.isFinite(openedAt) && openedAt > 0) {
|
|
1287
|
+
return "opened";
|
|
1288
|
+
}
|
|
1289
|
+
return "none";
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1260
1292
|
export function resolveViewerPosition({ seeked, lastRequestedStart, openedAt }) {
|
|
1261
1293
|
if (Number.isFinite(seeked) && seeked > 0) {
|
|
1262
1294
|
return seeked;
|
|
@@ -2408,8 +2440,19 @@ export class HlsSessionManager {
|
|
|
2408
2440
|
// asked for #152, and 45 s later the browser gave up with "no data arrived
|
|
2409
2441
|
// from the proxy" while the transcode ran happily at 9.9x through the
|
|
2410
2442
|
// opening credits.
|
|
2411
|
-
|
|
2412
|
-
|
|
2443
|
+
// From what the viewer ASKED for, not from the rounded figure. The rounding
|
|
2444
|
+
// exists to answer one question — is this the same session as somebody
|
|
2445
|
+
// else's — and it is the wrong number for this one, because `Math.round`
|
|
2446
|
+
// can move the position FORWARD: 588s became 590s, which falls in segment
|
|
2447
|
+
// #85 while the viewer at 588s is inside #84. The player then asked for a
|
|
2448
|
+
// segment behind the run, the run was restarted onto it, and the 4.5s it
|
|
2449
|
+
// had produced were thrown away (field 2026-08-31,
|
|
2450
|
+
// `research/cold-open-audio-start-2026-08-31.md`).
|
|
2451
|
+
const requestedStart = Number.isFinite(startPositionSeconds) && startPositionSeconds > 0
|
|
2452
|
+
? startPositionSeconds
|
|
2453
|
+
: 0;
|
|
2454
|
+
const firstIndex = requestedStart > 0
|
|
2455
|
+
? this.#segmentIndexForTime(session, requestedStart)
|
|
2413
2456
|
: 0;
|
|
2414
2457
|
await this.#startEncodeRun(session, firstIndex);
|
|
2415
2458
|
|
|
@@ -4660,6 +4703,10 @@ export class HlsSessionManager {
|
|
|
4660
4703
|
// 0.54-1.47 s — does not account for it. Before rebuilding the hottest path
|
|
4661
4704
|
// in the proxy on a guess, make each stage state its own cost.
|
|
4662
4705
|
const restartEnteredAt = Date.now();
|
|
4706
|
+
// Reads where the old run began BEFORE the new one overwrites it, and does
|
|
4707
|
+
// not await: everything below is the restart path, which is measured in
|
|
4708
|
+
// milliseconds and has been worked on twice to keep it that way.
|
|
4709
|
+
this.#accountBackwardRestart(session, startIndex);
|
|
4663
4710
|
// One directory per run. Two runs writing the same segment name at once
|
|
4664
4711
|
// produce a file that is neither, which is the only reason a restart ever
|
|
4665
4712
|
// had to wait for its predecessor to die.
|
|
@@ -7824,10 +7871,40 @@ export class HlsSessionManager {
|
|
|
7824
7871
|
// would start the run where no request can ever reach it.
|
|
7825
7872
|
return Math.max(0, Math.min(earliestStated, readHead) - this.segmentDurationSec);
|
|
7826
7873
|
}
|
|
7874
|
+
if (this.#viewerPositionSourceOf(watching) === "opened") {
|
|
7875
|
+
// The session has not started. Nobody has seeked, nobody has asked for a
|
|
7876
|
+
// segment, and nobody has reported anything — so the read head is not a
|
|
7877
|
+
// request edge at all, it is where the viewer opened, and a browser that
|
|
7878
|
+
// has just opened holds no buffer by construction. Subtracting one here
|
|
7879
|
+
// is not erring "early, the cheap direction": it is the whole of the
|
|
7880
|
+
// start-up cost. Field 2026-08-31: a page opened at 588s started its
|
|
7881
|
+
// sound at 460s, 131 seconds of film nobody would hear, and the segment
|
|
7882
|
+
// the viewer needed took 38.8s to appear against the picture's 8.4s
|
|
7883
|
+
// (`research/cold-open-audio-start-2026-08-31.md`).
|
|
7884
|
+
return Math.max(0, readHead - this.segmentDurationSec);
|
|
7885
|
+
}
|
|
7827
7886
|
const buffered = deepestBuffer === null ? LOOKAHEAD_PAUSE_SECONDS : deepestBuffer;
|
|
7828
7887
|
return Math.max(0, readHead - buffered - this.segmentDurationSec);
|
|
7829
7888
|
}
|
|
7830
7889
|
|
|
7890
|
+
/**
|
|
7891
|
+
* Which reading gave this session's viewer position — see
|
|
7892
|
+
* {@link viewerPositionSource}.
|
|
7893
|
+
*
|
|
7894
|
+
* @param {HlsSession} session
|
|
7895
|
+
* @returns {"seeked" | "requested" | "opened" | "none"}
|
|
7896
|
+
*/
|
|
7897
|
+
#viewerPositionSourceOf(session) {
|
|
7898
|
+
const lastRequestedStart = Number.isInteger(session.lastRequestedSegment) && session.lastRequestedSegment > 0
|
|
7899
|
+
? this.#segmentStartTime(session, session.lastRequestedSegment)
|
|
7900
|
+
: null;
|
|
7901
|
+
return viewerPositionSource({
|
|
7902
|
+
seeked: session.viewerPositionSeconds,
|
|
7903
|
+
lastRequestedStart,
|
|
7904
|
+
openedAt: session.progress?.startPositionSeconds
|
|
7905
|
+
});
|
|
7906
|
+
}
|
|
7907
|
+
|
|
7831
7908
|
#viewerPositionOf(session) {
|
|
7832
7909
|
const lastRequestedStart = Number.isInteger(session.lastRequestedSegment) && session.lastRequestedSegment > 0
|
|
7833
7910
|
? this.#segmentStartTime(session, session.lastRequestedSegment)
|
|
@@ -9261,6 +9338,85 @@ export class HlsSessionManager {
|
|
|
9261
9338
|
);
|
|
9262
9339
|
}
|
|
9263
9340
|
|
|
9341
|
+
/**
|
|
9342
|
+
* What moving the encoder BACKWARDS costs, said out loud when it happens.
|
|
9343
|
+
*
|
|
9344
|
+
* Nothing already written is lost — every run keeps its own directory and
|
|
9345
|
+
* {@link HlsSessionManager##findProducedFile} serves the union of all of them
|
|
9346
|
+
* — so the price of a restart is not the files. It is two other things, and
|
|
9347
|
+
* neither was ever counted:
|
|
9348
|
+
*
|
|
9349
|
+
* - **work done twice.** The new run begins at the target and encodes
|
|
9350
|
+
* forward through segments the old run had already finished. ffmpeg cannot
|
|
9351
|
+
* know they exist, so it makes them again.
|
|
9352
|
+
* - **the viewer in front.** While the run walks back up to where it already
|
|
9353
|
+
* was, nothing new is being made ahead of them, and their cushion drains.
|
|
9354
|
+
*
|
|
9355
|
+
* Both are what decides whether a session should be allowed a SECOND
|
|
9356
|
+
* concurrent run instead — roadmap item 64. That question cannot be answered
|
|
9357
|
+
* from taste, and this is the reading it needs: how often it happens at all,
|
|
9358
|
+
* how far back, and how much of the walk is a repeat.
|
|
9359
|
+
*
|
|
9360
|
+
* Nothing here is awaited by the caller. Everything below the call site is
|
|
9361
|
+
* the restart path, which is measured in milliseconds and has been worked on
|
|
9362
|
+
* twice to keep it that way; a reading that delays the thing it is reading
|
|
9363
|
+
* about is not a reading. The figures that MUST be taken before the new run
|
|
9364
|
+
* exists are taken synchronously, and only the file counting is left to run
|
|
9365
|
+
* on its own — against the directories that existed at this instant, so what
|
|
9366
|
+
* the new run is about to write cannot be counted as already there.
|
|
9367
|
+
*
|
|
9368
|
+
* @param {HlsSession} session
|
|
9369
|
+
* @param {number} startIndex - Where the new run will begin.
|
|
9370
|
+
* @returns {void}
|
|
9371
|
+
*/
|
|
9372
|
+
#accountBackwardRestart(session, startIndex) {
|
|
9373
|
+
const previousStart = session.encodeStartIndex;
|
|
9374
|
+
if (!Number.isInteger(previousStart) || !Number.isInteger(startIndex) || startIndex >= previousStart) {
|
|
9375
|
+
// A first run, or one moving forward. Neither costs anything here: a
|
|
9376
|
+
// forward restart skips material it never made.
|
|
9377
|
+
return;
|
|
9378
|
+
}
|
|
9379
|
+
const processed = Number(session.progress?.processedSeconds);
|
|
9380
|
+
const head = Number.isFinite(processed)
|
|
9381
|
+
? Math.max(previousStart, this.#segmentIndexForTime(session, processed))
|
|
9382
|
+
: previousStart;
|
|
9383
|
+
// Bounded: a session an hour in has thousands of segments, and the count is
|
|
9384
|
+
// for a comparison, not an inventory.
|
|
9385
|
+
const last = Math.min(head, startIndex + BACKWARD_RESTART_SCAN_SEGMENTS);
|
|
9386
|
+
const dirsBefore = this.#runDirs(session);
|
|
9387
|
+
const accounting = session.backwardRestarts ?? { count: 0, segmentsBack: 0, worstBack: 0, remade: 0 };
|
|
9388
|
+
accounting.count += 1;
|
|
9389
|
+
accounting.segmentsBack += previousStart - startIndex;
|
|
9390
|
+
accounting.worstBack = Math.max(accounting.worstBack, previousStart - startIndex);
|
|
9391
|
+
session.backwardRestarts = accounting;
|
|
9392
|
+
|
|
9393
|
+
void (async () => {
|
|
9394
|
+
let alreadyOnDisk = 0;
|
|
9395
|
+
for (let index = startIndex; index <= last; index += 1) {
|
|
9396
|
+
const fileName = session.segmentFormat.segmentFileName(index);
|
|
9397
|
+
for (const dir of dirsBefore) {
|
|
9398
|
+
try {
|
|
9399
|
+
await access(path.join(dir, fileName));
|
|
9400
|
+
alreadyOnDisk += 1;
|
|
9401
|
+
break;
|
|
9402
|
+
} catch {
|
|
9403
|
+
// Not this run's; try an older one.
|
|
9404
|
+
}
|
|
9405
|
+
}
|
|
9406
|
+
}
|
|
9407
|
+
accounting.remade += alreadyOnDisk;
|
|
9408
|
+
logger.info(
|
|
9409
|
+
`transcode ${session.id} moving the encoder BACK from #${previousStart} to #${startIndex} ` +
|
|
9410
|
+
`(head was #${head}): ${alreadyOnDisk} of the ${last - startIndex + 1} segment(s) it will walk through ` +
|
|
9411
|
+
`are already on disk and will be made again, and nothing is produced ahead of #${head} until it gets ` +
|
|
9412
|
+
`back there — ${accounting.count} backward restart(s) this session, worst ${accounting.worstBack} ` +
|
|
9413
|
+
`segment(s) back, ${accounting.remade} segment(s) remade in total (roadmap 64)`
|
|
9414
|
+
);
|
|
9415
|
+
})().catch(() => {
|
|
9416
|
+
// silent-ok: a reading that fails is not worth ending a restart over.
|
|
9417
|
+
});
|
|
9418
|
+
}
|
|
9419
|
+
|
|
9264
9420
|
/**
|
|
9265
9421
|
* The directories runs have written into, newest first.
|
|
9266
9422
|
*
|
|
@@ -1071,3 +1071,42 @@ test("a rung is never served from the COPY, whatever height the copy happens to
|
|
|
1071
1071
|
|
|
1072
1072
|
assert.equal(asked.id, VARIANT_ID, "the re-encoded rung is its own session, not the copy");
|
|
1073
1073
|
});
|
|
1074
|
+
|
|
1075
|
+
test("a file opened at a position starts its sound THERE, not a look-ahead earlier", async (t) => {
|
|
1076
|
+
const { manager, base, dirPath } = await managerWithBase();
|
|
1077
|
+
t.after(async () => {
|
|
1078
|
+
await manager.disposeAll();
|
|
1079
|
+
await rm(dirPath, { recursive: true, force: true });
|
|
1080
|
+
});
|
|
1081
|
+
base.audioSeparate = true;
|
|
1082
|
+
// The state at the instant a page is opened at a position: nothing seeked,
|
|
1083
|
+
// no segment served, no report from anybody. The read head is then not a
|
|
1084
|
+
// request edge — it is where the session was made — and a browser that has
|
|
1085
|
+
// just opened holds no buffer at all.
|
|
1086
|
+
base.viewerPositionSeconds = null;
|
|
1087
|
+
base.lastRequestedSegment = null;
|
|
1088
|
+
base.netReports.clear();
|
|
1089
|
+
base.progress.startPositionSeconds = 588;
|
|
1090
|
+
manager.getCachedAudioTracks = () => [
|
|
1091
|
+
{ index: 0, language: "rus", title: "", isDefault: true },
|
|
1092
|
+
{ index: 1, language: "eng", title: "", isDefault: false }
|
|
1093
|
+
];
|
|
1094
|
+
const created = [];
|
|
1095
|
+
manager.createOrGetSession = async (params) => {
|
|
1096
|
+
created.push(params);
|
|
1097
|
+
const rendition = fakeSession({ id: VARIANT_ID, encodeHeight: 0, dirPath });
|
|
1098
|
+
rendition.audioOnly = true;
|
|
1099
|
+
return { sessionId: VARIANT_ID, session: rendition };
|
|
1100
|
+
};
|
|
1101
|
+
|
|
1102
|
+
await manager.resolveAudioRenditionFile(BASE_ID, 1, "segment-00010.mp4");
|
|
1103
|
+
|
|
1104
|
+
// Field 2026-08-31: this answered 460 for a page opened at 588 — the whole
|
|
1105
|
+
// 120 s look-ahead subtracted from a buffer that did not exist — and the
|
|
1106
|
+
// segment the viewer needed took 38.8 s to appear against the picture's 8.4 s.
|
|
1107
|
+
assert.equal(
|
|
1108
|
+
created[0].startPositionSeconds,
|
|
1109
|
+
584,
|
|
1110
|
+
"where the viewer opened, less one segment of margin, and nothing else"
|
|
1111
|
+
);
|
|
1112
|
+
});
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
import assert from "node:assert/strict";
|
|
23
23
|
import test from "node:test";
|
|
24
24
|
|
|
25
|
-
import { resolveViewerPosition } from "../services/hls-session-manager.js";
|
|
25
|
+
import { resolveViewerPosition, viewerPositionSource } from "../services/hls-session-manager.js";
|
|
26
26
|
|
|
27
27
|
test("a file opened at a position has its viewer at that position", () => {
|
|
28
28
|
// Nothing has been seeked and nothing served yet — the state at the instant
|
|
@@ -51,3 +51,42 @@ test("with nothing to go on the answer is the beginning", () => {
|
|
|
51
51
|
assert.equal(resolveViewerPosition({ seeked: -5, openedAt: -5 }), 0);
|
|
52
52
|
assert.equal(resolveViewerPosition({ lastRequestedStart: null, openedAt: undefined }), 0);
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Which of the three answered is a separate question, and the audio start needs
|
|
57
|
+
* it. A seek and a served segment are request edges — the picture is behind
|
|
58
|
+
* them by however deep the viewer's buffer is, which is what the subtraction in
|
|
59
|
+
* `#audioStartSecondsFor` converts. The opening position is not an edge: it is
|
|
60
|
+
* where the session was made, nothing has been asked for since, and a browser
|
|
61
|
+
* that has just opened holds nothing.
|
|
62
|
+
*
|
|
63
|
+
* Field 2026-08-31: a page opened at 588s, no report yet, and the whole 120 s
|
|
64
|
+
* look-ahead was subtracted — the sound started at 460s and its first segment
|
|
65
|
+
* took 38.8 s to appear against the picture's 8.4 s.
|
|
66
|
+
*/
|
|
67
|
+
test("the reading says which of the three it came from", () => {
|
|
68
|
+
assert.equal(viewerPositionSource({ seeked: 900, lastRequestedStart: 400, openedAt: 3130 }), "seeked");
|
|
69
|
+
assert.equal(viewerPositionSource({ lastRequestedStart: 400, openedAt: 3130 }), "requested");
|
|
70
|
+
assert.equal(viewerPositionSource({ openedAt: 3130 }), "opened");
|
|
71
|
+
assert.equal(viewerPositionSource({}), "none");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("the source agrees with the position, reading for reading", () => {
|
|
75
|
+
const readings = [
|
|
76
|
+
{ seeked: 900, lastRequestedStart: 400, openedAt: 3130 },
|
|
77
|
+
{ lastRequestedStart: 400, openedAt: 3130 },
|
|
78
|
+
{ openedAt: 3130 },
|
|
79
|
+
{ seeked: Number.NaN, openedAt: Number.NaN },
|
|
80
|
+
{ seeked: -5, openedAt: -5 },
|
|
81
|
+
{}
|
|
82
|
+
];
|
|
83
|
+
for (const reading of readings) {
|
|
84
|
+
const position = resolveViewerPosition(reading);
|
|
85
|
+
const source = viewerPositionSource(reading);
|
|
86
|
+
assert.equal(
|
|
87
|
+
source === "none",
|
|
88
|
+
position === 0,
|
|
89
|
+
`no source must mean no position, and the other way round: ${JSON.stringify(reading)}`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
});
|