@torrent-tv/proxy 2.97.21 → 2.97.23

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.21",
3
+ "version": "2.97.23",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -123,6 +123,8 @@ export class EncodeRun {
123
123
  */
124
124
  #pendingAtStop = null;
125
125
  #closedTimings = new Map();
126
+ /** When the encoder named each closed piece on its channel, until it is published. */
127
+ #namedAt = new Map();
126
128
  #publicationError = null;
127
129
  #publicationCode = null;
128
130
  #inputTruncated = false;
@@ -622,6 +624,7 @@ export class EncodeRun {
622
624
  if (name.length === 0) {
623
625
  continue;
624
626
  }
627
+ this.#namedAt.set(name, this.now());
625
628
  if (this.#publicationError) continue;
626
629
  if (this.#stopping) {
627
630
  // Flushed by our stop. Never published; it tells where the piece closed
@@ -681,7 +684,8 @@ export class EncodeRun {
681
684
  // name a request can actually ask for.
682
685
  let served;
683
686
  try {
684
- served = this.onClosed(name, following, this.#closedTimings.get(name) ?? null, { tentative });
687
+ served = this.onClosed(name, following, this.#closedTimings.get(name) ?? null,
688
+ { tentative, namedAt: this.#namedAt.get(name) ?? null });
685
689
  } catch (error) {
686
690
  if (quiet) {
687
691
  // A piece closed before our stop that cannot be proved whole is not
@@ -702,7 +706,10 @@ export class EncodeRun {
702
706
  } finally {
703
707
  // A piece not proved whole on its own keeps its timing for the file
704
708
  // after it.
705
- if (!tentative || served) this.#closedTimings.delete(name);
709
+ if (!tentative || served) {
710
+ this.#closedTimings.delete(name);
711
+ this.#namedAt.delete(name);
712
+ }
706
713
  }
707
714
 
708
715
  if (!served) return false;
@@ -1121,7 +1121,7 @@ export class EncodeRuns {
1121
1121
  // from it — a re-encoded picture, or sound served as its own output. Such
1122
1122
  // a piece is published on its own line (torrent-tv/meta#178).
1123
1123
  publishesAlone: this.publishesAlone(session),
1124
- onClosed: (name, following, timing, { tentative = false } = {}) => {
1124
+ onClosed: (name, following, timing, { tentative = false, namedAt = null } = {}) => {
1125
1125
 
1126
1126
  const index = session.segmentFormat.segmentIndexFromName(
1127
1127
  session.segmentFormat.servedNameOf?.(name) ?? name);
@@ -1131,6 +1131,8 @@ export class EncodeRuns {
1131
1131
  if (Number.isInteger(index) && (index < safeIndex || index > run.to)) return null;
1132
1132
  let bytes = null;
1133
1133
  const address = session.outputKey ?? "";
1134
+ // Read before anything rewrites it: when the encoder finished it.
1135
+ const writtenAt = this.#host.segmentFiles.writtenAtOf?.(address, name) ?? null;
1134
1136
  const fmp4 = admittedInput && session.segmentFormat.id === "fmp4" && session.spec.carries !== "audio-only";
1135
1137
  // The first piece of a run that read the picture from its keyframe:
1136
1138
  // its leading pictures are the ones the run before made, or none.
@@ -1157,6 +1159,14 @@ export class EncodeRuns {
1157
1159
  const published = this.#host.segmentFiles.publish(address, name, session.segmentFormat, { mediaRanges, bytes });
1158
1160
  if (!published) throw new Error(`Closed piece #${index} could not be stored.`);
1159
1161
  this.#leadingByOutput.get(address)?.delete(index);
1162
+ // WHERE A LATE PIECE'S TIME WENT: the encoder writing it, this process
1163
+ // getting to its name, or its publication waiting for the file after
1164
+ // it. A piece 5.7 s apart from the one before stopped a viewer inside a
1165
+ // running run, and nothing said which (torrent-tv/meta#178).
1166
+ const since = at => (Number.isFinite(at) ? `+${Math.round(at - run.startedAt)}ms` : "unknown");
1167
+ this.#host.logger.info(`encode-run #${run.from}..#${run.to} on ${address}: #${index} written by the encoder ${since(writtenAt)}, ` +
1168
+ `named ${since(namedAt)}, published ${since(run.now())} ` +
1169
+ (following ? `when ${following} closed` : tentative ? "on its own" : "at the run's end"));
1160
1170
  return published;
1161
1171
 
1162
1172
  },
@@ -304,9 +304,19 @@ export class AviContainer extends Container {
304
304
  }
305
305
 
306
306
  /**
307
- * Duration from the main AVI header, per the RIFF AVI specification: the
308
- * header states microseconds per frame and the total number of frames, and
309
- * their product is the length.
307
+ * The length of the film, by the rule FFmpeg's AVI demuxer reads it with
308
+ * (libavformat/avidec.c n8.1, the `strh` case): a stream whose samples vary
309
+ * in size — every picture, and sound such as MP3 declared with
310
+ * `dwSampleSize` 0 — lasts `dwLength` units of its own `dwScale/dwRate`, and
311
+ * a stream of fixed-size samples is given no length by its header. The film
312
+ * lasts as long as the longest stream that has one.
313
+ *
314
+ * The main header's frame count is not that length. OpenDML has
315
+ * `avih.dwTotalFrames` count the first RIFF only, so a film with a second
316
+ * RIFF read as 8136.0 s of its 8998.1 and its last fourteen minutes were
317
+ * neither listed nor reachable (Home Assistant 2026-10-13,
318
+ * torrent-tv/meta#178). FFmpeg skips `dmlh` as well. The main header is
319
+ * read only when no stream states a length.
310
320
  *
311
321
  * An AVI has no edit list and no timeline offset of any kind, so its start is
312
322
  * zero — a declaration of the format itself, not an absence.
@@ -319,11 +329,17 @@ export class AviContainer extends Container {
319
329
  }
320
330
  /** @type {import("./Container.js").ContainerMediaInfo} */
321
331
  const info = { format: this.formatName, durationSeconds: null, startTimeSeconds: 0 };
322
- const { avih } = await this.#readHeaders();
323
- const microsecondsPerFrame = avih?.readUInt32LE(0) ?? 0;
324
- const totalFrames = avih?.readUInt32LE(16) ?? 0;
325
- if (microsecondsPerFrame > 0 && totalFrames > 0) {
326
- info.durationSeconds = (microsecondsPerFrame * totalFrames) / 1e6;
332
+ const stated = (await this.readTracks())
333
+ .filter(track => ["video", "audio"].includes(track.type) && (track.type === "video" || track.sampleSize === 0))
334
+ .map(track => track.durationSeconds)
335
+ .filter(seconds => seconds > 0);
336
+ if (stated.length > 0) {
337
+ info.durationSeconds = Math.max(...stated);
338
+ } else {
339
+ const { avih } = await this.#readHeaders();
340
+ const microsecondsPerFrame = avih?.readUInt32LE(0) ?? 0;
341
+ const totalFrames = avih?.readUInt32LE(16) ?? 0;
342
+ if (microsecondsPerFrame > 0 && totalFrames > 0) info.durationSeconds = (microsecondsPerFrame * totalFrames) / 1e6;
327
343
  }
328
344
  this.mediaInfo = info;
329
345
  return info;
@@ -363,10 +363,20 @@ export class MatroskaContainer extends Container {
363
363
  * Kept only once it has been read: a head that had not arrived used to leave
364
364
  * an empty answer here for the life of the container.
365
365
  *
366
+ * `Duration` is optional (RFC 9559 §5.1.2.4), and a file written as it was
367
+ * streamed states none. Such a film lasts to the end of its last frame, which
368
+ * the complete packet index knows: the plan asks for that index when no
369
+ * length is stated and then asks here again, and an answer kept from before
370
+ * the index ended the opening with `media-duration-unavailable`
371
+ * (torrent-tv/meta#178).
372
+ *
366
373
  * @returns {Promise<import("./Container.js").ContainerMediaInfo>}
367
374
  */
368
375
  async readMediaInfo() {
369
376
  if (this.mediaInfo) {
377
+ if (this.mediaInfo.durationSeconds === null && this.#packets) {
378
+ this.mediaInfo = { ...this.mediaInfo, durationSeconds: await this.#lastFrameEnd(this.mediaInfo.startTimeSeconds) };
379
+ }
370
380
  return this.mediaInfo;
371
381
  }
372
382
  /** @type {import("./Container.js").ContainerMediaInfo} */
@@ -394,10 +404,36 @@ export class MatroskaContainer extends Container {
394
404
  if (layout.firstClusterAt !== null) {
395
405
  info.startTimeSeconds = await clusterTimestampSeconds(reader, layout.firstClusterAt, layout);
396
406
  }
407
+ // `Duration` runs from the Segment's timestamp zero, and a muxer that keeps
408
+ // its source's timestamps (FFmpeg's does) puts the first Cluster later. The
409
+ // plan's timeline begins at that Cluster, as every other container states
410
+ // its length, so what lies before it is not the film's: counted in, a file
411
+ // starting at 5 s listed five seconds past its last frame
412
+ // (torrent-tv/meta#178).
413
+ if (info.durationSeconds !== null && info.startTimeSeconds > 0) {
414
+ const shown = info.durationSeconds - info.startTimeSeconds;
415
+ info.durationSeconds = shown > 0 ? shown : null;
416
+ }
417
+ if (info.durationSeconds === null && this.#packets) info.durationSeconds = await this.#lastFrameEnd(info.startTimeSeconds);
397
418
  this.mediaInfo = info;
398
419
  return info;
399
420
  }
400
421
 
422
+ /**
423
+ * How long after the start of the timeline the last picture or sound frame
424
+ * of the complete packet index ends; null when none does.
425
+ *
426
+ * @param {number | null} startSeconds
427
+ */
428
+ async #lastFrameEnd(startSeconds) {
429
+ const ends = (await this.readTracks())
430
+ .filter(track => ["video", "audio"].includes(track.type))
431
+ .map(track => this.#packets.boundsOf(track.trackNumber)?.end)
432
+ .filter(Number.isFinite);
433
+ const length = ends.length > 0 ? Math.max(...ends) - (startSeconds ?? 0) : 0;
434
+ return length > 0 ? length : null;
435
+ }
436
+
401
437
  /**
402
438
  * What the file states about the work: `Info/Title`, the `Tags` that apply
403
439
  * to the whole file, the titles of its chapters and tracks, and whether it
@@ -712,7 +748,9 @@ export class MatroskaContainer extends Container {
712
748
  }
713
749
  const index = await readMatroskaPackets({ readRange: this.readRange,
714
750
  fileSize: this.fileSize, portionBytes: this.portionBytes, layout, tracks,
715
- durationSeconds: info.durationSeconds, interval, state, packetMemory: this.packetMemory });
751
+ // The last frame ends where the film does, on the packets' own clock.
752
+ endSeconds: info.durationSeconds === null ? null : info.durationSeconds + (info.startTimeSeconds ?? 0),
753
+ interval, state, packetMemory: this.packetMemory });
716
754
  if (index.isComplete() && state.startAt === layout.firstClusterAt) {
717
755
  this.#packets = index;
718
756
  }
@@ -620,6 +620,16 @@ export class Mp4Container extends Container {
620
620
  info.startTimeSeconds = movieTimescale > 0
621
621
  ? Mp4Container.#emptyEditSeconds(moov, header, movieTimescale)
622
622
  : null;
623
+ // `mvhd` states the length of the longest track, and a track lasts as long
624
+ // as its edits, the empty one included (ISO/IEC 14496-12, 8.2.2 and 8.6.6).
625
+ // The film is shown from the end of that empty edit, and the plan's
626
+ // timeline begins there — as every other container states its length —
627
+ // so the edit is not the film's. Counted in, a file starting at 5 s listed
628
+ // five seconds past its last frame (torrent-tv/meta#178).
629
+ if (info.durationSeconds !== null && info.startTimeSeconds > 0) {
630
+ const shown = info.durationSeconds - info.startTimeSeconds;
631
+ info.durationSeconds = shown > 0 ? shown : null;
632
+ }
623
633
  return info;
624
634
  }
625
635
 
@@ -10,7 +10,7 @@ const CLUSTER = 0x1f43b675;
10
10
  const LEVEL_ONE = new Set([CLUSTER, 0x1c53bb6b, 0x114d9b74, 0x1549a966, 0x1654ae6b, 0x1043a770, 0x1941a469, 0x1254c367]);
11
11
 
12
12
  /** Index frame addresses by reading structural headers, never frame bodies. */
13
- export async function readMatroskaPackets({ readRange, fileSize, portionBytes, layout, tracks, durationSeconds, interval, packetMemory, state = {} }) {
13
+ export async function readMatroskaPackets({ readRange, fileSize, portionBytes, layout, tracks, endSeconds, interval, packetMemory, state = {} }) {
14
14
  const reader = new ElementReader({ read: readRange, fileSize, portionBytes });
15
15
  const declared = new Map(tracks.filter(track => ["video", "audio", "subtitle"].includes(track.type)).map(track => [track.trackNumber, track]));
16
16
  if (!state.packets) {
@@ -18,7 +18,7 @@ export async function readMatroskaPackets({ readRange, fileSize, portionBytes, l
18
18
  state.at ??= layout.firstClusterAt ?? layout.segmentEnd;
19
19
  }
20
20
  const packets = state.packets;
21
- if (intervalCovered(declared, packets, interval)) return buildIndex(declared, packets, durationSeconds, false, interval, packetMemory);
21
+ if (intervalCovered(declared, packets, interval)) return buildIndex(declared, packets, endSeconds, false, interval, packetMemory);
22
22
  let at = state.at;
23
23
  while (at < layout.segmentEnd) {
24
24
  const element = state.cluster?.element ?? await validHeader(reader, at, layout.segmentEnd);
@@ -80,14 +80,14 @@ export async function readMatroskaPackets({ readRange, fileSize, portionBytes, l
80
80
  cursor = child.end;
81
81
  state.cluster.cursor = cursor;
82
82
  state.cluster.clusterTicks = clusterTicks;
83
- if (intervalCovered(declared, packets, interval)) return buildIndex(declared, packets, durationSeconds, false, interval, packetMemory);
83
+ if (intervalCovered(declared, packets, interval)) return buildIndex(declared, packets, endSeconds, false, interval, packetMemory);
84
84
  }
85
85
  if (cursor <= at) throw new Error("Matroska packet walk did not advance.");
86
86
  at = element.end ?? cursor;
87
87
  state.at = at;
88
88
  state.cluster = null;
89
89
  }
90
- return buildIndex(declared, packets, durationSeconds, true, undefined, packetMemory);
90
+ return buildIndex(declared, packets, endSeconds, true, undefined, packetMemory);
91
91
  }
92
92
 
93
93
  function paddingForFrame(seconds, duration, count, frame) {
@@ -110,7 +110,7 @@ function intervalCovered(declared, packets, interval) {
110
110
  });
111
111
  }
112
112
 
113
- function buildIndex(declared, packets, durationSeconds, complete, interval, packetMemory) {
113
+ function buildIndex(declared, packets, endSeconds, complete, interval, packetMemory) {
114
114
  const index = new PacketIndex();
115
115
  for (const [id, track] of declared) {
116
116
  index.declareTrack(id, { type: track.type, codecId: track.codecId, codecRanges: track.codecRanges ?? [], prerollSeconds: track.seekPrerollSeconds ?? 0, reorderDepth: track.reorderDepth ?? 0 });
@@ -120,7 +120,7 @@ function buildIndex(declared, packets, durationSeconds, complete, interval, pack
120
120
  if (stream.durationAt(position) === 0 || stream.derivedDurationAt(position)) { needsFollowing = true; break; }
121
121
  }
122
122
  if (needsFollowing) {
123
- const endFor = followingPacketTime(stream, complete ? durationSeconds : null, packetMemory);
123
+ const endFor = followingPacketTime(stream, complete ? endSeconds : null, packetMemory);
124
124
  try { for (let position = 0; position < stream.length; position++) {
125
125
  if (stream.durationAt(position) !== 0 && !stream.derivedDurationAt(position)) continue;
126
126
  const pts = stream.ptsAt(position), end = endFor(pts);
@@ -28,6 +28,7 @@ export class MpegElementaryIndex {
28
28
  nalIndex: track.codecId === "h264" ? new H264ElementaryIndex(track, this.index)
29
29
  : track.codecId === "hevc" ? new HevcElementaryIndex(track, this.index) : null,
30
30
  picture: null, pendingStart: 0, lastPicture: -1, presentationBase: null, lastDts: null, referencePts: null,
31
+ lastGroup: -1, groupBegins: false, shownUntil: null,
31
32
  audioBytes: Buffer.alloc(0), audioStart: 0, audioPts: null, lastTimestamp: null, clockShift: 0,
32
33
  reservoir: new MpegAudioReservoir(), packetCount: 0,
33
34
  lastExtension: -1, requiresStamp: false });
@@ -97,6 +98,10 @@ export class MpegElementaryIndex {
97
98
  if ((code === 0xb3 || code === 0xb8) && offset > state.lastPicture) {
98
99
  if (state.pendingStart === null) state.pendingStart = offset;
99
100
  }
101
+ if (code === 0xb8 && offset > state.lastGroup) {
102
+ state.lastGroup = offset;
103
+ state.groupBegins = true;
104
+ }
100
105
  if (code !== 0 || at + 6 > bytes.length || offset <= state.lastPicture) continue;
101
106
  if (!(state.track.fps > 0)) throw new Error("MPEG video cadence is not declared.");
102
107
  const reference = (bytes[at + 4] << 2) | (bytes[at + 5] >> 6);
@@ -107,7 +112,16 @@ export class MpegElementaryIndex {
107
112
  const stamp = stampAt(state, offset);
108
113
  if (state.requiresStamp && !stamp) throw new Error("MPEG repeated frames require an explicit following timestamp.");
109
114
  state.requiresStamp = false;
115
+ // `temporal_reference` counts the pictures of one group of pictures in
116
+ // the order they are shown, from 0 (ISO/IEC 13818-2, 6.3.9), so a new
117
+ // group shows its first picture where the last one shown ended. A muxer
118
+ // stamps a PES packet, not every picture: a group whose first picture
119
+ // came unstamped was placed on the previous group's base, up to a group
120
+ // earlier, and the index refused to go back: none of four program
121
+ // streams FFmpeg wrote with more than one group opened (torrent-tv/meta#178).
110
122
  if (stamp) state.presentationBase = stamp.pts - reference / state.track.fps;
123
+ else if (state.groupBegins && state.shownUntil !== null) state.presentationBase = state.shownUntil;
124
+ state.groupBegins = false;
111
125
  if (state.presentationBase === null) throw new Error("MPEG picture has no presentation timestamp.");
112
126
  const pts = stamp?.pts ?? state.presentationBase + reference / state.track.fps;
113
127
  const dts = stamp?.dts ?? (state.lastDts === null ? pts : state.lastDts + 1 / state.track.fps);
@@ -129,6 +143,7 @@ export class MpegElementaryIndex {
129
143
  const { start, ...packet } = state.picture;
130
144
  if (!(end > start)) throw new Error("MPEG picture has no bytes.");
131
145
  this.index.append(state.track.trackNumber, { ...packet, ranges: addresses(state, start, end) });
146
+ state.shownUntil = Math.max(state.shownUntil ?? -Infinity, packet.pts + packet.duration);
132
147
  state.mappings = state.mappings.filter(mapping => mapping.to > end);
133
148
  }
134
149
 
@@ -594,6 +594,23 @@ export class SegmentStore {
594
594
  return readFileSync(path.join(this.#root, directoryNameFor(key), makingName));
595
595
  }
596
596
 
597
+ /**
598
+ * When the encoder last wrote a file it has closed under its working name,
599
+ * on the kernel's clock and so apart from when this process got to it; null
600
+ * when it is not there.
601
+ *
602
+ * @param {string} key
603
+ * @param {string} makingName
604
+ * @returns {number | null}
605
+ */
606
+ writtenAtOf(key, makingName) {
607
+ try {
608
+ return statSync(path.join(this.#root, directoryNameFor(key), makingName)).mtimeMs;
609
+ } catch {
610
+ return null;
611
+ }
612
+ }
613
+
597
614
  /**
598
615
  * The media intervals a stored piece holds, read once from its bytes and kept
599
616
  * with them, or undefined where its format reads none or it is not here.