@torrent-tv/proxy 2.95.4 → 2.95.6

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.95.4",
3
+ "version": "2.95.6",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
package/server.js CHANGED
@@ -467,9 +467,7 @@ export async function startProxyServer({
467
467
  const navigation = await containerOrchestrator.inspect(intervalParams, "source-navigation");
468
468
  if (navigation.kind !== "result") continue;
469
469
  const originalRanges = navigation.value;
470
- const packets = originalRanges && !zone.urgent
471
- ? { kind: "result", value: await container.readMappedSourceRanges(interval) }
472
- : await containerOrchestrator.inspect(intervalParams, originalRanges ? "source-ranges" : "packets");
470
+ const packets = await containerOrchestrator.inspect(intervalParams, originalRanges ? "source-ranges" : "packets");
473
471
  if (!map.isCurrent()) return [];
474
472
  if (packets.kind !== "result") continue;
475
473
  const input = originalRanges ? packets.value : new SegmentInputs({ index: packets.value, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
@@ -64,6 +64,13 @@ export function buildRunCommand({
64
64
  const audioCodecArgs = transcodeAudio
65
65
  ? ["-c:a", "aac", "-ac", "2", "-b:a", `${AUDIO_TRANSCODE_KBPS}k`]
66
66
  : ["-c:a", "copy"];
67
+ // Under `-copyts` FFmpeg trims a decoded stream at `-ss` PLUS its own idea of
68
+ // the file's start, ignoring `-seek_timestamp` (ffmpeg_demux.c, n8.1.2,
69
+ // `trim_start_us`): an absolute seek would be trimmed a second start later.
70
+ // On the keyframe grid its trim is therefore switched off on every input and
71
+ // the decoded soundtrack is cut here, on the picture file's clock, which is
72
+ // the clock every stream carries there.
73
+ const inputSeekArgs = keyframeGrid ? ["-noaccurate_seek"] : ["-accurate_seek"];
67
74
 
68
75
  const args = ["-hide_banner", "-nostats", "-loglevel", "error", "-progress", "pipe:1"];
69
76
  if (transcodeVideo && Array.isArray(videoEncoder.inputArgs)) {
@@ -71,16 +78,23 @@ export function buildRunCommand({
71
78
  }
72
79
  const inputStartSeconds = inputIndex === safeIndex ? startSeconds :
73
80
  publishedStartTime(timeline, inputIndex, segmentDurationSec);
74
- const seekSeconds = timeline.cutGrid === "keyframe"
75
- ? inputStartSeconds + sourceStartTime
76
- : inputStartSeconds;
81
+ // ONE CLOCK FOR EVERY INPUT SEEK. Each `-ss` below is an absolute timestamp
82
+ // of the file it seeks, given with `-seek_timestamp 1`: without it FFmpeg
83
+ // adds its own idea of the file's start time to the value, which is a second
84
+ // copy of a start this code already knows. Published positions begin at
85
+ // zero, so the picture's instant is the published time plus the picture
86
+ // file's start; keyframe times are on the same file clock; a soundtrack in
87
+ // another file is the same instant on that file's clock. Whether the cuts
88
+ // follow the source's keyframes or an even grid changes how the run ENDS
89
+ // and what its output timestamps are, never where its input is.
90
+ const seekAt = inputStartSeconds + sourceStartTime;
77
91
  const carriedKeyframe = timeline.cutGrid === "keyframe" && typeof timeline.sourceStartOf === "function"
78
92
  ? timeline.sourceStartOf(inputIndex)
79
93
  : null;
80
94
  const snappedKeyframe = Number.isFinite(carriedKeyframe)
81
95
  ? carriedKeyframe
82
96
  : (Array.isArray(keyframes?.times) && keyframes.times.length > 0
83
- ? nearestKeyframeAtOrBefore(keyframes.times, seekSeconds)
97
+ ? nearestKeyframeAtOrBefore(keyframes.times, seekAt)
84
98
  : null);
85
99
  const audioInputUrl =
86
100
  typeof audioInputUrlGiven === "string" && audioInputUrlGiven.length > 0
@@ -98,8 +112,8 @@ export function buildRunCommand({
98
112
  * order wrong would silently turn the audio file's seek into a trim of the
99
113
  * finished stream.
100
114
  *
101
- * @param {number} inputSeekSeconds - Where to start, on the PICTURE's
102
- * timeline. Translated to the soundtrack file's own here.
115
+ * @param {number} inputSeekSeconds - Where to start, on the PICTURE
116
+ * file's clock. Translated to the soundtrack file's own here.
103
117
  */
104
118
  const pushAudioInput = (inputSeekSeconds) => {
105
119
  if (!audioInputUrl) {
@@ -108,17 +122,17 @@ export function buildRunCommand({
108
122
  if (audioTimelineShift !== 0 && keyframeGrid) {
109
123
  args.push("-itsoffset", ffmpegSeconds(-audioTimelineShift));
110
124
  }
111
- const audioSeek = Math.max(0, inputSeekSeconds + audioTimelineShift);
112
- if (audioSeek > 0) {
113
- args.push("-accurate_seek", "-ss", ffmpegSeconds(audioSeek));
125
+ const audioSeek = inputSeekSeconds + audioTimelineShift;
126
+ if (audioSeek > audioFileStartTime) {
127
+ args.push("-seek_timestamp", "1", ...inputSeekArgs, "-ss", ffmpegSeconds(audioSeek));
114
128
  }
115
129
  args.push("-i", audioInputUrl);
116
130
  };
117
131
 
118
132
  if (snappedKeyframe !== null) {
119
- const residualSeconds = Math.max(0, seekSeconds - snappedKeyframe);
120
- if (snappedKeyframe > 0) {
121
- args.push("-ss", ffmpegSeconds(snappedKeyframe + seekLandingOffsetFor({ audioOnly, transcodeVideo, keyframes, reorderDepth }, snappedKeyframe)));
133
+ const residualSeconds = Math.max(0, seekAt - snappedKeyframe);
134
+ if (snappedKeyframe > sourceStartTime) {
135
+ args.push("-seek_timestamp", "1", ...inputSeekArgs, "-ss", ffmpegSeconds(snappedKeyframe + seekLandingOffsetFor({ audioOnly, transcodeVideo, keyframes, reorderDepth }, snappedKeyframe)));
122
136
  }
123
137
  args.push("-i", inputUrl);
124
138
  pushAudioInput(snappedKeyframe);
@@ -126,11 +140,11 @@ export function buildRunCommand({
126
140
  args.push("-ss", ffmpegSeconds(residualSeconds));
127
141
  }
128
142
  } else {
129
- if (seekSeconds > 0) {
130
- args.push("-accurate_seek", "-ss", ffmpegSeconds(seekSeconds));
143
+ if (seekAt > sourceStartTime) {
144
+ args.push("-seek_timestamp", "1", ...inputSeekArgs, "-ss", ffmpegSeconds(seekAt));
131
145
  }
132
146
  args.push("-i", inputUrl);
133
- pushAudioInput(seekSeconds);
147
+ pushAudioInput(seekAt);
134
148
  }
135
149
  if (!keyframeGrid) {
136
150
  if (startSeconds > 0) {
@@ -146,12 +160,20 @@ export function buildRunCommand({
146
160
  const publishedGrid = publishedGridFor(timeline);
147
161
  if (runEnd >= safeIndex && Array.isArray(publishedGrid) && publishedGrid[runEnd + 1] > 0) {
148
162
  const endsAt = publishedGrid[runEnd + 1];
149
- if (transcodeVideo) {
150
- args.push("-t", ffmpegSeconds(Math.max(0.1, endsAt - publishedGrid[safeIndex])));
163
+ if (keyframeGrid) {
164
+ // `-copyts` keeps the input's own clock up to the muxer, and an output
165
+ // `-to` is compared there, before `-output_ts_offset` moves it.
166
+ args.push("-to", ffmpegSeconds(endsAt + sourceStartTime));
151
167
  } else {
152
- args.push("-to", ffmpegSeconds(endsAt));
168
+ // The output clock starts at zero where this run begins, so the end is
169
+ // a length; an output `-to` would be read as a length from zero too and
170
+ // run past the interval by its own start.
171
+ args.push("-t", ffmpegSeconds(Math.max(0.1, endsAt - startSeconds)));
153
172
  }
154
173
  }
174
+ if (keyframeGrid && transcodeAudio && !servesAudioSeparately) {
175
+ audioCodecArgs.push("-af", `atrim=start=${ffmpegSeconds(seekAt)}`);
176
+ }
155
177
  if (audioOnly === true) {
156
178
  args.push("-vn", "-map", `0:a:${audioSourceTrackIndex}?`, ...audioCodecArgs);
157
179
  } else if (servesAudioSeparately) {
@@ -217,7 +239,7 @@ export function buildOriginalCommand(params) {
217
239
  if (!primary) throw new Error("The selected source track is absent from original input.");
218
240
  const url = source => new URL(`/encode-input/${inputToken}/${source.fileIndex}`, baseUrl).href;
219
241
  const localTimeline = { ...timeline, published: [from, to, to + (to - from)],
220
- boundaries: [from, to, to + (to - from)], sourceStartOf: () => timeline.sourceStartOf?.(startIndex) ?? from + primary.timeShiftSeconds };
242
+ boundaries: [from, to, to + (to - from)], sourceStartOf: () => primary.input.from ?? from + primary.timeShiftSeconds };
221
243
  const command = buildRunCommand({ ...params, startIndex: 0, endIndex: 0, timeline: localTimeline,
222
244
  videoSourceTrackIndex: primary.input.selections.find(selection => selection.track.type === "video")?.index ?? 0,
223
245
  reorderDepth: primary.input.selections.find(selection => selection.track.type === "video")?.track.reorderDepth ?? 0,
@@ -229,7 +251,7 @@ export function buildOriginalCommand(params) {
229
251
  // the inner muxer writes its edit list, moving B-picture PTS off the source.
230
252
  args.splice(args.indexOf("-f"), 0, "-avoid_negative_ts", "disabled");
231
253
  const formatOptions = args.indexOf("-segment_format_options");
232
- if (formatOptions >= 0) args[formatOptions + 1] += ":avoid_negative_ts=disabled";
254
+ if (formatOptions >= 0) args[formatOptions + 1] += ":avoid_negative_ts=disabled:movie_timescale=1000000";
233
255
  const numbering = args.indexOf("-segment_start_number");
234
256
  if (numbering < 0) throw new Error("The format cannot produce finite original-source segments.");
235
257
  args[numbering + 1] = String(startIndex);
@@ -600,7 +600,7 @@ export class MatroskaContainer extends Container {
600
600
  .filter(position => !video || position.track === video.trackNumber)
601
601
  .map(position => ({ at: position.clusterAt, seconds: point.ticks * layout.secondsPerTick })))
602
602
  .sort((left, right) => left.seconds - right.seconds || left.at - right.at);
603
- if (!points.length) return super.readSourceRanges(interval);
603
+ if (!points.length) return { kind: "needs-index", reason: "source-has-no-cue-map" };
604
604
  const preroll = Math.max(0, ...requested.map(track => track.seekPrerollSeconds ?? 0));
605
605
  const firstAt = (seconds, strict) => {
606
606
  let low = 0, high = points.length;
@@ -632,7 +632,7 @@ export class MatroskaContainer extends Container {
632
632
  [Math.max(0, this.fileSize - requestBytes), this.fileSize - 1]];
633
633
  // SeekHead may name top-level declarations after media clusters. Preserve
634
634
  // their original bytes instead of reconstructing declarations for FFmpeg.
635
- for (const at of [layout.infoAt, layout.tracksAt]) {
635
+ for (const at of [layout.infoAt, layout.tracksAt, layout.tagsAt, layout.chaptersAt, layout.attachmentsAt]) {
636
636
  if (at === null || at < layout.firstClusterAt) continue;
637
637
  const end = await elementEnd(at);
638
638
  if (end === null) return { kind: "terminal", reason: "source-declarations-are-unbounded" };
@@ -17,14 +17,28 @@
17
17
  * vendored 2.8.5: selections are sorted by priority only when one is inserted,
18
18
  * and `shufflePriority` then moves the selection just served to the back of the
19
19
  * whole non-zero group. Distinct numbers therefore order the list once and
20
- * round-robin it afterwards. So the ordering is kept HERE, by choosing what to
21
- * state at all, and the library is given only the distinction it honours:
22
- * non-zero for what is wanted now, zero for the speculative tail.
20
+ * round-robin it afterwards. The library honours one distinction: non-zero
21
+ * before zero. A wire with nothing left to take from the non-zero selections
22
+ * goes on to the zero ones, and within a selection it takes the earliest piece
23
+ * it holds.
24
+ *
25
+ * **So everything wanted is stated, and only the first class is non-zero.**
26
+ * The first class is the most urgent level that is still missing anything, at
27
+ * its highest priority, whatever its deadlines. Everything else that is not
28
+ * speculative is stated at zero, so it is fetched only by a wire that would
29
+ * otherwise stand idle. Stating only the earliest deadline, as this did after
30
+ * #95, left every peer that held none of that segment's pieces without a
31
+ * request: on Home Assistant, with three peers each holding every third piece
32
+ * and one complete slow peer, twelve segments took 184 s at 0.61 MiB/s against
33
+ * 18.3 s at 6.1 MiB/s when the whole band was stated
34
+ * (`research/download-translation-2026-10-07.md`, torrent-tv/meta#95).
35
+ * Speculative levels stay withheld while anything urgent is missing anywhere,
36
+ * because the link is shared.
23
37
  */
24
38
 
25
39
  import {
40
+ isConditional,
26
41
  piecesOf,
27
- selectionPriority,
28
42
  Urgency,
29
43
  urgencyName
30
44
  } from "../demand/index.js";
@@ -68,39 +82,40 @@ export class SwarmSelection {
68
82
  * torrent is still waiting for something urgent. The registry works it out
69
83
  * once and hands the same answer to every selection, because the link is
70
84
  * shared and the question is not a per-torrent one.
85
+ * @param {{ urgency: number, priority: number } | null} [options.firstClass] -
86
+ * The most urgent missing level and its highest priority across every
87
+ * torrent. Without it, this torrent's own.
71
88
  * @returns {{ stated: number, withdrawn: number }}
72
89
  */
73
- reconcile({ speculativeAllowed = true, maximumUrgency = Infinity, minimumPriority = 1, latestDeadlineAt = Infinity } = {}) {
74
- const windows=this.#register.windows();
75
- const missing=windows.filter(window=>!this.#isSatisfied(window));
76
- const urgency=missing.length?Math.min(...missing.map(window=>window.urgency)):null;
77
- const eligible=missing.filter(window=>window.urgency===urgency);
78
- const priority=eligible.length?Math.max(...eligible.map(window=>window.priority)):null;
79
- const ranked=eligible.filter(window=>window.priority===priority);
80
- const deadlineAt=ranked.length?Math.min(...ranked.map(window=>window.deadlineAt)):Infinity;
81
- const active=urgency===null||urgency>maximumUrgency||priority<minimumPriority||deadlineAt>latestDeadlineAt||(!speculativeAllowed&&urgency>=Urgency.TAIL)?[]:
82
- ranked.filter(window=>window.deadlineAt===deadlineAt);
90
+ reconcile({ speculativeAllowed = !this.hasUrgentMissing(), firstClass = null } = {}) {
91
+ const missing = this.#register.windows().filter(window => !this.#isSatisfied(window));
92
+ const first = firstClass ?? this.missingBand();
83
93
  /** @type {Map<string, { from: number, to: number, priority: number }>} */
84
94
  const wanted = new Map();
85
- for (const window of active) {
95
+ for (const priority of [1, 0]) {
96
+ const ranges = [];
97
+ for (const window of missing) {
98
+ if (!speculativeAllowed && isConditional(window.urgency)) continue;
99
+ // Speculative levels are never the first class: the library keeps
100
+ // zero-priority selections last and never rotates them above.
101
+ const inFirstClass = first !== null && !isConditional(window.urgency)
102
+ && window.urgency === first.urgency && window.priority >= first.priority;
103
+ if ((inFirstClass ? 1 : 0) !== priority) continue;
86
104
  const range = this.#piecesFor(window);
87
- if (!range) {
88
- continue;
89
- }
90
- // Merged by range and priority, not by claimant: two readers wanting
91
- // the same pieces are one instruction to the swarm.
92
- const key = `${range.from}-${range.to}-${selectionPriority(window.urgency)}`;
93
- wanted.set(key, { from: range.from, to: range.to, priority: selectionPriority(window.urgency) });
105
+ if (range) ranges.push(range);
106
+ }
107
+ // Merged within one priority: two readers wanting the same or adjacent
108
+ // pieces are one instruction, and an unchanged map restates nothing.
109
+ ranges.sort((a, b) => a.from - b.from);
110
+ const union = [];
111
+ for (const range of ranges) {
112
+ const previous = union.at(-1);
113
+ if (previous && range.from <= previous.to + 1) previous.to = Math.max(previous.to, range.to);
114
+ else union.push({ from: range.from, to: range.to, priority });
115
+ }
116
+ for (const range of union) wanted.set(`${range.from}-${range.to}-${priority}`, range);
94
117
  }
95
118
 
96
- // Public selections merge adjacent ranges. Publish their union so an
97
- // unchanged map does not repeatedly replace the library's merged range.
98
- const ordered=[...wanted.values()].sort((a,b)=>a.from-b.from);
99
- wanted.clear();
100
- const union=[];
101
- for(const range of ordered){const previous=union.at(-1);if(previous&&range.from<=previous.to+1)previous.to=Math.max(previous.to,range.to);else union.push({...range});}
102
- for(const range of union)wanted.set(`${range.from}-${range.to}-${range.priority}`,range);
103
-
104
119
  let withdrawn = 0;
105
120
  for (const [key, range] of [...this.#stated]) {
106
121
  if (wanted.has(key)) {
@@ -116,16 +116,18 @@ export function reconcileAll() {
116
116
  if (publishingSelections) return { torrents: live.size, speculativeAllowed: false, stated: 0, withdrawn: 0 };
117
117
  const entries = [...live];
118
118
  const speculativeAllowed = !entries.some((entry) => entry.selection.hasUrgentMissing());
119
- const bands=entries.map(entry=>entry.selection.missingBand()).filter(Boolean);
120
- const maximumUrgency=bands.length?Math.min(...bands.map(band=>band.urgency)):Infinity;
121
- const minimumPriority=bands.length?Math.max(...bands.filter(band=>band.urgency===maximumUrgency).map(band=>band.priority)):1;
122
- const latestDeadlineAt=bands.length?Math.min(...bands.filter(band=>band.urgency===maximumUrgency&&band.priority===minimumPriority).map(band=>band.deadlineAt)):Infinity;
119
+ // The first class across every torrent: the most urgent missing level and
120
+ // its highest priority. Each selection states it non-zero and the rest zero.
121
+ const bands = entries.map(entry => entry.selection.missingBand()).filter(Boolean);
122
+ const urgency = bands.length ? Math.min(...bands.map(band => band.urgency)) : null;
123
+ const firstClass = urgency === null ? null
124
+ : { urgency, priority: Math.max(...bands.filter(band => band.urgency === urgency).map(band => band.priority)) };
123
125
  let stated = 0;
124
126
  let withdrawn = 0;
125
127
  publishingSelections = true;
126
128
  try {
127
129
  for (const entry of entries) {
128
- const result = entry.selection.reconcile({ speculativeAllowed, maximumUrgency, minimumPriority, latestDeadlineAt });
130
+ const result = entry.selection.reconcile({ speculativeAllowed, firstClass });
129
131
  stated += result.stated;
130
132
  withdrawn += result.withdrawn;
131
133
  }