@apocaliss92/scrypted-reolink-native 0.1.32 → 0.1.35

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);
@@ -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
+ request.logger.log(`[Thumbnail] Download start: fileId=${request.fileId}, queuePosition=${queueLength + 1}`);
351
+
352
+ return new Promise((resolve, reject) => {
353
+ this.thumbnailQueue.push({
354
+ ...request,
355
+ resolve,
356
+ reject,
357
+ });
358
+ this.processThumbnailQueue();
359
+ });
360
+ }
361
+
362
+ /**
363
+ * Process the thumbnail queue sequentially
364
+ */
365
+ private async processThumbnailQueue(): Promise<void> {
366
+ if (this.thumbnailProcessing || this.thumbnailQueue.length === 0) {
367
+ return;
368
+ }
369
+
370
+ this.thumbnailProcessing = true;
371
+
372
+ while (this.thumbnailQueue.length > 0) {
373
+ const request = this.thumbnailQueue.shift()!;
374
+ const logger = request.logger;
375
+
376
+ try {
377
+ const thumbnail = await extractThumbnailFromVideo(request);
378
+ logger.log(`[Thumbnail] OK: fileId=${request.fileId}`);
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
@@ -1,7 +1,7 @@
1
- import type { DeviceInfoResponse, DeviceInputData, EventsResponse, ReolinkBaichuanApi, ReolinkCgiApi, ReolinkSimpleEvent } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
- import sdk, { AdoptDevice, Device, DeviceDiscovery, DeviceProvider, DiscoveredDevice, Reboot, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting, Settings, SettingValue } from "@scrypted/sdk";
1
+ import type { DeviceInfoResponse, EnrichedRecordingFile, EventsResponse, ReolinkBaichuanApi, ReolinkCgiApi, ReolinkSimpleEvent } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
+ import sdk, { AdoptDevice, Device, DeviceDiscovery, DeviceProvider, DiscoveredDevice, Reboot, ScryptedDeviceType, ScryptedInterface, Setting, Settings, SettingValue } from "@scrypted/sdk";
3
3
  import { StorageSettings } from "@scrypted/sdk/storage-settings";
4
- import { BaseBaichuanClass, type BaichuanConnectionConfig, type BaichuanConnectionCallbacks } from "./baichuan-base";
4
+ import { BaseBaichuanClass, type BaichuanConnectionCallbacks, type BaichuanConnectionConfig } from "./baichuan-base";
5
5
  import { ReolinkNativeCamera } from "./camera";
6
6
  import { ReolinkNativeBatteryCamera } from "./camera-battery";
7
7
  import { normalizeUid } from "./connect";
@@ -80,8 +80,8 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
80
80
  }
81
81
 
82
82
  async reboot(): Promise<void> {
83
- const api = await this.ensureClient();
84
- await api.Reboot();
83
+ const api = await this.ensureBaichuanClient();
84
+ await api.reboot();
85
85
  }
86
86
 
87
87
  // BaseBaichuanClass abstract methods implementation
@@ -175,16 +175,41 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
175
175
  }
176
176
 
177
177
  const { ReolinkCgiApi } = await import("@apocaliss92/reolink-baichuan-js");
178
+ const logger = this.getBaichuanLogger();
178
179
  this.nvrApi = new ReolinkCgiApi({
179
180
  host: ipAddress,
180
181
  username,
181
182
  password,
183
+ logger,
182
184
  });
183
185
 
184
186
  await this.nvrApi.login();
185
187
  return this.nvrApi;
186
188
  }
187
189
 
190
+ /**
191
+ * List enriched VOD files (with proper parsing and detection info)
192
+ * This uses the library's enrichVodFile which handles all parsing correctly
193
+ */
194
+ async listEnrichedVodFiles(params: {
195
+ channel: number;
196
+ start: Date;
197
+ end: Date;
198
+ streamType?: "main" | "sub";
199
+ autoSearchByDay?: boolean;
200
+ bypassCache?: boolean;
201
+ }): Promise<Array<EnrichedRecordingFile>> {
202
+ const api = await this.ensureClient();
203
+ return await api.listEnrichedVodFiles({
204
+ channel: params.channel,
205
+ start: params.start,
206
+ end: params.end,
207
+ streamType: params.streamType,
208
+ autoSearchByDay: params.autoSearchByDay,
209
+ bypassCache: params.bypassCache,
210
+ });
211
+ }
212
+
188
213
  private forwardNativeEvent(ev: ReolinkSimpleEvent): void {
189
214
  const logger = this.getBaichuanLogger();
190
215
 
@@ -222,9 +247,7 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
222
247
  case 'doorbell':
223
248
  // Handle doorbell if camera supports it
224
249
  try {
225
- if (typeof (targetCamera as any).handleDoorbellEvent === 'function') {
226
- (targetCamera as any).handleDoorbellEvent();
227
- }
250
+ targetCamera.handleDoorbellEvent();
228
251
  }
229
252
  catch (e) {
230
253
  logger.warn(`Error handling doorbell event for camera channel ${channel}`, e);
@@ -469,33 +492,15 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
469
492
  async syncEntitiesFromRemote() {
470
493
  const logger = this.getBaichuanLogger();
471
494
 
472
- // Ensure both APIs are ready before syncing
473
- const api = await this.ensureClient();
474
- const baichuanApi = await this.ensureBaichuanClient();
475
-
476
- // Wait for Baichuan connection to be fully established
477
- if (baichuanApi?.client) {
478
- // Check if already connected
479
- if (!baichuanApi.client.isSocketConnected()) {
480
- logger.debug('Waiting for Baichuan connection to be established...');
481
- // Wait up to 5 seconds for connection
482
- let attempts = 0;
483
- while (!baichuanApi.client.isSocketConnected() && attempts < 50) {
484
- await new Promise(resolve => setTimeout(resolve, 100));
485
- attempts++;
486
- }
487
- if (!baichuanApi.client.isSocketConnected()) {
488
- logger.warn('Baichuan connection not established after waiting, proceeding anyway');
489
- } else {
490
- logger.debug('Baichuan connection established');
491
- }
492
- }
493
- }
495
+ const cgiApi = await this.ensureClient();
496
+ const { devicesData, channels } = await cgiApi.getDevicesInfo();
494
497
 
495
- const { devicesData, channels } = await api.getDevicesInfo();
498
+ // const api = await this.ensureBaichuanClient();
499
+ // const devicesMap = api.getDevicesInfo();
500
+ // const deviceEntries = Object.entries(devicesMap);
496
501
 
497
502
  if (!channels.length) {
498
- logger.debug(`No channels found, ${JSON.stringify({ devicesData, channels })}`);
503
+ logger.debug(`No channels found, ${JSON.stringify({ channels, devicesData })}`);
499
504
  return;
500
505
  }
501
506
 
@@ -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