@torrent-tv/proxy 2.26.0 → 2.28.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,12 @@
1
+ ## 2.28.0
2
+
3
+ - **Fix**: The playlist and the media agree again, and the container's keyframe table was never at fault. On a file whose first timestamp is 2.002 s, the copied picture was asked to cut at 808.808 s on the 0-based grid and cut at 806.806 s — exactly the container's start time early, because that branch keeps the source's own timestamps and re-labels the output afterwards, so a cut list stated in 0-based terms is applied 2 s away from where it means. The soundtrack, re-encoded and on the other branch, cut where it was asked. The two then wrote different values into the shared boundary table and corrected each other for the whole session (#202: 808.808 → 806.806 → 808.750 → …), the playlist drifted a whole segment from the media, and the player refetched fragments it could not place. The cut list is now stated in the source's terms on that branch, which is the same shift the seek on it already applies.
4
+ - **Chore**: Which timeline a session works on is answered by one exported predicate instead of two expressions that could disagree — and their disagreement is exactly what desynced picture from sound. Pinned by `test/cut-times-timeline.test.js`, with the field numbers in its header.
5
+
6
+ ## 2.27.0
7
+
8
+ - **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.
9
+
1
10
  ## 2.26.0
2
11
 
3
12
  - **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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.26.0",
3
+ "version": "2.28.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": {
@@ -1019,6 +1019,29 @@ export function ffmpegSeconds(value) {
1019
1019
  * @param {{ useKeyframeGrid: boolean, durationSeconds: number, segDur: number, keyframeTimes: number[] | null, startTime: number }} params
1020
1020
  * @returns {number[]}
1021
1021
  */
1022
+ /**
1023
+ * Which timeline a session's own ffmpeg works on.
1024
+ *
1025
+ * True — the COPY branch: the source's timestamps are kept (`-copyts`) and the
1026
+ * output is re-labelled 0-based. Everything handed to the muxer is therefore
1027
+ * stated in the source's terms, and everything read back out of a produced
1028
+ * piece is 0-based.
1029
+ *
1030
+ * False — the re-encode branch: the output is labelled from the run's start on
1031
+ * the 0-based timeline, and the muxer is addressed in those same terms.
1032
+ *
1033
+ * One predicate for both callers, because the two used to answer it separately
1034
+ * and a disagreement between them is exactly what desynced picture from sound.
1035
+ *
1036
+ * @param {{ audioOnly?: boolean, cutGrid?: string, transcodeVideo?: boolean }} session
1037
+ * @returns {boolean}
1038
+ */
1039
+ export function onKeyframeGridFor(session) {
1040
+ return session?.audioOnly === true
1041
+ ? session?.cutGrid === "keyframe"
1042
+ : session?.transcodeVideo !== true;
1043
+ }
1044
+
1022
1045
  export function computeSegmentBoundaries({ useKeyframeGrid, durationSeconds, segDur, keyframeTimes, startTime }) {
1023
1046
  const total = Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : 0;
1024
1047
  const step = Number.isFinite(segDur) && segDur > 0 ? segDur : 4;
@@ -3541,9 +3564,33 @@ export class HlsSessionManager {
3541
3564
  // writes no self-contained pieces, so nothing could read a true start and
3542
3565
  // segments were stamped with times the file does not have — the 4.17 s
3543
3566
  // speech-against-subtitles drift, back again.
3544
- const cutTimes = explicitTimes && (!session.transcodeVideo || session.cutGrid === "keyframe")
3567
+ const gridCutTimes = explicitTimes && (!session.transcodeVideo || session.cutGrid === "keyframe")
3545
3568
  ? segmentCutTimesFrom(session.segmentBoundaries, safeIndex)
3546
3569
  : null;
3570
+ // On the COPY branch the muxer decides its cuts against the source's own
3571
+ // timestamps, not against the labels we ask it to write. That branch keeps
3572
+ // the source's timestamps (`-copyts`) and re-labels the output 0-based with
3573
+ // `-output_ts_offset -sourceStartTime`; the cut list, being applied before
3574
+ // that relabelling, must therefore be stated in the SOURCE's terms.
3575
+ //
3576
+ // Measured 2026-08-17, and this is the whole of the trouble: asked to cut
3577
+ // at 808.808 s on the 0-based grid, ffmpeg cut at 806.806 s — exactly
3578
+ // `sourceStartTime` (2.002 s) early, and 806.806 s is itself a keyframe the
3579
+ // container's table names, which is why every "disagreement" landed on
3580
+ // another real keyframe. The soundtrack, which is re-encoded and takes the
3581
+ // other branch, cut where it was asked. The two then told the shared
3582
+ // boundary table different things and corrected each other back and forth
3583
+ // for the whole session (#202: 808.808 → 806.806 → 808.750 → …), so the
3584
+ // playlist and the media drifted apart by a whole segment and the player
3585
+ // refetched what it could not place.
3586
+ //
3587
+ // Nothing here is a guess about ffmpeg's semantics: the shift is the same
3588
+ // one the seek already applies on this branch (`seekSeconds = startSeconds
3589
+ // + sourceStartTime`), and the field measurement above is what says the
3590
+ // cuts needed it too.
3591
+ const cutTimes = gridCutTimes && onKeyframeGridFor(session) && sourceStartTime !== 0
3592
+ ? gridCutTimes.map((time) => Number((time + sourceStartTime).toFixed(6)))
3593
+ : gridCutTimes;
3547
3594
 
3548
3595
  // A second chance for a predecessor that survived the escalation above —
3549
3596
  // the first block is the one that does the work. Its exit is ignored
@@ -3641,9 +3688,7 @@ export class HlsSessionManager {
3641
3688
  // the copy branch: `-copyts` and a shift by the container's start time,
3642
3689
  // against a picture labelled from zero. The two would be offset by
3643
3690
  // `sourceStartTime` for the whole file.
3644
- const onKeyframeGrid = session.audioOnly === true
3645
- ? session.cutGrid === "keyframe"
3646
- : !session.transcodeVideo;
3691
+ const onKeyframeGrid = onKeyframeGridFor(session);
3647
3692
  if (!onKeyframeGrid) {
3648
3693
  // Branch A (re-encode): fixed GOP makes keyframes land exactly on the
3649
3694
  // segment grid; relabel output onto the original timeline so segment N
@@ -4896,6 +4941,44 @@ export class HlsSessionManager {
4896
4941
  `transcode ${session.id} boundary #${index} corrected ${wasAt.toFixed(3)}s → ` +
4897
4942
  `${trueStart.toFixed(3)}s from the file itself`
4898
4943
  );
4944
+ // And every OTHER member whose run begins at this very boundary is moved
4945
+ // to the same instant.
4946
+ //
4947
+ // Why they were not there already: the two branches are asked for the same
4948
+ // time and land in different places. The picture cannot begin anywhere but
4949
+ // a real keyframe, and it may not begin before the time asked for — that
4950
+ // content belongs to the previous segment — so it moves FORWARD to the next
4951
+ // one, by up to the keyframe spacing (0.58-2.96 s measured 2026-08-17).
4952
+ // A soundtrack has no keyframes: it begins exactly where asked, to within
4953
+ // one audio frame. So after every restart the two runs of one film began up
4954
+ // to three seconds apart, each correctly labelled with where it really was,
4955
+ // and the viewer got sound with no new picture for the difference.
4956
+ //
4957
+ // The picture's true start is a MEASURED quantity — read from the piece it
4958
+ // just produced, which is what the correction above is — so the soundtrack
4959
+ // can be put exactly there instead of at the time the container's table
4960
+ // claimed. It converges: once the boundary holds the true time, the next
4961
+ // reading agrees with it and the guard above returns before doing anything.
4962
+ for (const member of this.#familyOf(session)) {
4963
+ if (member === session || member.encodeStartIndex !== index) {
4964
+ continue;
4965
+ }
4966
+ if (!processCanBeSignalled(member.runState)) {
4967
+ continue;
4968
+ }
4969
+ logger.info(
4970
+ `transcode ${member.id} begins at #${index}, which really starts ` +
4971
+ `${(trueStart - wasAt).toFixed(3)}s later than the table said — restarting it there ` +
4972
+ `so picture and sound begin together`
4973
+ );
4974
+ // Restarted at the same INDEX, deliberately, rather than seeked to the
4975
+ // time: a seek decides by index, finds this run already begins at #index,
4976
+ // and answers "already within the running encode" — which is true about
4977
+ // the index and false about the instant, and it is why the first version
4978
+ // of this fix moved nothing at all. The boundary now holds the corrected
4979
+ // time, so starting the run at this index starts it at that time.
4980
+ void this.#startEncodeRun(member, index).catch(() => {});
4981
+ }
4899
4982
  }
4900
4983
 
4901
4984
  /**
@@ -0,0 +1,64 @@
1
+ /**
2
+ * @file The cut list must be stated on the timeline the muxer decides against.
3
+ *
4
+ * Measured 2026-08-17 on a Matroska whose first timestamp is 2.002 s. Asked to
5
+ * cut at 808.808 s on the 0-based grid, the copied picture cut at 806.806 s —
6
+ * exactly the container's start time early, and itself a keyframe the file's
7
+ * own table names, which is why every "disagreement" landed on a real keyframe
8
+ * and the table looked like it was lying. The soundtrack, re-encoded and on the
9
+ * other branch, cut where it was asked. The two then wrote different values
10
+ * into the shared boundary table and corrected each other back and forth
11
+ * (#202: 808.808 → 806.806 → 808.750), so the playlist and the media drifted
12
+ * apart by a whole segment.
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import test from "node:test";
17
+
18
+ import { onKeyframeGridFor } from "../services/hls-session-manager.js";
19
+
20
+ test("a copied picture works on the source's timeline", () => {
21
+ assert.equal(
22
+ onKeyframeGridFor({ transcodeVideo: false }),
23
+ true,
24
+ "video copied means the source's own timestamps are kept"
25
+ );
26
+ });
27
+
28
+ test("a re-encoded picture works on the 0-based timeline", () => {
29
+ assert.equal(onKeyframeGridFor({ transcodeVideo: true }), false);
30
+ });
31
+
32
+ test("a soundtrack follows the grid it was cut on, not its own encoding", () => {
33
+ // A rendition is re-encoded by definition, so asking `transcodeVideo` about
34
+ // it answers nothing. What decides its timeline is the grid it shares with
35
+ // the picture it plays with.
36
+ assert.equal(onKeyframeGridFor({ audioOnly: true, cutGrid: "keyframe" }), true);
37
+ assert.equal(onKeyframeGridFor({ audioOnly: true, cutGrid: "uniform" }), false);
38
+ assert.equal(
39
+ onKeyframeGridFor({ audioOnly: true, cutGrid: "keyframe", transcodeVideo: true }),
40
+ true,
41
+ "its own encoding must not decide this — that disagreement is the defect"
42
+ );
43
+ });
44
+
45
+ test("one predicate answers for every caller", () => {
46
+ // The two callers used to answer this separately, in two expressions that
47
+ // could drift apart. A session put through both must get one answer.
48
+ for (const session of [
49
+ { transcodeVideo: false },
50
+ { transcodeVideo: true },
51
+ { audioOnly: true, cutGrid: "keyframe" },
52
+ { audioOnly: true, cutGrid: "uniform" },
53
+ {}
54
+ ]) {
55
+ assert.equal(onKeyframeGridFor(session), onKeyframeGridFor({ ...session }));
56
+ }
57
+ });
58
+
59
+ test("a session that says nothing is treated as copying", () => {
60
+ // The default matters: an absent `transcodeVideo` means the picture is
61
+ // copied, which is the branch that needs the shift.
62
+ assert.equal(onKeyframeGridFor({}), true);
63
+ assert.equal(onKeyframeGridFor(null), true);
64
+ });
@@ -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
+ });