@apocaliss92/scrypted-reolink-native 0.1.42 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
1
  import type {
2
2
  CompositeStreamPipOptions,
3
+ NativeVideoStreamVariant,
3
4
  ReolinkBaichuanApi,
4
5
  Rfc4571TcpServer,
5
6
  StreamProfile,
6
- VideoType,
7
7
  } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
8
8
 
9
9
  import sdk, {
@@ -17,10 +17,11 @@ export interface StreamManagerOptions {
17
17
  /**
18
18
  * Creates a dedicated Baichuan session for streaming.
19
19
  * Required to support concurrent main+ext streams on firmwares where streamType overlaps.
20
- * @param profile The stream profile (main, sub, ext) - used to determine if a new client is needed.
20
+ * @param streamKey The unique stream key (e.g., "composite_default_main", "channel_0_main", etc.)
21
+ * Contains all necessary information (profile, variantType, channel) for stream identification.
21
22
  */
22
- createStreamClient: (profile?: StreamProfile) => Promise<ReolinkBaichuanApi>;
23
- getLogger: () => Console;
23
+ createStreamClient: (streamKey: string) => Promise<ReolinkBaichuanApi>;
24
+ logger: Console;
24
25
  /**
25
26
  * Credentials to include in the TCP stream (username, password).
26
27
  * Uses the same credentials as the main connection.
@@ -39,19 +40,38 @@ export function parseStreamProfileFromId(id: string | undefined): StreamProfile
39
40
  if (!id)
40
41
  return;
41
42
 
42
- // Handle native stream IDs: native_main, native_sub, native_ext
43
+ // Handle plain profiles (used by composite parsing: 'main'/'sub'/'ext')
44
+ if (id === 'main' || id === 'sub' || id === 'ext') {
45
+ return id as StreamProfile;
46
+ }
47
+
48
+ // Handle native stream IDs: native_main, native_sub, native_ext, native_autotrack_main, native_autotrack_sub, etc.
43
49
  if (id.startsWith('native_')) {
44
- const profile = id.replace('native_', '');
45
- return profile as StreamProfile;
50
+ const withoutPrefix = id.replace('native_', '');
51
+ // Extract profile from formats like "main", "sub", "ext", "autotrack_main", "telephoto_sub", etc.
52
+ // The profile is always the last part after underscore or the whole string if no underscore
53
+ const parts = withoutPrefix.split('_');
54
+ const profile = parts[parts.length - 1]; // Take the last part as profile
55
+ if (profile === 'main' || profile === 'sub' || profile === 'ext') {
56
+ return profile as StreamProfile;
57
+ }
58
+ // If no valid profile found, try to match the whole string
59
+ if (withoutPrefix === 'main' || withoutPrefix === 'sub' || withoutPrefix === 'ext') {
60
+ return withoutPrefix as StreamProfile;
61
+ }
62
+ return undefined;
46
63
  }
47
64
 
48
65
  // Handle RTMP IDs: main.bcs, sub.bcs, ext.bcs
49
66
  if (id.endsWith('.bcs')) {
50
67
  const profile = id.replace('.bcs', '');
51
- return profile as StreamProfile;
68
+ if (profile === 'main' || profile === 'sub' || profile === 'ext') {
69
+ return profile as StreamProfile;
70
+ }
71
+ return undefined;
52
72
  }
53
73
 
54
- // Handle RTSP IDs: h264Preview_XX_main, h264Preview_XX_sub
74
+ // Handle RTSP IDs: h264Preview_XX_main, h264Preview_XX_sub, Preview_03_autotrack, etc.
55
75
  if (id.startsWith('h264Preview_')) {
56
76
  if (id.endsWith('_main'))
57
77
  return 'main';
@@ -59,9 +79,78 @@ export function parseStreamProfileFromId(id: string | undefined): StreamProfile
59
79
  return 'sub';
60
80
  }
61
81
 
82
+ // Handle RTSP IDs like Preview_03_autotrack, Preview_03_autotrack_sub
83
+ // These should map to main or sub based on the suffix
84
+ if (id.includes('Preview_')) {
85
+ if (id.endsWith('_autotrack_sub') || id.endsWith('_sub')) {
86
+ return 'sub';
87
+ }
88
+ if (id.endsWith('_autotrack') || id.endsWith('_main') || id.match(/Preview_\d+_?[a-z]*$/)) {
89
+ return 'main';
90
+ }
91
+ }
92
+
62
93
  return;
63
94
  }
64
95
 
96
+ /**
97
+ * Extract and normalize variant type from stream ID or URL (e.g., "autotrack" from "native_autotrack_main" or "?variant=autotrack")
98
+ * Returns undefined if no variant is present, or "autotrack"/"telephoto" if present
99
+ * Note: on Hub/NVR multifocal firmwares, the tele lens is often requested via "telephoto".
100
+ */
101
+ export function extractVariantFromStreamId(id: string | undefined, url?: string | undefined): 'autotrack' | 'telephoto' | undefined {
102
+ let variant: string | undefined;
103
+
104
+ // First try to extract from ID
105
+ if (id) {
106
+ // Handle native stream IDs: native_autotrack_main, native_telephoto_sub, etc.
107
+ if (id.startsWith('native_')) {
108
+ const withoutPrefix = id.replace('native_', '');
109
+ const parts = withoutPrefix.split('_');
110
+ // If there are more than 1 parts, the first part(s) before the profile is the variant
111
+ // e.g., "autotrack_main" -> variant: "autotrack", profile: "main"
112
+ if (parts.length > 1) {
113
+ const profile = parts[parts.length - 1];
114
+ // Only return variant if profile is valid (main/sub/ext)
115
+ if (profile === 'main' || profile === 'sub' || profile === 'ext') {
116
+ // Join all parts except the last one as variant (handles multi-part variants)
117
+ variant = parts.slice(0, -1).join('_');
118
+ }
119
+ }
120
+ }
121
+
122
+ // Handle RTSP IDs like Preview_03_autotrack, Preview_03_autotrack_sub
123
+ if (!variant && id.includes('Preview_')) {
124
+ if (id.includes('_autotrack')) {
125
+ variant = 'autotrack';
126
+ } else if (id.includes('_telephoto')) {
127
+ variant = 'telephoto';
128
+ }
129
+ }
130
+ }
131
+
132
+ // Fallback: try to extract from URL query parameter
133
+ if (!variant && url) {
134
+ try {
135
+ const urlObj = new URL(url);
136
+ const variantParam = urlObj.searchParams.get('variant');
137
+ if (variantParam) {
138
+ variant = variantParam;
139
+ }
140
+ } catch {
141
+ // Invalid URL, ignore
142
+ }
143
+ }
144
+
145
+ // Normalize variant: accept "autotrack", "telephoto", or map "default" to undefined
146
+ if (variant === 'autotrack' || variant === 'telephoto') {
147
+ // Preserve explicit variants; firmware-specific behavior is handled by the library.
148
+ return variant as 'autotrack' | 'telephoto';
149
+ }
150
+
151
+ return undefined;
152
+ }
153
+
65
154
  export function selectStreamOption(
66
155
  vsos: UrlMediaStreamOptions[] | undefined,
67
156
  request: RequestMediaStreamOptions,
@@ -72,39 +161,18 @@ export function selectStreamOption(
72
161
  return selected;
73
162
  }
74
163
 
75
- export function expectedVideoTypeFromUrlMediaStreamOptions(selected: UrlMediaStreamOptions): 'H264' | 'H265' | undefined {
76
- const codec = selected?.video?.codec;
77
- if (typeof codec !== 'string') return undefined;
78
- if (codec.includes('265')) return 'H265';
79
- if (codec.includes('264')) return 'H264';
80
- return undefined;
81
- }
82
-
83
164
  export async function createRfc4571MediaObjectFromStreamManager(params: {
84
165
  streamManager: StreamManager;
85
166
  channel: number;
86
167
  profile: StreamProfile;
87
168
  streamKey: string;
88
- expectedVideoType?: 'H264' | 'H265';
169
+ variant?: NativeVideoStreamVariant;
89
170
  selected: UrlMediaStreamOptions;
90
171
  sourceId: string;
91
- onDetectedCodec?: (detectedCodec: 'h264' | 'h265') => void;
92
172
  }): Promise<MediaObject> {
93
- const { streamManager, channel, profile, streamKey, expectedVideoType, selected, sourceId, onDetectedCodec } = params;
94
-
95
- const { host, port, sdp, audio, username, password } = await streamManager.getRfcStream(channel, profile, streamKey, expectedVideoType);
173
+ const { streamManager, channel, profile, streamKey, variant, selected, sourceId } = params;
96
174
 
97
- // Update cached stream options with the detected codec (helps prebuffer/NVR avoid mismatch).
98
- try {
99
- const detected = /a=rtpmap:\d+\s+(H26[45])\//.exec(sdp)?.[1];
100
- if (detected) {
101
- const dc = detected === 'H265' ? 'h265' : 'h264';
102
- onDetectedCodec?.(dc);
103
- }
104
- }
105
- catch {
106
- // ignore
107
- }
175
+ const { host, port, sdp, audio, username, password } = await streamManager.getRfcStream(channel, profile, streamKey, variant);
108
176
 
109
177
  const { url: _ignoredUrl, ...mso }: any = selected;
110
178
  mso.container = 'rtp';
@@ -139,26 +207,26 @@ export async function createRfc4571CompositeMediaObjectFromStreamManager(params:
139
207
  streamManager: StreamManager;
140
208
  profile: StreamProfile;
141
209
  streamKey: string;
142
- expectedVideoType?: 'H264' | 'H265';
143
210
  selected: UrlMediaStreamOptions;
144
211
  sourceId: string;
145
- onDetectedCodec?: (detectedCodec: 'h264' | 'h265') => void;
212
+ variantType?: NativeVideoStreamVariant;
146
213
  }): Promise<MediaObject> {
147
- const { streamManager, profile, streamKey, expectedVideoType, selected, sourceId, onDetectedCodec } = params;
148
-
149
- const { host, port, sdp, audio, username, password } = await streamManager.getRfcCompositeStream(profile, streamKey, expectedVideoType);
150
-
151
- // Update cached stream options with the detected codec (helps prebuffer/NVR avoid mismatch).
152
- try {
153
- const detected = /a=rtpmap:\d+\s+(H26[45])\//.exec(sdp)?.[1];
154
- if (detected) {
155
- const dc = detected === 'H265' ? 'h265' : 'h264';
156
- onDetectedCodec?.(dc);
214
+ const { streamManager, profile, streamKey, selected, sourceId, variantType } = params;
215
+
216
+ // Extract variantType from streamKey if not provided (format: composite_${variantType}_${profile})
217
+ let extractedVariantType = variantType;
218
+ if (!extractedVariantType && streamKey.startsWith('composite_')) {
219
+ const parts = streamKey.split('_');
220
+ if (parts.length >= 3) {
221
+ // Format: composite_${variantType}_${profile}
222
+ const variantPart = parts[1];
223
+ if (variantPart === 'default' || variantPart === 'autotrack' || variantPart === 'telephoto') {
224
+ extractedVariantType = variantPart as NativeVideoStreamVariant;
225
+ }
157
226
  }
158
227
  }
159
- catch {
160
- // ignore
161
- }
228
+
229
+ const { host, port, sdp, audio, username, password } = await streamManager.getRfcCompositeStream(profile, streamKey, extractedVariantType);
162
230
 
163
231
  const { url: _ignoredUrl, ...mso }: any = selected;
164
232
  mso.container = 'rtp';
@@ -170,12 +238,18 @@ export async function createRfc4571CompositeMediaObjectFromStreamManager(params:
170
238
  }
171
239
 
172
240
  // Build URL with credentials: tcp://username:password@host:port
173
- const encodedUsername = encodeURIComponent(username || '');
174
- const encodedPassword = encodeURIComponent(password || '');
175
- const url = `tcp://${encodedUsername}:${encodedPassword}@${host}:${port}`;
241
+ // Keep this consistent with non-composite path (URL object -> JSON string via toJSON()).
242
+ const urlObj = new URL(`tcp://${host}`);
243
+ urlObj.port = port.toString();
244
+ if (username) {
245
+ urlObj.username = username;
246
+ }
247
+ if (password) {
248
+ urlObj.password = password;
249
+ }
176
250
 
177
251
  const rfc = {
178
- url,
252
+ url: urlObj,
179
253
  sdp,
180
254
  mediaStreamOptions: mso as ResponseMediaStreamOptions,
181
255
  };
@@ -199,18 +273,22 @@ export class StreamManager {
199
273
  private nativeRfcServerCreatePromises = new Map<string, Promise<RfcServerInfo>>();
200
274
 
201
275
  constructor(private opts: StreamManagerOptions) {
276
+ // Ensure logger is always valid
277
+ if (!this.opts.logger) {
278
+ this.opts.logger = console;
279
+ }
202
280
  }
203
281
 
204
- private getLogger() {
205
- return this.opts.getLogger();
282
+ private getLogger(): Console {
283
+ return this.opts.logger || console;
206
284
  }
207
285
 
208
286
  private async ensureRfcServer(
209
287
  streamKey: string,
210
288
  profile: StreamProfile,
211
- expectedVideoType: 'H264' | 'H265' | undefined,
212
289
  options: {
213
290
  channel?: number;
291
+ variant?: NativeVideoStreamVariant;
214
292
  compositeOptions?: CompositeStreamPipOptions;
215
293
  },
216
294
  ): Promise<RfcServerInfo> {
@@ -223,37 +301,28 @@ export class StreamManager {
223
301
  // Double-check: if server already exists and is listening, return it immediately
224
302
  const existingServer = this.nativeRfcServers.get(streamKey);
225
303
  if (existingServer?.server?.listening) {
226
- if (!expectedVideoType || existingServer.videoType === expectedVideoType) {
227
- return {
228
- host: existingServer.host,
229
- port: existingServer.port,
230
- sdp: existingServer.sdp,
231
- audio: existingServer.audio,
232
- username: existingServer.username || this.opts.credentials.username,
233
- password: existingServer.password || this.opts.credentials.password,
234
- };
235
- }
304
+ this.getLogger().log(`Reusing existing RFC4571 server for streamKey=${streamKey} (port=${existingServer.port})`);
305
+ return {
306
+ host: existingServer.host,
307
+ port: existingServer.port,
308
+ sdp: existingServer.sdp,
309
+ audio: existingServer.audio,
310
+ username: existingServer.username || this.opts.credentials.username,
311
+ password: existingServer.password || this.opts.credentials.password,
312
+ };
236
313
  }
237
314
 
238
315
  const createPromise = (async () => {
239
316
  const cached = this.nativeRfcServers.get(streamKey);
240
317
  if (cached?.server?.listening) {
241
- if (expectedVideoType && cached.videoType !== expectedVideoType) {
242
- const kind = options.channel === undefined ? 'composite' : 'native';
243
- this.getLogger().warn(
244
- `Native RFC ${kind} cache codec mismatch for ${streamKey}: cached=${cached.videoType} expected=${expectedVideoType}; recreating server.`,
245
- );
246
- }
247
- else {
248
- return {
249
- host: cached.host,
250
- port: cached.port,
251
- sdp: cached.sdp,
252
- audio: cached.audio,
253
- username: cached.username || this.opts.credentials.username,
254
- password: cached.password || this.opts.credentials.password,
255
- };
256
- }
318
+ return {
319
+ host: cached.host,
320
+ port: cached.port,
321
+ sdp: cached.sdp,
322
+ audio: cached.audio,
323
+ username: cached.username || this.opts.credentials.username,
324
+ password: cached.password || this.opts.credentials.password,
325
+ };
257
326
  }
258
327
 
259
328
  if (cached) {
@@ -266,26 +335,71 @@ export class StreamManager {
266
335
  this.nativeRfcServers.delete(streamKey);
267
336
  }
268
337
 
269
- const api = await this.opts.createStreamClient(profile);
338
+ const isComposite = options.channel === undefined;
339
+
340
+ // For composite streams, MUST use two distinct Baichuan sessions (widerApi and teleApi).
341
+ // Otherwise cmd_id=3 frames can mix when streamType overlaps (wide/tele alternation/corruption).
342
+ // Each stream needs its own dedicated socket to avoid frame mixing.
343
+ // Create separate streamKeys for wider and tele to ensure distinct sockets:
344
+ // Format: composite_${variantType}_${profile}_wider and composite_${variantType}_${profile}_tele
345
+ const compositeApis = isComposite
346
+ ? {
347
+ widerApi: await this.opts.createStreamClient(`${streamKey}_wider`),
348
+ teleApi: await this.opts.createStreamClient(`${streamKey}_tele`),
349
+ }
350
+ : undefined;
351
+
352
+ // For non-composite streams, create a single API client
353
+ // For composite streams, api is still required as baseApi but widerApi and teleApi are used instead
354
+ // Pass streamKey to createStreamClient - it contains all necessary information (profile, variantType, channel)
355
+ // For composite streams, streamKey format: composite_${variantType}_${profile}
356
+ // For regular streams, streamKey format: channel_${channel}_${profile}_${variantType} or similar
357
+ const api = isComposite
358
+ ? compositeApis.widerApi // For composite, use widerApi as baseApi (it will be overridden by compositeApis)
359
+ : await this.opts.createStreamClient(streamKey);
360
+
270
361
  const { createRfc4571TcpServer } = await import('@apocaliss92/reolink-baichuan-js');
271
362
 
272
- // Use the same credentials as the main connection
273
363
  const { username, password } = this.opts.credentials;
274
364
 
275
365
  // If connection is shared, don't close it when stream teardown happens
276
- const closeApiOnTeardown = !(this.opts.sharedConnection ?? false);
277
-
278
- const created = await createRfc4571TcpServer({
279
- api,
280
- channel: options.channel,
281
- profile,
282
- logger: this.getLogger(),
283
- expectedVideoType: expectedVideoType as VideoType | undefined,
284
- closeApiOnTeardown,
285
- username,
286
- password,
287
- ...(options.compositeOptions ? { compositeOptions: options.compositeOptions } : {}),
288
- });
366
+ // For composite, we create dedicated APIs even if the device uses a shared main connection.
367
+ // Ensure they are closed on teardown.
368
+ const closeApiOnTeardown = isComposite ? true : !(this.opts.sharedConnection ?? false);
369
+
370
+ let created: any;
371
+ try {
372
+ const compositeOptions = isComposite
373
+ ? {
374
+ ...(options.compositeOptions ?? {}),
375
+ forceH264: true,
376
+ }
377
+ : undefined;
378
+
379
+ created = await createRfc4571TcpServer({
380
+ api,
381
+ channel: options.channel,
382
+ profile,
383
+ variant: options.variant,
384
+ logger: this.getLogger(),
385
+ closeApiOnTeardown,
386
+ username,
387
+ password,
388
+ // Composite can take a bit longer (ffmpeg warmup + first IDR).
389
+ ...(isComposite ? { keyframeTimeoutMs: 20_000, idleTeardownMs: 20_000 } : {}),
390
+ ...(compositeOptions ? { compositeOptions } : {}),
391
+ ...(compositeApis ? { compositeApis } : {}),
392
+ });
393
+ }
394
+ catch (e) {
395
+ if (isComposite && closeApiOnTeardown && compositeApis) {
396
+ await Promise.allSettled([
397
+ compositeApis.widerApi?.close?.(),
398
+ compositeApis.teleApi?.close?.(),
399
+ ]);
400
+ }
401
+ throw e;
402
+ }
289
403
 
290
404
  this.nativeRfcServers.set(streamKey, created);
291
405
  created.server.once('close', () => {
@@ -316,20 +430,37 @@ export class StreamManager {
316
430
  channel: number,
317
431
  profile: StreamProfile,
318
432
  streamKey: string,
319
- expectedVideoType?: 'H264' | 'H265',
433
+ variant?: NativeVideoStreamVariant,
320
434
  ): Promise<RfcServerInfo> {
321
- return await this.ensureRfcServer(streamKey, profile, expectedVideoType, {
435
+ return await this.ensureRfcServer(streamKey, profile, {
322
436
  channel,
437
+ variant,
323
438
  });
324
439
  }
325
440
 
326
441
  async getRfcCompositeStream(
327
442
  profile: StreamProfile,
328
443
  streamKey: string,
329
- expectedVideoType?: 'H264' | 'H265',
444
+ variantType?: NativeVideoStreamVariant,
330
445
  ): Promise<RfcServerInfo> {
331
- return await this.ensureRfcServer(streamKey, profile, expectedVideoType, {
446
+ // Extract variantType from streamKey if not provided (format: composite_${variantType}_${profile})
447
+ let extractedVariantType = variantType;
448
+ if (!extractedVariantType && streamKey.startsWith('composite_')) {
449
+ const parts = streamKey.split('_');
450
+ if (parts.length >= 3) {
451
+ // Format: composite_${variantType}_${profile}
452
+ const variantPart = parts[1];
453
+ if (variantPart === 'default' || variantPart === 'autotrack' || variantPart === 'telephoto') {
454
+ extractedVariantType = variantPart as NativeVideoStreamVariant;
455
+ }
456
+ }
457
+ }
458
+
459
+ // Pass variantType to ensureRfcServer so it can be used when creating the stream client
460
+ // This ensures each variantType gets its own socket
461
+ return await this.ensureRfcServer(streamKey, profile, {
332
462
  channel: undefined, // Undefined channel indicates composite stream
463
+ variant: extractedVariantType,
333
464
  compositeOptions: this.opts.compositeOptions,
334
465
  });
335
466
  }
@@ -347,7 +478,7 @@ export class StreamManager {
347
478
  try {
348
479
  await server.close(reason || 'connection reset');
349
480
  } catch (e) {
350
- this.getLogger().debug('Error closing stream server', e);
481
+ this.getLogger().debug('Error closing stream server', e?.message || String(e));
351
482
  }
352
483
  })
353
484
  );
package/src/utils.ts CHANGED
@@ -7,7 +7,7 @@ import https from "https";
7
7
  import fs from "fs";
8
8
  import path from "path";
9
9
  import crypto from "crypto";
10
- import { CommonCameraMixin } from "./common";
10
+ import { ReolinkCamera } from "./camera";
11
11
  /**
12
12
  * Sanitize FFmpeg output or URLs to avoid leaking credentials
13
13
  */
@@ -101,7 +101,7 @@ export const getDeviceInterfaces = (props: {
101
101
  interfaces.push(ScryptedInterface.BinarySensor);
102
102
  }
103
103
  } catch (e) {
104
- logger.error('Error getting device interfaces', e);
104
+ logger.error('Error getting device interfaces', e?.message || String(e));
105
105
  }
106
106
 
107
107
  return { interfaces, type: capabilities.isDoorbell ? ScryptedDeviceType.Doorbell : ScryptedDeviceType.Camera };
@@ -135,7 +135,6 @@ export const updateDeviceInfo = async (props: {
135
135
 
136
136
  throw e;
137
137
  } finally {
138
-
139
138
  logger.log(`Device info updated`);
140
139
  logger.debug(`${JSON.stringify({ newInfo: device.info, deviceData })}`);
141
140
  }
@@ -211,7 +210,7 @@ export async function recordingFileToVideoClip(
211
210
  thumbnailHref = thumbnailUrl;
212
211
  logger?.debug(`[recordingFileToVideoClip] Webhook URLs generated successfully: videoHref="${videoHref}", thumbnailHref="${thumbnailHref}"`);
213
212
  } catch (e) {
214
- logger?.error(`[recordingFileToVideoClip] Failed to generate webhook URLs for fileId=${id}:`, e);
213
+ logger?.error(`[recordingFileToVideoClip] Failed to generate webhook URLs for fileId=${id}:`, e?.message || String(e));
215
214
  }
216
215
  } else if (!videoHref && api) {
217
216
  // Fallback to direct RTMP URL if webhook is not used
@@ -223,7 +222,7 @@ export async function recordingFileToVideoClip(
223
222
  videoHref = rtmpVodUrl;
224
223
  logger?.debug(`[recordingFileToVideoClip] RTMP URL fetched successfully: videoHref="${videoHref}"`);
225
224
  } catch (e) {
226
- logger?.debug(`[recordingFileToVideoClip] Failed to build playback URL for recording fileName=${rec.fileName}:`, e);
225
+ logger?.debug(`[recordingFileToVideoClip] Failed to build playback URL for recording fileName=${rec.fileName}:`, e?.message || String(e));
227
226
  }
228
227
  } else {
229
228
  logger?.debug(`[recordingFileToVideoClip] No URL generation: useWebhook=${useWebhook}, hasPlugin=${!!plugin}, deviceId=${deviceId}, providedVideoHref=${providedVideoHref || 'none'}, hasApi=${!!api}`);
@@ -368,7 +367,7 @@ export async function recordingsToVideoClips(
368
367
  });
369
368
  clips.push(clip);
370
369
  } catch (e) {
371
- logger?.warn(`Failed to convert recording to video clip: fileName=${rec.fileName}`, e);
370
+ logger?.warn(`Failed to convert recording to video clip: fileName=${rec.fileName}`, e?.message || String(e));
372
371
  }
373
372
  }
374
373
 
@@ -399,7 +398,7 @@ export async function getVideoClipWebhookUrls(props: {
399
398
  // log.debug?.(`[getVideoClipWebhookUrls] Using cloud endpoint: ${endpoint}`);
400
399
  } catch (e) {
401
400
  // Fallback to local endpoint if cloud is not available (e.g., not logged in)
402
- log.debug?.(`[getVideoClipWebhookUrls] Cloud endpoint not available, using local endpoint: ${e instanceof Error ? e.message : String(e)}`);
401
+ log.debug?.(`[getVideoClipWebhookUrls] Cloud endpoint not available, using local endpoint: ${e?.message || String(e)}`);
403
402
  endpoint = await sdk.endpointManager.getLocalEndpoint(undefined, { public: true });
404
403
  endpointSource = 'local';
405
404
  // log.debug?.(`[getVideoClipWebhookUrls] Using local endpoint: ${endpoint}`);
@@ -428,7 +427,7 @@ export async function getVideoClipWebhookUrls(props: {
428
427
  } catch (e) {
429
428
  log.error?.(
430
429
  `[getVideoClipWebhookUrls] Failed to generate webhook URLs: deviceId=${deviceId}, fileId=${fileId}`,
431
- e
430
+ e?.message || String(e)
432
431
  );
433
432
  throw e;
434
433
  }
@@ -443,7 +442,7 @@ export async function extractThumbnailFromVideo(props: {
443
442
  fileId: string;
444
443
  deviceId: string;
445
444
  logger?: Console;
446
- device?: CommonCameraMixin;
445
+ device?: ReolinkCamera;
447
446
  }): Promise<MediaObject> {
448
447
  const { rtmpUrl, filePath, fileId, deviceId, device } = props;
449
448
  // Use device logger if available, otherwise fallback to provided logger
@@ -467,7 +466,7 @@ export async function extractThumbnailFromVideo(props: {
467
466
  return mo;
468
467
  } catch (e) {
469
468
  // Error already logged in main.ts
470
- throw e;
469
+ throw e?.message || String(e);
471
470
  }
472
471
  }
473
472
 
@@ -489,7 +488,7 @@ function getVideoClipCachePath(deviceId: string, fileId: string): string {
489
488
  * Checks cache first, then proxies RTMP stream if not cached
490
489
  */
491
490
  export async function handleVideoClipRequest(props: {
492
- device: CommonCameraMixin;
491
+ device: ReolinkCamera;
493
492
  deviceId: string;
494
493
  fileId: string;
495
494
  request: HttpRequest;
@@ -514,7 +513,7 @@ export async function handleVideoClipRequest(props: {
514
513
  try {
515
514
  await fs.promises.unlink(cachePath);
516
515
  } catch (unlinkErr) {
517
- logger.warn(`Failed to delete small cached video clip: fileId=${fileId}`, unlinkErr);
516
+ logger.warn(`Failed to delete small cached video clip: fileId=${fileId}`, unlinkErr?.message || String(unlinkErr));
518
517
  }
519
518
  // Force cache miss path below
520
519
  throw new Error('Cached video too small, deleted');
@@ -567,7 +566,7 @@ export async function handleVideoClipRequest(props: {
567
566
  try {
568
567
  rtmpVodUrl = await device.getVideoClipRtmpUrl(fileId);
569
568
  } catch (e2) {
570
- logger.error(`[VideoClip] Stream error: fileId=${fileId}`, e2);
569
+ logger.error(`[VideoClip] Stream error: fileId=${fileId}`, e2?.message || String(e2));
571
570
  response.send('Failed to get RTMP playback URL', { code: 500 });
572
571
  return;
573
572
  }
@@ -681,12 +680,12 @@ export async function handleVideoClipRequest(props: {
681
680
  ffmpeg.stdin.on('error', (err) => {
682
681
  // Ignore EPIPE errors when ffmpeg closes
683
682
  if ((err as any).code !== 'EPIPE') {
684
- logger.error(`FFmpeg stdin error: fileId=${fileId}`, err);
683
+ logger.error(`FFmpeg stdin error: fileId=${fileId}`, err?.message || String(err));
685
684
  }
686
685
  });
687
686
 
688
687
  httpResponse.on('error', (err) => {
689
- logger.error(`HTTP response error before ffmpeg: fileId=${fileId}`, err);
688
+ logger.error(`HTTP response error before ffmpeg: fileId=${fileId}`, err?.message || String(err));
690
689
  try {
691
690
  ffmpeg.kill('SIGKILL');
692
691
  } catch (e) {
@@ -706,7 +705,7 @@ export async function handleVideoClipRequest(props: {
706
705
  yield chunk;
707
706
  }
708
707
  } catch (e) {
709
- logger.error(`Error streaming ffmpeg output: fileId=${fileId}`, e);
708
+ logger.error(`Error streaming ffmpeg output: fileId=${fileId}`, e?.message || String(e));
710
709
  throw e;
711
710
  } finally {
712
711
  // Clean up ffmpeg process
@@ -738,7 +737,7 @@ export async function handleVideoClipRequest(props: {
738
737
  });
739
738
 
740
739
  ffmpeg.on('error', (error) => {
741
- logger.error(`FFmpeg spawn error: fileId=${fileId}`, error);
740
+ logger.error(`FFmpeg spawn error: fileId=${fileId}`, error?.message || String(error));
742
741
  reject(error);
743
742
  });
744
743
 
@@ -753,7 +752,7 @@ export async function handleVideoClipRequest(props: {
753
752
  }
754
753
  logger.log(`[VideoClip] Stream end: fileId=${fileId}`);
755
754
  } catch (streamErr) {
756
- logger.error(`[VideoClip] Stream error: fileId=${fileId}`, streamErr);
755
+ logger.error(`[VideoClip] Stream error: fileId=${fileId}`, streamErr?.message || String(streamErr));
757
756
  throw streamErr;
758
757
  }
759
758
  })(), {
@@ -764,11 +763,11 @@ export async function handleVideoClipRequest(props: {
764
763
  resolve();
765
764
  }
766
765
  } catch (err) {
767
- logger.error(`Error sending stream: fileId=${fileId}`, err);
766
+ logger.error(`Error sending stream: fileId=${fileId}`, err?.message || String(err));
768
767
  reject(err);
769
768
  }
770
769
  }).on('error', (e) => {
771
- logger.error(`Error fetching videoclip: fileId=${fileId}`, e);
770
+ logger.error(`Error fetching videoclip: fileId=${fileId}`, e?.message || String(e));
772
771
  reject(e);
773
772
  });
774
773
  });
@@ -778,7 +777,7 @@ export async function handleVideoClipRequest(props: {
778
777
  await sendVideo();
779
778
  return;
780
779
  } catch (e) {
781
- logger.error(`HTTP proxy error: fileId=${fileId}`, e);
780
+ logger.error(`HTTP proxy error: fileId=${fileId}`, e?.message || String(e));
782
781
  response.send('Failed to proxy HTTP stream', { code: 500 });
783
782
  return;
784
783
  }
@@ -816,7 +815,7 @@ export async function handleVideoClipRequest(props: {
816
815
  }
817
816
  logger.log(`[VideoClip] Stream end: fileId=${fileId}`);
818
817
  } catch (e) {
819
- logger.error(`[VideoClip] Stream error: fileId=${fileId}`, e);
818
+ logger.error(`[VideoClip] Stream error: fileId=${fileId}`, e?.message || String(e));
820
819
  throw e;
821
820
  } finally {
822
821
  // Clean up ffmpeg process
@@ -844,7 +843,7 @@ export async function handleVideoClipRequest(props: {
844
843
  });
845
844
 
846
845
  ffmpeg.on('error', (error) => {
847
- logger.error(`FFmpeg spawn error for video proxy: fileId=${fileId}`, error);
846
+ logger.error(`FFmpeg spawn error for video proxy: fileId=${fileId}`, error?.message || String(error));
848
847
  });
849
848
 
850
849
  return;