@apocaliss92/scrypted-reolink-native 0.1.33 → 0.1.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/baichuan-base.ts +4 -4
- package/src/common.ts +179 -47
- package/src/main.ts +2 -2
- package/src/nvr.ts +36 -29
- package/src/utils.ts +510 -111
package/src/utils.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import type { DeviceCapabilities, EnrichedRecordingFile, RecordingFile, ReolinkBaichuanApi, ReolinkDeviceInfo } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
1
|
+
import type { DeviceCapabilities, EnrichedRecordingFile, ParsedRecordingFileName, RecordingFile, ReolinkBaichuanApi, ReolinkDeviceInfo, VodFile, VodSearchResponse } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
2
2
|
import sdk, { DeviceBase, HttpRequest, HttpResponse, MediaObject, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, VideoClip, VideoClips } from "@scrypted/sdk";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
+
import { Readable } from "stream";
|
|
5
|
+
import http from "http";
|
|
6
|
+
import https from "https";
|
|
4
7
|
import fs from "fs";
|
|
5
8
|
import path from "path";
|
|
6
9
|
import crypto from "crypto";
|
|
@@ -173,8 +176,11 @@ export async function recordingFileToVideoClip(
|
|
|
173
176
|
let videoHref: string | undefined = providedVideoHref;
|
|
174
177
|
let thumbnailHref: string | undefined;
|
|
175
178
|
|
|
179
|
+
logger?.debug(`[recordingFileToVideoClip] URL generation: useWebhook=${useWebhook}, hasPlugin=${!!plugin}, deviceId=${deviceId}, providedVideoHref=${providedVideoHref || 'none'}, hasApi=${!!api}`);
|
|
180
|
+
|
|
176
181
|
// If webhook is enabled, generate webhook URLs
|
|
177
182
|
if (useWebhook && plugin && deviceId) {
|
|
183
|
+
logger?.debug(`[recordingFileToVideoClip] Generating webhook URLs for fileId=${id}`);
|
|
178
184
|
try {
|
|
179
185
|
const { videoUrl, thumbnailUrl } = await getVideoClipWebhookUrls({
|
|
180
186
|
deviceId,
|
|
@@ -183,48 +189,78 @@ export async function recordingFileToVideoClip(
|
|
|
183
189
|
});
|
|
184
190
|
videoHref = videoUrl;
|
|
185
191
|
thumbnailHref = thumbnailUrl;
|
|
192
|
+
logger?.debug(`[recordingFileToVideoClip] Webhook URLs generated successfully: videoHref="${videoHref}", thumbnailHref="${thumbnailHref}"`);
|
|
186
193
|
} catch (e) {
|
|
187
|
-
logger?.error(
|
|
194
|
+
logger?.error(`[recordingFileToVideoClip] Failed to generate webhook URLs for fileId=${id}:`, e);
|
|
188
195
|
}
|
|
189
196
|
} else if (!videoHref && api) {
|
|
190
197
|
// Fallback to direct RTMP URL if webhook is not used
|
|
198
|
+
logger?.debug(`[recordingFileToVideoClip] Fetching RTMP playback URL for fileName=${rec.fileName}`);
|
|
191
199
|
try {
|
|
192
200
|
const { rtmpVodUrl } = await api.getRecordingPlaybackUrls({
|
|
193
201
|
fileName: rec.fileName,
|
|
194
202
|
});
|
|
195
203
|
videoHref = rtmpVodUrl;
|
|
204
|
+
logger?.debug(`[recordingFileToVideoClip] RTMP URL fetched successfully: videoHref="${videoHref}"`);
|
|
196
205
|
} catch (e) {
|
|
197
|
-
logger?.debug(
|
|
206
|
+
logger?.debug(`[recordingFileToVideoClip] Failed to build playback URL for recording fileName=${rec.fileName}:`, e);
|
|
198
207
|
}
|
|
208
|
+
} else {
|
|
209
|
+
logger?.debug(`[recordingFileToVideoClip] No URL generation: useWebhook=${useWebhook}, hasPlugin=${!!plugin}, deviceId=${deviceId}, providedVideoHref=${providedVideoHref || 'none'}, hasApi=${!!api}`);
|
|
199
210
|
}
|
|
200
211
|
|
|
201
212
|
const description = ('name' in rec && typeof rec.name === 'string' && rec.name) ? rec.name : (rec.fileName ?? rec.id ?? '');
|
|
202
213
|
|
|
203
214
|
// Build detectionClasses from flags or recordType
|
|
204
|
-
const detectionClasses: string[] = [
|
|
215
|
+
const detectionClasses: string[] = [];
|
|
205
216
|
|
|
206
|
-
// Check for EnrichedRecordingFile flags
|
|
217
|
+
// Check for EnrichedRecordingFile flags first (most accurate)
|
|
218
|
+
let hasAnyDetection = false;
|
|
207
219
|
if ('hasPerson' in rec && rec.hasPerson) {
|
|
208
220
|
detectionClasses.push('Person');
|
|
221
|
+
hasAnyDetection = true;
|
|
209
222
|
}
|
|
210
223
|
if ('hasVehicle' in rec && rec.hasVehicle) {
|
|
211
224
|
detectionClasses.push('Vehicle');
|
|
225
|
+
hasAnyDetection = true;
|
|
212
226
|
}
|
|
213
227
|
if ('hasAnimal' in rec && rec.hasAnimal) {
|
|
214
228
|
detectionClasses.push('Animal');
|
|
229
|
+
hasAnyDetection = true;
|
|
215
230
|
}
|
|
216
231
|
if ('hasFace' in rec && rec.hasFace) {
|
|
217
232
|
detectionClasses.push('Face');
|
|
233
|
+
hasAnyDetection = true;
|
|
234
|
+
}
|
|
235
|
+
if ('hasMotion' in rec && rec.hasMotion) {
|
|
236
|
+
detectionClasses.push('Motion');
|
|
237
|
+
hasAnyDetection = true;
|
|
218
238
|
}
|
|
219
239
|
if ('hasDoorbell' in rec && rec.hasDoorbell) {
|
|
220
240
|
detectionClasses.push('Doorbell');
|
|
241
|
+
hasAnyDetection = true;
|
|
221
242
|
}
|
|
222
243
|
if ('hasPackage' in rec && rec.hasPackage) {
|
|
223
244
|
detectionClasses.push('Package');
|
|
245
|
+
hasAnyDetection = true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Log detection flags for debugging
|
|
249
|
+
if (logger) {
|
|
250
|
+
const flags = {
|
|
251
|
+
hasPerson: 'hasPerson' in rec ? rec.hasPerson : undefined,
|
|
252
|
+
hasVehicle: 'hasVehicle' in rec ? rec.hasVehicle : undefined,
|
|
253
|
+
hasAnimal: 'hasAnimal' in rec ? rec.hasAnimal : undefined,
|
|
254
|
+
hasFace: 'hasFace' in rec ? rec.hasFace : undefined,
|
|
255
|
+
hasMotion: 'hasMotion' in rec ? rec.hasMotion : undefined,
|
|
256
|
+
hasDoorbell: 'hasDoorbell' in rec ? rec.hasDoorbell : undefined,
|
|
257
|
+
hasPackage: 'hasPackage' in rec ? rec.hasPackage : undefined,
|
|
258
|
+
recordType: rec.recordType || 'none',
|
|
259
|
+
};
|
|
224
260
|
}
|
|
225
261
|
|
|
226
262
|
// Fallback: parse recordType string if flags are not available
|
|
227
|
-
if (
|
|
263
|
+
if (!hasAnyDetection && rec.recordType) {
|
|
228
264
|
const recordTypeLower = rec.recordType.toLowerCase();
|
|
229
265
|
if (recordTypeLower.includes('people') || recordTypeLower.includes('person')) {
|
|
230
266
|
detectionClasses.push('Person');
|
|
@@ -249,6 +285,19 @@ export async function recordingFileToVideoClip(
|
|
|
249
285
|
}
|
|
250
286
|
}
|
|
251
287
|
|
|
288
|
+
// Always include Motion if no other detections found
|
|
289
|
+
if (detectionClasses.length === 0) {
|
|
290
|
+
detectionClasses.push('Motion');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const resources = videoHref || thumbnailHref
|
|
294
|
+
? {
|
|
295
|
+
...(videoHref ? { video: { href: videoHref } } : {}),
|
|
296
|
+
...(thumbnailHref ? { thumbnail: { href: thumbnailHref } } : {}),
|
|
297
|
+
}
|
|
298
|
+
: undefined;
|
|
299
|
+
|
|
300
|
+
|
|
252
301
|
return {
|
|
253
302
|
id,
|
|
254
303
|
startTime: recStartMs,
|
|
@@ -256,12 +305,7 @@ export async function recordingFileToVideoClip(
|
|
|
256
305
|
event: rec.recordType,
|
|
257
306
|
description,
|
|
258
307
|
detectionClasses: detectionClasses.length > 0 ? detectionClasses : undefined,
|
|
259
|
-
resources
|
|
260
|
-
? {
|
|
261
|
-
...(videoHref ? { video: { href: videoHref } } : {}),
|
|
262
|
-
...(thumbnailHref ? { thumbnail: { href: thumbnailHref } } : {}),
|
|
263
|
-
}
|
|
264
|
-
: undefined,
|
|
308
|
+
resources,
|
|
265
309
|
};
|
|
266
310
|
}
|
|
267
311
|
|
|
@@ -275,14 +319,21 @@ export async function getVideoClipWebhookUrls(props: {
|
|
|
275
319
|
}): Promise<{ videoUrl: string; thumbnailUrl: string }> {
|
|
276
320
|
const { deviceId, fileId, plugin } = props;
|
|
277
321
|
|
|
322
|
+
plugin.console.debug(`[getVideoClipWebhookUrls] Starting URL generation: deviceId=${deviceId}, fileId=${fileId}`);
|
|
323
|
+
|
|
278
324
|
try {
|
|
279
325
|
let endpoint: string;
|
|
326
|
+
let endpointSource: 'cloud' | 'local';
|
|
280
327
|
try {
|
|
281
328
|
endpoint = await sdk.endpointManager.getCloudEndpoint(undefined, { public: true });
|
|
329
|
+
endpointSource = 'cloud';
|
|
330
|
+
plugin.console.debug(`[getVideoClipWebhookUrls] Using cloud endpoint: ${endpoint}`);
|
|
282
331
|
} catch (e) {
|
|
283
332
|
// Fallback to local endpoint if cloud is not available (e.g., not logged in)
|
|
284
|
-
|
|
333
|
+
plugin.console.debug(`[getVideoClipWebhookUrls] Cloud endpoint not available, using local endpoint: ${e instanceof Error ? e.message : String(e)}`);
|
|
285
334
|
endpoint = await sdk.endpointManager.getLocalEndpoint(undefined, { public: true });
|
|
335
|
+
endpointSource = 'local';
|
|
336
|
+
plugin.console.debug(`[getVideoClipWebhookUrls] Using local endpoint: ${endpoint}`);
|
|
286
337
|
}
|
|
287
338
|
|
|
288
339
|
const encodedDeviceId = encodeURIComponent(deviceId);
|
|
@@ -290,23 +341,31 @@ export async function getVideoClipWebhookUrls(props: {
|
|
|
290
341
|
const cleanFileId = fileId.startsWith('/') ? fileId.substring(1) : fileId;
|
|
291
342
|
const encodedFileId = encodeURIComponent(cleanFileId);
|
|
292
343
|
|
|
344
|
+
plugin.console.debug(`[getVideoClipWebhookUrls] Encoding: deviceId="${deviceId}" -> "${encodedDeviceId}", fileId="${fileId}" -> cleanFileId="${cleanFileId}" -> encodedFileId="${encodedFileId}"`);
|
|
345
|
+
|
|
293
346
|
// Parse endpoint URL to extract query parameters (for authentication)
|
|
294
347
|
const endpointUrl = new URL(endpoint);
|
|
295
348
|
// Preserve query parameters (e.g., user_token for authentication)
|
|
296
349
|
const queryParams = endpointUrl.search;
|
|
297
350
|
// Remove query parameters from the base endpoint URL
|
|
298
351
|
endpointUrl.search = '';
|
|
299
|
-
|
|
352
|
+
|
|
353
|
+
plugin.console.debug(`[getVideoClipWebhookUrls] Parsed endpoint URL: base="${endpointUrl.toString()}", queryParams="${queryParams}"`);
|
|
354
|
+
|
|
300
355
|
// Ensure endpoint has trailing slash
|
|
301
356
|
const normalizedEndpoint = endpointUrl.toString().endsWith('/') ? endpointUrl.toString() : `${endpointUrl.toString()}/`;
|
|
302
357
|
|
|
358
|
+
plugin.console.debug(`[getVideoClipWebhookUrls] Normalized endpoint: "${normalizedEndpoint}"`);
|
|
359
|
+
|
|
303
360
|
// Build webhook URLs and append query parameters at the end
|
|
304
361
|
const videoUrl = `${normalizedEndpoint}webhook/video/${encodedDeviceId}/${encodedFileId}${queryParams}`;
|
|
305
362
|
const thumbnailUrl = `${normalizedEndpoint}webhook/thumbnail/${encodedDeviceId}/${encodedFileId}${queryParams}`;
|
|
306
363
|
|
|
364
|
+
plugin.console.debug(`[getVideoClipWebhookUrls] Generated URLs: videoUrl="${videoUrl}", thumbnailUrl="${thumbnailUrl}"`);
|
|
365
|
+
|
|
307
366
|
return { videoUrl, thumbnailUrl };
|
|
308
367
|
} catch (e) {
|
|
309
|
-
plugin.console.error(
|
|
368
|
+
plugin.console.error(`[getVideoClipWebhookUrls] Failed to generate webhook URLs: deviceId=${deviceId}, fileId=${fileId}`, e);
|
|
310
369
|
throw e;
|
|
311
370
|
}
|
|
312
371
|
}
|
|
@@ -322,7 +381,7 @@ export async function extractThumbnailFromVideo(props: {
|
|
|
322
381
|
logger: Console;
|
|
323
382
|
}): Promise<MediaObject> {
|
|
324
383
|
const { rtmpUrl, filePath, fileId, deviceId, logger } = props;
|
|
325
|
-
|
|
384
|
+
|
|
326
385
|
// Use file path if available, otherwise use RTMP URL
|
|
327
386
|
const inputSource = filePath || rtmpUrl;
|
|
328
387
|
if (!inputSource) {
|
|
@@ -330,89 +389,17 @@ export async function extractThumbnailFromVideo(props: {
|
|
|
330
389
|
}
|
|
331
390
|
|
|
332
391
|
try {
|
|
333
|
-
//
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
'-vframes', '1', // Extract only 1 frame
|
|
341
|
-
'-q:v', '2', // High quality JPEG
|
|
342
|
-
'-f', 'image2', // Output format
|
|
343
|
-
'pipe:1', // Output to stdout
|
|
344
|
-
];
|
|
345
|
-
|
|
346
|
-
return new Promise<MediaObject>((resolve, reject) => {
|
|
347
|
-
const ffmpeg = spawn(ffmpegPath, ffmpegArgs, {
|
|
348
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
const chunks: Buffer[] = [];
|
|
352
|
-
let errorOutput = '';
|
|
353
|
-
|
|
354
|
-
ffmpeg.stdout.on('data', (chunk: Buffer) => {
|
|
355
|
-
chunks.push(chunk);
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
ffmpeg.stderr.on('data', (chunk: Buffer) => {
|
|
359
|
-
errorOutput += chunk.toString();
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
let resolved = false;
|
|
363
|
-
|
|
364
|
-
ffmpeg.on('close', async (code) => {
|
|
365
|
-
if (resolved) return;
|
|
366
|
-
resolved = true;
|
|
367
|
-
|
|
368
|
-
if (code !== 0) {
|
|
369
|
-
logger.error(`[Thumbnail] Error: fileId=${fileId}`, new Error(`ffmpeg failed with code ${code}: ${errorOutput}`));
|
|
370
|
-
reject(new Error(`ffmpeg failed with code ${code}: ${errorOutput}`));
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
try {
|
|
375
|
-
const imageBuffer = Buffer.concat(chunks);
|
|
376
|
-
if (imageBuffer.length === 0) {
|
|
377
|
-
logger.error(`[Thumbnail] Error: fileId=${fileId}`, new Error('No image data received from ffmpeg'));
|
|
378
|
-
reject(new Error('No image data received from ffmpeg'));
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const mo = await sdk.mediaManager.createMediaObject(imageBuffer, 'image/jpeg');
|
|
383
|
-
logger.log(`[Thumbnail] Completed: fileId=${fileId}, size=${imageBuffer.length} bytes`);
|
|
384
|
-
resolve(mo);
|
|
385
|
-
} catch (e) {
|
|
386
|
-
logger.error(`[Thumbnail] Error: fileId=${fileId}`, e);
|
|
387
|
-
reject(e);
|
|
388
|
-
}
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
ffmpeg.on('error', (error) => {
|
|
392
|
-
if (resolved) return;
|
|
393
|
-
resolved = true;
|
|
394
|
-
logger.error(`[Thumbnail] Error: fileId=${fileId}`, error);
|
|
395
|
-
reject(error);
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
// Timeout after 30 seconds
|
|
399
|
-
const timeout = setTimeout(() => {
|
|
400
|
-
if (resolved) return;
|
|
401
|
-
resolved = true;
|
|
402
|
-
try {
|
|
403
|
-
ffmpeg.kill('SIGKILL');
|
|
404
|
-
} catch (e) {
|
|
405
|
-
// Ignore
|
|
406
|
-
}
|
|
407
|
-
reject(new Error('Thumbnail extraction timeout'));
|
|
408
|
-
}, 30000);
|
|
409
|
-
|
|
410
|
-
ffmpeg.on('close', () => {
|
|
411
|
-
clearTimeout(timeout);
|
|
412
|
-
});
|
|
392
|
+
// Use createFFmpegMediaObject which handles codec detection better
|
|
393
|
+
// For Download URLs from NVR, they might return only a short segment, so use 1 second instead of 5
|
|
394
|
+
const mo = await sdk.mediaManager.createFFmpegMediaObject({
|
|
395
|
+
inputArguments: [
|
|
396
|
+
'-ss', '00:00:01', // Seek to 1 second (safer for short segments from NVR Download URLs)
|
|
397
|
+
'-i', inputSource,
|
|
398
|
+
],
|
|
413
399
|
});
|
|
400
|
+
return mo;
|
|
414
401
|
} catch (e) {
|
|
415
|
-
|
|
402
|
+
// Error already logged in main.ts
|
|
416
403
|
throw e;
|
|
417
404
|
}
|
|
418
405
|
}
|
|
@@ -426,7 +413,7 @@ function getVideoClipCachePath(deviceId: string, fileId: string): string {
|
|
|
426
413
|
const hash = crypto.createHash('md5').update(fileId).digest('hex');
|
|
427
414
|
// Keep original extension if present, otherwise use .mp4
|
|
428
415
|
const ext = fileId.includes('.') ? path.extname(fileId) : '.mp4';
|
|
429
|
-
const cacheDir = path.join(pluginVolume, '
|
|
416
|
+
const cacheDir = path.join(pluginVolume, 'videoclips', deviceId);
|
|
430
417
|
return path.join(cacheDir, `${hash}${ext}`);
|
|
431
418
|
}
|
|
432
419
|
|
|
@@ -493,32 +480,230 @@ export async function handleVideoClipRequest(props: {
|
|
|
493
480
|
}
|
|
494
481
|
} catch (e) {
|
|
495
482
|
// File not cached, need to proxy RTMP stream
|
|
496
|
-
logger.log(`
|
|
483
|
+
logger.log(`[VideoClip] Stream start: fileId=${fileId}`);
|
|
497
484
|
|
|
498
|
-
// Get RTMP URL
|
|
499
|
-
// Cast device to CommonCameraMixin to access API
|
|
485
|
+
// Get RTMP URL using the appropriate API (NVR or Baichuan)
|
|
500
486
|
let rtmpVodUrl: string | undefined;
|
|
501
487
|
try {
|
|
502
|
-
|
|
503
|
-
const result = await api.getRecordingPlaybackUrls({
|
|
504
|
-
fileName: fileId,
|
|
505
|
-
});
|
|
506
|
-
rtmpVodUrl = result.rtmpVodUrl;
|
|
488
|
+
rtmpVodUrl = await device.getVideoClipRtmpUrl(fileId);
|
|
507
489
|
} catch (e2) {
|
|
508
|
-
logger.error(`
|
|
490
|
+
logger.error(`[VideoClip] Stream error: fileId=${fileId}`, e2);
|
|
509
491
|
response.send('Failed to get RTMP playback URL', { code: 500 });
|
|
510
492
|
return;
|
|
511
493
|
}
|
|
512
494
|
|
|
513
495
|
if (!rtmpVodUrl) {
|
|
514
|
-
logger.error(`
|
|
496
|
+
logger.error(`[VideoClip] Stream error: fileId=${fileId} - No URL found`);
|
|
515
497
|
response.send('No RTMP playback URL found for video', { code: 404 });
|
|
516
498
|
return;
|
|
517
499
|
}
|
|
518
500
|
|
|
519
|
-
//
|
|
501
|
+
// Check if URL is HTTP (Playback/Download) or RTMP
|
|
502
|
+
const isHttpUrl = rtmpVodUrl.startsWith('http://') || rtmpVodUrl.startsWith('https://');
|
|
503
|
+
|
|
504
|
+
if (isHttpUrl) {
|
|
505
|
+
// For HTTP URLs (Playback/Download from NVR), do direct HTTP proxy with ranged headers support
|
|
506
|
+
logger.log(`Proxying HTTP URL directly: fileId=${fileId}, url=${rtmpVodUrl}`);
|
|
507
|
+
|
|
508
|
+
const sendVideo = async () => {
|
|
509
|
+
// Pre-fetch ffmpeg path in case we need it for FLV conversion
|
|
510
|
+
const ffmpegPathPromise = sdk.mediaManager.getFFmpegPath();
|
|
511
|
+
|
|
512
|
+
return new Promise<void>(async (resolve, reject) => {
|
|
513
|
+
const urlObj = new URL(rtmpVodUrl);
|
|
514
|
+
const httpModule = urlObj.protocol === 'https:' ? https : http;
|
|
515
|
+
|
|
516
|
+
// Filter and prepare headers (remove host, connection, etc. that shouldn't be forwarded)
|
|
517
|
+
const requestHeaders: Record<string, string> = {};
|
|
518
|
+
if (request.headers.range) {
|
|
519
|
+
requestHeaders['Range'] = request.headers.range;
|
|
520
|
+
}
|
|
521
|
+
// Add other headers that might be needed
|
|
522
|
+
if (request.headers['user-agent']) {
|
|
523
|
+
requestHeaders['User-Agent'] = request.headers['user-agent'];
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const options = {
|
|
527
|
+
hostname: urlObj.hostname,
|
|
528
|
+
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
|
529
|
+
path: urlObj.pathname + urlObj.search,
|
|
530
|
+
method: 'GET',
|
|
531
|
+
headers: requestHeaders,
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
logger.log(`Starting HTTP request: ${rtmpVodUrl}, headers: ${JSON.stringify(requestHeaders)}`);
|
|
535
|
+
|
|
536
|
+
httpModule.get(options, async (httpResponse) => {
|
|
537
|
+
if (httpResponse.statusCode && httpResponse.statusCode >= 400) {
|
|
538
|
+
logger.error(`HTTP error: status=${httpResponse.statusCode}, message=${httpResponse.statusMessage}`);
|
|
539
|
+
reject(new Error(`Error loading the video: ${httpResponse.statusCode} - ${httpResponse.statusMessage}`));
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
let contentType = httpResponse.headers['content-type'] || 'video/mp4';
|
|
544
|
+
const contentLength = httpResponse.headers['content-length'];
|
|
545
|
+
const contentRange = httpResponse.headers['content-range'];
|
|
546
|
+
const acceptRanges = httpResponse.headers['accept-ranges'] || 'bytes';
|
|
547
|
+
|
|
548
|
+
// Check if we need to convert FLV to MP4
|
|
549
|
+
const isFlv = typeof contentType === 'string' && (contentType === 'video/x-flv' || contentType === 'video/flv');
|
|
550
|
+
|
|
551
|
+
if (isFlv) {
|
|
552
|
+
logger.log(`Content-Type is FLV (${contentType}), will convert to MP4 using ffmpeg`);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const responseHeaders: Record<string, string> = {
|
|
556
|
+
'Content-Type': typeof contentType === 'string' ? contentType : 'video/mp4',
|
|
557
|
+
'Accept-Ranges': typeof acceptRanges === 'string' ? acceptRanges : 'bytes',
|
|
558
|
+
'Cache-Control': 'no-cache',
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
if (contentLength) {
|
|
562
|
+
responseHeaders['Content-Length'] = typeof contentLength === 'string' ? contentLength : String(contentLength);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (contentRange) {
|
|
566
|
+
responseHeaders['Content-Range'] = typeof contentRange === 'string' ? contentRange : String(contentRange);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const statusCode = httpResponse.statusCode || 200;
|
|
570
|
+
|
|
571
|
+
logger.log(`HTTP response received: status=${statusCode}, contentType=${contentType}, contentLength=${contentLength || 'unknown'}`);
|
|
572
|
+
|
|
573
|
+
try {
|
|
574
|
+
if (isFlv) {
|
|
575
|
+
// Convert FLV to MP4 using ffmpeg
|
|
576
|
+
const ffmpegPath = await ffmpegPathPromise;
|
|
577
|
+
// Re-encode instead of copy because FLV codec might not be supported
|
|
578
|
+
const ffmpegArgs: string[] = [
|
|
579
|
+
'-i', 'pipe:0', // Read from stdin (httpResponse)
|
|
580
|
+
'-c:v', 'libx264', // Re-encode video to H.264
|
|
581
|
+
'-preset', 'ultrafast', // Fast encoding for streaming
|
|
582
|
+
'-tune', 'zerolatency', // Low latency
|
|
583
|
+
'-c:a', 'aac', // Re-encode audio to AAC
|
|
584
|
+
'-f', 'mp4',
|
|
585
|
+
'-movflags', 'frag_keyframe+empty_moov', // Enable streaming
|
|
586
|
+
'pipe:1', // Output to stdout
|
|
587
|
+
];
|
|
588
|
+
|
|
589
|
+
const ffmpeg = spawn(ffmpegPath, ffmpegArgs, {
|
|
590
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
let ffmpegError = '';
|
|
594
|
+
ffmpeg.stderr.on('data', (chunk: Buffer) => {
|
|
595
|
+
ffmpegError += chunk.toString();
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// Pipe httpResponse to ffmpeg stdin
|
|
599
|
+
httpResponse.pipe(ffmpeg.stdin);
|
|
600
|
+
|
|
601
|
+
ffmpeg.stdin.on('error', (err) => {
|
|
602
|
+
// Ignore EPIPE errors when ffmpeg closes
|
|
603
|
+
if ((err as any).code !== 'EPIPE') {
|
|
604
|
+
logger.error(`FFmpeg stdin error: fileId=${fileId}`, err);
|
|
605
|
+
}
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
httpResponse.on('error', (err) => {
|
|
609
|
+
logger.error(`HTTP response error before ffmpeg: fileId=${fileId}`, err);
|
|
610
|
+
try {
|
|
611
|
+
ffmpeg.kill('SIGKILL');
|
|
612
|
+
} catch (e) {
|
|
613
|
+
// Ignore
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
let streamStarted = false;
|
|
618
|
+
|
|
619
|
+
// Stream ffmpeg output
|
|
620
|
+
response.sendStream((async function* () {
|
|
621
|
+
try {
|
|
622
|
+
for await (const chunk of ffmpeg.stdout) {
|
|
623
|
+
if (!streamStarted) {
|
|
624
|
+
streamStarted = true;
|
|
625
|
+
}
|
|
626
|
+
yield chunk;
|
|
627
|
+
}
|
|
628
|
+
} catch (e) {
|
|
629
|
+
logger.error(`Error streaming ffmpeg output: fileId=${fileId}`, e);
|
|
630
|
+
throw e;
|
|
631
|
+
} finally {
|
|
632
|
+
// Clean up ffmpeg process
|
|
633
|
+
try {
|
|
634
|
+
ffmpeg.kill('SIGKILL');
|
|
635
|
+
} catch (e) {
|
|
636
|
+
// Ignore
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
})(), {
|
|
640
|
+
code: 200,
|
|
641
|
+
headers: {
|
|
642
|
+
'Content-Type': 'video/mp4',
|
|
643
|
+
'Accept-Ranges': 'bytes',
|
|
644
|
+
'Cache-Control': 'no-cache',
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
// Handle ffmpeg errors
|
|
649
|
+
ffmpeg.on('close', (code) => {
|
|
650
|
+
if (code !== 0 && code !== null && !streamStarted) {
|
|
651
|
+
logger.error(`FFmpeg conversion failed: fileId=${fileId}, code=${code}, error=${ffmpegError}`);
|
|
652
|
+
reject(new Error(`FFmpeg conversion failed: ${code}`));
|
|
653
|
+
} else {
|
|
654
|
+
logger.log(`FFmpeg conversion completed: fileId=${fileId}, code=${code}`);
|
|
655
|
+
resolve();
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
ffmpeg.on('error', (error) => {
|
|
660
|
+
logger.error(`FFmpeg spawn error: fileId=${fileId}`, error);
|
|
661
|
+
reject(error);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
logger.log(`FFmpeg conversion started: fileId=${fileId}`);
|
|
665
|
+
} else {
|
|
666
|
+
// Direct proxy for non-FLV content (should be MP4 already)
|
|
667
|
+
// Stream directly without buffering - yield chunks as they arrive
|
|
668
|
+
response.sendStream((async function* () {
|
|
669
|
+
try {
|
|
670
|
+
for await (const chunk of Readable.from(httpResponse)) {
|
|
671
|
+
yield chunk;
|
|
672
|
+
}
|
|
673
|
+
logger.log(`[VideoClip] Stream end: fileId=${fileId}`);
|
|
674
|
+
} catch (streamErr) {
|
|
675
|
+
logger.error(`[VideoClip] Stream error: fileId=${fileId}`, streamErr);
|
|
676
|
+
throw streamErr;
|
|
677
|
+
}
|
|
678
|
+
})(), {
|
|
679
|
+
code: statusCode,
|
|
680
|
+
headers: responseHeaders,
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
resolve();
|
|
684
|
+
}
|
|
685
|
+
} catch (err) {
|
|
686
|
+
logger.error(`Error sending stream: fileId=${fileId}`, err);
|
|
687
|
+
reject(err);
|
|
688
|
+
}
|
|
689
|
+
}).on('error', (e) => {
|
|
690
|
+
logger.error(`Error fetching videoclip: fileId=${fileId}`, e);
|
|
691
|
+
reject(e);
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
try {
|
|
697
|
+
await sendVideo();
|
|
698
|
+
return;
|
|
699
|
+
} catch (e) {
|
|
700
|
+
logger.error(`HTTP proxy error: fileId=${fileId}`, e);
|
|
701
|
+
response.send('Failed to proxy HTTP stream', { code: 500 });
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
520
705
|
|
|
521
|
-
//
|
|
706
|
+
// For RTMP URLs (camera standalone), use ffmpeg
|
|
522
707
|
const ffmpegPath = await sdk.mediaManager.getFFmpegPath();
|
|
523
708
|
const ffmpegArgs: string[] = [
|
|
524
709
|
'-i', rtmpVodUrl,
|
|
@@ -548,8 +733,9 @@ export async function handleVideoClipRequest(props: {
|
|
|
548
733
|
}
|
|
549
734
|
yield chunk;
|
|
550
735
|
}
|
|
736
|
+
logger.log(`[VideoClip] Stream end: fileId=${fileId}`);
|
|
551
737
|
} catch (e) {
|
|
552
|
-
logger.error(`
|
|
738
|
+
logger.error(`[VideoClip] Stream error: fileId=${fileId}`, e);
|
|
553
739
|
throw e;
|
|
554
740
|
} finally {
|
|
555
741
|
// Clean up ffmpeg process
|
|
@@ -581,4 +767,217 @@ export async function handleVideoClipRequest(props: {
|
|
|
581
767
|
|
|
582
768
|
return;
|
|
583
769
|
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Parse recordType string to extract detection classes
|
|
774
|
+
* Based on the same logic as ReolinkCgiApi.parseRecordTypeFlags
|
|
775
|
+
*/
|
|
776
|
+
function parseRecordTypeToDetectionClasses(recordType?: string): string[] {
|
|
777
|
+
const detectionClasses: string[] = [];
|
|
778
|
+
|
|
779
|
+
if (!recordType) {
|
|
780
|
+
return ['Motion']; // Default to Motion if no type specified
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Split by comma or whitespace and process each type
|
|
784
|
+
const types = recordType.toLowerCase().split(/[,\s]+/).map((s) => s.trim()).filter(Boolean);
|
|
785
|
+
|
|
786
|
+
let hasMotion = false;
|
|
787
|
+
|
|
788
|
+
for (const t of types) {
|
|
789
|
+
if (t === "people" || t === "person") {
|
|
790
|
+
detectionClasses.push('Person');
|
|
791
|
+
} else if (t === "vehicle" || t === "car") {
|
|
792
|
+
detectionClasses.push('Vehicle');
|
|
793
|
+
} else if (t === "dog_cat" || t === "animal" || t === "pet") {
|
|
794
|
+
detectionClasses.push('Animal');
|
|
795
|
+
} else if (t === "face") {
|
|
796
|
+
detectionClasses.push('Face');
|
|
797
|
+
} else if (t === "md" || t === "motion") {
|
|
798
|
+
hasMotion = true;
|
|
799
|
+
} else if (t === "visitor" || t === "doorbell") {
|
|
800
|
+
detectionClasses.push('Doorbell');
|
|
801
|
+
} else if (t === "package") {
|
|
802
|
+
detectionClasses.push('Package');
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// Always include Motion as base (if not already added or if no other detections)
|
|
807
|
+
if (hasMotion || detectionClasses.length === 0) {
|
|
808
|
+
detectionClasses.unshift('Motion');
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
return detectionClasses;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Extract detection classes from parsed filename flags (hex decoding)
|
|
816
|
+
*/
|
|
817
|
+
function parseFilenameFlagsToDetectionClasses(parsed?: ParsedRecordingFileName): string[] {
|
|
818
|
+
const detectionClasses: string[] = [];
|
|
819
|
+
const flags = parsed?.flags;
|
|
820
|
+
|
|
821
|
+
if (!flags) {
|
|
822
|
+
return [];
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Extract detection types from hex flags (same logic as enrichVodFile)
|
|
826
|
+
if (flags.aiPerson) {
|
|
827
|
+
detectionClasses.push('Person');
|
|
828
|
+
}
|
|
829
|
+
if (flags.aiVehicle) {
|
|
830
|
+
detectionClasses.push('Vehicle');
|
|
831
|
+
}
|
|
832
|
+
if (flags.aiAnimal) {
|
|
833
|
+
detectionClasses.push('Animal');
|
|
834
|
+
}
|
|
835
|
+
if (flags.aiFace) {
|
|
836
|
+
detectionClasses.push('Face');
|
|
837
|
+
}
|
|
838
|
+
if (flags.motion) {
|
|
839
|
+
detectionClasses.push('Motion');
|
|
840
|
+
}
|
|
841
|
+
if (flags.doorbell) {
|
|
842
|
+
detectionClasses.push('Doorbell');
|
|
843
|
+
}
|
|
844
|
+
if (flags.package) {
|
|
845
|
+
detectionClasses.push('Package');
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
return detectionClasses;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* Convert Reolink time format to Date
|
|
853
|
+
* Uses UTC to match the API's dateToReolinkTime conversion
|
|
854
|
+
*/
|
|
855
|
+
function reolinkTimeToDate(time: { year: number; mon: number; day: number; hour: number; min: number; sec: number }): Date {
|
|
856
|
+
return new Date(Date.UTC(
|
|
857
|
+
time.year,
|
|
858
|
+
time.mon - 1,
|
|
859
|
+
time.day,
|
|
860
|
+
time.hour,
|
|
861
|
+
time.min,
|
|
862
|
+
time.sec
|
|
863
|
+
));
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Convert VOD search results to VideoClip array
|
|
868
|
+
*/
|
|
869
|
+
export async function vodSearchResultsToVideoClips(
|
|
870
|
+
vodResults: Array<VodSearchResponse>,
|
|
871
|
+
options: {
|
|
872
|
+
deviceId: string;
|
|
873
|
+
plugin: ScryptedDeviceBase;
|
|
874
|
+
logger?: Console;
|
|
875
|
+
}
|
|
876
|
+
): Promise<VideoClip[]> {
|
|
877
|
+
const { deviceId, plugin, logger } = options;
|
|
878
|
+
const clips: VideoClip[] = [];
|
|
879
|
+
|
|
880
|
+
// Import parseRecordingFileName once (it's exported from the package)
|
|
881
|
+
const reolinkModule = await import("@apocaliss92/reolink-baichuan-js");
|
|
882
|
+
const parseRecordingFileName = reolinkModule.parseRecordingFileName;
|
|
883
|
+
|
|
884
|
+
// Process VOD search results
|
|
885
|
+
for (const result of vodResults) {
|
|
886
|
+
if (result.code !== 0) {
|
|
887
|
+
logger?.debug(`VOD search result code: ${result.code}`, result.error);
|
|
888
|
+
continue;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
const searchResult = result.value?.SearchResult;
|
|
892
|
+
if (!searchResult?.File || !Array.isArray(searchResult.File)) {
|
|
893
|
+
continue;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Convert each VOD file to VideoClip
|
|
897
|
+
for (const file of searchResult.File) {
|
|
898
|
+
try {
|
|
899
|
+
// Parse filename to extract flags (like enrichVodFile does)
|
|
900
|
+
const parsed = parseRecordingFileName(file.name);
|
|
901
|
+
|
|
902
|
+
// Get times from parsed filename or from StartTime/EndTime
|
|
903
|
+
// Camera times in StartTime/EndTime are in local timezone (not UTC)
|
|
904
|
+
// Use local time constructor to preserve the timezone
|
|
905
|
+
const fileStart = parsed?.start ?? new Date(
|
|
906
|
+
file.StartTime.year,
|
|
907
|
+
file.StartTime.mon - 1,
|
|
908
|
+
file.StartTime.day,
|
|
909
|
+
file.StartTime.hour,
|
|
910
|
+
file.StartTime.min,
|
|
911
|
+
file.StartTime.sec
|
|
912
|
+
);
|
|
913
|
+
const fileEnd = parsed?.end ?? new Date(
|
|
914
|
+
file.EndTime.year,
|
|
915
|
+
file.EndTime.mon - 1,
|
|
916
|
+
file.EndTime.day,
|
|
917
|
+
file.EndTime.hour,
|
|
918
|
+
file.EndTime.min,
|
|
919
|
+
file.EndTime.sec
|
|
920
|
+
);
|
|
921
|
+
|
|
922
|
+
const duration = fileEnd.getTime() - fileStart.getTime();
|
|
923
|
+
const fileName = file.name || '';
|
|
924
|
+
|
|
925
|
+
// Extract detection classes from both filename flags (hex) and file.type
|
|
926
|
+
const filenameFlags = parseFilenameFlagsToDetectionClasses(parsed);
|
|
927
|
+
const typeFlags = parseRecordTypeToDetectionClasses(file.type);
|
|
928
|
+
|
|
929
|
+
// Debug: log file.type to see what we're parsing
|
|
930
|
+
if (logger && file.type) {
|
|
931
|
+
logger.debug(`[VOD] Parsing file.type="${file.type}" for file=${fileName}, filenameFlags=${filenameFlags.join(',')}, typeFlags=${typeFlags.join(',')}`);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
// Merge both sources (OR them together, like enrichVodFile does)
|
|
935
|
+
// Remove duplicates and ensure Motion is included if any detection is found
|
|
936
|
+
const allDetections = [...filenameFlags, ...typeFlags];
|
|
937
|
+
const detectionClasses = [...new Set(allDetections)];
|
|
938
|
+
|
|
939
|
+
// If we have detections from filename flags, use those (they're more accurate)
|
|
940
|
+
// Otherwise use type flags, or default to Motion
|
|
941
|
+
if (detectionClasses.length === 0) {
|
|
942
|
+
detectionClasses.push('Motion');
|
|
943
|
+
} else if (!detectionClasses.includes('Motion') && (filenameFlags.length > 0 || typeFlags.some(t => t.toLowerCase().includes('motion') || t.toLowerCase().includes('md')))) {
|
|
944
|
+
// Add Motion if we have other detections but Motion is not explicitly included
|
|
945
|
+
detectionClasses.push('Motion');
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// Generate webhook URLs
|
|
949
|
+
let videoHref: string | undefined;
|
|
950
|
+
let thumbnailHref: string | undefined;
|
|
951
|
+
try {
|
|
952
|
+
const { videoUrl, thumbnailUrl } = await getVideoClipWebhookUrls({
|
|
953
|
+
deviceId,
|
|
954
|
+
fileId: fileName,
|
|
955
|
+
plugin,
|
|
956
|
+
});
|
|
957
|
+
videoHref = videoUrl;
|
|
958
|
+
thumbnailHref = thumbnailUrl;
|
|
959
|
+
} catch (e) {
|
|
960
|
+
logger?.debug('Failed to generate webhook URLs for VOD file', fileName, e);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const clip: VideoClip = {
|
|
964
|
+
id: fileName,
|
|
965
|
+
description: fileName,
|
|
966
|
+
startTime: fileStart.getTime(),
|
|
967
|
+
duration,
|
|
968
|
+
resources: {
|
|
969
|
+
video: videoHref ? { href: videoHref } : undefined,
|
|
970
|
+
thumbnail: thumbnailHref ? { href: thumbnailHref } : undefined,
|
|
971
|
+
},
|
|
972
|
+
detectionClasses,
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
clips.push(clip);
|
|
976
|
+
} catch (e) {
|
|
977
|
+
logger?.warn(`Failed to convert VOD file to clip: ${file.name}`, e);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
return clips;
|
|
584
983
|
}
|