@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.
- package/README.md +44 -58
- package/dist/abr-controller.d.ts +34 -4
- package/dist/abr-controller.d.ts.map +1 -1
- package/dist/abr-controller.js +66 -17
- package/dist/abr-controller.js.map +1 -1
- package/dist/amt-gateway.d.ts.map +1 -1
- package/dist/amt-gateway.js +3 -4
- package/dist/amt-gateway.js.map +1 -1
- package/dist/driad-discovery.d.ts.map +1 -1
- package/dist/driad-discovery.js +3 -6
- package/dist/driad-discovery.js.map +1 -1
- package/dist/endpoint-config.d.ts +68 -0
- package/dist/endpoint-config.d.ts.map +1 -1
- package/dist/endpoint-config.js +118 -3
- package/dist/endpoint-config.js.map +1 -1
- package/dist/index.d.ts +10 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -11
- package/dist/index.js.map +1 -1
- package/dist/mmt-transport.no-modules.js +881 -60
- package/dist/mmt-transport.no-modules.js.map +4 -4
- package/dist/moqt-priority.d.ts +39 -0
- package/dist/moqt-priority.d.ts.map +1 -0
- package/dist/moqt-priority.js +41 -0
- package/dist/moqt-priority.js.map +1 -0
- package/dist/multicast-catalog-policy.d.ts +96 -0
- package/dist/multicast-catalog-policy.d.ts.map +1 -0
- package/dist/multicast-catalog-policy.js +660 -0
- package/dist/multicast-catalog-policy.js.map +1 -0
- package/dist/multicast-orchestrator.d.ts +24 -0
- package/dist/multicast-orchestrator.d.ts.map +1 -1
- package/dist/multicast-orchestrator.js +102 -9
- package/dist/multicast-orchestrator.js.map +1 -1
- package/dist/multicast-signaling-reconciler.d.ts +37 -0
- package/dist/multicast-signaling-reconciler.d.ts.map +1 -0
- package/dist/multicast-signaling-reconciler.js +161 -0
- package/dist/multicast-signaling-reconciler.js.map +1 -0
- package/dist/multicast-subscription-pool.d.ts.map +1 -1
- package/dist/multicast-subscription-pool.js +37 -10
- package/dist/multicast-subscription-pool.js.map +1 -1
- package/dist/multicast-tier.d.ts +19 -0
- package/dist/multicast-tier.d.ts.map +1 -1
- package/dist/multicast-tier.js +21 -4
- package/dist/multicast-tier.js.map +1 -1
- package/dist/no-modules.d.ts +40 -3
- package/dist/no-modules.d.ts.map +1 -1
- package/dist/no-modules.js +30 -3
- package/dist/no-modules.js.map +1 -1
- package/dist/packet-batch.d.ts +13 -0
- package/dist/packet-batch.d.ts.map +1 -0
- package/dist/packet-batch.js +14 -0
- package/dist/packet-batch.js.map +1 -0
- package/dist/stall-detector.d.ts +10 -0
- package/dist/stall-detector.d.ts.map +1 -1
- package/dist/stall-detector.js +21 -0
- package/dist/stall-detector.js.map +1 -1
- package/dist/takeover.d.ts +3 -0
- package/dist/takeover.d.ts.map +1 -1
- package/dist/takeover.js +35 -2
- package/dist/takeover.js.map +1 -1
- package/dist/transport-manager.d.ts +21 -17
- package/dist/transport-manager.d.ts.map +1 -1
- package/dist/transport-manager.js +63 -29
- package/dist/transport-manager.js.map +1 -1
- package/dist/types.d.ts +98 -123
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +12 -50
- package/dist/types.js.map +1 -1
- package/node_modules/@blockcast/mmt-alta-parse/README.md +181 -0
- package/node_modules/@blockcast/mmt-alta-parse/dist/.build-stamp +0 -0
- package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts +57 -0
- package/node_modules/@blockcast/mmt-alta-parse/dist/index.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-alta-parse/dist/index.js +115 -0
- package/node_modules/@blockcast/mmt-alta-parse/dist/index.js.map +1 -0
- package/node_modules/@blockcast/mmt-alta-parse/package.json +50 -0
- package/node_modules/@blockcast/mmt-alta-parse/src/index.ts +136 -0
- package/node_modules/@blockcast/mmt-base/README.md +134 -0
- package/node_modules/@blockcast/mmt-base/dist/.build-stamp +0 -0
- package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts +55 -0
- package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js +128 -0
- package/node_modules/@blockcast/mmt-base/dist/fec-block-profile.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/index.d.ts +7 -0
- package/node_modules/@blockcast/mmt-base/dist/index.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/index.js +7 -0
- package/node_modules/@blockcast/mmt-base/dist/index.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts +292 -0
- package/node_modules/@blockcast/mmt-base/dist/mmtp.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/mmtp.js +612 -0
- package/node_modules/@blockcast/mmt-base/dist/mmtp.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts +18 -0
- package/node_modules/@blockcast/mmt-base/dist/net-constants.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/net-constants.js +18 -0
- package/node_modules/@blockcast/mmt-base/dist/net-constants.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts +47 -0
- package/node_modules/@blockcast/mmt-base/dist/ntp-short.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/ntp-short.js +54 -0
- package/node_modules/@blockcast/mmt-base/dist/ntp-short.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts +57 -0
- package/node_modules/@blockcast/mmt-base/dist/serial-number.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/serial-number.js +98 -0
- package/node_modules/@blockcast/mmt-base/dist/serial-number.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/signalling.d.ts +312 -0
- package/node_modules/@blockcast/mmt-base/dist/signalling.d.ts.map +1 -0
- package/node_modules/@blockcast/mmt-base/dist/signalling.js +637 -0
- package/node_modules/@blockcast/mmt-base/dist/signalling.js.map +1 -0
- package/node_modules/@blockcast/mmt-base/package.json +49 -0
- package/node_modules/@blockcast/mmt-base/src/fec-block-profile.ts +145 -0
- package/node_modules/@blockcast/mmt-base/src/index.ts +6 -0
- package/node_modules/@blockcast/mmt-base/src/mmtp.ts +892 -0
- package/node_modules/@blockcast/mmt-base/src/net-constants.ts +17 -0
- package/node_modules/@blockcast/mmt-base/src/ntp-short.ts +67 -0
- package/node_modules/@blockcast/mmt-base/src/serial-number.ts +107 -0
- package/node_modules/@blockcast/mmt-base/src/signalling.ts +862 -0
- package/package.json +13 -6
- package/src/abr-controller.ts +81 -17
- package/src/amt-gateway.ts +3 -5
- package/src/driad-discovery.ts +3 -6
- package/src/endpoint-config.ts +157 -3
- package/src/index.ts +54 -33
- package/src/moqt-priority.ts +51 -0
- package/src/multicast-catalog-policy.ts +753 -0
- package/src/multicast-orchestrator.ts +114 -10
- package/src/multicast-signaling-reconciler.ts +156 -0
- package/src/multicast-subscription-pool.ts +46 -11
- package/src/multicast-tier.ts +30 -3
- package/src/no-modules.ts +54 -2
- package/src/packet-batch.ts +17 -0
- package/src/stall-detector.ts +37 -0
- package/src/takeover.ts +41 -3
- package/src/transport-manager.ts +64 -34
- package/src/types.ts +103 -160
- package/dist/endpoint-config.test.d.ts +0 -2
- package/dist/endpoint-config.test.d.ts.map +0 -1
- package/dist/endpoint-config.test.js +0 -39
- package/dist/endpoint-config.test.js.map +0 -1
- package/dist/fec-client.d.ts +0 -442
- package/dist/fec-client.d.ts.map +0 -1
- package/dist/fec-client.js +0 -789
- package/dist/fec-client.js.map +0 -1
- package/dist/fec-client.test.d.ts +0 -8
- package/dist/fec-client.test.d.ts.map +0 -1
- package/dist/fec-client.test.js +0 -112
- package/dist/fec-client.test.js.map +0 -1
- package/dist/multicast-subscription-pool.test.d.ts +0 -2
- package/dist/multicast-subscription-pool.test.d.ts.map +0 -1
- package/dist/multicast-subscription-pool.test.js +0 -108
- package/dist/multicast-subscription-pool.test.js.map +0 -1
- package/dist/multicast-tier.test.d.ts +0 -2
- package/dist/multicast-tier.test.d.ts.map +0 -1
- package/dist/multicast-tier.test.js +0 -75
- package/dist/multicast-tier.test.js.map +0 -1
- package/dist/stall-detector.test.d.ts +0 -2
- package/dist/stall-detector.test.d.ts.map +0 -1
- package/dist/stall-detector.test.js +0 -33
- package/dist/stall-detector.test.js.map +0 -1
- package/dist/transport-manager.test.d.ts +0 -2
- package/dist/transport-manager.test.d.ts.map +0 -1
- package/dist/transport-manager.test.js +0 -155
- package/dist/transport-manager.test.js.map +0 -1
- package/src/endpoint-config.test.ts +0 -54
- package/src/fec-client.test.ts +0 -140
- package/src/fec-client.ts +0 -1103
- package/src/multicast-subscription-pool.test.ts +0 -115
- package/src/multicast-tier.test.ts +0 -80
- package/src/stall-detector.test.ts +0 -34
- 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
|
+
}
|