@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.
- 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.ts +3004 -89
- package/src/intercom.ts +5 -7
- package/src/main.ts +18 -27
- package/src/multiFocal.ts +194 -172
- package/src/nvr.ts +96 -238
- package/src/presets.ts +2 -2
- package/src/stream-utils.ts +232 -101
- package/src/utils.ts +22 -23
- package/src/camera-battery.ts +0 -336
- package/src/common.ts +0 -2551
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
|
@@ -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 {
|
|
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
|
|
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?:
|
|
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:
|
|
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;
|