@torrent-tv/proxy 2.95.9 → 2.95.11

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.9",
3
+ "version": "2.95.11",
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
@@ -494,7 +494,7 @@ export async function startProxyServer({
494
494
  });
495
495
  const indexMemory = new IndexMemory({
496
496
  reviseBudget: () => { void outputParts.machineBudget.revise().catch(error => logger.warn(`packet index budget: ${error.message}`)); },
497
- changed: () => { mediaReads.memoryChanged(); probeReads.memoryChanged(); }
497
+ changed: () => { mediaReads.memoryChanged(); probeReads.memoryChanged(); outputParts.encodeInputs?.memoryChanged(); }
498
498
  });
499
499
  const containerOver = async ({ sourceKey, fileIndex, requestId, packetInterval, demand }) => {
500
500
  const record = sourceRegistry.get(sourceKey);
@@ -531,6 +531,10 @@ export async function startProxyServer({
531
531
  onReadResult: async (statement, result, revision) => {
532
532
  if (revision.demand !== downloadMaps.epoch(sourceKey, fileIndex)) return;
533
533
  if (demand && !downloadMaps.wantsInterval(sourceKey, fileIndex, demand)) return;
534
+ if (statement === "keyframes" && result.kind === "result") {
535
+ keyframeTables.learn(params, { times: result.value?.times ?? null, tolerance: result.value?.tolerance ?? 0,
536
+ format: containerOrchestrator.known(sourceKey, fileIndex)?.formatName ?? "unrecognised" });
537
+ }
534
538
  const finished = mediaReads.record(params, statement, result, revision.storage, revision.memory);
535
539
  await downloadMaps.metadata({ sourceKey, fileIndex, statement: requestId ? `${statement}:${requestId}` : statement, result,
536
540
  ...(demand ? { priority: demand.priority, urgent: demand.urgent, deadlineAt: demand.deadlineAt, interval: demand, leadSeconds: demand.leadSeconds ?? 0 } : {}) });
@@ -5,6 +5,7 @@ import { admitOriginalInput } from "./OriginalInput.js";
5
5
  export class EncodeInputs {
6
6
  #requests = new Map();
7
7
  #revision = 0;
8
+ #memoryRevision = 0;
8
9
  #held = 0;
9
10
  #allowed = 0;
10
11
  #wanted = new Map();
@@ -43,7 +44,13 @@ export class EncodeInputs {
43
44
  allow(bytes) {
44
45
  const changed = this.#allowed !== Math.max(0, bytes);
45
46
  this.#allowed = Math.max(0, bytes);
46
- if (changed) this.#retryMemory();
47
+ if (changed) this.memoryChanged();
48
+ }
49
+
50
+ /** Metadata admission and input admission can change independently. */
51
+ memoryChanged() {
52
+ this.#memoryRevision++;
53
+ this.#retryMemory();
47
54
  }
48
55
 
49
56
  #retryMemory() {
@@ -112,6 +119,7 @@ export class EncodeInputs {
112
119
  #prepare(request) {
113
120
  request.pending = true;
114
121
  request.revision = this.#revision;
122
+ request.memoryRevision = this.#memoryRevision;
115
123
  request.promise = (async () => {
116
124
  const resolved = await this.#resolve(request.output, request.from, request.to);
117
125
  if (this.#requests.get(request.key) !== request) return;
@@ -133,7 +141,7 @@ export class EncodeInputs {
133
141
  return () => {
134
142
  if (!released) {
135
143
  released = true; this.#held -= bytes;
136
- queueMicrotask(() => this.#retryMemory());
144
+ queueMicrotask(() => this.memoryChanged());
137
145
  }
138
146
  };
139
147
  }
@@ -149,7 +157,9 @@ export class EncodeInputs {
149
157
  }).finally(() => {
150
158
  request.pending = false;
151
159
  if (request.result?.kind !== "needs-memory") this.#wanted.delete(request.key);
152
- if (this.#requests.get(request.key) === request && request.revision !== this.#revision &&
160
+ const changed = request.result?.kind === "needs-memory"
161
+ ? request.memoryRevision !== this.#memoryRevision : request.revision !== this.#revision;
162
+ if (this.#requests.get(request.key) === request && changed &&
153
163
  request.result?.kind !== "result" && request.result?.kind !== "terminal") this.#prepare(request);
154
164
  });
155
165
  }
@@ -986,7 +986,7 @@ export class EncodeRuns {
986
986
  if (Number.isInteger(index) && (index < safeIndex || index > run.to)) return null;
987
987
  let bytes = null;
988
988
  if (admittedInput && session.segmentFormat.id === "fmp4" && session.spec.carries !== "audio-only" &&
989
- session.spec.audio && !this.#host.servesAudioSeparately(session)) {
989
+ (admittedInput.original || session.spec.audio && !this.#host.servesAudioSeparately(session))) {
990
990
  try {
991
991
  const address = session.outputKey ?? "";
992
992
  const grid = session.timeline.published ?? session.timeline.boundaries;
@@ -9,7 +9,7 @@ export function presentationSegment(current, following, { from, to }) {
9
9
  const edit = own.edits.get(id);
10
10
  if (!edit) throw new Error("A segment track has no declared clock.");
11
11
  const samples = [...track.samples];
12
- if (track.kind === "soun" && next?.tracks.has(id)) {
12
+ if (next?.tracks.has(id)) {
13
13
  const nextEdit = next.edits.get(id);
14
14
  if (!nextEdit || nextEdit.timescale !== edit.timescale) throw new Error("Adjacent segment clocks differ.");
15
15
  for (const sample of next.tracks.get(id).samples) {
@@ -18,18 +18,33 @@ export function presentationSegment(current, following, { from, to }) {
18
18
  }
19
19
  const start = BigInt(Math.round(from * Number(edit.timescale)));
20
20
  const end = BigInt(Math.round(to * Number(edit.timescale)));
21
- const selected = samples.filter(sample => {
21
+ const wanted = samples.filter(sample => {
22
22
  const pts = sample.dts + sample.composition + edit.offset;
23
- return pts < end && pts + sample.duration > start;
23
+ return pts < end && (track.kind === "vide" ? pts >= start : pts + sample.duration > start);
24
24
  });
25
+ if (!wanted.length) throw new Error("A presentation segment lacks required media.");
26
+ // An open-GOP picture before the cut can follow the next CRA in decode
27
+ // order. Retain the reference pictures through its last required DTS;
28
+ // dropping the CRA would make the recovered leading pictures undecodable.
29
+ const lastDecode = wanted.reduce((last, sample) => sample.dts > last ? sample.dts : last, -1n);
30
+ const firstDecode = wanted.reduce((first, sample) => first === null || sample.dts < first ? sample.dts : first, null);
31
+ const selected = track.kind === "vide" ? samples.filter(sample =>
32
+ sample.dts >= firstDecode && sample.dts <= lastDecode && sample.dts + sample.composition + edit.offset >= start) : wanted;
25
33
  selected.sort((left, right) => left.dts < right.dts ? -1 : left.dts > right.dts ? 1 : 0);
26
- if (!selected.length) throw new Error("A presentation segment lacks required media.");
27
34
  tracks.push({ id, samples: selected });
28
35
  }
29
36
  const makeMoof = offset => box("moof", full("mfhd", 0, 0, u32(1)), ...tracks.map(track => {
30
- const entries = track.samples.map(sample => Buffer.concat([
31
- u32(Number(sample.duration)), u32(sample.bytes.length), u32(sample.flags), i32(Number(sample.composition))
32
- ]));
37
+ let decodeTime = track.samples[0].dts;
38
+ const entries = track.samples.map(sample => {
39
+ // Removing leading pictures can leave a gap in decode timestamps.
40
+ // trun advances by durations only; retain each original presentation
41
+ // timestamp relative to the decode clock actually written here.
42
+ const composition = sample.dts + sample.composition - decodeTime;
43
+ decodeTime += sample.duration;
44
+ return Buffer.concat([
45
+ u32(Number(sample.duration)), u32(sample.bytes.length), u32(sample.flags), i32(Number(composition))
46
+ ]);
47
+ });
33
48
  const payload = Buffer.concat([u32(track.samples.length), i32(offset), ...entries]);
34
49
  offset += track.samples.reduce((bytes, sample) => bytes + sample.bytes.length, 0);
35
50
  const clock = Buffer.alloc(8);
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { KeyframeTable } from "./container/KeyframeTable.js";
4
4
  import { isUnavailable } from "./container/unavailable.js";
5
+ import { IndexMemoryUnavailable } from "./container/memory-unavailable.js";
5
6
  import { logger as defaultLogger } from "../../utils/logger.js";
6
7
 
7
8
  export class KeyframeTables {
@@ -123,6 +124,8 @@ export class KeyframeTables {
123
124
  `keyframe index "${logName}": not downloaded yet after ${Date.now() - startedMs}ms — ` +
124
125
  "read again when pieces of the file arrive"
125
126
  );
127
+ } else if (error instanceof IndexMemoryUnavailable) {
128
+ this.#logger.info(`keyframe index "${logName}": waiting for ${error.bytes} additional allocation bytes`);
126
129
  } else {
127
130
  this.#logger.warn(
128
131
  `keyframe index "${logName}": the read failed after ${Date.now() - startedMs}ms — ` +
@@ -183,7 +186,10 @@ export class KeyframeTables {
183
186
  const table = await this.read(params);
184
187
  return { table, arrived: table.answered };
185
188
  } catch (error) {
186
- if (isUnavailable(error)) return { table: this.of(params), arrived: false };
189
+ if (isUnavailable(error) || error instanceof IndexMemoryUnavailable) {
190
+ const table = this.of(params);
191
+ return { table, arrived: table.answered };
192
+ }
187
193
  throw error;
188
194
  }
189
195
  }
@@ -11,6 +11,7 @@ import { ContainerTrack } from "../tracks/ContainerTrack.js";
11
11
  import { PacketIndex } from "./PacketIndex.js";
12
12
  import { openDmlPackets } from "./avi-open-dml.js";
13
13
  import { isUnavailable } from "./unavailable.js";
14
+ import { IndexMemoryUnavailable } from "./memory-unavailable.js";
14
15
  import { Mpeg4PictureTiming } from "./mpeg4-picture-timing.js";
15
16
  import { MpegElementaryIndex } from "./mpeg-elementary-index.js";
16
17
  import { RetainedReads } from "./RetainedReads.js";
@@ -25,6 +26,7 @@ export class AviContainer extends Container {
25
26
  #tracks = null;
26
27
  #packets = null;
27
28
  #scan = null;
29
+ #indexed = null;
28
30
  #declarations = null;
29
31
  #workTags = null;
30
32
 
@@ -108,7 +110,7 @@ export class AviContainer extends Container {
108
110
  }
109
111
 
110
112
 
111
- packetIndexBytes() { return (this.#packets ?? this.#scan?.index)?.allocatedBytes() ?? 0; }
113
+ packetIndexBytes() { return (this.#packets ?? this.#scan?.index ?? this.#indexed?.index)?.allocatedBytes() ?? 0; }
112
114
 
113
115
  static detect(head) {
114
116
  return isAvi(head);
@@ -221,6 +223,7 @@ export class AviContainer extends Container {
221
223
  async readPacketIndex(interval) {
222
224
  if (this.#packets) return this.#packets;
223
225
  if (this.#scan) return this.#readUnindexed(interval);
226
+ if (this.#indexed) return this.#readIndexed();
224
227
  const tracks = await this.readTracks();
225
228
  const { streams } = await this.#readHeaders();
226
229
  if (streams.some(stream => stream.indexChunks.length)) {
@@ -278,20 +281,30 @@ export class AviContainer extends Container {
278
281
  }
279
282
  if ((table.end - table.start) % 16 !== 0) throw new Error("AVI packet index entry is truncated.");
280
283
  const index = new PacketIndex({ packetMemory: this.packetMemory, deferMemory: true });
281
- try {
282
284
  const clocks = new Map();
283
285
  const timings = new Map();
286
+ if (tracks.some(track => ["video", "audio", "subtitle"].includes(track.type) && !(track.timeBase > 0))) {
287
+ throw new Error("AVI stream time base is invalid.");
288
+ }
284
289
  for (const track of tracks) {
285
290
  if (!["video", "audio", "subtitle"].includes(track.type)) continue;
286
- if (!(track.timeBase > 0)) throw new Error("AVI stream time base is invalid.");
287
291
  index.declareTrack(track.trackNumber, { type: track.type, codecId: track.codecId, codecRanges: streams[track.trackNumber].codecRanges });
288
292
  clocks.set(track.trackNumber, track.startTimeSeconds);
289
293
  }
290
- let offsetBase = null;
291
- for (let at = table.start; at < table.end; at += 16) {
294
+ this.#indexed = { index, clocks, timings, tracks, movi, table, offsetBase: null, at: table.start };
295
+ return this.#readIndexed();
296
+ }
297
+
298
+ async #readIndexed() {
299
+ const state = this.#indexed;
300
+ const { index, clocks, timings, tracks, movi, table } = state;
301
+ try {
302
+ index.flushPending();
303
+ for (; state.at < table.end;) {
304
+ const at = state.at;
292
305
  const entry = await this.readRange(at, at + 15);
293
306
  const chunkId = entry.toString("ascii", 0, 4);
294
- if (!/^[0-9]{2}(db|dc|wb)$/.test(chunkId)) continue;
307
+ if (!/^[0-9]{2}(db|dc|wb)$/.test(chunkId)) { state.at += 16; continue; }
295
308
  const id = Number(chunkId.slice(0, 2));
296
309
  const track = tracks.find(track => track.trackNumber === id);
297
310
  if (!track || !clocks.has(id)) throw new Error("AVI packet refers to an undeclared stream.");
@@ -300,18 +313,19 @@ export class AviContainer extends Container {
300
313
  const length = entry.readUInt32LE(12);
301
314
  if (length === 0) {
302
315
  if (track.type === "video") { index.extendLastPresentation(id, track.timeBase); clocks.set(id, clocks.get(id) + track.timeBase); }
316
+ state.at += 16;
303
317
  continue;
304
318
  }
305
- if (offsetBase === null) {
319
+ if (state.offsetBase === null) {
306
320
  for (const base of [movi.start, 0, movi.start + 4]) {
307
321
  const address = base + offset;
308
322
  if (address < movi.start + 4 || address + 8 + length > movi.end) continue;
309
323
  const probe = await this.readRange(address, address + 7);
310
- if (probe.toString("ascii", 0, 4) === chunkId && probe.readUInt32LE(4) === length) { offsetBase = base; break; }
324
+ if (probe.toString("ascii", 0, 4) === chunkId && probe.readUInt32LE(4) === length) { state.offsetBase = base; break; }
311
325
  }
312
- if (offsetBase === null) throw new Error("AVI packet index has no valid offset base.");
326
+ if (state.offsetBase === null) throw new Error("AVI packet index has no valid offset base.");
313
327
  }
314
- const address = offsetBase + offset;
328
+ const address = state.offsetBase + offset;
315
329
  if (address < movi.start + 4 || address + 8 + length > movi.end) throw new Error("AVI indexed packet exceeds its media list.");
316
330
  const probe = await this.readRange(address, address + 7);
317
331
  if (probe.toString("ascii", 0, 4) !== chunkId || probe.readUInt32LE(4) !== length) throw new Error("AVI packet index disagrees with its chunk header.");
@@ -321,15 +335,21 @@ export class AviContainer extends Container {
321
335
  await this.#appendPacket(index, timings, track, { pts, duration, keyframe: track.type !== "video" || chunkId.endsWith("db") || !!(flags & 0x10),
322
336
  ranges: [[address + 8, address + 8 + length - 1]] });
323
337
  clocks.set(id, pts + duration);
338
+ // The pending facts own this entry even if their allocation must wait.
339
+ state.at += 16;
324
340
  index.flushPending();
325
341
  }
326
342
  for (const timing of timings.values()) timing.elementary?.complete();
327
343
  index.flushPending();
328
344
  for (const id of clocks.keys()) index.complete(id);
329
345
  this.#packets = index;
346
+ this.#indexed = null;
330
347
  return index;
331
348
  } catch (error) {
332
- index.dispose();
349
+ if (!isUnavailable(error) && !(error instanceof IndexMemoryUnavailable)) {
350
+ index.dispose();
351
+ this.#indexed = null;
352
+ }
333
353
  throw error;
334
354
  }
335
355
  }