@torrent-tv/proxy 2.107.35 → 2.107.37

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.107.35",
3
+ "version": "2.107.37",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -125,7 +125,10 @@ export class Timeline {
125
125
  const shift = sourceStartTime - (base ?? 0);
126
126
  return {
127
127
  from: from === null ? this.publishedStartOf(fromIndex) + sourceStartTime : from + shift,
128
- to: to === null ? this.publishedStartOf(toIndex + 1) + sourceStartTime : to + shift
128
+ to: to === null ? this.publishedStartOf(toIndex + 1) + sourceStartTime : to + shift,
129
+ // The last piece ends where the source does, wherever its declared
130
+ // duration lands against the frames (`PacketIndex.inputFor`).
131
+ ...(toIndex === this.segmentCount - 1 ? { endsWithSource: true } : {})
129
132
  };
130
133
  }
131
134
 
@@ -11,12 +11,13 @@ export class SegmentInputs {
11
11
  this.#tracks = tracks;
12
12
  }
13
13
 
14
- forInterval({ from, to, tracks = this.#tracks, mode = "transcode" }) {
14
+ forInterval({ from, to, tracks = this.#tracks, mode = "transcode", endsWithSource = false }) {
15
15
  const packets = [];
16
16
  const ranges = [];
17
17
  for (const track of tracks) {
18
18
  if (!["audio", "video"].includes(track.type)) continue;
19
- const input = this.#index.inputFor({ trackId: track.trackNumber, from, to, mode: typeof mode === "function" ? mode(track) : mode });
19
+ const input = this.#index.inputFor({ trackId: track.trackNumber, from, to,
20
+ mode: typeof mode === "function" ? mode(track) : mode, endsWithSource });
20
21
  if (input.kind !== "result") return { ...input, trackId: track.trackNumber };
21
22
  packets.push({ track, ...input });
22
23
  ranges.push(...input.ranges);
@@ -175,8 +175,20 @@ export class PacketIndex {
175
175
  track.coveredThrough = seconds;
176
176
  }
177
177
 
178
- /** The same calculation is used to order source bytes and admit an encode. */
179
- inputFor({ trackId, from, to, mode = "transcode" }) {
178
+ /**
179
+ * The same calculation is used to order source bytes and admit an encode.
180
+ *
181
+ * `endsWithSource` says this is the output's last piece: it ends where the
182
+ * source ends, and a copy of it needs no keyframe at `to`. Its `to` is the
183
+ * file's declared duration, and the track's end, its last frame's start plus
184
+ * that frame's duration added in floating point, can land past it: 314.96 s
185
+ * declared, 314.88 + 0.04 = 314.96000000000004 indexed. Compared with that
186
+ * end, the last piece of a copied MP4 was refused, the viewer got HTTP 500 at
187
+ * the end of the film and reopening near the end was refused at once (Home
188
+ * Assistant 2026-10-26, `2026-08-03 QUICK RELIEF 0364 BY KARA_C.mp4`,
189
+ * torrent-tv/meta#239).
190
+ */
191
+ inputFor({ trackId, from, to, mode = "transcode", endsWithSource = false }) {
180
192
  this.flushPending();
181
193
  if (!["copy", "transcode"].includes(mode)) throw new TypeError("Unknown segment input mode.");
182
194
  if (!Number.isFinite(from) || !Number.isFinite(to) || from < 0 || to <= from) {
@@ -223,9 +235,13 @@ export class PacketIndex {
223
235
  : { kind: "needs-index", trackId, from: startAt, to };
224
236
  let endsAtKeyframe = false;
225
237
  if (mode === "copy") endsAtKeyframe = packets.someAtTime(to, index => packets.keyframeAt(index));
226
- if (mode === "copy" && (packets.ptsAt(first) !== from ||
227
- (!endsAtKeyframe && (!track.complete || to < this.boundsOf(trackId).end)))) {
228
- return { kind: "terminal", reason: "video-copy-cut-is-not-a-keyframe" };
238
+ const reachesEnd = track.complete && (endsWithSource || to >= this.boundsOf(trackId).end);
239
+ if (mode === "copy" && (packets.ptsAt(first) !== from || (!endsAtKeyframe && !reachesEnd))) {
240
+ const bounds = track.complete ? this.boundsOf(trackId) : null;
241
+ return { kind: "terminal", reason: "video-copy-cut-is-not-a-keyframe",
242
+ message: `copy of ${from}..${to}${endsWithSource ? " (the last piece)" : ""} begins at the keyframe of ` +
243
+ `${packets.ptsAt(first)}, ${endsAtKeyframe ? "ends at a keyframe" : "ends at no keyframe"}; ` +
244
+ (bounds ? `the track ends at ${bounds.end}` : "the track is not indexed to its end yet") };
229
245
  }
230
246
  }
231
247
  // A transcoder needs the first audio packet at or after the cut to flush
@@ -248,7 +264,7 @@ export class PacketIndex {
248
264
  decodeFrom: packets.ptsAt(first),
249
265
  from,
250
266
  to,
251
- ...(track.complete && to >= this.boundsOf(trackId).end ? { sourceEndSeconds: this.boundsOf(trackId).end } : {}),
267
+ ...(track.complete && (endsWithSource || to >= this.boundsOf(trackId).end) ? { sourceEndSeconds: this.boundsOf(trackId).end } : {}),
252
268
  packets: input
253
269
  };
254
270
  }
@@ -286,19 +286,30 @@ export class DownloadMaps {
286
286
  viewersChanged() {
287
287
  const sent = [];
288
288
  for (const file of this.#files.values()) {
289
- const own = [...file.metadata].filter(([, metadata]) => metadata.owner === "file");
290
- if (own.length === 0) continue;
291
- const wanted = this.#fileWanted(file.sourceKey, file.fileIndex) === true;
292
- if (file.ownReadsWanted === wanted) continue;
293
- file.ownReadsWanted = wanted;
294
- this.#log?.(`download map ${file.sourceKey}:${file.fileIndex}: ${own.length} read(s) of the file itself ` +
295
- `${wanted ? "stated again — a viewer holds the file" : "withheld — no present viewer holds the file"} ` +
296
- `[${own.map(([statement]) => statement).join(", ")}]`);
289
+ if (![...file.metadata.values()].some(metadata => metadata.owner === "file")) continue;
290
+ if (file.ownReadsWanted === (this.#fileWanted(file.sourceKey, file.fileIndex) === true)) continue;
297
291
  sent.push(this.#emit(file));
298
292
  }
299
293
  return Promise.all(sent);
300
294
  }
301
295
 
296
+ /**
297
+ * Whether a present viewer holds this file, decided where the map is built
298
+ * and said when it changes for a file that has reads of its own.
299
+ *
300
+ * @returns {boolean}
301
+ */
302
+ #ownReadsWanted(file) {
303
+ const wanted = this.#fileWanted(file.sourceKey, file.fileIndex) === true;
304
+ const own = [...file.metadata].filter(([, metadata]) => metadata.owner === "file").map(([statement]) => statement);
305
+ if (own.length && wanted !== file.ownReadsWanted && (file.ownReadsWanted !== undefined || !wanted)) {
306
+ this.#log?.(`download map ${file.sourceKey}:${file.fileIndex}: ${own.length} read(s) of the file itself ` +
307
+ `${wanted ? "stated again — a viewer holds the file" : "withheld — no present viewer holds the file"} [${own.join(", ")}]`);
308
+ }
309
+ file.ownReadsWanted = wanted;
310
+ return wanted;
311
+ }
312
+
302
313
  forget(sourceKey, fileIndex, { keepPreparation = false } = {}) {
303
314
  const file = this.#file(sourceKey, fileIndex);
304
315
  this.#setZones(file, []);
@@ -347,8 +358,7 @@ export class DownloadMaps {
347
358
  // Compared and sent as the torrent is to act on it: the playback zones
348
359
  // packed into as few windows as say the same (`coalescedZones`).
349
360
  const zones = [...this.#packedPlayback(file)];
350
- const ownReadsWanted = this.#fileWanted(file.sourceKey, file.fileIndex) === true;
351
- file.ownReadsWanted = ownReadsWanted;
361
+ const ownReadsWanted = this.#ownReadsWanted(file);
352
362
  for (const { ranges, requestId, priority, urgent, deadlineAt, order, owner } of file.metadata.values()) {
353
363
  if (owner === "file" && !ownReadsWanted) continue;
354
364
  for (const [byteStart, byteEnd] of ranges) {