@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.
- package/build-lib.sh +31 -0
- package/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +2 -1
- package/src/baichuan-base.ts +149 -30
- package/src/camera-battery.ts +5 -58
- package/src/camera.ts +5 -2
- package/src/common.ts +471 -240
- package/src/intercom.ts +3 -3
- package/src/main.ts +38 -21
- package/src/multiFocal.ts +238 -144
- package/src/nvr.ts +194 -160
- package/src/stream-utils.ts +232 -101
- package/src/utils.ts +19 -19
package/src/stream-utils.ts
CHANGED
|
@@ -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
|
|
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: (
|
|
23
|
-
|
|
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
|
|
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
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
212
|
+
variantType?: NativeVideoStreamVariant;
|
|
146
213
|
}): Promise<MediaObject> {
|
|
147
|
-
const { streamManager, profile, streamKey,
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
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.
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
433
|
+
variant?: NativeVideoStreamVariant,
|
|
320
434
|
): Promise<RfcServerInfo> {
|
|
321
|
-
return await this.ensureRfcServer(streamKey, profile,
|
|
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
|
-
|
|
444
|
+
variantType?: NativeVideoStreamVariant,
|
|
330
445
|
): Promise<RfcServerInfo> {
|
|
331
|
-
|
|
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
|
|
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;
|