@apocaliss92/scrypted-reolink-native 0.1.42 → 0.2.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.
@@ -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
@@ -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 };
@@ -211,7 +211,7 @@ export async function recordingFileToVideoClip(
211
211
  thumbnailHref = thumbnailUrl;
212
212
  logger?.debug(`[recordingFileToVideoClip] Webhook URLs generated successfully: videoHref="${videoHref}", thumbnailHref="${thumbnailHref}"`);
213
213
  } catch (e) {
214
- logger?.error(`[recordingFileToVideoClip] Failed to generate webhook URLs for fileId=${id}:`, e);
214
+ logger?.error(`[recordingFileToVideoClip] Failed to generate webhook URLs for fileId=${id}:`, e?.message || String(e));
215
215
  }
216
216
  } else if (!videoHref && api) {
217
217
  // Fallback to direct RTMP URL if webhook is not used
@@ -223,7 +223,7 @@ export async function recordingFileToVideoClip(
223
223
  videoHref = rtmpVodUrl;
224
224
  logger?.debug(`[recordingFileToVideoClip] RTMP URL fetched successfully: videoHref="${videoHref}"`);
225
225
  } catch (e) {
226
- logger?.debug(`[recordingFileToVideoClip] Failed to build playback URL for recording fileName=${rec.fileName}:`, e);
226
+ logger?.debug(`[recordingFileToVideoClip] Failed to build playback URL for recording fileName=${rec.fileName}:`, e?.message || String(e));
227
227
  }
228
228
  } else {
229
229
  logger?.debug(`[recordingFileToVideoClip] No URL generation: useWebhook=${useWebhook}, hasPlugin=${!!plugin}, deviceId=${deviceId}, providedVideoHref=${providedVideoHref || 'none'}, hasApi=${!!api}`);
@@ -368,7 +368,7 @@ export async function recordingsToVideoClips(
368
368
  });
369
369
  clips.push(clip);
370
370
  } catch (e) {
371
- logger?.warn(`Failed to convert recording to video clip: fileName=${rec.fileName}`, e);
371
+ logger?.warn(`Failed to convert recording to video clip: fileName=${rec.fileName}`, e?.message || String(e));
372
372
  }
373
373
  }
374
374
 
@@ -399,7 +399,7 @@ export async function getVideoClipWebhookUrls(props: {
399
399
  // log.debug?.(`[getVideoClipWebhookUrls] Using cloud endpoint: ${endpoint}`);
400
400
  } catch (e) {
401
401
  // 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)}`);
402
+ log.debug?.(`[getVideoClipWebhookUrls] Cloud endpoint not available, using local endpoint: ${e?.message || String(e)}`);
403
403
  endpoint = await sdk.endpointManager.getLocalEndpoint(undefined, { public: true });
404
404
  endpointSource = 'local';
405
405
  // log.debug?.(`[getVideoClipWebhookUrls] Using local endpoint: ${endpoint}`);
@@ -428,7 +428,7 @@ export async function getVideoClipWebhookUrls(props: {
428
428
  } catch (e) {
429
429
  log.error?.(
430
430
  `[getVideoClipWebhookUrls] Failed to generate webhook URLs: deviceId=${deviceId}, fileId=${fileId}`,
431
- e
431
+ e?.message || String(e)
432
432
  );
433
433
  throw e;
434
434
  }
@@ -467,7 +467,7 @@ export async function extractThumbnailFromVideo(props: {
467
467
  return mo;
468
468
  } catch (e) {
469
469
  // Error already logged in main.ts
470
- throw e;
470
+ throw e?.message || String(e);
471
471
  }
472
472
  }
473
473
 
@@ -514,7 +514,7 @@ export async function handleVideoClipRequest(props: {
514
514
  try {
515
515
  await fs.promises.unlink(cachePath);
516
516
  } catch (unlinkErr) {
517
- logger.warn(`Failed to delete small cached video clip: fileId=${fileId}`, unlinkErr);
517
+ logger.warn(`Failed to delete small cached video clip: fileId=${fileId}`, unlinkErr?.message || String(unlinkErr));
518
518
  }
519
519
  // Force cache miss path below
520
520
  throw new Error('Cached video too small, deleted');
@@ -567,7 +567,7 @@ export async function handleVideoClipRequest(props: {
567
567
  try {
568
568
  rtmpVodUrl = await device.getVideoClipRtmpUrl(fileId);
569
569
  } catch (e2) {
570
- logger.error(`[VideoClip] Stream error: fileId=${fileId}`, e2);
570
+ logger.error(`[VideoClip] Stream error: fileId=${fileId}`, e2?.message || String(e2));
571
571
  response.send('Failed to get RTMP playback URL', { code: 500 });
572
572
  return;
573
573
  }
@@ -681,12 +681,12 @@ export async function handleVideoClipRequest(props: {
681
681
  ffmpeg.stdin.on('error', (err) => {
682
682
  // Ignore EPIPE errors when ffmpeg closes
683
683
  if ((err as any).code !== 'EPIPE') {
684
- logger.error(`FFmpeg stdin error: fileId=${fileId}`, err);
684
+ logger.error(`FFmpeg stdin error: fileId=${fileId}`, err?.message || String(err));
685
685
  }
686
686
  });
687
687
 
688
688
  httpResponse.on('error', (err) => {
689
- logger.error(`HTTP response error before ffmpeg: fileId=${fileId}`, err);
689
+ logger.error(`HTTP response error before ffmpeg: fileId=${fileId}`, err?.message || String(err));
690
690
  try {
691
691
  ffmpeg.kill('SIGKILL');
692
692
  } catch (e) {
@@ -706,7 +706,7 @@ export async function handleVideoClipRequest(props: {
706
706
  yield chunk;
707
707
  }
708
708
  } catch (e) {
709
- logger.error(`Error streaming ffmpeg output: fileId=${fileId}`, e);
709
+ logger.error(`Error streaming ffmpeg output: fileId=${fileId}`, e?.message || String(e));
710
710
  throw e;
711
711
  } finally {
712
712
  // Clean up ffmpeg process
@@ -738,7 +738,7 @@ export async function handleVideoClipRequest(props: {
738
738
  });
739
739
 
740
740
  ffmpeg.on('error', (error) => {
741
- logger.error(`FFmpeg spawn error: fileId=${fileId}`, error);
741
+ logger.error(`FFmpeg spawn error: fileId=${fileId}`, error?.message || String(error));
742
742
  reject(error);
743
743
  });
744
744
 
@@ -753,7 +753,7 @@ export async function handleVideoClipRequest(props: {
753
753
  }
754
754
  logger.log(`[VideoClip] Stream end: fileId=${fileId}`);
755
755
  } catch (streamErr) {
756
- logger.error(`[VideoClip] Stream error: fileId=${fileId}`, streamErr);
756
+ logger.error(`[VideoClip] Stream error: fileId=${fileId}`, streamErr?.message || String(streamErr));
757
757
  throw streamErr;
758
758
  }
759
759
  })(), {
@@ -764,11 +764,11 @@ export async function handleVideoClipRequest(props: {
764
764
  resolve();
765
765
  }
766
766
  } catch (err) {
767
- logger.error(`Error sending stream: fileId=${fileId}`, err);
767
+ logger.error(`Error sending stream: fileId=${fileId}`, err?.message || String(err));
768
768
  reject(err);
769
769
  }
770
770
  }).on('error', (e) => {
771
- logger.error(`Error fetching videoclip: fileId=${fileId}`, e);
771
+ logger.error(`Error fetching videoclip: fileId=${fileId}`, e?.message || String(e));
772
772
  reject(e);
773
773
  });
774
774
  });
@@ -778,7 +778,7 @@ export async function handleVideoClipRequest(props: {
778
778
  await sendVideo();
779
779
  return;
780
780
  } catch (e) {
781
- logger.error(`HTTP proxy error: fileId=${fileId}`, e);
781
+ logger.error(`HTTP proxy error: fileId=${fileId}`, e?.message || String(e));
782
782
  response.send('Failed to proxy HTTP stream', { code: 500 });
783
783
  return;
784
784
  }
@@ -816,7 +816,7 @@ export async function handleVideoClipRequest(props: {
816
816
  }
817
817
  logger.log(`[VideoClip] Stream end: fileId=${fileId}`);
818
818
  } catch (e) {
819
- logger.error(`[VideoClip] Stream error: fileId=${fileId}`, e);
819
+ logger.error(`[VideoClip] Stream error: fileId=${fileId}`, e?.message || String(e));
820
820
  throw e;
821
821
  } finally {
822
822
  // Clean up ffmpeg process
@@ -844,7 +844,7 @@ export async function handleVideoClipRequest(props: {
844
844
  });
845
845
 
846
846
  ffmpeg.on('error', (error) => {
847
- logger.error(`FFmpeg spawn error for video proxy: fileId=${fileId}`, error);
847
+ logger.error(`FFmpeg spawn error for video proxy: fileId=${fileId}`, error?.message || String(error));
848
848
  });
849
849
 
850
850
  return;