@apocaliss92/scrypted-reolink-native 0.3.17 → 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.
package/src/main.ts CHANGED
@@ -1,436 +1,405 @@
1
- import sdk, { DeviceCreator, DeviceCreatorSettings, DeviceProvider, HttpRequest, HttpResponse, MediaObject, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, ScryptedNativeId, Setting, VideoClips } from "@scrypted/sdk";
1
+ import sdk, {
2
+ DeviceCreator,
3
+ DeviceCreatorSettings,
4
+ DeviceProvider,
5
+ HttpRequest,
6
+ HttpResponse,
7
+ MediaObject,
8
+ ScryptedDeviceBase,
9
+ ScryptedDeviceType,
10
+ ScryptedInterface,
11
+ ScryptedMimeTypes,
12
+ ScryptedNativeId,
13
+ Setting,
14
+ VideoClips,
15
+ } from "@scrypted/sdk";
2
16
  import { BaseBaichuanClass } from "./baichuan-base";
3
17
  import { ReolinkNativeMultiFocalDevice } from "./multiFocal";
4
18
  import { ReolinkNativeNvrDevice } from "./nvr";
5
- import { batteryCameraSuffix, batteryMultifocalSuffix, cameraSuffix, extractThumbnailFromVideo, getDeviceInterfaces, handleVideoClipRequest, multifocalSuffix, nvrSuffix } from "./utils";
19
+ import {
20
+ batteryCameraSuffix,
21
+ batteryMultifocalSuffix,
22
+ cameraSuffix,
23
+ getDeviceInterfaces,
24
+ handleVideoClipRequest,
25
+ multifocalSuffix,
26
+ nvrSuffix,
27
+ } from "./utils";
6
28
  import { randomBytes } from "crypto";
7
29
  import { ReolinkCamera } from "./camera";
8
- import type { AutoDetectMode } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
9
-
10
- interface ThumbnailRequest {
11
- deviceId: string;
12
- fileId: string;
13
- rtmpUrl?: string;
14
- filePath?: string;
15
- logger?: Console;
16
- device?: ReolinkCamera;
17
- resolve: (mo: MediaObject) => void;
18
- reject: (error: Error) => void;
19
- }
20
-
21
- interface ThumbnailRequestInput {
22
- deviceId: string;
23
- fileId: string;
24
- rtmpUrl?: string;
25
- filePath?: string;
26
- logger?: Console;
27
- device?: ReolinkCamera;
28
- }
29
-
30
- class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
31
- devices = new Map<string, BaseBaichuanClass>();
32
- camerasMap = new Map<string, ReolinkCamera>();
33
- nvrDeviceId: string;
34
- private thumbnailQueue: ThumbnailRequest[] = [];
35
- private thumbnailProcessing = false;
36
- private thumbnailPendingRequests = new Map<string, Promise<MediaObject>>();
37
-
38
- constructor(nativeId: string) {
39
- super(nativeId);
40
-
41
- const nvrDevice = sdk.systemManager.getDeviceByName('Scrypted NVR');
42
- this.nvrDeviceId = nvrDevice?.id;
30
+ import type { AutoDetectMode } from "@apocaliss92/reolink-baichuan-js" with {
31
+ "resolution-mode": "import",
32
+ };
33
+
34
+ class ReolinkNativePlugin
35
+ extends ScryptedDeviceBase
36
+ implements DeviceProvider, DeviceCreator
37
+ {
38
+ devices = new Map<string, BaseBaichuanClass>();
39
+ camerasMap = new Map<string, ReolinkCamera>();
40
+ nvrDeviceId: string;
41
+
42
+ constructor(nativeId: string) {
43
+ super(nativeId);
44
+
45
+ const nvrDevice = sdk.systemManager.getDeviceByName("Scrypted NVR");
46
+ this.nvrDeviceId = nvrDevice?.id;
47
+ }
48
+
49
+ getScryptedDeviceCreator(): string {
50
+ return "Reolink Native camera";
51
+ }
52
+
53
+ async getDevice(nativeId: ScryptedNativeId): Promise<BaseBaichuanClass> {
54
+ if (this.devices.has(nativeId)) {
55
+ return this.devices.get(nativeId)!;
43
56
  }
44
57
 
45
- getScryptedDeviceCreator(): string {
46
- return 'Reolink Native camera';
58
+ const newCamera = this.createCamera(nativeId);
59
+ this.devices.set(nativeId, newCamera);
60
+ return newCamera;
61
+ }
62
+
63
+ async createDevice(
64
+ settings: DeviceCreatorSettings,
65
+ nativeId?: string,
66
+ ): Promise<string> {
67
+ const ipAddress = settings.ip?.toString();
68
+ const username = settings.username?.toString();
69
+ const password = settings.password?.toString();
70
+ const uid = settings.uid?.toString();
71
+
72
+ if (!ipAddress || !username || !password) {
73
+ throw new Error("IP address, username, and password are required");
47
74
  }
48
75
 
49
- async getDevice(nativeId: ScryptedNativeId): Promise<BaseBaichuanClass> {
50
- if (this.devices.has(nativeId)) {
51
- return this.devices.get(nativeId)!;
52
- }
53
-
54
- const newCamera = this.createCamera(nativeId);
55
- this.devices.set(nativeId, newCamera);
56
- return newCamera;
76
+ const deviceTypeSetting = settings.deviceType?.toString() || "Auto";
77
+ const forceType =
78
+ deviceTypeSetting === "Auto"
79
+ ? undefined
80
+ : deviceTypeSetting.toLowerCase();
81
+
82
+ this.console.log(
83
+ `[AutoDetect] Starting device type detection for ${ipAddress}...${forceType ? ` (forcing type: ${forceType})` : ""}`,
84
+ );
85
+ const { autoDetectDeviceType } =
86
+ await import("@apocaliss92/reolink-baichuan-js");
87
+ // 'Auto', 'NVR', 'Battery Camera', 'Regular Camera
88
+ const mode: AutoDetectMode =
89
+ forceType === "Auto"
90
+ ? "auto"
91
+ : forceType === "Battery Camera"
92
+ ? "udp"
93
+ : forceType === "Regular Camera"
94
+ ? "tcp"
95
+ : forceType === "NVR"
96
+ ? "tcp"
97
+ : "auto";
98
+
99
+ const maxRetries = mode === "auto" ? 2 : 10;
100
+
101
+ const detection = await autoDetectDeviceType({
102
+ host: ipAddress,
103
+ username,
104
+ password,
105
+ uid,
106
+ logger: this.console,
107
+ mode,
108
+ maxRetries,
109
+ });
110
+ const { ip, mac } = detection.hostNetworkInfo ?? {};
111
+
112
+ this.console.log(
113
+ `[AutoDetect] Detected device type: ${detection.type} (transport: ${detection.transport}). Device info: ${JSON.stringify(detection.deviceInfo)}`,
114
+ );
115
+
116
+ // Use the API that was successfully used for detection
117
+ const detectedApi = detection.api;
118
+ const deviceInfo = detection.deviceInfo || {};
119
+ const name = deviceInfo?.name || `Reolink ${detection.type}`;
120
+ const identifier =
121
+ uid || mac || ip || name || randomBytes(4).toString("hex");
122
+
123
+ // Handle multi-focal device case
124
+ if (detection.type === "multifocal") {
125
+ const isBattery = detection.transport === "udp";
126
+ nativeId = `${identifier}${isBattery ? batteryMultifocalSuffix : multifocalSuffix}`;
127
+
128
+ settings.newCamera ||= name;
129
+
130
+ const { capabilities, objects, presets } =
131
+ await detectedApi.getDeviceCapabilities();
132
+
133
+ const { interfaces } = getDeviceInterfaces({
134
+ capabilities,
135
+ logger: this.console,
136
+ });
137
+
138
+ await sdk.deviceManager.onDeviceDiscovered({
139
+ nativeId,
140
+ name,
141
+ interfaces,
142
+ type: ScryptedDeviceType.DeviceProvider,
143
+ providerNativeId: this.nativeId,
144
+ });
145
+
146
+ const device = await this.getDevice(nativeId);
147
+ if (!(device instanceof ReolinkNativeMultiFocalDevice)) {
148
+ throw new Error("Expected multi-focal device but got different type");
149
+ }
150
+ device.classes = objects;
151
+ device.presets = presets;
152
+ device.storageSettings.values.ipAddress = ipAddress;
153
+ device.storageSettings.values.username = username;
154
+ device.storageSettings.values.password = password;
155
+ device.storageSettings.values.uid = uid;
156
+ device.cachedCapabilities = capabilities;
157
+
158
+ return nativeId;
57
159
  }
58
160
 
59
- async createDevice(settings: DeviceCreatorSettings, nativeId?: string): Promise<string> {
60
- const ipAddress = settings.ip?.toString();
61
- const username = settings.username?.toString();
62
- const password = settings.password?.toString();
63
- const uid = settings.uid?.toString();
64
-
65
- if (!ipAddress || !username || !password) {
66
- throw new Error('IP address, username, and password are required');
67
- }
68
-
69
- const deviceTypeSetting = settings.deviceType?.toString() || 'Auto';
70
- const forceType = deviceTypeSetting === 'Auto' ? undefined : deviceTypeSetting.toLowerCase();
71
-
72
- this.console.log(`[AutoDetect] Starting device type detection for ${ipAddress}...${forceType ? ` (forcing type: ${forceType})` : ''}`);
73
- const { autoDetectDeviceType } = await import("@apocaliss92/reolink-baichuan-js");
74
- // 'Auto', 'NVR', 'Battery Camera', 'Regular Camera
75
- const mode: AutoDetectMode = forceType === 'Auto' ? 'auto' :
76
- forceType === 'Battery Camera' ? 'udp' :
77
- forceType === 'Regular Camera' ? 'tcp' :
78
- forceType === 'NVR' ? 'tcp' :
79
- 'auto';
80
-
81
- const maxRetries = mode === 'auto' ? 2 : 10;
82
-
83
- const detection = await autoDetectDeviceType(
84
- {
85
- host: ipAddress,
86
- username,
87
- password,
88
- uid,
89
- logger: this.console,
90
- mode,
91
- maxRetries,
92
- },
93
- );
94
- const { ip, mac } = detection.hostNetworkInfo ?? {}
95
-
96
- this.console.log(`[AutoDetect] Detected device type: ${detection.type} (transport: ${detection.transport}). Device info: ${JSON.stringify(detection.deviceInfo)}`);
97
-
98
- // Use the API that was successfully used for detection
99
- const detectedApi = detection.api;
100
- const deviceInfo = detection.deviceInfo || {};
101
- const name = deviceInfo?.name || `Reolink ${detection.type}`;
102
- const identifier = uid || mac || ip || name || randomBytes(4).toString('hex');
103
-
104
- // Handle multi-focal device case
105
- if (detection.type === 'multifocal') {
106
- const isBattery = detection.transport === 'udp';
107
- nativeId = `${identifier}${isBattery ? batteryMultifocalSuffix : multifocalSuffix}`;
108
-
109
- settings.newCamera ||= name;
110
-
111
- const { capabilities, objects, presets } = await detectedApi.getDeviceCapabilities();
112
-
113
- const { interfaces } = getDeviceInterfaces({
114
- capabilities,
115
- logger: this.console,
116
- });
117
-
118
- await sdk.deviceManager.onDeviceDiscovered({
119
- nativeId,
120
- name,
121
- interfaces,
122
- type: ScryptedDeviceType.DeviceProvider,
123
- providerNativeId: this.nativeId,
124
- });
125
-
126
- const device = await this.getDevice(nativeId);
127
- if (!(device instanceof ReolinkNativeMultiFocalDevice)) {
128
- throw new Error('Expected multi-focal device but got different type');
129
- }
130
- device.classes = objects;
131
- device.presets = presets;
132
- device.storageSettings.values.ipAddress = ipAddress;
133
- device.storageSettings.values.username = username;
134
- device.storageSettings.values.password = password;
135
- device.storageSettings.values.uid = uid;
136
- device.cachedCapabilities = capabilities;
137
-
138
- return nativeId;
139
- }
140
-
141
- // Handle NVR case
142
- if (detection.type === 'nvr') {
143
- nativeId = `${identifier}${nvrSuffix}`;
144
-
145
- settings.newCamera ||= name;
146
-
147
- await sdk.deviceManager.onDeviceDiscovered({
148
- nativeId,
149
- name,
150
- interfaces: [
151
- ScryptedInterface.Settings,
152
- ScryptedInterface.DeviceDiscovery,
153
- ScryptedInterface.DeviceProvider,
154
- ScryptedInterface.Reboot,
155
- ],
156
- type: ScryptedDeviceType.DeviceProvider,
157
- providerNativeId: this.nativeId,
158
- });
159
-
160
- const device = await this.getDevice(nativeId);
161
- if (!(device instanceof ReolinkNativeNvrDevice)) {
162
- throw new Error('Expected NVR device but got different type');
163
- }
164
- device.storageSettings.values.ipAddress = ipAddress;
165
- device.storageSettings.values.username = username;
166
- device.storageSettings.values.password = password;
167
-
168
- return nativeId;
169
- }
170
-
171
- // Create nativeId based on device type
172
- if (detection.type === 'battery-cam') {
173
- nativeId = `${identifier}${batteryCameraSuffix}`;
174
- } else {
175
- nativeId = `${identifier}${cameraSuffix}`;
176
- }
177
-
178
- settings.newCamera ||= name;
179
-
180
- // Use the API that was successfully used for detection
181
- try {
182
- const rtspChannel = 0;
183
- const { capabilities, objects, presets } = await detectedApi.getDeviceCapabilities(rtspChannel);
184
-
185
- const { interfaces, type } = getDeviceInterfaces({
186
- capabilities,
187
- logger: this.console,
188
- });
189
-
190
- await sdk.deviceManager.onDeviceDiscovered({
191
- nativeId,
192
- name,
193
- interfaces,
194
- type,
195
- providerNativeId: this.nativeId,
196
- });
197
-
198
- const device = await this.getDevice(nativeId) as ReolinkCamera;
199
-
200
- device.info = deviceInfo;
201
- device.classes = objects;
202
- device.presets = presets;
203
- device.storageSettings.values.username = username;
204
- device.storageSettings.values.password = password;
205
- device.storageSettings.values.rtspChannel = rtspChannel;
206
- device.storageSettings.values.ipAddress = ipAddress;
207
- device.storageSettings.values.uid = uid;
208
- device.storageSettings.values.discoveryMethod = detection.udpDiscoveryMethod;
209
-
210
- device.cachedCapabilities = capabilities;
211
-
212
- return nativeId;
213
- }
214
- catch (e) {
215
- this.console.error('Error adding Reolink device', e?.message || String(e));
216
- throw e;
217
- }
161
+ // Handle NVR case
162
+ if (detection.type === "nvr") {
163
+ nativeId = `${identifier}${nvrSuffix}`;
164
+
165
+ settings.newCamera ||= name;
166
+
167
+ await sdk.deviceManager.onDeviceDiscovered({
168
+ nativeId,
169
+ name,
170
+ interfaces: [
171
+ ScryptedInterface.Settings,
172
+ ScryptedInterface.DeviceDiscovery,
173
+ ScryptedInterface.DeviceProvider,
174
+ ScryptedInterface.Reboot,
175
+ ],
176
+ type: ScryptedDeviceType.DeviceProvider,
177
+ providerNativeId: this.nativeId,
178
+ });
179
+
180
+ const device = await this.getDevice(nativeId);
181
+ if (!(device instanceof ReolinkNativeNvrDevice)) {
182
+ throw new Error("Expected NVR device but got different type");
183
+ }
184
+ device.storageSettings.values.ipAddress = ipAddress;
185
+ device.storageSettings.values.username = username;
186
+ device.storageSettings.values.password = password;
187
+
188
+ return nativeId;
218
189
  }
219
190
 
220
- async releaseDevice(id: string, nativeId: ScryptedNativeId): Promise<void> {
221
- if (this.devices.has(nativeId)) {
222
- const device = this.devices.get(nativeId);
223
- if (device && 'release' in device && typeof device.release === 'function') {
224
- await device.release();
225
- }
226
- this.devices.delete(nativeId);
227
- }
191
+ // Create nativeId based on device type
192
+ if (detection.type === "battery-cam") {
193
+ nativeId = `${identifier}${batteryCameraSuffix}`;
194
+ } else {
195
+ nativeId = `${identifier}${cameraSuffix}`;
228
196
  }
229
197
 
230
- async getCreateDeviceSettings(): Promise<Setting[]> {
231
- return [
232
- {
233
- key: 'ip',
234
- title: 'IP Address',
235
- placeholder: '192.168.2.222',
236
- value: '192.168.',
237
- },
238
- {
239
- key: 'username',
240
- title: 'Username',
241
- value: 'admin',
242
- },
243
- {
244
- key: 'password',
245
- title: 'Password',
246
- type: 'password',
247
- },
248
- {
249
- key: 'uid',
250
- title: 'UID',
251
- description: 'Reolink UID (optional, required for battery cameras if TCP connection fails)',
252
- },
253
- {
254
- key: 'deviceType',
255
- title: 'Device Type',
256
- description: 'Device type detection mode. Use "Auto" for automatic detection, or force a specific type.',
257
- type: 'string',
258
- choices: ['Auto', 'NVR', 'Battery Camera', 'Regular Camera'],
259
- value: 'Auto',
260
- }
261
- ]
198
+ settings.newCamera ||= name;
199
+
200
+ // Use the API that was successfully used for detection
201
+ try {
202
+ const rtspChannel = 0;
203
+ const { capabilities, objects, presets } =
204
+ await detectedApi.getDeviceCapabilities(rtspChannel);
205
+
206
+ const { interfaces, type } = getDeviceInterfaces({
207
+ capabilities,
208
+ logger: this.console,
209
+ });
210
+
211
+ await sdk.deviceManager.onDeviceDiscovered({
212
+ nativeId,
213
+ name,
214
+ interfaces,
215
+ type,
216
+ providerNativeId: this.nativeId,
217
+ });
218
+
219
+ const device = (await this.getDevice(nativeId)) as ReolinkCamera;
220
+
221
+ device.info = deviceInfo;
222
+ device.classes = objects;
223
+ device.presets = presets;
224
+ device.storageSettings.values.username = username;
225
+ device.storageSettings.values.password = password;
226
+ device.storageSettings.values.rtspChannel = rtspChannel;
227
+ device.storageSettings.values.ipAddress = ipAddress;
228
+ device.storageSettings.values.uid = uid;
229
+ device.storageSettings.values.discoveryMethod =
230
+ detection.udpDiscoveryMethod;
231
+
232
+ device.cachedCapabilities = capabilities;
233
+
234
+ return nativeId;
235
+ } catch (e) {
236
+ this.console.error(
237
+ "Error adding Reolink device",
238
+ e?.message || String(e),
239
+ );
240
+ throw e;
262
241
  }
263
-
264
- createCamera(nativeId: string) {
265
- if (nativeId.endsWith(batteryCameraSuffix)) {
266
- return new ReolinkCamera(nativeId, this, { type: 'battery' });
267
- } else if (nativeId.endsWith(nvrSuffix)) {
268
- return new ReolinkNativeNvrDevice(nativeId, this);
269
- } else if (nativeId.endsWith(batteryMultifocalSuffix)) {
270
- return new ReolinkNativeMultiFocalDevice(nativeId, this, "multi-focal-battery");
271
- } else if (nativeId.endsWith(multifocalSuffix)) {
272
- return new ReolinkNativeMultiFocalDevice(nativeId, this, "multi-focal");
273
- } else {
274
- return new ReolinkCamera(nativeId, this, { type: 'regular' });
275
- }
242
+ }
243
+
244
+ async releaseDevice(id: string, nativeId: ScryptedNativeId): Promise<void> {
245
+ if (this.devices.has(nativeId)) {
246
+ const device = this.devices.get(nativeId);
247
+ if (
248
+ device &&
249
+ "release" in device &&
250
+ typeof device.release === "function"
251
+ ) {
252
+ await device.release();
253
+ }
254
+ this.devices.delete(nativeId);
276
255
  }
277
-
278
- async onRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
279
- const logger = this.console;
280
- const url = new URL(`http://localhost${request.url}`);
281
-
282
- try {
283
- // Parse webhook path: /.../webhook/{type}/{deviceId}/{fileId}
284
- // The path may include prefix like /endpoint/@apocaliss92/scrypted-reolink-native/public/webhook/...
285
- const pathParts = url.pathname.split('/').filter(p => p);
286
-
287
- // Find the index of 'webhook' in the path
288
- const webhookIndex = pathParts.indexOf('webhook');
289
- if (webhookIndex === -1 || pathParts.length < webhookIndex + 4) {
290
- response.send('Invalid webhook path', { code: 404 });
291
- return;
292
- }
293
-
294
- // Extract type, deviceId, and fileId after 'webhook'
295
- const type = pathParts[webhookIndex + 1];
296
- const encodedDeviceId = pathParts[webhookIndex + 2];
297
- // fileId may contain slashes, so join all remaining parts
298
- const encodedFileId = pathParts.slice(webhookIndex + 3).join('/');
299
- const deviceId = decodeURIComponent(encodedDeviceId);
300
- let fileId = decodeURIComponent(encodedFileId);
301
-
302
- // Restore leading slash if the original fileId had it (we removed it during encoding)
303
- // The API expects fileId with leading slash for absolute paths
304
- if (!fileId.startsWith('/') && !fileId.startsWith('http')) {
305
- // If it looks like an absolute path (starts with common path prefixes), add slash
306
- if (fileId.startsWith('mnt/') || fileId.startsWith('var/') || fileId.startsWith('tmp/')) {
307
- fileId = `/${fileId}`;
308
- }
309
- }
310
-
311
- // logger.log(`Webhook request: type=${type}, deviceId=${deviceId}, fileId=${fileId}`);
312
-
313
- // Get the device
314
- const device = this.camerasMap.get(deviceId);
315
- if (!device) {
316
- response.send('Device not found', { code: 404 });
317
- return;
318
- }
319
-
320
- if (type === 'video') {
321
- await handleVideoClipRequest({
322
- device,
323
- deviceId,
324
- fileId,
325
- request,
326
- response,
327
- logger,
328
- });
329
- return;
330
- } else if (type === 'thumbnail') {
331
- // Get thumbnail MediaObject
332
- const mo = await device.getVideoClipThumbnail(fileId);
333
-
334
- // Convert to buffer
335
- const buffer = await sdk.mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
336
-
337
- // Send image
338
- response.send(buffer, {
339
- code: 200,
340
- headers: {
341
- 'Content-Type': 'image/jpeg',
342
- 'Cache-Control': 'max-age=31536000',
343
- },
344
- });
345
- return;
346
- } else {
347
- response.send('Invalid webhook type', { code: 404 });
348
- return;
349
- }
350
- } catch (e: any) {
351
- logger.error('Error in onRequest', e?.message || String(e));
352
- response.send(`Error: ${e.message}`, {
353
- code: 500,
354
- });
355
- return;
356
- }
357
- }
358
-
359
- onPush(request: HttpRequest): Promise<void> {
360
- return this.onRequest(request, undefined);
256
+ }
257
+
258
+ async getCreateDeviceSettings(): Promise<Setting[]> {
259
+ return [
260
+ {
261
+ key: "ip",
262
+ title: "IP Address",
263
+ placeholder: "192.168.2.222",
264
+ value: "192.168.",
265
+ },
266
+ {
267
+ key: "username",
268
+ title: "Username",
269
+ value: "admin",
270
+ },
271
+ {
272
+ key: "password",
273
+ title: "Password",
274
+ type: "password",
275
+ },
276
+ {
277
+ key: "uid",
278
+ title: "UID",
279
+ description:
280
+ "Reolink UID (optional, required for battery cameras if TCP connection fails)",
281
+ },
282
+ {
283
+ key: "deviceType",
284
+ title: "Device Type",
285
+ description:
286
+ 'Device type detection mode. Use "Auto" for automatic detection, or force a specific type.',
287
+ type: "string",
288
+ choices: ["Auto", "NVR", "Battery Camera", "Regular Camera"],
289
+ value: "Auto",
290
+ },
291
+ ];
292
+ }
293
+
294
+ createCamera(nativeId: string) {
295
+ if (nativeId.endsWith(batteryCameraSuffix)) {
296
+ return new ReolinkCamera(nativeId, this, { type: "battery" });
297
+ } else if (nativeId.endsWith(nvrSuffix)) {
298
+ return new ReolinkNativeNvrDevice(nativeId, this);
299
+ } else if (nativeId.endsWith(batteryMultifocalSuffix)) {
300
+ return new ReolinkNativeMultiFocalDevice(
301
+ nativeId,
302
+ this,
303
+ "multi-focal-battery",
304
+ );
305
+ } else if (nativeId.endsWith(multifocalSuffix)) {
306
+ return new ReolinkNativeMultiFocalDevice(nativeId, this, "multi-focal");
307
+ } else {
308
+ return new ReolinkCamera(nativeId, this, { type: "regular" });
361
309
  }
362
-
363
- /**
364
- * Add a thumbnail generation request to the queue
365
- */
366
- async generateThumbnail(request: ThumbnailRequestInput): Promise<MediaObject> {
367
- // Create a unique key for this request (deviceId:fileId)
368
- const requestKey = `${request.deviceId}:${request.fileId}`;
369
-
370
- // Check if this thumbnail is already in queue or being processed
371
- const existingRequest = this.thumbnailPendingRequests.get(requestKey);
372
- if (existingRequest) {
373
- const logger = request.device?.getBaichuanLogger?.() || request.logger || console;
374
- logger.debug(`[Thumbnail] Request already in queue: fileId=${request.fileId}, reusing existing promise`);
375
- return existingRequest;
310
+ }
311
+
312
+ async onRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
313
+ const logger = this.console;
314
+ const url = new URL(`http://localhost${request.url}`);
315
+
316
+ try {
317
+ // Parse webhook path: /.../webhook/{type}/{deviceId}/{fileId}
318
+ // The path may include prefix like /endpoint/@apocaliss92/scrypted-reolink-native/public/webhook/...
319
+ const pathParts = url.pathname.split("/").filter((p) => p);
320
+
321
+ // Find the index of 'webhook' in the path
322
+ const webhookIndex = pathParts.indexOf("webhook");
323
+ if (webhookIndex === -1 || pathParts.length < webhookIndex + 4) {
324
+ response.send("Invalid webhook path", { code: 404 });
325
+ return;
326
+ }
327
+
328
+ // Extract type, deviceId, and fileId after 'webhook'
329
+ const type = pathParts[webhookIndex + 1];
330
+ const encodedDeviceId = pathParts[webhookIndex + 2];
331
+ // fileId may contain slashes, so join all remaining parts
332
+ const encodedFileId = pathParts.slice(webhookIndex + 3).join("/");
333
+ const deviceId = decodeURIComponent(encodedDeviceId);
334
+ let fileId = decodeURIComponent(encodedFileId);
335
+
336
+ // Restore leading slash if the original fileId had it (we removed it during encoding)
337
+ // The API expects fileId with leading slash for absolute paths
338
+ if (!fileId.startsWith("/") && !fileId.startsWith("http")) {
339
+ // If it looks like an absolute path (starts with common path prefixes), add slash
340
+ if (
341
+ fileId.startsWith("mnt/") ||
342
+ fileId.startsWith("var/") ||
343
+ fileId.startsWith("tmp/")
344
+ ) {
345
+ fileId = `/${fileId}`;
376
346
  }
377
-
378
- const queueLength = this.thumbnailQueue.length;
379
- // Use device logger if available, otherwise fallback to provided logger
380
- const logger = request.device?.getBaichuanLogger?.() || request.logger || console;
381
- logger.log(`[Thumbnail] Download start: fileId=${request.fileId}, queuePosition=${queueLength + 1}`);
382
-
383
- const promise = new Promise<MediaObject>((resolve, reject) => {
384
- this.thumbnailQueue.push({
385
- ...request,
386
- resolve,
387
- reject,
388
- });
389
- this.processThumbnailQueue();
347
+ }
348
+
349
+ // logger.log(`Webhook request: type=${type}, deviceId=${deviceId}, fileId=${fileId}`);
350
+
351
+ // Get the device
352
+ const device = this.camerasMap.get(deviceId);
353
+ if (!device) {
354
+ response.send("Device not found", { code: 404 });
355
+ return;
356
+ }
357
+
358
+ if (type === "video") {
359
+ await handleVideoClipRequest({
360
+ device,
361
+ deviceId,
362
+ fileId,
363
+ request,
364
+ response,
365
+ logger,
390
366
  });
367
+ return;
368
+ } else if (type === "thumbnail") {
369
+ // Get thumbnail MediaObject
370
+ const mo = await device.getVideoClipThumbnail(fileId);
371
+
372
+ // Convert to buffer
373
+ const buffer = await sdk.mediaManager.convertMediaObjectToBuffer(
374
+ mo,
375
+ "image/jpeg",
376
+ );
391
377
 
392
- // Track this request
393
- this.thumbnailPendingRequests.set(requestKey, promise);
394
-
395
- // Remove from tracking when the promise resolves or rejects
396
- promise.finally(() => {
397
- this.thumbnailPendingRequests.delete(requestKey);
378
+ // Send image
379
+ response.send(buffer, {
380
+ code: 200,
381
+ headers: {
382
+ "Content-Type": "image/jpeg",
383
+ "Cache-Control": "max-age=31536000",
384
+ },
398
385
  });
399
-
400
- return promise;
386
+ return;
387
+ } else {
388
+ response.send("Invalid webhook type", { code: 404 });
389
+ return;
390
+ }
391
+ } catch (e: any) {
392
+ logger.error("Error in onRequest", e?.message || String(e));
393
+ response.send(`Error: ${e.message}`, {
394
+ code: 500,
395
+ });
396
+ return;
401
397
  }
398
+ }
402
399
 
403
- /**
404
- * Process the thumbnail queue sequentially
405
- */
406
- private async processThumbnailQueue(): Promise<void> {
407
- if (this.thumbnailProcessing || this.thumbnailQueue.length === 0) {
408
- return;
409
- }
410
-
411
- this.thumbnailProcessing = true;
412
-
413
- while (this.thumbnailQueue.length > 0) {
414
- const request = this.thumbnailQueue.shift()!;
415
- const logger = request.device?.getBaichuanLogger?.() || request.logger || console;
416
-
417
- try {
418
- const thumbnail = await extractThumbnailFromVideo(request);
419
- logger.log(`[Thumbnail] Download completed: fileId=${request.fileId}`);
420
- request.resolve(thumbnail);
421
- } catch (error) {
422
- logger.error(`[Thumbnail] Error: fileId=${request.fileId}`, error?.message || String(error));
423
- request.reject(error instanceof Error ? error : new Error(String(error)));
424
- }
425
-
426
- // Add 2 second delay between thumbnails (except after the last one)
427
- if (this.thumbnailQueue.length > 0) {
428
- await new Promise(resolve => setTimeout(resolve, 2000));
429
- }
430
- }
431
-
432
- this.thumbnailProcessing = false;
433
- }
400
+ onPush(request: HttpRequest): Promise<void> {
401
+ return this.onRequest(request, undefined);
402
+ }
434
403
  }
435
404
 
436
405
  export default ReolinkNativePlugin;