@torrent-tv/proxy 2.106.0 → 2.107.0

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.106.0",
3
+ "version": "2.107.0",
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
@@ -23,6 +23,7 @@ import { trackChoicesOf } from "./services/viewer/segment-demands.js";
23
23
  import { PlaybackProjection } from "./services/torrent/PlaybackProjection.js";
24
24
  import { SegmentInputTable } from "./services/viewer/segment-input-table.js";
25
25
  import { SourcePreparation, preparationInterval } from "./services/viewer/SourcePreparation.js";
26
+ import { publishPlaybackBeforePreparationRefresh } from "./services/server/publish-playback-before-preparation-refresh.js";
26
27
  import { handleHealthzGet } from "./routes/healthz/get.js";
27
28
  import { handleApiDeliverySinkGet } from "./routes/api/delivery-sink/get.js";
28
29
  import { handleApiLinkProbeGet } from "./routes/api/link-probe/get.js";
@@ -1213,7 +1214,6 @@ export async function startProxyServer({
1213
1214
  setDownloadRate: (bytesPerSecond, because) => torrentPool.setDownloadRate?.(bytesPerSecond, because),
1214
1215
  setSpeculativeHold: (held, because) => torrentPool.setSpeculativeHold?.(held, because),
1215
1216
  setPriorityMap: async ({ sourceKey, fileIndex, durationSeconds, zones }) => {
1216
- void sourcePreparation?.refresh();
1217
1217
  const record = sourceRegistry.get(sourceKey);
1218
1218
  if (!record) {
1219
1219
  return;
@@ -1224,9 +1224,13 @@ export async function startProxyServer({
1224
1224
  if (!outputParts.viewers.forSource(sourceKey).length) probeReads.forget(sourceKey, fileIndex);
1225
1225
  await downloadMaps.forget(sourceKey, fileIndex, { keepPreparation: true });
1226
1226
  playbackProjections.get(`${sourceKey}:${fileIndex}`)?.clear();
1227
+ void sourcePreparation?.refresh();
1227
1228
  return;
1228
1229
  }
1229
- await downloadMaps.playback({ sourceKey, fileIndex, durationSeconds, zones });
1230
+ await publishPlaybackBeforePreparationRefresh(
1231
+ () => downloadMaps.playback({ sourceKey, fileIndex, durationSeconds, zones }),
1232
+ () => { void sourcePreparation?.refresh(); }
1233
+ );
1230
1234
  mediaReads.retain(sourceKey, fileIndex, params => !params.requestId?.startsWith("download:") ||
1231
1235
  zones.some(zone => zone.from === params.downloadInterval?.from && zone.to === params.downloadInterval?.to));
1232
1236
  } catch (error) {
@@ -1375,6 +1379,7 @@ export async function startProxyServer({
1375
1379
  const held = await torrentPool.heldRangesOf(torrent, work.fileIndex);
1376
1380
  return work.inputRanges.every(([start, end]) => held.some(([from, to]) => from <= start && to >= end));
1377
1381
  },
1382
+ playbackAppliedFor: (sourceKey, fileIndex) => downloadMaps.hasAppliedPlayback(sourceKey, fileIndex),
1378
1383
  readyChanged: (work, ready) => logger.info(`source urgent request=${work.requestId} viewer=${work.ownerId} ` +
1379
1384
  `source=${work.sourceKey} file=${work.fileIndex} inputReady=${ready}`),
1380
1385
  inspect: async work => {
@@ -2219,60 +2219,78 @@ export class Renditions {
2219
2219
  this.#host.outputs.touch(already);
2220
2220
  return already;
2221
2221
  }
2222
- const rendition = await this.#host.createOrGetSession({
2223
- sourceKey: base.file.sourceKey,
2224
- fileIndex: base.file.fileIndex,
2225
- // No picture at all: the video flag says what to do with a video stream
2226
- // this output does not carry.
2227
- transcodeVideo: false,
2228
- transcodeAudio,
2229
- fileName: base.file.name,
2230
- // Opened by nobody in particular, as a step is.
2231
- consumerId: "",
2232
- audioTrackIndex: trackIndex,
2233
- audioOnly: true,
2234
- // Where the viewer is, so the rendition starts with the picture rather
2235
- // than at the beginning of the file. Read the same way a quality variant
2236
- // reads it: the base's own field is only written by a seek or by a
2237
- // segment IT served, so on a resume-from-position open it is still unset
2238
- // while the player is asking for segment #537 — and the audio would begin
2239
- // at zero and never catch up, since nothing treats a far request as a
2240
- // seek. The accessor falls back to the last segment actually requested.
2241
- // Where the PICTURE is, not where it has been read to.
2242
- //
2243
- // The position this class keeps is written by the segments a session
2244
- // serves, so it is the READ head, and the viewer's picture sits behind it
2245
- // by everything the player has buffered. Started at the read head, the
2246
- // audio run begins AHEAD of the viewer, and every request they then make
2247
- // is behind a run that only moves forward — field 2026-08-15, placed at
2248
- // #16 while the player asked for #10, and the audio arrived only after
2249
- // the encoder was dragged back.
2250
- //
2251
- // The distance is measured, not assumed: the browser reports how many
2252
- // seconds it holds ahead of the picture with every link report, so the
2253
- // playhead is one subtraction away. A stale report is no use — a viewer
2254
- // who seeked since then is somewhere else entirely — so an old one is
2255
- // ignored and the whole look-ahead is subtracted instead, which cannot
2256
- // leave the run ahead of them.
2257
- startPositionSeconds: this.#host.audioStartSecondsFor({
2258
- family: this.#host.outputs.familyOf(base),
2259
- openedAtSeconds: this.#host.encodeRuns.progressOf(base)?.startPositionSeconds,
2260
- segmentSeconds: this.#host.segmentDurationSec
2261
- }),
2262
- segmentFormatId: base.segmentFormat.id,
2263
- // Cut where the picture is cut. Two streams meant to be played together
2264
- // have to be divided at the same times, and the grid is the base's — the
2265
- // table as it stands now, corrections included. A base on the uniform
2266
- // grid passes nothing: the rendition computes the same even grid from the
2267
- // same duration.
2268
- inheritedGrid: base.timeline.cutGrid === "keyframe"
2269
- ? {
2270
- boundaries: base.timeline.boundaries,
2271
- published: base.timeline.published
2272
- }
2273
- : null
2274
- });
2275
- return rendition ?? null;
2222
+ const waitStartedAt = performance.now();
2223
+ const waitFields = {
2224
+ process: "P6.a",
2225
+ baseSessionId: base.id,
2226
+ trackIndex,
2227
+ consumerId,
2228
+ audioMode: transcodeAudio ? "transcode" : "copy"
2229
+ };
2230
+ this.#host.observeWait?.(base.file.sourceKey, "process", { ...waitFields, phase: "begin", at: waitStartedAt });
2231
+ let waitOutcome = "error";
2232
+ try {
2233
+ const rendition = await this.#host.createOrGetSession({
2234
+ sourceKey: base.file.sourceKey,
2235
+ fileIndex: base.file.fileIndex,
2236
+ // No picture at all: the video flag says what to do with a video stream
2237
+ // this output does not carry.
2238
+ transcodeVideo: false,
2239
+ transcodeAudio,
2240
+ fileName: base.file.name,
2241
+ // Opened by nobody in particular, as a step is.
2242
+ consumerId: "",
2243
+ audioTrackIndex: trackIndex,
2244
+ audioOnly: true,
2245
+ // Where the viewer is, so the rendition starts with the picture rather
2246
+ // than at the beginning of the file. Read the same way a quality variant
2247
+ // reads it: the base's own field is only written by a seek or by a
2248
+ // segment IT served, so on a resume-from-position open it is still unset
2249
+ // while the player is asking for segment #537 — and the audio would begin
2250
+ // at zero and never catch up, since nothing treats a far request as a
2251
+ // seek. The accessor falls back to the last segment actually requested.
2252
+ // Where the PICTURE is, not where it has been read to.
2253
+ //
2254
+ // The position this class keeps is written by the segments a session
2255
+ // serves, so it is the READ head, and the viewer's picture sits behind it
2256
+ // by everything the player has buffered. Started at the read head, the
2257
+ // audio run begins AHEAD of the viewer, and every request they then make
2258
+ // is behind a run that only moves forward — field 2026-08-15, placed at
2259
+ // #16 while the player asked for #10, and the audio arrived only after
2260
+ // the encoder was dragged back.
2261
+ //
2262
+ // The distance is measured, not assumed: the browser reports how many
2263
+ // seconds it holds ahead of the picture with every link report, so the
2264
+ // playhead is one subtraction away. A stale report is no use — a viewer
2265
+ // who seeked since then is somewhere else entirely — so an old one is
2266
+ // ignored and the whole look-ahead is subtracted instead, which cannot
2267
+ // leave the run ahead of them.
2268
+ startPositionSeconds: this.#host.audioStartSecondsFor({
2269
+ family: this.#host.outputs.familyOf(base),
2270
+ openedAtSeconds: this.#host.encodeRuns.progressOf(base)?.startPositionSeconds,
2271
+ segmentSeconds: this.#host.segmentDurationSec
2272
+ }),
2273
+ segmentFormatId: base.segmentFormat.id,
2274
+ // Cut where the picture is cut. Two streams meant to be played together
2275
+ // have to be divided at the same times, and the grid is the base's — the
2276
+ // table as it stands now, corrections included. A base on the uniform
2277
+ // grid passes nothing: the rendition computes the same even grid from the
2278
+ // same duration.
2279
+ inheritedGrid: base.timeline.cutGrid === "keyframe"
2280
+ ? {
2281
+ boundaries: base.timeline.boundaries,
2282
+ published: base.timeline.published
2283
+ }
2284
+ : null
2285
+ });
2286
+ waitOutcome = rendition ? "opened" : "unavailable";
2287
+ return rendition ?? null;
2288
+ } finally {
2289
+ const waitEndedAt = performance.now();
2290
+ this.#host.observeWait?.(base.file.sourceKey, "process", { ...waitFields, phase: "end",
2291
+ at: waitEndedAt, startedAt: waitStartedAt, endedAt: waitEndedAt,
2292
+ durationMs: waitEndedAt - waitStartedAt, outcome: waitOutcome });
2293
+ }
2276
2294
  }
2277
2295
 
2278
2296
  /**
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Keep source preparation stated until the viewer's playback map is applied.
3
+ *
4
+ * @param {() => Promise<unknown>} publishPlayback
5
+ * @param {() => unknown} refreshPreparation
6
+ * @returns {Promise<void>}
7
+ */
8
+ export async function publishPlaybackBeforePreparationRefresh(publishPlayback, refreshPreparation) {
9
+ await publishPlayback();
10
+ refreshPreparation();
11
+ }
@@ -162,6 +162,11 @@ export class DownloadMaps {
162
162
  return this.#files.get(`${sourceKey}:${fileIndex}`)?.epoch ?? 0;
163
163
  }
164
164
 
165
+ hasAppliedPlayback(sourceKey, fileIndex) {
166
+ const file = this.#files.get(`${sourceKey}:${fileIndex}`);
167
+ return Boolean(file?.zones.length && file.shape && file.appliedShape === file.shape);
168
+ }
169
+
165
170
  native({ sourceKey, fileIndex, zones }) {
166
171
  const file = this.#file(sourceKey, fileIndex);
167
172
  file.nativeZones = zones;
@@ -325,7 +330,12 @@ export class DownloadMaps {
325
330
  const answeredAt = this.#clock();
326
331
  // A torrent not yet in the thread applies nothing, so this map was not
327
332
  // delivered and the next one, however alike, has to be.
328
- if (reply?.appliedMs === null && file.shape === shape) file.shape = null;
333
+ if (reply?.appliedMs === null) {
334
+ if (file.shape === shape) file.shape = null;
335
+ if (file.appliedShape === shape) file.appliedShape = null;
336
+ } else {
337
+ file.appliedShape = shape;
338
+ }
329
339
  // The window open now, which a report since this map was asked for may have started.
330
340
  const stats = this.#statsOf(file);
331
341
  stats.sent += 1;
@@ -345,6 +355,7 @@ export class DownloadMaps {
345
355
  // sent, and leaves nothing to compare the next one against.
346
356
  file.pending = pending.catch(() => {
347
357
  if (file.shape === shape) file.shape = null;
358
+ if (file.appliedShape === shape) file.appliedShape = null;
348
359
  });
349
360
  return pending;
350
361
  }
@@ -38,6 +38,7 @@ export class SourcePreparation {
38
38
  #reprice;
39
39
  #inputReady;
40
40
  #readyChanged;
41
+ #playbackAppliedFor;
41
42
  #work = new Map();
42
43
  #revision = 0;
43
44
  #completedRevision = null;
@@ -45,7 +46,8 @@ export class SourcePreparation {
45
46
  #candidateAbort = new AbortController();
46
47
 
47
48
  constructor({ viewers, candidatesFor, relatedFilesFor = () => [], priorityFor = (_viewer, priority) => priority,
48
- inputReady = async () => false, readyChanged = () => {}, inspect, withdraw, reprice = () => {}, failed }) {
49
+ inputReady = async () => false, readyChanged = () => {}, playbackAppliedFor = () => true,
50
+ inspect, withdraw, reprice = () => {}, failed }) {
49
51
  this.#viewers = viewers;
50
52
  this.#candidates = candidatesFor;
51
53
  this.#related = relatedFilesFor;
@@ -56,6 +58,7 @@ export class SourcePreparation {
56
58
  this.#reprice = reprice;
57
59
  this.#inputReady = inputReady;
58
60
  this.#readyChanged = readyChanged;
61
+ this.#playbackAppliedFor = playbackAppliedFor;
59
62
  }
60
63
 
61
64
  refresh() {
@@ -80,8 +83,13 @@ export class SourcePreparation {
80
83
 
81
84
  /** Current owners determine priority; old work never restores a withdrawn read. */
82
85
  demandFor(work) {
86
+ const handoffFileIndex = work.selected || (work.role === "source-rest" && work.ownerFileIndex === work.fileIndex)
87
+ ? work.fileIndex : null;
88
+ const opening = Number.isInteger(handoffFileIndex) && this.#viewers.forSource(work.sourceKey).some(viewer =>
89
+ (work.ownerId === undefined || viewer.id === work.ownerId) && viewer.source.selectedFileIndex === handoffFileIndex &&
90
+ viewer.outputs.size > 0 && !this.#playbackAppliedFor(work.sourceKey, handoffFileIndex));
83
91
  if (work.selected && work.statement === "packets" &&
84
- work.positions !== this.#positionsFor(work.sourceKey, work.fileIndex, work.ownerId)) return null;
92
+ work.positions !== this.#positionsFor(work.sourceKey, work.fileIndex, work.ownerId, opening)) return null;
85
93
  if (work.role === "subtitle-embedded" && ["packets", "subtitle-cues"].includes(work.statement) &&
86
94
  work.positions !== this.#positionsFor(work.sourceKey, work.fileIndex, work.ownerId, true)) return null;
87
95
  const owners = this.#viewers.forSource(work.sourceKey).filter(viewer => {
@@ -90,9 +98,9 @@ export class SourcePreparation {
90
98
  viewer.subtitle.trackIndex !== work.trackIndex)) return false;
91
99
  const selected = viewer.source.selectedFileIndex;
92
100
  if (work.ownerFileIndex !== undefined) return selected === work.ownerFileIndex &&
93
- (work.role !== "source-rest" || viewer.outputs.size === 0);
101
+ (work.role !== "source-rest" || viewer.outputs.size === 0 || opening);
94
102
  if (work.statement !== "packets") return selected === null || selected === work.fileIndex;
95
- if (selected !== null) return selected === work.fileIndex && viewer.outputs.size === 0 && work.selected;
103
+ if (selected !== null) return selected === work.fileIndex && (viewer.outputs.size === 0 || opening) && work.selected;
96
104
  return !work.selected && viewer.source.visibleFileIndices.includes(work.fileIndex);
97
105
  });
98
106
  if (!owners.length) return null;