@torrent-tv/proxy 2.95.39 → 2.95.40

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 CHANGED
@@ -376,7 +376,10 @@ try {
376
376
  // Late-bound the same way `webRtcManager` is below: the torrent pool is
377
377
  // built inside `startProxyServer`, before `dataChannelHandler` — the
378
378
  // thing that actually owns a channel to push down — exists.
379
- onSubtitleCues: (event) => dataChannelHandler?.publishSubtitleCues(event)
379
+ onSubtitleCues: (event) => dataChannelHandler?.publishSubtitleCues(event),
380
+ // Late-bound for the same reason: what the connections carry beyond the
381
+ // film is counted by the data-channel handler, built further down.
382
+ serviceShare: () => dataChannelHandler?.serviceShare?.() ?? null
380
383
  });
381
384
  app = started.app;
382
385
  actualPort = started.port;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.95.39",
3
+ "version": "2.95.40",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
package/server.js CHANGED
@@ -117,6 +117,9 @@ function buildPortCandidates(startPort, maxAttempts = 51) {
117
117
  export async function startProxyServer({
118
118
  host, port, transcodeAudio, ffmpegBin, memoryBytes, segmentFormat, stateDir, onSubtitleCues, diagnostics, budgetPolicy,
119
119
  deliverySink = false,
120
+ // What viewers' connections carry beyond the film, per byte of film; the
121
+ // transport counts it and is built after this server, so it is late-bound.
122
+ serviceShare = () => null,
120
123
  // Where the proxy writes its own log. Its DIRECTORY is what matters here:
121
124
  // the browser's half of every session is written beside it, so the two are
122
125
  // on one durable disk and join by name.
@@ -608,6 +611,7 @@ export async function startProxyServer({
608
611
  let sourcePreparation;
609
612
  const outputParts = wireOutputs({
610
613
  indexMemory,
614
+ serviceShare,
611
615
  readMetadataActivity: () => containerOrchestrator.activity(),
612
616
  onViewerChanged: () => { void sourcePreparation?.refresh(); },
613
617
  sourceInputsFor: (output, index) => downloadMaps.inputsForOutput(output.file.sourceKey, output.outputKey, index),
@@ -587,13 +587,21 @@ export class OutputOpening {
587
587
  linkMbps: viewerLinkMbps,
588
588
  keys: [...this.#host.outputs.values()].map((other) => other.outputKey).concat(this.#host.segmentOutputFiles.addresses()),
589
589
  readyAt: (key) => this.#host.segmentOutputFiles.isClosed(key, timeline.indexForTime(Math.max(0, startPositionSeconds))),
590
- observedPeakMbps: (candidate) => this.#host.observedPeakMbps?.(candidate) ?? null
590
+ observedPeakMbps: (candidate) => this.#host.observedPeakMbps?.(candidate) ?? null,
591
+ serviceShare: () => this.#host.serviceShare?.() ?? null
591
592
  }
592
593
  });
594
+ // Which picture rate the link was asked about, and where it came from
595
+ // (`SourceFile.pictureRate`): a track's own figure, the file minus its
596
+ // soundtracks, or the whole file as a bound.
597
+ const pictureRate = file.pictureRate;
598
+ const pictureRateNote = pictureRate
599
+ ? ` picture=${pictureRate.kbps}kbps (${pictureRate.source})`
600
+ : " picture rate unknown: the file's own rate is not known yet";
593
601
  if (decided.unavailable) {
594
602
  this.#host.logger.info(
595
603
  `transcode "${logName}": no output suits this viewer — ${decided.unavailable.reason} ` +
596
- `${JSON.stringify(decided.unavailable.figures)} (wanted ${decided.wantedKey})`
604
+ `${JSON.stringify(decided.unavailable.figures)} (wanted ${decided.wantedKey})${pictureRateNote}`
597
605
  );
598
606
  const error = new Error(`No output suits this viewer: ${decided.unavailable.reason}.`);
599
607
  // Bound by the MACHINE rather than by the viewer's link, for the output a
@@ -616,7 +624,7 @@ export class OutputOpening {
616
624
  }
617
625
  if (decided.answer) {
618
626
  this.#host.logger.info(
619
- `transcode "${logName}": ${decided.answer.verdict} for this viewer ${JSON.stringify(linkFiguresOf(decided.answer))}`
627
+ `transcode "${logName}": ${decided.answer.verdict} for this viewer ${JSON.stringify(linkFiguresOf(decided.answer))}${pictureRateNote}`
620
628
  );
621
629
  }
622
630
  const spec = decided.spec;
@@ -137,7 +137,8 @@ export class Renditions {
137
137
  Number.isFinite(base.file.pictureKbps) ? base.file.pictureKbps / 1000 : null,
138
138
  this.#host.observedPeakMbps?.(spec) ?? null
139
139
  ),
140
- audioLoad
140
+ audioLoad,
141
+ this.#host.serviceShare?.() ?? null
141
142
  )
142
143
  )
143
144
  });
@@ -535,7 +536,8 @@ export class Renditions {
535
536
  }
536
537
  const linkMbps = this.#host.linkMbpsOf(base, consumerId);
537
538
  const audioLoad = this.viewerAudioLoadOf(base, consumerId);
538
- const admits = (limit) => linkCouldCarry(linkMbps, loadOf(videoLoadOfLimit(limit), audioLoad)).admitted;
539
+ const serviceShare = this.#host.serviceShare?.() ?? null;
540
+ const admits = (limit) => linkCouldCarry(linkMbps, loadOf(videoLoadOfLimit(limit), audioLoad, serviceShare)).admitted;
539
541
  const target = direction === "down"
540
542
  ? limits.slice(at + 1).find(admits)
541
543
  : (at > 0 && admits(limits[at - 1]) ? limits[at - 1] : undefined);
@@ -373,7 +373,8 @@ export class QualityController {
373
373
  const report = this.#host.linkReportOf(session, consumerId);
374
374
  const load = loadOf(
375
375
  this.#host.qualityOffer.videoLoadFor(base, height, this.#host.videoEncoder?.kind ?? ""),
376
- this.#host.viewerAudioLoadOf(base, consumerId)
376
+ this.#host.viewerAudioLoadOf(base, consumerId),
377
+ this.#host.serviceShare?.() ?? null
377
378
  );
378
379
  return linkCouldCarry(report?.linkMbps ?? null, load).admitted;
379
380
  }
@@ -20,6 +20,14 @@
20
20
  * soundtrack whose rate nothing states.
21
21
  *
22
22
  * A load is as trustworthy as its least trustworthy part.
23
+ *
24
+ * AND WHAT THE CONNECTION CARRIES BESIDES THE FILM (torrent-tv/meta#169). The
25
+ * same connection carries message framing, playlists, poll answers, probes and
26
+ * pushed cues. Their share is measured by the transport over everything this
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`).
23
31
  */
24
32
 
25
33
  import { AUDIO_TRANSCODE_KBPS, maxrateKbpsFor, nominalKbpsFor } from "../args.js";
@@ -44,7 +52,7 @@ const CLASS_ORDER = [PEAK_CLASS.KNOWN, PEAK_CLASS.ESTIMATED, PEAK_CLASS.UNKNOWN]
44
52
 
45
53
  /**
46
54
  * @typedef {{ mbps: number | null, peakClass: string }} LoadPart
47
- * @typedef {{ video: LoadPart | null, audio: LoadPart | null, totalMbps: number | null, peakClass: string }} Load
55
+ * @typedef {{ video: LoadPart | null, audio: LoadPart | null, serviceMbps: number | null, serviceShare: number | null, totalMbps: number | null, peakClass: string }} Load
48
56
  */
49
57
 
50
58
  /**
@@ -220,22 +228,29 @@ export const SOUNDTRACK_MODE_CAUSE = Object.freeze({
220
228
  });
221
229
 
222
230
  /**
223
- * The whole load: the parts added, and the class of the least trustworthy.
231
+ * The whole load: the parts added, what the connection carries besides them,
232
+ * and the class of the least trustworthy part.
224
233
  *
225
234
  * @param {LoadPart | null} video
226
235
  * @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.
227
239
  * @returns {Load}
228
240
  */
229
- export function loadOf(video, audio) {
241
+ export function loadOf(video, audio, serviceShare = null) {
230
242
  const parts = [video, audio].filter(Boolean);
231
243
  const peakClass = parts.reduce(
232
244
  (worst, one) => (CLASS_ORDER.indexOf(one.peakClass) > CLASS_ORDER.indexOf(worst) ? one.peakClass : worst),
233
245
  PEAK_CLASS.KNOWN
234
246
  );
235
- const totalMbps = peakClass === PEAK_CLASS.UNKNOWN
247
+ const share = Number.isFinite(serviceShare) && serviceShare >= 0 ? serviceShare : null;
248
+ const filmMbps = peakClass === PEAK_CLASS.UNKNOWN
236
249
  ? null
237
250
  : parts.reduce((sum, one) => sum + one.mbps, 0);
238
- return { video, audio, totalMbps, peakClass };
251
+ const serviceMbps = filmMbps === null || share === null ? null : filmMbps * share;
252
+ const totalMbps = filmMbps === null ? null : filmMbps + (serviceMbps ?? 0);
253
+ return { video, audio, serviceMbps, serviceShare: share, totalMbps, peakClass };
239
254
  }
240
255
 
241
256
  /**
@@ -335,6 +350,8 @@ export function linkAnswerFigures(answer) {
335
350
  videoClass: answer.load.video?.peakClass ?? null,
336
351
  audioMbps: answer.load.audio?.mbps ?? null,
337
352
  audioClass: answer.load.audio?.peakClass ?? null,
353
+ serviceMbps: answer.load.serviceMbps ?? null,
354
+ serviceShare: answer.load.serviceShare ?? null,
338
355
  totalMbps: answer.load.totalMbps,
339
356
  peakClass: answer.load.peakClass
340
357
  };
@@ -62,7 +62,7 @@ import { linkAnswerFigures, linkCouldCarry, linkRefusalReason, loadOf, videoLoad
62
62
  * soundtrack this viewer receives with the picture, from it or beside it.
63
63
  * @param {(encode: object | null) => OutputSpec} params.specWith - The output
64
64
  * with this picture format and every other part as the request made it.
65
- * @param {{ mode: "auto" | "manual", linkMbps: number | null, keys: Iterable<string>, readyAt: (key: string) => boolean, observedPeakMbps?: (spec: OutputSpec) => number | null }} params.serving
65
+ * @param {{ mode: "auto" | "manual", linkMbps: number | null, keys: Iterable<string>, readyAt: (key: string) => boolean, observedPeakMbps?: (spec: OutputSpec) => number | null, serviceShare?: () => number | null }} params.serving
66
66
  * @returns {{
67
67
  * spec: OutputSpec | null,
68
68
  * budget: object | null,
@@ -97,7 +97,8 @@ export function decideOutputFormat({
97
97
  loadOf(
98
98
  videoLoadOfSpec(spec, Number.isFinite(source.pictureKbps) ? source.pictureKbps / 1000 : null,
99
99
  serving.observedPeakMbps?.(spec) ?? null),
100
- audioLoad
100
+ audioLoad,
101
+ serving.serviceShare?.() ?? null
101
102
  )
102
103
  );
103
104
  const given = (spec, answer, extra = {}) => ({
@@ -60,30 +60,58 @@ export function sourceDecodeCharacteristics(mediaInfo) {
60
60
  };
61
61
  }
62
62
 
63
+ /** Where a picture rate was taken from, most exact first. */
64
+ export const PICTURE_RATE_SOURCE = Object.freeze({
65
+ TRACK: "the picture track's own rate",
66
+ FILE_MINUS_AUDIO: "the file's rate minus every soundtrack's",
67
+ FILE_BOUND: "bounded by the whole file's rate"
68
+ });
69
+
63
70
  /**
64
- * The source picture's average rate, derived from the file rate after removing
65
- * the rates stated for every embedded audio track. A missing track rate makes
66
- * the picture rate unknown rather than treating the whole file rate as video.
71
+ * The source picture's average rate, and where it came from.
67
72
  *
68
- * @param {{ bitrateKbps?: number | null, audioTracks?: Array<{ bitrateKbps?: number | null }> } | null} mediaInfo
69
- * @returns {number | null}
73
+ * 1. the picture track's own rate, where the container states it per track —
74
+ * MP4 counts it from the sizes of its samples, Matroska carries it in the
75
+ * track's statistics tags;
76
+ * 2. otherwise the whole file's rate minus every soundtrack's stated rate;
77
+ * 3. otherwise the whole file's rate. The picture is part of the file, so its
78
+ * average cannot be larger: this is an upper bound, not a guess, and it
79
+ * errs towards a heavier load. It is used where a soundtrack states no
80
+ * average — before torrent-tv/meta#169 that made the picture unknown, and
81
+ * every MP4 and Matroska file with sound was refused on a measured link.
82
+ *
83
+ * @param {{ bitrateKbps?: number | null, videoBitrateKbps?: number | null, audioTracks?: Array<{ bitrateKbps?: number | null }> } | null} mediaInfo
84
+ * @returns {{ kbps: number, source: string } | null} Null when not even the
85
+ * file's rate is known.
70
86
  */
71
- export function sourcePictureBitrateKbps(mediaInfo) {
87
+ export function sourcePictureRate(mediaInfo) {
88
+ const positive = (value) => Number.isFinite(Number(value)) && Number(value) > 0;
89
+ if (positive(mediaInfo?.videoBitrateKbps)) {
90
+ return { kbps: Math.round(Number(mediaInfo.videoBitrateKbps)), source: PICTURE_RATE_SOURCE.TRACK };
91
+ }
72
92
  const totalKbps = Number(mediaInfo?.bitrateKbps);
73
- const audioTracks = mediaInfo?.audioTracks;
74
- if (!(Number.isFinite(totalKbps) && totalKbps > 0) || !Array.isArray(audioTracks)) {
93
+ if (!positive(totalKbps)) {
75
94
  return null;
76
95
  }
77
- let audioKbps = 0;
78
- for (const track of audioTracks) {
79
- const rate = Number(track?.bitrateKbps);
80
- if (!(Number.isFinite(rate) && rate > 0)) {
81
- return null;
96
+ const audioTracks = Array.isArray(mediaInfo?.audioTracks) ? mediaInfo.audioTracks : null;
97
+ if (audioTracks && audioTracks.every((track) => positive(track?.bitrateKbps))) {
98
+ const pictureKbps = totalKbps - audioTracks.reduce((sum, track) => sum + Number(track.bitrateKbps), 0);
99
+ if (pictureKbps > 0) {
100
+ return { kbps: Math.round(pictureKbps), source: PICTURE_RATE_SOURCE.FILE_MINUS_AUDIO };
82
101
  }
83
- audioKbps += rate;
84
102
  }
85
- const pictureKbps = totalKbps - audioKbps;
86
- return Number.isFinite(pictureKbps) && pictureKbps > 0 ? Math.round(pictureKbps) : null;
103
+ return { kbps: Math.round(totalKbps), source: PICTURE_RATE_SOURCE.FILE_BOUND };
104
+ }
105
+
106
+ /**
107
+ * The source picture's average rate in kbit/s, or null while not even the
108
+ * file's rate is known ({@link sourcePictureRate}).
109
+ *
110
+ * @param {object | null} mediaInfo
111
+ * @returns {number | null}
112
+ */
113
+ export function sourcePictureBitrateKbps(mediaInfo) {
114
+ return sourcePictureRate(mediaInfo)?.kbps ?? null;
87
115
  }
88
116
 
89
117
  export class SourceFile {
@@ -221,11 +249,20 @@ export class SourceFile {
221
249
  return Number.isFinite(kbps) && kbps > 0 ? kbps / 1000 : null;
222
250
  }
223
251
 
224
- /** The source picture's average rate, or null until every required fact is known. */
252
+ /** The source picture's average rate, or null until the file's rate is known. */
225
253
  get pictureKbps() {
226
254
  return sourcePictureBitrateKbps(this.media);
227
255
  }
228
256
 
257
+ /**
258
+ * The source picture's average rate with where it came from, for log lines.
259
+ *
260
+ * @returns {{ kbps: number, source: string } | null}
261
+ */
262
+ get pictureRate() {
263
+ return sourcePictureRate(this.media);
264
+ }
265
+
229
266
  /**
230
267
  * The address this proxy's own HTTP serves this file at.
231
268
  *
@@ -49,7 +49,7 @@ import { RetainedReads } from "./RetainedReads.js";
49
49
  import { isUnavailable } from "./unavailable.js";
50
50
  import {
51
51
  ID_ATTACHED_FILE, ID_ATTACHMENTS, ID_CHAPTERS, ID_FILE_DATA, ID_FILE_MEDIA_TYPE, ID_FILE_NAME, ID_INFO_TITLE, ID_TAGS,
52
- chapterTitlesOf, coverIndexOf, workFromTags
52
+ chapterTitlesOf, coverIndexOf, trackRatesFromTags, uidKeyOf, workFromTags
53
53
  } from "./matroska-work-tags.js";
54
54
  import {
55
55
  COVER_TYPES, MAX_COVER_BYTES, OutsideReadableEdges, edgeReader, emptyWorkTags, imageTypeOf, isNumberingOnly, text, textList
@@ -80,6 +80,7 @@ const ID_TRACKS = 0x1654ae6b;
80
80
  const ID_TRACK_ENTRY = 0xae;
81
81
  const ID_TRACK_NUMBER = 0xd7;
82
82
  const ID_TRACK_TYPE = 0x83;
83
+ const ID_TRACK_UID = 0x73c5;
83
84
  const ID_FLAG_ENABLED = 0xb9;
84
85
  const ID_FLAG_DEFAULT = 0x88;
85
86
  const ID_FLAG_FORCED = 0x55aa;
@@ -531,6 +532,8 @@ export class MatroskaContainer extends Container {
531
532
  /** @type {import("../tracks/index.js").ContainerTrack[]} */
532
533
  const result = [];
533
534
  const counters = { video: -1, audio: -1, subtitle: -1 };
535
+ /** @type {Map<string, import("../tracks/index.js").ContainerTrack>} */
536
+ const byUid = new Map();
534
537
  const within = this.#readerWithin(tracksHeader.end);
535
538
  let at = tracksHeader.dataOffset;
536
539
  while (at < tracksHeader.end) {
@@ -563,15 +566,51 @@ export class MatroskaContainer extends Container {
563
566
  track.codecDelaySeconds = fields.codecDelaySeconds ?? 0;
564
567
  track.timestampScale = fields.timestampScale ?? 1;
565
568
  track.codecRanges = [[entry.dataOffset, entry.end - 1]];
569
+ if (fields.trackUid && (track.type === "video" || track.type === "audio")) byUid.set(fields.trackUid, track);
566
570
  result.push(track);
567
571
  }
568
572
  }
569
573
  at = entry.end;
570
574
  }
575
+ await this.#readTrackRates(reader, layout, byUid);
571
576
  this.#tracks = result;
572
577
  return result;
573
578
  }
574
579
 
580
+ /**
581
+ * The average rate each picture and soundtrack's statistics state, written
582
+ * onto the track as `bitrateKbps` (`trackRatesFromTags`).
583
+ *
584
+ * Read strictly with the track table, like the table itself: a `Tags`
585
+ * element whose bytes have not arrived is not an answer that the file states
586
+ * none, so the whole reading is asked again when they have. A `Tags`
587
+ * element larger than one portion is not read, and one that cannot be parsed
588
+ * states no rates: a damaged tag costs the rate, never the track table.
589
+ *
590
+ * @param {ElementReader} reader
591
+ * @param {SegmentLayout} layout
592
+ * @param {Map<string, import("../tracks/index.js").ContainerTrack>} byUid
593
+ * @returns {Promise<void>}
594
+ */
595
+ async #readTrackRates(reader, layout, byUid) {
596
+ if (byUid.size === 0 || layout.tagsAt === null) return;
597
+ const header = await reader.header(layout.tagsAt, layout.segmentEnd);
598
+ if (!header || header.id !== ID_TAGS || header.size === null || header.size > this.portionBytes) return;
599
+ const data = await reader.data(header);
600
+ if (!data) return;
601
+ let rates;
602
+ try {
603
+ rates = trackRatesFromTags(data);
604
+ } catch (error) {
605
+ if (isUnavailable(error)) throw error;
606
+ return;
607
+ }
608
+ for (const [uid, kbps] of rates) {
609
+ const track = byUid.get(uid);
610
+ if (track) track.bitrateKbps = kbps;
611
+ }
612
+ }
613
+
575
614
  /** Original-file ranges from Cues alone, without reading media block headers.
576
615
  * FFmpeg needs the declarations, the Clusters its stream search reads from the
577
616
  * first one, and the seek table as well as complete clusters.
@@ -1071,6 +1110,7 @@ function parseTrackEntry(data) {
1071
1110
  const fields = {
1072
1111
  refused: false,
1073
1112
  trackNumber: null,
1113
+ trackUid: "",
1074
1114
  type: null,
1075
1115
  codecId: "",
1076
1116
  language: null,
@@ -1102,6 +1142,7 @@ function parseTrackEntry(data) {
1102
1142
  case 0x56aa: fields.codecDelaySeconds = readUint(data, f.dataOffset, f.size) / 1e9; break;
1103
1143
  case 0x23314f: fields.timestampScale = readFloat(data, f.dataOffset, f.size); break;
1104
1144
  case ID_TRACK_NUMBER: fields.trackNumber = readUint(data, f.dataOffset, f.size); break;
1145
+ case ID_TRACK_UID: fields.trackUid = uidKeyOf(data, f); break;
1105
1146
  case ID_TRACK_TYPE: fields.type = readUint(data, f.dataOffset, f.size); break;
1106
1147
  case ID_CODEC_ID: fields.codecId = readString(data, f); break;
1107
1148
  case ID_CODEC_PRIVATE: fields.codecPrivateB64 = data.toString("base64", f.dataOffset, f.dataOffset + f.size); break;
@@ -8,6 +8,7 @@
8
8
  * - elng: extendedLanguage BCP47 (when present, replaces mdhd language per spec)
9
9
  * - stsd: sample entry format (avc1/hev1/mp4a/tx3g/wvtt/stpp)
10
10
  * - stbl tables for subtitle cue ranges (stts/stsz/stsc/stco/co64) and video keyframes (stss/stts/ctts/elst)
11
+ * - stsz + mdhd: each track's average rate, from the sizes of its own samples
11
12
  *
12
13
  * Keyframe reading is delegated to mp4.js. The subtitle sample table is read in
13
14
  * this module, because every rule in it is a statement of ISO/IEC 14496-12 about
@@ -757,7 +758,8 @@ export class Mp4Container extends Container {
757
758
  displayWidth,
758
759
  displayHeight,
759
760
  fps: configuration?.fps ?? videoSampleRate(moov, mdhd, stbl),
760
- bitDepth: configuration?.bitDepth ?? null
761
+ bitDepth: configuration?.bitDepth ?? null,
762
+ bitrateKbps: trackBitrateKbps(moov, mdhd, stbl)
761
763
  });
762
764
  if (configuration) {
763
765
  track.codecConfiguration = configuration;
@@ -811,6 +813,7 @@ export class Mp4Container extends Container {
811
813
  channels: sampleEntry?.channels ?? null,
812
814
  samplingFrequency: sampleEntry?.sampleRate ?? null,
813
815
  bitDepth: sampleEntry?.bitDepth ?? null,
816
+ bitrateKbps: trackBitrateKbps(moov, mdhd, stbl),
814
817
  codecDelaySeconds: sampleEntry?.codecDelaySeconds ?? 0,
815
818
  seekPrerollSeconds: sampleEntry?.seekPrerollSeconds ?? 0
816
819
  }));
@@ -854,6 +857,42 @@ export class Mp4Container extends Container {
854
857
  }
855
858
 
856
859
  /** Average decoded-frame cadence from the declared sample timing table. */
860
+ /**
861
+ * The average rate of one track, from its own sample table: every sample's
862
+ * size (`stsz`, ISO/IEC 14496-12 §8.7.3) added up, over the track's duration
863
+ * in its media timescale (`mdhd`, §8.4.2). These are the bytes the track
864
+ * carries, counted, not a figure a muxer wrote down.
865
+ *
866
+ * Null where the table lists no samples — a fragmented file keeps them in its
867
+ * fragments — the duration is zero, or either box is too short for what it
868
+ * declares. A rate is one figure among the track's facts; a damaged table
869
+ * costs that figure and not the track.
870
+ *
871
+ * @param {Buffer} bytes
872
+ * @param {{ dataOffset: number, end: number } | null} mdhd
873
+ * @param {{ dataOffset: number, end: number } | null} stbl
874
+ * @returns {number | null} kbit/s
875
+ */
876
+ function trackBitrateKbps(bytes, mdhd, stbl) {
877
+ const stsz = mdhd && stbl && childOf(bytes, stbl.dataOffset, stbl.end, "stsz");
878
+ if (!stsz) return null;
879
+ const version = bytes[mdhd.dataOffset];
880
+ const scaleAt = mdhd.dataOffset + (version === 1 ? 20 : 12);
881
+ if (scaleAt + (version === 1 ? 12 : 8) > mdhd.end) return null;
882
+ const scale = bytes.readUInt32BE(scaleAt);
883
+ const duration = version === 1 ? Number(bytes.readBigUInt64BE(scaleAt + 4)) : bytes.readUInt32BE(scaleAt + 4);
884
+ if (stsz.dataOffset + 12 > stsz.end) return null;
885
+ const uniform = bytes.readUInt32BE(stsz.dataOffset + 4);
886
+ const count = bytes.readUInt32BE(stsz.dataOffset + 8);
887
+ if (!uniform && stsz.dataOffset + 12 + count * 4 > stsz.end) return null;
888
+ let total = uniform * count;
889
+ if (!uniform) {
890
+ for (let at = stsz.dataOffset + 12, end = at + count * 4; at < end; at += 4) total += bytes.readUInt32BE(at);
891
+ }
892
+ const seconds = scale > 0 && Number.isSafeInteger(duration) ? duration / scale : 0;
893
+ return total > 0 && seconds > 0 ? total * 8 / seconds / 1000 : null;
894
+ }
895
+
857
896
  function videoSampleRate(bytes, mdhd, stbl) {
858
897
  if (!mdhd || !stbl) return null;
859
898
  const scaleAt = mdhd.dataOffset + (bytes[mdhd.dataOffset] === 1 ? 20 : 12);
@@ -25,7 +25,8 @@ export const ID_INFO_TITLE = 0x7ba9;
25
25
  const ID_TAG = 0x7373;
26
26
  const ID_TARGETS = 0x63c0;
27
27
  const ID_TARGET_TYPE_VALUE = 0x68ca;
28
- const TARGET_UIDS = new Set([0x63c5, 0x63c9, 0x63c4, 0x63c6]);
28
+ const ID_TAG_TRACK_UID = 0x63c5;
29
+ const TARGET_UIDS = new Set([ID_TAG_TRACK_UID, 0x63c9, 0x63c4, 0x63c6]);
29
30
  const ID_SIMPLE_TAG = 0x67c8;
30
31
  const ID_TAG_NAME = 0x45a3;
31
32
  const ID_TAG_STRING = 0x4487;
@@ -102,6 +103,52 @@ export function tagsByLevel(data) {
102
103
  return levels;
103
104
  }
104
105
 
106
+ /**
107
+ * A Matroska UID as a key: its bytes in hexadecimal, leading zeros dropped.
108
+ * A UID is any 64-bit value, which a JavaScript number cannot hold exactly.
109
+ *
110
+ * @param {Buffer} buffer
111
+ * @param {{ dataOffset: number, size: number }} element
112
+ * @returns {string}
113
+ */
114
+ export function uidKeyOf(buffer, element) {
115
+ return buffer.toString("hex", element.dataOffset, element.dataOffset + element.size).replace(/^0+/u, "");
116
+ }
117
+
118
+ /**
119
+ * The average rate each track's statistics state, keyed by its `TrackUID`.
120
+ *
121
+ * mkvmerge writes, per track, a `Tag` whose `Targets` name that track by
122
+ * `TagTrackUID` and whose `SimpleTag`s carry `BPS` — the bits the track holds
123
+ * over its duration — beside `NUMBER_OF_BYTES` and `DURATION`. Those are the
124
+ * tags read here; a track the file gives no `BPS` is left out.
125
+ *
126
+ * @param {Buffer} data - The data of a `Tags` element.
127
+ * @returns {Map<string, number>} kbit/s by {@link uidKeyOf} of the track.
128
+ */
129
+ export function trackRatesFromTags(data) {
130
+ /** @type {Map<string, number>} */
131
+ const rates = new Map();
132
+ for (const tag of iterateElements(data)) {
133
+ if (tag.id !== ID_TAG) continue;
134
+ const end = Math.min(data.length, tag.dataOffset + tag.size);
135
+ const tracks = [];
136
+ for (const element of iterateElements(data, tag.dataOffset, end)) {
137
+ if (element.id !== ID_TARGETS) continue;
138
+ for (const field of iterateElements(data, element.dataOffset, Math.min(end, element.dataOffset + element.size))) {
139
+ if (field.id === ID_TAG_TRACK_UID) tracks.push(uidKeyOf(data, field));
140
+ }
141
+ }
142
+ if (tracks.length === 0) continue;
143
+ const found = [];
144
+ simpleTags(data, tag.dataOffset, end, found);
145
+ const bps = Number(found.find((simple) => simple.name === "BPS")?.value);
146
+ if (!(Number.isFinite(bps) && bps > 0)) continue;
147
+ for (const uid of tracks) if (uid.length > 0) rates.set(uid, bps / 1000);
148
+ }
149
+ return rates;
150
+ }
151
+
105
152
  /**
106
153
  * The values one level states for one name, the default one first.
107
154
  *
@@ -57,6 +57,8 @@ export function playbackDeclarations({ tracks, media, fileBytes = null }) {
57
57
  startTimeSeconds: media.startTimeSeconds,
58
58
  videoWidth: video?.width ?? 0, videoHeight: video?.height ?? 0,
59
59
  fps: video?.fps ?? null, isHdr: video?.isHdr ?? false, bitDepth: video?.bitDepth ?? null,
60
+ // The picture's own average, where the container states it per track.
61
+ videoBitrateKbps: video?.bitrateKbps ?? null,
60
62
  // The measured whole-file average prices decoding; it never addresses packets.
61
63
  bitrateKbps: Number.isFinite(media.bitrateKbps) ? media.bitrateKbps
62
64
  : Number.isSafeInteger(fileBytes) && fileBytes > 0 && media.durationSeconds > 0
@@ -716,6 +716,7 @@ export function createPlaybackPlanner({
716
716
  height: videoFacts.height,
717
717
  fps: videoFacts.fps,
718
718
  bitrateKbps: probe.bitrateKbps,
719
+ videoBitrateKbps: probe.videoBitrateKbps,
719
720
  audioTracks: probe.audioTracks,
720
721
  // Which family of the decode measurement prices this source. A video
721
722
  // that has to be re-encoded is one the browser could not play, so it
@@ -755,6 +756,7 @@ export function createPlaybackPlanner({
755
756
  width: videoFacts.width,
756
757
  height: videoFacts.height,
757
758
  bitrateKbps: probe.bitrateKbps,
759
+ videoBitrateKbps: probe.videoBitrateKbps,
758
760
  audioTracks: probe.audioTracks,
759
761
  fps: videoFacts.fps,
760
762
  startTime: probe.startTimeSeconds,
@@ -19,6 +19,8 @@ export class VideoTrack extends ContainerTrack {
19
19
  * @param {number | null} params.fps - From container when available (otherwise from ffmpeg banner elsewhere).
20
20
  * @param {boolean} params.isHdr - From container colr / ffmpeg detection later.
21
21
  * @param {number | null} params.bitDepth
22
+ * @param {number | null} [params.bitrateKbps] - The rate the file states for
23
+ * this track, in kbit/s: an average, not a bound.
22
24
  */
23
25
  constructor(params) {
24
26
  super({ ...params, type: "video" });
@@ -29,5 +31,6 @@ export class VideoTrack extends ContainerTrack {
29
31
  this.fps = Number.isFinite(params.fps) ? params.fps : null;
30
32
  this.isHdr = params.isHdr === true;
31
33
  this.bitDepth = Number.isFinite(params.bitDepth) ? params.bitDepth : null;
34
+ this.bitrateKbps = Number.isFinite(params.bitrateKbps) && params.bitrateKbps > 0 ? params.bitrateKbps : null;
32
35
  }
33
36
  }
@@ -222,6 +222,10 @@ export function wireOutputs({
222
222
  encodeInputs = null,
223
223
  indexMemory = null,
224
224
  budgetPolicy = null,
225
+ // What viewers' connections carry beyond the film, per byte of film, as
226
+ // the transport measured it (`transport/delivery-shares.js`); null while
227
+ // nothing has been delivered.
228
+ serviceShare = () => null,
225
229
  onViewerChanged = null}) {
226
230
  const parts = {};
227
231
  const audioDescriptionOf = (output) => (getCachedAudioTracks?.({
@@ -363,6 +367,7 @@ export function wireOutputs({
363
367
  // re-encoded where it may be, and copied where it may not.
364
368
  get transcodeEnabled() { return parts.enabled; },
365
369
  observedPeakMbps: (spec) => parts.localObservations?.peakMbps(spec) ?? null,
370
+ serviceShare,
366
371
  // A viewer has been moved onto an output prepared for them: how long it
367
372
  // took, and what they held.
368
373
  notePreparation: (output, seconds, bufferedSec) =>
@@ -519,6 +524,7 @@ export function wireOutputs({
519
524
  // The largest peak an output of this mode and rate control has been seen
520
525
  // carrying here: the only figure an encoder with no bound of its own has.
521
526
  observedPeakMbps: (spec) => parts.localObservations?.peakMbps(spec) ?? null,
527
+ serviceShare,
522
528
  limitsFor: (frame, file) => parts.limitsFor(frame, file),
523
529
  readSourceMedia,
524
530
  get encodeInputs() { return parts.encodeInputs; },
@@ -1000,6 +1006,7 @@ export function wireOutputs({
1000
1006
  // otherwise.
1001
1007
  // 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.
1002
1008
  parts.quality = new QualityController({
1009
+ serviceShare,
1003
1010
  // The longest an output of this mode has been seen taking to be ready for
1004
1011
  // a viewer moving onto it, in milliseconds, or null.
1005
1012
  observedPreparationMs: (output) => {
@@ -47,6 +47,7 @@
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";
50
51
  import { Agent } from "undici";
51
52
 
52
53
  // Route availability and caller cancellation determine media request lifetime.
@@ -845,6 +846,7 @@ export function createDataChannelHandler({
845
846
  }
846
847
  try {
847
848
  channel.sendMessage(message);
849
+ deliveryShares.recordSent(Buffer.byteLength(message));
848
850
  sent += 1;
849
851
  } catch {
850
852
  // Closed between the lookup and the send. The subscription belongs to
@@ -862,6 +864,9 @@ export function createDataChannelHandler({
862
864
  /** Channel-scoped requests cannot be cancelled by a different connection. */
863
865
  const activeRequests = new WeakMap();
864
866
 
867
+ // How much of what the connections carry is not the film
868
+ // (`delivery-shares.js`), counted where both halves are seen.
869
+ const deliveryShares = new DeliveryShares();
865
870
  const { watchSendQueue, readDelivery, noteBrowserReport } = makeSendQueueWatcher({
866
871
  log: (message) => log(message),
867
872
  getTransportSnapshot,
@@ -887,7 +892,8 @@ export function createDataChannelHandler({
887
892
  readDelivery,
888
893
  getTransportSnapshot,
889
894
  witness,
890
- usrsctpState
895
+ usrsctpState,
896
+ onSent: (bytes) => deliveryShares.recordSent(bytes)
891
897
  });
892
898
 
893
899
  /**
@@ -1235,6 +1241,7 @@ export function createDataChannelHandler({
1235
1241
  if (done) {
1236
1242
  chunks += body.flush();
1237
1243
  sendChunk(channel, requestId, null, true);
1244
+ deliveryShares.recordBody(path, totalBytes);
1238
1245
  const elapsedMs = Date.now() - sendStartedAt;
1239
1246
  let bufferedNow = 0;
1240
1247
  try { bufferedNow = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0; } catch { /* ignore */ }
@@ -1319,7 +1326,9 @@ export function createDataChannelHandler({
1319
1326
 
1320
1327
  function sendChunk(channel, requestId, bytes, done) {
1321
1328
  try {
1322
- channel.sendMessageBinary(encodeFrame(requestIdBytes(requestId), bytes, done));
1329
+ const frame = encodeFrame(requestIdBytes(requestId), bytes, done);
1330
+ channel.sendMessageBinary(frame);
1331
+ deliveryShares.recordSent(frame.length);
1323
1332
  } catch {
1324
1333
  // Channel closed between check and send — safe to ignore.
1325
1334
  }
@@ -1392,13 +1401,20 @@ export function createDataChannelHandler({
1392
1401
  */
1393
1402
  function send(channel, message) {
1394
1403
  try {
1395
- channel.sendMessage(JSON.stringify(message));
1404
+ const text = JSON.stringify(message);
1405
+ channel.sendMessage(text);
1406
+ deliveryShares.recordSent(Buffer.byteLength(text));
1396
1407
  } catch {
1397
1408
  // Channel closed between check and send — safe to ignore.
1398
1409
  }
1399
1410
  }
1400
1411
 
1401
- return { handleChannel, publishSubtitleCues };
1412
+ return {
1413
+ handleChannel,
1414
+ publishSubtitleCues,
1415
+ /** What the film costs viewers' links beyond itself, per byte of film, or null. */
1416
+ serviceShare: () => deliveryShares.serviceShare()
1417
+ };
1402
1418
  }
1403
1419
 
1404
1420
  /**
@@ -425,7 +425,10 @@ export function createDeliveryProbe({
425
425
  readDelivery,
426
426
  getTransportSnapshot,
427
427
  witness,
428
- usrsctpState
428
+ usrsctpState,
429
+ // Every probe handed to a channel, in bytes: what it costs the viewer's link
430
+ // is counted with everything else the connection carries.
431
+ onSent = () => {}
429
432
  }) {
430
433
  /** @type {Map<string, ProbeConnection>} */
431
434
  const connections = new Map();
@@ -456,6 +459,7 @@ export function createDeliveryProbe({
456
459
  for (const channel of connection.channels.keys()) {
457
460
  try {
458
461
  channel.sendMessage(message);
462
+ onSent(Buffer.byteLength(message));
459
463
  } catch {
460
464
  // A channel closing between the check and the send is ordinary.
461
465
  }
@@ -0,0 +1,108 @@
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
+ }