@apocaliss92/scrypted-reolink-native 0.2.9 → 0.2.10

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.
@@ -10,16 +10,12 @@ export enum DebugLogOption {
10
10
  DebugRtsp = 'debugRtsp',
11
11
  /** Low-level tracing for recording-related commands */
12
12
  TraceRecordings = 'traceRecordings',
13
- /** Stream command tracing */
14
- TraceStream = 'traceStream',
13
+ /** Native stream tracing (stream tx/rx + H264/H265 + param sets) */
14
+ TraceNativeStream = 'traceNativeStream',
15
15
  /** Talkback tracing */
16
16
  TraceTalk = 'traceTalk',
17
17
  /** Event tracing */
18
18
  TraceEvents = 'traceEvents',
19
- /** H.264 debug logs */
20
- DebugH264 = 'debugH264',
21
- /** SPS/PPS parameter sets debug logs */
22
- DebugParamSets = 'debugParamSets',
23
19
  }
24
20
 
25
21
  /**
@@ -30,11 +26,9 @@ export function mapDebugLogToApiOption(option: DebugLogOption): keyof DebugOptio
30
26
  [DebugLogOption.General]: 'general',
31
27
  [DebugLogOption.DebugRtsp]: 'debugRtsp',
32
28
  [DebugLogOption.TraceRecordings]: 'traceRecordings',
33
- [DebugLogOption.TraceStream]: 'traceStream',
29
+ [DebugLogOption.TraceNativeStream]: 'traceNativeStream',
34
30
  [DebugLogOption.TraceTalk]: 'traceTalk',
35
31
  [DebugLogOption.TraceEvents]: 'traceEvents',
36
- [DebugLogOption.DebugH264]: 'debugH264',
37
- [DebugLogOption.DebugParamSets]: 'debugParamSets',
38
32
  };
39
33
  return mapping[option];
40
34
  }
@@ -81,11 +75,9 @@ export const DebugLogDisplayNames: Record<DebugLogOption, string> = {
81
75
  [DebugLogOption.General]: 'General',
82
76
  [DebugLogOption.DebugRtsp]: 'RTSP',
83
77
  [DebugLogOption.TraceRecordings]: 'Trace recordings',
84
- [DebugLogOption.TraceStream]: 'Trace stream',
78
+ [DebugLogOption.TraceNativeStream]: 'Trace native stream',
85
79
  [DebugLogOption.TraceTalk]: 'Trace talk',
86
80
  [DebugLogOption.TraceEvents]: 'Trace events XML',
87
- [DebugLogOption.DebugH264]: 'H264',
88
- [DebugLogOption.DebugParamSets]: 'Video param sets',
89
81
  };
90
82
 
91
83
  /**
package/src/intercom.ts CHANGED
@@ -274,7 +274,7 @@ export class ReolinkBaichuanIntercom {
274
274
  }
275
275
  })
276
276
  .catch((e) => {
277
- logger.warn("Intercom PCM->ADPCM pipeline error", e);
277
+ logger.warn("Intercom PCM->ADPCM pipeline error", e?.message || String(e));
278
278
  });
279
279
  }
280
280
 
package/src/main.ts CHANGED
@@ -324,7 +324,7 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
324
324
  return;
325
325
  }
326
326
  } catch (e: any) {
327
- logger.error('Error in onRequest', e);
327
+ logger.error('Error in onRequest', e?.message || String(e));
328
328
  response.send(`Error: ${e.message}`, {
329
329
  code: 500,
330
330
  });
@@ -374,7 +374,7 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
374
374
  logger.log(`[Thumbnail] Download completed: fileId=${request.fileId}`);
375
375
  request.resolve(thumbnail);
376
376
  } catch (error) {
377
- logger.error(`[Thumbnail] Error: fileId=${request.fileId}`, error);
377
+ logger.error(`[Thumbnail] Error: fileId=${request.fileId}`, error?.message || String(error));
378
378
  request.reject(error instanceof Error ? error : new Error(String(error)));
379
379
  }
380
380
 
package/src/multiFocal.ts CHANGED
@@ -172,12 +172,28 @@ export class ReolinkNativeMultiFocalDevice extends ReolinkCamera implements Sett
172
172
  throw new Error('Missing device credentials');
173
173
  }
174
174
 
175
+ const outputPath = this.storageSettings.values.diagnosticsOutputPath || process.env.SCRYPTED_PLUGIN_VOLUME || "";
176
+ if (!outputPath) {
177
+ throw new Error('Diagnostics output path is required');
178
+ }
179
+
175
180
  const api = await this.ensureClient();
176
181
 
177
- const multifocalDiagnostics = await api.collectMultifocalDiagnostics(logger);
182
+ const channel = this.storageSettings.values.rtspChannel || 0;
183
+ const durationSeconds = 8;
184
+ const result = await api.runMultifocalDiagnosticsConsecutively({
185
+ logger,
186
+ outDir: outputPath,
187
+ channel,
188
+ durationSeconds,
189
+ rtmpApps: ["bcs"],
190
+ probeFull: true,
191
+ onNvr: !!this.nvrDevice,
192
+ });
178
193
 
179
- logger.log(`NVR diagnostics completed successfully.`);
180
- logger.debug(JSON.stringify(multifocalDiagnostics));
194
+ logger.log(`Multifocal diagnostics completed successfully. Output directory: ${result.runDir}`);
195
+ logger.log(`Results file: ${result.resultsPath}`);
196
+ logger.log(`Streams directory: ${result.streamsDir}`);
181
197
  } catch (e) {
182
198
  logger.error('Failed to run NVR diagnostics', e?.message || String(e));
183
199
  throw e;
@@ -93,6 +93,90 @@ export function parseStreamProfileFromId(id: string | undefined): StreamProfile
93
93
  return;
94
94
  }
95
95
 
96
+ function parseRfcStreamKey(streamKey: string): {
97
+ isComposite: boolean;
98
+ channel?: number;
99
+ variant?: NativeVideoStreamVariant;
100
+ profile?: StreamProfile;
101
+ } {
102
+ const key = String(streamKey ?? '');
103
+ if (!key) {
104
+ throw new Error('parseRfcStreamKey: missing streamKey');
105
+ }
106
+
107
+ // Composite forms supported by the library/server:
108
+ // - composite-main-main (wider-tele)
109
+ // - composite_<profile>
110
+ // - composite_<variant>_<profile>
111
+ // - composite_<variant>_<wider>_<tele>
112
+ if (key.startsWith('composite-')) {
113
+ const parts = key.split('-').filter(Boolean);
114
+ const tele = parts.length >= 3 ? parts[2] : undefined;
115
+ const teleProfile = tele === 'main' || tele === 'sub' || tele === 'ext' ? (tele as StreamProfile) : undefined;
116
+ return { isComposite: true, profile: teleProfile };
117
+ }
118
+
119
+ if (key.startsWith('composite_')) {
120
+ const parts = key.split('_').filter(Boolean);
121
+ // parts[0] === 'composite'
122
+ const maybeVariant = parts.length >= 2 ? parts[1] : undefined;
123
+ const variant =
124
+ maybeVariant === 'default' || maybeVariant === 'autotrack' || maybeVariant === 'telephoto'
125
+ ? (maybeVariant as NativeVideoStreamVariant)
126
+ : undefined;
127
+
128
+ // Heuristic: pick last token that looks like a profile as the tele profile.
129
+ const last = parts[parts.length - 1];
130
+ const profile = last === 'main' || last === 'sub' || last === 'ext' ? (last as StreamProfile) : undefined;
131
+ return {
132
+ isComposite: true,
133
+ ...(variant && variant !== 'default' ? { variant } : {}),
134
+ ...(profile ? { profile } : {}),
135
+ };
136
+ }
137
+
138
+ // Non-composite forms supported by the plugin:
139
+ // - channel_<ch>_<profile>
140
+ // - channel_<ch>_<variant>_<profile>
141
+ // - <ch>_<profile>
142
+ // - <ch>_<variant>_<profile>
143
+ const parts = key.split('_').filter(Boolean);
144
+ if (!parts.length) {
145
+ throw new Error(`parseRfcStreamKey: invalid streamKey='${key}'`);
146
+ }
147
+
148
+ let idx = 0;
149
+ if (parts[0] === 'channel') {
150
+ idx = 1;
151
+ }
152
+
153
+ const channelStr = parts[idx];
154
+ const channel = channelStr !== undefined ? Number(channelStr) : NaN;
155
+ if (!Number.isFinite(channel)) {
156
+ throw new Error(`parseRfcStreamKey: could not parse channel from streamKey='${key}'`);
157
+ }
158
+
159
+ const tail = parts.slice(idx + 1);
160
+ const last = tail[tail.length - 1];
161
+ const profile = last === 'main' || last === 'sub' || last === 'ext' ? (last as StreamProfile) : undefined;
162
+
163
+ // tail can be:
164
+ // - [profile]
165
+ // - [variant, profile]
166
+ const maybeVariant = tail.length >= 2 ? tail[0] : undefined;
167
+ const variant =
168
+ maybeVariant === 'default' || maybeVariant === 'autotrack' || maybeVariant === 'telephoto'
169
+ ? (maybeVariant as NativeVideoStreamVariant)
170
+ : undefined;
171
+
172
+ return {
173
+ isComposite: false,
174
+ channel,
175
+ ...(variant && variant !== 'default' ? { variant } : {}),
176
+ ...(profile ? { profile } : {}),
177
+ };
178
+ }
179
+
96
180
  /**
97
181
  * Extract and normalize variant type from stream ID or URL (e.g., "autotrack" from "native_autotrack_main" or "?variant=autotrack")
98
182
  * Returns undefined if no variant is present, or "autotrack"/"telephoto" if present
@@ -163,16 +247,13 @@ export function selectStreamOption(
163
247
 
164
248
  export async function createRfc4571MediaObjectFromStreamManager(params: {
165
249
  streamManager: StreamManager;
166
- channel: number;
167
- profile: StreamProfile;
168
250
  streamKey: string;
169
- variant?: NativeVideoStreamVariant;
170
251
  selected: UrlMediaStreamOptions;
171
252
  sourceId: string;
172
253
  }): Promise<MediaObject> {
173
- const { streamManager, channel, profile, streamKey, variant, selected, sourceId } = params;
254
+ const { streamManager, streamKey, selected, sourceId } = params;
174
255
 
175
- const { host, port, sdp, audio, username, password } = await streamManager.getRfcStream(channel, profile, streamKey, variant);
256
+ const { host, port, sdp, audio, username, password } = await streamManager.getRfcServer(streamKey);
176
257
 
177
258
  const { url: _ignoredUrl, ...mso }: any = selected;
178
259
  mso.container = 'rtp';
@@ -203,62 +284,6 @@ export async function createRfc4571MediaObjectFromStreamManager(params: {
203
284
  });
204
285
  }
205
286
 
206
- export async function createRfc4571CompositeMediaObjectFromStreamManager(params: {
207
- streamManager: StreamManager;
208
- profile: StreamProfile;
209
- streamKey: string;
210
- selected: UrlMediaStreamOptions;
211
- sourceId: string;
212
- variantType?: NativeVideoStreamVariant;
213
- }): Promise<MediaObject> {
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
- }
226
- }
227
- }
228
-
229
- const { host, port, sdp, audio, username, password } = await streamManager.getRfcCompositeStream(profile, streamKey, extractedVariantType);
230
-
231
- const { url: _ignoredUrl, ...mso }: any = selected;
232
- mso.container = 'rtp';
233
- if (audio) {
234
- mso.audio ||= {};
235
- mso.audio.codec = audio.codec;
236
- mso.audio.sampleRate = audio.sampleRate;
237
- mso.audio.channels = audio.channels;
238
- }
239
-
240
- // Build URL with credentials: tcp://username:password@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
- }
250
-
251
- const rfc = {
252
- url: urlObj,
253
- sdp,
254
- mediaStreamOptions: mso as ResponseMediaStreamOptions,
255
- };
256
-
257
- return await sdk.mediaManager.createMediaObject(Buffer.from(JSON.stringify(rfc)), 'x-scrypted/x-rfc4571', {
258
- sourceId,
259
- });
260
- }
261
-
262
287
  type RfcServerInfo = {
263
288
  host: string;
264
289
  port: number;
@@ -283,6 +308,27 @@ export class StreamManager {
283
308
  return this.opts.logger || console;
284
309
  }
285
310
 
311
+ /**
312
+ * Unified RFC4571 server accessor.
313
+ *
314
+ * The streamKey is the single source of truth:
315
+ * - Composite: composite_* (channel-less)
316
+ * - Single channel: channel_<ch>_* or <ch>_*
317
+ */
318
+ async getRfcServer(streamKey: string, profile?: StreamProfile): Promise<RfcServerInfo> {
319
+ const parsed = parseRfcStreamKey(streamKey);
320
+ const resolvedProfile = profile ?? parsed.profile;
321
+ if (!resolvedProfile) {
322
+ throw new Error(`getRfcServer: could not infer profile from streamKey='${streamKey}'`);
323
+ }
324
+
325
+ return await this.ensureRfcServer(streamKey, resolvedProfile, {
326
+ channel: parsed.isComposite ? undefined : parsed.channel,
327
+ variant: parsed.variant,
328
+ compositeOptions: parsed.isComposite ? this.opts.compositeOptions : undefined,
329
+ });
330
+ }
331
+
286
332
  private async ensureRfcServer(
287
333
  streamKey: string,
288
334
  profile: StreamProfile,
@@ -337,24 +383,16 @@ export class StreamManager {
337
383
 
338
384
  const isComposite = options.channel === undefined;
339
385
 
386
+ // IMPORTANT: do not parse composite profiles here.
387
+ // The library/server derives the composite pairing from the requested id.
388
+
340
389
  // For composite streams, we may want two distinct Baichuan sessions (wider + tele)
341
390
  // to avoid frame mixing on some firmwares. On BCUDP/battery devices, extra sessions
342
391
  // can be harmful; in that case, createStreamClient may return the same underlying client.
343
392
  //
344
- // IMPORTANT: Use the same per-lens streamKey format as regular streams so that later
345
- // requests for a single lens can reuse these same cached APIs.
346
- const compositeWiderChannel = options.compositeOptions?.widerChannel ?? 0;
347
- const compositeTeleChannel = options.compositeOptions?.teleChannel ?? 1;
348
- const compositeTeleIsVariantOnSameChannel =
349
- Boolean(options.compositeOptions?.onNvr) || compositeTeleChannel === compositeWiderChannel;
350
-
351
- const compositeWiderStreamKey = `${compositeWiderChannel}_${profile}`;
352
- const compositeTeleVariant = compositeTeleIsVariantOnSameChannel
353
- ? (options.variant && options.variant !== 'default' ? options.variant : 'telephoto')
354
- : undefined;
355
- const compositeTeleStreamKey = compositeTeleVariant
356
- ? `${compositeTeleChannel}_${compositeTeleVariant}_${profile}`
357
- : `${compositeTeleChannel}_${profile}`;
393
+ // Use stable per-request keys; include variant for tele to keep sessions distinct.
394
+ const compositeWiderStreamKey = `${streamKey}_wider`;
395
+ const compositeTeleStreamKey = `${streamKey}_tele_${options.variant ?? 'default'}`;
358
396
 
359
397
  // For composite streams, using two distinct Baichuan sessions can avoid frame mixing on some firmwares.
360
398
  // However, for UDP/battery devices extra BCUDP sessions can trigger storms; if we detect the same
@@ -387,11 +425,18 @@ export class StreamManager {
387
425
  }
388
426
  }
389
427
 
390
- // For non-composite streams, create a single API client.
428
+ // For non-composite streams, create/reuse a single API client.
429
+ // For NVR/Hub (sharedConnection=true), avoid creating one TCP session per profile
430
+ // (e.g. main+sub would otherwise become two sockets). Group by (channel, variant).
391
431
  // For composite streams, base api must be a real lens streamKey (not the composite RFC key).
432
+ const shared = this.opts.sharedConnection ?? false;
433
+ const nonCompositeApiKey = (!isComposite && shared && options.channel !== undefined)
434
+ ? `channel_${options.channel}_${options.variant ?? 'default'}`
435
+ : streamKey;
436
+
392
437
  const api = isComposite
393
438
  ? (compositeApis?.widerApi ?? await this.opts.createStreamClient(compositeWiderStreamKey))
394
- : await this.opts.createStreamClient(streamKey);
439
+ : await this.opts.createStreamClient(nonCompositeApiKey);
395
440
 
396
441
  const { createRfc4571TcpServer } = await import('@apocaliss92/reolink-baichuan-js');
397
442
 
@@ -417,6 +462,7 @@ export class StreamManager {
417
462
  closeApiOnTeardown,
418
463
  username,
419
464
  password,
465
+ requestedId: streamKey,
420
466
  // Composite can take a bit longer (ffmpeg warmup + first IDR).
421
467
  ...(isComposite ? { keyframeTimeoutMs: 20_000, idleTeardownMs: 20_000 } : {}),
422
468
  ...(compositeOptions ? { compositeOptions } : {}),
@@ -464,10 +510,8 @@ export class StreamManager {
464
510
  streamKey: string,
465
511
  variant?: NativeVideoStreamVariant,
466
512
  ): Promise<RfcServerInfo> {
467
- return await this.ensureRfcServer(streamKey, profile, {
468
- channel,
469
- variant,
470
- });
513
+ // Back-compat wrapper. Prefer getRfcServer(streamKey).
514
+ return await this.ensureRfcServer(streamKey, profile, { channel, variant });
471
515
  }
472
516
 
473
517
  async getRfcCompositeStream(
@@ -475,24 +519,11 @@ export class StreamManager {
475
519
  streamKey: string,
476
520
  variantType?: NativeVideoStreamVariant,
477
521
  ): Promise<RfcServerInfo> {
478
- // Extract variantType from streamKey if not provided (format: composite_${variantType}_${profile})
479
- let extractedVariantType = variantType;
480
- if (!extractedVariantType && streamKey.startsWith('composite_')) {
481
- const parts = streamKey.split('_');
482
- if (parts.length >= 3) {
483
- // Format: composite_${variantType}_${profile}
484
- const variantPart = parts[1];
485
- if (variantPart === 'default' || variantPart === 'autotrack' || variantPart === 'telephoto') {
486
- extractedVariantType = variantPart as NativeVideoStreamVariant;
487
- }
488
- }
489
- }
490
-
491
- // Pass variantType to ensureRfcServer so it can be used when creating the stream client
492
- // This ensures each variantType gets its own socket
522
+ // Back-compat wrapper. Prefer getRfcServer(streamKey).
523
+ // Pass variantType to ensureRfcServer so it can be used when creating the stream client.
493
524
  return await this.ensureRfcServer(streamKey, profile, {
494
- channel: undefined, // Undefined channel indicates composite stream
495
- variant: extractedVariantType,
525
+ channel: undefined,
526
+ variant: variantType,
496
527
  compositeOptions: this.opts.compositeOptions,
497
528
  });
498
529
  }
package/src/utils.ts CHANGED
@@ -1043,7 +1043,7 @@ export async function vodSearchResultsToVideoClips(
1043
1043
  videoHref = videoUrl;
1044
1044
  thumbnailHref = thumbnailUrl;
1045
1045
  } catch (e) {
1046
- logger?.debug('Failed to generate webhook URLs for VOD file', fileName, e);
1046
+ logger?.debug('Failed to generate webhook URLs for VOD file', fileName, e?.message || String(e));
1047
1047
  }
1048
1048
 
1049
1049
  const clip: VideoClip = {
@@ -1060,7 +1060,7 @@ export async function vodSearchResultsToVideoClips(
1060
1060
 
1061
1061
  clips.push(clip);
1062
1062
  } catch (e) {
1063
- logger?.warn(`Failed to convert VOD file to clip: ${file.name}`, e);
1063
+ logger?.warn(`Failed to convert VOD file to clip: ${file.name}`, e?.message || String(e));
1064
1064
  }
1065
1065
  }
1066
1066
  }