@apocaliss92/scrypted-reolink-native 0.3.15 → 0.4.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/.vscode/settings.json +1 -1
- package/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +1 -1
- package/src/baichuan-base.ts +863 -767
- package/src/camera.ts +3897 -2790
- package/src/intercom.ts +496 -476
- package/src/main.ts +378 -409
- package/src/multiFocal.ts +297 -265
- package/src/nvr.ts +588 -477
- package/src/stream-utils.ts +478 -427
- package/src/utils.ts +384 -1009
package/src/stream-utils.ts
CHANGED
|
@@ -1,104 +1,117 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
CompositeStreamPipOptions,
|
|
3
|
+
NativeVideoStreamVariant,
|
|
4
|
+
ReolinkBaichuanApi,
|
|
5
|
+
Rfc4571TcpServer,
|
|
6
|
+
StreamProfile,
|
|
7
7
|
} from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
8
8
|
|
|
9
9
|
import sdk, {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
type MediaObject,
|
|
11
|
+
type RequestMediaStreamOptions,
|
|
12
|
+
type ResponseMediaStreamOptions,
|
|
13
13
|
} from "@scrypted/sdk";
|
|
14
14
|
import type { UrlMediaStreamOptions } from "../../scrypted/plugins/rtsp/src/rtsp";
|
|
15
15
|
|
|
16
16
|
export interface StreamManagerOptions {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Creates a dedicated Baichuan session for streaming.
|
|
19
|
+
* Required to support concurrent main+ext streams on firmwares where streamType overlaps.
|
|
20
|
+
* @param streamKey The unique stream key (e.g., "composite-rtsp-default-sub-sub", "channel_0_main", etc.)
|
|
21
|
+
* Forwarded to the library as `requestedId`.
|
|
22
|
+
*/
|
|
23
|
+
createStreamClient: (streamKey: string) => Promise<ReolinkBaichuanApi>;
|
|
24
|
+
logger: Console;
|
|
25
|
+
/**
|
|
26
|
+
* Credentials to include in the TCP stream (username, password).
|
|
27
|
+
* Uses the same credentials as the main connection.
|
|
28
|
+
*/
|
|
29
|
+
credentials: {
|
|
30
|
+
username: string;
|
|
31
|
+
password: string;
|
|
32
|
+
};
|
|
33
|
+
/** If true, the stream client is shared with the main connection. Default: false. */
|
|
34
|
+
sharedConnection?: boolean;
|
|
35
|
+
/** Composite stream options for multifocal cameras */
|
|
36
|
+
compositeOptions?: CompositeStreamPipOptions;
|
|
37
|
+
/**
|
|
38
|
+
* Unique device identifier for dedicated socket sessions.
|
|
39
|
+
* When provided, the library creates a dedicated socket per stream to avoid interference.
|
|
40
|
+
* This should be a stable identifier (e.g., Scrypted nativeId).
|
|
41
|
+
*/
|
|
42
|
+
deviceId?: string;
|
|
37
43
|
}
|
|
38
44
|
|
|
39
|
-
export function parseStreamProfileFromId(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
export function parseStreamProfileFromId(
|
|
46
|
+
id: string | undefined,
|
|
47
|
+
): StreamProfile | undefined {
|
|
48
|
+
if (!id) return;
|
|
49
|
+
|
|
50
|
+
// Handle plain profiles (used by composite parsing: 'main'/'sub'/'ext')
|
|
51
|
+
if (id === "main" || id === "sub" || id === "ext") {
|
|
52
|
+
return id as StreamProfile;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Handle native stream IDs: native_main, native_sub, native_ext, native_autotrack_main, native_autotrack_sub, etc.
|
|
56
|
+
if (id.startsWith("native_")) {
|
|
57
|
+
const withoutPrefix = id.replace("native_", "");
|
|
58
|
+
// Extract profile from formats like "main", "sub", "ext", "autotrack_main", "telephoto_sub", etc.
|
|
59
|
+
// The profile is always the last part after underscore or the whole string if no underscore
|
|
60
|
+
const parts = withoutPrefix.split("_");
|
|
61
|
+
const profile = parts[parts.length - 1]; // Take the last part as profile
|
|
62
|
+
if (profile === "main" || profile === "sub" || profile === "ext") {
|
|
63
|
+
return profile as StreamProfile;
|
|
46
64
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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;
|
|
65
|
+
// If no valid profile found, try to match the whole string
|
|
66
|
+
if (
|
|
67
|
+
withoutPrefix === "main" ||
|
|
68
|
+
withoutPrefix === "sub" ||
|
|
69
|
+
withoutPrefix === "ext"
|
|
70
|
+
) {
|
|
71
|
+
return withoutPrefix as StreamProfile;
|
|
63
72
|
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
64
75
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
}
|
|
71
|
-
return undefined;
|
|
76
|
+
// Handle RTMP IDs: main.bcs, sub.bcs, ext.bcs
|
|
77
|
+
if (id.endsWith(".bcs")) {
|
|
78
|
+
const profile = id.replace(".bcs", "");
|
|
79
|
+
if (profile === "main" || profile === "sub" || profile === "ext") {
|
|
80
|
+
return profile as StreamProfile;
|
|
72
81
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Handle RTSP IDs: h264Preview_XX_main, h264Preview_XX_sub, Preview_03_autotrack, etc.
|
|
86
|
+
if (id.startsWith("h264Preview_")) {
|
|
87
|
+
if (id.endsWith("_main")) return "main";
|
|
88
|
+
if (id.endsWith("_sub")) return "sub";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Handle RTSP IDs like Preview_03_autotrack, Preview_03_autotrack_sub
|
|
92
|
+
// These should map to main or sub based on the suffix
|
|
93
|
+
if (id.includes("Preview_")) {
|
|
94
|
+
if (id.endsWith("_autotrack_sub") || id.endsWith("_sub")) {
|
|
95
|
+
return "sub";
|
|
80
96
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
if (id.endsWith('_autotrack') || id.endsWith('_main') || id.match(/Preview_\d+_?[a-z]*$/)) {
|
|
89
|
-
return 'main';
|
|
90
|
-
}
|
|
97
|
+
if (
|
|
98
|
+
id.endsWith("_autotrack") ||
|
|
99
|
+
id.endsWith("_main") ||
|
|
100
|
+
id.match(/Preview_\d+_?[a-z]*$/)
|
|
101
|
+
) {
|
|
102
|
+
return "main";
|
|
91
103
|
}
|
|
104
|
+
}
|
|
92
105
|
|
|
93
|
-
|
|
106
|
+
return;
|
|
94
107
|
}
|
|
95
108
|
|
|
96
109
|
type ReolinkRfc4571Metadata = {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
110
|
+
profile: StreamProfile;
|
|
111
|
+
channel?: number;
|
|
112
|
+
variant?: NativeVideoStreamVariant;
|
|
113
|
+
/** Explicitly mark composite (channel-less) streams. If omitted, `channel===undefined` implies composite. */
|
|
114
|
+
isComposite?: boolean;
|
|
102
115
|
};
|
|
103
116
|
|
|
104
117
|
/**
|
|
@@ -106,384 +119,422 @@ type ReolinkRfc4571Metadata = {
|
|
|
106
119
|
* Returns undefined if no variant is present, or "autotrack"/"telephoto" if present
|
|
107
120
|
* Note: on Hub/NVR multifocal firmwares, the tele lens is often requested via "telephoto".
|
|
108
121
|
*/
|
|
109
|
-
export function extractVariantFromStreamId(
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
// Handle RTSP IDs like Preview_03_autotrack, Preview_03_autotrack_sub
|
|
131
|
-
if (!variant && id.includes('Preview_')) {
|
|
132
|
-
if (id.includes('_autotrack')) {
|
|
133
|
-
variant = 'autotrack';
|
|
134
|
-
} else if (id.includes('_telephoto')) {
|
|
135
|
-
variant = 'telephoto';
|
|
136
|
-
}
|
|
122
|
+
export function extractVariantFromStreamId(
|
|
123
|
+
id: string | undefined,
|
|
124
|
+
url?: string | undefined,
|
|
125
|
+
): "autotrack" | "telephoto" | undefined {
|
|
126
|
+
let variant: string | undefined;
|
|
127
|
+
|
|
128
|
+
// First try to extract from ID
|
|
129
|
+
if (id) {
|
|
130
|
+
// Handle native stream IDs: native_autotrack_main, native_telephoto_sub, etc.
|
|
131
|
+
if (id.startsWith("native_")) {
|
|
132
|
+
const withoutPrefix = id.replace("native_", "");
|
|
133
|
+
const parts = withoutPrefix.split("_");
|
|
134
|
+
// If there are more than 1 parts, the first part(s) before the profile is the variant
|
|
135
|
+
// e.g., "autotrack_main" -> variant: "autotrack", profile: "main"
|
|
136
|
+
if (parts.length > 1) {
|
|
137
|
+
const profile = parts[parts.length - 1];
|
|
138
|
+
// Only return variant if profile is valid (main/sub/ext)
|
|
139
|
+
if (profile === "main" || profile === "sub" || profile === "ext") {
|
|
140
|
+
// Join all parts except the last one as variant (handles multi-part variants)
|
|
141
|
+
variant = parts.slice(0, -1).join("_");
|
|
137
142
|
}
|
|
143
|
+
}
|
|
138
144
|
}
|
|
139
145
|
|
|
140
|
-
//
|
|
141
|
-
if (!variant &&
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
}
|
|
148
|
-
} catch {
|
|
149
|
-
// Invalid URL, ignore
|
|
150
|
-
}
|
|
146
|
+
// Handle RTSP IDs like Preview_03_autotrack, Preview_03_autotrack_sub
|
|
147
|
+
if (!variant && id.includes("Preview_")) {
|
|
148
|
+
if (id.includes("_autotrack")) {
|
|
149
|
+
variant = "autotrack";
|
|
150
|
+
} else if (id.includes("_telephoto")) {
|
|
151
|
+
variant = "telephoto";
|
|
152
|
+
}
|
|
151
153
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Fallback: try to extract from URL query parameter
|
|
157
|
+
if (!variant && url) {
|
|
158
|
+
try {
|
|
159
|
+
const urlObj = new URL(url);
|
|
160
|
+
const variantParam = urlObj.searchParams.get("variant");
|
|
161
|
+
if (variantParam) {
|
|
162
|
+
variant = variantParam;
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
// Invalid URL, ignore
|
|
157
166
|
}
|
|
167
|
+
}
|
|
158
168
|
|
|
159
|
-
|
|
169
|
+
// Normalize variant: accept "autotrack", "telephoto", or map "default" to undefined
|
|
170
|
+
if (variant === "autotrack" || variant === "telephoto") {
|
|
171
|
+
// Preserve explicit variants; firmware-specific behavior is handled by the library.
|
|
172
|
+
return variant as "autotrack" | "telephoto";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return undefined;
|
|
160
176
|
}
|
|
161
177
|
|
|
162
178
|
export function selectStreamOption(
|
|
163
|
-
|
|
164
|
-
|
|
179
|
+
vsos: UrlMediaStreamOptions[] | undefined,
|
|
180
|
+
request: RequestMediaStreamOptions,
|
|
165
181
|
): UrlMediaStreamOptions {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
182
|
+
if (!request)
|
|
183
|
+
throw new Error("video streams not set up or no longer exists.");
|
|
184
|
+
const selected = vsos?.find((s) => s.id === request.id) || vsos?.[0];
|
|
185
|
+
if (!selected) throw new Error("No stream options available");
|
|
186
|
+
return selected;
|
|
170
187
|
}
|
|
171
188
|
|
|
172
189
|
export async function createRfc4571MediaObjectFromStreamManager(params: {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
190
|
+
streamManager: StreamManager;
|
|
191
|
+
streamKey: string;
|
|
192
|
+
selected: UrlMediaStreamOptions;
|
|
193
|
+
sourceId: string;
|
|
177
194
|
}): Promise<MediaObject> {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
195
|
+
const { streamManager, streamKey, selected, sourceId } = params;
|
|
196
|
+
|
|
197
|
+
const meta = (selected as any)?.reolinkRfc4571 as
|
|
198
|
+
| ReolinkRfc4571Metadata
|
|
199
|
+
| undefined;
|
|
200
|
+
if (!meta?.profile) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`Missing RFC4571 metadata/profile for streamKey='${streamKey}'`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const { host, port, sdp, audio, username, password } =
|
|
207
|
+
await streamManager.getRfcServer(streamKey, meta);
|
|
208
|
+
|
|
209
|
+
const { url: _ignoredUrl, ...mso }: any = selected;
|
|
210
|
+
mso.container = "rtp";
|
|
211
|
+
if (audio) {
|
|
212
|
+
mso.audio ||= {};
|
|
213
|
+
mso.audio.codec = audio.codec;
|
|
214
|
+
mso.audio.sampleRate = audio.sampleRate;
|
|
215
|
+
mso.audio.channels = audio.channels;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const url = new URL(`tcp://${host}`);
|
|
219
|
+
url.port = port.toString();
|
|
220
|
+
if (username) {
|
|
221
|
+
url.username = username;
|
|
222
|
+
}
|
|
223
|
+
if (password) {
|
|
224
|
+
url.password = password;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const rfc = {
|
|
228
|
+
url,
|
|
229
|
+
sdp,
|
|
230
|
+
mediaStreamOptions: mso as ResponseMediaStreamOptions,
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
return await sdk.mediaManager.createMediaObject(
|
|
234
|
+
Buffer.from(JSON.stringify(rfc)),
|
|
235
|
+
"x-scrypted/x-rfc4571",
|
|
236
|
+
{
|
|
237
|
+
sourceId,
|
|
238
|
+
},
|
|
239
|
+
);
|
|
214
240
|
}
|
|
215
241
|
|
|
216
242
|
type RfcServerInfo = {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
243
|
+
host: string;
|
|
244
|
+
port: number;
|
|
245
|
+
sdp: string;
|
|
246
|
+
audio?: { codec: string; sampleRate: number; channels: number };
|
|
247
|
+
username: string;
|
|
248
|
+
password: string;
|
|
223
249
|
};
|
|
224
250
|
|
|
225
251
|
export class StreamManager {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
252
|
+
private nativeRfcServers = new Map<string, Rfc4571TcpServer>();
|
|
253
|
+
private nativeRfcServerCreatePromises = new Map<
|
|
254
|
+
string,
|
|
255
|
+
Promise<RfcServerInfo>
|
|
256
|
+
>();
|
|
257
|
+
|
|
258
|
+
constructor(private opts: StreamManagerOptions) {
|
|
259
|
+
// Ensure logger is always valid
|
|
260
|
+
if (!this.opts.logger) {
|
|
261
|
+
this.opts.logger = console;
|
|
234
262
|
}
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private getLogger(): Console {
|
|
266
|
+
return this.opts.logger || console;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Unified RFC4571 server accessor.
|
|
271
|
+
*
|
|
272
|
+
* `stream-utils` does not parse `streamKey`. It forwards the `streamKey` as `requestedId`
|
|
273
|
+
* and relies on explicit metadata from the selected stream option (profile/channel/variant).
|
|
274
|
+
*/
|
|
275
|
+
async getRfcServer(
|
|
276
|
+
streamKey: string,
|
|
277
|
+
meta: ReolinkRfc4571Metadata,
|
|
278
|
+
): Promise<RfcServerInfo> {
|
|
279
|
+
if (!meta?.profile) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
`getRfcServer: missing profile for streamKey='${streamKey}'`,
|
|
282
|
+
);
|
|
238
283
|
}
|
|
239
284
|
|
|
240
|
-
|
|
241
|
-
* Unified RFC4571 server accessor.
|
|
242
|
-
*
|
|
243
|
-
* `stream-utils` does not parse `streamKey`. It forwards the `streamKey` as `requestedId`
|
|
244
|
-
* and relies on explicit metadata from the selected stream option (profile/channel/variant).
|
|
245
|
-
*/
|
|
246
|
-
async getRfcServer(streamKey: string, meta: ReolinkRfc4571Metadata): Promise<RfcServerInfo> {
|
|
247
|
-
if (!meta?.profile) {
|
|
248
|
-
throw new Error(`getRfcServer: missing profile for streamKey='${streamKey}'`);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
const isComposite = meta.isComposite ?? (meta.channel === undefined);
|
|
285
|
+
const isComposite = meta.isComposite ?? meta.channel === undefined;
|
|
252
286
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
287
|
+
return await this.ensureRfcServer(streamKey, meta.profile, {
|
|
288
|
+
channel: isComposite ? undefined : meta.channel,
|
|
289
|
+
variant: meta.variant,
|
|
290
|
+
compositeOptions: isComposite ? this.opts.compositeOptions : undefined,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private async ensureRfcServer(
|
|
295
|
+
streamKey: string,
|
|
296
|
+
profile: StreamProfile,
|
|
297
|
+
options: {
|
|
298
|
+
channel?: number;
|
|
299
|
+
variant?: NativeVideoStreamVariant;
|
|
300
|
+
compositeOptions?: CompositeStreamPipOptions;
|
|
301
|
+
},
|
|
302
|
+
): Promise<RfcServerInfo> {
|
|
303
|
+
// Check for existing promise first to prevent duplicate server creation
|
|
304
|
+
const existingCreate = this.nativeRfcServerCreatePromises.get(streamKey);
|
|
305
|
+
if (existingCreate) {
|
|
306
|
+
return await existingCreate;
|
|
258
307
|
}
|
|
259
308
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
// Double-check: if server already exists and is listening, return it immediately
|
|
276
|
-
const existingServer = this.nativeRfcServers.get(streamKey);
|
|
277
|
-
if (existingServer?.server?.listening) {
|
|
278
|
-
this.getLogger().log(`Reusing existing RFC4571 server for streamKey=${streamKey} (port=${existingServer.port})`);
|
|
279
|
-
return {
|
|
280
|
-
host: existingServer.host,
|
|
281
|
-
port: existingServer.port,
|
|
282
|
-
sdp: existingServer.sdp,
|
|
283
|
-
audio: existingServer.audio,
|
|
284
|
-
username: existingServer.username || this.opts.credentials.username,
|
|
285
|
-
password: existingServer.password || this.opts.credentials.password,
|
|
286
|
-
};
|
|
287
|
-
}
|
|
309
|
+
// Double-check: if server already exists and is listening, return it immediately
|
|
310
|
+
const existingServer = this.nativeRfcServers.get(streamKey);
|
|
311
|
+
if (existingServer?.server?.listening) {
|
|
312
|
+
this.getLogger().log(
|
|
313
|
+
`Reusing existing RFC4571 server for streamKey=${streamKey} (port=${existingServer.port})`,
|
|
314
|
+
);
|
|
315
|
+
return {
|
|
316
|
+
host: existingServer.host,
|
|
317
|
+
port: existingServer.port,
|
|
318
|
+
sdp: existingServer.sdp,
|
|
319
|
+
audio: existingServer.audio,
|
|
320
|
+
username: existingServer.username || this.opts.credentials.username,
|
|
321
|
+
password: existingServer.password || this.opts.credentials.password,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
288
324
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
try {
|
|
304
|
-
await cached.close('recreate');
|
|
305
|
-
}
|
|
306
|
-
catch {
|
|
307
|
-
// ignore
|
|
308
|
-
}
|
|
309
|
-
this.nativeRfcServers.delete(streamKey);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
const isComposite = options.channel === undefined;
|
|
313
|
-
|
|
314
|
-
// IMPORTANT: do not parse composite profiles here.
|
|
315
|
-
// The library/server derives the composite pairing from the requested id.
|
|
316
|
-
|
|
317
|
-
// For composite streams, we may want two distinct Baichuan sessions (wider + tele)
|
|
318
|
-
// to avoid frame mixing on some firmwares. On BCUDP/battery devices, extra sessions
|
|
319
|
-
// can be harmful; in that case, createStreamClient may return the same underlying client.
|
|
320
|
-
//
|
|
321
|
-
// Use stable per-request keys; include variant for tele to keep sessions distinct.
|
|
322
|
-
const compositeWiderStreamKey = `${streamKey}_wider`;
|
|
323
|
-
const compositeTeleStreamKey = `${streamKey}_tele_${options.variant ?? 'default'}`;
|
|
324
|
-
|
|
325
|
-
// For composite streams, using two distinct Baichuan sessions can avoid frame mixing on some firmwares.
|
|
326
|
-
// However, for UDP/battery devices extra BCUDP sessions can trigger storms; if we detect the same
|
|
327
|
-
// underlying client, fall back to single-session composite.
|
|
328
|
-
let compositeApis:
|
|
329
|
-
| {
|
|
330
|
-
widerApi: ReolinkBaichuanApi;
|
|
331
|
-
teleApi: ReolinkBaichuanApi;
|
|
332
|
-
}
|
|
333
|
-
| undefined;
|
|
334
|
-
if (isComposite) {
|
|
335
|
-
try {
|
|
336
|
-
const widerApi = await this.opts.createStreamClient(compositeWiderStreamKey);
|
|
337
|
-
const teleApi = await this.opts.createStreamClient(compositeTeleStreamKey);
|
|
338
|
-
|
|
339
|
-
const sameApiObject = widerApi === teleApi;
|
|
340
|
-
const sameUnderlyingClient = (widerApi as any)?.client && (teleApi as any)?.client
|
|
341
|
-
? (widerApi as any).client === (teleApi as any).client
|
|
342
|
-
: false;
|
|
343
|
-
|
|
344
|
-
if (!sameApiObject && !sameUnderlyingClient) {
|
|
345
|
-
compositeApis = { widerApi, teleApi };
|
|
346
|
-
} else {
|
|
347
|
-
// Likely a shared/battery connection: avoid forcing multi-session behavior.
|
|
348
|
-
compositeApis = undefined;
|
|
349
|
-
}
|
|
350
|
-
} catch {
|
|
351
|
-
// Best-effort: if creating dedicated sessions fails, fall back to single-session composite.
|
|
352
|
-
compositeApis = undefined;
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// For non-composite streams, create/reuse a single API client.
|
|
357
|
-
// For NVR/Hub (sharedConnection=true), avoid creating one TCP session per profile
|
|
358
|
-
// (e.g. main+sub would otherwise become two sockets). Group by (channel, variant).
|
|
359
|
-
// For composite streams, base api must be a real lens streamKey (not the composite RFC key).
|
|
360
|
-
const shared = this.opts.sharedConnection ?? false;
|
|
361
|
-
const nonCompositeApiKey = (!isComposite && shared && options.channel !== undefined)
|
|
362
|
-
? `channel_${options.channel}_${options.variant ?? 'default'}`
|
|
363
|
-
: streamKey;
|
|
364
|
-
|
|
365
|
-
const api = isComposite
|
|
366
|
-
? (compositeApis?.widerApi ?? await this.opts.createStreamClient(compositeWiderStreamKey))
|
|
367
|
-
: await this.opts.createStreamClient(nonCompositeApiKey);
|
|
368
|
-
|
|
369
|
-
const { createRfc4571TcpServer } = await import('@apocaliss92/reolink-baichuan-js');
|
|
370
|
-
|
|
371
|
-
const { username, password } = this.opts.credentials;
|
|
372
|
-
|
|
373
|
-
// If connection is shared, don't close it when stream teardown happens
|
|
374
|
-
// For composite, we create dedicated APIs even if the device uses a shared main connection.
|
|
375
|
-
// On battery/BCUDP (sharedConnection=true), prefer keeping them alive to avoid reconnect storms.
|
|
376
|
-
const closeApiOnTeardown = isComposite
|
|
377
|
-
? (Boolean(compositeApis) && !(this.opts.sharedConnection ?? false))
|
|
378
|
-
: !(this.opts.sharedConnection ?? false);
|
|
379
|
-
|
|
380
|
-
let created: any;
|
|
381
|
-
try {
|
|
382
|
-
const compositeOptions = isComposite ? options.compositeOptions : undefined;
|
|
383
|
-
|
|
384
|
-
created = await createRfc4571TcpServer({
|
|
385
|
-
api,
|
|
386
|
-
channel: options.channel,
|
|
387
|
-
profile,
|
|
388
|
-
variant: options.variant,
|
|
389
|
-
logger: this.getLogger(),
|
|
390
|
-
closeApiOnTeardown,
|
|
391
|
-
username,
|
|
392
|
-
password,
|
|
393
|
-
requestedId: streamKey,
|
|
394
|
-
// Composite can take a bit longer (ffmpeg warmup + first IDR).
|
|
395
|
-
...(isComposite ? { keyframeTimeoutMs: 20_000, idleTeardownMs: 20_000 } : {}),
|
|
396
|
-
...(compositeOptions ? { compositeOptions } : {}),
|
|
397
|
-
...(compositeApis ? { compositeApis } : {}),
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
catch (e) {
|
|
401
|
-
if (isComposite && closeApiOnTeardown && compositeApis) {
|
|
402
|
-
await Promise.allSettled([
|
|
403
|
-
compositeApis.widerApi?.close?.(),
|
|
404
|
-
compositeApis.teleApi?.close?.(),
|
|
405
|
-
]);
|
|
406
|
-
}
|
|
407
|
-
throw e;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
this.nativeRfcServers.set(streamKey, created);
|
|
411
|
-
created.server.once('close', () => {
|
|
412
|
-
const current = this.nativeRfcServers.get(streamKey);
|
|
413
|
-
if (current?.server === created.server) this.nativeRfcServers.delete(streamKey);
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
return {
|
|
417
|
-
host: created.host,
|
|
418
|
-
port: created.port,
|
|
419
|
-
sdp: created.sdp,
|
|
420
|
-
audio: created.audio,
|
|
421
|
-
username: created.username || this.opts.credentials.username,
|
|
422
|
-
password: created.password || this.opts.credentials.password,
|
|
423
|
-
};
|
|
424
|
-
})();
|
|
425
|
-
|
|
426
|
-
this.nativeRfcServerCreatePromises.set(streamKey, createPromise);
|
|
325
|
+
const createPromise = (async () => {
|
|
326
|
+
const cached = this.nativeRfcServers.get(streamKey);
|
|
327
|
+
if (cached?.server?.listening) {
|
|
328
|
+
return {
|
|
329
|
+
host: cached.host,
|
|
330
|
+
port: cached.port,
|
|
331
|
+
sdp: cached.sdp,
|
|
332
|
+
audio: cached.audio,
|
|
333
|
+
username: cached.username || this.opts.credentials.username,
|
|
334
|
+
password: cached.password || this.opts.credentials.password,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (cached) {
|
|
427
339
|
try {
|
|
428
|
-
|
|
340
|
+
await cached.close("recreate");
|
|
341
|
+
} catch {
|
|
342
|
+
// ignore
|
|
429
343
|
}
|
|
430
|
-
|
|
431
|
-
|
|
344
|
+
this.nativeRfcServers.delete(streamKey);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const isComposite = options.channel === undefined;
|
|
348
|
+
|
|
349
|
+
// IMPORTANT: do not parse composite profiles here.
|
|
350
|
+
// The library/server derives the composite pairing from the requested id.
|
|
351
|
+
|
|
352
|
+
// For composite streams, we may want two distinct Baichuan sessions (wider + tele)
|
|
353
|
+
// to avoid frame mixing on some firmwares. On BCUDP/battery devices, extra sessions
|
|
354
|
+
// can be harmful; in that case, createStreamClient may return the same underlying client.
|
|
355
|
+
//
|
|
356
|
+
// Use stable per-request keys; include variant for tele to keep sessions distinct.
|
|
357
|
+
const compositeWiderStreamKey = `${streamKey}_wider`;
|
|
358
|
+
const compositeTeleStreamKey = `${streamKey}_tele_${options.variant ?? "default"}`;
|
|
359
|
+
|
|
360
|
+
// For composite streams, using two distinct Baichuan sessions can avoid frame mixing on some firmwares.
|
|
361
|
+
// However, for UDP/battery devices extra BCUDP sessions can trigger storms; if we detect the same
|
|
362
|
+
// underlying client, fall back to single-session composite.
|
|
363
|
+
let compositeApis:
|
|
364
|
+
| {
|
|
365
|
+
widerApi: ReolinkBaichuanApi;
|
|
366
|
+
teleApi: ReolinkBaichuanApi;
|
|
367
|
+
}
|
|
368
|
+
| undefined;
|
|
369
|
+
if (isComposite) {
|
|
370
|
+
try {
|
|
371
|
+
const widerApi = await this.opts.createStreamClient(
|
|
372
|
+
compositeWiderStreamKey,
|
|
373
|
+
);
|
|
374
|
+
const teleApi = await this.opts.createStreamClient(
|
|
375
|
+
compositeTeleStreamKey,
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
const sameApiObject = widerApi === teleApi;
|
|
379
|
+
const sameUnderlyingClient =
|
|
380
|
+
(widerApi as any)?.client && (teleApi as any)?.client
|
|
381
|
+
? (widerApi as any).client === (teleApi as any).client
|
|
382
|
+
: false;
|
|
383
|
+
|
|
384
|
+
if (!sameApiObject && !sameUnderlyingClient) {
|
|
385
|
+
compositeApis = { widerApi, teleApi };
|
|
386
|
+
} else {
|
|
387
|
+
// Likely a shared/battery connection: avoid forcing multi-session behavior.
|
|
388
|
+
compositeApis = undefined;
|
|
389
|
+
}
|
|
390
|
+
} catch {
|
|
391
|
+
// Best-effort: if creating dedicated sessions fails, fall back to single-session composite.
|
|
392
|
+
compositeApis = undefined;
|
|
432
393
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// For non-composite streams, create/reuse a single API client.
|
|
397
|
+
// For NVR/Hub (sharedConnection=true), avoid creating one TCP session per profile
|
|
398
|
+
// (e.g. main+sub would otherwise become two sockets). Group by (channel, variant).
|
|
399
|
+
// For composite streams, base api must be a real lens streamKey (not the composite RFC key).
|
|
400
|
+
const shared = this.opts.sharedConnection ?? false;
|
|
401
|
+
const nonCompositeApiKey =
|
|
402
|
+
!isComposite && shared && options.channel !== undefined
|
|
403
|
+
? `channel_${options.channel}_${options.variant ?? "default"}`
|
|
404
|
+
: streamKey;
|
|
405
|
+
|
|
406
|
+
const api = isComposite
|
|
407
|
+
? (compositeApis?.widerApi ??
|
|
408
|
+
(await this.opts.createStreamClient(compositeWiderStreamKey)))
|
|
409
|
+
: await this.opts.createStreamClient(nonCompositeApiKey);
|
|
410
|
+
|
|
411
|
+
const { createRfc4571TcpServer } =
|
|
412
|
+
await import("@apocaliss92/reolink-baichuan-js");
|
|
413
|
+
|
|
414
|
+
const { username, password } = this.opts.credentials;
|
|
415
|
+
|
|
416
|
+
// If connection is shared, don't close it when stream teardown happens
|
|
417
|
+
// For composite, we create dedicated APIs even if the device uses a shared main connection.
|
|
418
|
+
// On battery/BCUDP (sharedConnection=true), prefer keeping them alive to avoid reconnect storms.
|
|
419
|
+
const closeApiOnTeardown = isComposite
|
|
420
|
+
? Boolean(compositeApis) && !(this.opts.sharedConnection ?? false)
|
|
421
|
+
: !(this.opts.sharedConnection ?? false);
|
|
422
|
+
|
|
423
|
+
let created: any;
|
|
424
|
+
try {
|
|
425
|
+
const compositeOptions = isComposite
|
|
426
|
+
? options.compositeOptions
|
|
427
|
+
: undefined;
|
|
428
|
+
|
|
429
|
+
created = await createRfc4571TcpServer({
|
|
430
|
+
api,
|
|
431
|
+
channel: options.channel,
|
|
432
|
+
profile,
|
|
433
|
+
variant: options.variant,
|
|
434
|
+
logger: this.getLogger(),
|
|
435
|
+
closeApiOnTeardown,
|
|
436
|
+
username,
|
|
437
|
+
password,
|
|
438
|
+
requestedId: streamKey,
|
|
439
|
+
// Pass deviceId for dedicated socket sessions (avoids stream interference)
|
|
440
|
+
...(this.opts.deviceId ? { deviceId: this.opts.deviceId } : {}),
|
|
441
|
+
// Composite can take a bit longer (ffmpeg warmup + first IDR).
|
|
442
|
+
...(isComposite
|
|
443
|
+
? { keyframeTimeoutMs: 20_000, idleTeardownMs: 20_000 }
|
|
444
|
+
: {}),
|
|
445
|
+
...(compositeOptions ? { compositeOptions } : {}),
|
|
446
|
+
...(compositeApis ? { compositeApis } : {}),
|
|
456
447
|
});
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
for (const server of this.nativeRfcServers.values()) {
|
|
464
|
-
if (server?.server?.listening) {
|
|
465
|
-
return true;
|
|
466
|
-
}
|
|
448
|
+
} catch (e) {
|
|
449
|
+
if (isComposite && closeApiOnTeardown && compositeApis) {
|
|
450
|
+
await Promise.allSettled([
|
|
451
|
+
compositeApis.widerApi?.close?.(),
|
|
452
|
+
compositeApis.teleApi?.close?.(),
|
|
453
|
+
]);
|
|
467
454
|
}
|
|
468
|
-
|
|
455
|
+
throw e;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
this.nativeRfcServers.set(streamKey, created);
|
|
459
|
+
created.server.once("close", () => {
|
|
460
|
+
const current = this.nativeRfcServers.get(streamKey);
|
|
461
|
+
if (current?.server === created.server)
|
|
462
|
+
this.nativeRfcServers.delete(streamKey);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
host: created.host,
|
|
467
|
+
port: created.port,
|
|
468
|
+
sdp: created.sdp,
|
|
469
|
+
audio: created.audio,
|
|
470
|
+
username: created.username || this.opts.credentials.username,
|
|
471
|
+
password: created.password || this.opts.credentials.password,
|
|
472
|
+
};
|
|
473
|
+
})();
|
|
474
|
+
|
|
475
|
+
this.nativeRfcServerCreatePromises.set(streamKey, createPromise);
|
|
476
|
+
try {
|
|
477
|
+
return await createPromise;
|
|
478
|
+
} finally {
|
|
479
|
+
this.nativeRfcServerCreatePromises.delete(streamKey);
|
|
469
480
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
async getRfcStream(
|
|
484
|
+
channel: number,
|
|
485
|
+
profile: StreamProfile,
|
|
486
|
+
streamKey: string,
|
|
487
|
+
variant?: NativeVideoStreamVariant,
|
|
488
|
+
): Promise<RfcServerInfo> {
|
|
489
|
+
// Back-compat wrapper. Prefer getRfcServer(streamKey).
|
|
490
|
+
return await this.ensureRfcServer(streamKey, profile, { channel, variant });
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async getRfcCompositeStream(
|
|
494
|
+
profile: StreamProfile,
|
|
495
|
+
streamKey: string,
|
|
496
|
+
variantType?: NativeVideoStreamVariant,
|
|
497
|
+
): Promise<RfcServerInfo> {
|
|
498
|
+
// Back-compat wrapper. Prefer getRfcServer(streamKey).
|
|
499
|
+
// Pass variantType to ensureRfcServer so it can be used when creating the stream client.
|
|
500
|
+
return await this.ensureRfcServer(streamKey, profile, {
|
|
501
|
+
channel: undefined,
|
|
502
|
+
variant: variantType,
|
|
503
|
+
compositeOptions: this.opts.compositeOptions,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Check if there are any active streams (servers that are listening).
|
|
509
|
+
*/
|
|
510
|
+
hasActiveStreams(): boolean {
|
|
511
|
+
for (const server of this.nativeRfcServers.values()) {
|
|
512
|
+
if (server?.server?.listening) {
|
|
513
|
+
return true;
|
|
514
|
+
}
|
|
488
515
|
}
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Close all active stream servers.
|
|
521
|
+
* Useful when the main connection is reset and streams need to be recreated.
|
|
522
|
+
*/
|
|
523
|
+
async closeAllStreams(reason?: string): Promise<void> {
|
|
524
|
+
const servers = Array.from(this.nativeRfcServers.values());
|
|
525
|
+
this.nativeRfcServers.clear();
|
|
526
|
+
|
|
527
|
+
await Promise.allSettled(
|
|
528
|
+
servers.map(async (server) => {
|
|
529
|
+
try {
|
|
530
|
+
await server.close(reason || "connection reset");
|
|
531
|
+
} catch (e) {
|
|
532
|
+
this.getLogger().debug(
|
|
533
|
+
"Error closing stream server",
|
|
534
|
+
e?.message || String(e),
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
}),
|
|
538
|
+
);
|
|
539
|
+
}
|
|
489
540
|
}
|