@apocaliss92/scrypted-reolink-native 0.1.32 → 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.
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);
@@ -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
@@ -75,7 +75,6 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
75
75
  this.storageSettings.settings.uid.hide = !this.isBattery;
76
76
 
77
77
  await this.ensureBaichuanClient();
78
- await this.updateDeviceInfo();
79
78
  await this.reportDevices();
80
79
  await this.subscribeToEvents();
81
80
  } catch (e) {
@@ -89,27 +88,9 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
89
88
  }
90
89
  }
91
90
 
92
- async updateDeviceInfo(): Promise<void> {
93
- const logger = this.getBaichuanLogger();
94
- try {
95
- const api = await this.ensureBaichuanClient();
96
- const deviceData = await api.getInfo();
97
-
98
- await updateDeviceInfo({
99
- device: this,
100
- deviceData,
101
- ipAddress: this.storageSettings.values.ipAddress,
102
- logger,
103
- });
104
- } catch (e) {
105
- logger.warn('Failed to fetch device info', e);
106
- }
107
- }
108
-
109
91
  getInterfaces(channel: number) {
110
92
  const logger = this.getBaichuanLogger();
111
- const values = this.storageSettings.values as any;
112
- const { capabilities: caps, multifocalInfo } = values;
93
+ const { capabilities: caps, multifocalInfo } = this.storageSettings.values;
113
94
  const channelInfo = (multifocalInfo as DualLensChannelAnalysis).channels.find(c => c.channel === channel);
114
95
 
115
96
  const capabilities: DeviceCapabilities = {
@@ -213,15 +194,6 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
213
194
  }
214
195
  }
215
196
 
216
- async getSettings(): Promise<Setting[]> {
217
- const settings = await this.storageSettings.getSettings();
218
- return settings;
219
- }
220
-
221
- async putSetting(key: string, value: SettingValue): Promise<void> {
222
- return this.storageSettings.putSetting(key, value);
223
- }
224
-
225
197
  async releaseDevice(id: string, nativeId: string) {
226
198
  this.cameraNativeMap.delete(nativeId);
227
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).
@@ -201,7 +202,7 @@ export class StreamManager {
201
202
  }
202
203
 
203
204
  private getLogger() {
204
- return this.opts.getLogger() ;
205
+ return this.opts.getLogger();
205
206
  }
206
207
 
207
208
  private async ensureRfcServer(
@@ -213,11 +214,27 @@ export class StreamManager {
213
214
  compositeOptions?: CompositeStreamPipOptions;
214
215
  },
215
216
  ): Promise<RfcServerInfo> {
217
+ // Check for existing promise first to prevent duplicate server creation
216
218
  const existingCreate = this.nativeRfcServerCreatePromises.get(streamKey);
217
219
  if (existingCreate) {
218
220
  return await existingCreate;
219
221
  }
220
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
+
221
238
  const createPromise = (async () => {
222
239
  const cached = this.nativeRfcServers.get(streamKey);
223
240
  if (cached?.server?.listening) {
@@ -233,8 +250,8 @@ export class StreamManager {
233
250
  port: cached.port,
234
251
  sdp: cached.sdp,
235
252
  audio: cached.audio,
236
- username: (cached as any).username || this.opts.credentials.username,
237
- 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,
238
255
  };
239
256
  }
240
257
  }
@@ -249,7 +266,7 @@ export class StreamManager {
249
266
  this.nativeRfcServers.delete(streamKey);
250
267
  }
251
268
 
252
- const api = await this.opts.createStreamClient();
269
+ const api = await this.opts.createStreamClient(profile);
253
270
  const { createRfc4571TcpServer } = await import('@apocaliss92/reolink-baichuan-js');
254
271
 
255
272
  // Use the same credentials as the main connection
@@ -281,8 +298,8 @@ export class StreamManager {
281
298
  port: created.port,
282
299
  sdp: created.sdp,
283
300
  audio: created.audio,
284
- username: (created as any).username || this.opts.credentials.username,
285
- 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,
286
303
  };
287
304
  })();
288
305