@torrent-tv/proxy 2.96.1 → 2.96.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.96.1",
3
+ "version": "2.96.3",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -285,6 +285,15 @@ export class EncodeRun {
285
285
  this.indexOfName = typeof indexOfName === "function" ? indexOfName : () => null;
286
286
  /** The last thing ffmpeg said on stderr, which is what a failure is explained by. */
287
287
  this.lastError = "";
288
+ /**
289
+ * How far after the start it was asked for this run's segment muxer counts
290
+ * its cuts from, in seconds. The muxer measures every cut from its first
291
+ * reference packet, and a copied stream's first packet lies up to one frame
292
+ * after the requested start, so every piece of the run ends that much later
293
+ * than its cut. `null` until the run's first piece has closed
294
+ * (`piece-completeness.js`, `cutShiftOf`).
295
+ */
296
+ this.cutShiftSeconds = null;
288
297
  // EXISTING IS RUNNING. There is no moment at which a built run is not yet a
289
298
  // process, so there is no second act for two owners to perform.
290
299
  this.#begin(because);
@@ -17,7 +17,7 @@ import { computeOutputDimensions } from "./args.js";
17
17
  import { buildAdmittedCommand } from "./admitted-command.js";
18
18
  import { buildOriginalCommand } from "./source-command.js";
19
19
  import { writeAdmittedInput } from "./AdmittedInput.js";
20
- import { cutOf, judgeNeighbors, judgePiece } from "./piece-completeness.js";
20
+ import { cutOf, cutShiftOf, judgeNeighbors, judgePiece } from "./piece-completeness.js";
21
21
  import { presentationSegment } from "./segment-formats/presentation-segment.js";
22
22
  import { InputFailures, failedAdmittedInput } from "./InputFailures.js";
23
23
 
@@ -351,9 +351,13 @@ export class EncodeRuns {
351
351
  * @param {HlsSession} session
352
352
  * @param {string} name - The working name the encoder closed it under.
353
353
  * @param {number} index
354
+ * @param {object | false} [admitted] - The original input the run reads, when it reads one.
355
+ * @param {Buffer | null} [bytes]
356
+ * @param {EncodeRun | null} [run] - The run that closed it, which keeps where its muxer counts cuts from.
357
+ * @param {{ endMicros: bigint } | null} [timing] - The piece's end in the muxer's clock, from its segment list.
354
358
  * @returns {object | null | false}
355
359
  */
356
- #wholeClosedPiece(session, name, index, admitted = false, bytes = null) {
360
+ #wholeClosedPiece(session, name, index, admitted = false, bytes = null, run = null, timing = null) {
357
361
 
358
362
  const format = session.segmentFormat;
359
363
  if (!format?.readMediaRanges || !Number.isInteger(index) || index < 0) {
@@ -371,6 +375,16 @@ export class EncodeRuns {
371
375
  .map(input => [input.track.type === "video" ? "vide" : "soun", input.sourceEndSeconds])) } : {}),
372
376
  requiredKinds: [session.spec.carries !== "audio-only" ? "vide" : null,
373
377
  session.spec.audio && !this.#host.servesAudioSeparately(session) ? "soun" : null].filter(Boolean) } : undefined;
378
+ if (run) {
379
+ // Where the muxer counts this run's cuts from is known once its first
380
+ // piece has closed, and holds for every piece after it. It is a fact
381
+ // of each piece the run makes, so it travels with the piece.
382
+ if (run.cutShiftSeconds === null && index === run.from) {
383
+ run.cutShiftSeconds = cutShiftOf(mediaRanges, grid?.[run.from], session.spec.carries !== "audio-only" ? "vide" : "soun",
384
+ timing?.endMicros ?? null);
385
+ }
386
+ if (mediaRanges && run.cutShiftSeconds !== null) mediaRanges.cutShiftSeconds = run.cutShiftSeconds;
387
+ }
374
388
  const { whole, throughSeconds, reason } = judgePiece(format, mediaRanges, cut, interval);
375
389
  if (!whole) {
376
390
  this.#host.logger.warn(
@@ -990,7 +1004,7 @@ export class EncodeRuns {
990
1004
  // Why this encoder exists, recorded with its argument list. It used to be
991
1005
  // handed to a separate `start` call; there is no separate call now.
992
1006
  because,
993
- onClosed: (name, following) => {
1007
+ onClosed: (name, following, timing) => {
994
1008
 
995
1009
  const index = session.segmentFormat.segmentIndexFromName(
996
1010
  session.segmentFormat.servedNameOf?.(name) ?? name);
@@ -1012,7 +1026,7 @@ export class EncodeRuns {
1012
1026
  throw error;
1013
1027
  }
1014
1028
  }
1015
- const mediaRanges = this.#wholeClosedPiece(session, name, index, admittedInput, bytes);
1029
+ const mediaRanges = this.#wholeClosedPiece(session, name, index, admittedInput, bytes, run, timing);
1016
1030
  if (mediaRanges === false) throw Object.assign(new Error(`Closed piece #${index} has incomplete media.`),
1017
1031
  { code: "ERR_INCOMPLETE_MEDIA" });
1018
1032
  const published = this.#host.segmentFiles.publish(session.outputKey ?? "", name, session.segmentFormat, { mediaRanges, bytes });
@@ -258,6 +258,19 @@ export class OutputSpec {
258
258
  return this.audio?.fileIndex ?? -1;
259
259
  }
260
260
 
261
+ /**
262
+ * The files of the torrent whose bytes this output reads: the picture's file
263
+ * where it carries a picture, the soundtrack's where it carries sound. A
264
+ * soundtrack shipped beside the picture is a file of its own, and its bytes
265
+ * are wanted from that file and not from the picture's.
266
+ *
267
+ * @returns {number[]}
268
+ */
269
+ get sourceFileIndexes() {
270
+ return [...new Set([this.video?.fileIndex, this.audio?.fileIndex]
271
+ .filter((fileIndex) => Number.isInteger(fileIndex) && fileIndex >= 0))];
272
+ }
273
+
261
274
  /** @returns {number} */
262
275
  get audioSourceTrackIndex() {
263
276
  return this.audio?.trackIndex ?? 0;
@@ -30,82 +30,81 @@ export function cutOf(timeline, index) {
30
30
  }
31
31
 
32
32
  /**
33
- * Track positions read from empty edits retain their movie-tick uncertainty.
34
- * Fresh closures also carry the muxer's packet end and its actual cut, so a
35
- * restarted run is judged on its own reference clock. Neither allowance is a
36
- * browser's frame-join rule.
33
+ * Whether a closed piece holds its stretch of film. With `interval` (a run
34
+ * reading the original file, which states the stretch each piece covers) the
35
+ * piece's tracks are compared with that stretch; otherwise with the cut.
36
+ *
37
+ * Both are compared on the clock the producing run's muxer cut by: moved by
38
+ * `ranges.cutShiftSeconds`, which the run states for every piece it closes
39
+ * ({@link cutShiftOf}) and which travels with the piece. A piece whose run is
40
+ * not known — one adopted from an earlier life of the process — has none, and
41
+ * is compared with the cut as published.
37
42
  *
38
43
  * @param {{ producedThroughSeconds?: (ranges: object) => number | null }} format
39
44
  * @param {object} ranges - The piece's media intervals, as the format read them.
40
45
  * @param {number | undefined} cutSeconds - From {@link cutOf}.
41
- * @returns {{ whole: boolean, throughSeconds: number | null }}
46
+ * @param {{ from: number, to: number, requiredKinds: string[], sourceEnds?: object }} [interval]
47
+ * @returns {{ whole: boolean, throughSeconds: number | null, reason?: string }}
42
48
  */
43
49
  export function judgePiece(format, ranges, cutSeconds, interval) {
44
50
  const throughSeconds = format.producedThroughSeconds?.(ranges) ?? null;
45
51
  if (throughSeconds === null) {
46
52
  return { whole: false, throughSeconds };
47
53
  }
54
+ const shift = Number.isFinite(ranges?.cutShiftSeconds) ? ranges.cutShiftSeconds : 0;
48
55
  if (interval) {
49
- const reason = intervalFailure(ranges, interval);
56
+ const reason = intervalFailure(ranges, { ...interval, shift });
50
57
  return reason ? { whole: false, throughSeconds, reason } : { whole: true, throughSeconds };
51
58
  }
52
- const cutMicros = ranges?.production?.cutMicros ??
53
- (Number.isFinite(cutSeconds) ? BigInt(Math.round(cutSeconds * 1_000_000)) : null);
59
+ const cutMicros = Number.isFinite(cutSeconds) ? BigInt(Math.round((cutSeconds + shift) * 1_000_000)) : null;
54
60
  if (cutMicros !== null && ranges?.tracks?.length) {
55
- const tracks = ranges.tracks;
56
- const production = ranges.production;
57
- const reference = production ? tracks.find(({ kind }) => kind === production.kind) : null;
58
- const referenceEnd = reference?.ranges.at(-1)?.end;
59
- const whole = tracks.every((track) => {
60
- const { timescale, ranges: held, positionErrorTicks = 0n } = track;
61
+ const whole = ranges.tracks.every(({ timescale, ranges: held, positionErrorTicks = 0n }) => {
61
62
  const end = held.at(-1)?.end;
62
- if (end === undefined) return false;
63
- if (referenceEnd !== undefined) {
64
- if (track === reference) return 2n * production.endMicros + 1n >= 2n * cutMicros;
65
- // The segment muxer's CSV reports packet time before movenc truncates
66
- // the empty edit. Compare on that clock, including another track's
67
- // relative end and the precision of both written track positions.
68
- const difference = (end + positionErrorTicks) * reference.timescale -
69
- (referenceEnd - (reference.positionErrorTicks ?? 0n)) * timescale;
70
- const denominator = timescale * reference.timescale;
71
- return 2n * difference * 1_000_000n +
72
- (2n * production.endMicros + 1n) * denominator >= 2n * cutMicros * denominator;
73
- }
74
- return (end + positionErrorTicks) * 1_000_000n >= cutMicros * timescale;
63
+ return end !== undefined && (end + positionErrorTicks) * 1_000_000n >= cutMicros * timescale;
75
64
  });
76
65
  return { whole, throughSeconds };
77
66
  }
78
67
  if (cutMicros !== null && throughSeconds < Number(cutMicros) / 1_000_000) {
79
-
80
68
  return { whole: false, throughSeconds };
81
69
  }
82
70
  return { whole: true, throughSeconds };
83
71
  }
84
72
 
85
- /** The muxer cuts relative to its first reference packet, not the requested seek. */
86
- export function productionOf(timing, cutTimes, relativeIndex, kind) {
87
- if (!timing || timing.originMicros === null || !Number.isFinite(cutTimes?.[relativeIndex])) return null;
88
- return {
89
- kind,
90
- endMicros: timing.endMicros,
91
- cutMicros: timing.originMicros + BigInt(Math.round(cutTimes[relativeIndex] * 1_000_000)) -
92
- BigInt(Math.round(SEGMENT_CUT_TIME_DELTA_SECONDS * 1_000_000))
93
- };
94
- }
95
-
96
73
  /**
97
- * The first CSV entry has start_time = 0 even after a seek (segment.c).
98
- * Recover its reference packet's PTS from the reported end minus the exact
99
- * sample span. The edit-list offset cancels, including its lost precision.
74
+ * How far after its requested start a run's segment muxer counts its cuts
75
+ * from, read from the run's FIRST piece, or `null` when that piece does not
76
+ * start within one frame of the start.
77
+ *
78
+ * With `endMicros`, the end of the piece in the muxer's own clock as its
79
+ * segment list states it, the first packet is that end less the span of the
80
+ * piece's samples: exact, because the span is a sum of sample durations and
81
+ * the muxer's clock needs no track position. Without it, the first sample's
82
+ * position is read, which the piece states only to within its position error.
83
+ *
84
+ * The muxer measures every cut time from its first packet of the reference
85
+ * stream, not from the start it was asked for (measured on ffmpeg 8.1.2,
86
+ * 2026-10-09: AAC copied from Matroska, asked to start at 10.385 s with its
87
+ * first packet at 10.403 s and a cut at 10.386 s from the start, closed the
88
+ * piece at 20.805 s — the first packet at or after 10.403 + 10.386). A copied
89
+ * stream cannot start between its packets, so its first packet lies up to one
90
+ * frame after the requested start, and every cut of the run moves by as much.
91
+ *
92
+ * @param {object} ranges - The first piece's media intervals.
93
+ * @param {number} startSeconds - Where the run was asked to start.
94
+ * @param {string} kind - The reference track: `vide` when the output carries a picture, else `soun`.
95
+ * @returns {number | null}
100
96
  */
101
- export function originOf(ranges, endMicros, kind) {
102
- const track = ranges?.tracks?.find(track => track.kind === kind);
103
- if (track?.firstSampleStart === undefined || !track.ranges.length) return null;
104
- const ticks = endMicros * track.timescale -
105
- (track.ranges.at(-1).end - track.firstSampleStart) * 1_000_000n;
106
- // CSV rounds to microseconds; rescale with the muxer's nearest rounding.
107
- return ticks >= 0n ? (2n * ticks + track.timescale) / (2n * track.timescale) :
108
- -((-2n * ticks + track.timescale) / (2n * track.timescale));
97
+ export function cutShiftOf(ranges, startSeconds, kind, endMicros = null) {
98
+ const track = ranges?.tracks?.find((candidate) => candidate.kind === kind);
99
+ const first = track?.ranges?.[0];
100
+ if (!first || !(track.timescale > 0n) || !Number.isFinite(startSeconds)) return null;
101
+ const timescale = Number(track.timescale);
102
+ const firstSeconds = typeof endMicros === "bigint" && track.firstSampleStart !== undefined
103
+ ? Number(endMicros) / 1_000_000 - Number(track.ranges.at(-1).end - track.firstSampleStart) / timescale
104
+ : Number(first.start) / timescale;
105
+ const shift = firstSeconds - startSeconds;
106
+ const allowance = Number((track.productionFrame ?? first.frame) + (track.positionErrorTicks ?? 0n)) / timescale;
107
+ return Math.abs(shift) <= allowance ? shift : null;
109
108
  }
110
109
 
111
110
  /** Compare adjacent production ranges in their declared track clocks. */
@@ -129,15 +128,19 @@ export function judgeNeighbors(left, right) {
129
128
  return { whole: true };
130
129
  }
131
130
 
132
- /** Every required track must cover the interval with at most one-frame error. */
133
- function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {} }) {
131
+ /**
132
+ * Every required track must cover the interval with at most one-frame error,
133
+ * on the clock the run's muxer cut by: the interval moved by `shift`
134
+ * ({@link cutShiftOf}). A track's own end in the file is not moved.
135
+ */
136
+ function intervalFailure(coverage, { from, to, requiredKinds, sourceEnds = {}, shift = 0 }) {
134
137
  if (!Number.isFinite(from) || !Number.isFinite(to) || !(to > from) || !requiredKinds?.length) return "segment-interval-is-not-declared";
135
138
  for (const kind of requiredKinds) {
136
139
  const track = coverage?.tracks?.find(track => track.kind === kind);
137
140
  if (!track?.ranges?.length || !(track.timescale > 0n)) return `segment-missing-${kind}`;
138
- const start = BigInt(Math.round(from * Number(track.timescale)));
139
- const through = Number.isFinite(sourceEnds[kind]) ? Math.min(to, sourceEnds[kind]) : to;
140
- if (!(through > from)) return `segment-interval-is-empty-${kind}`;
141
+ const start = BigInt(Math.round((from + shift) * Number(track.timescale)));
142
+ const through = Number.isFinite(sourceEnds[kind]) ? Math.min(to + shift, sourceEnds[kind]) : to + shift;
143
+ if (!(through > from + shift)) return `segment-interval-is-empty-${kind}`;
141
144
  const end = BigInt(Math.round(through * Number(track.timescale)));
142
145
  const first = track.ranges[0];
143
146
  const frame = track.productionFrame ?? first.frame;
@@ -584,8 +584,7 @@ export class ViewerRequests {
584
584
  output.segmentFormat.servedMediaRanges(ranges, { initBytes: sessionInit }) : undefined
585
585
  };
586
586
  });
587
- const sourceIndexes = [output.spec.video?.fileIndex, output.spec.audio?.fileIndex]
588
- .filter((fileIndex) => Number.isInteger(fileIndex) && fileIndex >= 0);
587
+ const sourceIndexes = [...output.spec.sourceFileIndexes];
589
588
  if (sourceIndexes.length === 0) {
590
589
  sourceIndexes.push(output.file.fileIndex);
591
590
  }
@@ -216,23 +216,34 @@ export class PriorityOrchestrator {
216
216
  if (typeof sourceKey !== "string" || !sourceKey || !Number.isSafeInteger(fileIndex) || fileIndex < 0) {
217
217
  throw new TypeError("A priority map requires its output file's source and file index.");
218
218
  }
219
- const key = `${sourceKey}:${fileIndex}`;
220
219
  const durationSeconds = Number(session.file?.durationSeconds) || 0;
221
220
  // The first band is as wide as an interruption this file has actually
222
221
  // shown on this swarm, never a chosen number.
223
222
  const allowanceSeconds = this.#allowanceFor(session);
224
- let held = byFile.get(key);
225
- if (!held) {
226
- held = {
227
- sourceKey,
228
- fileIndex,
229
- durationSeconds,
230
- allowanceSeconds,
231
- viewers: [],
232
- demandKey: []
233
- };
234
- byFile.set(key, held);
235
- }
223
+ // EVERY FILE THE OUTPUT READS, not only the one it is named after. A
224
+ // soundtrack shipped beside the picture is an output of the picture's
225
+ // film whose bytes live in another file; mapped under the picture's
226
+ // file only, nobody asked the swarm for that file, its encoder waited
227
+ // for pieces for ever and the viewer who chose the soundtrack stood
228
+ // still (Home Assistant 2026-10-09, torrent-tv/meta#8).
229
+ const read = session.spec?.sourceFileIndexes;
230
+ const files = Array.isArray(read) && read.length ? read : [fileIndex];
231
+ const helds = files.map((index) => {
232
+ const fileKey = `${sourceKey}:${index}`;
233
+ let held = byFile.get(fileKey);
234
+ if (!held) {
235
+ held = {
236
+ sourceKey,
237
+ fileIndex: index,
238
+ durationSeconds,
239
+ allowanceSeconds,
240
+ viewers: [],
241
+ demandKey: []
242
+ };
243
+ byFile.set(fileKey, held);
244
+ }
245
+ return held;
246
+ });
236
247
  // Every output anybody holds a session for, whether or not a viewer is
237
248
  // consuming it — an output with nobody on it must get a map with
238
249
  // nothing in it, which is how the plan is told to stop its encoders.
@@ -240,16 +251,19 @@ export class PriorityOrchestrator {
240
251
  const address = session.outputKey ?? "";
241
252
  let mine = byOutput.get(address);
242
253
  if (!mine) {
243
- mine = { durationSeconds, allowanceSeconds, viewers: [], fileKey: key };
254
+ // Counted over a file the output itself reads, which is always among
255
+ // the files mapped above; the picture's file is not one for a
256
+ // soundtrack read from a file of its own.
257
+ mine = { durationSeconds, allowanceSeconds, viewers: [], fileKey: `${sourceKey}:${files[0]}` };
244
258
  byOutput.set(address, mine);
245
259
  }
246
260
  for (const viewer of this.#viewers.forOutput(session).values()) {
247
261
  if (!viewer.isPresent()) {
248
262
  continue;
249
263
  }
250
- held.demandKey.push([session.outputKey, viewer.id, viewer.audio?.trackIndex,
264
+ const demand = [session.outputKey, viewer.id, viewer.audio?.trackIndex,
251
265
  viewer.audio?.transcode, viewer.activeVariantId, viewer.warmingVariantId,
252
- viewer.warmingAudioId, this.#watchedBy(session, viewer)]);
266
+ viewer.warmingAudioId, this.#watchedBy(session, viewer)];
253
267
  const stated = {
254
268
  id: viewer.id,
255
269
  atSeconds: viewer.positionSeconds(now) ?? 0,
@@ -267,7 +281,10 @@ export class PriorityOrchestrator {
267
281
  ? viewer.wantsFilmNow()
268
282
  : viewer.playing === true || viewer.waiting === true)
269
283
  };
270
- held.viewers.push(stated);
284
+ for (const held of helds) {
285
+ held.demandKey.push(demand);
286
+ held.viewers.push(stated);
287
+ }
271
288
  if (this.#watchedBy(session, viewer)) {
272
289
  mine.viewers.push(stated);
273
290
  }