@apocaliss92/scrypted-reolink-native 0.3.15 → 0.4.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.
@@ -1,104 +1,117 @@
1
1
  import type {
2
- CompositeStreamPipOptions,
3
- NativeVideoStreamVariant,
4
- ReolinkBaichuanApi,
5
- Rfc4571TcpServer,
6
- StreamProfile,
2
+ CompositeStreamPipOptions,
3
+ NativeVideoStreamVariant,
4
+ ReolinkBaichuanApi,
5
+ Rfc4571TcpServer,
6
+ StreamProfile,
7
7
  } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
8
8
 
9
9
  import sdk, {
10
- type MediaObject,
11
- type RequestMediaStreamOptions,
12
- type ResponseMediaStreamOptions,
10
+ type MediaObject,
11
+ type RequestMediaStreamOptions,
12
+ type ResponseMediaStreamOptions,
13
13
  } from "@scrypted/sdk";
14
14
  import type { UrlMediaStreamOptions } from "../../scrypted/plugins/rtsp/src/rtsp";
15
15
 
16
16
  export interface StreamManagerOptions {
17
- /**
18
- * Creates a dedicated Baichuan session for streaming.
19
- * Required to support concurrent main+ext streams on firmwares where streamType overlaps.
20
- * @param streamKey The unique stream key (e.g., "composite-rtsp-default-sub-sub", "channel_0_main", etc.)
21
- * Forwarded to the library as `requestedId`.
22
- */
23
- createStreamClient: (streamKey: string) => Promise<ReolinkBaichuanApi>;
24
- logger: Console;
25
- /**
26
- * Credentials to include in the TCP stream (username, password).
27
- * Uses the same credentials as the main connection.
28
- */
29
- credentials: {
30
- username: string;
31
- password: string;
32
- };
33
- /** If true, the stream client is shared with the main connection. Default: false. */
34
- sharedConnection?: boolean;
35
- /** Composite stream options for multifocal cameras */
36
- compositeOptions?: CompositeStreamPipOptions;
17
+ /**
18
+ * Creates a dedicated Baichuan session for streaming.
19
+ * Required to support concurrent main+ext streams on firmwares where streamType overlaps.
20
+ * @param streamKey The unique stream key (e.g., "composite-rtsp-default-sub-sub", "channel_0_main", etc.)
21
+ * Forwarded to the library as `requestedId`.
22
+ */
23
+ createStreamClient: (streamKey: string) => Promise<ReolinkBaichuanApi>;
24
+ logger: Console;
25
+ /**
26
+ * Credentials to include in the TCP stream (username, password).
27
+ * Uses the same credentials as the main connection.
28
+ */
29
+ credentials: {
30
+ username: string;
31
+ password: string;
32
+ };
33
+ /** If true, the stream client is shared with the main connection. Default: false. */
34
+ sharedConnection?: boolean;
35
+ /** Composite stream options for multifocal cameras */
36
+ compositeOptions?: CompositeStreamPipOptions;
37
+ /**
38
+ * Unique device identifier for dedicated socket sessions.
39
+ * When provided, the library creates a dedicated socket per stream to avoid interference.
40
+ * This should be a stable identifier (e.g., Scrypted nativeId).
41
+ */
42
+ deviceId?: string;
37
43
  }
38
44
 
39
- export function parseStreamProfileFromId(id: string | undefined): StreamProfile | undefined {
40
- if (!id)
41
- return;
42
-
43
- // Handle plain profiles (used by composite parsing: 'main'/'sub'/'ext')
44
- if (id === 'main' || id === 'sub' || id === 'ext') {
45
- return id as StreamProfile;
45
+ export function parseStreamProfileFromId(
46
+ id: string | undefined,
47
+ ): StreamProfile | undefined {
48
+ if (!id) return;
49
+
50
+ // Handle plain profiles (used by composite parsing: 'main'/'sub'/'ext')
51
+ if (id === "main" || id === "sub" || id === "ext") {
52
+ return id as StreamProfile;
53
+ }
54
+
55
+ // Handle native stream IDs: native_main, native_sub, native_ext, native_autotrack_main, native_autotrack_sub, etc.
56
+ if (id.startsWith("native_")) {
57
+ const withoutPrefix = id.replace("native_", "");
58
+ // Extract profile from formats like "main", "sub", "ext", "autotrack_main", "telephoto_sub", etc.
59
+ // The profile is always the last part after underscore or the whole string if no underscore
60
+ const parts = withoutPrefix.split("_");
61
+ const profile = parts[parts.length - 1]; // Take the last part as profile
62
+ if (profile === "main" || profile === "sub" || profile === "ext") {
63
+ return profile as StreamProfile;
46
64
  }
47
-
48
- // Handle native stream IDs: native_main, native_sub, native_ext, native_autotrack_main, native_autotrack_sub, etc.
49
- if (id.startsWith('native_')) {
50
- const withoutPrefix = id.replace('native_', '');
51
- // Extract profile from formats like "main", "sub", "ext", "autotrack_main", "telephoto_sub", etc.
52
- // The profile is always the last part after underscore or the whole string if no underscore
53
- const parts = withoutPrefix.split('_');
54
- const profile = parts[parts.length - 1]; // Take the last part as profile
55
- if (profile === 'main' || profile === 'sub' || profile === 'ext') {
56
- return profile as StreamProfile;
57
- }
58
- // If no valid profile found, try to match the whole string
59
- if (withoutPrefix === 'main' || withoutPrefix === 'sub' || withoutPrefix === 'ext') {
60
- return withoutPrefix as StreamProfile;
61
- }
62
- return undefined;
65
+ // If no valid profile found, try to match the whole string
66
+ if (
67
+ withoutPrefix === "main" ||
68
+ withoutPrefix === "sub" ||
69
+ withoutPrefix === "ext"
70
+ ) {
71
+ return withoutPrefix as StreamProfile;
63
72
  }
73
+ return undefined;
74
+ }
64
75
 
65
- // Handle RTMP IDs: main.bcs, sub.bcs, ext.bcs
66
- if (id.endsWith('.bcs')) {
67
- const profile = id.replace('.bcs', '');
68
- if (profile === 'main' || profile === 'sub' || profile === 'ext') {
69
- return profile as StreamProfile;
70
- }
71
- return undefined;
76
+ // Handle RTMP IDs: main.bcs, sub.bcs, ext.bcs
77
+ if (id.endsWith(".bcs")) {
78
+ const profile = id.replace(".bcs", "");
79
+ if (profile === "main" || profile === "sub" || profile === "ext") {
80
+ return profile as StreamProfile;
72
81
  }
73
-
74
- // Handle RTSP IDs: h264Preview_XX_main, h264Preview_XX_sub, Preview_03_autotrack, etc.
75
- if (id.startsWith('h264Preview_')) {
76
- if (id.endsWith('_main'))
77
- return 'main';
78
- if (id.endsWith('_sub'))
79
- return 'sub';
82
+ return undefined;
83
+ }
84
+
85
+ // Handle RTSP IDs: h264Preview_XX_main, h264Preview_XX_sub, Preview_03_autotrack, etc.
86
+ if (id.startsWith("h264Preview_")) {
87
+ if (id.endsWith("_main")) return "main";
88
+ if (id.endsWith("_sub")) return "sub";
89
+ }
90
+
91
+ // Handle RTSP IDs like Preview_03_autotrack, Preview_03_autotrack_sub
92
+ // These should map to main or sub based on the suffix
93
+ if (id.includes("Preview_")) {
94
+ if (id.endsWith("_autotrack_sub") || id.endsWith("_sub")) {
95
+ return "sub";
80
96
  }
81
-
82
- // Handle RTSP IDs like Preview_03_autotrack, Preview_03_autotrack_sub
83
- // These should map to main or sub based on the suffix
84
- if (id.includes('Preview_')) {
85
- if (id.endsWith('_autotrack_sub') || id.endsWith('_sub')) {
86
- return 'sub';
87
- }
88
- if (id.endsWith('_autotrack') || id.endsWith('_main') || id.match(/Preview_\d+_?[a-z]*$/)) {
89
- return 'main';
90
- }
97
+ if (
98
+ id.endsWith("_autotrack") ||
99
+ id.endsWith("_main") ||
100
+ id.match(/Preview_\d+_?[a-z]*$/)
101
+ ) {
102
+ return "main";
91
103
  }
104
+ }
92
105
 
93
- return;
106
+ return;
94
107
  }
95
108
 
96
109
  type ReolinkRfc4571Metadata = {
97
- profile: StreamProfile;
98
- channel?: number;
99
- variant?: NativeVideoStreamVariant;
100
- /** Explicitly mark composite (channel-less) streams. If omitted, `channel===undefined` implies composite. */
101
- isComposite?: boolean;
110
+ profile: StreamProfile;
111
+ channel?: number;
112
+ variant?: NativeVideoStreamVariant;
113
+ /** Explicitly mark composite (channel-less) streams. If omitted, `channel===undefined` implies composite. */
114
+ isComposite?: boolean;
102
115
  };
103
116
 
104
117
  /**
@@ -106,384 +119,422 @@ type ReolinkRfc4571Metadata = {
106
119
  * Returns undefined if no variant is present, or "autotrack"/"telephoto" if present
107
120
  * Note: on Hub/NVR multifocal firmwares, the tele lens is often requested via "telephoto".
108
121
  */
109
- export function extractVariantFromStreamId(id: string | undefined, url?: string | undefined): 'autotrack' | 'telephoto' | undefined {
110
- let variant: string | undefined;
111
-
112
- // First try to extract from ID
113
- if (id) {
114
- // Handle native stream IDs: native_autotrack_main, native_telephoto_sub, etc.
115
- if (id.startsWith('native_')) {
116
- const withoutPrefix = id.replace('native_', '');
117
- const parts = withoutPrefix.split('_');
118
- // If there are more than 1 parts, the first part(s) before the profile is the variant
119
- // e.g., "autotrack_main" -> variant: "autotrack", profile: "main"
120
- if (parts.length > 1) {
121
- const profile = parts[parts.length - 1];
122
- // Only return variant if profile is valid (main/sub/ext)
123
- if (profile === 'main' || profile === 'sub' || profile === 'ext') {
124
- // Join all parts except the last one as variant (handles multi-part variants)
125
- variant = parts.slice(0, -1).join('_');
126
- }
127
- }
128
- }
129
-
130
- // Handle RTSP IDs like Preview_03_autotrack, Preview_03_autotrack_sub
131
- if (!variant && id.includes('Preview_')) {
132
- if (id.includes('_autotrack')) {
133
- variant = 'autotrack';
134
- } else if (id.includes('_telephoto')) {
135
- variant = 'telephoto';
136
- }
122
+ export function extractVariantFromStreamId(
123
+ id: string | undefined,
124
+ url?: string | undefined,
125
+ ): "autotrack" | "telephoto" | undefined {
126
+ let variant: string | undefined;
127
+
128
+ // First try to extract from ID
129
+ if (id) {
130
+ // Handle native stream IDs: native_autotrack_main, native_telephoto_sub, etc.
131
+ if (id.startsWith("native_")) {
132
+ const withoutPrefix = id.replace("native_", "");
133
+ const parts = withoutPrefix.split("_");
134
+ // If there are more than 1 parts, the first part(s) before the profile is the variant
135
+ // e.g., "autotrack_main" -> variant: "autotrack", profile: "main"
136
+ if (parts.length > 1) {
137
+ const profile = parts[parts.length - 1];
138
+ // Only return variant if profile is valid (main/sub/ext)
139
+ if (profile === "main" || profile === "sub" || profile === "ext") {
140
+ // Join all parts except the last one as variant (handles multi-part variants)
141
+ variant = parts.slice(0, -1).join("_");
137
142
  }
143
+ }
138
144
  }
139
145
 
140
- // Fallback: try to extract from URL query parameter
141
- if (!variant && url) {
142
- try {
143
- const urlObj = new URL(url);
144
- const variantParam = urlObj.searchParams.get('variant');
145
- if (variantParam) {
146
- variant = variantParam;
147
- }
148
- } catch {
149
- // Invalid URL, ignore
150
- }
146
+ // Handle RTSP IDs like Preview_03_autotrack, Preview_03_autotrack_sub
147
+ if (!variant && id.includes("Preview_")) {
148
+ if (id.includes("_autotrack")) {
149
+ variant = "autotrack";
150
+ } else if (id.includes("_telephoto")) {
151
+ variant = "telephoto";
152
+ }
151
153
  }
152
-
153
- // Normalize variant: accept "autotrack", "telephoto", or map "default" to undefined
154
- if (variant === 'autotrack' || variant === 'telephoto') {
155
- // Preserve explicit variants; firmware-specific behavior is handled by the library.
156
- return variant as 'autotrack' | 'telephoto';
154
+ }
155
+
156
+ // Fallback: try to extract from URL query parameter
157
+ if (!variant && url) {
158
+ try {
159
+ const urlObj = new URL(url);
160
+ const variantParam = urlObj.searchParams.get("variant");
161
+ if (variantParam) {
162
+ variant = variantParam;
163
+ }
164
+ } catch {
165
+ // Invalid URL, ignore
157
166
  }
167
+ }
158
168
 
159
- return undefined;
169
+ // Normalize variant: accept "autotrack", "telephoto", or map "default" to undefined
170
+ if (variant === "autotrack" || variant === "telephoto") {
171
+ // Preserve explicit variants; firmware-specific behavior is handled by the library.
172
+ return variant as "autotrack" | "telephoto";
173
+ }
174
+
175
+ return undefined;
160
176
  }
161
177
 
162
178
  export function selectStreamOption(
163
- vsos: UrlMediaStreamOptions[] | undefined,
164
- request: RequestMediaStreamOptions,
179
+ vsos: UrlMediaStreamOptions[] | undefined,
180
+ request: RequestMediaStreamOptions,
165
181
  ): UrlMediaStreamOptions {
166
- if (!request) throw new Error('video streams not set up or no longer exists.');
167
- const selected = vsos?.find((s) => s.id === request.id) || vsos?.[0];
168
- if (!selected) throw new Error('No stream options available');
169
- return selected;
182
+ if (!request)
183
+ throw new Error("video streams not set up or no longer exists.");
184
+ const selected = vsos?.find((s) => s.id === request.id) || vsos?.[0];
185
+ if (!selected) throw new Error("No stream options available");
186
+ return selected;
170
187
  }
171
188
 
172
189
  export async function createRfc4571MediaObjectFromStreamManager(params: {
173
- streamManager: StreamManager;
174
- streamKey: string;
175
- selected: UrlMediaStreamOptions;
176
- sourceId: string;
190
+ streamManager: StreamManager;
191
+ streamKey: string;
192
+ selected: UrlMediaStreamOptions;
193
+ sourceId: string;
177
194
  }): Promise<MediaObject> {
178
- const { streamManager, streamKey, selected, sourceId } = params;
179
-
180
- const meta = (selected as any)?.reolinkRfc4571 as ReolinkRfc4571Metadata | undefined;
181
- if (!meta?.profile) {
182
- throw new Error(`Missing RFC4571 metadata/profile for streamKey='${streamKey}'`);
183
- }
184
-
185
- const { host, port, sdp, audio, username, password } = await streamManager.getRfcServer(streamKey, meta);
186
-
187
- const { url: _ignoredUrl, ...mso }: any = selected;
188
- mso.container = 'rtp';
189
- if (audio) {
190
- mso.audio ||= {};
191
- mso.audio.codec = audio.codec;
192
- mso.audio.sampleRate = audio.sampleRate;
193
- mso.audio.channels = audio.channels;
194
- }
195
-
196
- const url = new URL(`tcp://${host}`);
197
- url.port = port.toString();
198
- if (username) {
199
- url.username = username;
200
- }
201
- if (password) {
202
- url.password = password;
203
- }
204
-
205
- const rfc = {
206
- url,
207
- sdp,
208
- mediaStreamOptions: mso as ResponseMediaStreamOptions,
209
- };
210
-
211
- return await sdk.mediaManager.createMediaObject(Buffer.from(JSON.stringify(rfc)), 'x-scrypted/x-rfc4571', {
212
- sourceId,
213
- });
195
+ const { streamManager, streamKey, selected, sourceId } = params;
196
+
197
+ const meta = (selected as any)?.reolinkRfc4571 as
198
+ | ReolinkRfc4571Metadata
199
+ | undefined;
200
+ if (!meta?.profile) {
201
+ throw new Error(
202
+ `Missing RFC4571 metadata/profile for streamKey='${streamKey}'`,
203
+ );
204
+ }
205
+
206
+ const { host, port, sdp, audio, username, password } =
207
+ await streamManager.getRfcServer(streamKey, meta);
208
+
209
+ const { url: _ignoredUrl, ...mso }: any = selected;
210
+ mso.container = "rtp";
211
+ if (audio) {
212
+ mso.audio ||= {};
213
+ mso.audio.codec = audio.codec;
214
+ mso.audio.sampleRate = audio.sampleRate;
215
+ mso.audio.channels = audio.channels;
216
+ }
217
+
218
+ const url = new URL(`tcp://${host}`);
219
+ url.port = port.toString();
220
+ if (username) {
221
+ url.username = username;
222
+ }
223
+ if (password) {
224
+ url.password = password;
225
+ }
226
+
227
+ const rfc = {
228
+ url,
229
+ sdp,
230
+ mediaStreamOptions: mso as ResponseMediaStreamOptions,
231
+ };
232
+
233
+ return await sdk.mediaManager.createMediaObject(
234
+ Buffer.from(JSON.stringify(rfc)),
235
+ "x-scrypted/x-rfc4571",
236
+ {
237
+ sourceId,
238
+ },
239
+ );
214
240
  }
215
241
 
216
242
  type RfcServerInfo = {
217
- host: string;
218
- port: number;
219
- sdp: string;
220
- audio?: { codec: string; sampleRate: number; channels: number };
221
- username: string;
222
- password: string;
243
+ host: string;
244
+ port: number;
245
+ sdp: string;
246
+ audio?: { codec: string; sampleRate: number; channels: number };
247
+ username: string;
248
+ password: string;
223
249
  };
224
250
 
225
251
  export class StreamManager {
226
- private nativeRfcServers = new Map<string, Rfc4571TcpServer>();
227
- private nativeRfcServerCreatePromises = new Map<string, Promise<RfcServerInfo>>();
228
-
229
- constructor(private opts: StreamManagerOptions) {
230
- // Ensure logger is always valid
231
- if (!this.opts.logger) {
232
- this.opts.logger = console;
233
- }
252
+ private nativeRfcServers = new Map<string, Rfc4571TcpServer>();
253
+ private nativeRfcServerCreatePromises = new Map<
254
+ string,
255
+ Promise<RfcServerInfo>
256
+ >();
257
+
258
+ constructor(private opts: StreamManagerOptions) {
259
+ // Ensure logger is always valid
260
+ if (!this.opts.logger) {
261
+ this.opts.logger = console;
234
262
  }
235
-
236
- private getLogger(): Console {
237
- return this.opts.logger || console;
263
+ }
264
+
265
+ private getLogger(): Console {
266
+ return this.opts.logger || console;
267
+ }
268
+
269
+ /**
270
+ * Unified RFC4571 server accessor.
271
+ *
272
+ * `stream-utils` does not parse `streamKey`. It forwards the `streamKey` as `requestedId`
273
+ * and relies on explicit metadata from the selected stream option (profile/channel/variant).
274
+ */
275
+ async getRfcServer(
276
+ streamKey: string,
277
+ meta: ReolinkRfc4571Metadata,
278
+ ): Promise<RfcServerInfo> {
279
+ if (!meta?.profile) {
280
+ throw new Error(
281
+ `getRfcServer: missing profile for streamKey='${streamKey}'`,
282
+ );
238
283
  }
239
284
 
240
- /**
241
- * Unified RFC4571 server accessor.
242
- *
243
- * `stream-utils` does not parse `streamKey`. It forwards the `streamKey` as `requestedId`
244
- * and relies on explicit metadata from the selected stream option (profile/channel/variant).
245
- */
246
- async getRfcServer(streamKey: string, meta: ReolinkRfc4571Metadata): Promise<RfcServerInfo> {
247
- if (!meta?.profile) {
248
- throw new Error(`getRfcServer: missing profile for streamKey='${streamKey}'`);
249
- }
250
-
251
- const isComposite = meta.isComposite ?? (meta.channel === undefined);
285
+ const isComposite = meta.isComposite ?? meta.channel === undefined;
252
286
 
253
- return await this.ensureRfcServer(streamKey, meta.profile, {
254
- channel: isComposite ? undefined : meta.channel,
255
- variant: meta.variant,
256
- compositeOptions: isComposite ? this.opts.compositeOptions : undefined,
257
- });
287
+ return await this.ensureRfcServer(streamKey, meta.profile, {
288
+ channel: isComposite ? undefined : meta.channel,
289
+ variant: meta.variant,
290
+ compositeOptions: isComposite ? this.opts.compositeOptions : undefined,
291
+ });
292
+ }
293
+
294
+ private async ensureRfcServer(
295
+ streamKey: string,
296
+ profile: StreamProfile,
297
+ options: {
298
+ channel?: number;
299
+ variant?: NativeVideoStreamVariant;
300
+ compositeOptions?: CompositeStreamPipOptions;
301
+ },
302
+ ): Promise<RfcServerInfo> {
303
+ // Check for existing promise first to prevent duplicate server creation
304
+ const existingCreate = this.nativeRfcServerCreatePromises.get(streamKey);
305
+ if (existingCreate) {
306
+ return await existingCreate;
258
307
  }
259
308
 
260
- private async ensureRfcServer(
261
- streamKey: string,
262
- profile: StreamProfile,
263
- options: {
264
- channel?: number;
265
- variant?: NativeVideoStreamVariant;
266
- compositeOptions?: CompositeStreamPipOptions;
267
- },
268
- ): Promise<RfcServerInfo> {
269
- // Check for existing promise first to prevent duplicate server creation
270
- const existingCreate = this.nativeRfcServerCreatePromises.get(streamKey);
271
- if (existingCreate) {
272
- return await existingCreate;
273
- }
274
-
275
- // Double-check: if server already exists and is listening, return it immediately
276
- const existingServer = this.nativeRfcServers.get(streamKey);
277
- if (existingServer?.server?.listening) {
278
- this.getLogger().log(`Reusing existing RFC4571 server for streamKey=${streamKey} (port=${existingServer.port})`);
279
- return {
280
- host: existingServer.host,
281
- port: existingServer.port,
282
- sdp: existingServer.sdp,
283
- audio: existingServer.audio,
284
- username: existingServer.username || this.opts.credentials.username,
285
- password: existingServer.password || this.opts.credentials.password,
286
- };
287
- }
309
+ // Double-check: if server already exists and is listening, return it immediately
310
+ const existingServer = this.nativeRfcServers.get(streamKey);
311
+ if (existingServer?.server?.listening) {
312
+ this.getLogger().log(
313
+ `Reusing existing RFC4571 server for streamKey=${streamKey} (port=${existingServer.port})`,
314
+ );
315
+ return {
316
+ host: existingServer.host,
317
+ port: existingServer.port,
318
+ sdp: existingServer.sdp,
319
+ audio: existingServer.audio,
320
+ username: existingServer.username || this.opts.credentials.username,
321
+ password: existingServer.password || this.opts.credentials.password,
322
+ };
323
+ }
288
324
 
289
- const createPromise = (async () => {
290
- const cached = this.nativeRfcServers.get(streamKey);
291
- if (cached?.server?.listening) {
292
- return {
293
- host: cached.host,
294
- port: cached.port,
295
- sdp: cached.sdp,
296
- audio: cached.audio,
297
- username: cached.username || this.opts.credentials.username,
298
- password: cached.password || this.opts.credentials.password,
299
- };
300
- }
301
-
302
- if (cached) {
303
- try {
304
- await cached.close('recreate');
305
- }
306
- catch {
307
- // ignore
308
- }
309
- this.nativeRfcServers.delete(streamKey);
310
- }
311
-
312
- const isComposite = options.channel === undefined;
313
-
314
- // IMPORTANT: do not parse composite profiles here.
315
- // The library/server derives the composite pairing from the requested id.
316
-
317
- // For composite streams, we may want two distinct Baichuan sessions (wider + tele)
318
- // to avoid frame mixing on some firmwares. On BCUDP/battery devices, extra sessions
319
- // can be harmful; in that case, createStreamClient may return the same underlying client.
320
- //
321
- // Use stable per-request keys; include variant for tele to keep sessions distinct.
322
- const compositeWiderStreamKey = `${streamKey}_wider`;
323
- const compositeTeleStreamKey = `${streamKey}_tele_${options.variant ?? 'default'}`;
324
-
325
- // For composite streams, using two distinct Baichuan sessions can avoid frame mixing on some firmwares.
326
- // However, for UDP/battery devices extra BCUDP sessions can trigger storms; if we detect the same
327
- // underlying client, fall back to single-session composite.
328
- let compositeApis:
329
- | {
330
- widerApi: ReolinkBaichuanApi;
331
- teleApi: ReolinkBaichuanApi;
332
- }
333
- | undefined;
334
- if (isComposite) {
335
- try {
336
- const widerApi = await this.opts.createStreamClient(compositeWiderStreamKey);
337
- const teleApi = await this.opts.createStreamClient(compositeTeleStreamKey);
338
-
339
- const sameApiObject = widerApi === teleApi;
340
- const sameUnderlyingClient = (widerApi as any)?.client && (teleApi as any)?.client
341
- ? (widerApi as any).client === (teleApi as any).client
342
- : false;
343
-
344
- if (!sameApiObject && !sameUnderlyingClient) {
345
- compositeApis = { widerApi, teleApi };
346
- } else {
347
- // Likely a shared/battery connection: avoid forcing multi-session behavior.
348
- compositeApis = undefined;
349
- }
350
- } catch {
351
- // Best-effort: if creating dedicated sessions fails, fall back to single-session composite.
352
- compositeApis = undefined;
353
- }
354
- }
355
-
356
- // For non-composite streams, create/reuse a single API client.
357
- // For NVR/Hub (sharedConnection=true), avoid creating one TCP session per profile
358
- // (e.g. main+sub would otherwise become two sockets). Group by (channel, variant).
359
- // For composite streams, base api must be a real lens streamKey (not the composite RFC key).
360
- const shared = this.opts.sharedConnection ?? false;
361
- const nonCompositeApiKey = (!isComposite && shared && options.channel !== undefined)
362
- ? `channel_${options.channel}_${options.variant ?? 'default'}`
363
- : streamKey;
364
-
365
- const api = isComposite
366
- ? (compositeApis?.widerApi ?? await this.opts.createStreamClient(compositeWiderStreamKey))
367
- : await this.opts.createStreamClient(nonCompositeApiKey);
368
-
369
- const { createRfc4571TcpServer } = await import('@apocaliss92/reolink-baichuan-js');
370
-
371
- const { username, password } = this.opts.credentials;
372
-
373
- // If connection is shared, don't close it when stream teardown happens
374
- // For composite, we create dedicated APIs even if the device uses a shared main connection.
375
- // On battery/BCUDP (sharedConnection=true), prefer keeping them alive to avoid reconnect storms.
376
- const closeApiOnTeardown = isComposite
377
- ? (Boolean(compositeApis) && !(this.opts.sharedConnection ?? false))
378
- : !(this.opts.sharedConnection ?? false);
379
-
380
- let created: any;
381
- try {
382
- const compositeOptions = isComposite ? options.compositeOptions : undefined;
383
-
384
- created = await createRfc4571TcpServer({
385
- api,
386
- channel: options.channel,
387
- profile,
388
- variant: options.variant,
389
- logger: this.getLogger(),
390
- closeApiOnTeardown,
391
- username,
392
- password,
393
- requestedId: streamKey,
394
- // Composite can take a bit longer (ffmpeg warmup + first IDR).
395
- ...(isComposite ? { keyframeTimeoutMs: 20_000, idleTeardownMs: 20_000 } : {}),
396
- ...(compositeOptions ? { compositeOptions } : {}),
397
- ...(compositeApis ? { compositeApis } : {}),
398
- });
399
- }
400
- catch (e) {
401
- if (isComposite && closeApiOnTeardown && compositeApis) {
402
- await Promise.allSettled([
403
- compositeApis.widerApi?.close?.(),
404
- compositeApis.teleApi?.close?.(),
405
- ]);
406
- }
407
- throw e;
408
- }
409
-
410
- this.nativeRfcServers.set(streamKey, created);
411
- created.server.once('close', () => {
412
- const current = this.nativeRfcServers.get(streamKey);
413
- if (current?.server === created.server) this.nativeRfcServers.delete(streamKey);
414
- });
415
-
416
- return {
417
- host: created.host,
418
- port: created.port,
419
- sdp: created.sdp,
420
- audio: created.audio,
421
- username: created.username || this.opts.credentials.username,
422
- password: created.password || this.opts.credentials.password,
423
- };
424
- })();
425
-
426
- this.nativeRfcServerCreatePromises.set(streamKey, createPromise);
325
+ const createPromise = (async () => {
326
+ const cached = this.nativeRfcServers.get(streamKey);
327
+ if (cached?.server?.listening) {
328
+ return {
329
+ host: cached.host,
330
+ port: cached.port,
331
+ sdp: cached.sdp,
332
+ audio: cached.audio,
333
+ username: cached.username || this.opts.credentials.username,
334
+ password: cached.password || this.opts.credentials.password,
335
+ };
336
+ }
337
+
338
+ if (cached) {
427
339
  try {
428
- return await createPromise;
340
+ await cached.close("recreate");
341
+ } catch {
342
+ // ignore
429
343
  }
430
- finally {
431
- this.nativeRfcServerCreatePromises.delete(streamKey);
344
+ this.nativeRfcServers.delete(streamKey);
345
+ }
346
+
347
+ const isComposite = options.channel === undefined;
348
+
349
+ // IMPORTANT: do not parse composite profiles here.
350
+ // The library/server derives the composite pairing from the requested id.
351
+
352
+ // For composite streams, we may want two distinct Baichuan sessions (wider + tele)
353
+ // to avoid frame mixing on some firmwares. On BCUDP/battery devices, extra sessions
354
+ // can be harmful; in that case, createStreamClient may return the same underlying client.
355
+ //
356
+ // Use stable per-request keys; include variant for tele to keep sessions distinct.
357
+ const compositeWiderStreamKey = `${streamKey}_wider`;
358
+ const compositeTeleStreamKey = `${streamKey}_tele_${options.variant ?? "default"}`;
359
+
360
+ // For composite streams, using two distinct Baichuan sessions can avoid frame mixing on some firmwares.
361
+ // However, for UDP/battery devices extra BCUDP sessions can trigger storms; if we detect the same
362
+ // underlying client, fall back to single-session composite.
363
+ let compositeApis:
364
+ | {
365
+ widerApi: ReolinkBaichuanApi;
366
+ teleApi: ReolinkBaichuanApi;
367
+ }
368
+ | undefined;
369
+ if (isComposite) {
370
+ try {
371
+ const widerApi = await this.opts.createStreamClient(
372
+ compositeWiderStreamKey,
373
+ );
374
+ const teleApi = await this.opts.createStreamClient(
375
+ compositeTeleStreamKey,
376
+ );
377
+
378
+ const sameApiObject = widerApi === teleApi;
379
+ const sameUnderlyingClient =
380
+ (widerApi as any)?.client && (teleApi as any)?.client
381
+ ? (widerApi as any).client === (teleApi as any).client
382
+ : false;
383
+
384
+ if (!sameApiObject && !sameUnderlyingClient) {
385
+ compositeApis = { widerApi, teleApi };
386
+ } else {
387
+ // Likely a shared/battery connection: avoid forcing multi-session behavior.
388
+ compositeApis = undefined;
389
+ }
390
+ } catch {
391
+ // Best-effort: if creating dedicated sessions fails, fall back to single-session composite.
392
+ compositeApis = undefined;
432
393
  }
433
- }
434
-
435
- async getRfcStream(
436
- channel: number,
437
- profile: StreamProfile,
438
- streamKey: string,
439
- variant?: NativeVideoStreamVariant,
440
- ): Promise<RfcServerInfo> {
441
- // Back-compat wrapper. Prefer getRfcServer(streamKey).
442
- return await this.ensureRfcServer(streamKey, profile, { channel, variant });
443
- }
444
-
445
- async getRfcCompositeStream(
446
- profile: StreamProfile,
447
- streamKey: string,
448
- variantType?: NativeVideoStreamVariant,
449
- ): Promise<RfcServerInfo> {
450
- // Back-compat wrapper. Prefer getRfcServer(streamKey).
451
- // Pass variantType to ensureRfcServer so it can be used when creating the stream client.
452
- return await this.ensureRfcServer(streamKey, profile, {
453
- channel: undefined,
454
- variant: variantType,
455
- compositeOptions: this.opts.compositeOptions,
394
+ }
395
+
396
+ // For non-composite streams, create/reuse a single API client.
397
+ // For NVR/Hub (sharedConnection=true), avoid creating one TCP session per profile
398
+ // (e.g. main+sub would otherwise become two sockets). Group by (channel, variant).
399
+ // For composite streams, base api must be a real lens streamKey (not the composite RFC key).
400
+ const shared = this.opts.sharedConnection ?? false;
401
+ const nonCompositeApiKey =
402
+ !isComposite && shared && options.channel !== undefined
403
+ ? `channel_${options.channel}_${options.variant ?? "default"}`
404
+ : streamKey;
405
+
406
+ const api = isComposite
407
+ ? (compositeApis?.widerApi ??
408
+ (await this.opts.createStreamClient(compositeWiderStreamKey)))
409
+ : await this.opts.createStreamClient(nonCompositeApiKey);
410
+
411
+ const { createRfc4571TcpServer } =
412
+ await import("@apocaliss92/reolink-baichuan-js");
413
+
414
+ const { username, password } = this.opts.credentials;
415
+
416
+ // If connection is shared, don't close it when stream teardown happens
417
+ // For composite, we create dedicated APIs even if the device uses a shared main connection.
418
+ // On battery/BCUDP (sharedConnection=true), prefer keeping them alive to avoid reconnect storms.
419
+ const closeApiOnTeardown = isComposite
420
+ ? Boolean(compositeApis) && !(this.opts.sharedConnection ?? false)
421
+ : !(this.opts.sharedConnection ?? false);
422
+
423
+ let created: any;
424
+ try {
425
+ const compositeOptions = isComposite
426
+ ? options.compositeOptions
427
+ : undefined;
428
+
429
+ created = await createRfc4571TcpServer({
430
+ api,
431
+ channel: options.channel,
432
+ profile,
433
+ variant: options.variant,
434
+ logger: this.getLogger(),
435
+ closeApiOnTeardown,
436
+ username,
437
+ password,
438
+ requestedId: streamKey,
439
+ // Pass deviceId for dedicated socket sessions (avoids stream interference)
440
+ ...(this.opts.deviceId ? { deviceId: this.opts.deviceId } : {}),
441
+ // Composite can take a bit longer (ffmpeg warmup + first IDR).
442
+ ...(isComposite
443
+ ? { keyframeTimeoutMs: 20_000, idleTeardownMs: 20_000 }
444
+ : {}),
445
+ ...(compositeOptions ? { compositeOptions } : {}),
446
+ ...(compositeApis ? { compositeApis } : {}),
456
447
  });
457
- }
458
-
459
- /**
460
- * Check if there are any active streams (servers that are listening).
461
- */
462
- hasActiveStreams(): boolean {
463
- for (const server of this.nativeRfcServers.values()) {
464
- if (server?.server?.listening) {
465
- return true;
466
- }
448
+ } catch (e) {
449
+ if (isComposite && closeApiOnTeardown && compositeApis) {
450
+ await Promise.allSettled([
451
+ compositeApis.widerApi?.close?.(),
452
+ compositeApis.teleApi?.close?.(),
453
+ ]);
467
454
  }
468
- return false;
455
+ throw e;
456
+ }
457
+
458
+ this.nativeRfcServers.set(streamKey, created);
459
+ created.server.once("close", () => {
460
+ const current = this.nativeRfcServers.get(streamKey);
461
+ if (current?.server === created.server)
462
+ this.nativeRfcServers.delete(streamKey);
463
+ });
464
+
465
+ return {
466
+ host: created.host,
467
+ port: created.port,
468
+ sdp: created.sdp,
469
+ audio: created.audio,
470
+ username: created.username || this.opts.credentials.username,
471
+ password: created.password || this.opts.credentials.password,
472
+ };
473
+ })();
474
+
475
+ this.nativeRfcServerCreatePromises.set(streamKey, createPromise);
476
+ try {
477
+ return await createPromise;
478
+ } finally {
479
+ this.nativeRfcServerCreatePromises.delete(streamKey);
469
480
  }
470
-
471
- /**
472
- * Close all active stream servers.
473
- * Useful when the main connection is reset and streams need to be recreated.
474
- */
475
- async closeAllStreams(reason?: string): Promise<void> {
476
- const servers = Array.from(this.nativeRfcServers.values());
477
- this.nativeRfcServers.clear();
478
-
479
- await Promise.allSettled(
480
- servers.map(async (server) => {
481
- try {
482
- await server.close(reason || 'connection reset');
483
- } catch (e) {
484
- this.getLogger().debug('Error closing stream server', e?.message || String(e));
485
- }
486
- })
487
- );
481
+ }
482
+
483
+ async getRfcStream(
484
+ channel: number,
485
+ profile: StreamProfile,
486
+ streamKey: string,
487
+ variant?: NativeVideoStreamVariant,
488
+ ): Promise<RfcServerInfo> {
489
+ // Back-compat wrapper. Prefer getRfcServer(streamKey).
490
+ return await this.ensureRfcServer(streamKey, profile, { channel, variant });
491
+ }
492
+
493
+ async getRfcCompositeStream(
494
+ profile: StreamProfile,
495
+ streamKey: string,
496
+ variantType?: NativeVideoStreamVariant,
497
+ ): Promise<RfcServerInfo> {
498
+ // Back-compat wrapper. Prefer getRfcServer(streamKey).
499
+ // Pass variantType to ensureRfcServer so it can be used when creating the stream client.
500
+ return await this.ensureRfcServer(streamKey, profile, {
501
+ channel: undefined,
502
+ variant: variantType,
503
+ compositeOptions: this.opts.compositeOptions,
504
+ });
505
+ }
506
+
507
+ /**
508
+ * Check if there are any active streams (servers that are listening).
509
+ */
510
+ hasActiveStreams(): boolean {
511
+ for (const server of this.nativeRfcServers.values()) {
512
+ if (server?.server?.listening) {
513
+ return true;
514
+ }
488
515
  }
516
+ return false;
517
+ }
518
+
519
+ /**
520
+ * Close all active stream servers.
521
+ * Useful when the main connection is reset and streams need to be recreated.
522
+ */
523
+ async closeAllStreams(reason?: string): Promise<void> {
524
+ const servers = Array.from(this.nativeRfcServers.values());
525
+ this.nativeRfcServers.clear();
526
+
527
+ await Promise.allSettled(
528
+ servers.map(async (server) => {
529
+ try {
530
+ await server.close(reason || "connection reset");
531
+ } catch (e) {
532
+ this.getLogger().debug(
533
+ "Error closing stream server",
534
+ e?.message || String(e),
535
+ );
536
+ }
537
+ }),
538
+ );
539
+ }
489
540
  }