@apocaliss92/scrypted-reolink-native 0.3.17 → 0.4.1

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/utils.ts CHANGED
@@ -1,45 +1,33 @@
1
- import type { DeviceCapabilities, EnrichedRecordingFile, NativeVideoStreamVariant, ParsedRecordingFileName, RecordingFile, ReolinkBaichuanApi, ReolinkDeviceInfo, ReolinkSupportedStream, VodFile, VodSearchResponse } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
2
- import sdk, { DeviceBase, HttpRequest, HttpResponse, MediaObject, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, VideoClip, VideoClips } from "@scrypted/sdk";
3
- import { spawn } from "node:child_process";
4
- import { Readable } from "stream";
5
- import http from "http";
6
- import https from "https";
7
- import fs from "fs";
8
- import path from "path";
1
+ import type {
2
+ DeviceCapabilities,
3
+ RecordingFile,
4
+ ReolinkDeviceInfo,
5
+ ReolinkSupportedStream,
6
+ } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
7
+ import sdk, {
8
+ DeviceBase,
9
+ HttpRequest,
10
+ HttpResponse,
11
+ ScryptedDeviceBase,
12
+ ScryptedDeviceType,
13
+ ScryptedInterface,
14
+ VideoClip,
15
+ } from "@scrypted/sdk";
9
16
  import crypto from "crypto";
10
17
  import { ReolinkCamera } from "./camera";
11
- /**
12
- * Sanitize FFmpeg output or URLs to avoid leaking credentials
13
- */
14
- export function sanitizeFfmpegOutput(text: string): string {
15
- if (!text)
16
- return text;
17
-
18
- let sanitized = text;
19
-
20
- // Remove user/password query parameters from URLs: ?user=xxx&password=yyy
21
- sanitized = sanitized.replace(/(\buser=)[^&\s]*/gi, '$1***');
22
- sanitized = sanitized.replace(/(\bpassword=)[^&\s]*/gi, '$1***');
23
-
24
- // Remove credentials from URLs like rtmp://user:pass@host/...
25
- sanitized = sanitized.replace(/(rtmp:\/\/)([^:@\/\s]+):([^@\/\s]+)@/gi, '$1$2:***@');
26
-
27
- return sanitized;
28
- }
29
-
30
18
 
31
19
  /**
32
20
  * Enumeration of operation types that may require specific channel assignments
33
21
  */
34
22
  export enum OperationChannelType {
35
- PAN = 'pan',
36
- TILT = 'tilt',
37
- ZOOM = 'zoom',
38
- INTERCOM = 'intercom',
39
- GOTO = 'goto',
40
- PRESET = 'preset',
41
- PATROL = 'patrol',
42
- TRACK = 'track',
23
+ PAN = "pan",
24
+ TILT = "tilt",
25
+ ZOOM = "zoom",
26
+ INTERCOM = "intercom",
27
+ GOTO = "goto",
28
+ PRESET = "preset",
29
+ PATROL = "patrol",
30
+ TRACK = "track",
43
31
  }
44
32
 
45
33
  /**
@@ -54,1023 +42,411 @@ export const batteryMultifocalSuffix = `-battery-multifocal`;
54
42
  export const cameraSuffix = `-cam`;
55
43
  export const sirenSuffix = `-siren`;
56
44
  export const floodlightSuffix = `-floodlight`;
45
+ export const motionSirenSuffix = `-motion-siren`;
46
+ export const motionFloodlightSuffix = `-motion-floodlight`;
57
47
  export const pirSuffix = `-pir`;
48
+ export const autotrackingSuffix = `-autotracking`;
58
49
 
59
50
  export const getDeviceInterfaces = (props: {
60
- capabilities: DeviceCapabilities,
61
- logger: Console,
62
- isLensDevice?: boolean
51
+ capabilities: DeviceCapabilities;
52
+ logger: Console;
53
+ isLensDevice?: boolean;
63
54
  }) => {
64
- const { capabilities, logger, isLensDevice } = props;
65
-
66
- const interfaces = [
67
- ScryptedInterface.VideoCamera,
68
- ScryptedInterface.Settings,
69
- ScryptedInterface.Reboot,
70
- ScryptedInterface.VideoCameraConfiguration,
71
- ScryptedInterface.Camera,
72
- ScryptedInterface.AudioSensor,
73
- ScryptedInterface.MotionSensor,
74
- ScryptedInterface.VideoTextOverlays,
75
- ];
76
-
77
- if (!isLensDevice) {
78
- interfaces.push(ScryptedInterface.VideoClips);
79
- }
80
-
81
- try {
82
- const {
83
- hasPtz,
84
- hasSiren,
85
- hasFloodlight,
86
- hasPir,
87
- hasBattery,
88
- hasIntercom,
89
- isDoorbell,
90
- } = capabilities;
91
-
92
- if (hasPtz) {
93
- interfaces.push(ScryptedInterface.PanTiltZoom);
94
- }
95
- interfaces.push(ScryptedInterface.ObjectDetector);
96
- if (hasSiren || hasFloodlight || hasPir)
97
- interfaces.push(ScryptedInterface.DeviceProvider);
98
- if (hasBattery) {
99
- interfaces.push(ScryptedInterface.Battery, ScryptedInterface.Sleep);
100
- }
101
- if (hasIntercom) {
102
- interfaces.push(ScryptedInterface.Intercom);
103
- }
104
- if (isDoorbell) {
105
- interfaces.push(ScryptedInterface.BinarySensor);
106
- }
107
- } catch (e) {
108
- logger.error('Error getting device interfaces', e?.message || String(e));
109
- }
110
-
111
- return { interfaces, type: capabilities.isDoorbell ? ScryptedDeviceType.Doorbell : ScryptedDeviceType.Camera };
112
- }
55
+ const { capabilities, logger, isLensDevice } = props;
56
+
57
+ const interfaces = [
58
+ ScryptedInterface.VideoCamera,
59
+ ScryptedInterface.Settings,
60
+ ScryptedInterface.Reboot,
61
+ ScryptedInterface.VideoCameraConfiguration,
62
+ ScryptedInterface.Camera,
63
+ ScryptedInterface.AudioSensor,
64
+ ScryptedInterface.MotionSensor,
65
+ ScryptedInterface.VideoTextOverlays,
66
+ ];
67
+
68
+ if (!isLensDevice) {
69
+ interfaces.push(ScryptedInterface.VideoClips);
70
+ }
71
+
72
+ try {
73
+ const {
74
+ hasPtz,
75
+ hasSiren,
76
+ hasFloodlight,
77
+ hasPir,
78
+ hasBattery,
79
+ hasIntercom,
80
+ isDoorbell,
81
+ } = capabilities;
82
+
83
+ if (hasPtz) {
84
+ interfaces.push(ScryptedInterface.PanTiltZoom);
85
+ }
86
+ interfaces.push(ScryptedInterface.ObjectDetector);
87
+ if (hasSiren || hasFloodlight || hasPir)
88
+ interfaces.push(ScryptedInterface.DeviceProvider);
89
+ if (hasBattery) {
90
+ interfaces.push(ScryptedInterface.Battery, ScryptedInterface.Sleep);
91
+ }
92
+ if (hasIntercom) {
93
+ interfaces.push(ScryptedInterface.Intercom);
94
+ }
95
+ if (isDoorbell) {
96
+ interfaces.push(ScryptedInterface.BinarySensor);
97
+ }
98
+ } catch (e) {
99
+ logger.error("Error getting device interfaces", e?.message || String(e));
100
+ }
101
+
102
+ return {
103
+ interfaces,
104
+ type: capabilities.isDoorbell
105
+ ? ScryptedDeviceType.Doorbell
106
+ : ScryptedDeviceType.Camera,
107
+ };
108
+ };
113
109
 
114
110
  export const updateDeviceInfo = async (props: {
115
- device: DeviceBase,
116
- ipAddress: string,
117
- deviceData: ReolinkDeviceInfo,
118
- logger: Console
111
+ device: DeviceBase;
112
+ ipAddress: string;
113
+ deviceData: ReolinkDeviceInfo;
114
+ logger: Console;
119
115
  }) => {
120
- const { device, ipAddress, deviceData, logger } = props;
121
- try {
122
- const info = device.info || {};
123
-
124
- info.ip = ipAddress;
125
- info.serialNumber = deviceData?.serialNumber || deviceData?.itemNo;
126
- info.firmware = deviceData?.firmwareVersion;
127
- info.version = deviceData?.hardwareVersion;
128
- info.model = deviceData?.type;
129
- info.manufacturer = 'Reolink';
130
- info.managementUrl = `http://${ipAddress}`;
131
- device.info = info;
132
- } catch (e) {
133
- // If API call fails, at least set basic info
134
- const info = device.info || {};
135
- info.ip = ipAddress;
136
- info.manufacturer = 'Reolink native';
137
- info.managementUrl = `http://${ipAddress}`;
138
- device.info = info;
139
-
140
- throw e;
141
- } finally {
142
- logger.log(`Device info updated`);
143
- logger.debug(`${JSON.stringify({ newInfo: device.info, deviceData })}`);
144
- }
145
- }
116
+ const { device, ipAddress, deviceData, logger } = props;
117
+ try {
118
+ const info = device.info || {};
119
+
120
+ info.ip = ipAddress;
121
+ info.serialNumber = deviceData?.serialNumber || deviceData?.itemNo;
122
+ info.firmware = deviceData?.firmwareVersion;
123
+ info.version = deviceData?.hardwareVersion;
124
+ info.model = deviceData?.type;
125
+ info.manufacturer = "Reolink";
126
+ info.managementUrl = `http://${ipAddress}`;
127
+ device.info = info;
128
+ } catch (e) {
129
+ // If API call fails, at least set basic info
130
+ const info = device.info || {};
131
+ info.ip = ipAddress;
132
+ info.manufacturer = "Reolink native";
133
+ info.managementUrl = `http://${ipAddress}`;
134
+ device.info = info;
135
+
136
+ throw e;
137
+ } finally {
138
+ logger.log(`Device info updated`);
139
+ logger.debug(`${JSON.stringify({ newInfo: device.info, deviceData })}`);
140
+ }
141
+ };
146
142
 
147
143
  /**
148
- * Convert a Reolink RecordingFile or EnrichedRecordingFile to a Scrypted VideoClip
144
+ * Convert a Reolink RecordingFile to a Scrypted VideoClip
145
+ * Simple mapping - all data is already in RecordingFile
149
146
  */
150
147
  export async function recordingFileToVideoClip(
151
- rec: RecordingFile | EnrichedRecordingFile,
152
- options: {
153
- /** Fallback start date if recording doesn't have one */
154
- fallbackStart: Date;
155
- /** API instance to get playback URLs (optional, can provide videoHref directly) */
156
- api?: ReolinkBaichuanApi;
157
- /** Pre-fetched video URL (optional, will fetch if not provided and api is available) */
158
- videoHref?: string;
159
- /** Logger for debug messages */
160
- logger?: Console;
161
- /** Plugin instance for generating webhook URLs */
162
- plugin?: ScryptedDeviceBase;
163
- /** Device ID for webhook URLs */
164
- deviceId?: string;
165
- /** Use webhook URLs instead of direct RTMP URLs */
166
- useWebhook?: boolean;
167
- }
148
+ rec: RecordingFile,
149
+ options: {
150
+ /** Plugin instance for generating webhook URLs */
151
+ plugin: ScryptedDeviceBase;
152
+ /** Device ID for webhook URLs */
153
+ deviceId: string;
154
+ /** Logger for debug messages */
155
+ logger?: Console;
156
+ },
168
157
  ): Promise<VideoClip> {
169
- const { fallbackStart, api, videoHref: providedVideoHref, logger, plugin, deviceId, useWebhook } = options;
170
-
171
- // Handle both RecordingFile (has startTime/endTime as Date) and EnrichedRecordingFile (has startTimeMs/endTimeMs as number)
172
- let recStart: Date;
173
- let recEnd: Date;
174
-
175
- if ('startTime' in rec && rec.startTime instanceof Date) {
176
- recStart = rec.startTime;
177
- } else if ('startTimeMs' in rec && typeof rec.startTimeMs === 'number') {
178
- recStart = new Date(rec.startTimeMs);
179
- } else {
180
- recStart = rec.parsedFileName?.start ?? fallbackStart;
181
- }
182
-
183
- if ('endTime' in rec && rec.endTime instanceof Date) {
184
- recEnd = rec.endTime;
185
- } else if ('endTimeMs' in rec && typeof rec.endTimeMs === 'number') {
186
- recEnd = new Date(rec.endTimeMs);
187
- } else {
188
- recEnd = rec.parsedFileName?.end ?? recStart;
189
- }
190
-
191
- const recStartMs = recStart.getTime();
192
- const recEndMs = Math.max(recEnd.getTime(), recStartMs);
193
- const duration = recEndMs - recStartMs;
194
-
195
- // IMPORTANT: For NVR/Hub, ensure the clip id (fileId) is the actual recording path (/mnt/...) when available.
196
- // Some sources may provide an alternate id (e.g. eventId/Baichuan id); we prefer the filesystem path because
197
- // downstream VOD download/playback endpoints expect it.
198
- const id = typeof rec.fileName === 'string' && rec.fileName.startsWith('/mnt/')
199
- ? rec.fileName
200
- : (rec.id || rec.fileName);
201
-
202
- // Get video URL if not provided
203
- let videoHref: string | undefined = providedVideoHref;
204
- let thumbnailHref: string | undefined;
205
-
206
- // logger?.debug(`[recordingFileToVideoClip] URL generation: useWebhook=${useWebhook}, hasPlugin=${!!plugin}, deviceId=${deviceId}, providedVideoHref=${providedVideoHref || 'none'}, hasApi=${!!api}`);
207
-
208
- // If webhook is enabled, generate webhook URLs
209
- if (useWebhook && plugin && deviceId) {
210
- // logger?.debug(`[recordingFileToVideoClip] Generating webhook URLs for fileId=${id}`);
211
- try {
212
- const { videoUrl, thumbnailUrl } = await getVideoClipWebhookUrls({
213
- deviceId,
214
- fileId: id,
215
- plugin,
216
- logger,
217
- });
218
- videoHref = videoUrl;
219
- thumbnailHref = thumbnailUrl;
220
- // logger?.debug(`[recordingFileToVideoClip] Webhook URLs generated successfully: videoHref="${videoHref}", thumbnailHref="${thumbnailHref}"`);
221
- } catch (e) {
222
- logger?.error(`[recordingFileToVideoClip] Failed to generate webhook URLs for fileId=${id}:`, e?.message || String(e));
223
- }
224
- } else if (!videoHref && api) {
225
- // Fallback to direct RTMP URL if webhook is not used
226
- // logger?.debug(`[recordingFileToVideoClip] Fetching RTMP playback URL for fileName=${rec.fileName}`);
227
- try {
228
- const { rtmpVodUrl } = await api.getRecordingPlaybackUrls({
229
- fileName: rec.fileName,
230
- });
231
- videoHref = rtmpVodUrl;
232
- // logger?.debug(`[recordingFileToVideoClip] RTMP URL fetched successfully: videoHref="${videoHref}"`);
233
- } catch (e) {
234
- logger?.debug(`[recordingFileToVideoClip] Failed to build playback URL for recording fileName=${rec.fileName}:`, e?.message || String(e));
235
- }
236
- } else {
237
- // logger?.debug(`[recordingFileToVideoClip] No URL generation: useWebhook=${useWebhook}, hasPlugin=${!!plugin}, deviceId=${deviceId}, providedVideoHref=${providedVideoHref || 'none'}, hasApi=${!!api}`);
238
- }
239
-
240
- const description = ('name' in rec && typeof rec.name === 'string' && rec.name) ? rec.name : (rec.fileName ?? rec.id ?? '');
241
-
242
- // Build detectionClasses from flags or recordType
243
- const detectionClasses: string[] = [];
244
-
245
- // Check for EnrichedRecordingFile flags first (most accurate)
246
- let hasAnyDetection = false;
247
- if ('hasPerson' in rec && rec.hasPerson) {
248
- detectionClasses.push('Person');
249
- hasAnyDetection = true;
250
- }
251
- if ('hasVehicle' in rec && rec.hasVehicle) {
252
- detectionClasses.push('Vehicle');
253
- hasAnyDetection = true;
254
- }
255
- if ('hasAnimal' in rec && rec.hasAnimal) {
256
- detectionClasses.push('Animal');
257
- hasAnyDetection = true;
258
- }
259
- if ('hasFace' in rec && rec.hasFace) {
260
- detectionClasses.push('Face');
261
- hasAnyDetection = true;
262
- }
263
- if ('hasMotion' in rec && rec.hasMotion) {
264
- detectionClasses.push('Motion');
265
- hasAnyDetection = true;
266
- }
267
- if ('hasDoorbell' in rec && rec.hasDoorbell) {
268
- detectionClasses.push('Doorbell');
269
- hasAnyDetection = true;
270
- }
271
- if ('hasPackage' in rec && rec.hasPackage) {
272
- detectionClasses.push('Package');
273
- hasAnyDetection = true;
274
- }
275
-
276
- // Log detection flags for debugging
277
- if (logger) {
278
- const flags = {
279
- hasPerson: 'hasPerson' in rec ? rec.hasPerson : undefined,
280
- hasVehicle: 'hasVehicle' in rec ? rec.hasVehicle : undefined,
281
- hasAnimal: 'hasAnimal' in rec ? rec.hasAnimal : undefined,
282
- hasFace: 'hasFace' in rec ? rec.hasFace : undefined,
283
- hasMotion: 'hasMotion' in rec ? rec.hasMotion : undefined,
284
- hasDoorbell: 'hasDoorbell' in rec ? rec.hasDoorbell : undefined,
285
- hasPackage: 'hasPackage' in rec ? rec.hasPackage : undefined,
286
- recordType: rec.recordType || 'none',
287
- };
288
- }
289
-
290
- // Fallback: parse recordType string if flags are not available
291
- if (!hasAnyDetection && rec.recordType) {
292
- const recordTypeLower = rec.recordType.toLowerCase();
293
- if (recordTypeLower.includes('people') || recordTypeLower.includes('person')) {
294
- detectionClasses.push('Person');
295
- }
296
- if (recordTypeLower.includes('vehicle')) {
297
- detectionClasses.push('Vehicle');
298
- }
299
- if (recordTypeLower.includes('dog_cat') || recordTypeLower.includes('animal')) {
300
- detectionClasses.push('Animal');
301
- }
302
- if (recordTypeLower.includes('face')) {
303
- detectionClasses.push('Face');
304
- }
305
- if (recordTypeLower.includes('md') || recordTypeLower.includes('motion')) {
306
- detectionClasses.push('Motion');
307
- }
308
- if (recordTypeLower.includes('visitor') || recordTypeLower.includes('doorbell')) {
309
- detectionClasses.push('Doorbell');
310
- }
311
- if (recordTypeLower.includes('package')) {
312
- detectionClasses.push('Package');
313
- }
314
- }
315
-
316
- // Always include Motion if no other detections found
317
- if (detectionClasses.length === 0) {
318
- detectionClasses.push('Motion');
319
- }
320
-
321
- const resources = videoHref || thumbnailHref
158
+ const { plugin, deviceId, logger } = options;
159
+
160
+ // Get times from RecordingFile (already parsed)
161
+ const recStart = rec.startTime ?? rec.parsedFileName?.start ?? new Date();
162
+ const recEnd = rec.endTime ?? rec.parsedFileName?.end ?? recStart;
163
+
164
+ const recStartMs = recStart.getTime();
165
+ const recEndMs = Math.max(recEnd.getTime(), recStartMs);
166
+ const duration = recEndMs - recStartMs;
167
+
168
+ // Use fileName as id (for NVR it's the full path like /mnt/...)
169
+ const id = rec.id || rec.fileName;
170
+
171
+ // Generate webhook URLs
172
+ let videoHref: string | undefined;
173
+ let thumbnailHref: string | undefined;
174
+
175
+ try {
176
+ const { videoUrl, thumbnailUrl } = await getVideoClipWebhookUrls({
177
+ deviceId,
178
+ fileId: id,
179
+ plugin,
180
+ logger,
181
+ });
182
+ videoHref = videoUrl;
183
+ thumbnailHref = thumbnailUrl;
184
+ } catch (e) {
185
+ logger?.error(
186
+ `[recordingFileToVideoClip] Failed to generate webhook URLs for fileId=${id}:`,
187
+ e?.message || String(e),
188
+ );
189
+ }
190
+
191
+ // Use detectionClasses from RecordingFile (already populated by CGI/Baichuan API)
192
+ // Default to motion if not available
193
+ const detectionClasses = rec.detectionClasses ?? ["motion"];
194
+
195
+ return {
196
+ id,
197
+ startTime: recStartMs,
198
+ duration,
199
+ event: rec.recordType,
200
+ description: rec.name || rec.fileName || rec.id || "",
201
+ detectionClasses,
202
+ resources:
203
+ videoHref || thumbnailHref
322
204
  ? {
323
205
  ...(videoHref ? { video: { href: videoHref } } : {}),
324
206
  ...(thumbnailHref ? { thumbnail: { href: thumbnailHref } } : {}),
325
- }
326
- : undefined;
327
-
328
-
329
- return {
330
- id,
331
- startTime: recStartMs,
332
- duration,
333
- event: rec.recordType,
334
- description,
335
- detectionClasses: detectionClasses.length > 0 ? detectionClasses : undefined,
336
- resources,
337
- };
207
+ }
208
+ : undefined,
209
+ };
338
210
  }
339
211
 
340
212
  /**
341
- * Convert an array of RecordingFile or EnrichedRecordingFile to VideoClip array
342
- * Uses recordingFileToVideoClip for each recording
343
- * Handles both NVR (EnrichedRecordingFile) and device standalone (RecordingFile) cases
213
+ * Convert an array of RecordingFile to VideoClip array
214
+ * Simple mapping with optional limit
344
215
  */
345
216
  export async function recordingsToVideoClips(
346
- recordings: (RecordingFile | EnrichedRecordingFile)[],
347
- options: {
348
- /** Fallback start date if recording doesn't have one */
349
- fallbackStart: Date;
350
- /** API instance to get playback URLs (optional, for device standalone recordings) */
351
- api?: ReolinkBaichuanApi;
352
- /** Logger for debug messages */
353
- logger?: Console;
354
- /** Plugin instance for generating webhook URLs */
355
- plugin?: ScryptedDeviceBase;
356
- /** Device ID for webhook URLs */
357
- deviceId?: string;
358
- /** Use webhook URLs instead of direct RTMP URLs */
359
- useWebhook?: boolean;
360
- /** Maximum number of clips to return (optional) */
361
- count?: number;
362
- }
217
+ recordings: RecordingFile[],
218
+ options: {
219
+ /** Plugin instance for generating webhook URLs */
220
+ plugin: ScryptedDeviceBase;
221
+ /** Device ID for webhook URLs */
222
+ deviceId: string;
223
+ /** Logger for debug messages */
224
+ logger?: Console;
225
+ /** Maximum number of clips to return (optional) */
226
+ count?: number;
227
+ },
363
228
  ): Promise<VideoClip[]> {
364
- const { fallbackStart, api, logger, plugin, deviceId, useWebhook, count } = options;
365
- const clips: VideoClip[] = [];
229
+ const { plugin, deviceId, logger, count } = options;
366
230
 
367
- for (const rec of recordings) {
368
- try {
369
- const clip = await recordingFileToVideoClip(rec, {
370
- fallbackStart,
371
- api,
372
- logger,
373
- plugin,
374
- deviceId,
375
- useWebhook,
376
- });
377
- clips.push(clip);
378
- } catch (e) {
379
- logger?.warn(`Failed to convert recording to video clip: fileName=${rec.fileName}`, e?.message || String(e));
380
- }
231
+ const clipPromises = recordings.map(async (rec) => {
232
+ try {
233
+ return await recordingFileToVideoClip(rec, { plugin, deviceId, logger });
234
+ } catch (e) {
235
+ logger?.warn(
236
+ `Failed to convert recording to video clip: fileName=${rec.fileName}`,
237
+ e?.message || String(e),
238
+ );
239
+ return null;
381
240
  }
241
+ });
382
242
 
383
- // Apply count limit if specified
384
- return count ? clips.slice(0, count) : clips;
243
+ const clips = await Promise.all(clipPromises);
244
+ const validClips = clips.filter((c): c is VideoClip => c !== null);
245
+ return count ? validClips.slice(0, count) : validClips;
385
246
  }
386
247
 
387
248
  /**
388
249
  * Generate webhook URLs for video clips
389
250
  */
390
251
  export async function getVideoClipWebhookUrls(props: {
391
- deviceId: string;
392
- fileId: string;
393
- plugin: ScryptedDeviceBase;
394
- logger?: Console;
252
+ deviceId: string;
253
+ fileId: string;
254
+ plugin: ScryptedDeviceBase;
255
+ logger?: Console;
395
256
  }): Promise<{ videoUrl: string; thumbnailUrl: string }> {
396
- const { deviceId, fileId, plugin, logger } = props;
397
- const log = logger || plugin.console;
398
-
399
- // log.debug?.(`[getVideoClipWebhookUrls] Starting URL generation: deviceId=${deviceId}, fileId=${fileId}`);
400
-
401
- try {
402
- let endpoint: string;
403
- let endpointSource: 'cloud' | 'local';
404
- try {
405
- endpoint = await sdk.endpointManager.getCloudEndpoint(undefined, { public: true });
406
- endpointSource = 'cloud';
407
- // log.debug?.(`[getVideoClipWebhookUrls] Using cloud endpoint: ${endpoint}`);
408
- } catch (e) {
409
- // Fallback to local endpoint if cloud is not available (e.g., not logged in)
410
- log.debug?.(`[getVideoClipWebhookUrls] Cloud endpoint not available, using local endpoint: ${e?.message || String(e)}`);
411
- endpoint = await sdk.endpointManager.getLocalEndpoint(undefined, { public: true });
412
- endpointSource = 'local';
413
- // log.debug?.(`[getVideoClipWebhookUrls] Using local endpoint: ${endpoint}`);
414
- }
415
-
416
- const encodedDeviceId = encodeURIComponent(deviceId);
417
- // Remove leading slash from fileId if present, as it causes invalid paths when encoded
418
- const cleanFileId = fileId.startsWith('/') ? fileId.substring(1) : fileId;
419
- const encodedFileId = encodeURIComponent(cleanFileId);
420
-
421
- // Parse endpoint URL to extract query parameters (for authentication)
422
- const endpointUrl = new URL(endpoint);
423
- // Preserve query parameters (e.g., user_token for authentication)
424
- const queryParams = endpointUrl.search;
425
- // Remove query parameters from the base endpoint URL
426
- endpointUrl.search = '';
427
-
428
- // Ensure endpoint has trailing slash
429
- const normalizedEndpoint = endpointUrl.toString().endsWith('/') ? endpointUrl.toString() : `${endpointUrl.toString()}/`;
430
-
431
- // Build webhook URLs and append query parameters at the end
432
- const videoUrl = `${normalizedEndpoint}webhook/video/${encodedDeviceId}/${encodedFileId}${queryParams}`;
433
- const thumbnailUrl = `${normalizedEndpoint}webhook/thumbnail/${encodedDeviceId}/${encodedFileId}${queryParams}`;
434
-
435
- return { videoUrl, thumbnailUrl };
436
- } catch (e) {
437
- log.error?.(
438
- `[getVideoClipWebhookUrls] Failed to generate webhook URLs: deviceId=${deviceId}, fileId=${fileId}`,
439
- e?.message || String(e)
440
- );
441
- throw e;
442
- }
443
- }
257
+ const { deviceId, fileId, plugin, logger } = props;
258
+ const log = logger || plugin.console;
444
259
 
445
- /**
446
- * Extract a thumbnail frame from video using ffmpeg
447
- */
448
- export async function extractThumbnailFromVideo(props: {
449
- rtmpUrl?: string;
450
- filePath?: string;
451
- fileId: string;
452
- deviceId: string;
453
- logger?: Console;
454
- device?: ReolinkCamera;
455
- }): Promise<MediaObject> {
456
- const { rtmpUrl, filePath, fileId, deviceId, device } = props;
457
- // Use device logger if available, otherwise fallback to provided logger
458
- const logger = device?.getBaichuanLogger?.() || props.logger || console;
459
-
460
- // Use file path if available, otherwise use RTMP URL
461
- const inputSource = filePath || rtmpUrl;
462
- if (!inputSource) {
463
- throw new Error('Either rtmpUrl or filePath must be provided');
464
- }
260
+ // log.debug?.(`[getVideoClipWebhookUrls] Starting URL generation: deviceId=${deviceId}, fileId=${fileId}`);
465
261
 
262
+ try {
263
+ let endpoint: string;
264
+ let endpointSource: "cloud" | "local";
466
265
  try {
467
- // Use createFFmpegMediaObject which handles codec detection better
468
- // For Download URLs from NVR, they might return only a short segment, so use 1 second instead of 5
469
- const mo = await sdk.mediaManager.createFFmpegMediaObject({
470
- inputArguments: [
471
- '-ss', '00:00:01', // Seek to 1 second (safer for short segments from NVR Download URLs)
472
- '-i', inputSource,
473
- ],
474
- });
475
- return mo;
266
+ endpoint = await sdk.endpointManager.getCloudEndpoint(undefined, {
267
+ public: true,
268
+ });
269
+ endpointSource = "cloud";
270
+ // log.debug?.(`[getVideoClipWebhookUrls] Using cloud endpoint: ${endpoint}`);
476
271
  } catch (e) {
477
- // Error already logged in main.ts
478
- throw e?.message || String(e);
479
- }
480
- }
481
-
482
- /**
483
- * Get cache file path for a video clip
484
- */
485
- function getVideoClipCachePath(deviceId: string, fileId: string): string {
486
- const pluginVolume = process.env.SCRYPTED_PLUGIN_VOLUME || '';
487
- // Create a safe filename from fileId using hash
488
- const hash = crypto.createHash('md5').update(fileId).digest('hex');
489
- // Keep original extension if present, otherwise use .mp4
490
- const ext = fileId.includes('.') ? path.extname(fileId) : '.mp4';
491
- const cacheDir = path.join(pluginVolume, 'videoclips', deviceId);
492
- return path.join(cacheDir, `${hash}${ext}`);
272
+ // Fallback to local endpoint if cloud is not available (e.g., not logged in)
273
+ log.debug?.(
274
+ `[getVideoClipWebhookUrls] Cloud endpoint not available, using local endpoint: ${e?.message || String(e)}`,
275
+ );
276
+ endpoint = await sdk.endpointManager.getLocalEndpoint(undefined, {
277
+ public: true,
278
+ });
279
+ endpointSource = "local";
280
+ // log.debug?.(`[getVideoClipWebhookUrls] Using local endpoint: ${endpoint}`);
281
+ }
282
+
283
+ const encodedDeviceId = encodeURIComponent(deviceId);
284
+ // Remove leading slash from fileId if present, as it causes invalid paths when encoded
285
+ const cleanFileId = fileId.startsWith("/") ? fileId.substring(1) : fileId;
286
+ const encodedFileId = encodeURIComponent(cleanFileId);
287
+
288
+ // Parse endpoint URL to extract query parameters (for authentication)
289
+ const endpointUrl = new URL(endpoint);
290
+ // Preserve query parameters (e.g., user_token for authentication)
291
+ const queryParams = endpointUrl.search;
292
+ // Remove query parameters from the base endpoint URL
293
+ endpointUrl.search = "";
294
+
295
+ // Ensure endpoint has trailing slash
296
+ const normalizedEndpoint = endpointUrl.toString().endsWith("/")
297
+ ? endpointUrl.toString()
298
+ : `${endpointUrl.toString()}/`;
299
+
300
+ // Build webhook URLs and append query parameters at the end
301
+ const videoUrl = `${normalizedEndpoint}webhook/video/${encodedDeviceId}/${encodedFileId}${queryParams}`;
302
+ const thumbnailUrl = `${normalizedEndpoint}webhook/thumbnail/${encodedDeviceId}/${encodedFileId}${queryParams}`;
303
+
304
+ return { videoUrl, thumbnailUrl };
305
+ } catch (e) {
306
+ log.error?.(
307
+ `[getVideoClipWebhookUrls] Failed to generate webhook URLs: deviceId=${deviceId}, fileId=${fileId}`,
308
+ e?.message || String(e),
309
+ );
310
+ throw e;
311
+ }
493
312
  }
494
313
 
495
314
  /**
496
315
  * Handle video clip webhook request
497
- * Checks cache first, then proxies RTMP stream if not cached
316
+ * Uses progressive streaming for immediate playback.
317
+ * Stream management (stopping previous streams, cooldown) is handled by the API layer
318
+ * in ReolinkBaichuanApi.createRecordingReplayMp4Stream via activeReplayStreams per channel.
498
319
  */
499
320
  export async function handleVideoClipRequest(props: {
500
- device: ReolinkCamera;
501
- deviceId: string;
502
- fileId: string;
503
- request: HttpRequest;
504
- response: HttpResponse;
505
- logger?: Console;
321
+ device: ReolinkCamera;
322
+ deviceId: string;
323
+ fileId: string;
324
+ request: HttpRequest;
325
+ response: HttpResponse;
326
+ logger?: Console;
506
327
  }): Promise<void> {
507
- const { device, deviceId, fileId, request, response } = props;
508
- const logger = device.getBaichuanLogger?.() || props.logger || console;
509
-
510
- // Check if file is cached
511
- const cachePath = getVideoClipCachePath(deviceId, fileId);
512
- const MIN_VIDEO_CACHE_BYTES = 16 * 1024; // 16KB, evita file quasi vuoti/corrotti
513
-
514
- try {
515
- // Check if cached file exists
516
- const stat = await fs.promises.stat(cachePath);
517
- const fileSize = stat.size;
518
- const range = request.headers.range;
519
-
520
- if (fileSize < MIN_VIDEO_CACHE_BYTES) {
521
- logger.warn(`Cached video clip too small, deleting and reloading: fileId=${fileId}, size=${fileSize} bytes`);
522
- try {
523
- await fs.promises.unlink(cachePath);
524
- } catch (unlinkErr) {
525
- logger.warn(`Failed to delete small cached video clip: fileId=${fileId}`, unlinkErr?.message || String(unlinkErr));
526
- }
527
- // Force cache miss path below
528
- throw new Error('Cached video too small, deleted');
529
- }
530
-
531
- logger.log(`Serving cached video clip: fileId=${fileId}, size=${fileSize}, range=${range}`);
532
-
533
- if (range) {
534
- // Parse range header
535
- const parts = range.replace(/bytes=/, "").split("-");
536
- const start = parseInt(parts[0], 10);
537
- const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
538
-
539
- const chunksize = (end - start) + 1;
540
- const file = fs.createReadStream(cachePath, { start, end });
541
-
542
- // Send stream with range support
543
- response.sendStream((async function* () {
544
- for await (const chunk of file) {
545
- yield chunk;
546
- }
547
- })(), {
548
- code: 206,
549
- headers: {
550
- 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
551
- 'Accept-Ranges': 'bytes',
552
- 'Content-Length': chunksize.toString(),
553
- 'Content-Type': 'video/mp4',
554
- }
555
- });
556
- return;
557
- } else {
558
- // No range header, send full file
559
- response.sendFile(cachePath, {
560
- code: 200,
561
- headers: {
562
- 'Content-Length': fileSize.toString(),
563
- 'Content-Type': 'video/mp4',
564
- 'Accept-Ranges': 'bytes',
565
- }
566
- });
567
- return;
568
- }
569
- } catch (e) {
570
- // File not cached, need to proxy RTMP stream
571
- logger.log(`[VideoClip] Stream start: fileId=${fileId}`);
572
-
573
- // Get RTMP URL using the appropriate API (NVR or Baichuan)
574
- let rtmpVodUrl: string | undefined;
328
+ const { device, fileId, request, response } = props;
329
+ const logger = device.getBaichuanLogger?.() || props.logger || console;
330
+ const useHttpSource =
331
+ device.storageSettings?.values?.videoclipSource === "HTTP";
332
+
333
+ logger.log(
334
+ `[VideoClip] REQUEST: fileId=${fileId.slice(-40)}, isOnNvr=${device.isOnNvr}, source=${useHttpSource ? "HTTP" : "Native"}`,
335
+ );
336
+
337
+ try {
338
+ const api = await device.ensureClient();
339
+ const channel = device.storageSettings?.values?.rtspChannel ?? 0;
340
+
341
+ if (useHttpSource) {
342
+ // HTTP mode: use CGI API to download the video file
343
+ logger.debug(`[VideoClip] Using CGI API (HTTP) to download: ${fileId}`);
344
+
345
+ const mp4Buffer = await api.downloadVod(fileId, {
346
+ output: fileId,
347
+ });
348
+
349
+ logger.debug(`[VideoClip] Downloaded via CGI: ${mp4Buffer.length} bytes`);
350
+
351
+ // Send the buffer as a complete response
352
+ const CHUNK_SIZE = 64 * 1024; // 64KB chunks
353
+ response.sendStream(
354
+ (async function* () {
355
+ let offset = 0;
356
+ while (offset < mp4Buffer.length) {
357
+ const end = Math.min(offset + CHUNK_SIZE, mp4Buffer.length);
358
+ yield mp4Buffer.subarray(offset, end);
359
+ offset = end;
360
+ }
361
+ })(),
362
+ {
363
+ code: 200,
364
+ headers: {
365
+ "Content-Type": "video/mp4",
366
+ "Content-Length": mp4Buffer.length.toString(),
367
+ "Cache-Control": "no-cache",
368
+ },
369
+ },
370
+ );
371
+ return;
372
+ }
373
+
374
+ // Native mode: use Baichuan streaming replay
375
+ // Add error handler to prevent uncaughtException from client socket errors
376
+ const onClientError = (err: Error) => {
377
+ logger.warn?.(
378
+ `[VideoClip] Client error during stream: ${err?.message || "unknown"}`,
379
+ );
380
+ };
381
+ api.client.on("error", onClientError);
382
+
383
+ // Use streaming replay - this starts immediately and produces fMP4 chunks
384
+ // Stream management (stopping previous streams, cooldown) is handled by the API layer
385
+ // Generate a unique session ID based on client fingerprint (UA + IP + other factors)
386
+ // This allows the same client to reuse the dedicated socket when switching clips
387
+ const clientFingerprint = [
388
+ request.headers?.["user-agent"] || "",
389
+ request.headers?.["x-forwarded-for"] ||
390
+ request.headers?.["x-real-ip"] ||
391
+ "",
392
+ request.headers?.["accept-language"] || "",
393
+ request.headers?.["accept-encoding"] || "",
394
+ ].join("|");
395
+ const sessionId =
396
+ request.headers?.["x-request-id"] ||
397
+ crypto
398
+ .createHash("sha256")
399
+ .update(clientFingerprint)
400
+ .digest("hex")
401
+ .slice(0, 16);
402
+ const { mp4: mp4Stream, stop } = await api.createRecordingReplayMp4Stream({
403
+ channel,
404
+ fileName: fileId,
405
+ isNvr: device.isOnNvr,
406
+ logger,
407
+ deviceId: sessionId,
408
+ });
409
+
410
+ let totalSize = 0;
411
+
412
+ // Simple response - no range support
413
+ response.sendStream(
414
+ (async function* () {
575
415
  try {
576
- rtmpVodUrl = await device.getVideoClipRtmpUrl(fileId);
577
- } catch (e2) {
578
- logger.error(`[VideoClip] Stream error: fileId=${fileId}`, e2?.message || String(e2));
579
- response.send('Failed to get RTMP playback URL', { code: 500 });
580
- return;
581
- }
582
-
583
- if (!rtmpVodUrl) {
584
- logger.error(`[VideoClip] Stream error: fileId=${fileId} - No URL found`);
585
- response.send('No RTMP playback URL found for video', { code: 404 });
586
- return;
587
- }
588
-
589
- // Check if URL is HTTP (Playback/Download) or RTMP
590
- const isHttpUrl = rtmpVodUrl.startsWith('http://') || rtmpVodUrl.startsWith('https://');
591
-
592
- if (isHttpUrl) {
593
- // For HTTP URLs (Playback/Download from NVR), do direct HTTP proxy with ranged headers support
594
- logger.debug(`Proxying HTTP URL directly: fileId=${fileId}, url=${rtmpVodUrl}`);
595
-
596
- const sendVideo = async () => {
597
- // Pre-fetch ffmpeg path in case we need it for FLV conversion
598
- const ffmpegPathPromise = sdk.mediaManager.getFFmpegPath();
599
-
600
- return new Promise<void>(async (resolve, reject) => {
601
- const urlObj = new URL(rtmpVodUrl);
602
- const httpModule = urlObj.protocol === 'https:' ? https : http;
603
-
604
- // Filter and prepare headers (remove host, connection, etc. that shouldn't be forwarded)
605
- const requestHeaders: Record<string, string> = {};
606
- if (request.headers.range) {
607
- requestHeaders['Range'] = request.headers.range;
608
- }
609
- // Add other headers that might be needed
610
- if (request.headers['user-agent']) {
611
- requestHeaders['User-Agent'] = request.headers['user-agent'];
612
- }
613
-
614
- const options = {
615
- hostname: urlObj.hostname,
616
- port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
617
- path: urlObj.pathname + urlObj.search,
618
- method: 'GET',
619
- headers: requestHeaders,
620
- };
621
-
622
- logger.debug(`Starting HTTP request: ${rtmpVodUrl}, headers: ${JSON.stringify(requestHeaders)}`);
623
-
624
- httpModule.get(options, async (httpResponse) => {
625
- if (httpResponse.statusCode && httpResponse.statusCode >= 400) {
626
- logger.error(`HTTP error: status=${httpResponse.statusCode}, message=${httpResponse.statusMessage}`);
627
- reject(new Error(`Error loading the video: ${httpResponse.statusCode} - ${httpResponse.statusMessage}`));
628
- return;
629
- }
630
-
631
- let contentType = httpResponse.headers['content-type'] || 'video/mp4';
632
- const contentLength = httpResponse.headers['content-length'];
633
- const contentRange = httpResponse.headers['content-range'];
634
- const acceptRanges = httpResponse.headers['accept-ranges'] || 'bytes';
635
-
636
- // Check if we need to convert FLV to MP4
637
- const isFlv = typeof contentType === 'string' && (contentType === 'video/x-flv' || contentType === 'video/flv');
638
-
639
- if (isFlv) {
640
- logger.debug(`Content-Type is FLV (${contentType}), will convert to MP4 using ffmpeg`);
641
- }
642
-
643
- const responseHeaders: Record<string, string> = {
644
- 'Content-Type': typeof contentType === 'string' ? contentType : 'video/mp4',
645
- 'Accept-Ranges': typeof acceptRanges === 'string' ? acceptRanges : 'bytes',
646
- 'Cache-Control': 'no-cache',
647
- };
648
-
649
- if (contentLength) {
650
- responseHeaders['Content-Length'] = typeof contentLength === 'string' ? contentLength : String(contentLength);
651
- }
652
-
653
- if (contentRange) {
654
- responseHeaders['Content-Range'] = typeof contentRange === 'string' ? contentRange : String(contentRange);
655
- }
656
-
657
- const statusCode = httpResponse.statusCode || 200;
658
-
659
- logger.debug(`HTTP response received: status=${statusCode}, contentType=${contentType}, contentLength=${contentLength || 'unknown'}`);
660
-
661
- try {
662
- if (isFlv) {
663
- // Convert FLV to MP4 using ffmpeg
664
- const ffmpegPath = await ffmpegPathPromise;
665
- // Re-encode instead of copy because FLV codec might not be supported
666
- const ffmpegArgs: string[] = [
667
- '-i', 'pipe:0', // Read from stdin (httpResponse)
668
- '-c:v', 'libx264', // Re-encode video to H.264
669
- '-preset', 'ultrafast', // Fast encoding for streaming
670
- '-tune', 'zerolatency', // Low latency
671
- '-c:a', 'aac', // Re-encode audio to AAC
672
- '-f', 'mp4',
673
- '-movflags', 'frag_keyframe+empty_moov', // Enable streaming
674
- 'pipe:1', // Output to stdout
675
- ];
676
-
677
- const ffmpeg = spawn(ffmpegPath, ffmpegArgs, {
678
- stdio: ['pipe', 'pipe', 'pipe'],
679
- });
680
-
681
- let ffmpegError = '';
682
- ffmpeg.stderr.on('data', (chunk: Buffer) => {
683
- ffmpegError += chunk.toString();
684
- });
685
-
686
- // Pipe httpResponse to ffmpeg stdin
687
- httpResponse.pipe(ffmpeg.stdin);
688
-
689
- ffmpeg.stdin.on('error', (err) => {
690
- // Ignore EPIPE errors when ffmpeg closes
691
- if ((err as any).code !== 'EPIPE') {
692
- logger.error(`FFmpeg stdin error: fileId=${fileId}`, err?.message || String(err));
693
- }
694
- });
695
-
696
- httpResponse.on('error', (err) => {
697
- logger.error(`HTTP response error before ffmpeg: fileId=${fileId}`, err?.message || String(err));
698
- try {
699
- ffmpeg.kill('SIGKILL');
700
- } catch (e) {
701
- // Ignore
702
- }
703
- });
704
-
705
- let streamStarted = false;
706
-
707
- // Stream ffmpeg output
708
- response.sendStream((async function* () {
709
- try {
710
- for await (const chunk of ffmpeg.stdout) {
711
- if (!streamStarted) {
712
- streamStarted = true;
713
- }
714
- yield chunk;
715
- }
716
- } catch (e) {
717
- logger.error(`Error streaming ffmpeg output: fileId=${fileId}`, e?.message || String(e));
718
- throw e;
719
- } finally {
720
- // Clean up ffmpeg process
721
- try {
722
- ffmpeg.kill('SIGKILL');
723
- } catch (e) {
724
- // Ignore
725
- }
726
- }
727
- })(), {
728
- code: 200,
729
- headers: {
730
- 'Content-Type': 'video/mp4',
731
- 'Accept-Ranges': 'bytes',
732
- 'Cache-Control': 'no-cache',
733
- },
734
- });
735
-
736
- // Handle ffmpeg errors
737
- ffmpeg.on('close', (code) => {
738
- if (code !== 0 && code !== null && !streamStarted) {
739
- const sanitized = sanitizeFfmpegOutput(ffmpegError);
740
- logger.error(`FFmpeg conversion failed: fileId=${fileId}, code=${code}, error=${sanitized}`);
741
- reject(new Error(`FFmpeg conversion failed: ${code}`));
742
- } else {
743
- logger.debug(`FFmpeg conversion completed: fileId=${fileId}, code=${code}`);
744
- resolve();
745
- }
746
- });
747
-
748
- ffmpeg.on('error', (error) => {
749
- logger.error(`FFmpeg spawn error: fileId=${fileId}`, error?.message || String(error));
750
- reject(error);
751
- });
752
-
753
- logger.debug(`FFmpeg conversion started: fileId=${fileId}`);
754
- } else {
755
- // Direct proxy for non-FLV content (should be MP4 already)
756
- // Stream directly without buffering - yield chunks as they arrive
757
- response.sendStream((async function* () {
758
- try {
759
- for await (const chunk of Readable.from(httpResponse)) {
760
- yield chunk;
761
- }
762
- logger.log(`[VideoClip] Stream end: fileId=${fileId}`);
763
- } catch (streamErr) {
764
- logger.error(`[VideoClip] Stream error: fileId=${fileId}`, streamErr?.message || String(streamErr));
765
- throw streamErr;
766
- }
767
- })(), {
768
- code: statusCode,
769
- headers: responseHeaders,
770
- });
771
-
772
- resolve();
773
- }
774
- } catch (err) {
775
- logger.error(`Error sending stream: fileId=${fileId}`, err?.message || String(err));
776
- reject(err);
777
- }
778
- }).on('error', (e) => {
779
- logger.error(`Error fetching videoclip: fileId=${fileId}`, e?.message || String(e));
780
- reject(e);
781
- });
782
- });
783
- };
784
-
785
- try {
786
- await sendVideo();
787
- return;
788
- } catch (e) {
789
- logger.error(`HTTP proxy error: fileId=${fileId}`, e?.message || String(e));
790
- response.send('Failed to proxy HTTP stream', { code: 500 });
791
- return;
792
- }
416
+ for await (const chunk of mp4Stream) {
417
+ yield chunk;
418
+ totalSize += chunk.length;
419
+ }
420
+ } catch (e: any) {
421
+ // Stream error - library handles logging
422
+ } finally {
423
+ // Remove the error handler
424
+ api.client.off("error", onClientError);
425
+ await stop().catch(() => {});
793
426
  }
794
-
795
- // For RTMP URLs (camera standalone), use ffmpeg
796
- const ffmpegPath = await sdk.mediaManager.getFFmpegPath();
797
- const ffmpegArgs: string[] = [
798
- '-i', rtmpVodUrl,
799
- '-c', 'copy', // Copy codecs without re-encoding
800
- '-f', 'mp4',
801
- '-movflags', 'frag_keyframe+empty_moov', // Enable streaming
802
- 'pipe:1', // Output to stdout
803
- ];
804
-
805
- const ffmpeg = spawn(ffmpegPath, ffmpegArgs, {
806
- stdio: ['ignore', 'pipe', 'pipe'],
807
- });
808
-
809
- let ffmpegError = '';
810
- ffmpeg.stderr.on('data', (chunk: Buffer) => {
811
- ffmpegError += chunk.toString();
812
- });
813
-
814
- let streamStarted = false;
815
-
816
- // Stream the output
817
- response.sendStream((async function* () {
818
- try {
819
- for await (const chunk of ffmpeg.stdout) {
820
- if (!streamStarted) {
821
- streamStarted = true;
822
- }
823
- yield chunk;
824
- }
825
- logger.log(`[VideoClip] Stream end: fileId=${fileId}`);
826
- } catch (e) {
827
- logger.error(`[VideoClip] Stream error: fileId=${fileId}`, e?.message || String(e));
828
- throw e;
829
- } finally {
830
- // Clean up ffmpeg process
831
- try {
832
- ffmpeg.kill('SIGKILL');
833
- } catch (e) {
834
- // Ignore
835
- }
836
- }
837
- })(), {
838
- code: 200,
839
- headers: {
840
- 'Content-Type': 'video/mp4',
841
- 'Accept-Ranges': 'bytes',
842
- 'Cache-Control': 'no-cache',
843
- },
844
- });
845
-
846
- // Handle ffmpeg errors
847
- ffmpeg.on('close', (code) => {
848
- if (code !== 0 && code !== null && !streamStarted) {
849
- const sanitized = sanitizeFfmpegOutput(ffmpegError);
850
- logger.error(`FFmpeg proxy failed for video: fileId=${fileId}, code=${code}, error=${sanitized}`);
851
- }
852
- });
853
-
854
- ffmpeg.on('error', (error) => {
855
- logger.error(`FFmpeg spawn error for video proxy: fileId=${fileId}`, error?.message || String(error));
856
- });
857
-
858
- return;
859
- }
860
- }
861
-
862
- /**
863
- * Parse recordType string to extract detection classes
864
- * Based on the same logic as ReolinkCgiApi.parseRecordTypeFlags
865
- */
866
- function parseRecordTypeToDetectionClasses(recordType?: string): string[] {
867
- const detectionClasses: string[] = [];
868
-
869
- if (!recordType) {
870
- return ['Motion']; // Default to Motion if no type specified
871
- }
872
-
873
- // Split by comma or whitespace and process each type
874
- const types = recordType.toLowerCase().split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
875
-
876
- let hasMotion = false;
877
-
878
- for (const t of types) {
879
- if (t === "people" || t === "person") {
880
- detectionClasses.push('Person');
881
- } else if (t === "vehicle" || t === "car") {
882
- detectionClasses.push('Vehicle');
883
- } else if (t === "dog_cat" || t === "animal" || t === "pet") {
884
- detectionClasses.push('Animal');
885
- } else if (t === "face") {
886
- detectionClasses.push('Face');
887
- } else if (t === "md" || t === "motion") {
888
- hasMotion = true;
889
- } else if (t === "visitor" || t === "doorbell") {
890
- detectionClasses.push('Doorbell');
891
- } else if (t === "package") {
892
- detectionClasses.push('Package');
893
- }
894
- }
895
-
896
- // Always include Motion as base (if not already added or if no other detections)
897
- if (hasMotion || detectionClasses.length === 0) {
898
- detectionClasses.unshift('Motion');
899
- }
900
-
901
- return detectionClasses;
902
- }
903
-
904
- /**
905
- * Extract detection classes from parsed filename flags (hex decoding)
906
- */
907
- function parseFilenameFlagsToDetectionClasses(parsed?: ParsedRecordingFileName): string[] {
908
- const detectionClasses: string[] = [];
909
- const flags = parsed?.flags;
910
-
911
- if (!flags) {
912
- return [];
913
- }
914
-
915
- // Extract detection types from hex flags (same logic as enrichVodFile)
916
- if (flags.aiPerson) {
917
- detectionClasses.push('Person');
918
- }
919
- if (flags.aiVehicle) {
920
- detectionClasses.push('Vehicle');
921
- }
922
- if (flags.aiAnimal) {
923
- detectionClasses.push('Animal');
924
- }
925
- if (flags.aiFace) {
926
- detectionClasses.push('Face');
927
- }
928
- if (flags.motion) {
929
- detectionClasses.push('Motion');
930
- }
931
- if (flags.doorbell) {
932
- detectionClasses.push('Doorbell');
933
- }
934
- if (flags.package) {
935
- detectionClasses.push('Package');
936
- }
937
-
938
- return detectionClasses;
939
- }
940
-
941
- /**
942
- * Convert Reolink time format to Date
943
- * Uses UTC to match the API's dateToReolinkTime conversion
944
- */
945
- function reolinkTimeToDate(time: { year: number; mon: number; day: number; hour: number; min: number; sec: number }): Date {
946
- return new Date(Date.UTC(
947
- time.year,
948
- time.mon - 1,
949
- time.day,
950
- time.hour,
951
- time.min,
952
- time.sec
953
- ));
954
- }
955
-
956
- /**
957
- * Convert VOD search results to VideoClip array
958
- */
959
- export async function vodSearchResultsToVideoClips(
960
- vodResults: Array<VodSearchResponse>,
961
- options: {
962
- deviceId: string;
963
- plugin: ScryptedDeviceBase;
964
- logger?: Console;
965
- }
966
- ): Promise<VideoClip[]> {
967
- const { deviceId, plugin, logger } = options;
968
- const clips: VideoClip[] = [];
969
-
970
- // Import parseRecordingFileName once (it's exported from the package)
971
- const reolinkModule = await import("@apocaliss92/reolink-baichuan-js");
972
- const parseRecordingFileName = reolinkModule.parseRecordingFileName;
973
-
974
- // Process VOD search results
975
- for (const result of vodResults) {
976
- if (result.code !== 0) {
977
- logger?.debug(`VOD search result code: ${result.code}`, result.error);
978
- continue;
979
- }
980
-
981
- const searchResult = result.value?.SearchResult;
982
- if (!searchResult?.File || !Array.isArray(searchResult.File)) {
983
- continue;
984
- }
985
-
986
- // Convert each VOD file to VideoClip
987
- for (const file of searchResult.File) {
988
- try {
989
- // Parse filename to extract flags (like enrichVodFile does)
990
- const parsed = parseRecordingFileName(file.name);
991
-
992
- // Get times from parsed filename or from StartTime/EndTime
993
- // Camera times in StartTime/EndTime are in local timezone (not UTC)
994
- // Use local time constructor to preserve the timezone
995
- const fileStart = parsed?.start ?? new Date(
996
- file.StartTime.year,
997
- file.StartTime.mon - 1,
998
- file.StartTime.day,
999
- file.StartTime.hour,
1000
- file.StartTime.min,
1001
- file.StartTime.sec
1002
- );
1003
- const fileEnd = parsed?.end ?? new Date(
1004
- file.EndTime.year,
1005
- file.EndTime.mon - 1,
1006
- file.EndTime.day,
1007
- file.EndTime.hour,
1008
- file.EndTime.min,
1009
- file.EndTime.sec
1010
- );
1011
-
1012
- const duration = fileEnd.getTime() - fileStart.getTime();
1013
- const fileName = file.name || '';
1014
-
1015
- // Extract detection classes from both filename flags (hex) and file.type
1016
- const filenameFlags = parseFilenameFlagsToDetectionClasses(parsed);
1017
- const typeFlags = parseRecordTypeToDetectionClasses(file.type);
1018
-
1019
- // Debug: log file.type to see what we're parsing
1020
- if (logger && file.type) {
1021
- logger.debug(`[VOD] Parsing file.type="${file.type}" for file=${fileName}, filenameFlags=${filenameFlags.join(',')}, typeFlags=${typeFlags.join(',')}`);
1022
- }
1023
-
1024
- // Merge both sources (OR them together, like enrichVodFile does)
1025
- // Remove duplicates and ensure Motion is included if any detection is found
1026
- const allDetections = [...filenameFlags, ...typeFlags];
1027
- const detectionClasses = [...new Set(allDetections)];
1028
-
1029
- // If we have detections from filename flags, use those (they're more accurate)
1030
- // Otherwise use type flags, or default to Motion
1031
- if (detectionClasses.length === 0) {
1032
- detectionClasses.push('Motion');
1033
- } else if (!detectionClasses.includes('Motion') && (filenameFlags.length > 0 || typeFlags.some(t => t.toLowerCase().includes('motion') || t.toLowerCase().includes('md')))) {
1034
- // Add Motion if we have other detections but Motion is not explicitly included
1035
- detectionClasses.push('Motion');
1036
- }
1037
-
1038
- // Generate webhook URLs
1039
- let videoHref: string | undefined;
1040
- let thumbnailHref: string | undefined;
1041
- try {
1042
- const { videoUrl, thumbnailUrl } = await getVideoClipWebhookUrls({
1043
- deviceId,
1044
- fileId: fileName,
1045
- plugin,
1046
- logger,
1047
- });
1048
- videoHref = videoUrl;
1049
- thumbnailHref = thumbnailUrl;
1050
- } catch (e) {
1051
- logger?.debug('Failed to generate webhook URLs for VOD file', fileName, e?.message || String(e));
1052
- }
1053
-
1054
- const clip: VideoClip = {
1055
- id: fileName,
1056
- description: fileName,
1057
- startTime: fileStart.getTime(),
1058
- duration,
1059
- resources: {
1060
- video: videoHref ? { href: videoHref } : undefined,
1061
- thumbnail: thumbnailHref ? { href: thumbnailHref } : undefined,
1062
- },
1063
- detectionClasses,
1064
- };
1065
-
1066
- clips.push(clip);
1067
- } catch (e) {
1068
- logger?.warn(`Failed to convert VOD file to clip: ${file.name}`, e?.message || String(e));
1069
- }
1070
- }
1071
- }
1072
-
1073
- return clips;
427
+ })(),
428
+ {
429
+ code: 200,
430
+ headers: {
431
+ "Content-Type": "video/mp4",
432
+ "Cache-Control": "no-cache",
433
+ },
434
+ },
435
+ );
436
+ return;
437
+ } catch (streamErr: any) {
438
+ logger.error(
439
+ `[VideoClip] Streaming failed: ${streamErr?.message || String(streamErr)}`,
440
+ );
441
+ response.send(
442
+ `Streaming failed: ${streamErr?.message || "Unknown error"}`,
443
+ {
444
+ code: 500,
445
+ },
446
+ );
447
+ return;
448
+ }
1074
449
  }
1075
450
 
1076
- export const removeAuthUrls = (streams: ReolinkSupportedStream[]) => streams.map(({ urlWithAuth, ...rest }) => ({ rest }));
451
+ export const removeAuthUrls = (streams: ReolinkSupportedStream[]) =>
452
+ streams.map(({ urlWithAuth, ...rest }) => ({ rest }));