@torrent-tv/proxy 2.107.33 → 2.107.35
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/bin/cli.js +0 -1
- package/package.json +1 -1
- package/server.js +17 -6
- package/services/encode/OutputOpening.js +1 -2
- package/services/encode/Renditions.js +6 -8
- package/services/encode/quality/QualityController.js +4 -5
- package/services/encode/quality/link-budget.js +23 -62
- package/services/encode/quality/output-format.js +21 -31
- package/services/encode/quality/serving-output.js +15 -15
- package/services/server/http-responses.js +1 -4
- package/services/server/wire-outputs.js +0 -7
- package/services/torrent/download/SwarmSelection.js +5 -0
- package/services/torrent/download/registry.js +8 -1
- package/services/transport/data-channel-handler.js +2 -12
- package/services/transport/delivery-probe.js +1 -5
- package/services/viewer/DownloadMaps.js +53 -4
- package/services/transport/delivery-shares.js +0 -108
package/bin/cli.js
CHANGED
|
@@ -405,7 +405,6 @@ try {
|
|
|
405
405
|
onSubtitleCues: (event) => dataChannelHandler?.publishSubtitleCues(event),
|
|
406
406
|
// Late-bound for the same reason: what the connections carry beyond the
|
|
407
407
|
// film is counted by the data-channel handler, built further down.
|
|
408
|
-
serviceShare: () => dataChannelHandler?.serviceShare?.() ?? null,
|
|
409
408
|
onStateChanged: () => void stateReporter.changed(),
|
|
410
409
|
onChaptersChanged: (event) => dataChannelHandler?.publishChaptersChanged(event)
|
|
411
410
|
});
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -140,9 +140,6 @@ export async function startProxyServer({
|
|
|
140
140
|
// The one software preset every picture is encoded at, for runs compared at
|
|
141
141
|
// one quality; null leaves the choice to the machine.
|
|
142
142
|
pinnedPreset = null,
|
|
143
|
-
// What viewers' connections carry beyond the film, per byte of film; the
|
|
144
|
-
// transport counts it and is built after this server, so it is late-bound.
|
|
145
|
-
serviceShare = () => null,
|
|
146
143
|
// Where the proxy writes its own log. Its DIRECTORY is what matters here:
|
|
147
144
|
// the browser's half of every session is written beside it, so the two are
|
|
148
145
|
// on one durable disk and join by name.
|
|
@@ -652,6 +649,7 @@ export async function startProxyServer({
|
|
|
652
649
|
return downloadMaps.refresh(sourceKey, fileIndex);
|
|
653
650
|
}).catch(error => logger.warn(`download map after a packet table: ${error?.message ?? error}`));
|
|
654
651
|
};
|
|
652
|
+
let outputPartsReady = false;
|
|
655
653
|
const downloadMaps = new DownloadMaps({
|
|
656
654
|
// The ranges a picture reads at open are named again by every segment;
|
|
657
655
|
// only the zone the swarm would rank first is sent (torrent-tv/meta#166).
|
|
@@ -668,6 +666,12 @@ export async function startProxyServer({
|
|
|
668
666
|
pack: (zones) => coalescedZones(zones),
|
|
669
667
|
log: (line) => logger.info(line),
|
|
670
668
|
observeWait: (sourceKey, event, fields) => waitTrace.source(sourceKey, event, fields),
|
|
669
|
+
// A file's own reads are wanted while a present viewer has the file chosen
|
|
670
|
+
// or on screen. The viewers are assembled below; until they exist no read
|
|
671
|
+
// can have been asked for by one.
|
|
672
|
+
fileWanted: (sourceKey, fileIndex) => (outputPartsReady ? outputParts.viewers.forSource(sourceKey) : [])
|
|
673
|
+
.some(viewer => viewer.source?.selectedFileIndex === fileIndex ||
|
|
674
|
+
viewer.source?.visibleFileIndices?.includes(fileIndex)),
|
|
671
675
|
resolvePlayback: async (map) => {
|
|
672
676
|
const fileKey = `${map.sourceKey}:${map.fileIndex}`;
|
|
673
677
|
if (map.zones.length === 0) {
|
|
@@ -1003,7 +1007,10 @@ export async function startProxyServer({
|
|
|
1003
1007
|
...(demand ? { priority: demand.priority, urgent: demand.urgent, deadlineAt: demand.deadlineAt, interval: demand, leadSeconds: demand.leadSeconds ?? 0 } : {}),
|
|
1004
1008
|
...(readPriority?.requestId ? { priority: readPriority.rank, urgent: readPriority.urgent === true,
|
|
1005
1009
|
deadlineAt: Number.isFinite(readPriority.deadlineAt) ? readPriority.deadlineAt : Number.POSITIVE_INFINITY,
|
|
1006
|
-
scope: "preparation" } : {})
|
|
1010
|
+
scope: "preparation" } : {}),
|
|
1011
|
+
// Neither a viewer's interval nor an encode input asked for it: the
|
|
1012
|
+
// file read itself, and that read is wanted while the file is.
|
|
1013
|
+
...(!demand && !readPriority?.requestId ? { owner: "file" } : {}) });
|
|
1007
1014
|
}
|
|
1008
1015
|
if (finished && result.kind === "result") {
|
|
1009
1016
|
void downloadMaps.refresh(sourceKey, fileIndex).catch(error =>
|
|
@@ -1041,9 +1048,12 @@ export async function startProxyServer({
|
|
|
1041
1048
|
observeWait: (sourceKey, event, fields) => waitTrace.source(sourceKey, event, fields),
|
|
1042
1049
|
pinnedPreset,
|
|
1043
1050
|
indexMemory,
|
|
1044
|
-
serviceShare,
|
|
1045
1051
|
readMetadataActivity: () => containerOrchestrator.activity(),
|
|
1046
|
-
onViewerChanged: () => {
|
|
1052
|
+
onViewerChanged: () => {
|
|
1053
|
+
void sourcePreparation?.refresh();
|
|
1054
|
+
void downloadMaps.viewersChanged().catch(error => logger.warn(`download maps after a viewer change: ${error.message}`));
|
|
1055
|
+
onStateChanged();
|
|
1056
|
+
},
|
|
1047
1057
|
onEncodersChanged: () => onStateChanged(),
|
|
1048
1058
|
sourceInputsFor: (output, index) => segmentInputs.inputsFor(output.file.sourceKey, output.outputKey, index),
|
|
1049
1059
|
readSourceMedia: async (params) => {
|
|
@@ -1337,6 +1347,7 @@ export async function startProxyServer({
|
|
|
1337
1347
|
await torrentPool.fillFileInBackground?.(torrent, fileIndex);
|
|
1338
1348
|
}
|
|
1339
1349
|
});
|
|
1350
|
+
outputPartsReady = true;
|
|
1340
1351
|
// Where the processor goes, and at a cold start what the main thread does
|
|
1341
1352
|
// with it, when the operator asks (torrent-tv/meta#177).
|
|
1342
1353
|
const startupProfile = startupCpuProfile({
|
|
@@ -599,8 +599,7 @@ export class OutputOpening {
|
|
|
599
599
|
linkMbps: viewerLinkMbps,
|
|
600
600
|
keys: [...this.#host.outputs.values()].map((other) => other.outputKey).concat(this.#host.segmentOutputFiles.addresses()),
|
|
601
601
|
readyAt: (key) => this.#host.segmentOutputFiles.isClosed(key, timeline.indexForTime(Math.max(0, startPositionSeconds))),
|
|
602
|
-
observedPeakMbps: (candidate) => this.#host.observedPeakMbps?.(candidate) ?? null
|
|
603
|
-
serviceShare: () => this.#host.serviceShare?.() ?? null
|
|
602
|
+
observedPeakMbps: (candidate) => this.#host.observedPeakMbps?.(candidate) ?? null
|
|
604
603
|
}
|
|
605
604
|
});
|
|
606
605
|
// Which picture rate the link was asked about, and where it came from
|
|
@@ -148,8 +148,7 @@ export class Renditions {
|
|
|
148
148
|
Number.isFinite(base.file.pictureKbps) ? base.file.pictureKbps / 1000 : null,
|
|
149
149
|
this.#host.observedPeakMbps?.(spec) ?? null
|
|
150
150
|
),
|
|
151
|
-
audioLoad
|
|
152
|
-
this.#host.serviceShare?.() ?? null
|
|
151
|
+
audioLoad
|
|
153
152
|
)
|
|
154
153
|
)
|
|
155
154
|
});
|
|
@@ -504,8 +503,8 @@ export class Renditions {
|
|
|
504
503
|
* size whose bounds are two neighbouring limits of `limitsFor(frame, file)` — the
|
|
505
504
|
* source-scaled nominal for the frame on screen, the same nominal used
|
|
506
505
|
* to open the output (`output-format.js`). Down,
|
|
507
|
-
* the highest lower limit
|
|
508
|
-
* only if
|
|
506
|
+
* the highest lower limit the measured link supports; up, the next limit
|
|
507
|
+
* only, and only if the measured link supports it. A copy has no limit and a hardware encode is
|
|
509
508
|
* given none, so neither has anywhere to move to here, and the caller's other
|
|
510
509
|
* lever — another height — is what remains.
|
|
511
510
|
*
|
|
@@ -551,11 +550,10 @@ export class Renditions {
|
|
|
551
550
|
}
|
|
552
551
|
const linkMbps = this.#host.linkMbpsOf(base, consumerId);
|
|
553
552
|
const audioLoad = this.viewerAudioLoadOf(base, consumerId);
|
|
554
|
-
const
|
|
555
|
-
const admits = (limit) => linkCouldCarry(linkMbps, loadOf(videoLoadOfLimit(limit), audioLoad, serviceShare)).admitted;
|
|
553
|
+
const fits = (limit) => linkCouldCarry(linkMbps, loadOf(videoLoadOfLimit(limit), audioLoad)).fits;
|
|
556
554
|
const target = direction === "down"
|
|
557
|
-
? limits.slice(at + 1).find(
|
|
558
|
-
: (at > 0 &&
|
|
555
|
+
? limits.slice(at + 1).find(fits)
|
|
556
|
+
: (at > 0 && fits(limits[at - 1]) ? limits[at - 1] : undefined);
|
|
559
557
|
if (target === undefined) {
|
|
560
558
|
return refuse(direction === "down"
|
|
561
559
|
? `no lower limit of ${encode.height}p is admitted by this viewer's link`
|
|
@@ -276,8 +276,8 @@ export class QualityController {
|
|
|
276
276
|
* being told (roadmap item 97, step 12). A limit is part of an output, so
|
|
277
277
|
* this is a move between outputs, prepared for one viewer.
|
|
278
278
|
* 2. ANOTHER HEIGHT, asked of the player in AUTO — only for a viewer with no
|
|
279
|
-
* higher limit to go to on the height they watch, and
|
|
280
|
-
* link
|
|
279
|
+
* higher limit to go to on the height they watch, and when the measured
|
|
280
|
+
* link supports the next height's whole load.
|
|
281
281
|
*
|
|
282
282
|
* Both follow one window: the machine and the viewer's link have held the
|
|
283
283
|
* output on screen, with room to spare, for four times as long as a step
|
|
@@ -382,10 +382,9 @@ export class QualityController {
|
|
|
382
382
|
const report = this.#host.linkReportOf(session, consumerId);
|
|
383
383
|
const load = loadOf(
|
|
384
384
|
this.#host.qualityOffer.videoLoadFor(base, height, this.#host.videoEncoder?.kind ?? ""),
|
|
385
|
-
this.#host.viewerAudioLoadOf(base, consumerId)
|
|
386
|
-
this.#host.serviceShare?.() ?? null
|
|
385
|
+
this.#host.viewerAudioLoadOf(base, consumerId)
|
|
387
386
|
);
|
|
388
|
-
return linkCouldCarry(report?.linkMbps ?? null, load).
|
|
387
|
+
return linkCouldCarry(report?.linkMbps ?? null, load).fits;
|
|
389
388
|
}
|
|
390
389
|
|
|
391
390
|
/**
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* the last report showed — and never as a reference to a viewer. Nothing here
|
|
6
6
|
* holds anybody, reads anything or can be asked at the wrong moment.
|
|
7
7
|
*
|
|
8
|
-
* WHAT A LINK CARRIES IS A LOAD, NOT A PICTURE
|
|
8
|
+
* WHAT A LINK CARRIES IS A LOAD, NOT A PICTURE. The
|
|
9
9
|
* viewer receives the picture AND the soundtrack they chose — inside the
|
|
10
10
|
* picture's own stream, or as a separate one — so both are added before the
|
|
11
11
|
* link is asked. And each part says how much its figure can be trusted:
|
|
@@ -14,20 +14,16 @@
|
|
|
14
14
|
* most a copied soundtrack's codec allows it to carry;
|
|
15
15
|
* 2. `estimated` — an average: a copied picture's or soundtrack's rate as the
|
|
16
16
|
* FILE states it, or the target a re-encoded soundtrack is asked for. An
|
|
17
|
-
* average is not a bound, so a load built on it is
|
|
18
|
-
*
|
|
17
|
+
* average is not a bound, so a load built on it is only estimated to fit
|
|
18
|
+
* and is never confirmed;
|
|
19
19
|
* 3. `unknown` — no figure at all: a hardware encoder given no limit, a copied
|
|
20
20
|
* soundtrack whose rate nothing states.
|
|
21
21
|
*
|
|
22
22
|
* A load is as trustworthy as its least trustworthy part.
|
|
23
23
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* proxy has delivered (`transport/delivery-shares.js`) and added on top of the
|
|
28
|
-
* picture and the sound. It is an average proportion and not a bound, so it
|
|
29
|
-
* moves the figure without deciding the load's class; unmeasured, nothing is
|
|
30
|
-
* added and the figures say so (`serviceShare: null`).
|
|
24
|
+
* Control traffic and measurement transfers cannot be apportioned to a new
|
|
25
|
+
* output from lifetime byte counters; doing so turned short or interrupted
|
|
26
|
+
* playback into fictitious per-video rates.
|
|
31
27
|
*/
|
|
32
28
|
|
|
33
29
|
import { AUDIO_TRANSCODE_KBPS, maxrateKbpsFor, nominalKbpsFor } from "../args.js";
|
|
@@ -52,7 +48,7 @@ const CLASS_ORDER = [PEAK_CLASS.KNOWN, PEAK_CLASS.ESTIMATED, PEAK_CLASS.UNKNOWN]
|
|
|
52
48
|
|
|
53
49
|
/**
|
|
54
50
|
* @typedef {{ mbps: number | null, peakClass: string }} LoadPart
|
|
55
|
-
* @typedef {{ video: LoadPart | null, audio: LoadPart | null,
|
|
51
|
+
* @typedef {{ video: LoadPart | null, audio: LoadPart | null, totalMbps: number | null, peakClass: string }} Load
|
|
56
52
|
*/
|
|
57
53
|
|
|
58
54
|
/**
|
|
@@ -233,52 +229,43 @@ export const SOUNDTRACK_MODE_CAUSE = Object.freeze({
|
|
|
233
229
|
*
|
|
234
230
|
* @param {LoadPart | null} video
|
|
235
231
|
* @param {LoadPart | null} audio
|
|
236
|
-
* @param {number | null} [serviceShare] - What the connection carries beyond
|
|
237
|
-
* the film, per byte of film, as the transport measured it; null while
|
|
238
|
-
* nothing has been measured.
|
|
239
232
|
* @returns {Load}
|
|
240
233
|
*/
|
|
241
|
-
export function loadOf(video, audio
|
|
234
|
+
export function loadOf(video, audio) {
|
|
242
235
|
const parts = [video, audio].filter(Boolean);
|
|
243
236
|
const peakClass = parts.reduce(
|
|
244
237
|
(worst, one) => (CLASS_ORDER.indexOf(one.peakClass) > CLASS_ORDER.indexOf(worst) ? one.peakClass : worst),
|
|
245
238
|
PEAK_CLASS.KNOWN
|
|
246
239
|
);
|
|
247
|
-
const share = Number.isFinite(serviceShare) && serviceShare >= 0 ? serviceShare : null;
|
|
248
240
|
const filmMbps = peakClass === PEAK_CLASS.UNKNOWN
|
|
249
241
|
? null
|
|
250
242
|
: parts.reduce((sum, one) => sum + one.mbps, 0);
|
|
251
|
-
|
|
252
|
-
const totalMbps = filmMbps === null ? null : filmMbps + (serviceMbps ?? 0);
|
|
253
|
-
return { video, audio, serviceMbps, serviceShare: share, totalMbps, peakClass };
|
|
243
|
+
return { video, audio, totalMbps: filmMbps, peakClass };
|
|
254
244
|
}
|
|
255
245
|
|
|
256
246
|
/**
|
|
257
247
|
* What a link that measured `linkMbps` does with a load.
|
|
258
248
|
*
|
|
259
|
-
* 1. nothing measured the link —
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
* 3. otherwise the load is compared with the usable share of the link: a
|
|
264
|
-
* `known` load that passes FITS and is confirmed, an `estimated` one that
|
|
265
|
-
* passes is admitted by estimate and is NOT confirmed.
|
|
249
|
+
* 1. nothing measured the link — no comparison can be made;
|
|
250
|
+
* 2. an `unknown` load against a measured link — `no safe bound`;
|
|
251
|
+
* 3. otherwise the load is compared with the measured link. The result helps
|
|
252
|
+
* choose a sustainable format, but every result remains playable.
|
|
266
253
|
*
|
|
267
254
|
* @param {number | null} linkMbps
|
|
268
255
|
* @param {Load} load
|
|
269
|
-
* @returns {{ verdict: string,
|
|
256
|
+
* @returns {{ verdict: string, confirmed: boolean, fits: boolean, linkMbps: number | null, load: Load }}
|
|
270
257
|
*/
|
|
271
258
|
export function linkCouldCarry(linkMbps, load) {
|
|
272
259
|
const measured = Number.isFinite(linkMbps) && linkMbps > 0;
|
|
273
|
-
const answer = (verdict,
|
|
260
|
+
const answer = (verdict, confirmed, fits) => ({
|
|
274
261
|
verdict,
|
|
275
|
-
admitted,
|
|
276
262
|
confirmed,
|
|
263
|
+
fits,
|
|
277
264
|
linkMbps: measured ? linkMbps : null,
|
|
278
265
|
load
|
|
279
266
|
});
|
|
280
267
|
if (!measured) {
|
|
281
|
-
return answer(LINK_VERDICT.NO_MEASUREMENT,
|
|
268
|
+
return answer(LINK_VERDICT.NO_MEASUREMENT, false, true);
|
|
282
269
|
}
|
|
283
270
|
if (load.peakClass === PEAK_CLASS.UNKNOWN) {
|
|
284
271
|
return answer(LINK_VERDICT.NO_SAFE_BOUND, false, false);
|
|
@@ -292,44 +279,20 @@ export function linkCouldCarry(linkMbps, load) {
|
|
|
292
279
|
}
|
|
293
280
|
return load.peakClass === PEAK_CLASS.KNOWN
|
|
294
281
|
? answer(LINK_VERDICT.FITS, true, true)
|
|
295
|
-
: answer(LINK_VERDICT.ESTIMATED_TO_FIT,
|
|
282
|
+
: answer(LINK_VERDICT.ESTIMATED_TO_FIT, false, true);
|
|
296
283
|
}
|
|
297
284
|
|
|
298
285
|
/**
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
* `no safe bound` names every part that has no figure — the picture, the
|
|
302
|
-
* soundtrack or both — because that, and not the link, is what the refusal is
|
|
303
|
-
* about. Any other refusal is about the size of the load and is described by
|
|
304
|
-
* the caller, who knows which outputs were weighed.
|
|
305
|
-
*
|
|
306
|
-
* @param {{ verdict: string, load: Load }} answer
|
|
307
|
-
* @param {string} otherwise - The words for a load that is too large.
|
|
308
|
-
* @returns {string}
|
|
309
|
-
*/
|
|
310
|
-
export function linkRefusalReason(answer, otherwise) {
|
|
311
|
-
if (answer?.verdict !== LINK_VERDICT.NO_SAFE_BOUND) {
|
|
312
|
-
return otherwise;
|
|
313
|
-
}
|
|
314
|
-
const missing = [
|
|
315
|
-
answer.load?.video?.peakClass === PEAK_CLASS.UNKNOWN ? "the picture" : "",
|
|
316
|
-
answer.load?.audio?.peakClass === PEAK_CLASS.UNKNOWN ? "the soundtrack" : ""
|
|
317
|
-
].filter(Boolean);
|
|
318
|
-
return `nothing states how much ${missing.length > 0 ? missing.join(" or ") : "this output"} would send, ` +
|
|
319
|
-
"so this viewer's link cannot be asked whether it fits";
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* How strongly an answer is admitted, for ordering: a load whose figure is a
|
|
286
|
+
* How strongly a sustainable answer ranks, for selection: a load whose figure is a
|
|
324
287
|
* bound before one whose figure is an average, and both before one with no
|
|
325
|
-
* figure
|
|
326
|
-
*
|
|
288
|
+
* figure. A load above the measured link remains playable but ranks below
|
|
289
|
+
* outputs the measurement supports.
|
|
327
290
|
*
|
|
328
|
-
* @param {{
|
|
291
|
+
* @param {{ fits: boolean, load: Load }} answer
|
|
329
292
|
* @returns {number}
|
|
330
293
|
*/
|
|
331
294
|
export function admissionRank(answer) {
|
|
332
|
-
if (!answer.
|
|
295
|
+
if (!answer.fits) {
|
|
333
296
|
return 0;
|
|
334
297
|
}
|
|
335
298
|
return 3 - CLASS_ORDER.indexOf(answer.load.peakClass);
|
|
@@ -350,8 +313,6 @@ export function linkAnswerFigures(answer) {
|
|
|
350
313
|
videoClass: answer.load.video?.peakClass ?? null,
|
|
351
314
|
audioMbps: answer.load.audio?.mbps ?? null,
|
|
352
315
|
audioClass: answer.load.audio?.peakClass ?? null,
|
|
353
|
-
serviceMbps: answer.load.serviceMbps ?? null,
|
|
354
|
-
serviceShare: answer.load.serviceShare ?? null,
|
|
355
316
|
totalMbps: answer.load.totalMbps,
|
|
356
317
|
peakClass: answer.load.peakClass
|
|
357
318
|
};
|
|
@@ -18,17 +18,16 @@
|
|
|
18
18
|
* for (`capKbps`), and declared at that source and size's nominal level. A
|
|
19
19
|
* hardware encoder is given no bound. Unknown source rates mean no bound.
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* (`link-budget.js`). In order:
|
|
21
|
+
* THE VIEWER'S LINK HELPS CHOOSE A SUSTAINABLE FORMAT, BUT NEVER DECIDES
|
|
22
|
+
* WHETHER THE VIDEO MAY OPEN. Every format is compared with the measured rate
|
|
23
|
+
* against the picture and soundtrack they chose (`link-budget.js`). In order:
|
|
25
24
|
*
|
|
26
25
|
* 1. an output already here that suits them (`serving-output.js`);
|
|
27
|
-
* 2. the size wanted, at the highest limit
|
|
28
|
-
* 3. in AUTO only, a smaller rung
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
26
|
+
* 2. the size wanted, at the highest limit the measured link supports;
|
|
27
|
+
* 3. in AUTO only, a smaller rung when its measured load fits;
|
|
28
|
+
* 4. when no sustainable format is available, prepare the first
|
|
29
|
+
* machine-supported format in this order and let playback readiness wait
|
|
30
|
+
* for its continuous media.
|
|
32
31
|
*
|
|
33
32
|
* Nothing here reads the machine or the disk: what it needs arrives as values
|
|
34
33
|
* and as functions over them.
|
|
@@ -39,7 +38,7 @@ import { buildResolutionLadder, canSustainOutput, pickSoftwarePreset } from "../
|
|
|
39
38
|
import { OutputSpec } from "../output/OutputSpec.js";
|
|
40
39
|
import { throughputAt } from "../throughput.js";
|
|
41
40
|
import { chooseServingOutput, nextRungAreaBelow, servingCandidates } from "./serving-output.js";
|
|
42
|
-
import { linkAnswerFigures, linkCouldCarry,
|
|
41
|
+
import { linkAnswerFigures, linkCouldCarry, loadOf, videoLoadOfSpec } from "./link-budget.js";
|
|
43
42
|
|
|
44
43
|
/**
|
|
45
44
|
* @param {object} params
|
|
@@ -67,7 +66,7 @@ import { linkAnswerFigures, linkCouldCarry, linkRefusalReason, loadOf, videoLoad
|
|
|
67
66
|
* soundtrack this viewer receives with the picture, from it or beside it.
|
|
68
67
|
* @param {(encode: object | null) => OutputSpec} params.specWith - The output
|
|
69
68
|
* with this picture format and every other part as the request made it.
|
|
70
|
-
* @param {{ mode: "auto" | "manual", linkMbps: number | null, keys: Iterable<string>, readyAt: (key: string) => boolean, observedPeakMbps?: (spec: OutputSpec) => number | null
|
|
69
|
+
* @param {{ mode: "auto" | "manual", linkMbps: number | null, keys: Iterable<string>, readyAt: (key: string) => boolean, observedPeakMbps?: (spec: OutputSpec) => number | null }} params.serving
|
|
71
70
|
* @returns {{
|
|
72
71
|
* spec: OutputSpec | null,
|
|
73
72
|
* budget: object | null,
|
|
@@ -103,8 +102,7 @@ export function decideOutputFormat({
|
|
|
103
102
|
loadOf(
|
|
104
103
|
videoLoadOfSpec(spec, Number.isFinite(source.pictureKbps) ? source.pictureKbps / 1000 : null,
|
|
105
104
|
serving.observedPeakMbps?.(spec) ?? null),
|
|
106
|
-
audioLoad
|
|
107
|
-
serving.serviceShare?.() ?? null
|
|
105
|
+
audioLoad
|
|
108
106
|
)
|
|
109
107
|
);
|
|
110
108
|
const given = (spec, answer, extra = {}) => ({
|
|
@@ -122,7 +120,7 @@ export function decideOutputFormat({
|
|
|
122
120
|
wantedKey: spec.toKey(),
|
|
123
121
|
reusedKey: null,
|
|
124
122
|
answer,
|
|
125
|
-
unavailable: { reason, figures: answer ? linkAnswerFigures(answer) : null
|
|
123
|
+
unavailable: { reason, figures: answer ? linkAnswerFigures(answer) : null },
|
|
126
124
|
...extra
|
|
127
125
|
});
|
|
128
126
|
|
|
@@ -134,9 +132,7 @@ export function decideOutputFormat({
|
|
|
134
132
|
return given(spec, null);
|
|
135
133
|
}
|
|
136
134
|
const answer = judge(spec);
|
|
137
|
-
return answer
|
|
138
|
-
? given(spec, answer)
|
|
139
|
-
: refused(spec, answer, linkRefusalReason(answer, "the source's own picture is more than this viewer's link carries"));
|
|
135
|
+
return given(spec, answer);
|
|
140
136
|
}
|
|
141
137
|
|
|
142
138
|
const ceiling = computeOutputDimensions(target.width, target.height, source.width, source.height);
|
|
@@ -277,6 +273,7 @@ export function decideOutputFormat({
|
|
|
277
273
|
}
|
|
278
274
|
|
|
279
275
|
let anySizeHeld = false;
|
|
276
|
+
let fallback = null;
|
|
280
277
|
if (machineCan(width, height)) {
|
|
281
278
|
for (const spec of wantedFormats) {
|
|
282
279
|
if (!admittedByMachine(spec)) {
|
|
@@ -284,7 +281,8 @@ export function decideOutputFormat({
|
|
|
284
281
|
}
|
|
285
282
|
anySizeHeld = true;
|
|
286
283
|
const answer = judge(spec);
|
|
287
|
-
|
|
284
|
+
fallback ??= { spec, answer };
|
|
285
|
+
if (answer.fits) {
|
|
288
286
|
return given(spec, answer, { budget: budgetFor(spec), wantedKey });
|
|
289
287
|
}
|
|
290
288
|
}
|
|
@@ -300,7 +298,8 @@ export function decideOutputFormat({
|
|
|
300
298
|
}
|
|
301
299
|
anySizeHeld = true;
|
|
302
300
|
const answer = judge(spec);
|
|
303
|
-
|
|
301
|
+
fallback ??= { spec, answer };
|
|
302
|
+
if (answer.fits) {
|
|
304
303
|
return given(spec, answer, { budget: budgetFor(spec), wantedKey });
|
|
305
304
|
}
|
|
306
305
|
}
|
|
@@ -329,16 +328,7 @@ export function decideOutputFormat({
|
|
|
329
328
|
}
|
|
330
329
|
};
|
|
331
330
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
wantedAnswer,
|
|
336
|
-
linkRefusalReason(
|
|
337
|
-
wantedAnswer,
|
|
338
|
-
serving.mode === "manual"
|
|
339
|
-
? "no limit of the size picked is admitted by this viewer's link"
|
|
340
|
-
: "no size and limit this viewer's ladder offers is admitted by their link"
|
|
341
|
-
),
|
|
342
|
-
{ budget, wantedKey }
|
|
343
|
-
);
|
|
331
|
+
// Link measurements are advisory. If none of the sustainable formats can be
|
|
332
|
+
// used yet, keep the best machine-supported output and let readiness wait.
|
|
333
|
+
return given(fallback.spec, fallback.answer, { budget: budgetFor(fallback.spec), wantedKey });
|
|
344
334
|
}
|
|
@@ -3,14 +3,14 @@
|
|
|
3
3
|
* encode of the format they would otherwise be given.
|
|
4
4
|
*
|
|
5
5
|
* Decided with the user 2026-09-16, and made one rule for every path on
|
|
6
|
-
* 2026-09-23
|
|
7
|
-
*
|
|
6
|
+
* 2026-09-23. Quality is compared by the number of points in the picture.
|
|
7
|
+
* An output qualifies only if the piece at the viewer's
|
|
8
8
|
* position is already made — material somewhere else in the film spares them
|
|
9
|
-
* nothing —
|
|
10
|
-
*
|
|
9
|
+
* nothing — and the viewer's own link can carry its load (`link-budget.js`)
|
|
10
|
+
* where possible. A load whose figure is a bound (`known`) is
|
|
11
11
|
* taken before one whose figure is an average (`estimated`), whatever their
|
|
12
|
-
* sizes
|
|
13
|
-
*
|
|
12
|
+
* sizes. If none fit the measured rate, format selection prepares a supported
|
|
13
|
+
* output and readiness waits for media to arrive.
|
|
14
14
|
*
|
|
15
15
|
* AUTOMATIC:
|
|
16
16
|
* 1. at least the quality wanted — the nearest to it;
|
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
* waiting: this machine does not hold it at the speed the file needs — the
|
|
21
21
|
* largest of those.
|
|
22
22
|
*
|
|
23
|
-
* CHOSEN BY HAND: exactly the size chosen — of
|
|
24
|
-
*
|
|
25
|
-
* not matter to the viewer.
|
|
23
|
+
* CHOSEN BY HAND: exactly the size chosen — of sustainable outputs, use the
|
|
24
|
+
* one with the highest bitrate. Which encoder made it and at which speed
|
|
25
|
+
* setting do not matter to the viewer.
|
|
26
26
|
*
|
|
27
27
|
* No reading of disk or machine here: every figure arrives as a plain value.
|
|
28
28
|
*/
|
|
@@ -50,7 +50,7 @@ import { admissionRank } from "./link-budget.js";
|
|
|
50
50
|
* keep up with what the file needs. False where nothing measured it.
|
|
51
51
|
* @param {(candidate: Candidate) => boolean} [params.canProduce] - Whether
|
|
52
52
|
* this machine can keep producing an output that is not finished.
|
|
53
|
-
* @param {(candidate: Candidate) => {
|
|
53
|
+
* @param {(candidate: Candidate) => { fits: boolean, load: object }} params.judge -
|
|
54
54
|
* What this viewer's link does with the candidate's whole load.
|
|
55
55
|
* @param {Candidate[]} params.candidates
|
|
56
56
|
* @returns {{ key: string, answer: object } | null} The output to serve, or
|
|
@@ -60,7 +60,7 @@ export function chooseServingOutput({ mode, wanted, nextLowerArea, atRisk, canPr
|
|
|
60
60
|
const admitted = (candidates ?? [])
|
|
61
61
|
.filter((candidate) => candidate.readyHere === true && canProduce(candidate))
|
|
62
62
|
.map((candidate) => ({ candidate, answer: judge(candidate) }))
|
|
63
|
-
.filter((one) => one.answer.
|
|
63
|
+
.filter((one) => one.answer.fits);
|
|
64
64
|
const areaOf = (one) => one.candidate.width * one.candidate.height;
|
|
65
65
|
const rateOf = (one) => one.answer.load.totalMbps ?? 0;
|
|
66
66
|
const byRank = (left, right) => admissionRank(right.answer) - admissionRank(left.answer);
|
|
@@ -185,8 +185,8 @@ export function servingCandidates({ wanted, keys, source, encoderName, readyAt }
|
|
|
185
185
|
|
|
186
186
|
/**
|
|
187
187
|
* Whether ONE output may serve this viewer: the same material as the picture
|
|
188
|
-
* it would stand in for, the size their mode requires, and
|
|
189
|
-
*
|
|
188
|
+
* it would stand in for, the size their mode requires, and its link estimate
|
|
189
|
+
* for quality selection.
|
|
190
190
|
*
|
|
191
191
|
* THE ONE RULE EVERY PATH ASKS (roadmap item 97, step 11). The picture
|
|
192
192
|
* answering for its own height, an output found already producing a height,
|
|
@@ -201,7 +201,7 @@ export function servingCandidates({ wanted, keys, source, encoderName, readyAt }
|
|
|
201
201
|
* output produces; for a copy, the source's.
|
|
202
202
|
* @param {{ width: number, height: number } | null} params.wanted - The size a
|
|
203
203
|
* viewer who picked by hand must get exactly; ignored in AUTO.
|
|
204
|
-
* @param {(spec: OutputSpec) => {
|
|
204
|
+
* @param {(spec: OutputSpec) => { fits: boolean }} params.judge
|
|
205
205
|
* @returns {{ suits: boolean, answer: object | null, reason: string }}
|
|
206
206
|
*/
|
|
207
207
|
export function outputSuits({ spec, pictureSpec, mode, size, wanted, judge }) {
|
|
@@ -212,7 +212,7 @@ export function outputSuits({ spec, pictureSpec, mode, size, wanted, judge }) {
|
|
|
212
212
|
return { suits: false, answer: null, reason: "not the size picked" };
|
|
213
213
|
}
|
|
214
214
|
const answer = judge(spec);
|
|
215
|
-
return answer.
|
|
215
|
+
return answer.fits
|
|
216
216
|
? { suits: true, answer, reason: answer.verdict }
|
|
217
217
|
: { suits: false, answer, reason: answer.verdict };
|
|
218
218
|
}
|
|
@@ -2,10 +2,7 @@
|
|
|
2
2
|
* Shared HTTP responses used by more than one route.
|
|
3
3
|
*/
|
|
4
4
|
/**
|
|
5
|
-
* The answer given when no output
|
|
6
|
-
* or could produce is admitted by their link. Machine-readable, because the
|
|
7
|
-
* page has to explain it to the viewer and offer what they can do; the
|
|
8
|
-
* figures are the ones the decision was made on.
|
|
5
|
+
* The answer given when no output can be opened.
|
|
9
6
|
*
|
|
10
7
|
* 409 and not a 5xx: nothing is wrong with the proxy, and repeating the same
|
|
11
8
|
* request against the same link gives the same answer.
|
|
@@ -239,10 +239,6 @@ export function wireOutputs({
|
|
|
239
239
|
encodeInputs = null,
|
|
240
240
|
indexMemory = null,
|
|
241
241
|
budgetPolicy = null,
|
|
242
|
-
// What viewers' connections carry beyond the film, per byte of film, as
|
|
243
|
-
// the transport measured it (`transport/delivery-shares.js`); null while
|
|
244
|
-
// nothing has been delivered.
|
|
245
|
-
serviceShare = () => null,
|
|
246
242
|
onViewerChanged = null,
|
|
247
243
|
// Told when an encoder starts or ends: the room this host has for one more
|
|
248
244
|
// encode changes with it, and the server chooses proxies by that room.
|
|
@@ -400,7 +396,6 @@ export function wireOutputs({
|
|
|
400
396
|
// re-encoded where it may be, and copied where it may not.
|
|
401
397
|
get transcodeEnabled() { return parts.enabled; },
|
|
402
398
|
observedPeakMbps: (spec) => parts.localObservations?.peakMbps(spec) ?? null,
|
|
403
|
-
serviceShare,
|
|
404
399
|
// A viewer has been moved onto an output prepared for them: how long it
|
|
405
400
|
// took, and what they held.
|
|
406
401
|
notePreparation: (output, seconds, bufferedSec) =>
|
|
@@ -599,7 +594,6 @@ export function wireOutputs({
|
|
|
599
594
|
// The largest peak an output of this mode and rate control has been seen
|
|
600
595
|
// carrying here: the only figure an encoder with no bound of its own has.
|
|
601
596
|
observedPeakMbps: (spec) => parts.localObservations?.peakMbps(spec) ?? null,
|
|
602
|
-
serviceShare,
|
|
603
597
|
limitsFor: (frame, file) => parts.limitsFor(frame, file),
|
|
604
598
|
readSourceMedia,
|
|
605
599
|
get encodeInputs() { return parts.encodeInputs; },
|
|
@@ -1268,7 +1262,6 @@ export function wireOutputs({
|
|
|
1268
1262
|
// otherwise.
|
|
1269
1263
|
// The quality budget: what each output is asked to step to, and the bitrate ceiling a viewer's link sets. Everything it reads of the rest of the proxy is listed here.
|
|
1270
1264
|
parts.quality = new QualityController({
|
|
1271
|
-
serviceShare,
|
|
1272
1265
|
// The longest an output of this mode has been seen taking to be ready for
|
|
1273
1266
|
// a viewer moving onto it, in milliseconds, or null.
|
|
1274
1267
|
observedPreparationMs: (output) => {
|
|
@@ -89,6 +89,9 @@ export class SwarmSelection {
|
|
|
89
89
|
const strictFirstClass = first !== null && !isConditional(first.urgency);
|
|
90
90
|
/** @type {Map<string, { from: number, to: number, priority: number }>} */
|
|
91
91
|
const wanted = new Map();
|
|
92
|
+
// Who asked for the first class here, for the line that says what the
|
|
93
|
+
// swarm is told first: which file and which request holds it.
|
|
94
|
+
const holders = new Set();
|
|
92
95
|
for (const priority of [1, 0]) {
|
|
93
96
|
const ranges = [];
|
|
94
97
|
for (const window of missing) {
|
|
@@ -99,6 +102,7 @@ export class SwarmSelection {
|
|
|
99
102
|
&& window.urgency === first.urgency && window.priority >= first.priority;
|
|
100
103
|
if (strictFirstClass && !inFirstClass) continue;
|
|
101
104
|
if ((inFirstClass ? 1 : 0) !== priority) continue;
|
|
105
|
+
if (priority === 1) holders.add(`${window.fileIndex}:${window.requestId}`);
|
|
102
106
|
const range = this.#piecesFor(window);
|
|
103
107
|
if (range) ranges.push(range);
|
|
104
108
|
}
|
|
@@ -114,6 +118,7 @@ export class SwarmSelection {
|
|
|
114
118
|
for (const range of union) wanted.set(`${range.from}-${range.to}-${priority}`, range);
|
|
115
119
|
if (priority === 1) {
|
|
116
120
|
this.firstClassPieces = union.map(range => range.from === range.to ? `${range.from}` : `${range.from}-${range.to}`);
|
|
121
|
+
this.firstClassHolders = [...holders].sort();
|
|
117
122
|
this.#required = strictFirstClass ? union.map(({ from, to }) => ({ from, to })) : [];
|
|
118
123
|
}
|
|
119
124
|
}
|
|
@@ -285,9 +285,16 @@ export function reconcileAll() {
|
|
|
285
285
|
for (const entry of entries) entry.limit.sample();
|
|
286
286
|
// What the swarm is told to fetch FIRST, said when it changes. Lower classes
|
|
287
287
|
// are withheld because a wire with no first-class piece would fall through.
|
|
288
|
+
// And who asked for it, as file:request: a class one torrent holds is what
|
|
289
|
+
// every other torrent waits behind, so the line names the request holding it.
|
|
290
|
+
const holdersOf = entry => {
|
|
291
|
+
const holders = entry.selection.firstClassPieces?.length ? entry.selection.firstClassHolders ?? [] : [];
|
|
292
|
+
return holders.length ? ` (held by ${holders.slice(0, 4).join(", ")}${holders.length > 4 ? ` +${holders.length - 4} more` : ""})` : "";
|
|
293
|
+
};
|
|
288
294
|
const said = entries.map(entry => `${String(entry.torrent.infoHash ?? "").slice(0, 8)}: ` +
|
|
289
295
|
`${(entry.selection.firstClassPieces ?? []).slice(0, 8).join(",") || "nothing"}` +
|
|
290
|
-
`${(entry.selection.firstClassPieces?.length ?? 0) > 8 ? ` +${entry.selection.firstClassPieces.length - 8} more` : ""}`
|
|
296
|
+
`${(entry.selection.firstClassPieces?.length ?? 0) > 8 ? ` +${entry.selection.firstClassPieces.length - 8} more` : ""}` +
|
|
297
|
+
holdersOf(entry)).join("; ");
|
|
291
298
|
const head = firstClass ? `${urgencyName(firstClass.urgency)} priority ${firstClass.priority}` : "none";
|
|
292
299
|
firstClassStated = `${head} — pieces ${said}`;
|
|
293
300
|
return { torrents: entries.length, speculativeAllowed, stated, withdrawn };
|
|
@@ -47,7 +47,6 @@
|
|
|
47
47
|
/** @import { DataChannel } from 'node-datachannel' */
|
|
48
48
|
|
|
49
49
|
import { createDeliveryProbe, PROBE_INTERVAL_MS } from "./delivery-probe.js";
|
|
50
|
-
import { DeliveryShares } from "./delivery-shares.js";
|
|
51
50
|
import { Agent } from "undici";
|
|
52
51
|
|
|
53
52
|
// Route availability and caller cancellation determine media request lifetime.
|
|
@@ -885,7 +884,6 @@ export function createDataChannelHandler({
|
|
|
885
884
|
}
|
|
886
885
|
try {
|
|
887
886
|
channel.sendMessage(message);
|
|
888
|
-
deliveryShares.recordSent(Buffer.byteLength(message));
|
|
889
887
|
sent += 1;
|
|
890
888
|
} catch {
|
|
891
889
|
// Closed between the lookup and the send. The subscription belongs to
|
|
@@ -903,9 +901,7 @@ export function createDataChannelHandler({
|
|
|
903
901
|
/** Channel-scoped requests cannot be cancelled by a different connection. */
|
|
904
902
|
const activeRequests = new WeakMap();
|
|
905
903
|
|
|
906
|
-
//
|
|
907
|
-
// (`delivery-shares.js`), counted where both halves are seen.
|
|
908
|
-
const deliveryShares = new DeliveryShares();
|
|
904
|
+
// Transport delivery observations used to report link quality.
|
|
909
905
|
const { watchSendQueue, readDelivery, noteBrowserReport } = makeSendQueueWatcher({
|
|
910
906
|
log: (message) => log(message),
|
|
911
907
|
getTransportSnapshot,
|
|
@@ -931,8 +927,7 @@ export function createDataChannelHandler({
|
|
|
931
927
|
readDelivery,
|
|
932
928
|
getTransportSnapshot,
|
|
933
929
|
witness,
|
|
934
|
-
usrsctpState
|
|
935
|
-
onSent: (bytes) => deliveryShares.recordSent(bytes)
|
|
930
|
+
usrsctpState
|
|
936
931
|
});
|
|
937
932
|
|
|
938
933
|
/**
|
|
@@ -1307,7 +1302,6 @@ export function createDataChannelHandler({
|
|
|
1307
1302
|
if (done) {
|
|
1308
1303
|
chunks += body.flush();
|
|
1309
1304
|
sendChunk(channel, requestId, null, true);
|
|
1310
|
-
deliveryShares.recordBody(path, totalBytes);
|
|
1311
1305
|
const elapsedMs = Date.now() - sendStartedAt;
|
|
1312
1306
|
let bufferedNow = 0;
|
|
1313
1307
|
try { bufferedNow = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0; } catch { /* ignore */ }
|
|
@@ -1396,7 +1390,6 @@ export function createDataChannelHandler({
|
|
|
1396
1390
|
try {
|
|
1397
1391
|
const frame = encodeFrame(requestIdBytes(requestId), bytes, done);
|
|
1398
1392
|
channel.sendMessageBinary(frame);
|
|
1399
|
-
deliveryShares.recordSent(frame.length);
|
|
1400
1393
|
} catch {
|
|
1401
1394
|
// Channel closed between check and send — safe to ignore.
|
|
1402
1395
|
}
|
|
@@ -1503,7 +1496,6 @@ export function createDataChannelHandler({
|
|
|
1503
1496
|
try {
|
|
1504
1497
|
const text = JSON.stringify(message);
|
|
1505
1498
|
channel.sendMessage(text);
|
|
1506
|
-
deliveryShares.recordSent(Buffer.byteLength(text));
|
|
1507
1499
|
} catch {
|
|
1508
1500
|
// Channel closed between check and send — safe to ignore.
|
|
1509
1501
|
}
|
|
@@ -1512,8 +1504,6 @@ export function createDataChannelHandler({
|
|
|
1512
1504
|
return {
|
|
1513
1505
|
handleChannel,
|
|
1514
1506
|
publishSubtitleCues,
|
|
1515
|
-
/** What the film costs viewers' links beyond itself, per byte of film, or null. */
|
|
1516
|
-
serviceShare: () => deliveryShares.serviceShare(),
|
|
1517
1507
|
publishChaptersChanged
|
|
1518
1508
|
};
|
|
1519
1509
|
}
|
|
@@ -427,10 +427,7 @@ export function createDeliveryProbe({
|
|
|
427
427
|
readDelivery,
|
|
428
428
|
getTransportSnapshot,
|
|
429
429
|
witness,
|
|
430
|
-
usrsctpState
|
|
431
|
-
// Every probe handed to a channel, in bytes: what it costs the viewer's link
|
|
432
|
-
// is counted with everything else the connection carries.
|
|
433
|
-
onSent = () => {}
|
|
430
|
+
usrsctpState
|
|
434
431
|
}) {
|
|
435
432
|
/** @type {Map<string, ProbeConnection>} */
|
|
436
433
|
const connections = new Map();
|
|
@@ -461,7 +458,6 @@ export function createDeliveryProbe({
|
|
|
461
458
|
for (const channel of connection.channels.keys()) {
|
|
462
459
|
try {
|
|
463
460
|
channel.sendMessage(message);
|
|
464
|
-
onSent(Buffer.byteLength(message));
|
|
465
461
|
} catch {
|
|
466
462
|
// A channel closing between the check and the send is ordinary.
|
|
467
463
|
}
|
|
@@ -21,13 +21,21 @@ export class DownloadMaps {
|
|
|
21
21
|
/** What the torrent is told of the playback zones: as many windows as say the same thing. */
|
|
22
22
|
#pack;
|
|
23
23
|
#observeWait;
|
|
24
|
+
/**
|
|
25
|
+
* Whether a present viewer holds this file, chosen or listed. A read the
|
|
26
|
+
* file itself made — its track table, media info, keyframes, cover — has no
|
|
27
|
+
* owner of its own, so it is wanted exactly while the file is.
|
|
28
|
+
*/
|
|
29
|
+
#fileWanted;
|
|
24
30
|
|
|
25
31
|
constructor({ publish, resolvePlayback = async map => map.zones, log = null, now = Date.now,
|
|
26
|
-
clock = () => performance.timeOrigin + performance.now(), pack = zones => zones, observeWait = null
|
|
32
|
+
clock = () => performance.timeOrigin + performance.now(), pack = zones => zones, observeWait = null,
|
|
33
|
+
fileWanted = () => true }) {
|
|
27
34
|
if (typeof publish !== "function") throw new TypeError("Download map publication is required.");
|
|
28
35
|
this.#publish = publish;
|
|
29
36
|
this.#resolvePlayback = resolvePlayback;
|
|
30
37
|
this.#pack = pack;
|
|
38
|
+
this.#fileWanted = fileWanted;
|
|
31
39
|
this.#log = typeof log === "function" ? log : null;
|
|
32
40
|
this.#now = now;
|
|
33
41
|
this.#clock = clock;
|
|
@@ -178,7 +186,14 @@ export class DownloadMaps {
|
|
|
178
186
|
return Boolean(file && zoneIndexOf(file).exactly({ from, to }));
|
|
179
187
|
}
|
|
180
188
|
|
|
181
|
-
|
|
189
|
+
/**
|
|
190
|
+
* @param {object} params
|
|
191
|
+
* @param {"file"} [params.owner] - `"file"` for a read the file made for
|
|
192
|
+
* itself with no viewer's demand behind it. Such a statement is published
|
|
193
|
+
* only while {@link DownloadMaps#viewersChanged}'s question says the file
|
|
194
|
+
* is held by somebody.
|
|
195
|
+
*/
|
|
196
|
+
metadata({ sourceKey, fileIndex, statement, result, priority = 100, urgent = true, deadlineAt = 0, interval, leadSeconds = 0, scope = "playback", order = 0, owner }) {
|
|
182
197
|
if (!Number.isFinite(leadSeconds) || leadSeconds < 0) throw new TypeError("Metadata preparation time must be finite and nonnegative.");
|
|
183
198
|
const file = this.#file(sourceKey, fileIndex);
|
|
184
199
|
if (result.kind === "needs-ranges") {
|
|
@@ -189,7 +204,8 @@ export class DownloadMaps {
|
|
|
189
204
|
urgent = current.urgent;
|
|
190
205
|
deadlineAt = Number.isFinite(current.deadlineAt) ? current.deadlineAt - leadSeconds * 1000 : current.deadlineAt;
|
|
191
206
|
}
|
|
192
|
-
const entry = { ranges: result.ranges, requestId: result.requestId, priority, urgent, deadlineAt, interval, leadSeconds, scope, order
|
|
207
|
+
const entry = { ranges: result.ranges, requestId: result.requestId, priority, urgent, deadlineAt, interval, leadSeconds, scope, order,
|
|
208
|
+
...(owner === "file" ? { owner } : {}) };
|
|
193
209
|
const previous = file.metadata.get(statement);
|
|
194
210
|
if (previous && sameMetadata(previous, entry) && this.#standing(file)) return this.#unchanged(file);
|
|
195
211
|
file.metadata.set(statement, entry);
|
|
@@ -254,6 +270,35 @@ export class DownloadMaps {
|
|
|
254
270
|
return Promise.all(changed);
|
|
255
271
|
}
|
|
256
272
|
|
|
273
|
+
/**
|
|
274
|
+
* The viewers changed: a file's own reads are published again where whether
|
|
275
|
+
* anybody holds the file has changed.
|
|
276
|
+
*
|
|
277
|
+
* A read the file made for itself states its bytes at the urgency of a
|
|
278
|
+
* stopped viewer, and the swarm fetches only the most urgent class across
|
|
279
|
+
* every torrent. Kept after its viewer had gone, the head of a film nobody
|
|
280
|
+
* was opening any more held that class for the whole proxy: its swarm gave
|
|
281
|
+
* nothing, and every other film was told to fetch only the piece its viewer
|
|
282
|
+
* stood on (Home Assistant 2026-10-26, PPPD-452, torrent-tv/meta#239).
|
|
283
|
+
*
|
|
284
|
+
* @returns {Promise<unknown[]>}
|
|
285
|
+
*/
|
|
286
|
+
viewersChanged() {
|
|
287
|
+
const sent = [];
|
|
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(", ")}]`);
|
|
297
|
+
sent.push(this.#emit(file));
|
|
298
|
+
}
|
|
299
|
+
return Promise.all(sent);
|
|
300
|
+
}
|
|
301
|
+
|
|
257
302
|
forget(sourceKey, fileIndex, { keepPreparation = false } = {}) {
|
|
258
303
|
const file = this.#file(sourceKey, fileIndex);
|
|
259
304
|
this.#setZones(file, []);
|
|
@@ -302,7 +347,10 @@ export class DownloadMaps {
|
|
|
302
347
|
// Compared and sent as the torrent is to act on it: the playback zones
|
|
303
348
|
// packed into as few windows as say the same (`coalescedZones`).
|
|
304
349
|
const zones = [...this.#packedPlayback(file)];
|
|
305
|
-
|
|
350
|
+
const ownReadsWanted = this.#fileWanted(file.sourceKey, file.fileIndex) === true;
|
|
351
|
+
file.ownReadsWanted = ownReadsWanted;
|
|
352
|
+
for (const { ranges, requestId, priority, urgent, deadlineAt, order, owner } of file.metadata.values()) {
|
|
353
|
+
if (owner === "file" && !ownReadsWanted) continue;
|
|
306
354
|
for (const [byteStart, byteEnd] of ranges) {
|
|
307
355
|
zones.push({ byteStart, byteEnd, priority, urgent, downloadOnly: true, requestId, deadlineAt, ...(order ? { order } : {}) });
|
|
308
356
|
}
|
|
@@ -520,6 +568,7 @@ function sameMetadata(left, right) {
|
|
|
520
568
|
return left.requestId === right.requestId && Object.is(left.priority, right.priority) &&
|
|
521
569
|
left.urgent === right.urgent && Object.is(left.deadlineAt, right.deadlineAt) &&
|
|
522
570
|
Object.is(left.leadSeconds, right.leadSeconds) && left.scope === right.scope && left.order === right.order &&
|
|
571
|
+
left.owner === right.owner &&
|
|
523
572
|
(left.interval?.from ?? null) === (right.interval?.from ?? null) &&
|
|
524
573
|
(left.interval?.to ?? null) === (right.interval?.to ?? null) &&
|
|
525
574
|
left.ranges.length === right.ranges.length &&
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file How much of what this proxy hands to its viewers' connections is not
|
|
3
|
-
* the film.
|
|
4
|
-
*
|
|
5
|
-
* A viewer's link carries the picture and the soundtrack AND everything else
|
|
6
|
-
* this proxy sends down the same connection: the framing of every data-channel
|
|
7
|
-
* message, playlists, answers to progress polls, delivery probes, pushed
|
|
8
|
-
* subtitle cues, segments sent and then abandoned. A load that counts only the
|
|
9
|
-
* picture and the sound asks the link for less than it will be given
|
|
10
|
-
* (torrent-tv/meta#169).
|
|
11
|
-
*
|
|
12
|
-
* What is counted, all of it in bytes handed to a data channel, which is the
|
|
13
|
-
* level the browser's own link measurement is taken at — application bytes,
|
|
14
|
-
* without SCTP, DTLS, UDP or IP framing — and all of it at the moment it is
|
|
15
|
-
* sent, so the three counts always describe the same messages:
|
|
16
|
-
*
|
|
17
|
-
* 1. `sent` — every message handed to a channel, frame headers included;
|
|
18
|
-
* 2. `media` — the bodies of segment and init responses delivered whole;
|
|
19
|
-
* 3. `measurement` — the bodies of the link measurement and the delivery sink,
|
|
20
|
-
* which are sent when a connection is made or on request and are not part
|
|
21
|
-
* of watching a film.
|
|
22
|
-
*
|
|
23
|
-
* The share is `(sent - media - measurement) / media`: what the film costs
|
|
24
|
-
* the link beyond itself, per byte of film. Counted over the life of the
|
|
25
|
-
* process and across every connection, because a viewer about to open a film
|
|
26
|
-
* has no history of their own and the traffic around a film is the proxy's,
|
|
27
|
-
* not the person's.
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
/** Which kind of response a request path asks for. */
|
|
31
|
-
export const DELIVERY_KIND = Object.freeze({
|
|
32
|
-
MEDIA: "media",
|
|
33
|
-
MEASUREMENT: "measurement",
|
|
34
|
-
SERVICE: "service"
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
const MEDIA_FILE = /\/(?:segment-\d+\.[a-z0-9]+|init\.mp4)$/u;
|
|
38
|
-
const MEASUREMENT_ROUTE = /^\/api\/(?:link-probe|delivery-sink)$/u;
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* What a data-channel request path asks for.
|
|
42
|
-
*
|
|
43
|
-
* @param {string} path - The request path, query included or not.
|
|
44
|
-
* @returns {string} One of {@link DELIVERY_KIND}.
|
|
45
|
-
*/
|
|
46
|
-
export function deliveryKindOf(path) {
|
|
47
|
-
const bare = String(path ?? "").split("?")[0];
|
|
48
|
-
if (MEDIA_FILE.test(bare)) return DELIVERY_KIND.MEDIA;
|
|
49
|
-
if (MEASUREMENT_ROUTE.test(bare)) return DELIVERY_KIND.MEASUREMENT;
|
|
50
|
-
return DELIVERY_KIND.SERVICE;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export class DeliveryShares {
|
|
54
|
-
#sent = 0;
|
|
55
|
-
#media = 0;
|
|
56
|
-
#measurement = 0;
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* A response body handed over whole.
|
|
60
|
-
*
|
|
61
|
-
* @param {string} path
|
|
62
|
-
* @param {number} bytes
|
|
63
|
-
*/
|
|
64
|
-
recordBody(path, bytes) {
|
|
65
|
-
if (!(Number.isFinite(bytes) && bytes > 0)) return;
|
|
66
|
-
const kind = deliveryKindOf(path);
|
|
67
|
-
if (kind === DELIVERY_KIND.MEDIA) this.#media += bytes;
|
|
68
|
-
else if (kind === DELIVERY_KIND.MEASUREMENT) this.#measurement += bytes;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* One message handed to a channel, whatever it carries.
|
|
73
|
-
*
|
|
74
|
-
* @param {number} bytes
|
|
75
|
-
*/
|
|
76
|
-
recordSent(bytes) {
|
|
77
|
-
if (Number.isFinite(bytes) && bytes > 0) this.#sent += bytes;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* What the film costs the link beyond itself, per byte of film, or null
|
|
82
|
-
* while no film has been delivered.
|
|
83
|
-
*
|
|
84
|
-
* Never below zero: a body is counted once its last message has gone, so
|
|
85
|
-
* the two counts agree exactly between responses and the body's own
|
|
86
|
-
* messages are already in `sent` when it is counted.
|
|
87
|
-
*
|
|
88
|
-
* @returns {number | null}
|
|
89
|
-
*/
|
|
90
|
-
serviceShare() {
|
|
91
|
-
if (this.#media <= 0) return null;
|
|
92
|
-
return Math.max(0, (this.#sent - this.#media - this.#measurement) / this.#media);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* The counts behind the share, for a log line.
|
|
97
|
-
*
|
|
98
|
-
* @returns {{ sentBytes: number, mediaBytes: number, measurementBytes: number, serviceShare: number | null }}
|
|
99
|
-
*/
|
|
100
|
-
figures() {
|
|
101
|
-
return {
|
|
102
|
-
sentBytes: this.#sent,
|
|
103
|
-
mediaBytes: this.#media,
|
|
104
|
-
measurementBytes: this.#measurement,
|
|
105
|
-
serviceShare: this.serviceShare()
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
}
|