@torrent-tv/proxy 2.87.18 → 2.87.20

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/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 2.87.20
2
+
3
+ - **Fix**: Expose completed files through the worker pool adapter so source HTTP reads use the assembled file; open it before sending headers and return to torrent reads if it has already been removed.
4
+
5
+ ## 2.87.19
6
+
7
+ - **Fix**: Return shared source bytes after whole-file assembly and retain the buffer held by concurrent readers instead of replacing it; fail malformed fragments explicitly rather than skipping source bytes.
8
+ - **Fix**: Establish decode timestamp ordering in a private preceding cut before a copied-video restart, preserving leading reordered frames at the requested boundary.
9
+ - **Fix**: Carry variable-rate video presentation across cuts only when decode timestamps prove continuity, and predict unproduced cuts in the measured track clock within the readiness integral.
10
+
1
11
  ## 2.87.18
2
12
 
3
13
  - **Fix**: Interpret variable-rate video sample presentation holds and movie-clock resolution as media coverage; project prepared intervals onto the browser's reported HLS timestamp offsets per track.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.87.18",
3
+ "version": "2.87.20",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -6,7 +6,7 @@
6
6
  * and HTTP 200 for full-file requests.
7
7
  */
8
8
 
9
- import { createReadStream } from "node:fs";
9
+ import { open } from "node:fs/promises";
10
10
  import { parseRange } from "../../utils/parse-range.js";
11
11
  import { logger } from "../../utils/logger.js";
12
12
 
@@ -107,9 +107,10 @@ function wholeFileFor(torrentPool, sourceKey, fileIndex) {
107
107
  * @param {import("fastify").FastifyRequest} req
108
108
  * @param {import("fastify").FastifyReply} reply
109
109
  * @param {{ path: string, length: number, name: string }} file
110
+ * @param {import("node:fs/promises").FileHandle} handle - Already open; closed for HEAD.
110
111
  * @returns {Promise<void> | void}
111
112
  */
112
- function serveWholeFile(req, reply, file) {
113
+ function serveWholeFile(req, reply, file, handle) {
113
114
  const disposition = `inline; filename*=UTF-8''${encodeURIComponent(file.name)}`;
114
115
  if (req.method === "HEAD") {
115
116
  reply.hijack();
@@ -128,13 +129,13 @@ function serveWholeFile(req, reply, file) {
128
129
  const range = parseRange(req.headers.range, file.length);
129
130
  if (!range) {
130
131
  reply.header("Content-Length", String(file.length));
131
- return reply.send(createReadStream(file.path));
132
+ return reply.send(handle.createReadStream());
132
133
  }
133
134
  const { start, end } = range;
134
135
  reply.code(206);
135
136
  reply.header("Content-Length", String(end - start + 1));
136
137
  reply.header("Content-Range", `bytes ${start}-${end}/${file.length}`);
137
- return reply.send(createReadStream(file.path, { start, end }));
138
+ return reply.send(handle.createReadStream({ start, end }));
138
139
  }
139
140
 
140
141
  export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool, noteInputBytes = null }) {
@@ -157,7 +158,25 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
157
158
  // nothing the torrent would have told us.
158
159
  const whole = wholeFileFor(torrentPool, sourceKey, fileIndex);
159
160
  if (whole) {
160
- return serveWholeFile(req, reply, whole);
161
+ let handle;
162
+ try {
163
+ // Open before committing headers: the disk owner may have removed an
164
+ // announced file. An open descriptor also preserves a live POSIX read.
165
+ handle = await open(whole.path, "r");
166
+ } catch (error) {
167
+ if (error.code !== "ENOENT") throw error;
168
+ const key = `${sourceKey.slice("torrent:".length).toLowerCase()}/${fileIndex}`;
169
+ if (torrentPool.wholeFiles.get(key) === whole) torrentPool.wholeFiles.delete(key);
170
+ }
171
+ if (handle) {
172
+ if (req.method === "HEAD") await handle.close();
173
+ try {
174
+ return serveWholeFile(req, reply, whole, handle);
175
+ } catch (error) {
176
+ await handle.close().catch(() => undefined);
177
+ throw error;
178
+ }
179
+ }
161
180
  }
162
181
 
163
182
  let torrent;
@@ -832,7 +832,12 @@ export class EncodeRuns {
832
832
  // Why this encoder exists, recorded with its argument list. It used to be
833
833
  // handed to a separate `start` call; there is no separate call now.
834
834
  because,
835
- onClosed: (name) => this.#host.segmentFiles.publish(session.outputKey ?? "", name, session.segmentFormat),
835
+ onClosed: (name) => {
836
+ const index = session.segmentFormat.segmentIndexFromName(
837
+ session.segmentFormat.servedNameOf?.(name) ?? name);
838
+ if (Number.isInteger(index) && index < safeIndex) return null;
839
+ return this.#host.segmentFiles.publish(session.outputKey ?? "", name, session.segmentFormat);
840
+ },
836
841
  onEnded: (ended) => this.noteRunEnded(session, run, ended)
837
842
  });
838
843
  // THE ONE FAULT THAT IS OTHERWISE SILENT, asked before this run produces a
@@ -358,6 +358,11 @@ export function buildRunCommand({
358
358
  // the cut points and, when re-encoding, as the keyframes to force — one
359
359
  // list, so the two cannot drift apart.
360
360
  const explicitTimes = segmentFormat.explicitTimesMuxerArgs?.() ?? null;
361
+ // Matroska seeks begin without the preceding decode timestamps. Copy one
362
+ // preceding cut privately so reordered frames have established DTS before
363
+ // the first requested cut. It is never published over another run's output.
364
+ const inputIndex = !transcodeVideo && !audioOnly && explicitTimes && safeIndex > 0
365
+ ? safeIndex - 1 : safeIndex;
361
366
  // A COPY is cut by this list whatever grid it ended up on. Even when no
362
367
  // keyframe index could be read and the boundaries are a plain grid, saying
363
368
  // them outright is what keeps the playlist and the muxer agreeing — ffmpeg
@@ -382,7 +387,7 @@ export function buildRunCommand({
382
387
  // playlist and its own cuts agree from the start. What they may not do is
383
388
  // move the cuts of a session whose playlist is already being read.
384
389
  const gridCutTimes = explicitTimes && (!transcodeVideo || timeline.cutGrid === "keyframe")
385
- ? segmentCutTimesFrom(publishedGridFor(timeline), safeIndex)
390
+ ? segmentCutTimesFrom(publishedGridFor(timeline), inputIndex)
386
391
  : null;
387
392
  // Cut times are stated on the grid, for both branches.
388
393
  //
@@ -443,9 +448,11 @@ export function buildRunCommand({
443
448
  // back on to reach it; on the uniform grid it is a plain offset. This
444
449
  // follows the GRID, not whether the video is re-encoded — a variant cut on
445
450
  // the source's keyframes has to seek to them like the copy it accompanies.
451
+ const inputStartSeconds = inputIndex === safeIndex ? startSeconds :
452
+ publishedStartTime(timeline, inputIndex, segmentDurationSec);
446
453
  const seekSeconds = timeline.cutGrid === "keyframe"
447
- ? startSeconds + sourceStartTime
448
- : startSeconds;
454
+ ? inputStartSeconds + sourceStartTime
455
+ : inputStartSeconds;
449
456
  // Two-step seek when we have a real keyframe map: jump to a KNOWN-valid
450
457
  // keyframe (coarse, before -i — safe because WE sourced it from ffprobe,
451
458
  // not the container's own on-the-fly seek/index) and trim the short
@@ -477,7 +484,7 @@ export function buildRunCommand({
477
484
  // The search stays for a grid restored without its source clock, which is the
478
485
  // only case that has nothing to carry.
479
486
  const carriedKeyframe = timeline.cutGrid === "keyframe" && typeof timeline.sourceStartOf === "function"
480
- ? timeline.sourceStartOf(safeIndex)
487
+ ? timeline.sourceStartOf(inputIndex)
481
488
  : null;
482
489
  const snappedKeyframe = Number.isFinite(carriedKeyframe)
483
490
  ? carriedKeyframe
@@ -689,7 +696,7 @@ export function buildRunCommand({
689
696
  "-segment_time_delta",
690
697
  "0.05",
691
698
  "-segment_start_number",
692
- String(safeIndex),
699
+ String(inputIndex),
693
700
  // THE ENCODER SAYS WHEN A PIECE IS FINISHED, on a channel of its own.
694
701
  //
695
702
  // Measured on the addon host 2026-09-05: a name appears in this list when
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import {
14
+ continuePresentationRanges,
14
15
  rebaseSegmentDecodeTimes,
15
16
  neutralizeEmptyEdits,
16
17
  readPresentationRanges,
@@ -287,7 +288,8 @@ export const fmp4Format = {
287
288
  return readPresentationRanges(Buffer.concat([initBytes, served]));
288
289
  },
289
290
  clientMediaRanges(ranges, context) {
290
- return translatePresentationRanges(ranges, context.initBytes, context.timestampOffsetSeconds);
291
+ return translatePresentationRanges(continuePresentationRanges(ranges, context.nextRanges),
292
+ context.initBytes, context.timestampOffsetSeconds);
291
293
  },
292
294
 
293
295
  /**
@@ -53,7 +53,7 @@ import { mpegtsFormat } from "./mpegts.js";
53
53
  * { start: number, end: number }[] | undefined} [readMediaRanges] - Actual
54
54
  * served presentation intervals, or undefined until their header is available.
55
55
  * @property {(ranges: { start: number, end: number }[], context: {
56
- * initBytes: Buffer | null, timestampOffsetSeconds?: number }) =>
56
+ * initBytes: Buffer | null, timestampOffsetSeconds?: number, nextRanges?: Array<{ start: number, end: number }> }) =>
57
57
  * { start: number, end: number }[]} [clientMediaRanges] - Project parsed
58
58
  * presentation intervals onto the clock reported by this viewer's player.
59
59
  */
@@ -176,6 +176,7 @@ export function readPresentationRanges(raw) {
176
176
  const scales = readTrackTimescales(raw);
177
177
  const edits = readTrackEditOffsets(raw);
178
178
  const byTrack = new Map();
179
+ const decodeRanges = new Map();
179
180
  const defaultDurations = new Map();
180
181
  let trackId = null;
181
182
  let decodeTime = 0;
@@ -221,6 +222,10 @@ export function readPresentationRanges(raw) {
221
222
  if (!(duration > 0)) return;
222
223
  const position = (decodeTime + composition) / scale + (edits.get(trackId) ?? 0);
223
224
  ranges.push({ start: position, end: position + duration / scale });
225
+ const decode = decodeRanges.get(trackId) ?? [];
226
+ decode.push({ start: decodeTime / scale + (edits.get(trackId) ?? 0),
227
+ end: (decodeTime + duration) / scale + (edits.get(trackId) ?? 0) });
228
+ decodeRanges.set(trackId, decode);
224
229
  decodeTime += duration;
225
230
  }
226
231
  byTrack.set(trackId, ranges);
@@ -243,7 +248,10 @@ export function readPresentationRanges(raw) {
243
248
  // Movie edits quantize independent pieces to movie ticks. Preserve that
244
249
  // declared resolution at joins; it is not a playback buffer threshold.
245
250
  for (const range of merged) range.end += precision;
246
- return { id, ranges: merged };
251
+ const decoded = decodeRanges.get(id);
252
+ return { id, ranges: merged, kind: kinds.get(id), precision,
253
+ decodeStart: Math.min(...decoded.map(({ start }) => start)),
254
+ decodeEnd: Math.max(...decoded.map(({ end }) => end)) };
247
255
  });
248
256
  // A multiplexed segment is playable only where every declared track exists.
249
257
  const result = intersectPresentationTracks(mergedTracks);
@@ -251,6 +259,26 @@ export function readPresentationRanges(raw) {
251
259
  return result;
252
260
  }
253
261
 
262
+ /** A video frame can cross a file boundary when decoding remains continuous. */
263
+ export function continuePresentationRanges(ranges, nextRanges) {
264
+ const tracks = presentationTracks.get(ranges);
265
+ const next = presentationTracks.get(nextRanges);
266
+ if (!tracks || !next) return ranges;
267
+ const continued = tracks.map((track) => {
268
+ const following = next.find(({ id }) => id === track.id);
269
+ const held = track.ranges.map((range) => ({ ...range }));
270
+ if (track.kind === "vide" && following && held.length > 0 && following.ranges.length > 0 &&
271
+ Math.abs(track.decodeEnd - following.decodeStart) <= track.precision + following.precision +
272
+ Number.EPSILON * Math.max(1, following.decodeStart) * 8) {
273
+ held.at(-1).end = Math.max(held.at(-1).end, following.ranges[0].start);
274
+ }
275
+ return { ...track, ranges: held };
276
+ });
277
+ const result = intersectPresentationTracks(continued);
278
+ presentationTracks.set(result, continued);
279
+ return result;
280
+ }
281
+
254
282
  function intersectPresentationTracks(tracks) {
255
283
  return tracks.map(({ ranges }) => ranges).reduce((common, ranges) => common.flatMap((left) => ranges
256
284
  .map((right) => ({ start: Math.max(left.start, right.start), end: Math.min(left.end, right.end) }))
@@ -566,10 +566,12 @@ export class ViewerRequests {
566
566
  const trackRates = this.#trackRateReadings(measurement, output, now);
567
567
  const timeline = output.timeline;
568
568
  const trackName = output === session ? "video" : "audio";
569
- const segments = Array.from({ length: timeline?.segmentCount ?? 0 }, (_, index) => {
570
- const ranges = this.#host.segmentStore.mediaRangesOf(output.outputKey, index, {
569
+ const nativeRanges = Array.from({ length: timeline?.segmentCount ?? 0 }, (_, index) =>
570
+ this.#host.segmentStore.mediaRangesOf(output.outputKey, index, {
571
571
  startSeconds: timeline.publishedStartOf(index)
572
- });
572
+ }));
573
+ const segments = Array.from({ length: timeline?.segmentCount ?? 0 }, (_, index) => {
574
+ const ranges = nativeRanges[index];
573
575
  return {
574
576
  index,
575
577
  startSeconds: timeline.publishedStartOf(index),
@@ -577,6 +579,7 @@ export class ViewerRequests {
577
579
  mediaRanges: ranges && output.segmentFormat?.clientMediaRanges ?
578
580
  output.segmentFormat.clientMediaRanges(ranges, {
579
581
  initBytes: this.#host.segmentStore.initOf(output.outputKey),
582
+ nextRanges: nativeRanges[index + 1],
580
583
  timestampOffsetSeconds: viewerReading?.timestampOffsets?.[trackName]
581
584
  }) : ranges
582
585
  };
@@ -128,7 +128,7 @@ export function predictPlaybackReadiness(input = {}) {
128
128
  curve: rateCurve(track.readings, now),
129
129
  bitsPerMediaSecond: averageBitsPerMediaSecond(track, track.segments,
130
130
  new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : [])),
131
- segments: track.segments
131
+ segments: predictedMediaSegments(track.segments)
132
132
  .filter((segment) => Number.isInteger(segment?.index) &&
133
133
  Number.isFinite(segment?.startSeconds) && Number.isFinite(segment?.endSeconds) &&
134
134
  segment.endSeconds > segment.startSeconds && segment.endSeconds > position)
@@ -388,6 +388,25 @@ export function predictPlaybackReadiness(input = {}) {
388
388
  "minimum-safe-delay", preparedSegments, readiness.bufferedAtStart);
389
389
  }
390
390
 
391
+ /** Predict only unknown cuts in the same clock as the measured track. */
392
+ function predictedMediaSegments(segments) {
393
+ const anchors = segments.filter(({ mediaRanges }) => mediaRanges?.length > 0);
394
+ return segments.map((segment) => {
395
+ if (segment.mediaRanges !== undefined && segment.mediaRanges !== null || anchors.length === 0) return segment;
396
+ const previous = anchors.findLast(({ index }) => index < segment.index);
397
+ const next = anchors.find(({ index }) => index > segment.index);
398
+ const nominalLeft = previous?.endSeconds ?? next.startSeconds;
399
+ const actualLeft = previous?.mediaRanges.at(-1).end ?? next.mediaRanges[0].start;
400
+ const nominalRight = next?.startSeconds ?? nominalLeft;
401
+ const actualRight = next?.mediaRanges[0].start ?? actualLeft;
402
+ const scale = nominalRight > nominalLeft ? (actualRight - actualLeft) / (nominalRight - nominalLeft) : 1;
403
+ return { ...segment, mediaRanges: [{
404
+ start: actualLeft + (segment.startSeconds - nominalLeft) * scale,
405
+ end: actualLeft + (segment.endSeconds - nominalLeft) * scale
406
+ }] };
407
+ });
408
+ }
409
+
391
410
  function rateCurve(readings, now) {
392
411
  if (readings && Number.isFinite(readings.count) && readings.count > 0 &&
393
412
  Number.isFinite(readings.lastAt) && Number.isFinite(readings.lastValue) &&
@@ -933,13 +933,19 @@ export class SharedPieceStore {
933
933
  // THREE pieces reported 812 MB committed against 68 MB allowed, 739 blocks
934
934
  // allocated and 676 still alive, and the process was killed at 4.37 GB.
935
935
  const displaced = this.#buffers.get(index);
936
- this.#buffers.set(index, buffer);
937
936
  if (displaced !== undefined && displaced !== buffer) {
938
937
  this.#counters.blocksDisplaced += 1;
939
- this.#returnBlock(displaced);
938
+ // A reader may still hold the existing buffer pinned. Verified bytes for
939
+ // this index are identical, so retain that buffer and return the duplicate.
940
+ this.#returnBlock(buffer);
941
+ this.#lru.touch(index);
942
+ this.#noteProgress();
943
+ return displaced;
940
944
  }
945
+ this.#buffers.set(index, buffer);
941
946
  this.#lru.touch(index);
942
947
  this.#noteProgress();
948
+ return buffer;
943
949
  }
944
950
 
945
951
  /**
@@ -1394,12 +1400,9 @@ export class SharedPieceStore {
1394
1400
  return null;
1395
1401
  }
1396
1402
  this.#counters.fromWholeFile += 1;
1397
- const release = await this.#claimSlotOrNull();
1398
- if (release === null) {
1399
- // No block to put it in. The caller gets the bytes anyway; what it
1400
- // loses is only that the next read pays for this one again.
1401
- return whole;
1402
- }
1403
+ // Fragment readers require shared memory, including when the bytes now
1404
+ // live in an assembled file. Wait for the same reservation as disk reads.
1405
+ const release = await this.#claimSlot();
1403
1406
  try {
1404
1407
  return this.#registerPiece(index, this.#copyIntoNewBuffer(index, whole));
1405
1408
  } finally {
@@ -1432,11 +1435,11 @@ export class SharedPieceStore {
1432
1435
  this.#returnBlock(target);
1433
1436
  throw error;
1434
1437
  }
1435
- this.#registerPiece(index, target);
1438
+ const registered = this.#registerPiece(index, target);
1436
1439
  this.#counters.fromDisk += 1;
1437
1440
  this.#counters.revivals += 1;
1438
1441
  this.#noteRevival(index);
1439
- return target;
1442
+ return registered;
1440
1443
  } finally {
1441
1444
  release?.();
1442
1445
  releaseDisk();
@@ -149,18 +149,18 @@ export class TorrentWorkerClient {
149
149
  switch (message?.type) {
150
150
  case Event.FRAGMENT: {
151
151
  const buffer = message.buffer;
152
- if (!buffer) {
153
- // No buffer means no way to read the fragment; confirm it so the
154
- // worker is not left waiting, and let the read end short.
155
- this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
156
- break;
157
- }
158
- if (message.offset + message.length > buffer.byteLength) {
159
- logger.warn(
160
- `torrent-worker: fragment ${message.offset}+${message.length} lies outside ` +
161
- `its buffer of ${buffer.byteLength}B (read ${message.id}) — ending the read short`
162
- );
152
+ if (!buffer || !Number.isInteger(message.offset) || message.offset < 0 ||
153
+ !Number.isInteger(message.length) || message.length <= 0 ||
154
+ message.offset + message.length > buffer.byteLength) {
155
+ const error = new Error(`Invalid source fragment ${message.offset}+${message.length} ` +
156
+ `in a buffer of ${buffer?.byteLength ?? 0}B (read ${message.id}).`);
157
+ logger.warn(`torrent-worker: ${error.message}`);
158
+ this.#fragmentReaders.get(message.id)?.fail(error);
159
+ this.#fragmentReaders.delete(message.id);
160
+ this.#reads.get(message.id)?.fail(error);
161
+ this.#reads.delete(message.id);
163
162
  this.#worker.postMessage({ type: Event.FRAGMENT_DONE, id: message.id });
163
+ void this.#caller.call(Command.CANCEL_READ, { readId: message.id }).catch(() => undefined);
164
164
  break;
165
165
  }
166
166
  // A sequential read walks the file forwards, so each fragment either
@@ -36,9 +36,10 @@ export class WorkerTorrentPool {
36
36
 
37
37
  /**
38
38
  * @param {{ memoryBytes?: number, stateDir?: string }} [options]
39
+ * @param {object | null} [client] - The worker interface; tests supply a fake.
39
40
  */
40
- constructor(options = {}) {
41
- this.#client = new TorrentWorkerClient(options);
41
+ constructor(options = {}, client = null) {
42
+ this.#client = client ?? new TorrentWorkerClient(options);
42
43
  }
43
44
 
44
45
  /**
@@ -95,6 +96,11 @@ export class WorkerTorrentPool {
95
96
  return this.#client.wholeFileBytes ?? 0;
96
97
  }
97
98
 
99
+ /** Completed files announced by the worker, shared with the HTTP reader. */
100
+ get wholeFiles() {
101
+ return this.#client.wholeFiles;
102
+ }
103
+
98
104
  allowWholeFileBytes(bytes) {
99
105
  return this.#client.allowWholeFileBytes(bytes);
100
106
  }
@@ -1,7 +1,7 @@
1
1
  import test from "node:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { fmp4Format } from "../services/encode/segment-formats/fmp4.js";
4
- import { readPresentationRanges, walkBoxes } from "../services/encode/segment-formats/mp4-boxes.js";
4
+ import { continuePresentationRanges, readPresentationRanges, walkBoxes } from "../services/encode/segment-formats/mp4-boxes.js";
5
5
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
@@ -127,6 +127,17 @@ test("client clock projection accounts for shared edits and audio initPTS", () =
127
127
  assert.deepEqual(projected, video);
128
128
  });
129
129
 
130
+ test("a variable-rate frame crosses a cut only with continuous decode samples", () => {
131
+ const first = readPresentationRanges(Buffer.concat([init(0, 0), fragment(0, 0)]));
132
+ const continuous = readPresentationRanges(Buffer.concat([init(0, 0), fragment(1000, 72000)]));
133
+ assert.equal(continuePresentationRanges(first, continuous)[0].end, continuous[0].start);
134
+ const missing = readPresentationRanges(Buffer.concat([init(0, 0), fragment(24000, 72000)]));
135
+ assert.deepEqual(continuePresentationRanges(first, missing), first);
136
+ const audioFirst = readPresentationRanges(Buffer.concat([init(0, 0, "soun"), fragment(0, 0)]));
137
+ const audioNext = readPresentationRanges(Buffer.concat([init(0, 0, "soun"), fragment(1000, 72000)]));
138
+ assert.deepEqual(continuePresentationRanges(audioFirst, audioNext), audioFirst);
139
+ });
140
+
130
141
  test("a viewer retains only finite per-track player clock offsets", () => {
131
142
  const viewer = new Viewer("clock-reading", 1);
132
143
  viewer.report({ timestampOffsets: { video: -0.083, audio: 0, media: 400 } }, 2);
@@ -148,6 +148,28 @@ test("a revival holds disk bytes across the asynchronous memory reservation", as
148
148
  }
149
149
  });
150
150
 
151
+ test("an assembled-file revival returns shared bytes and preserves a concurrent pinned reader", async () => {
152
+ const disk = makeDisk();
153
+ const store = new SharedPieceStore(CHUNK, {
154
+ length: CHUNK * 16, memoryBytes: CHUNK * 4, disk,
155
+ readPieceElsewhere: async ({ index }) => piece(index), name: "whole-file-read"
156
+ });
157
+ try {
158
+ store.pin(0);
159
+ const [first, second] = await Promise.all([store.reside(0), store.reside(0)]);
160
+ assert.ok(first.buffer instanceof SharedArrayBuffer);
161
+ assert.equal(first.buffer, second.buffer);
162
+ await put(store, 1);
163
+ await put(store, 2);
164
+ assert.deepEqual(Buffer.from(first.buffer, first.offset, first.length), piece(0));
165
+ assert.equal(store.locate(0).buffer, first.buffer);
166
+ assert.equal(store.stats().outstanding, 0);
167
+ store.unpin(0);
168
+ } finally {
169
+ await new Promise((resolve) => store.destroy(resolve));
170
+ }
171
+ });
172
+
151
173
  /**
152
174
  * Wait until a condition holds, rather than for a chosen interval — a test that
153
175
  * sleeps samples, it does not check (roadmap item 54).
@@ -39,6 +39,17 @@ test("finds the first safe start from segments delivered before playback", () =>
39
39
  assert.ok(Math.abs(forecast.delaySeconds - 1) < 1e-8);
40
40
  });
41
41
 
42
+ test("future cuts follow measured track time without inventing a permanent clock gap", () => {
43
+ const state = input({ bufferedAheadSeconds: 3.9, reserveSeconds: 0 });
44
+ state.tracks[0].clientRanges = [{ start: 0, end: 3.9 }];
45
+ state.tracks[0].segments[0].mediaRanges = [{ start: 0, end: 3.9 }];
46
+ const forecast = predictPlaybackReadiness(state);
47
+ assert.notEqual(forecast.reason, "no-safe-start-found");
48
+ state.tracks[0].segments[1].mediaRanges = [{ start: 4, end: 8 }];
49
+ state.tracks[0].readySegmentIndices = [0, 1];
50
+ assert.equal(predictPlaybackReadiness(state).reason, "no-safe-start-found");
51
+ });
52
+
42
53
  test("keeps source-stall reserve in the proxy's prepared timeline, not the capped browser buffer", () => {
43
54
  const now = 10_000;
44
55
  const forecast = predictPlaybackReadiness({
@@ -761,8 +761,7 @@ test("a run on the keyframe grid is given no trim to apply", () => {
761
761
  videoEncoder: { name: "libx264" },
762
762
  segmentDurationSec: SEGMENT_SECONDS
763
763
  });
764
- // Exactly one `-ss`, and it is the input seek: the trim is not there to be
765
- // inherited by the cuts.
764
+ // One input seek establishes decode order in the preceding private cut.
766
765
  assert.equal(
767
766
  args.filter((one) => one === "-ss").length,
768
767
  1,
@@ -770,9 +769,10 @@ test("a run on the keyframe grid is given no trim to apply", () => {
770
769
  );
771
770
  const seek = Number(args[args.indexOf("-ss") + 1]);
772
771
  assert.ok(
773
- Math.abs(seek - grid.sourceTimes[index]) < 0.2,
774
- `run at #${index} seeks to ${seek}, not to its own keyframe ${grid.sourceTimes[index]}`
772
+ Math.abs(seek - grid.sourceTimes[index - 1]) < 0.2,
773
+ `run at #${index} seeks to ${seek}, not to the preceding keyframe ${grid.sourceTimes[index - 1]}`
775
774
  );
775
+ assert.equal(Number(args[args.indexOf("-segment_start_number") + 1]), index - 1);
776
776
  }
777
777
  });
778
778
 
@@ -14,6 +14,7 @@ import fs from "node:fs/promises";
14
14
  import os from "node:os";
15
15
  import path from "node:path";
16
16
  import { handleStreamGet } from "../routes/stream/get.js";
17
+ import { WorkerTorrentPool } from "../services/torrent/worker/pool-adapter.js";
17
18
 
18
19
  /**
19
20
  * Minimal stand-ins for the parts of Fastify and the pool this route touches.
@@ -151,13 +152,18 @@ test("a file downloaded whole is served from disk without touching the torrent",
151
152
  try {
152
153
  const { req, reply, sent, deps } = harness({ method: "GET", range: "bytes=2-6" });
153
154
  let asked = false;
154
- deps.torrentPool.getTorrent = async () => {
155
- asked = true;
156
- throw new Error("the torrent was asked for, which is what this avoids");
157
- };
158
- deps.torrentPool.wholeFiles = new Map([
155
+ const wholeFiles = new Map([
159
156
  ["abc/0", { path: where, length: bytes.length, name: "film.mkv" }]
160
157
  ]);
158
+ // Supply the worker boundary explicitly: no Worker or torrent is started.
159
+ deps.torrentPool = new WorkerTorrentPool({}, {
160
+ wholeFiles,
161
+ async getTorrent() {
162
+ asked = true;
163
+ throw new Error("the worker must not be asked for completed bytes");
164
+ }
165
+ });
166
+ assert.equal(deps.torrentPool.wholeFiles, wholeFiles);
161
167
  // The source key IS the identity — `torrent:<infohash>` — and it is all the
162
168
  // route needs to find the file.
163
169
  deps.sourceRegistry = { get: () => ({ sourceType: "torrent", source: "magnet:?xt=urn:btih:abc" }) };
@@ -169,11 +175,32 @@ test("a file downloaded whole is served from disk without touching the torrent",
169
175
  assert.equal(sent.code, 206);
170
176
  assert.equal(sent.headers["content-range"], `bytes 2-6/${bytes.length}`);
171
177
  assert.equal(sent.headers["content-length"], "5");
178
+ const chunks = [];
179
+ for await (const chunk of sent.body) chunks.push(chunk);
180
+ assert.deepEqual(Buffer.concat(chunks), bytes.subarray(2, 7));
172
181
  } finally {
173
182
  await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
174
183
  }
175
184
  });
176
185
 
186
+ test("an announced file removed before opening falls back to the torrent before headers", async () => {
187
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "stream-removed-"));
188
+ try {
189
+ const { req, reply, sent, opened, deps } = harness({ method: "GET", range: "bytes=2-6" });
190
+ deps.torrentPool.wholeFiles = new Map([
191
+ ["abc/0", { path: path.join(root, "missing"), length: 10, name: "gone.mkv" }]
192
+ ]);
193
+ deps.sourceRegistry = { get: () => ({ sourceType: "magnet", source: "magnet:?xt=urn:btih:abc" }) };
194
+ req.query = { sourceKey: "torrent:abc", fileIndex: "0" };
195
+ await handleStreamGet(req, reply, deps);
196
+ assert.deepEqual(opened, ["2-6"]);
197
+ assert.equal(deps.torrentPool.wholeFiles.size, 0);
198
+ assert.equal(sent.headers["content-range"], "bytes 2-6/5869669065");
199
+ } finally {
200
+ await fs.rm(root, { recursive: true, force: true });
201
+ }
202
+ });
203
+
177
204
  test("a file this proxy does not have whole still goes to the torrent", async () => {
178
205
  const { req, reply, state, deps } = harness({ method: "GET", range: "bytes=0-99" });
179
206
  deps.torrentPool.wholeFiles = new Map([["other/7", { path: "/nowhere", length: 1, name: "x" }]]);