@apocaliss92/scrypted-reolink-native 0.3.15 → 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/.vscode/settings.json +1 -1
- package/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/baichuan-base.ts +863 -767
- package/src/camera.ts +3897 -2790
- package/src/intercom.ts +496 -476
- package/src/main.ts +378 -409
- package/src/multiFocal.ts +297 -265
- package/src/nvr.ts +588 -477
- package/src/stream-utils.ts +478 -427
- package/src/utils.ts +384 -1009
package/src/utils.ts
CHANGED
|
@@ -1,45 +1,33 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
import
|
|
8
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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,410 @@ 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`;
|
|
58
48
|
|
|
59
49
|
export const getDeviceInterfaces = (props: {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
50
|
+
capabilities: DeviceCapabilities;
|
|
51
|
+
logger: Console;
|
|
52
|
+
isLensDevice?: boolean;
|
|
63
53
|
}) => {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
54
|
+
const { capabilities, logger, isLensDevice } = props;
|
|
55
|
+
|
|
56
|
+
const interfaces = [
|
|
57
|
+
ScryptedInterface.VideoCamera,
|
|
58
|
+
ScryptedInterface.Settings,
|
|
59
|
+
ScryptedInterface.Reboot,
|
|
60
|
+
ScryptedInterface.VideoCameraConfiguration,
|
|
61
|
+
ScryptedInterface.Camera,
|
|
62
|
+
ScryptedInterface.AudioSensor,
|
|
63
|
+
ScryptedInterface.MotionSensor,
|
|
64
|
+
ScryptedInterface.VideoTextOverlays,
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
if (!isLensDevice) {
|
|
68
|
+
interfaces.push(ScryptedInterface.VideoClips);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const {
|
|
73
|
+
hasPtz,
|
|
74
|
+
hasSiren,
|
|
75
|
+
hasFloodlight,
|
|
76
|
+
hasPir,
|
|
77
|
+
hasBattery,
|
|
78
|
+
hasIntercom,
|
|
79
|
+
isDoorbell,
|
|
80
|
+
} = capabilities;
|
|
81
|
+
|
|
82
|
+
if (hasPtz) {
|
|
83
|
+
interfaces.push(ScryptedInterface.PanTiltZoom);
|
|
84
|
+
}
|
|
85
|
+
interfaces.push(ScryptedInterface.ObjectDetector);
|
|
86
|
+
if (hasSiren || hasFloodlight || hasPir)
|
|
87
|
+
interfaces.push(ScryptedInterface.DeviceProvider);
|
|
88
|
+
if (hasBattery) {
|
|
89
|
+
interfaces.push(ScryptedInterface.Battery, ScryptedInterface.Sleep);
|
|
90
|
+
}
|
|
91
|
+
if (hasIntercom) {
|
|
92
|
+
interfaces.push(ScryptedInterface.Intercom);
|
|
93
|
+
}
|
|
94
|
+
if (isDoorbell) {
|
|
95
|
+
interfaces.push(ScryptedInterface.BinarySensor);
|
|
96
|
+
}
|
|
97
|
+
} catch (e) {
|
|
98
|
+
logger.error("Error getting device interfaces", e?.message || String(e));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
interfaces,
|
|
103
|
+
type: capabilities.isDoorbell
|
|
104
|
+
? ScryptedDeviceType.Doorbell
|
|
105
|
+
: ScryptedDeviceType.Camera,
|
|
106
|
+
};
|
|
107
|
+
};
|
|
113
108
|
|
|
114
109
|
export const updateDeviceInfo = async (props: {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
110
|
+
device: DeviceBase;
|
|
111
|
+
ipAddress: string;
|
|
112
|
+
deviceData: ReolinkDeviceInfo;
|
|
113
|
+
logger: Console;
|
|
119
114
|
}) => {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
115
|
+
const { device, ipAddress, deviceData, logger } = props;
|
|
116
|
+
try {
|
|
117
|
+
const info = device.info || {};
|
|
118
|
+
|
|
119
|
+
info.ip = ipAddress;
|
|
120
|
+
info.serialNumber = deviceData?.serialNumber || deviceData?.itemNo;
|
|
121
|
+
info.firmware = deviceData?.firmwareVersion;
|
|
122
|
+
info.version = deviceData?.hardwareVersion;
|
|
123
|
+
info.model = deviceData?.type;
|
|
124
|
+
info.manufacturer = "Reolink";
|
|
125
|
+
info.managementUrl = `http://${ipAddress}`;
|
|
126
|
+
device.info = info;
|
|
127
|
+
} catch (e) {
|
|
128
|
+
// If API call fails, at least set basic info
|
|
129
|
+
const info = device.info || {};
|
|
130
|
+
info.ip = ipAddress;
|
|
131
|
+
info.manufacturer = "Reolink native";
|
|
132
|
+
info.managementUrl = `http://${ipAddress}`;
|
|
133
|
+
device.info = info;
|
|
134
|
+
|
|
135
|
+
throw e;
|
|
136
|
+
} finally {
|
|
137
|
+
logger.log(`Device info updated`);
|
|
138
|
+
logger.debug(`${JSON.stringify({ newInfo: device.info, deviceData })}`);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
146
141
|
|
|
147
142
|
/**
|
|
148
|
-
* Convert a Reolink RecordingFile
|
|
143
|
+
* Convert a Reolink RecordingFile to a Scrypted VideoClip
|
|
144
|
+
* Simple mapping - all data is already in RecordingFile
|
|
149
145
|
*/
|
|
150
146
|
export async function recordingFileToVideoClip(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
}
|
|
147
|
+
rec: RecordingFile,
|
|
148
|
+
options: {
|
|
149
|
+
/** Plugin instance for generating webhook URLs */
|
|
150
|
+
plugin: ScryptedDeviceBase;
|
|
151
|
+
/** Device ID for webhook URLs */
|
|
152
|
+
deviceId: string;
|
|
153
|
+
/** Logger for debug messages */
|
|
154
|
+
logger?: Console;
|
|
155
|
+
},
|
|
168
156
|
): Promise<VideoClip> {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
157
|
+
const { plugin, deviceId, logger } = options;
|
|
158
|
+
|
|
159
|
+
// Get times from RecordingFile (already parsed)
|
|
160
|
+
const recStart = rec.startTime ?? rec.parsedFileName?.start ?? new Date();
|
|
161
|
+
const recEnd = rec.endTime ?? rec.parsedFileName?.end ?? recStart;
|
|
162
|
+
|
|
163
|
+
const recStartMs = recStart.getTime();
|
|
164
|
+
const recEndMs = Math.max(recEnd.getTime(), recStartMs);
|
|
165
|
+
const duration = recEndMs - recStartMs;
|
|
166
|
+
|
|
167
|
+
// Use fileName as id (for NVR it's the full path like /mnt/...)
|
|
168
|
+
const id = rec.id || rec.fileName;
|
|
169
|
+
|
|
170
|
+
// Generate webhook URLs
|
|
171
|
+
let videoHref: string | undefined;
|
|
172
|
+
let thumbnailHref: string | undefined;
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
const { videoUrl, thumbnailUrl } = await getVideoClipWebhookUrls({
|
|
176
|
+
deviceId,
|
|
177
|
+
fileId: id,
|
|
178
|
+
plugin,
|
|
179
|
+
logger,
|
|
180
|
+
});
|
|
181
|
+
videoHref = videoUrl;
|
|
182
|
+
thumbnailHref = thumbnailUrl;
|
|
183
|
+
} catch (e) {
|
|
184
|
+
logger?.error(
|
|
185
|
+
`[recordingFileToVideoClip] Failed to generate webhook URLs for fileId=${id}:`,
|
|
186
|
+
e?.message || String(e),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Use detectionClasses from RecordingFile (already populated by CGI/Baichuan API)
|
|
191
|
+
// Default to motion if not available
|
|
192
|
+
const detectionClasses = rec.detectionClasses ?? ["motion"];
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
id,
|
|
196
|
+
startTime: recStartMs,
|
|
197
|
+
duration,
|
|
198
|
+
event: rec.recordType,
|
|
199
|
+
description: rec.name || rec.fileName || rec.id || "",
|
|
200
|
+
detectionClasses,
|
|
201
|
+
resources:
|
|
202
|
+
videoHref || thumbnailHref
|
|
322
203
|
? {
|
|
323
204
|
...(videoHref ? { video: { href: videoHref } } : {}),
|
|
324
205
|
...(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
|
-
};
|
|
206
|
+
}
|
|
207
|
+
: undefined,
|
|
208
|
+
};
|
|
338
209
|
}
|
|
339
210
|
|
|
340
211
|
/**
|
|
341
|
-
* Convert an array of RecordingFile
|
|
342
|
-
*
|
|
343
|
-
* Handles both NVR (EnrichedRecordingFile) and device standalone (RecordingFile) cases
|
|
212
|
+
* Convert an array of RecordingFile to VideoClip array
|
|
213
|
+
* Simple mapping with optional limit
|
|
344
214
|
*/
|
|
345
215
|
export async function recordingsToVideoClips(
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
}
|
|
216
|
+
recordings: RecordingFile[],
|
|
217
|
+
options: {
|
|
218
|
+
/** Plugin instance for generating webhook URLs */
|
|
219
|
+
plugin: ScryptedDeviceBase;
|
|
220
|
+
/** Device ID for webhook URLs */
|
|
221
|
+
deviceId: string;
|
|
222
|
+
/** Logger for debug messages */
|
|
223
|
+
logger?: Console;
|
|
224
|
+
/** Maximum number of clips to return (optional) */
|
|
225
|
+
count?: number;
|
|
226
|
+
},
|
|
363
227
|
): Promise<VideoClip[]> {
|
|
364
|
-
|
|
365
|
-
const clips: VideoClip[] = [];
|
|
228
|
+
const { plugin, deviceId, logger, count } = options;
|
|
366
229
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
}
|
|
230
|
+
const clipPromises = recordings.map(async (rec) => {
|
|
231
|
+
try {
|
|
232
|
+
return await recordingFileToVideoClip(rec, { plugin, deviceId, logger });
|
|
233
|
+
} catch (e) {
|
|
234
|
+
logger?.warn(
|
|
235
|
+
`Failed to convert recording to video clip: fileName=${rec.fileName}`,
|
|
236
|
+
e?.message || String(e),
|
|
237
|
+
);
|
|
238
|
+
return null;
|
|
381
239
|
}
|
|
240
|
+
});
|
|
382
241
|
|
|
383
|
-
|
|
384
|
-
|
|
242
|
+
const clips = await Promise.all(clipPromises);
|
|
243
|
+
const validClips = clips.filter((c): c is VideoClip => c !== null);
|
|
244
|
+
return count ? validClips.slice(0, count) : validClips;
|
|
385
245
|
}
|
|
386
246
|
|
|
387
247
|
/**
|
|
388
248
|
* Generate webhook URLs for video clips
|
|
389
249
|
*/
|
|
390
250
|
export async function getVideoClipWebhookUrls(props: {
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
251
|
+
deviceId: string;
|
|
252
|
+
fileId: string;
|
|
253
|
+
plugin: ScryptedDeviceBase;
|
|
254
|
+
logger?: Console;
|
|
395
255
|
}): Promise<{ videoUrl: string; thumbnailUrl: string }> {
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
}
|
|
256
|
+
const { deviceId, fileId, plugin, logger } = props;
|
|
257
|
+
const log = logger || plugin.console;
|
|
444
258
|
|
|
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
|
-
}
|
|
259
|
+
// log.debug?.(`[getVideoClipWebhookUrls] Starting URL generation: deviceId=${deviceId}, fileId=${fileId}`);
|
|
465
260
|
|
|
261
|
+
try {
|
|
262
|
+
let endpoint: string;
|
|
263
|
+
let endpointSource: "cloud" | "local";
|
|
466
264
|
try {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
'-i', inputSource,
|
|
473
|
-
],
|
|
474
|
-
});
|
|
475
|
-
return mo;
|
|
265
|
+
endpoint = await sdk.endpointManager.getCloudEndpoint(undefined, {
|
|
266
|
+
public: true,
|
|
267
|
+
});
|
|
268
|
+
endpointSource = "cloud";
|
|
269
|
+
// log.debug?.(`[getVideoClipWebhookUrls] Using cloud endpoint: ${endpoint}`);
|
|
476
270
|
} catch (e) {
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
const
|
|
489
|
-
//
|
|
490
|
-
const
|
|
491
|
-
const
|
|
492
|
-
|
|
271
|
+
// Fallback to local endpoint if cloud is not available (e.g., not logged in)
|
|
272
|
+
log.debug?.(
|
|
273
|
+
`[getVideoClipWebhookUrls] Cloud endpoint not available, using local endpoint: ${e?.message || String(e)}`,
|
|
274
|
+
);
|
|
275
|
+
endpoint = await sdk.endpointManager.getLocalEndpoint(undefined, {
|
|
276
|
+
public: true,
|
|
277
|
+
});
|
|
278
|
+
endpointSource = "local";
|
|
279
|
+
// log.debug?.(`[getVideoClipWebhookUrls] Using local endpoint: ${endpoint}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const encodedDeviceId = encodeURIComponent(deviceId);
|
|
283
|
+
// Remove leading slash from fileId if present, as it causes invalid paths when encoded
|
|
284
|
+
const cleanFileId = fileId.startsWith("/") ? fileId.substring(1) : fileId;
|
|
285
|
+
const encodedFileId = encodeURIComponent(cleanFileId);
|
|
286
|
+
|
|
287
|
+
// Parse endpoint URL to extract query parameters (for authentication)
|
|
288
|
+
const endpointUrl = new URL(endpoint);
|
|
289
|
+
// Preserve query parameters (e.g., user_token for authentication)
|
|
290
|
+
const queryParams = endpointUrl.search;
|
|
291
|
+
// Remove query parameters from the base endpoint URL
|
|
292
|
+
endpointUrl.search = "";
|
|
293
|
+
|
|
294
|
+
// Ensure endpoint has trailing slash
|
|
295
|
+
const normalizedEndpoint = endpointUrl.toString().endsWith("/")
|
|
296
|
+
? endpointUrl.toString()
|
|
297
|
+
: `${endpointUrl.toString()}/`;
|
|
298
|
+
|
|
299
|
+
// Build webhook URLs and append query parameters at the end
|
|
300
|
+
const videoUrl = `${normalizedEndpoint}webhook/video/${encodedDeviceId}/${encodedFileId}${queryParams}`;
|
|
301
|
+
const thumbnailUrl = `${normalizedEndpoint}webhook/thumbnail/${encodedDeviceId}/${encodedFileId}${queryParams}`;
|
|
302
|
+
|
|
303
|
+
return { videoUrl, thumbnailUrl };
|
|
304
|
+
} catch (e) {
|
|
305
|
+
log.error?.(
|
|
306
|
+
`[getVideoClipWebhookUrls] Failed to generate webhook URLs: deviceId=${deviceId}, fileId=${fileId}`,
|
|
307
|
+
e?.message || String(e),
|
|
308
|
+
);
|
|
309
|
+
throw e;
|
|
310
|
+
}
|
|
493
311
|
}
|
|
494
312
|
|
|
495
313
|
/**
|
|
496
314
|
* Handle video clip webhook request
|
|
497
|
-
*
|
|
315
|
+
* Uses progressive streaming for immediate playback.
|
|
316
|
+
* Stream management (stopping previous streams, cooldown) is handled by the API layer
|
|
317
|
+
* in ReolinkBaichuanApi.createRecordingReplayMp4Stream via activeReplayStreams per channel.
|
|
498
318
|
*/
|
|
499
319
|
export async function handleVideoClipRequest(props: {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
320
|
+
device: ReolinkCamera;
|
|
321
|
+
deviceId: string;
|
|
322
|
+
fileId: string;
|
|
323
|
+
request: HttpRequest;
|
|
324
|
+
response: HttpResponse;
|
|
325
|
+
logger?: Console;
|
|
506
326
|
}): Promise<void> {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
327
|
+
const { device, fileId, request, response } = props;
|
|
328
|
+
const logger = device.getBaichuanLogger?.() || props.logger || console;
|
|
329
|
+
const useHttpSource =
|
|
330
|
+
device.storageSettings?.values?.videoclipSource === "HTTP";
|
|
331
|
+
|
|
332
|
+
logger.log(
|
|
333
|
+
`[VideoClip] REQUEST: fileId=${fileId.slice(-40)}, isOnNvr=${device.isOnNvr}, source=${useHttpSource ? "HTTP" : "Native"}`,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
const api = await device.ensureClient();
|
|
338
|
+
const channel = device.storageSettings?.values?.rtspChannel ?? 0;
|
|
339
|
+
|
|
340
|
+
if (useHttpSource) {
|
|
341
|
+
// HTTP mode: use CGI API to download the video file
|
|
342
|
+
logger.debug(`[VideoClip] Using CGI API (HTTP) to download: ${fileId}`);
|
|
343
|
+
|
|
344
|
+
const mp4Buffer = await api.downloadVod(fileId, {
|
|
345
|
+
output: fileId,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
logger.debug(`[VideoClip] Downloaded via CGI: ${mp4Buffer.length} bytes`);
|
|
349
|
+
|
|
350
|
+
// Send the buffer as a complete response
|
|
351
|
+
const CHUNK_SIZE = 64 * 1024; // 64KB chunks
|
|
352
|
+
response.sendStream(
|
|
353
|
+
(async function* () {
|
|
354
|
+
let offset = 0;
|
|
355
|
+
while (offset < mp4Buffer.length) {
|
|
356
|
+
const end = Math.min(offset + CHUNK_SIZE, mp4Buffer.length);
|
|
357
|
+
yield mp4Buffer.subarray(offset, end);
|
|
358
|
+
offset = end;
|
|
359
|
+
}
|
|
360
|
+
})(),
|
|
361
|
+
{
|
|
362
|
+
code: 200,
|
|
363
|
+
headers: {
|
|
364
|
+
"Content-Type": "video/mp4",
|
|
365
|
+
"Content-Length": mp4Buffer.length.toString(),
|
|
366
|
+
"Cache-Control": "no-cache",
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Native mode: use Baichuan streaming replay
|
|
374
|
+
// Add error handler to prevent uncaughtException from client socket errors
|
|
375
|
+
const onClientError = (err: Error) => {
|
|
376
|
+
logger.warn?.(
|
|
377
|
+
`[VideoClip] Client error during stream: ${err?.message || "unknown"}`,
|
|
378
|
+
);
|
|
379
|
+
};
|
|
380
|
+
api.client.on("error", onClientError);
|
|
381
|
+
|
|
382
|
+
// Use streaming replay - this starts immediately and produces fMP4 chunks
|
|
383
|
+
// Stream management (stopping previous streams, cooldown) is handled by the API layer
|
|
384
|
+
// Generate a unique session ID based on client fingerprint (UA + IP + other factors)
|
|
385
|
+
// This allows the same client to reuse the dedicated socket when switching clips
|
|
386
|
+
const clientFingerprint = [
|
|
387
|
+
request.headers?.["user-agent"] || "",
|
|
388
|
+
request.headers?.["x-forwarded-for"] ||
|
|
389
|
+
request.headers?.["x-real-ip"] ||
|
|
390
|
+
"",
|
|
391
|
+
request.headers?.["accept-language"] || "",
|
|
392
|
+
request.headers?.["accept-encoding"] || "",
|
|
393
|
+
].join("|");
|
|
394
|
+
const sessionId =
|
|
395
|
+
request.headers?.["x-request-id"] ||
|
|
396
|
+
crypto
|
|
397
|
+
.createHash("sha256")
|
|
398
|
+
.update(clientFingerprint)
|
|
399
|
+
.digest("hex")
|
|
400
|
+
.slice(0, 16);
|
|
401
|
+
const { mp4: mp4Stream, stop } = await api.createRecordingReplayMp4Stream({
|
|
402
|
+
channel,
|
|
403
|
+
fileName: fileId,
|
|
404
|
+
isNvr: device.isOnNvr,
|
|
405
|
+
logger,
|
|
406
|
+
deviceId: sessionId,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
let totalSize = 0;
|
|
410
|
+
|
|
411
|
+
// Simple response - no range support
|
|
412
|
+
response.sendStream(
|
|
413
|
+
(async function* () {
|
|
575
414
|
try {
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
}
|
|
415
|
+
for await (const chunk of mp4Stream) {
|
|
416
|
+
yield chunk;
|
|
417
|
+
totalSize += chunk.length;
|
|
418
|
+
}
|
|
419
|
+
} catch (e: any) {
|
|
420
|
+
// Stream error - library handles logging
|
|
421
|
+
} finally {
|
|
422
|
+
// Remove the error handler
|
|
423
|
+
api.client.off("error", onClientError);
|
|
424
|
+
await stop().catch(() => {});
|
|
793
425
|
}
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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;
|
|
426
|
+
})(),
|
|
427
|
+
{
|
|
428
|
+
code: 200,
|
|
429
|
+
headers: {
|
|
430
|
+
"Content-Type": "video/mp4",
|
|
431
|
+
"Cache-Control": "no-cache",
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
);
|
|
435
|
+
return;
|
|
436
|
+
} catch (streamErr: any) {
|
|
437
|
+
logger.error(
|
|
438
|
+
`[VideoClip] Streaming failed: ${streamErr?.message || String(streamErr)}`,
|
|
439
|
+
);
|
|
440
|
+
response.send(
|
|
441
|
+
`Streaming failed: ${streamErr?.message || "Unknown error"}`,
|
|
442
|
+
{
|
|
443
|
+
code: 500,
|
|
444
|
+
},
|
|
445
|
+
);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
1074
448
|
}
|
|
1075
449
|
|
|
1076
|
-
export const removeAuthUrls = (streams: ReolinkSupportedStream[]) =>
|
|
450
|
+
export const removeAuthUrls = (streams: ReolinkSupportedStream[]) =>
|
|
451
|
+
streams.map(({ urlWithAuth, ...rest }) => ({ rest }));
|