@blockcast/mmt-transport 0.2.2 → 0.3.0

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.
Files changed (167) hide show
  1. package/README.md +44 -58
  2. package/dist/abr-controller.d.ts +34 -4
  3. package/dist/abr-controller.d.ts.map +1 -1
  4. package/dist/abr-controller.js +66 -17
  5. package/dist/abr-controller.js.map +1 -1
  6. package/dist/amt-gateway.d.ts.map +1 -1
  7. package/dist/amt-gateway.js +3 -4
  8. package/dist/amt-gateway.js.map +1 -1
  9. package/dist/driad-discovery.d.ts.map +1 -1
  10. package/dist/driad-discovery.js +3 -6
  11. package/dist/driad-discovery.js.map +1 -1
  12. package/dist/endpoint-config.d.ts +68 -0
  13. package/dist/endpoint-config.d.ts.map +1 -1
  14. package/dist/endpoint-config.js +118 -3
  15. package/dist/endpoint-config.js.map +1 -1
  16. package/dist/index.d.ts +10 -8
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +8 -11
  19. package/dist/index.js.map +1 -1
  20. package/dist/mmt-transport.no-modules.js +881 -60
  21. package/dist/mmt-transport.no-modules.js.map +4 -4
  22. package/dist/moqt-priority.d.ts +39 -0
  23. package/dist/moqt-priority.d.ts.map +1 -0
  24. package/dist/moqt-priority.js +41 -0
  25. package/dist/moqt-priority.js.map +1 -0
  26. package/dist/multicast-catalog-policy.d.ts +96 -0
  27. package/dist/multicast-catalog-policy.d.ts.map +1 -0
  28. package/dist/multicast-catalog-policy.js +660 -0
  29. package/dist/multicast-catalog-policy.js.map +1 -0
  30. package/dist/multicast-orchestrator.d.ts +24 -0
  31. package/dist/multicast-orchestrator.d.ts.map +1 -1
  32. package/dist/multicast-orchestrator.js +102 -9
  33. package/dist/multicast-orchestrator.js.map +1 -1
  34. package/dist/multicast-signaling-reconciler.d.ts +37 -0
  35. package/dist/multicast-signaling-reconciler.d.ts.map +1 -0
  36. package/dist/multicast-signaling-reconciler.js +161 -0
  37. package/dist/multicast-signaling-reconciler.js.map +1 -0
  38. package/dist/multicast-subscription-pool.d.ts.map +1 -1
  39. package/dist/multicast-subscription-pool.js +37 -10
  40. package/dist/multicast-subscription-pool.js.map +1 -1
  41. package/dist/multicast-tier.d.ts +19 -0
  42. package/dist/multicast-tier.d.ts.map +1 -1
  43. package/dist/multicast-tier.js +21 -4
  44. package/dist/multicast-tier.js.map +1 -1
  45. package/dist/no-modules.d.ts +40 -3
  46. package/dist/no-modules.d.ts.map +1 -1
  47. package/dist/no-modules.js +30 -3
  48. package/dist/no-modules.js.map +1 -1
  49. package/dist/packet-batch.d.ts +13 -0
  50. package/dist/packet-batch.d.ts.map +1 -0
  51. package/dist/packet-batch.js +14 -0
  52. package/dist/packet-batch.js.map +1 -0
  53. package/dist/stall-detector.d.ts +10 -0
  54. package/dist/stall-detector.d.ts.map +1 -1
  55. package/dist/stall-detector.js +21 -0
  56. package/dist/stall-detector.js.map +1 -1
  57. package/dist/takeover.d.ts +3 -0
  58. package/dist/takeover.d.ts.map +1 -1
  59. package/dist/takeover.js +35 -2
  60. package/dist/takeover.js.map +1 -1
  61. package/dist/transport-manager.d.ts +21 -17
  62. package/dist/transport-manager.d.ts.map +1 -1
  63. package/dist/transport-manager.js +63 -29
  64. package/dist/transport-manager.js.map +1 -1
  65. package/dist/types.d.ts +98 -123
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/types.js +12 -50
  68. package/dist/types.js.map +1 -1
  69. package/node_modules/@blockcast/mmt-alta-parse/README.md +181 -0
  70. package/node_modules/@blockcast/mmt-alta-parse/dist/.build-stamp +0 -0
  71. package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts +57 -0
  72. package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts.map +1 -0
  73. package/node_modules/@blockcast/mmt-alta-parse/dist/index.js +115 -0
  74. package/node_modules/@blockcast/mmt-alta-parse/dist/index.js.map +1 -0
  75. package/node_modules/@blockcast/mmt-alta-parse/package.json +50 -0
  76. package/node_modules/@blockcast/mmt-alta-parse/src/index.ts +136 -0
  77. package/node_modules/@blockcast/mmt-base/README.md +134 -0
  78. package/node_modules/@blockcast/mmt-base/dist/.build-stamp +0 -0
  79. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts +55 -0
  80. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts.map +1 -0
  81. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js +128 -0
  82. package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js.map +1 -0
  83. package/node_modules/@blockcast/mmt-base/dist/index.d.ts +7 -0
  84. package/node_modules/@blockcast/mmt-base/dist/index.d.ts.map +1 -0
  85. package/node_modules/@blockcast/mmt-base/dist/index.js +7 -0
  86. package/node_modules/@blockcast/mmt-base/dist/index.js.map +1 -0
  87. package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts +292 -0
  88. package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts.map +1 -0
  89. package/node_modules/@blockcast/mmt-base/dist/mmtp.js +612 -0
  90. package/node_modules/@blockcast/mmt-base/dist/mmtp.js.map +1 -0
  91. package/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts +18 -0
  92. package/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts.map +1 -0
  93. package/node_modules/@blockcast/mmt-base/dist/net-constants.js +18 -0
  94. package/node_modules/@blockcast/mmt-base/dist/net-constants.js.map +1 -0
  95. package/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts +47 -0
  96. package/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts.map +1 -0
  97. package/node_modules/@blockcast/mmt-base/dist/ntp-short.js +54 -0
  98. package/node_modules/@blockcast/mmt-base/dist/ntp-short.js.map +1 -0
  99. package/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts +57 -0
  100. package/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts.map +1 -0
  101. package/node_modules/@blockcast/mmt-base/dist/serial-number.js +98 -0
  102. package/node_modules/@blockcast/mmt-base/dist/serial-number.js.map +1 -0
  103. package/node_modules/@blockcast/mmt-base/dist/signalling.d.ts +312 -0
  104. package/node_modules/@blockcast/mmt-base/dist/signalling.d.ts.map +1 -0
  105. package/node_modules/@blockcast/mmt-base/dist/signalling.js +637 -0
  106. package/node_modules/@blockcast/mmt-base/dist/signalling.js.map +1 -0
  107. package/node_modules/@blockcast/mmt-base/package.json +49 -0
  108. package/node_modules/@blockcast/mmt-base/src/fec-block-profile.ts +145 -0
  109. package/node_modules/@blockcast/mmt-base/src/index.ts +6 -0
  110. package/node_modules/@blockcast/mmt-base/src/mmtp.ts +892 -0
  111. package/node_modules/@blockcast/mmt-base/src/net-constants.ts +17 -0
  112. package/node_modules/@blockcast/mmt-base/src/ntp-short.ts +67 -0
  113. package/node_modules/@blockcast/mmt-base/src/serial-number.ts +107 -0
  114. package/node_modules/@blockcast/mmt-base/src/signalling.ts +862 -0
  115. package/package.json +13 -6
  116. package/src/abr-controller.ts +81 -17
  117. package/src/amt-gateway.ts +3 -5
  118. package/src/driad-discovery.ts +3 -6
  119. package/src/endpoint-config.ts +157 -3
  120. package/src/index.ts +54 -33
  121. package/src/moqt-priority.ts +51 -0
  122. package/src/multicast-catalog-policy.ts +753 -0
  123. package/src/multicast-orchestrator.ts +114 -10
  124. package/src/multicast-signaling-reconciler.ts +156 -0
  125. package/src/multicast-subscription-pool.ts +46 -11
  126. package/src/multicast-tier.ts +30 -3
  127. package/src/no-modules.ts +54 -2
  128. package/src/packet-batch.ts +17 -0
  129. package/src/stall-detector.ts +37 -0
  130. package/src/takeover.ts +41 -3
  131. package/src/transport-manager.ts +64 -34
  132. package/src/types.ts +103 -160
  133. package/dist/endpoint-config.test.d.ts +0 -2
  134. package/dist/endpoint-config.test.d.ts.map +0 -1
  135. package/dist/endpoint-config.test.js +0 -39
  136. package/dist/endpoint-config.test.js.map +0 -1
  137. package/dist/fec-client.d.ts +0 -442
  138. package/dist/fec-client.d.ts.map +0 -1
  139. package/dist/fec-client.js +0 -789
  140. package/dist/fec-client.js.map +0 -1
  141. package/dist/fec-client.test.d.ts +0 -8
  142. package/dist/fec-client.test.d.ts.map +0 -1
  143. package/dist/fec-client.test.js +0 -112
  144. package/dist/fec-client.test.js.map +0 -1
  145. package/dist/multicast-subscription-pool.test.d.ts +0 -2
  146. package/dist/multicast-subscription-pool.test.d.ts.map +0 -1
  147. package/dist/multicast-subscription-pool.test.js +0 -108
  148. package/dist/multicast-subscription-pool.test.js.map +0 -1
  149. package/dist/multicast-tier.test.d.ts +0 -2
  150. package/dist/multicast-tier.test.d.ts.map +0 -1
  151. package/dist/multicast-tier.test.js +0 -75
  152. package/dist/multicast-tier.test.js.map +0 -1
  153. package/dist/stall-detector.test.d.ts +0 -2
  154. package/dist/stall-detector.test.d.ts.map +0 -1
  155. package/dist/stall-detector.test.js +0 -33
  156. package/dist/stall-detector.test.js.map +0 -1
  157. package/dist/transport-manager.test.d.ts +0 -2
  158. package/dist/transport-manager.test.d.ts.map +0 -1
  159. package/dist/transport-manager.test.js +0 -155
  160. package/dist/transport-manager.test.js.map +0 -1
  161. package/src/endpoint-config.test.ts +0 -54
  162. package/src/fec-client.test.ts +0 -140
  163. package/src/fec-client.ts +0 -1103
  164. package/src/multicast-subscription-pool.test.ts +0 -115
  165. package/src/multicast-tier.test.ts +0 -80
  166. package/src/stall-detector.test.ts +0 -34
  167. package/src/transport-manager.test.ts +0 -192
@@ -0,0 +1,660 @@
1
+ import { normalizeCatalogEndpoints, } from "./multicast-signaling-reconciler.js";
2
+ import { assertNoRetiredMulticastUrlParams, DEFAULT_AMT_RELAY_PORT, isMulticastTrackPacketId, isUsableMulticastEndpoint, isValidMulticastGroupAddress, } from "./endpoint-config.js";
3
+ export function hasMulticastCatalogBaseline(catalog) {
4
+ const multicast = catalog?.multicast;
5
+ return normalizeCatalogEndpoints(catalog).length > 0 || Boolean(multicast?.groupAddress && multicast?.port);
6
+ }
7
+ export function hasUnicastMediaRenditions(catalog) {
8
+ return Object.keys(catalog?.video?.renditions ?? {}).length > 0 ||
9
+ Object.keys(catalog?.audio?.renditions ?? {}).length > 0;
10
+ }
11
+ export function isMulticastOnlyCatalog(catalog) {
12
+ return hasMulticastCatalogBaseline(catalog) && !hasUnicastMediaRenditions(catalog);
13
+ }
14
+ export function requiresMulticastCapability(catalog, capabilities = {}) {
15
+ return isMulticastOnlyCatalog(catalog) && !capabilities.multicast;
16
+ }
17
+ export function isRecoverableMulticastSignalReadError(error) {
18
+ const candidate = error;
19
+ const name = String(candidate?.name ?? "");
20
+ const message = String(candidate?.message ?? error ?? "");
21
+ return name === "SyntaxError" ||
22
+ /not valid JSON|unexpected token|JSON\.parse|encoded data was not valid|malformed/i.test(message);
23
+ }
24
+ export function withMulticastEndpoints(catalog, endpoints, version) {
25
+ if (!catalog?.multicast)
26
+ return catalog;
27
+ return {
28
+ ...catalog,
29
+ multicast: {
30
+ ...catalog.multicast,
31
+ version,
32
+ endpoints: endpoints.map(copyEndpoint),
33
+ },
34
+ };
35
+ }
36
+ export function selectMulticastCandidate(catalog, endpoints = normalizeCatalogEndpoints(catalog), search = currentSearchParams()) {
37
+ const params = search instanceof URLSearchParams ? search : new URLSearchParams(search);
38
+ return selectMulticastCandidateForTracks(catalog, endpoints, {
39
+ containerKind: params.get("packaging") || params.get("container") || "mmtp",
40
+ rendition: params.get("rendition") || undefined,
41
+ });
42
+ }
43
+ export function selectMulticastCandidateForTracks(catalog, endpoints = normalizeCatalogEndpoints(catalog), selection = {}) {
44
+ const packaging = selection.containerKind || "mmtp";
45
+ const renditionTrack = selection.rendition || "";
46
+ const preferredTrack = packaging === "mmtp" ? "video" : `video.${packaging}`;
47
+ const usableEndpoints = endpoints.filter(isUsableMulticastEndpoint);
48
+ const defaultRenditionTrack = renditionTrack ? "" : lowestServedVideoRendition(catalog, usableEndpoints);
49
+ return (renditionTrack && usableEndpoints.find(endpoint => endpoint.tracks?.some(track => matchesPreferredTrack(track.name, renditionTrack)))) ||
50
+ (defaultRenditionTrack && usableEndpoints.find(endpoint => endpoint.tracks?.some(track => matchesPreferredTrack(track.name, defaultRenditionTrack)))) ||
51
+ usableEndpoints.find(endpoint => endpoint.tracks?.some(track => matchesPreferredTrack(track.name, preferredTrack))) ||
52
+ usableEndpoints[0] || null;
53
+ }
54
+ export function endpointToMulticastConfig(endpoint, catalog, selection) {
55
+ if (!endpoint || !isUsableMulticastEndpoint(endpoint) || advertisesInvalidPacketId(endpoint))
56
+ return null;
57
+ const config = {
58
+ group: endpoint.groupAddress,
59
+ source: endpoint.sourceAddress || "",
60
+ port: Number(endpoint.port),
61
+ };
62
+ const networkSource = endpoint.networkSource ?? catalog?.multicast?.networkSource;
63
+ const amt = findAmtRelay(networkSource);
64
+ if (amt) {
65
+ config.mode = "tunnel";
66
+ config.amtRelay = amt.relay;
67
+ config.amtRelayPort = amt.port;
68
+ }
69
+ else if (endpoint.protocol === "amt") {
70
+ // AMT is wanted and no relay resolved. `mode: "tunnel"` would be
71
+ // unsatisfiable: `toWindowMulticastSubscribeConfig` refuses tunnel with
72
+ // no relay by design, and `MulticastTransport.join` swallows that throw
73
+ // into a plain `false` indistinguishable from a gateway failure
74
+ // (BLO-38184). Carry the AMT entries instead, as the relay-pending
75
+ // state: a caller that can resolve DRIAD has the DNS name to resolve,
76
+ // one that cannot joins native and can still see AMT was asked for. The
77
+ // entries are carried, not a flag, because a DRIAD entry's own `relay`
78
+ // is that DNS name and a boolean would discard it.
79
+ //
80
+ // The carry is deliberately scoped to `protocol === "amt"` while the
81
+ // dialable branch above is not: a resolved relay is dialable whatever
82
+ // the endpoint claims, but a *pending* one is a statement of intent,
83
+ // and the endpoint's own protocol is where that intent is declared.
84
+ // Widening this to match the branch above would have the reader assert
85
+ // AMT intent for an endpoint that never claimed it.
86
+ //
87
+ // Only `type: "amt"` entries ride along, and only if there are any:
88
+ // `findAmtRelay` also returns undefined for the tolerant bare form
89
+ // (`{type: "amt"}`, no relay) and for a `networkSource` carrying no AMT
90
+ // entry at all, so an unfiltered carry would populate an `amt*` field
91
+ // with a payload holding zero AMT entries.
92
+ const amtEntries = amtNetworkSourceEntries(networkSource);
93
+ if (amtEntries.length > 0)
94
+ config.amtNetworkSource = amtEntries;
95
+ }
96
+ const packetIds = selection === undefined ?
97
+ selectEndpointPacketIds(endpoint, catalog) :
98
+ selectEndpointPacketIdsForTracks(endpoint, catalog, selection);
99
+ if (packetIds === null)
100
+ return null;
101
+ if (packetIds.length > 0)
102
+ config.packetIds = packetIds;
103
+ return config;
104
+ }
105
+ export function selectEndpointPacketIds(endpoint, catalog, search = currentSearchParams()) {
106
+ const params = search instanceof URLSearchParams ? search : new URLSearchParams(search);
107
+ return selectEndpointPacketIdsForTracks(endpoint, catalog, {
108
+ containerKind: params.get("packaging") || params.get("container") || "mmtp",
109
+ rendition: params.get("rendition") || undefined,
110
+ });
111
+ }
112
+ export function selectEndpointPacketIdsForTracks(endpoint, catalog, selection = {}) {
113
+ if (!endpoint || !Array.isArray(endpoint.tracks))
114
+ return [];
115
+ // An empty result means "no packet_id filter" to the join, so an invalid
116
+ // packet_id must fail here rather than be dropped: dropping the only track
117
+ // would widen the filter to every packet_id, the signaling flow included.
118
+ // null ("this endpoint is unusable") matches endpointToMulticastConfig on
119
+ // the same input, and the widened return type is the signal to callers.
120
+ if (advertisesInvalidPacketId(endpoint))
121
+ return null;
122
+ const tracks = endpoint.tracks
123
+ .map((track) => ({
124
+ name: typeof track?.name === "string" ? track.name : "",
125
+ packetId: validPacketId(track?.packetId),
126
+ }))
127
+ .filter((track) => track.name.length > 0 && track.packetId !== undefined);
128
+ if (tracks.length === 0)
129
+ return [];
130
+ const packaging = selection.containerKind || "mmtp";
131
+ const renditionTrack = selection.rendition || "";
132
+ const videoRenditions = catalogRenditions(catalog, "video");
133
+ const audioRenditions = catalogRenditions(catalog, "audio");
134
+ const videoTracks = tracks.filter(track => Object.hasOwn(videoRenditions, track.name));
135
+ const audioTracks = tracks.filter(track => Object.hasOwn(audioRenditions, track.name));
136
+ const preferredVideo = packaging === "mmtp" ? "video" : `video.${packaging}`;
137
+ const preferredAudio = packaging === "mmtp" ? "audio" : `audio.${packaging}`;
138
+ const video = (renditionTrack ? videoTracks.find(track => track.name === renditionTrack) : undefined) ??
139
+ videoTracks.find(track => track.name === preferredVideo) ??
140
+ [...videoTracks].sort((a, b) => videoRenditionRank(videoRenditions, a.name) - videoRenditionRank(videoRenditions, b.name))[0];
141
+ const audio = audioTracks.find(track => track.name === preferredAudio) ?? audioTracks[0];
142
+ const selected = [video, audio].filter((track) => track !== undefined);
143
+ if (selected.length === 0)
144
+ return normalizePacketIds(tracks.map(track => track.packetId));
145
+ // Every repair layer and overlay of a selected track is a repair track of
146
+ // that track (draft-ramadan-moq-fec §6.3), so a layer listed on the endpoint
147
+ // is selected alongside layer 0 rather than treated as unknown.
148
+ const selectedRepairNames = new Set(selected.flatMap(track => catalogRepairTracks(catalog, track.name)));
149
+ const selectedRepairTracks = tracks.filter(track => selectedRepairNames.has(track.name));
150
+ return normalizePacketIds([...selected, ...selectedRepairTracks].map(track => track.packetId));
151
+ }
152
+ /** Read the player's explicit multicast override from `mc_*` URL parameters.
153
+ * Throws when an override carries a retired unprefixed alias
154
+ * (`assertNoRetiredMulticastUrlParams`). */
155
+ export function readPlayerMulticastConfig(search = currentSearchParams()) {
156
+ const params = search instanceof URLSearchParams ? search : new URLSearchParams(search || "");
157
+ const group = params.get("mc_group") || "";
158
+ if (!isValidMulticastGroupAddress(group))
159
+ return null;
160
+ const port = positivePort(params.get("mc_port"));
161
+ if (!port)
162
+ return null;
163
+ assertNoRetiredMulticastUrlParams(params);
164
+ const mode = params.get("mc_mode") || "";
165
+ const amtRelay = params.get("mc_amt_relay") || "";
166
+ const amtRelayPort = positivePort(params.get("mc_amt_relay_port"));
167
+ const config = {
168
+ group,
169
+ source: params.get("mc_source") || "",
170
+ port,
171
+ };
172
+ if (mode)
173
+ config.mode = mode;
174
+ if (amtRelay) {
175
+ config.amtRelay = amtRelay;
176
+ // Trimmed for the DECISION only, matching `dialableRelay` in
177
+ // `normalizeMulticastConfig`. `?mc_amt_relay=%20%20` is not a relay, and
178
+ // stamping `tunnel` here outlives that trim: `normalizeMulticastConfig`
179
+ // only FORCES `tunnel` when the relay is dialable, and otherwise
180
+ // PRESERVES whatever `mode` arrived — so a blank override set here turns
181
+ // a DRIAD catalog's native join into a boundary throw (BLO-38184).
182
+ if (amtRelay.trim())
183
+ config.mode = "tunnel";
184
+ }
185
+ if (amtRelayPort)
186
+ config.amtRelayPort = amtRelayPort;
187
+ return config;
188
+ }
189
+ export function normalizeMulticastConfig(config) {
190
+ if (!config?.group)
191
+ return null;
192
+ // A join list naming an invalid packet_id is refused whole: dropping the
193
+ // entry could leave the list empty, which the join reads as "no filter".
194
+ if (Array.isArray(config.packetIds) && !config.packetIds.every(isMulticastTrackPacketId))
195
+ return null;
196
+ const amt = findAmtRelay(config.amtNetworkSource);
197
+ const amtRelay = config.amtRelay || amt?.relay || "";
198
+ // Blank is no relay for every decision below, matching the other two readers
199
+ // and the `input.amtRelay?.trim()` the consumer re-derives. `amtRelay` is
200
+ // kept verbatim in the output — only what we DECIDE from it is trimmed.
201
+ // `amtRelay` already coalesces to `""`, so this needs no `?.`.
202
+ const dialableRelay = amtRelay.trim();
203
+ // Same RFC 7450 default as `findAmtRelay`, applied to a relay from ANY source
204
+ // (URL override, player config) rather than only the catalog-derived one.
205
+ // Two spellings of one port-less relay must normalize alike or
206
+ // `sameMulticastConfig` reports a change that is not there, and a caller
207
+ // naming `amtRelay` alone would still reach the join with no port. An
208
+ // unusable explicit port (0, NaN) falls through to the same default.
209
+ // Only the DEFAULT is gated on the relay. An explicit `amtRelayPort` is
210
+ // caller data and passes through whatever the relay looks like, since
211
+ // dropping it would be the worse behaviour — the same rule
212
+ // `MulticastManager.join` states. Gating it here too would collapse two
213
+ // blank-relay configs naming different ports to one `undefined`, hiding a
214
+ // change `sameMulticastConfig` used to report. The catalog's `amt.port` is
215
+ // the opposite case and is dropped deliberately: a whitespace-only
216
+ // `amtRelay` override still wins the `||` in `amtRelay` above, shadowing the
217
+ // catalog relay, and that relay's port goes with it rather than shipping a
218
+ // port for a relay nobody will dial.
219
+ const amtRelayPort = positivePort(String(config.amtRelayPort ?? "")) ??
220
+ (dialableRelay ? (amt?.port ?? DEFAULT_AMT_RELAY_PORT) : undefined);
221
+ const packetIds = normalizePacketIds(config.packetIds);
222
+ // The same `type: "amt"` filter the catalog reader applies. This is the
223
+ // other public entry point onto the field (a player may hand-build a config
224
+ // rather than derive one from a catalog), and `amtNetworkSource` is typed
225
+ // `unknown`, so without it a caller could still land a zero-AMT payload
226
+ // under an `amt*` name — the shape the reader refuses one function up.
227
+ // Gated on `dialableRelay`, not bare truthiness: a whitespace-only override
228
+ // is not a relay, so it must not erase the pending entries one hop before
229
+ // the join (BLO-38184). Bare truthiness dropped them AND forced
230
+ // `mode: "tunnel"` below, and the pair throws in `endpoint-config.ts` —
231
+ // turning "joins native" back into "never".
232
+ const pendingAmt = dialableRelay ? [] : amtNetworkSourceEntries(config.amtNetworkSource);
233
+ return {
234
+ group: config.group,
235
+ source: config.source || "",
236
+ port: Number(config.port),
237
+ mode: dialableRelay ? "tunnel" : (config.mode || "auto"),
238
+ amtRelay,
239
+ ...(amtRelayPort ? { amtRelayPort } : {}),
240
+ // Relay-pending DRIAD survives normalization while it is still pending:
241
+ // dropping it here would erase, one hop before the join, the only thing
242
+ // telling the call site "AMT was asked for and is unresolved" apart from
243
+ // "this endpoint was always native" (BLO-38184). Once a relay resolves
244
+ // the entries have done their job and are not carried. `amtNetworkSource`
245
+ // is deliberately not part of `sameMulticastConfig`: it is provenance for
246
+ // the relay, not endpoint identity, so it must not report a change.
247
+ //
248
+ // That holds only while no caller acts on the field — today a pending
249
+ // DRIAD entry and a native endpoint both join `ip-multicast`, so
250
+ // comparing equal reports the truth. Once a caller resolves DRIAD and
251
+ // tunnels, a catalog swapping native for DRIAD-AMT becomes a real
252
+ // join-behaviour change that would compare equal and never re-join:
253
+ // whoever wires that resolution adds `amtNetworkSource` to
254
+ // `sameMulticastConfig` in the same change.
255
+ ...(pendingAmt.length > 0 ? { amtNetworkSource: pendingAmt } : {}),
256
+ ...(packetIds.length > 0 ? { packetIds } : {}),
257
+ };
258
+ }
259
+ export function sameMulticastConfig(a, b) {
260
+ const left = normalizeMulticastConfig(a);
261
+ const right = normalizeMulticastConfig(b);
262
+ return Boolean(left && right &&
263
+ left.group === right.group &&
264
+ left.source === right.source &&
265
+ left.port === right.port &&
266
+ left.mode === right.mode &&
267
+ left.amtRelay === right.amtRelay &&
268
+ (left.amtRelayPort ?? 0) === (right.amtRelayPort ?? 0) &&
269
+ JSON.stringify(left.packetIds ?? []) === JSON.stringify(right.packetIds ?? []));
270
+ }
271
+ /** An omitted `urlConfig` is read from `location.search` by
272
+ * `readPlayerMulticastConfig`, which throws on a retired unprefixed alias
273
+ * inside an override. */
274
+ export function urlOverrideBlocksMulticastCandidate(candidateConfig, urlConfig = readPlayerMulticastConfig()) {
275
+ if (!urlConfig)
276
+ return false;
277
+ const candidate = normalizeMulticastConfig(candidateConfig);
278
+ const explicit = normalizeMulticastConfig(urlConfig);
279
+ return !candidate || !explicit ||
280
+ candidate.group !== explicit.group ||
281
+ candidate.source !== explicit.source ||
282
+ candidate.port !== explicit.port;
283
+ }
284
+ /** Preserve catalog track selection while applying explicit URL transport policy.
285
+ * An omitted `urlConfig` is read from `location.search` by
286
+ * `readPlayerMulticastConfig`, which throws on a retired unprefixed alias
287
+ * inside an override. */
288
+ export function mergeUrlMulticastPolicy(candidateConfig, urlConfig = readPlayerMulticastConfig()) {
289
+ const candidate = normalizeMulticastConfig(candidateConfig);
290
+ if (!candidate || !urlConfig || urlOverrideBlocksMulticastCandidate(candidate, urlConfig))
291
+ return candidate;
292
+ const merged = { ...candidate };
293
+ if (urlConfig.mode !== undefined) {
294
+ merged.mode = urlConfig.mode;
295
+ if (urlConfig.mode !== "tunnel" && urlConfig.amtRelay === undefined) {
296
+ merged.amtRelay = "";
297
+ delete merged.amtRelayPort;
298
+ // Relay-pending DRIAD is an AMT signal like the two above, so an
299
+ // explicit non-tunnel mode clears it too. Keeping it would leave a
300
+ // caller that resolves DRIAD holding the DNS name it needs to
301
+ // tunnel, against a user who just asked for native — the field
302
+ // saying "AMT was asked for" outliving the instruction overriding
303
+ // it. Inert today for the same reason `sameMulticastConfig` can
304
+ // omit the field; whoever wires DRIAD resolution makes it load-
305
+ // bearing here and there at the same moment.
306
+ //
307
+ // The boundary is `!== "tunnel"`, not `=== "native"`, so `auto`
308
+ // rides along with native and clears — while an ABSENT `mc_mode`
309
+ // keeps the entries, though both normalize to `mode: "auto"` and
310
+ // are indistinguishable downstream. That is the shared boundary
311
+ // the two lines above already use, not a choice made here;
312
+ // `readPlayerMulticastConfig` applies no allowlist to `mc_mode`,
313
+ // so any unrecognized value clears too. Whether an explicit `auto`
314
+ // should override AMT intent is one question for all three lines,
315
+ // and it is the DRIAD-resolution follow-on's to answer.
316
+ delete merged.amtNetworkSource;
317
+ }
318
+ }
319
+ if (urlConfig.amtRelay !== undefined) {
320
+ merged.amtRelay = urlConfig.amtRelay;
321
+ // Whatever supplied `merged.amtRelayPort`, it was declared for the
322
+ // BASE-CONFIG relay, so it is shadowed along with that relay by ANY
323
+ // override — blank or not (BLO-38514). By here the first normalize has
324
+ // already made the three sources indistinguishable (a caller's explicit
325
+ // `candidateConfig.amtRelayPort`, the catalog's `amt.port`, and
326
+ // `DEFAULT_AMT_RELAY_PORT`), and all three drop. That is not in tension
327
+ // with `:274-283`, which preserves a caller's port against the relay it
328
+ // was named with; the rule here is different authorship, not provenance.
329
+ // Ungated on purpose: the blank branch below once owned this line on the
330
+ // reasoning "a port for a relay nobody will dial", and a non-blank
331
+ // override is the same shadowing with a worse outcome — it JOINS,
332
+ // dialing a host the user named on a port only the replaced host ever
333
+ // declared. A host swap therefore takes `DEFAULT_AMT_RELAY_PORT`
334
+ // (RFC 7450) from `normalizeMulticastConfig`, not the shadowed host's
335
+ // port. An explicit `?mc_amt_relay_port=` still wins, below.
336
+ delete merged.amtRelayPort;
337
+ // Same trim, same reason as `readPlayerMulticastConfig`. Fixing only one
338
+ // writer is not enough: `:363` copies `urlConfig.mode` across, and that
339
+ // mode is already `"tunnel"` from the other writer.
340
+ if (urlConfig.amtRelay.trim()) {
341
+ merged.mode = "tunnel";
342
+ // The delete above closes only ONE of the two port sources.
343
+ // `normalizeMulticastConfig` re-derives the port as
344
+ // `positivePort(amtRelayPort) ?? (dialableRelay ? (amt?.port ??
345
+ // DEFAULT) : undefined)`, and `amt` comes from `amtNetworkSource` —
346
+ // so a surviving entry naming a port puts the shadowed port straight
347
+ // back on the new host, which is BLO-38514 verbatim. The entries
348
+ // describe the relay this override just replaced; they are its
349
+ // provenance, and they are shadowed with it.
350
+ //
351
+ // Nothing is lost by dropping them here: `:297` already discards
352
+ // `amtNetworkSource` from the OUTPUT whenever the relay is dialable,
353
+ // which a non-blank override always is. This only stops them acting
354
+ // as a port source on the way through. Catalog-derived candidates
355
+ // are safe today for that same reason — `endpointToMulticastConfig`
356
+ // trims `amt.relay`, so the first normalize drops the entries before
357
+ // merge sees them — but that is an accident of the catalog path, not
358
+ // the invariant, and the DRIAD follow-on at `:305-320` retires it.
359
+ //
360
+ // Gated on `.trim()`: a blank override is not a relay, must not erase
361
+ // the pending entries one hop before the join (BLO-38184), and keeps
362
+ // them as the relay-pending residual the same follow-on resolves.
363
+ delete merged.amtNetworkSource;
364
+ }
365
+ else if (urlConfig.mode === undefined && candidate.amtRelay?.trim()) {
366
+ // The blank override shadows a DIALABLE catalog relay, and
367
+ // `candidate.mode` is the `"tunnel"` `normalizeMulticastConfig`
368
+ // DERIVED from that very relay — normalization output fed back in
369
+ // as input. The second normalize only ever FORCES `tunnel`, never
370
+ // clears it, so the derived mode outlives the relay it came from
371
+ // and the pair throws at the boundary (BLO-38415). Drop it with the
372
+ // relay. The catalog's port went with it one branch up, now for
373
+ // every override rather than only this one.
374
+ //
375
+ // Gated on an absent `urlConfig.mode`, because a user-asserted
376
+ // `?mc_mode=tunnel` is not derived from this relay and is not the
377
+ // caller's to discard — that stays the deliberate
378
+ // tunnel-with-no-resolved-relay refusal, alongside the relay-pending
379
+ // DRIAD residual. Gated on the CANDIDATE's relay being dialable,
380
+ // because only then is its `tunnel` derived; a catalog that named
381
+ // its own non-tunnel mode keeps it.
382
+ merged.mode = "auto";
383
+ }
384
+ }
385
+ if (urlConfig.amtRelayPort !== undefined)
386
+ merged.amtRelayPort = urlConfig.amtRelayPort;
387
+ return normalizeMulticastConfig(merged);
388
+ }
389
+ function currentSearchParams() {
390
+ return typeof location === "undefined" ?
391
+ new URLSearchParams() :
392
+ new URLSearchParams(location.search);
393
+ }
394
+ function positivePort(value) {
395
+ const port = Number(value);
396
+ return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
397
+ }
398
+ function copyEndpoint(endpoint) {
399
+ return { ...endpoint, tracks: endpoint.tracks.map(track => ({ ...track })) };
400
+ }
401
+ /** The `type: "amt"` entries of a `networkSource` (array or bare-object form),
402
+ * in catalog order. Every reader of the field spells "which entries are AMT"
403
+ * the same way here, so none of them can populate an `amt*` field with a
404
+ * payload holding zero AMT entries — the two readers of one field must agree
405
+ * (`catalogAmtNetworkSource` in `@blockcast/mmt-container` applies the same
406
+ * rule). Entries are unfiltered by dialability: `findAmtRelay` decides which
407
+ * of them is *dialable*, a narrower question than which of them is AMT.
408
+ *
409
+ * Each entry is shallow-copied, for the same reason `copyEndpoint` copies:
410
+ * these ride out in a returned config, and the catalog is not ours to hand
411
+ * out references to. A DRIAD resolver writing its resolved relay back into
412
+ * the entry it was handed would otherwise mutate the catalog in place — and
413
+ * `sameMulticastConfig` deliberately omits this field, so that mutation
414
+ * would be invisible to change detection. Primitives only, so one level is
415
+ * the whole object. */
416
+ function amtNetworkSourceEntries(networkSource) {
417
+ const sources = Array.isArray(networkSource) ? networkSource : networkSource ? [networkSource] : [];
418
+ return sources.filter((entry) => entry?.type === "amt").map((entry) => ({ ...entry }));
419
+ }
420
+ // A `discovery: "driad"` entry's relay is a discovery hint, not a pinned
421
+ // relay. The entry says "resolve this at runtime", so it must resolve exactly
422
+ // as a relay-less DRIAD entry does: `amtRelay` unset, the relay left to
423
+ // runtime discovery (`TransportManager.connectAmt`). The emitter produces that
424
+ // shape (`amtFallback: {discovery: "driad", relayDns}` derives
425
+ // `{type: "amt", discovery: "driad", relay}`), and before BLO-37834 this
426
+ // reader never read `amtFallback` at all, so pinning that relay would turn a
427
+ // catalog that asked for DRIAD into a manual join. An entry with no
428
+ // `discovery` (the tolerant bare form) keeps its relay.
429
+ function findAmtRelay(networkSource) {
430
+ const source = amtNetworkSourceEntries(networkSource).find(candidate => candidate?.discovery !== "driad" &&
431
+ typeof candidate?.relay === "string" && candidate.relay.trim());
432
+ if (!source)
433
+ return undefined;
434
+ // A relay that names no port — or an unusable one — is reachable on the AMT
435
+ // well-known port (RFC 7450 §4). Resolving it here rather than at the
436
+ // `toWindowMulticastSubscribeConfig` boundary keeps that boundary's
437
+ // "no implicit relay-port default" rule: the port is explicit by the time a
438
+ // join is built. Without this every catalog carrying a relay and no port —
439
+ // which is every catalog migrated from `amtFallback.relayDns` or an endpoint
440
+ // `relayAddress`, the derived path emitting no port at all — reached that
441
+ // boundary portless and the join threw (BLO-38176).
442
+ const port = positivePort(String(source.port ?? ""));
443
+ return { relay: source.relay.trim(), port: port ?? DEFAULT_AMT_RELAY_PORT };
444
+ }
445
+ function validPacketId(value) {
446
+ return isMulticastTrackPacketId(value) ? value : undefined;
447
+ }
448
+ /** True when some endpoint track advertises a `packetId` that is present but
449
+ * not a valid track packet_id. A track that omits `packetId` (the legacy
450
+ * string form) advertises none and is not invalid. */
451
+ function advertisesInvalidPacketId(endpoint) {
452
+ return Array.isArray(endpoint.tracks) && endpoint.tracks.some((track) => track?.packetId !== undefined && !isMulticastTrackPacketId(track.packetId));
453
+ }
454
+ function normalizePacketIds(packetIds) {
455
+ if (!Array.isArray(packetIds))
456
+ return [];
457
+ return [...new Set(packetIds.map(validPacketId).filter((id) => id !== undefined))].sort((a, b) => a - b);
458
+ }
459
+ function matchesPreferredTrack(trackName, preferredTrack) {
460
+ return trackName === preferredTrack || trackName.startsWith(`${preferredTrack}/`);
461
+ }
462
+ function catalogRenditions(catalog, role) {
463
+ const renditions = { ...(catalog?.[role]?.renditions ?? {}) };
464
+ for (const track of Array.isArray(catalog?.tracks) ? catalog.tracks : []) {
465
+ if (track?.role === role && typeof track.name === "string" && track.name.length > 0) {
466
+ renditions[track.name] = track;
467
+ }
468
+ }
469
+ return renditions;
470
+ }
471
+ /** Reject catalog shapes whose repair classification this package cannot
472
+ * derive. `commonTrackFields` (root) and `selectionParams` (per track) are
473
+ * retired: every track now declares its own `packaging`. A legacy catalog that
474
+ * marks its repair tracks only through `commonTrackFields.packaging` would
475
+ * otherwise yield an empty repair set here, and every endpoint (repair-only
476
+ * ones included) would classify as media. The container-side validator
477
+ * (`validateCmsfMmtpCatalog`) rejects these keys too, but transport has no
478
+ * dependency edge to it, so this layer must fail closed on its own. */
479
+ function assertNoRetiredTrackFields(catalog) {
480
+ if (catalog && typeof catalog === "object" && "commonTrackFields" in catalog) {
481
+ throw new Error("multicast catalog policy: $.commonTrackFields is retired; every track must declare its own packaging");
482
+ }
483
+ const tracks = Array.isArray(catalog?.tracks) ? catalog.tracks : [];
484
+ tracks.forEach((track, index) => {
485
+ if (track && typeof track === "object" && "selectionParams" in track) {
486
+ throw new Error(`multicast catalog policy: $.tracks[${index}].selectionParams is retired; selection fields are flat on the track`);
487
+ }
488
+ });
489
+ }
490
+ /** Repair tracks a source track (or rendition) depends on for its FEC: layer 0
491
+ * named by `fec.repairTrack`, then every self-describing repair track that
492
+ * `depends` on it (MSF `tracks[]` entries with `packaging: "fec-repair"`, or
493
+ * hang `repair.renditions` entries), per draft-ramadan-moq-fec §6.3. Names
494
+ * carry no meaning: membership comes from the pointer and from `depends`,
495
+ * never from a name suffix. Empty when the track carries no FEC, including
496
+ * `fec.algorithm: "none"`, which the container-side derivations
497
+ * (`carp-catalog` and `catalog-fec-params`) also read as no FEC. */
498
+ function catalogRepairTracks(catalog, name) {
499
+ assertNoRetiredTrackFields(catalog);
500
+ const tracks = Array.isArray(catalog?.tracks) ? catalog.tracks : [];
501
+ const track = tracks.find((candidate) => candidate?.name === name);
502
+ const rendition = catalog?.video?.renditions?.[name] ?? catalog?.audio?.renditions?.[name];
503
+ const fec = track?.fec ?? rendition?.fec;
504
+ if (!fec || fec.algorithm === "none")
505
+ return [];
506
+ const names = new Set();
507
+ if (typeof fec.repairTrack === "string" && fec.repairTrack.length > 0)
508
+ names.add(fec.repairTrack);
509
+ const dependsOn = (entry) => Array.isArray(entry?.depends) && entry.depends.length === 1 && entry.depends[0] === name;
510
+ for (const candidate of tracks) {
511
+ const packaging = candidate?.packaging;
512
+ if (packaging === "fec-repair" && typeof candidate?.name === "string" && candidate.name.length > 0 && dependsOn(candidate)) {
513
+ names.add(candidate.name);
514
+ }
515
+ }
516
+ const repairRenditions = catalog?.repair?.renditions;
517
+ if (repairRenditions && typeof repairRenditions === "object") {
518
+ for (const [repairName, entry] of Object.entries(repairRenditions)) {
519
+ if (repairName.length > 0 && dependsOn(entry))
520
+ names.add(repairName);
521
+ }
522
+ }
523
+ return [...names];
524
+ }
525
+ /** Every self-describing repair track name in the catalog, in either dialect:
526
+ * MSF `tracks[]` entries with `packaging: "fec-repair"` and hang
527
+ * `repair.renditions` keys. A repair track describes itself, so membership
528
+ * needs no source resolution and never comes from a name suffix. */
529
+ function catalogRepairTrackNames(catalog) {
530
+ assertNoRetiredTrackFields(catalog);
531
+ const names = new Set();
532
+ for (const track of Array.isArray(catalog?.tracks) ? catalog.tracks : []) {
533
+ const packaging = track?.packaging;
534
+ if (packaging === "fec-repair" && typeof track?.name === "string" && track.name.length > 0) {
535
+ names.add(track.name);
536
+ }
537
+ }
538
+ const repairRenditions = catalog?.repair?.renditions;
539
+ if (repairRenditions && typeof repairRenditions === "object") {
540
+ for (const name of Object.keys(repairRenditions)) {
541
+ if (name.length > 0)
542
+ names.add(name);
543
+ }
544
+ }
545
+ return names;
546
+ }
547
+ /** True when an endpoint carries at least one track that is not a repair
548
+ * track, i.e. it can serve media on its own. A repair-layer endpoint carries
549
+ * only `fec-repair` tracks and is joined by the loss-driven layer policy, so
550
+ * it must be excluded from the bandwidth ladder (`selectMulticastTier`).
551
+ * An endpoint listing no tracks is treated as carrying media: that is the
552
+ * pre-layer signalling shape, and a naming-era catalog with no repair tracks
553
+ * declared classifies every endpoint as media.
554
+ *
555
+ * Throws on a catalog carrying the retired `commonTrackFields` or per-track
556
+ * `selectionParams`: repair membership cannot be derived from such a catalog,
557
+ * and classifying it as all-media would make repair-only endpoints joinable. */
558
+ export function endpointCarriesMedia(catalog, endpoint) {
559
+ const tracks = endpoint?.tracks;
560
+ if (!Array.isArray(tracks) || tracks.length === 0)
561
+ return true;
562
+ const repairNames = catalogRepairTrackNames(catalog);
563
+ if (repairNames.size === 0)
564
+ return true;
565
+ return tracks.some(track => typeof track?.name === "string" && !repairNames.has(track.name));
566
+ }
567
+ /** Catalog signalling endpoints as bandwidth-ladder endpoints, classified.
568
+ *
569
+ * Every ladder caller must build its `MulticastTierEndpoint[]` through this
570
+ * rather than hand-shaping them from the catalog: `carriesMedia` is optional
571
+ * and an unset one is treated as carrying media, so an endpoint that was
572
+ * never classified stays selectable as a tier and joining it would leave the
573
+ * receiver with repair symbols and no media. Classifying at the one place
574
+ * endpoints are converted is what makes that impossible to forget.
575
+ *
576
+ * `bandwidth` is carried through only when the endpoint declares a positive
577
+ * finite number and is never synthesized, so an endpoint that omits it stays
578
+ * unranked exactly as before. Only the fields the ladder reads are copied;
579
+ * signalling-only keys stay behind. */
580
+ export function catalogTierEndpoints(catalog, endpoints = normalizeCatalogEndpoints(catalog)) {
581
+ return endpoints.map(endpoint => {
582
+ const tier = {
583
+ groupAddress: endpoint.groupAddress,
584
+ port: Number(endpoint.port),
585
+ tracks: endpoint.tracks.map(track => track.packetId === undefined ? { name: track.name } : { name: track.name, packetId: track.packetId }),
586
+ carriesMedia: endpointCarriesMedia(catalog, endpoint),
587
+ };
588
+ if (endpoint.sourceAddress)
589
+ tier.sourceAddress = endpoint.sourceAddress;
590
+ if (endpoint.protocol)
591
+ tier.protocol = endpoint.protocol;
592
+ const bandwidth = endpoint.bandwidth;
593
+ if (typeof bandwidth === "number" && Number.isFinite(bandwidth) && bandwidth > 0)
594
+ tier.bandwidth = bandwidth;
595
+ return tier;
596
+ });
597
+ }
598
+ /** The ABR ladder a catalog advertises, for `TransportManager.connect()`'s
599
+ * `abrLadder`: one rung per rendition of `role` (the role's `renditions` map
600
+ * and any `tracks` entry with that role), named by its catalog track name
601
+ * and costed at its catalog `bitrate`. `width`, `height` and `fps` are copied
602
+ * from `codedWidth`, `codedHeight` and `framerate` only when the catalog
603
+ * states them as positive numbers; none is defaulted. Rungs keep catalog
604
+ * order; the ABR controller walks them by ascending bitrate.
605
+ *
606
+ * Throws `RangeError` naming the first video rendition whose `bitrate` is not
607
+ * a positive finite number. ABR costs measured throughput against each
608
+ * rung's bitrate, so a rung without one cannot drive it, and the coded-size
609
+ * or name rank `videoRenditionRank` falls back to orders renditions without
610
+ * pricing them. A catalog that omits `bitrate` therefore has no ABR ladder:
611
+ * have its publisher state one per rendition, or connect with a ladder of
612
+ * only the renditions the caller can price. A catalog with no video
613
+ * rendition yields an empty ladder, which `connect()` refuses. Pass `"audio"`
614
+ * for an audio-only stream; the default is `"video"`. */
615
+ export function catalogQualityLadder(catalog, role = "video") {
616
+ return Object.entries(catalogRenditions(catalog, role)).map(([name, config]) => {
617
+ const { bitrate, codedWidth, codedHeight, framerate } = (config ?? {});
618
+ if (!isPositiveFinite(bitrate)) {
619
+ throw new RangeError(`[ABR] catalog ${role} rendition ${name} states no positive bitrate to cost it by`);
620
+ }
621
+ const rung = { name, bitrate };
622
+ if (isPositiveFinite(codedWidth))
623
+ rung.width = codedWidth;
624
+ if (isPositiveFinite(codedHeight))
625
+ rung.height = codedHeight;
626
+ if (isPositiveFinite(framerate))
627
+ rung.fps = framerate;
628
+ return rung;
629
+ });
630
+ }
631
+ function isPositiveFinite(value) {
632
+ return typeof value === "number" && Number.isFinite(value) && value > 0;
633
+ }
634
+ function videoRenditionRank(config, name) {
635
+ if (typeof config?.bitrate === "number" && config.bitrate > 0)
636
+ return config.bitrate;
637
+ const width = typeof config?.codedWidth === "number" ? config.codedWidth : 0;
638
+ const height = typeof config?.codedHeight === "number" ? config.codedHeight : 0;
639
+ if (width > 0 && height > 0)
640
+ return width * height;
641
+ const match = /(?:^|[/_.-])(\d{3,4})p(?:$|[/_.-])/i.exec(name);
642
+ return match ? Number(match[1]) : Number.POSITIVE_INFINITY;
643
+ }
644
+ function lowestServedVideoRendition(catalog, endpoints) {
645
+ const renditions = catalog?.video?.renditions ?? {};
646
+ const servedTracks = new Set(endpoints.flatMap(endpoint => endpoint.tracks?.map(track => track.name) ?? []));
647
+ let selected = "";
648
+ let selectedRank = Number.POSITIVE_INFINITY;
649
+ for (const [name, config] of Object.entries(renditions)) {
650
+ if (!name || !servedTracks.has(name))
651
+ continue;
652
+ const rank = videoRenditionRank(config, name);
653
+ if (!selected || rank < selectedRank) {
654
+ selected = name;
655
+ selectedRank = rank;
656
+ }
657
+ }
658
+ return selected;
659
+ }
660
+ //# sourceMappingURL=multicast-catalog-policy.js.map