@torrent-tv/proxy 2.107.56 → 2.107.57
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 +1 -1
- package/server.js +195 -1139
- package/services/encode/EncodeInputResolution.js +157 -0
- package/services/encode/measure-host.js +147 -0
- package/services/media/SegmentSourceRanges.js +95 -0
- package/services/media/SubtitleWalk.js +172 -0
- package/services/media/WorkTagsReport.js +30 -0
- package/services/media/segment-reading.js +36 -0
- package/services/server/MediaReadParams.js +134 -0
- package/services/server/PreparationReads.js +175 -0
- package/services/server/forwarded-response-log.js +55 -0
- package/services/server/start-playback.js +34 -0
- package/services/server/torrent-pool-ports.js +105 -0
- package/services/server/wire-outputs.js +57 -47
- package/services/storage/processor-share.js +24 -0
- package/services/torrent/PlaybackResolution.js +397 -0
- package/services/viewer/watched-addresses.js +51 -0
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* `server.js` to give each route the ones that route asks.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { EventEmitter } from "node:events";
|
|
11
12
|
import { logger } from "../../utils/logger.js";
|
|
12
13
|
import { KeyframeTables } from "../media/KeyframeTables.js";
|
|
13
14
|
import { bandOf, WaitLedger } from "../encode/OutputWaitLedger.js";
|
|
@@ -15,6 +16,7 @@ import { readEndedChildrenCpuSeconds, readMachineState, readProcessCpuSeconds, r
|
|
|
15
16
|
import { minimumBufferFrom } from "../torrent/supply-margin.js";
|
|
16
17
|
import { PriorityOrchestrator } from "../viewer/PriorityOrchestrator.js";
|
|
17
18
|
import { urgentOutputsReady } from "../viewer/urgent-output-ready.js";
|
|
19
|
+
import { trackChoicesOf } from "../viewer/segment-demands.js";
|
|
18
20
|
import { softwareDescriptor } from "../encode/hwaccel.js";
|
|
19
21
|
import { resolveSegmentFormat } from "../encode/segment-formats/index.js";
|
|
20
22
|
import { Timelines } from "../encode/output/Timeline.js";
|
|
@@ -50,7 +52,6 @@ import {
|
|
|
50
52
|
noteServingVerdict,
|
|
51
53
|
noteStepBeingWarmed,
|
|
52
54
|
noteStepOnScreen,
|
|
53
|
-
outputsBeingPrepared,
|
|
54
55
|
placeInitially,
|
|
55
56
|
placeOn,
|
|
56
57
|
presentOn,
|
|
@@ -102,7 +103,8 @@ import path from "node:path";
|
|
|
102
103
|
import { contentOf, LocalObservations, PROXY_ROOT } from "../encode/LocalObservations.js";
|
|
103
104
|
import { configurationKeyOf } from "../encode/fingerprint.js";
|
|
104
105
|
import { qualityStateOf } from "../encode/quality/OutputQualityState.js";
|
|
105
|
-
import {
|
|
106
|
+
import { preparedAddresses, watchedAddresses } from "../viewer/watched-addresses.js";
|
|
107
|
+
import { downloadShare, watchedFiles } from "../storage/processor-share.js";
|
|
106
108
|
import { describeProcessor, processorBetween, sampleProcessor } from "../storage/machine-processor.js";
|
|
107
109
|
import { ProcessorBudget } from "../encode/ProcessorBudget.js";
|
|
108
110
|
import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode/encode-run-state.js";
|
|
@@ -232,19 +234,17 @@ export function wireOutputs({
|
|
|
232
234
|
diagnostics = null,
|
|
233
235
|
diagnosticsRoot = "",
|
|
234
236
|
memoryClaimant = null,
|
|
235
|
-
|
|
237
|
+
encodeInputResolution = null,
|
|
236
238
|
repriceEncodeDemand = null,
|
|
237
239
|
readSourceMedia = null,
|
|
238
240
|
readMetadataActivity = null,
|
|
239
241
|
sourceInputsFor = null,
|
|
240
242
|
readEncodeRanges = null,
|
|
241
243
|
openEncodeWholeFile = null,
|
|
242
|
-
stretchCopied = () => {},
|
|
243
244
|
readEncodeHeldRanges = null,
|
|
244
245
|
encodeInputs = null,
|
|
245
246
|
indexMemory = null,
|
|
246
247
|
budgetPolicy = null,
|
|
247
|
-
onViewerChanged = null,
|
|
248
248
|
// Told when an encoder starts or ends: the room this host has for one more
|
|
249
249
|
// encode changes with it, and the server chooses proxies by that room.
|
|
250
250
|
onEncodersChanged = null}) {
|
|
@@ -774,19 +774,15 @@ export function wireOutputs({
|
|
|
774
774
|
// watched file of every torrent.
|
|
775
775
|
parts.applyDownloadShare = () => {
|
|
776
776
|
if (!parts.setDownloadRate || !parts.hostLoad || !parts.encodeCost) return;
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
if (processCanBeSignalled(state) && state !== ENCODE_RUN_STATE.SUSPENDED) {
|
|
785
|
-
file.encodeCostSec += parts.encodeCost.loadOfOutput(output.outputKey)?.costSec ?? 0;
|
|
777
|
+
const watched = watchedFiles(parts.outputs.values(), {
|
|
778
|
+
watched: (output) => parts.viewers?.forOutput(output)?.size > 0,
|
|
779
|
+
lengthOf: (output) => Number(output.file.lengthBytes ?? parts.hostLoad.fileLengthByKey.get(output.file.key)),
|
|
780
|
+
encodeCostOf: (output) => {
|
|
781
|
+
const state = parts.encodeRuns.runStateOf(output);
|
|
782
|
+
return processCanBeSignalled(state) && state !== ENCODE_RUN_STATE.SUSPENDED
|
|
783
|
+
? parts.encodeCost.loadOfOutput(output.outputKey)?.costSec ?? 0 : 0;
|
|
786
784
|
}
|
|
787
|
-
|
|
788
|
-
}
|
|
789
|
-
const watched = [...files.values()].filter((file) => Number.isFinite(file.bytesPerFilmSecond) && file.bytesPerFilmSecond > 0);
|
|
785
|
+
});
|
|
790
786
|
const share = downloadShare({ availableShare: parts.processor?.availability()?.share ?? 1,
|
|
791
787
|
baseDraw: parts.hostLoad.baseDraw, costPerMegabyte: parts.hostLoad.observedTorrentCostPerMegabyte, files: watched });
|
|
792
788
|
// Kept beside the readings it is judged against: what the torrents moved
|
|
@@ -979,10 +975,13 @@ export function wireOutputs({
|
|
|
979
975
|
// be re-taken on a five-second timer instead, which made a just-created
|
|
980
976
|
// output wait up to five seconds before anything noticed it had a viewer.
|
|
981
977
|
parts.retention = new OutputRetention();
|
|
978
|
+
// Who listens to viewer changes subscribes here (`startPlayback`); a throw
|
|
979
|
+
// of a listener reaches the code that changed the viewers.
|
|
980
|
+
parts.viewerChanges = new EventEmitter();
|
|
982
981
|
parts.viewers = new Viewers({ onChange: () => {
|
|
983
982
|
parts.lifecycle.observeUse();
|
|
984
983
|
parts.encodeRuns.planEncodersSoon();
|
|
985
|
-
|
|
984
|
+
parts.viewerChanges.emit("change");
|
|
986
985
|
} });
|
|
987
986
|
// The outputs that exist, and every question about them: the picture a step
|
|
988
987
|
// belongs to, the steps, the soundtracks, the height a step is named by.
|
|
@@ -1124,36 +1123,12 @@ export function wireOutputs({
|
|
|
1124
1123
|
// cost) and how much of the machine nobody has priced (the host's reading).
|
|
1125
1124
|
parts.admission = new EncodeAdmission({
|
|
1126
1125
|
liveRunsByAddress: () => parts.encodeOrchestrator.liveRunsByAddress(),
|
|
1127
|
-
preparedAddresses: () =>
|
|
1128
|
-
const addresses = new Set();
|
|
1129
|
-
for (const id of outputsBeingPrepared(parts.viewers)) {
|
|
1130
|
-
const key = parts.outputs.get(id)?.outputKey;
|
|
1131
|
-
if (key) {
|
|
1132
|
-
addresses.add(key);
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
return addresses;
|
|
1136
|
-
},
|
|
1126
|
+
preparedAddresses: () => preparedAddresses(parts.viewers, parts.outputs),
|
|
1137
1127
|
// Every output a present viewer RECEIVES — by the same rule the per-output
|
|
1138
1128
|
// priority map is built by — that still has something left to make. The
|
|
1139
1129
|
// place an output is opened on is held from the moment its viewer is put
|
|
1140
1130
|
// on it, not from when the plan gets round to its first encoder.
|
|
1141
|
-
watchedAddresses: () =>
|
|
1142
|
-
const addresses = new Set();
|
|
1143
|
-
for (const session of parts.outputs.values()) {
|
|
1144
|
-
const address = session.outputKey;
|
|
1145
|
-
if (!address || addresses.has(address) || finishedOutput(session)) {
|
|
1146
|
-
continue;
|
|
1147
|
-
}
|
|
1148
|
-
for (const viewer of parts.viewers.forOutput(session).values()) {
|
|
1149
|
-
if (viewer.isPresent() && !parts.outputs.supersededBy(session, viewer.activeVariantId ?? null)) {
|
|
1150
|
-
addresses.add(address);
|
|
1151
|
-
break;
|
|
1152
|
-
}
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
return addresses;
|
|
1156
|
-
},
|
|
1131
|
+
watchedAddresses: () => watchedAddresses(parts.viewers, parts.outputs, finishedOutput),
|
|
1157
1132
|
finished: (address) => parts.outputs.outputsOn(address).some((session) => finishedOutput(session)),
|
|
1158
1133
|
loadOf: (address) => parts.encodeCost.loadOfOutput(address),
|
|
1159
1134
|
loadForCandidate: (spec, file) => parts.encodeCost.loadForCandidate(spec, file),
|
|
@@ -1216,12 +1191,12 @@ export function wireOutputs({
|
|
|
1216
1191
|
void parts.machineBudget.revise(parts.machineBudget.storageResource, "playback stock");
|
|
1217
1192
|
}
|
|
1218
1193
|
});
|
|
1219
|
-
parts.encodeInputs = encodeInputs ?? (typeof
|
|
1220
|
-
resolve:
|
|
1194
|
+
parts.encodeInputs = encodeInputs ?? (typeof encodeInputResolution?.resolve === "function" ? new EncodeInputs({
|
|
1195
|
+
resolve: (...args) => encodeInputResolution.resolve(...args),
|
|
1221
1196
|
readRanges: readEncodeRanges,
|
|
1222
1197
|
openWholeFile: openEncodeWholeFile,
|
|
1223
1198
|
heldRanges: readEncodeHeldRanges,
|
|
1224
|
-
stretchCopied,
|
|
1199
|
+
stretchCopied: (...args) => encodeInputResolution.stretchCopied(...args),
|
|
1225
1200
|
reviseBudget: () => parts.machineBudget.revise("memory", "admitted encode inputs"),
|
|
1226
1201
|
capacity: () => parts.machineBudget.capacityOf("memory"),
|
|
1227
1202
|
// How long this output's input takes to open, from the one owner of start
|
|
@@ -1376,5 +1351,40 @@ export function wireOutputs({
|
|
|
1376
1351
|
void parts.quality.runQualityBudgetOnce();
|
|
1377
1352
|
}, BUDGET_CHECK_INTERVAL_MS);
|
|
1378
1353
|
parts.budgetTimer.unref();
|
|
1354
|
+
/**
|
|
1355
|
+
* What the download map's pass reads of the outputs: each output of a source
|
|
1356
|
+
* that a present viewer watches and that reads the file, in catalog order,
|
|
1357
|
+
* with what decides how long its segments take to make and deliver and what
|
|
1358
|
+
* it has made (`PlaybackResolution`).
|
|
1359
|
+
*
|
|
1360
|
+
* @param {string} sourceKey
|
|
1361
|
+
* @param {number} fileIndex
|
|
1362
|
+
* @returns {Array<{ outputKey: string, timeline: object, choices: object[], revision: unknown,
|
|
1363
|
+
* sizes: () => Map<number, number>, encodeSpeed: number | undefined, linkMbps: number | null, target: number | null }>}
|
|
1364
|
+
*/
|
|
1365
|
+
parts.playedOutputsOf = (sourceKey, fileIndex) => {
|
|
1366
|
+
const played = [];
|
|
1367
|
+
for (const output of parts.outputs.values()) {
|
|
1368
|
+
if (output.file.sourceKey !== sourceKey) continue;
|
|
1369
|
+
const consumers = [...parts.viewers.forOutput(output).values()].filter(viewer =>
|
|
1370
|
+
viewer.isPresent() && !parts.outputs.supersededBy(output, viewer.activeVariantId ?? null));
|
|
1371
|
+
if (consumers.length === 0) continue;
|
|
1372
|
+
const choices = trackChoicesOf(output, fileIndex);
|
|
1373
|
+
if (choices.length === 0) continue;
|
|
1374
|
+
// An output with no cut grid is left out by the pass (`PlaybackResolution`);
|
|
1375
|
+
// none of what follows is worth working out for it.
|
|
1376
|
+
const timeline = output.timeline;
|
|
1377
|
+
if (!timeline || typeof timeline !== "object" || !Array.isArray(timeline.published ?? timeline.boundaries)) continue;
|
|
1378
|
+
const links = consumers.map(viewer => viewer.linkReading()?.linkMbps)
|
|
1379
|
+
.filter(value => Number.isFinite(value) && value > 0);
|
|
1380
|
+
played.push({ outputKey: output.outputKey, timeline: output.timeline, choices,
|
|
1381
|
+
revision: parts.segmentStore.revisionOf(output.outputKey),
|
|
1382
|
+
sizes: () => parts.segmentStore.sizesOf(output.outputKey),
|
|
1383
|
+
encodeSpeed: parts.encodeCost.latestSpeedReadingOf(output)?.speed,
|
|
1384
|
+
linkMbps: links.length ? Math.min(...links) : null,
|
|
1385
|
+
target: parts.encodeInputs?.targetInputBytes?.(output) ?? null });
|
|
1386
|
+
}
|
|
1387
|
+
return played;
|
|
1388
|
+
};
|
|
1379
1389
|
return parts;
|
|
1380
1390
|
}
|
|
@@ -55,3 +55,27 @@ export function downloadShare({ availableShare, baseDraw, costPerMegabyte, files
|
|
|
55
55
|
const bytesPerSecond = perByte > 0 ? Math.max(filmSpeed, 1) * bytes : Infinity;
|
|
56
56
|
return { filmSpeed, capacity, bytesPerSecond: Number.isFinite(bytesPerSecond) && bytes > 0 ? bytesPerSecond : Infinity };
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The watched outputs grouped by the file they read: what the share is worked
|
|
61
|
+
* out over. Moved out of `applyDownloadShare` verbatim (torrent-tv/meta#280).
|
|
62
|
+
*
|
|
63
|
+
* @param {Iterable<{ file: { key: string, durationSeconds: number } }>} outputs
|
|
64
|
+
* @param {object} ports
|
|
65
|
+
* @param {(output: object) => boolean} ports.watched - Whether a viewer receives the output.
|
|
66
|
+
* @param {(output: object) => number} ports.lengthOf - The file's length in bytes.
|
|
67
|
+
* @param {(output: object) => number} ports.encodeCostOf - What its encoder costs, 0 when none runs.
|
|
68
|
+
* @returns {{ bytesPerFilmSecond: number, encodeCostSec: number }[]}
|
|
69
|
+
* One per file in the order first seen; only finite positive rates kept.
|
|
70
|
+
*/
|
|
71
|
+
export function watchedFiles(outputs, { watched, lengthOf, encodeCostOf }) {
|
|
72
|
+
const files = new Map();
|
|
73
|
+
for (const output of outputs) {
|
|
74
|
+
if (!watched(output)) continue;
|
|
75
|
+
const key = output.file.key;
|
|
76
|
+
const file = files.get(key) ?? { bytesPerFilmSecond: lengthOf(output) / Number(output.file.durationSeconds), encodeCostSec: 0 };
|
|
77
|
+
file.encodeCostSec += encodeCostOf(output);
|
|
78
|
+
files.set(key, file);
|
|
79
|
+
}
|
|
80
|
+
return [...files.values()].filter((file) => Number.isFinite(file.bytesPerFilmSecond) && file.bytesPerFilmSecond > 0);
|
|
81
|
+
}
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The download map's pass: what the torrent is told about the file a
|
|
3
|
+
* viewer plays, worked out from the viewers' map (torrent-tv/meta#270, #273).
|
|
4
|
+
*
|
|
5
|
+
* `DownloadMaps` hands it a file's map; it answers the windows of source bytes
|
|
6
|
+
* the swarm is to fetch. It owns what that pass keeps from map to map: the
|
|
7
|
+
* projection of each played file (`PlaybackProjection`), what each segment of
|
|
8
|
+
* each output reads and the bytes it was worked out to read, the cost of the
|
|
9
|
+
* passes. Everything it reads of the world comes in by a port: the file's
|
|
10
|
+
* parameters, the resolver of a segment's source ranges, the rule of what a
|
|
11
|
+
* segment reads, the outputs somebody watches, the piece length, the table of
|
|
12
|
+
* what each segment reads, the wait-trace and the log.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { performance } from "node:perf_hooks";
|
|
16
|
+
import { takeTurns } from "../../utils/take-turns.js";
|
|
17
|
+
import { joinedWithinPieces } from "./demand/pieces.js";
|
|
18
|
+
import { PlaybackProjection } from "./PlaybackProjection.js";
|
|
19
|
+
|
|
20
|
+
export class PlaybackResolution {
|
|
21
|
+
#paramsFor;
|
|
22
|
+
#sourceRanges;
|
|
23
|
+
#segmentReading;
|
|
24
|
+
#playedOutputsOf;
|
|
25
|
+
#pieceLengthOf;
|
|
26
|
+
#noteSegmentInput;
|
|
27
|
+
#refresh;
|
|
28
|
+
#observeWait;
|
|
29
|
+
#log;
|
|
30
|
+
#warn;
|
|
31
|
+
#now;
|
|
32
|
+
#clock;
|
|
33
|
+
#turns;
|
|
34
|
+
/** What the torrent is told of each played file, kept from map to map (`PlaybackProjection`); forgotten with the source. */
|
|
35
|
+
#projections = new Map();
|
|
36
|
+
/**
|
|
37
|
+
* WHAT ONE SEGMENT OF AN OUTPUT IS, kept from pass to pass: where it lies in
|
|
38
|
+
* the source, what it reads — its tracks and the key its bytes are kept under
|
|
39
|
+
* — and, once known, those bytes joined at the precision the torrent fetches,
|
|
40
|
+
* by output and segment. Worked out again only when the output's timeline,
|
|
41
|
+
* the tracks, the file's start time or the output's choices change; forgotten
|
|
42
|
+
* with the source. None of it depends on where a viewer stands, and a pass is
|
|
43
|
+
* made every time one moves: looked up, made and joined again for each of a
|
|
44
|
+
* film's 3500 segments on every pass, they were 6 s of the 40 s the passes
|
|
45
|
+
* cost the main thread in fifteen minutes on Home Assistant (r30,
|
|
46
|
+
* torrent-tv/meta#178).
|
|
47
|
+
*/
|
|
48
|
+
#segmentReadings = new Map();
|
|
49
|
+
/** The choices of one output's tracks as a key, worked out once for the list of them a pass shares. */
|
|
50
|
+
#choicesKeys = new WeakMap();
|
|
51
|
+
/** An output's cut grid, checked once for each timeline. */
|
|
52
|
+
#checkedGrids = new WeakMap();
|
|
53
|
+
/**
|
|
54
|
+
* How many bytes a second of an output's film takes, from what it has made,
|
|
55
|
+
* read again only when what it has made changes (`SegmentStore.revisionOf`):
|
|
56
|
+
* summed over every made segment on every pass, it was a walk of the whole
|
|
57
|
+
* film a second (torrent-tv/meta#178).
|
|
58
|
+
*/
|
|
59
|
+
#madeRates = new Map();
|
|
60
|
+
// WHAT THE PASSES COST, said once a minute: how many maps were taken, how
|
|
61
|
+
// many segments a map made the projection work out again and how many of
|
|
62
|
+
// those maps it worked out whole, how many segments had their ranges worked
|
|
63
|
+
// out, the windows told, and the time from a pass's start to its result —
|
|
64
|
+
// which includes waits for reads, so it bounds the main thread's share
|
|
65
|
+
// rather than measuring it (torrent-tv/meta#178).
|
|
66
|
+
#passCosts;
|
|
67
|
+
// A container whose packet table a pass did not wait for builds the map
|
|
68
|
+
// again once, when the table is built or refused (`directSegmentInput`).
|
|
69
|
+
#refreshAfterTable = new WeakSet();
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* @param {object} ports
|
|
73
|
+
* @param {(request: { sourceKey: string, fileIndex: number, requestId?: string, packetInterval?: object, demand?: object }) => Promise<object | null>} ports.paramsFor
|
|
74
|
+
* @param {object} ports.sourceRanges - `SegmentSourceRanges`.
|
|
75
|
+
* @param {(trackList: object[], choices: object[], sourceInterval: object) => object | null} ports.segmentReading
|
|
76
|
+
* @param {(sourceKey: string, fileIndex: number) => object[]} ports.playedOutputsOf
|
|
77
|
+
* @param {(sourceKey: string) => number} ports.pieceLengthOf - NaN when the torrent is not known.
|
|
78
|
+
* @param {(sourceKey: string, fileIndex: number, outputKey: string, index: number, joined: Array<[number, number]>) => void} ports.noteSegmentInput
|
|
79
|
+
* @param {(sourceKey: string, fileIndex: number) => Promise<unknown>} ports.refresh
|
|
80
|
+
* @param {(sourceKey: string, event: string, fields: object) => void} ports.observeWait
|
|
81
|
+
* @param {(line: string) => void} ports.log
|
|
82
|
+
* @param {(line: string) => void} ports.warn
|
|
83
|
+
* @param {() => number} [ports.now] - The clock of the once-a-minute reading.
|
|
84
|
+
* @param {() => number} [ports.clock] - Pass and P4.1 timing.
|
|
85
|
+
* @param {() => (() => Promise<unknown>)} [ports.turns]
|
|
86
|
+
*/
|
|
87
|
+
constructor({ paramsFor, sourceRanges, segmentReading, playedOutputsOf, pieceLengthOf, noteSegmentInput, refresh,
|
|
88
|
+
observeWait, log, warn, now = Date.now, clock = () => performance.now(), turns = takeTurns }) {
|
|
89
|
+
this.#paramsFor = paramsFor;
|
|
90
|
+
this.#sourceRanges = sourceRanges;
|
|
91
|
+
this.#segmentReading = segmentReading;
|
|
92
|
+
this.#playedOutputsOf = playedOutputsOf;
|
|
93
|
+
this.#pieceLengthOf = pieceLengthOf;
|
|
94
|
+
this.#noteSegmentInput = noteSegmentInput;
|
|
95
|
+
this.#refresh = refresh;
|
|
96
|
+
this.#observeWait = observeWait;
|
|
97
|
+
this.#log = log;
|
|
98
|
+
this.#warn = warn;
|
|
99
|
+
this.#now = now;
|
|
100
|
+
this.#clock = clock;
|
|
101
|
+
this.#turns = turns;
|
|
102
|
+
this.#passCosts = { since: now(), passes: 0, whole: 0, reworked: 0, resolved: 0, windows: 0, ms: 0, maxMs: 0 };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The file has no playback zones: what is told of it is emptied. */
|
|
106
|
+
clear(sourceKey, fileIndex) {
|
|
107
|
+
this.#projections.get(`${sourceKey}:${fileIndex}`)?.clear();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The source is forgotten: its projections, records and made rates go. */
|
|
111
|
+
forget(sourceKey) {
|
|
112
|
+
for (const key of this.#segmentReadings.keys()) if (key.startsWith(`${sourceKey}:`)) this.#segmentReadings.delete(key);
|
|
113
|
+
for (const key of this.#projections.keys()) if (key.startsWith(`${sourceKey}:`)) this.#projections.delete(key);
|
|
114
|
+
for (const key of this.#madeRates.keys()) if (key.startsWith(`${sourceKey}:`)) this.#madeRates.delete(key);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** One output's segments, by index. */
|
|
118
|
+
#segmentRecordsOf(map, output) {
|
|
119
|
+
const key = `${map.sourceKey}:${map.fileIndex}:${output.outputKey}`;
|
|
120
|
+
let records = this.#segmentReadings.get(key);
|
|
121
|
+
if (!records) this.#segmentReadings.set(key, records = new Map());
|
|
122
|
+
return records;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#choicesKeyOf(choices) {
|
|
126
|
+
let key = this.#choicesKeys.get(choices);
|
|
127
|
+
if (key === undefined) this.#choicesKeys.set(choices, key = choices.map(choice => `${choice.type}/${choice.index}/${choice.mode}`).join(","));
|
|
128
|
+
return key;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
#segmentRecordOf(records, demand, { timeline, trackList, shift, map }) {
|
|
132
|
+
const choicesKey = this.#choicesKeyOf(demand.tracks);
|
|
133
|
+
const kept = records.get(demand.index);
|
|
134
|
+
if (kept && kept.timeline === timeline && kept.trackList === trackList && kept.shift === shift && kept.choicesKey === choicesKey) return kept;
|
|
135
|
+
const sourceInterval = timeline.sourceInterval(demand.index, demand.index, shift);
|
|
136
|
+
const found = this.#segmentReading(trackList, demand.tracks, sourceInterval);
|
|
137
|
+
const reading = found && { ...found, rangesKey: this.#sourceRanges.keyOf(map.sourceKey, map.fileIndex, found.interval) };
|
|
138
|
+
// `joined` is the segment's ranges as the torrent is told them, made from
|
|
139
|
+
// the list `ranges` at `pieceLength`; `registered` is the list the input
|
|
140
|
+
// table was last given.
|
|
141
|
+
const record = { timeline, trackList, shift, choicesKey, sourceInterval, reading,
|
|
142
|
+
ranges: null, pieceLength: Number.NaN, joined: null, registered: null };
|
|
143
|
+
records.set(demand.index, record);
|
|
144
|
+
return record;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#gridOf(timeline) {
|
|
148
|
+
if (!timeline || typeof timeline !== "object") return null;
|
|
149
|
+
if (this.#checkedGrids.has(timeline)) return this.#checkedGrids.get(timeline);
|
|
150
|
+
const grid = timeline.published ?? timeline.boundaries;
|
|
151
|
+
if (!Array.isArray(grid)) {
|
|
152
|
+
this.#checkedGrids.set(timeline, null);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
for (let index = 0; index + 1 < grid.length; index++) {
|
|
156
|
+
if (!Number.isFinite(grid[index]) || !Number.isFinite(grid[index + 1]) || !(grid[index + 1] > grid[index])) {
|
|
157
|
+
throw new Error("An output segment needs a valid interval.");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
this.#checkedGrids.set(timeline, grid);
|
|
161
|
+
return grid;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
#bytesPerSecondOf(entry) {
|
|
165
|
+
const kept = this.#madeRates.get(entry.outputKey);
|
|
166
|
+
if (kept && kept.revision === entry.revision && kept.timeline === entry.timeline) return kept.bytesPerSecond;
|
|
167
|
+
let bytes = 0, seconds = 0;
|
|
168
|
+
for (const [index, size] of entry.sizes()) {
|
|
169
|
+
const span = entry.timeline.publishedStartOf(index + 1) - entry.timeline.publishedStartOf(index);
|
|
170
|
+
if (span > 0) { bytes += size; seconds += span; }
|
|
171
|
+
}
|
|
172
|
+
const bytesPerSecond = seconds > 0 ? bytes / seconds : null;
|
|
173
|
+
this.#madeRates.set(entry.outputKey, { revision: entry.revision, timeline: entry.timeline, bytesPerSecond });
|
|
174
|
+
return bytesPerSecond;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#notePass({ whole = false, reworked = 0, resolved = 0, windows = 0, ms = 0 }) {
|
|
178
|
+
const passCosts = this.#passCosts;
|
|
179
|
+
passCosts.passes += 1;
|
|
180
|
+
if (whole) passCosts.whole += 1;
|
|
181
|
+
passCosts.reworked += reworked; passCosts.resolved += resolved; passCosts.windows += windows;
|
|
182
|
+
passCosts.ms += ms; passCosts.maxMs = Math.max(passCosts.maxMs, ms);
|
|
183
|
+
const now = this.#now();
|
|
184
|
+
if (now - passCosts.since < 60_000) return;
|
|
185
|
+
this.#log(`download map passes in ${((now - passCosts.since) / 1000).toFixed(1)}s: ${passCosts.passes} map(s), ` +
|
|
186
|
+
`${passCosts.whole} worked out whole, ${passCosts.reworked} segment(s) worked out again, ` +
|
|
187
|
+
`${passCosts.resolved} segment(s) had their ranges worked out, ${passCosts.windows} window(s) told; ` +
|
|
188
|
+
`${Math.round(passCosts.ms)}ms from start to result in all, longest ${Math.round(passCosts.maxMs)}ms`);
|
|
189
|
+
Object.assign(passCosts, { since: now, passes: 0, whole: 0, reworked: 0, resolved: 0, windows: 0, ms: 0, maxMs: 0 });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#refreshWhenBuilt(container, built, { sourceKey, fileIndex }) {
|
|
193
|
+
if (this.#refreshAfterTable.has(container)) return;
|
|
194
|
+
this.#refreshAfterTable.add(container);
|
|
195
|
+
void built.then(() => {
|
|
196
|
+
this.#refreshAfterTable.delete(container);
|
|
197
|
+
return this.#refresh(sourceKey, fileIndex);
|
|
198
|
+
}).catch(error => this.#warn(`download map after a packet table: ${error?.message ?? error}`));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* One pass over a file's map.
|
|
203
|
+
*
|
|
204
|
+
* @param {{ sourceKey: string, fileIndex: number, zones: object[], isCurrent: () => boolean,
|
|
205
|
+
* workingOut?: (priority: number, zonesOf: () => object[]) => void }} map
|
|
206
|
+
* @returns {Promise<object[]>} The windows to tell the torrent; empty when the map was replaced meanwhile.
|
|
207
|
+
*/
|
|
208
|
+
async resolve(map) {
|
|
209
|
+
const fileKey = `${map.sourceKey}:${map.fileIndex}`;
|
|
210
|
+
if (map.zones.length === 0) {
|
|
211
|
+
this.#projections.get(fileKey)?.clear();
|
|
212
|
+
return [];
|
|
213
|
+
}
|
|
214
|
+
const params = await this.#paramsFor({ sourceKey: map.sourceKey, fileIndex: map.fileIndex });
|
|
215
|
+
if (!params || !map.isCurrent()) return [];
|
|
216
|
+
const tracks = await this.#sourceRanges.tracksOf(params, () => map.isCurrent());
|
|
217
|
+
if (tracks.kind !== "result") return [];
|
|
218
|
+
const container = tracks.container;
|
|
219
|
+
const media = await this.#sourceRanges.shiftOf(params, () => map.isCurrent());
|
|
220
|
+
if (media.kind !== "result") return [];
|
|
221
|
+
const shift = media.shift;
|
|
222
|
+
// A zone's ranges are stated to the torrent at the precision it fetches:
|
|
223
|
+
// ranges closer than a piece hold the same pieces as one.
|
|
224
|
+
const pieceLength = this.#pieceLengthOf(map.sourceKey);
|
|
225
|
+
const passStartedAt = this.#clock();
|
|
226
|
+
// THE PROJECTION IS KEPT while what it was made from stands: the file's
|
|
227
|
+
// tracks, where its clock starts and how the torrent fetches. Any of them
|
|
228
|
+
// changed, every segment's ranges are other ranges.
|
|
229
|
+
let projection = this.#projections.get(fileKey);
|
|
230
|
+
if (!projection || projection.trackList !== tracks.trackList || projection.shift !== shift ||
|
|
231
|
+
!Object.is(projection.pieceLength, pieceLength)) {
|
|
232
|
+
projection?.clear();
|
|
233
|
+
projection = new PlaybackProjection();
|
|
234
|
+
Object.assign(projection, { trackList: tracks.trackList, shift, pieceLength });
|
|
235
|
+
this.#projections.set(fileKey, projection);
|
|
236
|
+
}
|
|
237
|
+
// Every output of this file somebody is watching, with what decides how
|
|
238
|
+
// long its segments take to make and deliver, what it has made, and what
|
|
239
|
+
// its next run would copy.
|
|
240
|
+
const played = [];
|
|
241
|
+
/** By output: what its segments read and how they are looked up. */
|
|
242
|
+
const readers = new Map();
|
|
243
|
+
for (const entry of this.#playedOutputsOf(map.sourceKey, map.fileIndex)) {
|
|
244
|
+
const grid = this.#gridOf(entry.timeline);
|
|
245
|
+
if (!grid) continue;
|
|
246
|
+
// Every piece answers for its exact ending boundary, as the encode
|
|
247
|
+
// plan reads it: the player may request the preceding piece there.
|
|
248
|
+
played.push({ outputKey: entry.outputKey, grid, encodeSpeed: entry.encodeSpeed,
|
|
249
|
+
bytesPerSecond: this.#bytesPerSecondOf(entry), linkMbps: entry.linkMbps,
|
|
250
|
+
readyRevision: entry.revision, ready: entry.sizes, target: entry.target });
|
|
251
|
+
readers.set(entry.outputKey, { records: this.#segmentRecordsOf(map, entry), choices: entry.choices,
|
|
252
|
+
facts: { timeline: entry.timeline, trackList: tracks.trackList, shift, map } });
|
|
253
|
+
}
|
|
254
|
+
// WHAT THE MAP CHANGED, and only that, is worked out again here.
|
|
255
|
+
projection.update({ zones: map.zones, outputs: played });
|
|
256
|
+
const reworked = projection.lastUpdate;
|
|
257
|
+
let resolved = 0;
|
|
258
|
+
// A container that names the bytes of an interval from an index it holds
|
|
259
|
+
// answers every segment by arithmetic: once that index is read
|
|
260
|
+
// ("source-navigation"), the ranges are computed here directly instead of
|
|
261
|
+
// through the file's queue of reads, three steps per segment. On Home
|
|
262
|
+
// Assistant those steps made a pass over a two-hour AVI take minutes while
|
|
263
|
+
// the arithmetic takes milliseconds (torrent-tv/meta#151).
|
|
264
|
+
const navigated = await this.#sourceRanges.navigable(params, () => map.isCurrent());
|
|
265
|
+
if (navigated.kind === "stale") return [];
|
|
266
|
+
const direct = navigated.kind === "result" && navigated.value === true &&
|
|
267
|
+
typeof container?.readSourceRanges === "function" ? container : null;
|
|
268
|
+
// An output whose file-level reading is not there in this pass — the
|
|
269
|
+
// index or Cues that say where a segment is, not yet downloaded, or the
|
|
270
|
+
// table its segments read, refused memory — answers its other segments
|
|
271
|
+
// the same way. One unanswered request is kept to be retried when the
|
|
272
|
+
// bytes or the memory change, and the map is built again when it
|
|
273
|
+
// answers; asking every segment held the main thread for 3.3 s on a cold
|
|
274
|
+
// two-hour AVI (torrent-tv/meta#178).
|
|
275
|
+
const waiting = new Set();
|
|
276
|
+
const turn = this.#turns();
|
|
277
|
+
// ONLY THE SEGMENTS WHOSE RANGES ARE NOT KNOWN are worked out, in the
|
|
278
|
+
// order the swarm is to fetch them. Range resolution can use either a
|
|
279
|
+
// direct source index or the fallback reads below; yield between zones
|
|
280
|
+
// in both cases so a large map cannot hold the main thread across its
|
|
281
|
+
// full projection.
|
|
282
|
+
for (const zone of projection.unresolved()) {
|
|
283
|
+
if (!map.isCurrent()) return [];
|
|
284
|
+
await turn();
|
|
285
|
+
if (!map.isCurrent()) return [];
|
|
286
|
+
const resolutionStartedAt = this.#clock();
|
|
287
|
+
const resolution = { outcome: "unresolved", method: null, trackCount: null, rangeCount: null, rangeBytes: null };
|
|
288
|
+
const resolutionId = `${map.fileIndex}:${zone.outputKey}:${zone.index}:${passStartedAt.toFixed(3)}`;
|
|
289
|
+
this.#observeWait(map.sourceKey, "process", { process: "P4.1", phase: "begin", resolutionId,
|
|
290
|
+
sourceId: `${map.sourceKey}:${map.fileIndex}`, fileIndex: map.fileIndex, outputKey: zone.outputKey,
|
|
291
|
+
segment: zone.index, priority: zone.priority, deadlineAt: zone.deadlineAt });
|
|
292
|
+
try {
|
|
293
|
+
const reader = readers.get(zone.outputKey);
|
|
294
|
+
if (!reader) {
|
|
295
|
+
resolution.outcome = "reader-missing";
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
// WHAT A SEGMENT READS IS WORKED OUT ONCE (`#segmentRecordOf`). Rebuilt
|
|
299
|
+
// for every segment of the film on every map, this pass held the main
|
|
300
|
+
// thread up to 1.5 s at a time on Home Assistant (torrent-tv/meta#178).
|
|
301
|
+
const record = this.#segmentRecordOf(reader.records, { index: zone.index, tracks: reader.choices }, reader.facts);
|
|
302
|
+
const reading = record.reading;
|
|
303
|
+
if (!reading) {
|
|
304
|
+
resolution.outcome = "segment-reading-unavailable";
|
|
305
|
+
projection.setUnreadable(zone.outputKey, zone.index);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const { wanted, modes, interval, rangesKey } = reading;
|
|
309
|
+
resolution.trackCount = wanted.length;
|
|
310
|
+
resolution.trackIds = wanted.map(track => track.trackNumber);
|
|
311
|
+
resolution.sourceInterval = { from: interval.from, to: interval.to };
|
|
312
|
+
const convert = (ranges, method) => {
|
|
313
|
+
if (record.ranges !== ranges || record.pieceLength !== pieceLength) {
|
|
314
|
+
record.ranges = ranges;
|
|
315
|
+
record.pieceLength = pieceLength;
|
|
316
|
+
record.joined = joinedWithinPieces(ranges, pieceLength);
|
|
317
|
+
}
|
|
318
|
+
const joined = record.joined;
|
|
319
|
+
resolution.method = method;
|
|
320
|
+
resolution.rangeCount = joined.length;
|
|
321
|
+
resolution.rangeBytes = joined.reduce((total, [start, end]) => total + end - start + 1, 0);
|
|
322
|
+
// What the segment reads is kept for the forecast in a table of its
|
|
323
|
+
// own, whatever form the torrent is told it in.
|
|
324
|
+
if (record.registered !== joined) {
|
|
325
|
+
this.#noteSegmentInput(map.sourceKey, map.fileIndex, zone.outputKey, zone.index, joined);
|
|
326
|
+
record.registered = joined;
|
|
327
|
+
}
|
|
328
|
+
projection.setRanges(zone.outputKey, zone.index, joined);
|
|
329
|
+
resolution.outcome = "resolved";
|
|
330
|
+
resolved += 1;
|
|
331
|
+
};
|
|
332
|
+
// The bytes one segment needs are a fact of the file and the output, so
|
|
333
|
+
// they are worked out once. A whole film is thousands of segments and
|
|
334
|
+
// the map changes every few seconds; worked out afresh on every change,
|
|
335
|
+
// a pass over a two-hour AVI never finished and the next segment's bytes
|
|
336
|
+
// were never asked for (Home Assistant 2026-10-08, torrent-tv/meta#151).
|
|
337
|
+
const known = record.ranges ? { ranges: record.ranges, method: "segment-record-cache" } : this.#sourceRanges.cached(rangesKey);
|
|
338
|
+
if (known) { convert(known.ranges, known.method); continue; }
|
|
339
|
+
if (waiting.has(zone.outputKey)) {
|
|
340
|
+
resolution.outcome = "waiting-for-output-input";
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
// What is resolved so far is stated before a lower zone is worked out
|
|
344
|
+
// (DownloadMaps, torrent-tv/meta#178).
|
|
345
|
+
map.workingOut?.(zone.priority, () => projection.windows());
|
|
346
|
+
const found = await this.#sourceRanges.resolve({ rangesKey, direct, interval, tracks: wanted, modes,
|
|
347
|
+
isCurrent: () => map.isCurrent(),
|
|
348
|
+
whenBuilt: built => this.#refreshWhenBuilt(direct, built, map),
|
|
349
|
+
intervalParams: () => {
|
|
350
|
+
resolution.method = "container-index-fallback";
|
|
351
|
+
return this.#paramsFor({ sourceKey: map.sourceKey, fileIndex: map.fileIndex, packetInterval: interval,
|
|
352
|
+
requestId: `download:${map.sourceKey}:${map.fileIndex}:${zone.outputKey}:${zone.index}`,
|
|
353
|
+
demand: { ...zone.owner, leadSeconds: zone.leadSeconds } });
|
|
354
|
+
} });
|
|
355
|
+
if (found.kind === "result") { convert(found.ranges, found.method); continue; }
|
|
356
|
+
resolution.method = found.method;
|
|
357
|
+
if (found.kind === "stale") {
|
|
358
|
+
resolution.outcome = "map-replaced";
|
|
359
|
+
return [];
|
|
360
|
+
}
|
|
361
|
+
if (found.kind === "pending") {
|
|
362
|
+
// A table still being built holds this output's segments, and only
|
|
363
|
+
// this output's: the map is built again when it is.
|
|
364
|
+
resolution.outcome = "source-index-pending";
|
|
365
|
+
waiting.add(zone.outputKey);
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
if (found.kind === "needs-source") {
|
|
369
|
+
resolution.outcome = "source-file-unavailable";
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
if (found.step === "navigation") {
|
|
373
|
+
resolution.outcome = `source-navigation-${found.kind}`;
|
|
374
|
+
if (found.kind === "needs-ranges" || found.kind === "needs-memory") waiting.add(zone.outputKey);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (found.kind === "needs-memory") waiting.add(zone.outputKey);
|
|
378
|
+
resolution.outcome = found.step === "packets" ? `source-packets-${found.kind}` : `segment-input-${found.kind}`;
|
|
379
|
+
} catch (error) {
|
|
380
|
+
resolution.outcome = "error";
|
|
381
|
+
resolution.error = error?.message ?? String(error);
|
|
382
|
+
throw error;
|
|
383
|
+
} finally {
|
|
384
|
+
const resolutionEndedAt = this.#clock();
|
|
385
|
+
this.#observeWait(map.sourceKey, "process", { process: "P4.1", phase: "end", resolutionId,
|
|
386
|
+
sourceId: `${map.sourceKey}:${map.fileIndex}`, fileIndex: map.fileIndex, outputKey: zone.outputKey,
|
|
387
|
+
segment: zone.index, priority: zone.priority,
|
|
388
|
+
startedAt: resolutionStartedAt, endedAt: resolutionEndedAt, durationMs: resolutionEndedAt - resolutionStartedAt,
|
|
389
|
+
...resolution });
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const result = projection.windows();
|
|
393
|
+
this.#notePass({ whole: reworked.whole, reworked: reworked.segments, resolved, windows: result.length,
|
|
394
|
+
ms: this.#clock() - passStartedAt });
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Which output addresses are being prepared, and which are watched, for
|
|
3
|
+
* the encoder admission. Moved out of `wire-outputs.js` verbatim
|
|
4
|
+
* (torrent-tv/meta#280).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { outputsBeingPrepared } from "./choices.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The output keys of the outputs a viewer is being prepared for.
|
|
11
|
+
*
|
|
12
|
+
* @param {object} viewers
|
|
13
|
+
* @param {{ get: (id: string) => { outputKey?: string } | undefined }} outputs
|
|
14
|
+
* @returns {Set<string>}
|
|
15
|
+
*/
|
|
16
|
+
export function preparedAddresses(viewers, outputs) {
|
|
17
|
+
const addresses = new Set();
|
|
18
|
+
for (const id of outputsBeingPrepared(viewers)) {
|
|
19
|
+
const key = outputs.get(id)?.outputKey;
|
|
20
|
+
if (key) {
|
|
21
|
+
addresses.add(key);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return addresses;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Every output a present viewer receives that still has something left to
|
|
29
|
+
* make, by address.
|
|
30
|
+
*
|
|
31
|
+
* @param {object} viewers
|
|
32
|
+
* @param {{ values: () => Iterable<object>, supersededBy: (session: object, variantId: string | null) => boolean }} outputs
|
|
33
|
+
* @param {(output: object) => boolean} finished
|
|
34
|
+
* @returns {Set<string>}
|
|
35
|
+
*/
|
|
36
|
+
export function watchedAddresses(viewers, outputs, finished) {
|
|
37
|
+
const addresses = new Set();
|
|
38
|
+
for (const session of outputs.values()) {
|
|
39
|
+
const address = session.outputKey;
|
|
40
|
+
if (!address || addresses.has(address) || finished(session)) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
for (const viewer of viewers.forOutput(session).values()) {
|
|
44
|
+
if (viewer.isPresent() && !outputs.supersededBy(session, viewer.activeVariantId ?? null)) {
|
|
45
|
+
addresses.add(address);
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return addresses;
|
|
51
|
+
}
|