@torrent-tv/proxy 2.107.56 → 2.107.58

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.
@@ -8,6 +8,8 @@
8
8
  * `server.js` to give each route the ones that route asks.
9
9
  */
10
10
 
11
+ import { EventEmitter } from "node:events";
12
+ import { writeJsonAtomic } from "../../utils/atomic-json.js";
11
13
  import { logger } from "../../utils/logger.js";
12
14
  import { KeyframeTables } from "../media/KeyframeTables.js";
13
15
  import { bandOf, WaitLedger } from "../encode/OutputWaitLedger.js";
@@ -15,6 +17,7 @@ import { readEndedChildrenCpuSeconds, readMachineState, readProcessCpuSeconds, r
15
17
  import { minimumBufferFrom } from "../torrent/supply-margin.js";
16
18
  import { PriorityOrchestrator } from "../viewer/PriorityOrchestrator.js";
17
19
  import { urgentOutputsReady } from "../viewer/urgent-output-ready.js";
20
+ import { trackChoicesOf } from "../viewer/segment-demands.js";
18
21
  import { softwareDescriptor } from "../encode/hwaccel.js";
19
22
  import { resolveSegmentFormat } from "../encode/segment-formats/index.js";
20
23
  import { Timelines } from "../encode/output/Timeline.js";
@@ -50,7 +53,6 @@ import {
50
53
  noteServingVerdict,
51
54
  noteStepBeingWarmed,
52
55
  noteStepOnScreen,
53
- outputsBeingPrepared,
54
56
  placeInitially,
55
57
  placeOn,
56
58
  presentOn,
@@ -99,10 +101,11 @@ import { freeBytesFor } from "../storage/free.js";
99
101
  import { PlaybackStorageDemand } from "../storage/PlaybackStorageDemand.js";
100
102
  import { inputCacheRoot, probeInputCache } from "../encode/input-cache.js";
101
103
  import path from "node:path";
102
- import { contentOf, LocalObservations, PROXY_ROOT } from "../encode/LocalObservations.js";
104
+ import { contentOf, LocalObservations } from "../encode/LocalObservations.js";
103
105
  import { configurationKeyOf } from "../encode/fingerprint.js";
104
106
  import { qualityStateOf } from "../encode/quality/OutputQualityState.js";
105
- import { downloadShare } from "../storage/processor-share.js";
107
+ import { preparedAddresses, watchedAddresses } from "../viewer/watched-addresses.js";
108
+ import { downloadShare, watchedFiles } from "../storage/processor-share.js";
106
109
  import { describeProcessor, processorBetween, sampleProcessor } from "../storage/machine-processor.js";
107
110
  import { ProcessorBudget } from "../encode/ProcessorBudget.js";
108
111
  import { ENCODE_RUN_STATE, processCanBeSignalled } from "../encode/encode-run-state.js";
@@ -232,19 +235,17 @@ export function wireOutputs({
232
235
  diagnostics = null,
233
236
  diagnosticsRoot = "",
234
237
  memoryClaimant = null,
235
- resolveEncodeInput = null,
238
+ encodeInputResolution = null,
236
239
  repriceEncodeDemand = null,
237
240
  readSourceMedia = null,
238
241
  readMetadataActivity = null,
239
242
  sourceInputsFor = null,
240
243
  readEncodeRanges = null,
241
244
  openEncodeWholeFile = null,
242
- stretchCopied = () => {},
243
245
  readEncodeHeldRanges = null,
244
246
  encodeInputs = null,
245
247
  indexMemory = null,
246
248
  budgetPolicy = null,
247
- onViewerChanged = null,
248
249
  // Told when an encoder starts or ends: the room this host has for one more
249
250
  // encode changes with it, and the server chooses proxies by that room.
250
251
  onEncodersChanged = null}) {
@@ -774,19 +775,15 @@ export function wireOutputs({
774
775
  // watched file of every torrent.
775
776
  parts.applyDownloadShare = () => {
776
777
  if (!parts.setDownloadRate || !parts.hostLoad || !parts.encodeCost) return;
777
- const files = new Map();
778
- for (const output of parts.outputs.values()) {
779
- if (!(parts.viewers?.forOutput(output)?.size > 0)) continue;
780
- const key = output.file.key;
781
- const length = Number(output.file.lengthBytes ?? parts.hostLoad.fileLengthByKey.get(key));
782
- const file = files.get(key) ?? { bytesPerFilmSecond: length / Number(output.file.durationSeconds), encodeCostSec: 0 };
783
- const state = parts.encodeRuns.runStateOf(output);
784
- if (processCanBeSignalled(state) && state !== ENCODE_RUN_STATE.SUSPENDED) {
785
- file.encodeCostSec += parts.encodeCost.loadOfOutput(output.outputKey)?.costSec ?? 0;
778
+ const watched = watchedFiles(parts.outputs.values(), {
779
+ watched: (output) => parts.viewers?.forOutput(output)?.size > 0,
780
+ lengthOf: (output) => Number(output.file.lengthBytes ?? parts.hostLoad.fileLengthByKey.get(output.file.key)),
781
+ encodeCostOf: (output) => {
782
+ const state = parts.encodeRuns.runStateOf(output);
783
+ return processCanBeSignalled(state) && state !== ENCODE_RUN_STATE.SUSPENDED
784
+ ? parts.encodeCost.loadOfOutput(output.outputKey)?.costSec ?? 0 : 0;
786
785
  }
787
- files.set(key, file);
788
- }
789
- const watched = [...files.values()].filter((file) => Number.isFinite(file.bytesPerFilmSecond) && file.bytesPerFilmSecond > 0);
786
+ });
790
787
  const share = downloadShare({ availableShare: parts.processor?.availability()?.share ?? 1,
791
788
  baseDraw: parts.hostLoad.baseDraw, costPerMegabyte: parts.hostLoad.observedTorrentCostPerMegabyte, files: watched });
792
789
  // Kept beside the readings it is judged against: what the torrents moved
@@ -879,7 +876,9 @@ export function wireOutputs({
879
876
  if (calibration?.fingerprint) {
880
877
  parts.localObservations = new LocalObservations({
881
878
  fingerprint: calibration.fingerprint,
882
- filePath: path.join(typeof stateDir === "string" && stateDir.length > 0 ? stateDir : PROXY_ROOT, "local-observations.json"),
879
+ // The state directory arrives resolved; none keeps the observations in memory only.
880
+ filePath: typeof stateDir === "string" && stateDir.length > 0 ? path.join(stateDir, "local-observations.json") : "",
881
+ writeJson: writeJsonAtomic,
883
882
  logger
884
883
  });
885
884
  parts.localObservations.load(calibration.kinds().map((kind) => configurationKeyOf(calibration.fingerprint, {
@@ -979,10 +978,13 @@ export function wireOutputs({
979
978
  // be re-taken on a five-second timer instead, which made a just-created
980
979
  // output wait up to five seconds before anything noticed it had a viewer.
981
980
  parts.retention = new OutputRetention();
981
+ // Who listens to viewer changes subscribes here (`startPlayback`); a throw
982
+ // of a listener reaches the code that changed the viewers.
983
+ parts.viewerChanges = new EventEmitter();
982
984
  parts.viewers = new Viewers({ onChange: () => {
983
985
  parts.lifecycle.observeUse();
984
986
  parts.encodeRuns.planEncodersSoon();
985
- onViewerChanged?.();
987
+ parts.viewerChanges.emit("change");
986
988
  } });
987
989
  // The outputs that exist, and every question about them: the picture a step
988
990
  // belongs to, the steps, the soundtracks, the height a step is named by.
@@ -1124,36 +1126,12 @@ export function wireOutputs({
1124
1126
  // cost) and how much of the machine nobody has priced (the host's reading).
1125
1127
  parts.admission = new EncodeAdmission({
1126
1128
  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
- },
1129
+ preparedAddresses: () => preparedAddresses(parts.viewers, parts.outputs),
1137
1130
  // Every output a present viewer RECEIVES — by the same rule the per-output
1138
1131
  // priority map is built by — that still has something left to make. The
1139
1132
  // place an output is opened on is held from the moment its viewer is put
1140
1133
  // 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
- },
1134
+ watchedAddresses: () => watchedAddresses(parts.viewers, parts.outputs, finishedOutput),
1157
1135
  finished: (address) => parts.outputs.outputsOn(address).some((session) => finishedOutput(session)),
1158
1136
  loadOf: (address) => parts.encodeCost.loadOfOutput(address),
1159
1137
  loadForCandidate: (spec, file) => parts.encodeCost.loadForCandidate(spec, file),
@@ -1216,12 +1194,12 @@ export function wireOutputs({
1216
1194
  void parts.machineBudget.revise(parts.machineBudget.storageResource, "playback stock");
1217
1195
  }
1218
1196
  });
1219
- parts.encodeInputs = encodeInputs ?? (typeof resolveEncodeInput === "function" ? new EncodeInputs({
1220
- resolve: resolveEncodeInput,
1197
+ parts.encodeInputs = encodeInputs ?? (typeof encodeInputResolution?.resolve === "function" ? new EncodeInputs({
1198
+ resolve: (...args) => encodeInputResolution.resolve(...args),
1221
1199
  readRanges: readEncodeRanges,
1222
1200
  openWholeFile: openEncodeWholeFile,
1223
1201
  heldRanges: readEncodeHeldRanges,
1224
- stretchCopied,
1202
+ stretchCopied: (...args) => encodeInputResolution.stretchCopied(...args),
1225
1203
  reviseBudget: () => parts.machineBudget.revise("memory", "admitted encode inputs"),
1226
1204
  capacity: () => parts.machineBudget.capacityOf("memory"),
1227
1205
  // How long this output's input takes to open, from the one owner of start
@@ -1376,5 +1354,40 @@ export function wireOutputs({
1376
1354
  void parts.quality.runQualityBudgetOnce();
1377
1355
  }, BUDGET_CHECK_INTERVAL_MS);
1378
1356
  parts.budgetTimer.unref();
1357
+ /**
1358
+ * What the download map's pass reads of the outputs: each output of a source
1359
+ * that a present viewer watches and that reads the file, in catalog order,
1360
+ * with what decides how long its segments take to make and deliver and what
1361
+ * it has made (`PlaybackResolution`).
1362
+ *
1363
+ * @param {string} sourceKey
1364
+ * @param {number} fileIndex
1365
+ * @returns {Array<{ outputKey: string, timeline: object, choices: object[], revision: unknown,
1366
+ * sizes: () => Map<number, number>, encodeSpeed: number | undefined, linkMbps: number | null, target: number | null }>}
1367
+ */
1368
+ parts.playedOutputsOf = (sourceKey, fileIndex) => {
1369
+ const played = [];
1370
+ for (const output of parts.outputs.values()) {
1371
+ if (output.file.sourceKey !== sourceKey) continue;
1372
+ const consumers = [...parts.viewers.forOutput(output).values()].filter(viewer =>
1373
+ viewer.isPresent() && !parts.outputs.supersededBy(output, viewer.activeVariantId ?? null));
1374
+ if (consumers.length === 0) continue;
1375
+ const choices = trackChoicesOf(output, fileIndex);
1376
+ if (choices.length === 0) continue;
1377
+ // An output with no cut grid is left out by the pass (`PlaybackResolution`);
1378
+ // none of what follows is worth working out for it.
1379
+ const timeline = output.timeline;
1380
+ if (!timeline || typeof timeline !== "object" || !Array.isArray(timeline.published ?? timeline.boundaries)) continue;
1381
+ const links = consumers.map(viewer => viewer.linkReading()?.linkMbps)
1382
+ .filter(value => Number.isFinite(value) && value > 0);
1383
+ played.push({ outputKey: output.outputKey, timeline: output.timeline, choices,
1384
+ revision: parts.segmentStore.revisionOf(output.outputKey),
1385
+ sizes: () => parts.segmentStore.sizesOf(output.outputKey),
1386
+ encodeSpeed: parts.encodeCost.latestSpeedReadingOf(output)?.speed,
1387
+ linkMbps: links.length ? Math.min(...links) : null,
1388
+ target: parts.encodeInputs?.targetInputBytes?.(output) ?? null });
1389
+ }
1390
+ return played;
1391
+ };
1379
1392
  return parts;
1380
1393
  }
@@ -27,6 +27,7 @@ import fs from "node:fs/promises";
27
27
  import { randomUUID } from "node:crypto";
28
28
  import os from "node:os";
29
29
  import path from "node:path";
30
+ import { writeJsonAtomic } from "../../../utils/atomic-json.js";
30
31
  import { IDLE_KEEP_MS } from "../keep.js";
31
32
 
32
33
  /**
@@ -541,7 +542,11 @@ export class CompletedFiles {
541
542
  await fs.rm(directory, { ...REMOVAL, recursive: true }).catch(() => undefined);
542
543
  return;
543
544
  }
544
- await fs.writeFile(path.join(directory, "manifest.json"), JSON.stringify(named)).catch(() => undefined);
545
+ try {
546
+ writeJsonAtomic(path.join(directory, "manifest.json"), named);
547
+ } catch {
548
+ // The manifest is a convenience for the next start; a failed write is not a failed keep.
549
+ }
545
550
  }
546
551
 
547
552
  /** Remove an empty or partial-only directory discovered during startup. */
@@ -459,13 +459,13 @@ export function startMemoryReport({
459
459
  label = "",
460
460
  diskPath = "",
461
461
  intervalMs = MEMORY_REPORT_INTERVAL_MS,
462
- quietMs = 0,
463
- changeBytes = 0,
462
+ quietMs = 60_000,
463
+ changeBytes = 25 * 1024 * 1024,
464
464
  snapshotDir = "",
465
465
  mayKeep = null,
466
- snapshotFloorBytes = 500 * 1024 * 1024,
467
- snapshotGrowthBytes = 100 * 1024 * 1024,
468
- keepSnapshots = 0,
466
+ snapshotFloorBytes = 400 * 1024 * 1024,
467
+ snapshotGrowthBytes = 400 * 1024 * 1024,
468
+ keepSnapshots = 3,
469
469
  heapSnapshots = false,
470
470
  bufferSnapshotFloorBytes = 0,
471
471
  bufferSnapshotGrowthBytes = 500 * 1024 * 1024
@@ -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
+ }
@@ -44,6 +44,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync,
44
44
  import { rmSync } from "node:fs";
45
45
  import os from "node:os";
46
46
  import path from "node:path";
47
+ import { writeFileAtomic } from "../../../utils/atomic-json.js";
47
48
 
48
49
  /** Where every output's segments live. One root for the process. */
49
50
  const DEFAULT_STORE_ROOT = path.join(os.tmpdir(), "torrent-tv-hls");
@@ -235,7 +236,7 @@ export class SegmentStore {
235
236
  mkdirSync(dir, { recursive: true });
236
237
  // What this directory is, for whoever finds it after this process has
237
238
  // been killed. Without it the sweep can only throw everything away.
238
- writeFileSync(path.join(dir, KEY_FILE), `${key}\n`, "utf8");
239
+ writeFileAtomic(path.join(dir, KEY_FILE), `${key}\n`);
239
240
  }
240
241
  this.#touched.set(key, this.#now());
241
242
  // Production is about to begin here, and a piece closing in this directory