@apocaliss92/scrypted-reolink-native 0.1.31 → 0.1.33

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.
@@ -8,6 +8,8 @@ export enum DebugLogOption {
8
8
  General = 'general',
9
9
  /** RTSP proxy/server debug logs */
10
10
  DebugRtsp = 'debugRtsp',
11
+ /** Low-level tracing for recording-related commands */
12
+ TraceRecordings = 'traceRecordings',
11
13
  /** Stream command tracing */
12
14
  TraceStream = 'traceStream',
13
15
  /** Talkback tracing */
@@ -27,6 +29,7 @@ export function mapDebugLogToApiOption(option: DebugLogOption): keyof DebugOptio
27
29
  const mapping: Record<DebugLogOption, keyof DebugOptions | null> = {
28
30
  [DebugLogOption.General]: 'general',
29
31
  [DebugLogOption.DebugRtsp]: 'debugRtsp',
32
+ [DebugLogOption.TraceRecordings]: 'traceRecordings',
30
33
  [DebugLogOption.TraceStream]: 'traceStream',
31
34
  [DebugLogOption.TraceTalk]: 'traceTalk',
32
35
  [DebugLogOption.TraceEvents]: 'traceEvents',
@@ -77,6 +80,7 @@ export function getApiRelevantDebugLogs(debugLogs: string[]): string[] {
77
80
  export const DebugLogDisplayNames: Record<DebugLogOption, string> = {
78
81
  [DebugLogOption.General]: 'General',
79
82
  [DebugLogOption.DebugRtsp]: 'RTSP',
83
+ [DebugLogOption.TraceRecordings]: 'Trace recordings',
80
84
  [DebugLogOption.TraceStream]: 'Trace stream',
81
85
  [DebugLogOption.TraceTalk]: 'Trace talk',
82
86
  [DebugLogOption.TraceEvents]: 'Trace events XML',
package/src/main.ts CHANGED
@@ -1,16 +1,36 @@
1
- import sdk, { DeviceCreator, DeviceCreatorSettings, DeviceProvider, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedNativeId, Setting } from "@scrypted/sdk";
1
+ import sdk, { DeviceCreator, DeviceCreatorSettings, DeviceProvider, HttpRequest, HttpResponse, MediaObject, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, ScryptedNativeId, Setting, VideoClips } from "@scrypted/sdk";
2
2
  import { BaseBaichuanClass } from "./baichuan-base";
3
3
  import { ReolinkNativeCamera } from "./camera";
4
4
  import { ReolinkNativeBatteryCamera } from "./camera-battery";
5
5
  import { CommonCameraMixin } from "./common";
6
- import { createBaichuanApi } from "./connect";
7
- import { ReolinkNativeNvrDevice } from "./nvr";
8
- import { batteryCameraSuffix, batteryMultifocalSuffix, cameraSuffix, getDeviceInterfaces, multifocalSuffix, nvrSuffix } from "./utils";
9
6
  import { ReolinkNativeMultiFocalDevice } from "./multiFocal";
7
+ import { ReolinkNativeNvrDevice } from "./nvr";
8
+ import { batteryCameraSuffix, batteryMultifocalSuffix, cameraSuffix, extractThumbnailFromVideo, getDeviceInterfaces, handleVideoClipRequest, multifocalSuffix, nvrSuffix } from "./utils";
9
+
10
+ interface ThumbnailRequest {
11
+ deviceId: string;
12
+ fileId: string;
13
+ rtmpUrl?: string;
14
+ filePath?: string;
15
+ logger: Console;
16
+ resolve: (mo: MediaObject) => void;
17
+ reject: (error: Error) => void;
18
+ }
19
+
20
+ interface ThumbnailRequestInput {
21
+ deviceId: string;
22
+ fileId: string;
23
+ rtmpUrl?: string;
24
+ filePath?: string;
25
+ logger: Console;
26
+ }
10
27
 
11
28
  class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
12
29
  devices = new Map<string, BaseBaichuanClass>();
30
+ mixinsMap = new Map<string, CommonCameraMixin>();
13
31
  nvrDeviceId: string;
32
+ private thumbnailQueue: ThumbnailRequest[] = [];
33
+ private thumbnailProcessing = false;
14
34
 
15
35
  constructor(nativeId: string) {
16
36
  super(nativeId);
@@ -96,7 +116,7 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
96
116
  device.storageSettings.values.ipAddress = ipAddress;
97
117
  device.storageSettings.values.username = username;
98
118
  device.storageSettings.values.password = password;
99
- device.storageSettings.values.uid = detection.uid || '';
119
+ device.storageSettings.values.uid = uid;
100
120
  device.storageSettings.values.capabilities = capabilities;
101
121
 
102
122
  return nativeId;
@@ -177,7 +197,7 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
177
197
  device.storageSettings.values.rtspChannel = rtspChannel;
178
198
  device.storageSettings.values.ipAddress = ipAddress;
179
199
  device.storageSettings.values.capabilities = capabilities;
180
- device.storageSettings.values.uid = detection.uid;
200
+ device.storageSettings.values.uid = uid;
181
201
 
182
202
  return nativeId;
183
203
  }
@@ -236,6 +256,140 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
236
256
  return new ReolinkNativeCamera(nativeId, this);
237
257
  }
238
258
  }
259
+
260
+ async onRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
261
+ const logger = this.console;
262
+ const url = new URL(`http://localhost${request.url}`);
263
+
264
+ try {
265
+ // Parse webhook path: /.../webhook/{type}/{deviceId}/{fileId}
266
+ // The path may include prefix like /endpoint/@apocaliss92/scrypted-reolink-native/public/webhook/...
267
+ const pathParts = url.pathname.split('/').filter(p => p);
268
+
269
+ // Find the index of 'webhook' in the path
270
+ const webhookIndex = pathParts.indexOf('webhook');
271
+ if (webhookIndex === -1 || pathParts.length < webhookIndex + 4) {
272
+ response.send('Invalid webhook path', { code: 404 });
273
+ return;
274
+ }
275
+
276
+ // Extract type, deviceId, and fileId after 'webhook'
277
+ const type = pathParts[webhookIndex + 1];
278
+ const encodedDeviceId = pathParts[webhookIndex + 2];
279
+ // fileId may contain slashes, so join all remaining parts
280
+ const encodedFileId = pathParts.slice(webhookIndex + 3).join('/');
281
+ const deviceId = decodeURIComponent(encodedDeviceId);
282
+ let fileId = decodeURIComponent(encodedFileId);
283
+
284
+ // Restore leading slash if the original fileId had it (we removed it during encoding)
285
+ // The API expects fileId with leading slash for absolute paths
286
+ if (!fileId.startsWith('/') && !fileId.startsWith('http')) {
287
+ // If it looks like an absolute path (starts with common path prefixes), add slash
288
+ if (fileId.startsWith('mnt/') || fileId.startsWith('var/') || fileId.startsWith('tmp/')) {
289
+ fileId = `/${fileId}`;
290
+ }
291
+ }
292
+
293
+ // logger.log(`Webhook request: type=${type}, deviceId=${deviceId}, fileId=${fileId}`);
294
+
295
+ // Get the device
296
+ const device = this.mixinsMap.get(deviceId);
297
+ if (!device) {
298
+ response.send('Device not found', { code: 404 });
299
+ return;
300
+ }
301
+
302
+ if (type === 'video') {
303
+ await handleVideoClipRequest({
304
+ device,
305
+ deviceId,
306
+ fileId,
307
+ request,
308
+ response,
309
+ logger,
310
+ });
311
+ return;
312
+ } else if (type === 'thumbnail') {
313
+ // Get thumbnail MediaObject
314
+ const mo = await device.getVideoClipThumbnail(fileId);
315
+
316
+ // Convert to buffer
317
+ const buffer = await sdk.mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
318
+
319
+ // Send image
320
+ response.send(buffer, {
321
+ code: 200,
322
+ headers: {
323
+ 'Content-Type': 'image/jpeg',
324
+ 'Cache-Control': 'max-age=31536000',
325
+ },
326
+ });
327
+ return;
328
+ } else {
329
+ response.send('Invalid webhook type', { code: 404 });
330
+ return;
331
+ }
332
+ } catch (e: any) {
333
+ logger.error('Error in onRequest', e);
334
+ response.send(`Error: ${e.message}`, {
335
+ code: 500,
336
+ });
337
+ return;
338
+ }
339
+ }
340
+
341
+ onPush(request: HttpRequest): Promise<void> {
342
+ return this.onRequest(request, undefined);
343
+ }
344
+
345
+ /**
346
+ * Add a thumbnail generation request to the queue
347
+ */
348
+ async generateThumbnail(request: ThumbnailRequestInput): Promise<MediaObject> {
349
+ const queueLength = this.thumbnailQueue.length;
350
+ const isProcessing = this.thumbnailProcessing;
351
+ request.logger.log(`[Thumbnail] Adding to queue: fileId=${request.fileId}, queueLength=${queueLength}, processing=${isProcessing}`);
352
+
353
+ return new Promise((resolve, reject) => {
354
+ this.thumbnailQueue.push({
355
+ ...request,
356
+ resolve,
357
+ reject,
358
+ });
359
+ this.processThumbnailQueue();
360
+ });
361
+ }
362
+
363
+ /**
364
+ * Process the thumbnail queue sequentially
365
+ */
366
+ private async processThumbnailQueue(): Promise<void> {
367
+ if (this.thumbnailProcessing || this.thumbnailQueue.length === 0) {
368
+ return;
369
+ }
370
+
371
+ this.thumbnailProcessing = true;
372
+
373
+ while (this.thumbnailQueue.length > 0) {
374
+ const request = this.thumbnailQueue.shift()!;
375
+ const logger = request.logger;
376
+
377
+ try {
378
+ const thumbnail = await extractThumbnailFromVideo(request);
379
+ request.resolve(thumbnail);
380
+ } catch (error) {
381
+ logger.error(`[Thumbnail] Error: fileId=${request.fileId}`, error);
382
+ request.reject(error instanceof Error ? error : new Error(String(error)));
383
+ }
384
+
385
+ // Add 2 second delay between thumbnails (except after the last one)
386
+ if (this.thumbnailQueue.length > 0) {
387
+ await new Promise(resolve => setTimeout(resolve, 2000));
388
+ }
389
+ }
390
+
391
+ this.thumbnailProcessing = false;
392
+ }
239
393
  }
240
394
 
241
395
  export default ReolinkNativePlugin;
package/src/multiFocal.ts CHANGED
@@ -1,12 +1,9 @@
1
- import type { DeviceCapabilities, DualLensChannelAnalysis, ReolinkSimpleEvent, ReolinkSupportedStream } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
- import sdk, { Device, DeviceProvider, MediaObject, Reboot, RequestMediaStreamOptions, ScryptedDeviceType, Setting, Settings, SettingValue } from "@scrypted/sdk";
3
- import { type BaichuanConnectionCallbacks } from "./baichuan-base";
1
+ import type { DeviceCapabilities, DualLensChannelAnalysis, ReolinkSimpleEvent } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
+ import sdk, { Device, DeviceProvider, Reboot, ScryptedDeviceType, Setting, Settings, SettingValue } from "@scrypted/sdk";
4
3
  import { ReolinkNativeCamera } from "./camera";
5
4
  import { ReolinkNativeBatteryCamera } from "./camera-battery";
6
5
  import { CameraType, CommonCameraMixin } from "./common";
7
6
  import ReolinkNativePlugin from "./main";
8
- import { StreamManager } from "./stream-utils";
9
- import type { UrlMediaStreamOptions } from "../../scrypted/plugins/rtsp/src/rtsp";
10
7
  import { batteryCameraSuffix, cameraSuffix, getDeviceInterfaces, updateDeviceInfo } from "./utils";
11
8
 
12
9
  export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements Settings, DeviceProvider, Reboot {
@@ -36,28 +33,6 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
36
33
  }
37
34
  }
38
35
 
39
- protected getConnectionCallbacks(): BaichuanConnectionCallbacks {
40
- return {
41
- onError: undefined, // Use default error handling
42
- onClose: async () => {
43
- // Reinit after cleanup
44
- await this.reinit();
45
- if (!this.isBattery) {
46
- setTimeout(async () => {
47
- try {
48
- await this.subscribeToEvents();
49
- } catch (e) {
50
- const logger = this.getBaichuanLogger();
51
- logger.warn('Failed to resubscribe to events after reconnection', e);
52
- }
53
- }, 1000);
54
- }
55
- },
56
- onSimpleEvent: (ev) => this.forwardNativeEvent(ev),
57
- getEventSubscriptionEnabled: () => true,
58
- };
59
- }
60
-
61
36
  protected async onBeforeCleanup(): Promise<void> {
62
37
  await this.unsubscribeFromAllEvents();
63
38
  }
@@ -100,7 +75,6 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
100
75
  this.storageSettings.settings.uid.hide = !this.isBattery;
101
76
 
102
77
  await this.ensureBaichuanClient();
103
- await this.updateDeviceInfo();
104
78
  await this.reportDevices();
105
79
  await this.subscribeToEvents();
106
80
  } catch (e) {
@@ -114,27 +88,9 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
114
88
  }
115
89
  }
116
90
 
117
- async updateDeviceInfo(): Promise<void> {
118
- const logger = this.getBaichuanLogger();
119
- try {
120
- const api = await this.ensureBaichuanClient();
121
- const deviceData = await api.getInfo();
122
-
123
- await updateDeviceInfo({
124
- device: this,
125
- deviceData,
126
- ipAddress: this.storageSettings.values.ipAddress,
127
- logger,
128
- });
129
- } catch (e) {
130
- logger.warn('Failed to fetch device info', e);
131
- }
132
- }
133
-
134
91
  getInterfaces(channel: number) {
135
92
  const logger = this.getBaichuanLogger();
136
- const values = this.storageSettings.values as any;
137
- const { capabilities: caps, multifocalInfo } = values;
93
+ const { capabilities: caps, multifocalInfo } = this.storageSettings.values;
138
94
  const channelInfo = (multifocalInfo as DualLensChannelAnalysis).channels.find(c => c.channel === channel);
139
95
 
140
96
  const capabilities: DeviceCapabilities = {
@@ -220,53 +176,6 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
220
176
  }
221
177
 
222
178
  await super.reportDevices();
223
-
224
- // Initialize StreamManager with composite options for multifocal device
225
- // Use saved settings or defaults
226
- const values = this.storageSettings.values as any;
227
- const pipPosition = (values.pipPosition || 'bottom-right') as any;
228
- const pipSize = values.pipSize ?? 0.25;
229
- const pipMargin = values.pipMargin ?? 10;
230
- const widerChannel = values.widerChannel ?? 0;
231
- const teleChannel = values.teleChannel ?? 1;
232
-
233
- if (!this.streamManager) {
234
- this.streamManager = new StreamManager({
235
- createStreamClient: () => this.createStreamClient(),
236
- getLogger: () => logger,
237
- credentials: {
238
- username,
239
- password
240
- },
241
- sharedConnection: this.isBattery,
242
- compositeOptions: {
243
- widerChannel,
244
- teleChannel,
245
- pipPosition: pipPosition as any,
246
- pipSize,
247
- pipMargin,
248
- },
249
- });
250
- } else {
251
- // Recreate StreamManager with new settings if they changed
252
- // StreamManager doesn't expose opts, so we need to recreate it
253
- this.streamManager = new StreamManager({
254
- createStreamClient: () => this.createStreamClient(),
255
- getLogger: () => logger,
256
- credentials: {
257
- username,
258
- password
259
- },
260
- sharedConnection: this.isBattery,
261
- compositeOptions: {
262
- widerChannel,
263
- teleChannel,
264
- pipPosition: pipPosition as any,
265
- pipSize,
266
- pipMargin,
267
- },
268
- });
269
- }
270
179
  }
271
180
 
272
181
  async getDevice(nativeId: string) {
@@ -285,15 +194,6 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
285
194
  }
286
195
  }
287
196
 
288
- async getSettings(): Promise<Setting[]> {
289
- const settings = await this.storageSettings.getSettings();
290
- return settings;
291
- }
292
-
293
- async putSetting(key: string, value: SettingValue): Promise<void> {
294
- return this.storageSettings.putSetting(key, value);
295
- }
296
-
297
197
  async releaseDevice(id: string, nativeId: string) {
298
198
  this.cameraNativeMap.delete(nativeId);
299
199
  super.releaseDevice(id, nativeId);
package/src/nvr.ts CHANGED
@@ -222,9 +222,7 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
222
222
  case 'doorbell':
223
223
  // Handle doorbell if camera supports it
224
224
  try {
225
- if (typeof (targetCamera as any).handleDoorbellEvent === 'function') {
226
- (targetCamera as any).handleDoorbellEvent();
227
- }
225
+ targetCamera.handleDoorbellEvent();
228
226
  }
229
227
  catch (e) {
230
228
  logger.warn(`Error handling doorbell event for camera channel ${channel}`, e);
@@ -17,8 +17,9 @@ export interface StreamManagerOptions {
17
17
  /**
18
18
  * Creates a dedicated Baichuan session for streaming.
19
19
  * Required to support concurrent main+ext streams on firmwares where streamType overlaps.
20
+ * @param profile The stream profile (main, sub, ext) - used to determine if a new client is needed.
20
21
  */
21
- createStreamClient: () => Promise<ReolinkBaichuanApi>;
22
+ createStreamClient: (profile?: StreamProfile) => Promise<ReolinkBaichuanApi>;
22
23
  getLogger: () => Console;
23
24
  /**
24
25
  * Credentials to include in the TCP stream (username, password).
@@ -113,11 +114,15 @@ export async function createRfc4571MediaObjectFromStreamManager(params: {
113
114
  mso.audio.sampleRate = audio.sampleRate;
114
115
  mso.audio.channels = audio.channels;
115
116
  }
116
- const url = new URL(host);
117
+
118
+ const url = new URL(`tcp://${host}`);
117
119
  url.port = port.toString();
118
- url.protocol = 'tcp';
119
- url.username = username;
120
- url.password = password;
120
+ if (username) {
121
+ url.username = username;
122
+ }
123
+ if (password) {
124
+ url.password = password;
125
+ }
121
126
 
122
127
  const rfc = {
123
128
  url,
@@ -197,26 +202,46 @@ export class StreamManager {
197
202
  }
198
203
 
199
204
  private getLogger() {
200
- return this.opts.getLogger() ;
205
+ return this.opts.getLogger();
201
206
  }
202
207
 
203
- private async ensureNativeRfcServer(
208
+ private async ensureRfcServer(
204
209
  streamKey: string,
205
- channel: number,
206
210
  profile: StreamProfile,
207
- expectedVideoType?: 'H264' | 'H265',
211
+ expectedVideoType: 'H264' | 'H265' | undefined,
212
+ options: {
213
+ channel?: number;
214
+ compositeOptions?: CompositeStreamPipOptions;
215
+ },
208
216
  ): Promise<RfcServerInfo> {
217
+ // Check for existing promise first to prevent duplicate server creation
209
218
  const existingCreate = this.nativeRfcServerCreatePromises.get(streamKey);
210
219
  if (existingCreate) {
211
220
  return await existingCreate;
212
221
  }
213
222
 
223
+ // Double-check: if server already exists and is listening, return it immediately
224
+ const existingServer = this.nativeRfcServers.get(streamKey);
225
+ if (existingServer?.server?.listening) {
226
+ if (!expectedVideoType || existingServer.videoType === expectedVideoType) {
227
+ return {
228
+ host: existingServer.host,
229
+ port: existingServer.port,
230
+ sdp: existingServer.sdp,
231
+ audio: existingServer.audio,
232
+ username: existingServer.username || this.opts.credentials.username,
233
+ password: existingServer.password || this.opts.credentials.password,
234
+ };
235
+ }
236
+ }
237
+
214
238
  const createPromise = (async () => {
215
239
  const cached = this.nativeRfcServers.get(streamKey);
216
240
  if (cached?.server?.listening) {
217
241
  if (expectedVideoType && cached.videoType !== expectedVideoType) {
242
+ const kind = options.channel === undefined ? 'composite' : 'native';
218
243
  this.getLogger().warn(
219
- `Native RFC cache codec mismatch for ${streamKey}: cached=${cached.videoType} expected=${expectedVideoType}; recreating server.`,
244
+ `Native RFC ${kind} cache codec mismatch for ${streamKey}: cached=${cached.videoType} expected=${expectedVideoType}; recreating server.`,
220
245
  );
221
246
  }
222
247
  else {
@@ -225,8 +250,8 @@ export class StreamManager {
225
250
  port: cached.port,
226
251
  sdp: cached.sdp,
227
252
  audio: cached.audio,
228
- username: (cached as any).username || this.opts.credentials.username,
229
- password: (cached as any).password || this.opts.credentials.password,
253
+ username: cached.username || this.opts.credentials.username,
254
+ password: cached.password || this.opts.credentials.password,
230
255
  };
231
256
  }
232
257
  }
@@ -241,7 +266,7 @@ export class StreamManager {
241
266
  this.nativeRfcServers.delete(streamKey);
242
267
  }
243
268
 
244
- const api = await this.opts.createStreamClient();
269
+ const api = await this.opts.createStreamClient(profile);
245
270
  const { createRfc4571TcpServer } = await import('@apocaliss92/reolink-baichuan-js');
246
271
 
247
272
  // Use the same credentials as the main connection
@@ -252,13 +277,14 @@ export class StreamManager {
252
277
 
253
278
  const created = await createRfc4571TcpServer({
254
279
  api,
255
- channel,
280
+ channel: options.channel,
256
281
  profile,
257
282
  logger: this.getLogger(),
258
283
  expectedVideoType: expectedVideoType as VideoType | undefined,
259
284
  closeApiOnTeardown,
260
285
  username,
261
286
  password,
287
+ ...(options.compositeOptions ? { compositeOptions: options.compositeOptions } : {}),
262
288
  });
263
289
 
264
290
  this.nativeRfcServers.set(streamKey, created);
@@ -272,8 +298,8 @@ export class StreamManager {
272
298
  port: created.port,
273
299
  sdp: created.sdp,
274
300
  audio: created.audio,
275
- username: (created as any).username || this.opts.credentials.username,
276
- password: (created as any).password || this.opts.credentials.password,
301
+ username: created.username || this.opts.credentials.username,
302
+ password: created.password || this.opts.credentials.password,
277
303
  };
278
304
  })();
279
305
 
@@ -292,7 +318,9 @@ export class StreamManager {
292
318
  streamKey: string,
293
319
  expectedVideoType?: 'H264' | 'H265',
294
320
  ): Promise<RfcServerInfo> {
295
- return await this.ensureNativeRfcServer(streamKey, channel, profile, expectedVideoType);
321
+ return await this.ensureRfcServer(streamKey, profile, expectedVideoType, {
322
+ channel,
323
+ });
296
324
  }
297
325
 
298
326
  async getRfcCompositeStream(
@@ -300,85 +328,10 @@ export class StreamManager {
300
328
  streamKey: string,
301
329
  expectedVideoType?: 'H264' | 'H265',
302
330
  ): Promise<RfcServerInfo> {
303
- const existingCreate = this.nativeRfcServerCreatePromises.get(streamKey);
304
- if (existingCreate) {
305
- return await existingCreate;
306
- }
307
-
308
- const createPromise = (async () => {
309
- const cached = this.nativeRfcServers.get(streamKey);
310
- if (cached?.server?.listening) {
311
- if (expectedVideoType && cached.videoType !== expectedVideoType) {
312
- this.getLogger().warn(
313
- `Native RFC composite cache codec mismatch for ${streamKey}: cached=${cached.videoType} expected=${expectedVideoType}; recreating server.`,
314
- );
315
- }
316
- else {
317
- return {
318
- host: cached.host,
319
- port: cached.port,
320
- sdp: cached.sdp,
321
- audio: cached.audio,
322
- username: (cached as any).username || this.opts.credentials.username,
323
- password: (cached as any).password || this.opts.credentials.password,
324
- };
325
- }
326
- }
327
-
328
- if (cached) {
329
- try {
330
- await cached.close('recreate');
331
- }
332
- catch {
333
- // ignore
334
- }
335
- this.nativeRfcServers.delete(streamKey);
336
- }
337
-
338
- const api = await this.opts.createStreamClient();
339
- const { createRfc4571TcpServer } = await import('@apocaliss92/reolink-baichuan-js');
340
-
341
- // Use the same credentials as the main connection
342
- const { username, password } = this.opts.credentials;
343
-
344
- // If connection is shared, don't close it when stream teardown happens
345
- const closeApiOnTeardown = !(this.opts.sharedConnection ?? false);
346
-
347
- const created = await createRfc4571TcpServer({
348
- api,
349
- channel: undefined, // Undefined channel indicates composite stream
350
- profile,
351
- logger: this.getLogger(),
352
- expectedVideoType: expectedVideoType as VideoType | undefined,
353
- closeApiOnTeardown,
354
- username,
355
- password,
356
- compositeOptions: this.opts.compositeOptions,
357
- });
358
-
359
- this.nativeRfcServers.set(streamKey, created);
360
- created.server.once('close', () => {
361
- const current = this.nativeRfcServers.get(streamKey);
362
- if (current?.server === created.server) this.nativeRfcServers.delete(streamKey);
363
- });
364
-
365
- return {
366
- host: created.host,
367
- port: created.port,
368
- sdp: created.sdp,
369
- audio: created.audio,
370
- username: (created as any).username || this.opts.credentials.username,
371
- password: (created as any).password || this.opts.credentials.password,
372
- };
373
- })();
374
-
375
- this.nativeRfcServerCreatePromises.set(streamKey, createPromise);
376
- try {
377
- return await createPromise;
378
- }
379
- finally {
380
- this.nativeRfcServerCreatePromises.delete(streamKey);
381
- }
331
+ return await this.ensureRfcServer(streamKey, profile, expectedVideoType, {
332
+ channel: undefined, // Undefined channel indicates composite stream
333
+ compositeOptions: this.opts.compositeOptions,
334
+ });
382
335
  }
383
336
 
384
337
  /**