@torrent-tv/proxy 2.25.1 → 2.27.0

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,11 @@
1
+ ## 2.27.0
2
+
3
+ - **Fix**: Picture and sound now begin a run at the same instant. They were asked for the same time and landed in different places: a copied picture may begin only at a real keyframe and may not begin before the time asked for — that content belongs to the previous segment — so it moves FORWARD to the next keyframe, by up to the keyframe spacing (0.58-2.96 s measured on the field file); a soundtrack has no keyframes and begins exactly where asked, to within one audio frame. So after every seek the two runs of one film began up to three seconds apart. The picture's true start is measured from the piece it produces, and that measurement now moves every other member of the family whose run begins at the same boundary. Restarted at the boundary rather than seeked to the time, deliberately: a seek decides by segment index, finds the run already begins there and answers "already within the running encode" — true about the index and false about the instant, which is why the first version of this fix moved nothing at all.
4
+
5
+ ## 2.26.0
6
+
7
+ - **New**: The keyframe-index measurement now answers the question it was raising. Each file's summary reports the distribution of how far produced segments fell from the playlist (median and worst, not one extreme), how many keyframes were read from the container, and — the discriminator — **how many of the disagreeing segments began at ANOTHER time the same table names**. That separates the two explanations that have been argued rather than measured: a table describing times the file does not have, against a table listing only some keyframes with our grid built over its gaps. Every deviation measured on 2026-08-17 was positive, 0.58-2.96 s, which is what a cut pushed forward to the next real keyframe looks like. The summary is also written every 25 distinct boundaries instead of only when a session is disposed, because a proxy restart — every addon update is one — takes its sessions with it and the summary was routinely never written.
8
+
1
9
  ## 2.25.1
2
10
 
3
11
  - **Fix**: Picture and sound are back in step. Two releases in a row moved a segment's stamp toward the playlist — 2.24.1 per session, 2.25.0 by one offset for the whole family — and both desynced playback in the field the same day. The reason is what the first segment of a run is: it is not CUT at all, it begins where ffmpeg's seek landed, and the picture must land on a keyframe while the sound needs none, so after every restart the two runs genuinely begin at different real times and the whole run carries that difference (measured: the sound's #292 began at 1587.892 s and #293 at 1592.692 s, one segment apart, the run shifted 2.5 s from the grid). Labelling each track with its own true time is what keeps them together in real time; a segment is stamped with its own start again, as it was for weeks before 2.24.1. What stays from those releases is the part that was right: one published timeline per family, and a warning when a piece lands further from the playlist than a player will bridge.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.25.1",
3
+ "version": "2.27.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -202,6 +202,18 @@ export function newIndexCheck() {
202
202
  disagreed: 0,
203
203
  maxDeviationSec: 0,
204
204
  firstDisagreementIndex: -1,
205
+ // Every deviation, so the summary can report a distribution instead of one
206
+ // extreme. Bounded by the number of distinct boundaries a session produces.
207
+ deviations: [],
208
+ // Of the segments that started away from the playlist, how many began at
209
+ // ANOTHER time in the very list the grid was built from. This is the
210
+ // measurement that separates the two explanations: a table that describes
211
+ // times the file does not have, against a table that lists only SOME
212
+ // keyframes and a grid built over its gaps. Asked 2026-08-17 by the user,
213
+ // who was right that the second is far more likely — every deviation
214
+ // measured that day was positive, 0.58-2.96 s, which is what a cut pushed
215
+ // forward to the next real keyframe looks like.
216
+ landedOnAnotherKeyframe: 0,
205
217
  // Which boundaries have been counted. A segment can be requested again, and
206
218
  // a repeat is the same boundary, not new evidence.
207
219
  seen: new Set()
@@ -217,12 +229,17 @@ export function newIndexCheck() {
217
229
  * start the playlist declared for it.
218
230
  * @returns {void}
219
231
  */
220
- export function noteIndexDeviation(check, index, deviationSec) {
232
+ export function noteIndexDeviation(check, index, deviationSec, landedOnKeyframe = null) {
221
233
  if (check.seen.has(index)) {
222
234
  return;
223
235
  }
224
236
  check.seen.add(index);
225
237
  check.checked += 1;
238
+ check.deviations ??= [];
239
+ check.deviations.push(deviationSec);
240
+ if (landedOnKeyframe === true) {
241
+ check.landedOnAnotherKeyframe = (check.landedOnAnotherKeyframe ?? 0) + 1;
242
+ }
226
243
  if (deviationSec > SEGMENT_START_DISAGREEMENT_SEC) {
227
244
  check.disagreed += 1;
228
245
  if (check.firstDisagreementIndex < 0) {
@@ -4786,7 +4803,14 @@ export class HlsSessionManager {
4786
4803
  #noteIndexAccuracy(session, index, trueStart, declaredStart) {
4787
4804
  const deviation = Math.abs(trueStart - declaredStart);
4788
4805
  session.indexCheck ??= newIndexCheck();
4789
- noteIndexDeviation(session.indexCheck, index, deviation);
4806
+ // Did this segment begin at ANOTHER keyframe from the same list? Half an
4807
+ // audio frame is the tolerance — anything the list names is exact, so a
4808
+ // match is a match. `keyframeTimes` is the list the grid was built from, so
4809
+ // this compares the file against the table on the table's own terms.
4810
+ const knownKeyframe = Array.isArray(session.keyframeTimes)
4811
+ ? session.keyframeTimes.some((time) => Math.abs(time - trueStart) <= 0.05)
4812
+ : null;
4813
+ noteIndexDeviation(session.indexCheck, index, deviation, knownKeyframe);
4790
4814
  if (deviation > SEGMENT_START_DISAGREEMENT_SEC) {
4791
4815
  // Which boundary the true start DOES match, if any. This is what tells
4792
4816
  // the two possible faults apart, and they need opposite fixes: matching
@@ -4808,6 +4832,14 @@ export class HlsSessionManager {
4808
4832
  : "the container's keyframe index disagrees with the file; using the file")
4809
4833
  );
4810
4834
  }
4835
+ // Said as the evidence accumulates, not only when the session is disposed.
4836
+ // A proxy restart takes its sessions with it — every addon update does —
4837
+ // and a summary that only ever appears at the end is a summary that is
4838
+ // routinely never written. Twenty-five distinct boundaries is enough for
4839
+ // the proportion to mean something and rare enough not to repeat itself.
4840
+ if (session.indexCheck.checked > 0 && session.indexCheck.checked % 25 === 0) {
4841
+ this.#logIndexAccuracy(session);
4842
+ }
4811
4843
  this.correctBoundaryFromSegment(session, index, trueStart);
4812
4844
  }
4813
4845
 
@@ -4864,6 +4896,44 @@ export class HlsSessionManager {
4864
4896
  `transcode ${session.id} boundary #${index} corrected ${wasAt.toFixed(3)}s → ` +
4865
4897
  `${trueStart.toFixed(3)}s from the file itself`
4866
4898
  );
4899
+ // And every OTHER member whose run begins at this very boundary is moved
4900
+ // to the same instant.
4901
+ //
4902
+ // Why they were not there already: the two branches are asked for the same
4903
+ // time and land in different places. The picture cannot begin anywhere but
4904
+ // a real keyframe, and it may not begin before the time asked for — that
4905
+ // content belongs to the previous segment — so it moves FORWARD to the next
4906
+ // one, by up to the keyframe spacing (0.58-2.96 s measured 2026-08-17).
4907
+ // A soundtrack has no keyframes: it begins exactly where asked, to within
4908
+ // one audio frame. So after every restart the two runs of one film began up
4909
+ // to three seconds apart, each correctly labelled with where it really was,
4910
+ // and the viewer got sound with no new picture for the difference.
4911
+ //
4912
+ // The picture's true start is a MEASURED quantity — read from the piece it
4913
+ // just produced, which is what the correction above is — so the soundtrack
4914
+ // can be put exactly there instead of at the time the container's table
4915
+ // claimed. It converges: once the boundary holds the true time, the next
4916
+ // reading agrees with it and the guard above returns before doing anything.
4917
+ for (const member of this.#familyOf(session)) {
4918
+ if (member === session || member.encodeStartIndex !== index) {
4919
+ continue;
4920
+ }
4921
+ if (!processCanBeSignalled(member.runState)) {
4922
+ continue;
4923
+ }
4924
+ logger.info(
4925
+ `transcode ${member.id} begins at #${index}, which really starts ` +
4926
+ `${(trueStart - wasAt).toFixed(3)}s later than the table said — restarting it there ` +
4927
+ `so picture and sound begin together`
4928
+ );
4929
+ // Restarted at the same INDEX, deliberately, rather than seeked to the
4930
+ // time: a seek decides by index, finds this run already begins at #index,
4931
+ // and answers "already within the running encode" — which is true about
4932
+ // the index and false about the instant, and it is why the first version
4933
+ // of this fix moved nothing at all. The boundary now holds the corrected
4934
+ // time, so starting the run at this index starts it at that time.
4935
+ void this.#startEncodeRun(member, index).catch(() => {});
4936
+ }
4867
4937
  }
4868
4938
 
4869
4939
  /**
@@ -4974,12 +5044,19 @@ export class HlsSessionManager {
4974
5044
  if (!check || check.checked === 0) {
4975
5045
  return;
4976
5046
  }
5047
+ const deviations = [...(check.deviations ?? [])].sort((left, right) => left - right);
5048
+ const median = deviations.length > 0 ? deviations[Math.floor(deviations.length / 2)] : 0;
5049
+ const landed = check.landedOnAnotherKeyframe ?? 0;
4977
5050
  logger.info(
4978
5051
  `keyframe-index ${session.containerFormat || "unknown"} "${session.fileName}": ` +
4979
5052
  `${check.disagreed} of ${check.checked} produced segments started away from the playlist, ` +
4980
- `worst ${check.maxDeviationSec.toFixed(3)}s` +
5053
+ `median ${median.toFixed(3)}s worst ${check.maxDeviationSec.toFixed(3)}s` +
4981
5054
  (check.firstDisagreementIndex >= 0 ? ` (first at #${check.firstDisagreementIndex})` : "") +
4982
- ` [tolerance ${SEGMENT_START_DISAGREEMENT_SEC}s]`
5055
+ // The discriminator, stated in the same line as the count it explains: a
5056
+ // segment that began at another time the SAME table names was not
5057
+ // mis-described by the table — the grid was built over a gap in it.
5058
+ `; ${landed} of them began at another keyframe the table names` +
5059
+ ` [tolerance ${SEGMENT_START_DISAGREEMENT_SEC}s, ${(session.keyframeTimes?.length ?? 0)} keyframes read]`
4983
5060
  );
4984
5061
  }
4985
5062
 
@@ -0,0 +1,114 @@
1
+ /**
2
+ * @file Picture and sound must begin their runs at the same real instant.
3
+ *
4
+ * The two branches are asked for the same time and land in different places: a
5
+ * copied picture may only begin at a real keyframe and may not begin before the
6
+ * time asked for, so it moves FORWARD to the next one; a soundtrack has no
7
+ * keyframes and begins exactly where asked. Measured 2026-08-17, the difference
8
+ * was 0.58-2.96 s on one file, and the viewer got sound with no new picture for
9
+ * as long as it lasted.
10
+ *
11
+ * The picture's true start is measured from the piece it produced. This pins
12
+ * that the measurement is carried to the other members of the family.
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import test from "node:test";
17
+
18
+ import { HlsSessionManager } from "../services/hls-session-manager.js";
19
+ import { ENCODE_RUN_STATE, INITIAL_RUN_STATE } from "../services/encode-run-state.js";
20
+
21
+ const BOUNDARIES = [0, 4, 8, 12, 16, 20];
22
+
23
+ /**
24
+ * A film's family: the picture, and a soundtrack rendition of it. Both runs
25
+ * begin at boundary #2, which the container's table puts at 8 s.
26
+ *
27
+ * @returns {{ manager: HlsSessionManager, picture: object, sound: object }}
28
+ */
29
+ function familyAtBoundaryTwo() {
30
+ const manager = new HlsSessionManager({
31
+ enabled: true,
32
+ ffmpegBin: "ffmpeg",
33
+ localBindHost: "127.0.0.1",
34
+ localPort: 9090
35
+ });
36
+ const picture = {
37
+ id: "picture",
38
+ state: "ready",
39
+ runState: ENCODE_RUN_STATE.PRODUCING,
40
+ segmentBoundaries: [...BOUNDARIES],
41
+ encodeStartIndex: 2,
42
+ audioRenditionSessions: new Map([[1, "sound"]]),
43
+ indexCheck: null
44
+ };
45
+ const sound = {
46
+ id: "sound",
47
+ state: "ready",
48
+ runState: ENCODE_RUN_STATE.PRODUCING,
49
+ audioOnly: true,
50
+ baseSessionId: "picture",
51
+ segmentBoundaries: [...BOUNDARIES],
52
+ encodeStartIndex: 2,
53
+ runSerial: 0,
54
+ indexCheck: null
55
+ };
56
+ manager.sessionsById.set("picture", picture);
57
+ manager.sessionsById.set("sound", sound);
58
+
59
+ return { manager, picture, sound };
60
+ }
61
+
62
+ test("a soundtrack follows the picture to the instant the picture really began", () => {
63
+ const { manager, picture, sound } = familyAtBoundaryTwo();
64
+ const runsBefore = sound.runSerial;
65
+
66
+ manager.correctBoundaryFromSegment(picture, 2, 10.5);
67
+
68
+ assert.deepEqual(
69
+ picture.segmentBoundaries,
70
+ [0, 4, 10.5, 12, 16, 20],
71
+ "the family's table must hold what the file itself said"
72
+ );
73
+ assert.equal(
74
+ sound.segmentBoundaries[2],
75
+ 10.5,
76
+ "and every member's table with it — one film, one timeline"
77
+ );
78
+ // A NEW run, not a seek. A seek decides by index, finds the soundtrack
79
+ // already begins at #2 and answers "already within the running encode" —
80
+ // true about the index, false about the instant. The first version of this
81
+ // fix did exactly that and moved nothing.
82
+ //
83
+ // `runSerial` is the evidence because it is the first thing a run start
84
+ // writes, before it awaits anything: the assertion then holds without the
85
+ // test needing a filesystem, a process, or a guess about how many ticks to
86
+ // wait for one.
87
+ assert.equal(
88
+ sound.runSerial,
89
+ runsBefore + 1,
90
+ "the soundtrack's run must be started again, at the corrected time"
91
+ );
92
+ });
93
+
94
+ test("a correction the table already holds moves nobody", () => {
95
+ const { manager, picture, sound } = familyAtBoundaryTwo();
96
+ const before = [...sound.segmentBoundaries];
97
+ // Within the tolerance: the reading agrees with the table, so there is
98
+ // nothing to correct and nothing to move. This is what makes the repositioning
99
+ // converge instead of repeating on every produced segment.
100
+ manager.correctBoundaryFromSegment(picture, 2, 8.1);
101
+ assert.deepEqual(sound.segmentBoundaries, before);
102
+ });
103
+
104
+ test("a member that is not running is left alone", () => {
105
+ const { manager, picture, sound } = familyAtBoundaryTwo();
106
+ // A rung the viewer switched away from has no process. Moving it would start
107
+ // an encoder for nobody — the failure that put three ffmpeg runs on one file.
108
+ sound.runState = ENCODE_RUN_STATE.STOPPED;
109
+ manager.correctBoundaryFromSegment(picture, 2, 10.5);
110
+ assert.equal(sound.runSerial, 0, "a stopped member is not started again for nobody");
111
+ assert.equal(sound.encodeStartIndex, 2, "a stopped member keeps its place and its silence");
112
+ assert.equal(sound.runState, ENCODE_RUN_STATE.STOPPED);
113
+ assert.notEqual(INITIAL_RUN_STATE, ENCODE_RUN_STATE.STOPPED);
114
+ });