@torrent-tv/proxy 2.79.0 → 2.80.1

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.
@@ -53,133 +53,89 @@
53
53
  * zones is meaningful; the numbers themselves are not a scale.
54
54
  */
55
55
 
56
- /** Where the viewer stands. Nothing outranks it. */
57
- const AT_THE_VIEWER = 33;
58
-
59
- /** In front of them, within reach while they watch what is already made. */
60
- const IN_FRONT = 32;
61
-
62
- /** The rest of the track: wanted, because the file is encoded whole. */
63
- const THE_REST = 1;
64
-
65
56
  /**
66
- * How many zones one viewer's map may hold.
67
- *
68
- * Below realtime the zones grow geometrically, so their number is the logarithm
69
- * of the film left over what the viewer holdsfive on the addon host's worst
70
- * measured case, and it grows by one each time the speed halves. The bound is
71
- * not a policy about how many encoders may run (the machine's budget answers
72
- * that, and it is far smaller); it stops a speed measured at almost zero from
73
- * turning a film into thousands of slivers before anything reads the map.
57
+ * The number a stretch gets when nobody is heading towards it.
58
+ *
59
+ * Behind a viewer, and everywhere for a viewer who has stopped the picture: the
60
+ * film is still wanted a seek back must be cheap but nobody is on their way
61
+ * there, so it yields to everything anybody is approaching.
74
62
  */
75
- const MOST_ZONES = 32;
63
+ export const NOBODY_IS_COMING = 1;
76
64
 
77
65
  /**
78
- * One viewer's map, and every boundary in it is measured rather than chosen.
66
+ * The number the second a viewer is about to watch gets. Everything else ahead
67
+ * of them counts down from here.
79
68
  *
80
- * The shape depends on one measured number how fast this machine encodes this
81
- * track against realtime:
82
- *
83
- * - **at or above realtime** the encoder gains on the viewer everywhere, so one
84
- * of them holds the whole film. Three zones: the measured allowance in front
85
- * of the viewer, what the encoder reaches while they watch it, and the rest;
86
- * - **below realtime** the encoder loses `1 - speed` of a second for every
87
- * second played, so one cannot hold the film and the map says how many can.
88
- * An encoder starting at `q` stays ahead of a viewer at `p` for
89
- * `(q - p) * s / (1 - s)`, which GROWS with its distance from them — so the
90
- * zones grow, each is one encoder's share, and their number is the smallest
91
- * that holds this viewer.
92
- *
93
- * The last zone is always the rest of the track, wanted because the file is
94
- * encoded whole and last because nobody is waiting on it.
69
+ * One scale for every viewer, because the maps are merged by taking the highest
70
+ * number per second: two viewers must be comparable, and they are, because the
71
+ * number depends only on how far each of them still has to travel.
72
+ */
73
+ export const AT_THE_VIEWER = 32;
74
+
75
+ /**
76
+ * One viewer's map: seconds of film against a number, and nothing else.
77
+ *
78
+ * **The number is a reading of how soon they will be there.** A viewer moving
79
+ * forward reaches the second `x` after `x - p` seconds of film. That distance —
80
+ * not a clock time, not a deadline — is what the number is derived from, and it
81
+ * is why two viewers can be compared at all: the nearer one wins the second
82
+ * they both want.
83
+ *
84
+ * **The bands widen as they go.** Near the viewer the difference between now
85
+ * and ten seconds away decides what is made first; twenty minutes out, the
86
+ * difference between twenty and twenty-one changes nothing. So the first band
87
+ * is the measured allowance — the depth below which an interruption reaches
88
+ * this viewer — and each next band is twice the last. A film of any length is
89
+ * then described by a handful of bands, fine where it matters.
90
+ *
91
+ * **What must NOT be here**, and the boundary is the point: how fast this
92
+ * machine encodes, how many encoders that takes, what a second weighs in bytes,
93
+ * where a piece boundary falls. Those are answers the encoding and the
94
+ * downloading work out for themselves, from this map and from what each knows
95
+ * about itself.
95
96
  *
96
97
  * @param {object} params
97
98
  * @param {number} params.atSeconds - Where they are watching from.
98
99
  * @param {number} params.durationSeconds - How long the film is.
99
100
  * @param {number} params.allowanceSeconds - The measured depth below which an
100
- * interruption reaches this viewer (`minimumBufferSeconds`).
101
- * @param {number} params.encodeSpeedX - Measured encode speed against realtime
102
- * for this track on this machine. Zero or less means nothing has measured it
103
- * yet, and then zone 2 is left out rather than invented.
104
- * @returns {DemandZone[]} Ascending, without gaps or overlaps, covering
105
- * everything from where they are to the end of the film.
101
+ * interruption reaches this viewer. The width of the first band.
102
+ * @param {boolean} [params.playing] - Whether the picture is moving. A viewer
103
+ * who has stopped it is going nowhere, so nothing is nearer to them than
104
+ * anything else.
105
+ * @returns {DemandZone[]} Ascending by position, without gaps or overlaps.
106
106
  */
107
- export function mapForViewer({ atSeconds, durationSeconds, allowanceSeconds, encodeSpeedX }) {
107
+ export function mapForViewer({ atSeconds, durationSeconds, allowanceSeconds, playing = true }) {
108
108
  const from = Number.isFinite(atSeconds) && atSeconds > 0 ? atSeconds : 0;
109
109
  const end = Number.isFinite(durationSeconds) ? durationSeconds : 0;
110
- if (!(end > from)) {
110
+ if (!(end > 0)) {
111
111
  return [];
112
112
  }
113
113
  const allowance = Number.isFinite(allowanceSeconds) && allowanceSeconds > 0 ? allowanceSeconds : 0;
114
- const speed = Number.isFinite(encodeSpeedX) && encodeSpeedX > 0 ? encodeSpeedX : 0;
114
+ if (!playing || allowance <= 0) {
115
+ // Nobody is on their way anywhere: the film is wanted and nothing in it is
116
+ // wanted sooner than the rest.
117
+ return [{ from: 0, to: end, priority: NOBODY_IS_COMING }];
118
+ }
115
119
 
116
120
  /** @type {DemandZone[]} */
117
121
  const zones = [];
118
-
119
- // AT OR ABOVE REALTIME ONE ENCODER SUFFICES, whatever the film's length.
120
- //
121
- // From the condition below with `s >= 1`: the encoder gains on the viewer at
122
- // every point, so there is no distance at which they catch it. All that has
123
- // to exist in front of them is the allowance this file's own interruptions
124
- // have shown to be necessary.
125
- if (speed === 0 || speed >= 1) {
126
- const readyBy = Math.min(end, from + allowance);
127
- zones.push({ from, to: readyBy, priority: AT_THE_VIEWER });
128
- if (readyBy < end && speed > 0) {
129
- // While they watch what the first zone holds, the encoder makes `speed`
130
- // times as much again. Beyond that nobody is waiting yet.
131
- const reach = Math.min(end, readyBy + (readyBy - from) * speed);
132
- if (reach > readyBy) {
133
- zones.push({ from: readyBy, to: reach, priority: IN_FRONT });
134
- }
135
- }
136
- const covered = zones[zones.length - 1].to;
137
- if (covered < end) {
138
- zones.push({ from: covered, to: end, priority: THE_REST });
139
- }
140
- return zones;
141
- }
142
-
143
- // BELOW REALTIME THE ZONES GROW, AND EACH IS ONE ENCODER'S SHARE.
144
- //
145
- // An encoder starting at `q` produces the point `q + y` after `y / s`, and the
146
- // viewer reaches it after `q + y - p`. It stays ahead while
147
- //
148
- // y <= (q - p) * s / (1 - s)
149
- //
150
- // so the length one encoder can hold GROWS with its distance from the viewer:
151
- // the further off it starts, the later the viewer arrives. Equal shares are
152
- // therefore the wrong division, and by a wide margin — on the addon host with
153
- // 2400 s of film left, 120 s held and 0.5x, equal shares need twenty encoders
154
- // and growing ones need five (120, 240, 480, 960, 1920).
155
- //
156
- // Each zone is exactly as long as its bound allows, which makes the count the
157
- // smallest that can hold this viewer: any zone longer stalls them, and any
158
- // shorter leaves the next one starting nearer, where its own bound is tighter.
159
- // With nothing held, no partition holds this viewer: the first encoder can
160
- // stay ahead for `b * s / (1 - s)`, and that is zero. Saying so plainly beats
161
- // slicing the film into equal slivers that pretend otherwise — the whole of
162
- // what is left is urgent, and it will still not be enough.
163
- if (allowance <= 0) {
164
- return [{ from, to: end, priority: AT_THE_VIEWER }];
122
+ if (from > 0) {
123
+ zones.push({ from: 0, to: from, priority: NOBODY_IS_COMING });
165
124
  }
166
- const growth = speed / (1 - speed);
167
125
  let at = from;
168
- let held = allowance;
126
+ let width = allowance;
169
127
  let priority = AT_THE_VIEWER;
170
- while (at < end && zones.length < MOST_ZONES) {
171
- const share = held * growth;
172
- const to = Math.min(end, at + share);
128
+ while (at < end && priority > NOBODY_IS_COMING + 1) {
129
+ const to = Math.min(end, at + width);
173
130
  zones.push({ from: at, to, priority });
174
- held += to - at;
175
131
  at = to;
176
- // The next zone is one step less urgent: the viewer meets the one before it
177
- // first. One scale for every viewer, or merging two maps would compare
178
- // numbers that mean different things.
179
- priority = Math.max(THE_REST + 1, priority - 1);
132
+ width *= 2;
133
+ priority -= 1;
180
134
  }
181
135
  if (at < end) {
182
- zones.push({ from: at, to: end, priority: THE_REST });
136
+ // Everything left is equally far off: at this distance one more band would
137
+ // not change any decision.
138
+ zones.push({ from: at, to: end, priority: NOBODY_IS_COMING + 1 });
183
139
  }
184
140
  return zones;
185
141
  }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @file The priority map, built once and handed to everybody who acts on it.
3
+ *
4
+ * One map per film, in seconds of film against a number. It is built from where
5
+ * the viewers are and nothing else, and both of the things that do work — the
6
+ * encoding and the downloading — read it and decide for themselves. They do not
7
+ * talk to each other, and neither of them tells this class anything.
8
+ *
9
+ * **Why it has to be published rather than asked for.** The downloading lives
10
+ * in another thread. Until now it took its orders from the reads themselves:
11
+ * every read declared a window around its own head, so fifteen reads declared
12
+ * fifteen windows on a piece store that holds sixteen pieces. Half of all
13
+ * evictions then took a piece a reader had said it wanted, two thirds of reads
14
+ * came back from disk, and what `/stream` handed out stopped being the file's
15
+ * bytes — twenty-two source-parse errors, a segment the player could not
16
+ * append, and an empty picture for six minutes (field 2026-09-05).
17
+ */
18
+
19
+ import { mapForViewer, mergeMaps } from "./PriorityMap.js";
20
+
21
+ export class PriorityOrchestrator {
22
+ /** Where the map goes once it is built. @type {(published: object) => void} */
23
+ #publish;
24
+
25
+ /** The last map published per film and file, so an unchanged one is not resent. */
26
+ #last = new Map();
27
+
28
+ /** Who is watching one session. @type {(session: object) => Map<string, object>} */
29
+ #viewersOf;
30
+
31
+ /** How wide the first band of one session's file is. @type {(session: object) => number} */
32
+ #allowanceFor;
33
+
34
+ /**
35
+ * This layer states facts and imports nothing above itself, so what it needs
36
+ * of a session — who is watching it, and how wide an interruption this file
37
+ * has shown on this swarm — is passed in.
38
+ *
39
+ * @param {object} params
40
+ * @param {(published: { sourceKey: string, fileIndex: number, durationSeconds: number,
41
+ * zones: { from: number, to: number, priority: number }[] }) => void} params.publish
42
+ * @param {(session: object) => Map<string, object>} [params.viewersOf]
43
+ * @param {(session: object) => number} [params.allowanceFor]
44
+ */
45
+ constructor({ publish, viewersOf, allowanceFor }) {
46
+ this.#publish = typeof publish === "function" ? publish : () => {};
47
+ this.#viewersOf = typeof viewersOf === "function" ? viewersOf : () => new Map();
48
+ this.#allowanceFor = typeof allowanceFor === "function" ? allowanceFor : () => 0;
49
+ }
50
+
51
+ /**
52
+ * The map for one film, from everyone watching it.
53
+ *
54
+ * @param {object} params
55
+ * @param {string} params.sourceKey
56
+ * @param {number} params.fileIndex
57
+ * @param {number} params.durationSeconds
58
+ * @param {number} params.allowanceSeconds - The measured depth below which an
59
+ * interruption reaches a viewer of this file.
60
+ * @param {{ atSeconds: number, playing: boolean }[]} params.viewers
61
+ * @returns {{ from: number, to: number, priority: number }[]} Seconds of film
62
+ * against a number, merged over everyone.
63
+ */
64
+ build({ sourceKey, fileIndex, durationSeconds, allowanceSeconds, viewers }) {
65
+ const zones = mergeMaps(
66
+ (viewers ?? []).map((viewer) =>
67
+ mapForViewer({
68
+ atSeconds: viewer.atSeconds,
69
+ durationSeconds,
70
+ allowanceSeconds,
71
+ playing: viewer.playing !== false
72
+ })
73
+ )
74
+ );
75
+ const key = `${sourceKey}:${fileIndex}`;
76
+ // Unchanged maps are not republished: the downloading rebuilds what it asks
77
+ // the swarm for on every one, and a viewer sitting still would otherwise
78
+ // make it do that several times a second.
79
+ const shape = JSON.stringify(zones);
80
+ if (this.#last.get(key) !== shape) {
81
+ this.#last.set(key, shape);
82
+ this.#publish({ sourceKey, fileIndex, durationSeconds, zones });
83
+ }
84
+ return zones;
85
+ }
86
+
87
+ /**
88
+ * Build and publish the map for every file anybody is watching.
89
+ *
90
+ * One map per FILE, not per output: the picture, a quality step and a
91
+ * soundtrack of one film are three outputs reading the same bytes, and the
92
+ * swarm is asked for bytes. Viewers of all of them merge into one map.
93
+ *
94
+ * @param {object} params
95
+ * @param {Iterable<object[]>} params.sessionGroups - The live sessions, in
96
+ * whatever grouping the caller holds them; they are regrouped by file here.
97
+ * @param {number} params.staleAfterMs - How long a viewer may be silent and
98
+ * still count as watching.
99
+ * @param {number} [params.now]
100
+ * @returns {void}
101
+ */
102
+ publishFor({ sessionGroups, staleAfterMs, now = Date.now() }) {
103
+ /** @type {Map<string, { sourceKey: string, fileIndex: number, durationSeconds: number, allowanceSeconds: number, viewers: object[] }>} */
104
+ const byFile = new Map();
105
+ for (const sessions of sessionGroups) {
106
+ for (const session of sessions) {
107
+ const key = `${session.sourceKey}:${session.fileIndex}`;
108
+ let held = byFile.get(key);
109
+ if (!held) {
110
+ held = {
111
+ sourceKey: session.sourceKey,
112
+ fileIndex: session.fileIndex,
113
+ durationSeconds: Number(session.file?.durationSeconds) || 0,
114
+ // The first band is as wide as an interruption this file has
115
+ // actually shown on this swarm, never a chosen number.
116
+ allowanceSeconds: this.#allowanceFor(session),
117
+ viewers: []
118
+ };
119
+ byFile.set(key, held);
120
+ }
121
+ for (const viewer of this.#viewersOf(session).values()) {
122
+ if (viewer.isPresent(now, staleAfterMs)) {
123
+ held.viewers.push({
124
+ atSeconds: viewer.positionSeconds() ?? 0,
125
+ playing: viewer.playing !== false
126
+ });
127
+ }
128
+ }
129
+ }
130
+ }
131
+ for (const one of byFile.values()) {
132
+ // A file of unknown length cannot be divided into zones, and a file
133
+ // nobody is watching has nothing to be urgent about.
134
+ if (one.durationSeconds > 0 && one.viewers.length > 0) {
135
+ this.build(one);
136
+ }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Nobody is watching this file any more.
142
+ *
143
+ * @param {string} sourceKey
144
+ * @param {number} fileIndex
145
+ */
146
+ forget(sourceKey, fileIndex) {
147
+ this.#last.delete(`${sourceKey}:${fileIndex}`);
148
+ }
149
+ }
@@ -17,8 +17,12 @@ import { logger } from "../utils/logger.js";
17
17
  import { SharedPieceStore, findSharedStore } from "./piece-store/shared-piece-store.js";
18
18
  import { Urgency } from "./demand/index.js";
19
19
  import { demandFor, forgetTorrent, reconcileAll } from "./download/registry.js";
20
+ import { AT_THE_VIEWER, NOBODY_IS_COMING } from "./priority/PriorityMap.js";
20
21
  import { deriveSourceKey } from "./torrent-source-key.js";
21
22
 
23
+ /** How a window stated from the priority map names itself. */
24
+ const MAP_CLAIMANT = "priority-map";
25
+
22
26
  // The DHT's entry points. Two of the three the library ships answer nothing —
23
27
  // measured 2026-08-21 from the addon host: `router.bittorrent.com` and
24
28
  // `router.utorrent.com` did not reply to a hand-written `ping` at all, while a
@@ -992,6 +996,106 @@ export class TorrentPool {
992
996
  }
993
997
  }
994
998
 
999
+ /**
1000
+ * Take the priority map for one file and state what to fetch from it.
1001
+ *
1002
+ * THE MAP IS THE ONLY SOURCE. What used to state demand here was the reads
1003
+ * themselves: every read declared a window around its own head, so fifteen
1004
+ * reads declared fifteen windows on a piece store that holds sixteen pieces —
1005
+ * half of all evictions then took a piece a reader had said it wanted, two
1006
+ * thirds of reads came back from disk, and the bytes handed out stopped being
1007
+ * the file's (field 2026-09-05).
1008
+ *
1009
+ * Seconds become bytes here, because this is the side that knows how the file
1010
+ * is laid out. The map never learns about bytes and the viewer never appears.
1011
+ *
1012
+ * @param {object} torrent
1013
+ * @param {number} fileIndex
1014
+ * @param {{ from: number, to: number, priority: number }[]} zones - Seconds of
1015
+ * film against a number, highest first in urgency.
1016
+ * @param {number} durationSeconds
1017
+ */
1018
+ applyPriorityMap(torrent, fileIndex, zones, durationSeconds) {
1019
+ const file = Array.isArray(torrent?.files) ? torrent.files[fileIndex] : null;
1020
+ const length = Number(file?.length);
1021
+ const duration = Number(durationSeconds);
1022
+ if (!file || !(length > 0) || !(duration > 0) || !Array.isArray(zones)) {
1023
+ return;
1024
+ }
1025
+ const { register } = demandFor(torrent);
1026
+ // Highest number first, so the stretch a viewer reaches soonest is stated
1027
+ // first and the rest fall in behind it.
1028
+ const ordered = [...zones]
1029
+ .filter((zone) => Number.isFinite(zone?.from) && Number.isFinite(zone?.to) && zone.to > zone.from)
1030
+ .sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0));
1031
+ /**
1032
+ * Which level of urgency one zone is stated at, from the map's own number
1033
+ * and nothing else. The map's numbers are a scale as long as the film needs
1034
+ * — ten bands on a fifty-minute film — while the register has five levels,
1035
+ * and the fit is by meaning:
1036
+ *
1037
+ * - the top of the scale is where a viewer is standing, so it is the
1038
+ * cushion being built: {@link Urgency.NEAR}. Never BLOCKED — that level
1039
+ * means a reader is stopped on those bytes right now, which only a read
1040
+ * can say;
1041
+ * - the bottom is what nobody is approaching: behind a viewer moving
1042
+ * forward, and the whole film of a viewer who has stopped the picture.
1043
+ * Wanted only if somebody seeks back, which is {@link Urgency.BEHIND};
1044
+ * - one above the bottom is the far tail — wanted for certain if the
1045
+ * viewer watches on, and not before: {@link Urgency.TAIL};
1046
+ * - everything between is the lead being built: {@link Urgency.AHEAD}.
1047
+ *
1048
+ * Read from the scale's own ends rather than from the highest and lowest
1049
+ * number in THIS file's map. The two speculative levels are withheld across
1050
+ * every torrent at once while anything urgent is missing anywhere, so a
1051
+ * paused viewer's film has to compare as wanted-last against another film
1052
+ * somebody is watching — and relative to itself alone it would compare as
1053
+ * the most urgent thing there is.
1054
+ *
1055
+ * @param {{ priority?: number }} zone
1056
+ * @returns {number}
1057
+ */
1058
+ const levelOf = (zone) => {
1059
+ const priority = zone.priority ?? 0;
1060
+ if (priority <= NOBODY_IS_COMING) {
1061
+ return Urgency.BEHIND;
1062
+ }
1063
+ if (priority <= NOBODY_IS_COMING + 1) {
1064
+ return Urgency.TAIL;
1065
+ }
1066
+ return priority >= AT_THE_VIEWER ? Urgency.NEAR : Urgency.AHEAD;
1067
+ };
1068
+ ordered.forEach((zone, index) => {
1069
+ // A second of film sits at that fraction of the file. Constant bitrate is
1070
+ // an approximation, and it is the only one available without an index of
1071
+ // the container — near enough to say which stretch matters more, which is
1072
+ // all this decides.
1073
+ const byteStart = Math.max(0, Math.floor((zone.from / duration) * length));
1074
+ const byteEnd = Math.min(length - 1, Math.ceil((zone.to / duration) * length) - 1);
1075
+ if (byteEnd < byteStart) {
1076
+ return;
1077
+ }
1078
+ register.state({
1079
+ claimant: `${MAP_CLAIMANT}:${fileIndex}:${index}`,
1080
+ fileIndex,
1081
+ byteStart,
1082
+ byteEnd,
1083
+ urgency: levelOf(zone)
1084
+ });
1085
+ });
1086
+ // Bands the map no longer has: a viewer moved on, and what they had wanted
1087
+ // is not wanted by anybody now.
1088
+ for (const window of register.windows()) {
1089
+ if (!String(window.claimant).startsWith(`${MAP_CLAIMANT}:${fileIndex}:`)) {
1090
+ continue;
1091
+ }
1092
+ const index = Number(String(window.claimant).split(":").pop());
1093
+ if (!(index < ordered.length)) {
1094
+ register.withdraw(window.claimant);
1095
+ }
1096
+ }
1097
+ }
1098
+
995
1099
  #reportStalledDownloads() {
996
1100
  const now = Date.now();
997
1101
  for (const torrent of this.torrents.values()) {
@@ -432,6 +432,26 @@ export class TorrentWorkerClient {
432
432
  });
433
433
  }
434
434
 
435
+ /**
436
+ * Hand the download the priority map for one file.
437
+ *
438
+ * Seconds of film against a number, and nothing else: what is wanted and in
439
+ * what order. Turning that into bytes, into pieces, and into what the swarm
440
+ * is asked for is the download's own business, and so is what it keeps in
441
+ * memory.
442
+ *
443
+ * @param {{ sourceKey: string, fileIndex: number, durationSeconds: number, zones: { from: number, to: number, priority: number }[] }} params
444
+ * @returns {Promise<void>}
445
+ */
446
+ async setPriorityMap({ sourceKey, fileIndex, zones, durationSeconds }) {
447
+ await this.#caller.call(Command.PRIORITY_MAP, {
448
+ sourceKey,
449
+ fileIndex,
450
+ zones,
451
+ durationSeconds
452
+ });
453
+ }
454
+
435
455
  /**
436
456
  * Pre-fetch the head and tail the codec probe needs.
437
457
  *