@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
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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,
|
|
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
|
|
102
|
-
*
|
|
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 =
|
|
112
|
-
if (audioSeek >
|
|
113
|
-
args.push("-
|
|
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,
|
|
120
|
-
if (snappedKeyframe >
|
|
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 (
|
|
130
|
-
args.push("-
|
|
143
|
+
if (seekAt > sourceStartTime) {
|
|
144
|
+
args.push("-seek_timestamp", "1", ...inputSeekArgs, "-ss", ffmpegSeconds(seekAt));
|
|
131
145
|
}
|
|
132
146
|
args.push("-i", inputUrl);
|
|
133
|
-
pushAudioInput(
|
|
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 (
|
|
150
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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
|
|
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.
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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 =
|
|
74
|
-
const
|
|
75
|
-
const
|
|
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
|
|
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 (
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
const
|
|
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,
|
|
130
|
+
const result = entry.selection.reconcile({ speculativeAllowed, firstClass });
|
|
129
131
|
stated += result.stated;
|
|
130
132
|
withdrawn += result.withdrawn;
|
|
131
133
|
}
|