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