@apocaliss92/scrypted-reolink-native 0.1.35 → 0.1.37
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/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/baichuan-base.ts +3 -2
- package/src/common.ts +192 -152
- package/src/connect.ts +2 -0
- package/src/main.ts +10 -5
- package/src/nvr.ts +56 -32
- package/src/utils.ts +113 -30
package/src/main.ts
CHANGED
|
@@ -12,7 +12,8 @@ interface ThumbnailRequest {
|
|
|
12
12
|
fileId: string;
|
|
13
13
|
rtmpUrl?: string;
|
|
14
14
|
filePath?: string;
|
|
15
|
-
logger
|
|
15
|
+
logger?: Console;
|
|
16
|
+
device?: CommonCameraMixin;
|
|
16
17
|
resolve: (mo: MediaObject) => void;
|
|
17
18
|
reject: (error: Error) => void;
|
|
18
19
|
}
|
|
@@ -22,7 +23,8 @@ interface ThumbnailRequestInput {
|
|
|
22
23
|
fileId: string;
|
|
23
24
|
rtmpUrl?: string;
|
|
24
25
|
filePath?: string;
|
|
25
|
-
logger
|
|
26
|
+
logger?: Console;
|
|
27
|
+
device?: CommonCameraMixin;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
|
|
@@ -198,6 +200,7 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
|
|
|
198
200
|
device.storageSettings.values.ipAddress = ipAddress;
|
|
199
201
|
device.storageSettings.values.capabilities = capabilities;
|
|
200
202
|
device.storageSettings.values.uid = uid;
|
|
203
|
+
device.storageSettings.values.discoveryMethod = detection.udpDiscoveryMethod;
|
|
201
204
|
|
|
202
205
|
return nativeId;
|
|
203
206
|
}
|
|
@@ -347,7 +350,9 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
|
|
|
347
350
|
*/
|
|
348
351
|
async generateThumbnail(request: ThumbnailRequestInput): Promise<MediaObject> {
|
|
349
352
|
const queueLength = this.thumbnailQueue.length;
|
|
350
|
-
|
|
353
|
+
// Use device logger if available, otherwise fallback to provided logger
|
|
354
|
+
const logger = request.device?.getBaichuanLogger?.() || request.logger || console;
|
|
355
|
+
logger.log(`[Thumbnail] Download start: fileId=${request.fileId}, queuePosition=${queueLength + 1}`);
|
|
351
356
|
|
|
352
357
|
return new Promise((resolve, reject) => {
|
|
353
358
|
this.thumbnailQueue.push({
|
|
@@ -371,11 +376,11 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
|
|
|
371
376
|
|
|
372
377
|
while (this.thumbnailQueue.length > 0) {
|
|
373
378
|
const request = this.thumbnailQueue.shift()!;
|
|
374
|
-
const logger = request.logger;
|
|
379
|
+
const logger = request.device?.getBaichuanLogger?.() || request.logger || console;
|
|
375
380
|
|
|
376
381
|
try {
|
|
377
382
|
const thumbnail = await extractThumbnailFromVideo(request);
|
|
378
|
-
logger.log(`[Thumbnail]
|
|
383
|
+
logger.log(`[Thumbnail] Download completed: fileId=${request.fileId}`);
|
|
379
384
|
request.resolve(thumbnail);
|
|
380
385
|
} catch (error) {
|
|
381
386
|
logger.error(`[Thumbnail] Error: fileId=${request.fileId}`, error);
|
package/src/nvr.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import type { DeviceInfoResponse, EnrichedRecordingFile, EventsResponse, ReolinkBaichuanApi, ReolinkCgiApi, ReolinkSimpleEvent } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
1
|
+
import type { DeviceInfoResponse, EnrichedRecordingFile, EventsResponse, ListNvrRecordingsParams, ReolinkBaichuanApi, ReolinkCgiApi, ReolinkSimpleEvent } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
2
2
|
import sdk, { AdoptDevice, Device, DeviceDiscovery, DeviceProvider, DiscoveredDevice, Reboot, ScryptedDeviceType, ScryptedInterface, Setting, Settings, SettingValue } from "@scrypted/sdk";
|
|
3
3
|
import { StorageSettings } from "@scrypted/sdk/storage-settings";
|
|
4
4
|
import { BaseBaichuanClass, type BaichuanConnectionCallbacks, type BaichuanConnectionConfig } from "./baichuan-base";
|
|
5
5
|
import { ReolinkNativeCamera } from "./camera";
|
|
6
6
|
import { ReolinkNativeBatteryCamera } from "./camera-battery";
|
|
7
|
+
import { convertDebugLogsToApiOptions, getApiRelevantDebugLogs, getDebugLogChoices } from "./debug-options";
|
|
7
8
|
import { normalizeUid } from "./connect";
|
|
8
9
|
import ReolinkNativePlugin from "./main";
|
|
9
10
|
import { getDeviceInterfaces, updateDeviceInfo } from "./utils";
|
|
@@ -44,8 +45,8 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
|
|
|
44
45
|
onPut: async () => await this.reinit()
|
|
45
46
|
},
|
|
46
47
|
diagnosticsRun: {
|
|
47
|
-
subgroup: '
|
|
48
|
-
title: 'Run
|
|
48
|
+
subgroup: 'Advanced',
|
|
49
|
+
title: 'Run Diagnostics',
|
|
49
50
|
description: 'Collect NVR diagnostics and display results in logs.',
|
|
50
51
|
type: 'button',
|
|
51
52
|
immediate: true,
|
|
@@ -53,6 +54,47 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
|
|
|
53
54
|
await this.runNvrDiagnostics();
|
|
54
55
|
},
|
|
55
56
|
},
|
|
57
|
+
socketApiDebugLogs: {
|
|
58
|
+
subgroup: 'Advanced',
|
|
59
|
+
title: 'Socket API Debug Logs',
|
|
60
|
+
description: 'Enable specific debug logs.',
|
|
61
|
+
multiple: true,
|
|
62
|
+
combobox: true,
|
|
63
|
+
immediate: true,
|
|
64
|
+
defaultValue: [],
|
|
65
|
+
choices: getDebugLogChoices(),
|
|
66
|
+
onPut: async (ov, value) => {
|
|
67
|
+
const logger = this.getBaichuanLogger();
|
|
68
|
+
const oldApiOptions = getApiRelevantDebugLogs(ov || []);
|
|
69
|
+
const newApiOptions = getApiRelevantDebugLogs(value || []);
|
|
70
|
+
|
|
71
|
+
const oldSel = new Set(oldApiOptions);
|
|
72
|
+
const newSel = new Set(newApiOptions);
|
|
73
|
+
|
|
74
|
+
const changed = oldSel.size !== newSel.size || Array.from(oldSel).some((k) => !newSel.has(k));
|
|
75
|
+
if (changed) {
|
|
76
|
+
// Clear any existing timeout
|
|
77
|
+
if (this.debugLogsResetTimeout) {
|
|
78
|
+
clearTimeout(this.debugLogsResetTimeout);
|
|
79
|
+
this.debugLogsResetTimeout = undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Defer reset by 2 seconds to allow settings to settle
|
|
83
|
+
this.debugLogsResetTimeout = setTimeout(async () => {
|
|
84
|
+
this.debugLogsResetTimeout = undefined;
|
|
85
|
+
try {
|
|
86
|
+
// Force reconnection with new debug options
|
|
87
|
+
this.baichuanApi = undefined;
|
|
88
|
+
this.ensureClientPromise = undefined;
|
|
89
|
+
// Trigger reconnection
|
|
90
|
+
await this.ensureBaichuanClient();
|
|
91
|
+
} catch (e) {
|
|
92
|
+
logger.warn('Failed to reset client after debug logs change', e);
|
|
93
|
+
}
|
|
94
|
+
}, 2000);
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
},
|
|
56
98
|
});
|
|
57
99
|
plugin: ReolinkNativePlugin;
|
|
58
100
|
nvrApi: ReolinkCgiApi | undefined;
|
|
@@ -71,6 +113,7 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
|
|
|
71
113
|
private discoverDevicesPromise: Promise<DiscoveredDevice[]> | undefined;
|
|
72
114
|
processing = false;
|
|
73
115
|
private initReinitTimeout: NodeJS.Timeout | undefined;
|
|
116
|
+
private debugLogsResetTimeout: NodeJS.Timeout | undefined;
|
|
74
117
|
|
|
75
118
|
constructor(nativeId: string, plugin: ReolinkNativePlugin) {
|
|
76
119
|
super(nativeId);
|
|
@@ -84,22 +127,28 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
|
|
|
84
127
|
await api.reboot();
|
|
85
128
|
}
|
|
86
129
|
|
|
87
|
-
// BaseBaichuanClass abstract methods implementation
|
|
88
130
|
protected getConnectionConfig(): BaichuanConnectionConfig {
|
|
89
131
|
const { ipAddress, username, password } = this.storageSettings.values;
|
|
90
132
|
if (!ipAddress || !username || !password) {
|
|
91
133
|
throw new Error('Missing NVR credentials');
|
|
92
134
|
}
|
|
93
135
|
|
|
136
|
+
const debugOptions = this.getBaichuanDebugOptions();
|
|
137
|
+
|
|
94
138
|
return {
|
|
95
139
|
host: ipAddress,
|
|
96
140
|
username,
|
|
97
141
|
password,
|
|
98
142
|
transport: 'tcp',
|
|
99
|
-
|
|
143
|
+
debugOptions,
|
|
100
144
|
};
|
|
101
145
|
}
|
|
102
146
|
|
|
147
|
+
getBaichuanDebugOptions(): any | undefined {
|
|
148
|
+
const socketDebugLogs = this.storageSettings.values.socketApiDebugLogs || [];
|
|
149
|
+
return convertDebugLogsToApiOptions(socketDebugLogs);
|
|
150
|
+
}
|
|
151
|
+
|
|
103
152
|
protected getConnectionCallbacks(): BaichuanConnectionCallbacks {
|
|
104
153
|
return {
|
|
105
154
|
onError: undefined, // Use default error handling
|
|
@@ -187,29 +236,6 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
|
|
|
187
236
|
return this.nvrApi;
|
|
188
237
|
}
|
|
189
238
|
|
|
190
|
-
/**
|
|
191
|
-
* List enriched VOD files (with proper parsing and detection info)
|
|
192
|
-
* This uses the library's enrichVodFile which handles all parsing correctly
|
|
193
|
-
*/
|
|
194
|
-
async listEnrichedVodFiles(params: {
|
|
195
|
-
channel: number;
|
|
196
|
-
start: Date;
|
|
197
|
-
end: Date;
|
|
198
|
-
streamType?: "main" | "sub";
|
|
199
|
-
autoSearchByDay?: boolean;
|
|
200
|
-
bypassCache?: boolean;
|
|
201
|
-
}): Promise<Array<EnrichedRecordingFile>> {
|
|
202
|
-
const api = await this.ensureClient();
|
|
203
|
-
return await api.listEnrichedVodFiles({
|
|
204
|
-
channel: params.channel,
|
|
205
|
-
start: params.start,
|
|
206
|
-
end: params.end,
|
|
207
|
-
streamType: params.streamType,
|
|
208
|
-
autoSearchByDay: params.autoSearchByDay,
|
|
209
|
-
bypassCache: params.bypassCache,
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
|
|
213
239
|
private forwardNativeEvent(ev: ReolinkSimpleEvent): void {
|
|
214
240
|
const logger = this.getBaichuanLogger();
|
|
215
241
|
|
|
@@ -298,15 +324,13 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
|
|
|
298
324
|
logger.log(`Starting NVR diagnostics...`);
|
|
299
325
|
|
|
300
326
|
try {
|
|
301
|
-
const cgiApi = await this.
|
|
327
|
+
const cgiApi = await this.ensureBaichuanClient();
|
|
302
328
|
|
|
303
329
|
const diagnostics = await cgiApi.collectNvrDiagnostics({
|
|
304
330
|
logger: this.console,
|
|
305
331
|
});
|
|
306
332
|
|
|
307
333
|
logger.log(`NVR diagnostics completed successfully.`);
|
|
308
|
-
|
|
309
|
-
cgiApi.printNvrDiagnostics(diagnostics, this.console);
|
|
310
334
|
} catch (e) {
|
|
311
335
|
logger.error('Failed to run NVR diagnostics', e);
|
|
312
336
|
throw e;
|
|
@@ -427,7 +451,7 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
|
|
|
427
451
|
|
|
428
452
|
const { ipAddress } = this.storageSettings.values;
|
|
429
453
|
try {
|
|
430
|
-
const api = await this.
|
|
454
|
+
const api = await this.ensureBaichuanClient();
|
|
431
455
|
const deviceData = await api.getInfo();
|
|
432
456
|
|
|
433
457
|
await updateDeviceInfo({
|
package/src/utils.ts
CHANGED
|
@@ -8,6 +8,25 @@ import fs from "fs";
|
|
|
8
8
|
import path from "path";
|
|
9
9
|
import crypto from "crypto";
|
|
10
10
|
import { CommonCameraMixin } from "./common";
|
|
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
|
+
|
|
11
30
|
|
|
12
31
|
/**
|
|
13
32
|
* Enumeration of operation types that may require specific channel assignments
|
|
@@ -186,6 +205,7 @@ export async function recordingFileToVideoClip(
|
|
|
186
205
|
deviceId,
|
|
187
206
|
fileId: id,
|
|
188
207
|
plugin,
|
|
208
|
+
logger,
|
|
189
209
|
});
|
|
190
210
|
videoHref = videoUrl;
|
|
191
211
|
thumbnailHref = thumbnailUrl;
|
|
@@ -309,6 +329,53 @@ export async function recordingFileToVideoClip(
|
|
|
309
329
|
};
|
|
310
330
|
}
|
|
311
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Convert an array of RecordingFile or EnrichedRecordingFile to VideoClip array
|
|
334
|
+
* Uses recordingFileToVideoClip for each recording
|
|
335
|
+
* Handles both NVR (EnrichedRecordingFile) and device standalone (RecordingFile) cases
|
|
336
|
+
*/
|
|
337
|
+
export async function recordingsToVideoClips(
|
|
338
|
+
recordings: (RecordingFile | EnrichedRecordingFile)[],
|
|
339
|
+
options: {
|
|
340
|
+
/** Fallback start date if recording doesn't have one */
|
|
341
|
+
fallbackStart: Date;
|
|
342
|
+
/** API instance to get playback URLs (optional, for device standalone recordings) */
|
|
343
|
+
api?: ReolinkBaichuanApi;
|
|
344
|
+
/** Logger for debug messages */
|
|
345
|
+
logger?: Console;
|
|
346
|
+
/** Plugin instance for generating webhook URLs */
|
|
347
|
+
plugin?: ScryptedDeviceBase;
|
|
348
|
+
/** Device ID for webhook URLs */
|
|
349
|
+
deviceId?: string;
|
|
350
|
+
/** Use webhook URLs instead of direct RTMP URLs */
|
|
351
|
+
useWebhook?: boolean;
|
|
352
|
+
/** Maximum number of clips to return (optional) */
|
|
353
|
+
count?: number;
|
|
354
|
+
}
|
|
355
|
+
): Promise<VideoClip[]> {
|
|
356
|
+
const { fallbackStart, api, logger, plugin, deviceId, useWebhook, count } = options;
|
|
357
|
+
const clips: VideoClip[] = [];
|
|
358
|
+
|
|
359
|
+
for (const rec of recordings) {
|
|
360
|
+
try {
|
|
361
|
+
const clip = await recordingFileToVideoClip(rec, {
|
|
362
|
+
fallbackStart,
|
|
363
|
+
api,
|
|
364
|
+
logger,
|
|
365
|
+
plugin,
|
|
366
|
+
deviceId,
|
|
367
|
+
useWebhook,
|
|
368
|
+
});
|
|
369
|
+
clips.push(clip);
|
|
370
|
+
} catch (e) {
|
|
371
|
+
logger?.warn(`Failed to convert recording to video clip: fileName=${rec.fileName}`, e);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Apply count limit if specified
|
|
376
|
+
return count ? clips.slice(0, count) : clips;
|
|
377
|
+
}
|
|
378
|
+
|
|
312
379
|
/**
|
|
313
380
|
* Generate webhook URLs for video clips
|
|
314
381
|
*/
|
|
@@ -316,10 +383,12 @@ export async function getVideoClipWebhookUrls(props: {
|
|
|
316
383
|
deviceId: string;
|
|
317
384
|
fileId: string;
|
|
318
385
|
plugin: ScryptedDeviceBase;
|
|
386
|
+
logger?: Console;
|
|
319
387
|
}): Promise<{ videoUrl: string; thumbnailUrl: string }> {
|
|
320
|
-
const { deviceId, fileId, plugin } = props;
|
|
388
|
+
const { deviceId, fileId, plugin, logger } = props;
|
|
389
|
+
const log = logger || plugin.console;
|
|
321
390
|
|
|
322
|
-
|
|
391
|
+
// log.debug?.(`[getVideoClipWebhookUrls] Starting URL generation: deviceId=${deviceId}, fileId=${fileId}`);
|
|
323
392
|
|
|
324
393
|
try {
|
|
325
394
|
let endpoint: string;
|
|
@@ -327,13 +396,13 @@ export async function getVideoClipWebhookUrls(props: {
|
|
|
327
396
|
try {
|
|
328
397
|
endpoint = await sdk.endpointManager.getCloudEndpoint(undefined, { public: true });
|
|
329
398
|
endpointSource = 'cloud';
|
|
330
|
-
|
|
399
|
+
// log.debug?.(`[getVideoClipWebhookUrls] Using cloud endpoint: ${endpoint}`);
|
|
331
400
|
} catch (e) {
|
|
332
401
|
// Fallback to local endpoint if cloud is not available (e.g., not logged in)
|
|
333
|
-
|
|
402
|
+
log.debug?.(`[getVideoClipWebhookUrls] Cloud endpoint not available, using local endpoint: ${e instanceof Error ? e.message : String(e)}`);
|
|
334
403
|
endpoint = await sdk.endpointManager.getLocalEndpoint(undefined, { public: true });
|
|
335
404
|
endpointSource = 'local';
|
|
336
|
-
|
|
405
|
+
// log.debug?.(`[getVideoClipWebhookUrls] Using local endpoint: ${endpoint}`);
|
|
337
406
|
}
|
|
338
407
|
|
|
339
408
|
const encodedDeviceId = encodeURIComponent(deviceId);
|
|
@@ -341,8 +410,6 @@ export async function getVideoClipWebhookUrls(props: {
|
|
|
341
410
|
const cleanFileId = fileId.startsWith('/') ? fileId.substring(1) : fileId;
|
|
342
411
|
const encodedFileId = encodeURIComponent(cleanFileId);
|
|
343
412
|
|
|
344
|
-
plugin.console.debug(`[getVideoClipWebhookUrls] Encoding: deviceId="${deviceId}" -> "${encodedDeviceId}", fileId="${fileId}" -> cleanFileId="${cleanFileId}" -> encodedFileId="${encodedFileId}"`);
|
|
345
|
-
|
|
346
413
|
// Parse endpoint URL to extract query parameters (for authentication)
|
|
347
414
|
const endpointUrl = new URL(endpoint);
|
|
348
415
|
// Preserve query parameters (e.g., user_token for authentication)
|
|
@@ -350,22 +417,19 @@ export async function getVideoClipWebhookUrls(props: {
|
|
|
350
417
|
// Remove query parameters from the base endpoint URL
|
|
351
418
|
endpointUrl.search = '';
|
|
352
419
|
|
|
353
|
-
plugin.console.debug(`[getVideoClipWebhookUrls] Parsed endpoint URL: base="${endpointUrl.toString()}", queryParams="${queryParams}"`);
|
|
354
|
-
|
|
355
420
|
// Ensure endpoint has trailing slash
|
|
356
421
|
const normalizedEndpoint = endpointUrl.toString().endsWith('/') ? endpointUrl.toString() : `${endpointUrl.toString()}/`;
|
|
357
422
|
|
|
358
|
-
plugin.console.debug(`[getVideoClipWebhookUrls] Normalized endpoint: "${normalizedEndpoint}"`);
|
|
359
|
-
|
|
360
423
|
// Build webhook URLs and append query parameters at the end
|
|
361
424
|
const videoUrl = `${normalizedEndpoint}webhook/video/${encodedDeviceId}/${encodedFileId}${queryParams}`;
|
|
362
425
|
const thumbnailUrl = `${normalizedEndpoint}webhook/thumbnail/${encodedDeviceId}/${encodedFileId}${queryParams}`;
|
|
363
426
|
|
|
364
|
-
plugin.console.debug(`[getVideoClipWebhookUrls] Generated URLs: videoUrl="${videoUrl}", thumbnailUrl="${thumbnailUrl}"`);
|
|
365
|
-
|
|
366
427
|
return { videoUrl, thumbnailUrl };
|
|
367
428
|
} catch (e) {
|
|
368
|
-
|
|
429
|
+
log.error?.(
|
|
430
|
+
`[getVideoClipWebhookUrls] Failed to generate webhook URLs: deviceId=${deviceId}, fileId=${fileId}`,
|
|
431
|
+
e
|
|
432
|
+
);
|
|
369
433
|
throw e;
|
|
370
434
|
}
|
|
371
435
|
}
|
|
@@ -378,9 +442,12 @@ export async function extractThumbnailFromVideo(props: {
|
|
|
378
442
|
filePath?: string;
|
|
379
443
|
fileId: string;
|
|
380
444
|
deviceId: string;
|
|
381
|
-
logger
|
|
445
|
+
logger?: Console;
|
|
446
|
+
device?: CommonCameraMixin;
|
|
382
447
|
}): Promise<MediaObject> {
|
|
383
|
-
const { rtmpUrl, filePath, fileId, deviceId,
|
|
448
|
+
const { rtmpUrl, filePath, fileId, deviceId, device } = props;
|
|
449
|
+
// Use device logger if available, otherwise fallback to provided logger
|
|
450
|
+
const logger = device?.getBaichuanLogger?.() || props.logger || console;
|
|
384
451
|
|
|
385
452
|
// Use file path if available, otherwise use RTMP URL
|
|
386
453
|
const inputSource = filePath || rtmpUrl;
|
|
@@ -427,12 +494,14 @@ export async function handleVideoClipRequest(props: {
|
|
|
427
494
|
fileId: string;
|
|
428
495
|
request: HttpRequest;
|
|
429
496
|
response: HttpResponse;
|
|
430
|
-
logger
|
|
497
|
+
logger?: Console;
|
|
431
498
|
}): Promise<void> {
|
|
432
|
-
const { device, deviceId, fileId, request, response
|
|
499
|
+
const { device, deviceId, fileId, request, response } = props;
|
|
500
|
+
const logger = device.getBaichuanLogger?.() || props.logger || console;
|
|
433
501
|
|
|
434
502
|
// Check if file is cached
|
|
435
503
|
const cachePath = getVideoClipCachePath(deviceId, fileId);
|
|
504
|
+
const MIN_VIDEO_CACHE_BYTES = 16 * 1024; // 16KB, evita file quasi vuoti/corrotti
|
|
436
505
|
|
|
437
506
|
try {
|
|
438
507
|
// Check if cached file exists
|
|
@@ -440,6 +509,17 @@ export async function handleVideoClipRequest(props: {
|
|
|
440
509
|
const fileSize = stat.size;
|
|
441
510
|
const range = request.headers.range;
|
|
442
511
|
|
|
512
|
+
if (fileSize < MIN_VIDEO_CACHE_BYTES) {
|
|
513
|
+
logger.warn(`Cached video clip too small, deleting and reloading: fileId=${fileId}, size=${fileSize} bytes`);
|
|
514
|
+
try {
|
|
515
|
+
await fs.promises.unlink(cachePath);
|
|
516
|
+
} catch (unlinkErr) {
|
|
517
|
+
logger.warn(`Failed to delete small cached video clip: fileId=${fileId}`, unlinkErr);
|
|
518
|
+
}
|
|
519
|
+
// Force cache miss path below
|
|
520
|
+
throw new Error('Cached video too small, deleted');
|
|
521
|
+
}
|
|
522
|
+
|
|
443
523
|
logger.log(`Serving cached video clip: fileId=${fileId}, size=${fileSize}, range=${range}`);
|
|
444
524
|
|
|
445
525
|
if (range) {
|
|
@@ -503,12 +583,12 @@ export async function handleVideoClipRequest(props: {
|
|
|
503
583
|
|
|
504
584
|
if (isHttpUrl) {
|
|
505
585
|
// For HTTP URLs (Playback/Download from NVR), do direct HTTP proxy with ranged headers support
|
|
506
|
-
logger.
|
|
586
|
+
logger.debug(`Proxying HTTP URL directly: fileId=${fileId}, url=${rtmpVodUrl}`);
|
|
507
587
|
|
|
508
588
|
const sendVideo = async () => {
|
|
509
589
|
// Pre-fetch ffmpeg path in case we need it for FLV conversion
|
|
510
590
|
const ffmpegPathPromise = sdk.mediaManager.getFFmpegPath();
|
|
511
|
-
|
|
591
|
+
|
|
512
592
|
return new Promise<void>(async (resolve, reject) => {
|
|
513
593
|
const urlObj = new URL(rtmpVodUrl);
|
|
514
594
|
const httpModule = urlObj.protocol === 'https:' ? https : http;
|
|
@@ -531,7 +611,7 @@ export async function handleVideoClipRequest(props: {
|
|
|
531
611
|
headers: requestHeaders,
|
|
532
612
|
};
|
|
533
613
|
|
|
534
|
-
logger.
|
|
614
|
+
logger.debug(`Starting HTTP request: ${rtmpVodUrl}, headers: ${JSON.stringify(requestHeaders)}`);
|
|
535
615
|
|
|
536
616
|
httpModule.get(options, async (httpResponse) => {
|
|
537
617
|
if (httpResponse.statusCode && httpResponse.statusCode >= 400) {
|
|
@@ -547,9 +627,9 @@ export async function handleVideoClipRequest(props: {
|
|
|
547
627
|
|
|
548
628
|
// Check if we need to convert FLV to MP4
|
|
549
629
|
const isFlv = typeof contentType === 'string' && (contentType === 'video/x-flv' || contentType === 'video/flv');
|
|
550
|
-
|
|
630
|
+
|
|
551
631
|
if (isFlv) {
|
|
552
|
-
logger.
|
|
632
|
+
logger.debug(`Content-Type is FLV (${contentType}), will convert to MP4 using ffmpeg`);
|
|
553
633
|
}
|
|
554
634
|
|
|
555
635
|
const responseHeaders: Record<string, string> = {
|
|
@@ -568,11 +648,11 @@ export async function handleVideoClipRequest(props: {
|
|
|
568
648
|
|
|
569
649
|
const statusCode = httpResponse.statusCode || 200;
|
|
570
650
|
|
|
571
|
-
logger.
|
|
651
|
+
logger.debug(`HTTP response received: status=${statusCode}, contentType=${contentType}, contentLength=${contentLength || 'unknown'}`);
|
|
572
652
|
|
|
573
653
|
try {
|
|
574
|
-
|
|
575
|
-
|
|
654
|
+
if (isFlv) {
|
|
655
|
+
// Convert FLV to MP4 using ffmpeg
|
|
576
656
|
const ffmpegPath = await ffmpegPathPromise;
|
|
577
657
|
// Re-encode instead of copy because FLV codec might not be supported
|
|
578
658
|
const ffmpegArgs: string[] = [
|
|
@@ -648,10 +728,11 @@ export async function handleVideoClipRequest(props: {
|
|
|
648
728
|
// Handle ffmpeg errors
|
|
649
729
|
ffmpeg.on('close', (code) => {
|
|
650
730
|
if (code !== 0 && code !== null && !streamStarted) {
|
|
651
|
-
|
|
731
|
+
const sanitized = sanitizeFfmpegOutput(ffmpegError);
|
|
732
|
+
logger.error(`FFmpeg conversion failed: fileId=${fileId}, code=${code}, error=${sanitized}`);
|
|
652
733
|
reject(new Error(`FFmpeg conversion failed: ${code}`));
|
|
653
734
|
} else {
|
|
654
|
-
logger.
|
|
735
|
+
logger.debug(`FFmpeg conversion completed: fileId=${fileId}, code=${code}`);
|
|
655
736
|
resolve();
|
|
656
737
|
}
|
|
657
738
|
});
|
|
@@ -661,7 +742,7 @@ export async function handleVideoClipRequest(props: {
|
|
|
661
742
|
reject(error);
|
|
662
743
|
});
|
|
663
744
|
|
|
664
|
-
logger.
|
|
745
|
+
logger.debug(`FFmpeg conversion started: fileId=${fileId}`);
|
|
665
746
|
} else {
|
|
666
747
|
// Direct proxy for non-FLV content (should be MP4 already)
|
|
667
748
|
// Stream directly without buffering - yield chunks as they arrive
|
|
@@ -757,7 +838,8 @@ export async function handleVideoClipRequest(props: {
|
|
|
757
838
|
// Handle ffmpeg errors
|
|
758
839
|
ffmpeg.on('close', (code) => {
|
|
759
840
|
if (code !== 0 && code !== null && !streamStarted) {
|
|
760
|
-
|
|
841
|
+
const sanitized = sanitizeFfmpegOutput(ffmpegError);
|
|
842
|
+
logger.error(`FFmpeg proxy failed for video: fileId=${fileId}, code=${code}, error=${sanitized}`);
|
|
761
843
|
}
|
|
762
844
|
});
|
|
763
845
|
|
|
@@ -953,6 +1035,7 @@ export async function vodSearchResultsToVideoClips(
|
|
|
953
1035
|
deviceId,
|
|
954
1036
|
fileId: fileName,
|
|
955
1037
|
plugin,
|
|
1038
|
+
logger,
|
|
956
1039
|
});
|
|
957
1040
|
videoHref = videoUrl;
|
|
958
1041
|
thumbnailHref = thumbnailUrl;
|