@torrent-tv/proxy 2.97.17 → 2.97.19

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.97.17",
3
+ "version": "2.97.19",
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
@@ -556,6 +556,9 @@ export async function startProxyServer({
556
556
  const known = segmentSourceRanges.get(rangesKey);
557
557
  if (known) { convert(known); continue; }
558
558
  if (waiting.has(zone.outputKey)) continue;
559
+ // What is resolved so far is stated before a lower zone is worked out
560
+ // (DownloadMaps, torrent-tv/meta#178).
561
+ map.workingOut?.(zone.priority, converted);
559
562
  if (direct) {
560
563
  // Asked per zone too: a soundtrack of an AVI is read from its own
561
564
  // packets, which the same index states (torrent-tv/meta#166).
@@ -99,8 +99,17 @@ export async function admitInput({ sources, reserve, readRanges }) {
99
99
  }
100
100
  }
101
101
 
102
- /** Respect stdin backpressure and release input on every completion path. */
103
- export async function writeAdmittedInput(input, stdin, { next = null } = {}) {
102
+ /**
103
+ * Respect stdin backpressure and release input on every completion path.
104
+ *
105
+ * `waiting(true)` and `waiting(false)` bracket each wait for the next input:
106
+ * the encoder has nothing to read then, and that time is not its work. Counted
107
+ * as work, a soundtrack copy fed a segment at a time read 0.43x while ffmpeg
108
+ * used 0.014 s of processor per second of film, and the start forecast charged
109
+ * the download a second time through it (Home Assistant 2026-10-12,
110
+ * torrent-tv/meta#178).
111
+ */
112
+ export async function writeAdmittedInput(input, stdin, { next = null, waiting = null } = {}) {
104
113
  if (input?.kind !== "result") throw new TypeError("Only complete input may be written to an encoder.");
105
114
  const originSeconds = input.originSeconds;
106
115
  const declarations = JSON.stringify(input.tracks.map(input => matroskaTrackIdentity(input.track)));
@@ -137,7 +146,14 @@ export async function writeAdmittedInput(input, stdin, { next = null } = {}) {
137
146
  }
138
147
  await flush();
139
148
  current.release();
140
- current = next ? await next() : null;
149
+ if (next) {
150
+ waiting?.(true);
151
+ try {
152
+ current = await next();
153
+ } finally {
154
+ waiting?.(false);
155
+ }
156
+ } else current = null;
141
157
  includeHeader = false;
142
158
  }
143
159
  stdin.end();
@@ -1206,7 +1206,7 @@ export class EncodeRuns {
1206
1206
  run.admittedInputKeys.set(nextIndex, this.#inputFailures.key(next, inputParameters));
1207
1207
  run.to = nextIndex++;
1208
1208
  return next;
1209
- } }).catch(error => {
1209
+ }, waiting: waits => { if (waits) run.inputWaitBegins(); else run.inputWaitEnds(); } }).catch(error => {
1210
1210
  this.#host.logger.warn(`encode input output=${session.outputKey} segment=${safeIndex} write failed: ${error.message}`);
1211
1211
  run.process?.stdin?.destroy();
1212
1212
  });
@@ -43,6 +43,8 @@ export class AviContainer extends Container {
43
43
  /** The index alone: undefined until read, null when the file states none. */
44
44
  #index = undefined;
45
45
  #layout = null;
46
+ /** The first RIFF's `idx1`: undefined until looked for, null when there is none. */
47
+ #idx1Chunk = undefined;
46
48
 
47
49
  async #readHeaders() {
48
50
  if (this.#headers) return this.#headers;
@@ -124,17 +126,40 @@ export class AviContainer extends Container {
124
126
  }
125
127
 
126
128
 
127
- /** The first RIFF's `movi` list and `idx1` chunk. */
129
+ /**
130
+ * The first RIFF's `movi` list, found without reading past it: the chunk
131
+ * after `movi` lies where the first RIFF ends, which on a cold start is a
132
+ * piece of its own off the swarm.
133
+ */
128
134
  async #topLevel() {
129
135
  if (this.#layout) return this.#layout;
130
136
  const header = await this.readRange(0, 11);
131
- const chunks = await riffChunks(this.readRange, 12, 8 + header.readUInt32LE(4));
132
- const movi = chunks.find(chunk => chunk.id === "LIST" && chunk.type === "movi");
137
+ const end = 8 + header.readUInt32LE(4);
138
+ const before = await riffChunks(this.readRange, 12, end, "movi");
139
+ const movi = before.find(chunk => chunk.id === "LIST" && chunk.type === "movi");
133
140
  if (!movi) throw new Error("AVI media list is absent.");
134
- this.#layout = { movi, idx1: chunks.find(chunk => chunk.id === "idx1") ?? null };
141
+ this.#layout = { movi, end, before };
135
142
  return this.#layout;
136
143
  }
137
144
 
145
+ /**
146
+ * The first RIFF's `idx1` chunk, which follows `movi`. Read only for a file
147
+ * with no OpenDML index, as FFmpeg reads it: a loaded OpenDML index marks
148
+ * the index loaded and `avi_load_index` is not called (avidec.c n6.1 and
149
+ * n8.1, `read_odml_index`). Looked for on every file, it cost a cold
150
+ * two-hour OpenDML AVI a round trip to the swarm: its index's own piece was
151
+ * asked for 4.4 s later than it could have been (Home Assistant 2026-10-12,
152
+ * torrent-tv/meta#178).
153
+ */
154
+ async #idx1() {
155
+ if (this.#idx1Chunk !== undefined) return this.#idx1Chunk;
156
+ const { movi, end, before } = await this.#topLevel();
157
+ const after = movi.end + ((movi.end - movi.start) & 1);
158
+ const chunks = [...before, ...(after < end ? await riffChunks(this.readRange, after, end) : [])];
159
+ this.#idx1Chunk = chunks.find(chunk => chunk.id === "idx1") ?? null;
160
+ return this.#idx1Chunk;
161
+ }
162
+
138
163
  /**
139
164
  * What the index states, read once; null when the file states no index.
140
165
  * A shortage of bytes or memory is thrown and nothing is kept, so the next
@@ -144,7 +169,8 @@ export class AviContainer extends Container {
144
169
  if (this.#index !== undefined) return this.#index;
145
170
  const tracks = await this.readTracks();
146
171
  const { streams } = await this.#readHeaders();
147
- const { movi, idx1 } = await this.#topLevel();
172
+ const { movi } = await this.#topLevel();
173
+ const idx1 = streams.some(stream => stream.indexChunks.length) ? null : await this.#idx1();
148
174
  const record = { dispose: () => this.#index?.dispose?.() };
149
175
  const allocation = this.packetMemory?.forRecord?.(record, "index") ?? this.packetMemory;
150
176
  const index = await readAviIndex({ read: this.readRange, fileSize: this.fileSize, movi, idx1, allocation,
@@ -74,8 +74,28 @@ export class DownloadMaps {
74
74
  }
75
75
  if (changed) await this.#emit(file);
76
76
  if (revision !== file.revision) return;
77
+ const isCurrent = () => this.#files.get(`${sourceKey}:${fileIndex}`) === file && revision === file.revision;
78
+ // WHAT IS RESOLVED IS STATED WHILE THE REST IS STILL BEING RESOLVED. A pass
79
+ // works the zones out in the order the swarm is to fetch them, and
80
+ // published only at its end it kept the first bytes from the swarm for as
81
+ // long as the far end of the film took: 14.6 s on a cold two-hour AVI, the
82
+ // swarm fetching nothing for 17.5 s while the encoder waited for its next
83
+ // piece (Home Assistant 2026-10-12, torrent-tv/meta#178). The swarm
84
+ // fetches the highest group still missing, so before the pass works out a
85
+ // zone of a lower priority than any before it, what it has resolved is
86
+ // published, with the zones still retained for what it has not reached. A
87
+ // pass that works nothing out — every pass over a film already worked out
88
+ // — states nothing early.
89
+ let stated = Number.POSITIVE_INFINITY;
90
+ const workingOut = (priority, zones) => {
91
+ if (!(priority < stated)) return;
92
+ stated = priority;
93
+ if (zones.length === 0 || !isCurrent()) return;
94
+ this.#setZones(file, withRetained(zones, retained));
95
+ this.#emit(file).catch(() => undefined);
96
+ };
77
97
  const resolved = await this.#resolvePlayback({ sourceKey, fileIndex, durationSeconds, zones: file.sourceZones,
78
- isCurrent: () => this.#files.get(`${sourceKey}:${fileIndex}`) === file && revision === file.revision });
98
+ isCurrent, workingOut });
79
99
  if (revision !== file.revision) return;
80
100
  this.#setZones(file, resolved);
81
101
  return this.#emit(file);
@@ -333,6 +353,19 @@ export class DownloadMaps {
333
353
  }
334
354
  }
335
355
 
356
+ /**
357
+ * Zones resolved so far, with the retained zones of every segment they do not
358
+ * yet resolve again: a segment's bytes are stated once, by its newest reading.
359
+ */
360
+ function withRetained(resolved, retained) {
361
+ const keyOf = zone => typeof zone.outputKey === "string" && Number.isSafeInteger(zone.index) ? `${zone.outputKey}:${zone.index}` : null;
362
+ const resolvedKeys = new Set(resolved.map(keyOf).filter(key => key !== null));
363
+ return [...resolved, ...retained.filter(zone => {
364
+ const key = keyOf(zone);
365
+ return key === null || !resolvedKeys.has(key);
366
+ })];
367
+ }
368
+
336
369
  /** @param {number} since */
337
370
  function emptyStats(since) {
338
371
  return { since, sent: 0, unchanged: 0, zones: [], deliveredMs: [], appliedMs: [],