@apocaliss92/scrypted-reolink-native 0.1.31 → 0.1.33
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/dist/main.nodejs.js +1 -1
- package/dist/plugin.zip +0 -0
- package/package.json +3 -2
- package/src/baichuan-base.ts +35 -7
- package/src/camera-battery.ts +0 -9
- package/src/camera.ts +1 -58
- package/src/common.ts +669 -47
- package/src/debug-options.ts +4 -0
- package/src/main.ts +160 -6
- package/src/multiFocal.ts +3 -103
- package/src/nvr.ts +1 -3
- package/src/stream-utils.ts +49 -96
- package/src/utils.ts +472 -2
package/src/debug-options.ts
CHANGED
|
@@ -8,6 +8,8 @@ export enum DebugLogOption {
|
|
|
8
8
|
General = 'general',
|
|
9
9
|
/** RTSP proxy/server debug logs */
|
|
10
10
|
DebugRtsp = 'debugRtsp',
|
|
11
|
+
/** Low-level tracing for recording-related commands */
|
|
12
|
+
TraceRecordings = 'traceRecordings',
|
|
11
13
|
/** Stream command tracing */
|
|
12
14
|
TraceStream = 'traceStream',
|
|
13
15
|
/** Talkback tracing */
|
|
@@ -27,6 +29,7 @@ export function mapDebugLogToApiOption(option: DebugLogOption): keyof DebugOptio
|
|
|
27
29
|
const mapping: Record<DebugLogOption, keyof DebugOptions | null> = {
|
|
28
30
|
[DebugLogOption.General]: 'general',
|
|
29
31
|
[DebugLogOption.DebugRtsp]: 'debugRtsp',
|
|
32
|
+
[DebugLogOption.TraceRecordings]: 'traceRecordings',
|
|
30
33
|
[DebugLogOption.TraceStream]: 'traceStream',
|
|
31
34
|
[DebugLogOption.TraceTalk]: 'traceTalk',
|
|
32
35
|
[DebugLogOption.TraceEvents]: 'traceEvents',
|
|
@@ -77,6 +80,7 @@ export function getApiRelevantDebugLogs(debugLogs: string[]): string[] {
|
|
|
77
80
|
export const DebugLogDisplayNames: Record<DebugLogOption, string> = {
|
|
78
81
|
[DebugLogOption.General]: 'General',
|
|
79
82
|
[DebugLogOption.DebugRtsp]: 'RTSP',
|
|
83
|
+
[DebugLogOption.TraceRecordings]: 'Trace recordings',
|
|
80
84
|
[DebugLogOption.TraceStream]: 'Trace stream',
|
|
81
85
|
[DebugLogOption.TraceTalk]: 'Trace talk',
|
|
82
86
|
[DebugLogOption.TraceEvents]: 'Trace events XML',
|
package/src/main.ts
CHANGED
|
@@ -1,16 +1,36 @@
|
|
|
1
|
-
import sdk, { DeviceCreator, DeviceCreatorSettings, DeviceProvider, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedNativeId, Setting } from "@scrypted/sdk";
|
|
1
|
+
import sdk, { DeviceCreator, DeviceCreatorSettings, DeviceProvider, HttpRequest, HttpResponse, MediaObject, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, ScryptedNativeId, Setting, VideoClips } from "@scrypted/sdk";
|
|
2
2
|
import { BaseBaichuanClass } from "./baichuan-base";
|
|
3
3
|
import { ReolinkNativeCamera } from "./camera";
|
|
4
4
|
import { ReolinkNativeBatteryCamera } from "./camera-battery";
|
|
5
5
|
import { CommonCameraMixin } from "./common";
|
|
6
|
-
import { createBaichuanApi } from "./connect";
|
|
7
|
-
import { ReolinkNativeNvrDevice } from "./nvr";
|
|
8
|
-
import { batteryCameraSuffix, batteryMultifocalSuffix, cameraSuffix, getDeviceInterfaces, multifocalSuffix, nvrSuffix } from "./utils";
|
|
9
6
|
import { ReolinkNativeMultiFocalDevice } from "./multiFocal";
|
|
7
|
+
import { ReolinkNativeNvrDevice } from "./nvr";
|
|
8
|
+
import { batteryCameraSuffix, batteryMultifocalSuffix, cameraSuffix, extractThumbnailFromVideo, getDeviceInterfaces, handleVideoClipRequest, multifocalSuffix, nvrSuffix } from "./utils";
|
|
9
|
+
|
|
10
|
+
interface ThumbnailRequest {
|
|
11
|
+
deviceId: string;
|
|
12
|
+
fileId: string;
|
|
13
|
+
rtmpUrl?: string;
|
|
14
|
+
filePath?: string;
|
|
15
|
+
logger: Console;
|
|
16
|
+
resolve: (mo: MediaObject) => void;
|
|
17
|
+
reject: (error: Error) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ThumbnailRequestInput {
|
|
21
|
+
deviceId: string;
|
|
22
|
+
fileId: string;
|
|
23
|
+
rtmpUrl?: string;
|
|
24
|
+
filePath?: string;
|
|
25
|
+
logger: Console;
|
|
26
|
+
}
|
|
10
27
|
|
|
11
28
|
class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
|
|
12
29
|
devices = new Map<string, BaseBaichuanClass>();
|
|
30
|
+
mixinsMap = new Map<string, CommonCameraMixin>();
|
|
13
31
|
nvrDeviceId: string;
|
|
32
|
+
private thumbnailQueue: ThumbnailRequest[] = [];
|
|
33
|
+
private thumbnailProcessing = false;
|
|
14
34
|
|
|
15
35
|
constructor(nativeId: string) {
|
|
16
36
|
super(nativeId);
|
|
@@ -96,7 +116,7 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
|
|
|
96
116
|
device.storageSettings.values.ipAddress = ipAddress;
|
|
97
117
|
device.storageSettings.values.username = username;
|
|
98
118
|
device.storageSettings.values.password = password;
|
|
99
|
-
device.storageSettings.values.uid =
|
|
119
|
+
device.storageSettings.values.uid = uid;
|
|
100
120
|
device.storageSettings.values.capabilities = capabilities;
|
|
101
121
|
|
|
102
122
|
return nativeId;
|
|
@@ -177,7 +197,7 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
|
|
|
177
197
|
device.storageSettings.values.rtspChannel = rtspChannel;
|
|
178
198
|
device.storageSettings.values.ipAddress = ipAddress;
|
|
179
199
|
device.storageSettings.values.capabilities = capabilities;
|
|
180
|
-
device.storageSettings.values.uid =
|
|
200
|
+
device.storageSettings.values.uid = uid;
|
|
181
201
|
|
|
182
202
|
return nativeId;
|
|
183
203
|
}
|
|
@@ -236,6 +256,140 @@ class ReolinkNativePlugin extends ScryptedDeviceBase implements DeviceProvider,
|
|
|
236
256
|
return new ReolinkNativeCamera(nativeId, this);
|
|
237
257
|
}
|
|
238
258
|
}
|
|
259
|
+
|
|
260
|
+
async onRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
|
|
261
|
+
const logger = this.console;
|
|
262
|
+
const url = new URL(`http://localhost${request.url}`);
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
// Parse webhook path: /.../webhook/{type}/{deviceId}/{fileId}
|
|
266
|
+
// The path may include prefix like /endpoint/@apocaliss92/scrypted-reolink-native/public/webhook/...
|
|
267
|
+
const pathParts = url.pathname.split('/').filter(p => p);
|
|
268
|
+
|
|
269
|
+
// Find the index of 'webhook' in the path
|
|
270
|
+
const webhookIndex = pathParts.indexOf('webhook');
|
|
271
|
+
if (webhookIndex === -1 || pathParts.length < webhookIndex + 4) {
|
|
272
|
+
response.send('Invalid webhook path', { code: 404 });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Extract type, deviceId, and fileId after 'webhook'
|
|
277
|
+
const type = pathParts[webhookIndex + 1];
|
|
278
|
+
const encodedDeviceId = pathParts[webhookIndex + 2];
|
|
279
|
+
// fileId may contain slashes, so join all remaining parts
|
|
280
|
+
const encodedFileId = pathParts.slice(webhookIndex + 3).join('/');
|
|
281
|
+
const deviceId = decodeURIComponent(encodedDeviceId);
|
|
282
|
+
let fileId = decodeURIComponent(encodedFileId);
|
|
283
|
+
|
|
284
|
+
// Restore leading slash if the original fileId had it (we removed it during encoding)
|
|
285
|
+
// The API expects fileId with leading slash for absolute paths
|
|
286
|
+
if (!fileId.startsWith('/') && !fileId.startsWith('http')) {
|
|
287
|
+
// If it looks like an absolute path (starts with common path prefixes), add slash
|
|
288
|
+
if (fileId.startsWith('mnt/') || fileId.startsWith('var/') || fileId.startsWith('tmp/')) {
|
|
289
|
+
fileId = `/${fileId}`;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// logger.log(`Webhook request: type=${type}, deviceId=${deviceId}, fileId=${fileId}`);
|
|
294
|
+
|
|
295
|
+
// Get the device
|
|
296
|
+
const device = this.mixinsMap.get(deviceId);
|
|
297
|
+
if (!device) {
|
|
298
|
+
response.send('Device not found', { code: 404 });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (type === 'video') {
|
|
303
|
+
await handleVideoClipRequest({
|
|
304
|
+
device,
|
|
305
|
+
deviceId,
|
|
306
|
+
fileId,
|
|
307
|
+
request,
|
|
308
|
+
response,
|
|
309
|
+
logger,
|
|
310
|
+
});
|
|
311
|
+
return;
|
|
312
|
+
} else if (type === 'thumbnail') {
|
|
313
|
+
// Get thumbnail MediaObject
|
|
314
|
+
const mo = await device.getVideoClipThumbnail(fileId);
|
|
315
|
+
|
|
316
|
+
// Convert to buffer
|
|
317
|
+
const buffer = await sdk.mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg');
|
|
318
|
+
|
|
319
|
+
// Send image
|
|
320
|
+
response.send(buffer, {
|
|
321
|
+
code: 200,
|
|
322
|
+
headers: {
|
|
323
|
+
'Content-Type': 'image/jpeg',
|
|
324
|
+
'Cache-Control': 'max-age=31536000',
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
return;
|
|
328
|
+
} else {
|
|
329
|
+
response.send('Invalid webhook type', { code: 404 });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
} catch (e: any) {
|
|
333
|
+
logger.error('Error in onRequest', e);
|
|
334
|
+
response.send(`Error: ${e.message}`, {
|
|
335
|
+
code: 500,
|
|
336
|
+
});
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
onPush(request: HttpRequest): Promise<void> {
|
|
342
|
+
return this.onRequest(request, undefined);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Add a thumbnail generation request to the queue
|
|
347
|
+
*/
|
|
348
|
+
async generateThumbnail(request: ThumbnailRequestInput): Promise<MediaObject> {
|
|
349
|
+
const queueLength = this.thumbnailQueue.length;
|
|
350
|
+
const isProcessing = this.thumbnailProcessing;
|
|
351
|
+
request.logger.log(`[Thumbnail] Adding to queue: fileId=${request.fileId}, queueLength=${queueLength}, processing=${isProcessing}`);
|
|
352
|
+
|
|
353
|
+
return new Promise((resolve, reject) => {
|
|
354
|
+
this.thumbnailQueue.push({
|
|
355
|
+
...request,
|
|
356
|
+
resolve,
|
|
357
|
+
reject,
|
|
358
|
+
});
|
|
359
|
+
this.processThumbnailQueue();
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Process the thumbnail queue sequentially
|
|
365
|
+
*/
|
|
366
|
+
private async processThumbnailQueue(): Promise<void> {
|
|
367
|
+
if (this.thumbnailProcessing || this.thumbnailQueue.length === 0) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
this.thumbnailProcessing = true;
|
|
372
|
+
|
|
373
|
+
while (this.thumbnailQueue.length > 0) {
|
|
374
|
+
const request = this.thumbnailQueue.shift()!;
|
|
375
|
+
const logger = request.logger;
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
const thumbnail = await extractThumbnailFromVideo(request);
|
|
379
|
+
request.resolve(thumbnail);
|
|
380
|
+
} catch (error) {
|
|
381
|
+
logger.error(`[Thumbnail] Error: fileId=${request.fileId}`, error);
|
|
382
|
+
request.reject(error instanceof Error ? error : new Error(String(error)));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Add 2 second delay between thumbnails (except after the last one)
|
|
386
|
+
if (this.thumbnailQueue.length > 0) {
|
|
387
|
+
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
this.thumbnailProcessing = false;
|
|
392
|
+
}
|
|
239
393
|
}
|
|
240
394
|
|
|
241
395
|
export default ReolinkNativePlugin;
|
package/src/multiFocal.ts
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
import type { DeviceCapabilities, DualLensChannelAnalysis, ReolinkSimpleEvent
|
|
2
|
-
import sdk, { Device, DeviceProvider,
|
|
3
|
-
import { type BaichuanConnectionCallbacks } from "./baichuan-base";
|
|
1
|
+
import type { DeviceCapabilities, DualLensChannelAnalysis, ReolinkSimpleEvent } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
2
|
+
import sdk, { Device, DeviceProvider, Reboot, ScryptedDeviceType, Setting, Settings, SettingValue } from "@scrypted/sdk";
|
|
4
3
|
import { ReolinkNativeCamera } from "./camera";
|
|
5
4
|
import { ReolinkNativeBatteryCamera } from "./camera-battery";
|
|
6
5
|
import { CameraType, CommonCameraMixin } from "./common";
|
|
7
6
|
import ReolinkNativePlugin from "./main";
|
|
8
|
-
import { StreamManager } from "./stream-utils";
|
|
9
|
-
import type { UrlMediaStreamOptions } from "../../scrypted/plugins/rtsp/src/rtsp";
|
|
10
7
|
import { batteryCameraSuffix, cameraSuffix, getDeviceInterfaces, updateDeviceInfo } from "./utils";
|
|
11
8
|
|
|
12
9
|
export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements Settings, DeviceProvider, Reboot {
|
|
@@ -36,28 +33,6 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
|
|
|
36
33
|
}
|
|
37
34
|
}
|
|
38
35
|
|
|
39
|
-
protected getConnectionCallbacks(): BaichuanConnectionCallbacks {
|
|
40
|
-
return {
|
|
41
|
-
onError: undefined, // Use default error handling
|
|
42
|
-
onClose: async () => {
|
|
43
|
-
// Reinit after cleanup
|
|
44
|
-
await this.reinit();
|
|
45
|
-
if (!this.isBattery) {
|
|
46
|
-
setTimeout(async () => {
|
|
47
|
-
try {
|
|
48
|
-
await this.subscribeToEvents();
|
|
49
|
-
} catch (e) {
|
|
50
|
-
const logger = this.getBaichuanLogger();
|
|
51
|
-
logger.warn('Failed to resubscribe to events after reconnection', e);
|
|
52
|
-
}
|
|
53
|
-
}, 1000);
|
|
54
|
-
}
|
|
55
|
-
},
|
|
56
|
-
onSimpleEvent: (ev) => this.forwardNativeEvent(ev),
|
|
57
|
-
getEventSubscriptionEnabled: () => true,
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
36
|
protected async onBeforeCleanup(): Promise<void> {
|
|
62
37
|
await this.unsubscribeFromAllEvents();
|
|
63
38
|
}
|
|
@@ -100,7 +75,6 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
|
|
|
100
75
|
this.storageSettings.settings.uid.hide = !this.isBattery;
|
|
101
76
|
|
|
102
77
|
await this.ensureBaichuanClient();
|
|
103
|
-
await this.updateDeviceInfo();
|
|
104
78
|
await this.reportDevices();
|
|
105
79
|
await this.subscribeToEvents();
|
|
106
80
|
} catch (e) {
|
|
@@ -114,27 +88,9 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
|
|
|
114
88
|
}
|
|
115
89
|
}
|
|
116
90
|
|
|
117
|
-
async updateDeviceInfo(): Promise<void> {
|
|
118
|
-
const logger = this.getBaichuanLogger();
|
|
119
|
-
try {
|
|
120
|
-
const api = await this.ensureBaichuanClient();
|
|
121
|
-
const deviceData = await api.getInfo();
|
|
122
|
-
|
|
123
|
-
await updateDeviceInfo({
|
|
124
|
-
device: this,
|
|
125
|
-
deviceData,
|
|
126
|
-
ipAddress: this.storageSettings.values.ipAddress,
|
|
127
|
-
logger,
|
|
128
|
-
});
|
|
129
|
-
} catch (e) {
|
|
130
|
-
logger.warn('Failed to fetch device info', e);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
91
|
getInterfaces(channel: number) {
|
|
135
92
|
const logger = this.getBaichuanLogger();
|
|
136
|
-
const
|
|
137
|
-
const { capabilities: caps, multifocalInfo } = values;
|
|
93
|
+
const { capabilities: caps, multifocalInfo } = this.storageSettings.values;
|
|
138
94
|
const channelInfo = (multifocalInfo as DualLensChannelAnalysis).channels.find(c => c.channel === channel);
|
|
139
95
|
|
|
140
96
|
const capabilities: DeviceCapabilities = {
|
|
@@ -220,53 +176,6 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
|
|
|
220
176
|
}
|
|
221
177
|
|
|
222
178
|
await super.reportDevices();
|
|
223
|
-
|
|
224
|
-
// Initialize StreamManager with composite options for multifocal device
|
|
225
|
-
// Use saved settings or defaults
|
|
226
|
-
const values = this.storageSettings.values as any;
|
|
227
|
-
const pipPosition = (values.pipPosition || 'bottom-right') as any;
|
|
228
|
-
const pipSize = values.pipSize ?? 0.25;
|
|
229
|
-
const pipMargin = values.pipMargin ?? 10;
|
|
230
|
-
const widerChannel = values.widerChannel ?? 0;
|
|
231
|
-
const teleChannel = values.teleChannel ?? 1;
|
|
232
|
-
|
|
233
|
-
if (!this.streamManager) {
|
|
234
|
-
this.streamManager = new StreamManager({
|
|
235
|
-
createStreamClient: () => this.createStreamClient(),
|
|
236
|
-
getLogger: () => logger,
|
|
237
|
-
credentials: {
|
|
238
|
-
username,
|
|
239
|
-
password
|
|
240
|
-
},
|
|
241
|
-
sharedConnection: this.isBattery,
|
|
242
|
-
compositeOptions: {
|
|
243
|
-
widerChannel,
|
|
244
|
-
teleChannel,
|
|
245
|
-
pipPosition: pipPosition as any,
|
|
246
|
-
pipSize,
|
|
247
|
-
pipMargin,
|
|
248
|
-
},
|
|
249
|
-
});
|
|
250
|
-
} else {
|
|
251
|
-
// Recreate StreamManager with new settings if they changed
|
|
252
|
-
// StreamManager doesn't expose opts, so we need to recreate it
|
|
253
|
-
this.streamManager = new StreamManager({
|
|
254
|
-
createStreamClient: () => this.createStreamClient(),
|
|
255
|
-
getLogger: () => logger,
|
|
256
|
-
credentials: {
|
|
257
|
-
username,
|
|
258
|
-
password
|
|
259
|
-
},
|
|
260
|
-
sharedConnection: this.isBattery,
|
|
261
|
-
compositeOptions: {
|
|
262
|
-
widerChannel,
|
|
263
|
-
teleChannel,
|
|
264
|
-
pipPosition: pipPosition as any,
|
|
265
|
-
pipSize,
|
|
266
|
-
pipMargin,
|
|
267
|
-
},
|
|
268
|
-
});
|
|
269
|
-
}
|
|
270
179
|
}
|
|
271
180
|
|
|
272
181
|
async getDevice(nativeId: string) {
|
|
@@ -285,15 +194,6 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
|
|
|
285
194
|
}
|
|
286
195
|
}
|
|
287
196
|
|
|
288
|
-
async getSettings(): Promise<Setting[]> {
|
|
289
|
-
const settings = await this.storageSettings.getSettings();
|
|
290
|
-
return settings;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
async putSetting(key: string, value: SettingValue): Promise<void> {
|
|
294
|
-
return this.storageSettings.putSetting(key, value);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
197
|
async releaseDevice(id: string, nativeId: string) {
|
|
298
198
|
this.cameraNativeMap.delete(nativeId);
|
|
299
199
|
super.releaseDevice(id, nativeId);
|
package/src/nvr.ts
CHANGED
|
@@ -222,9 +222,7 @@ export class ReolinkNativeNvrDevice extends BaseBaichuanClass implements Setting
|
|
|
222
222
|
case 'doorbell':
|
|
223
223
|
// Handle doorbell if camera supports it
|
|
224
224
|
try {
|
|
225
|
-
|
|
226
|
-
(targetCamera as any).handleDoorbellEvent();
|
|
227
|
-
}
|
|
225
|
+
targetCamera.handleDoorbellEvent();
|
|
228
226
|
}
|
|
229
227
|
catch (e) {
|
|
230
228
|
logger.warn(`Error handling doorbell event for camera channel ${channel}`, e);
|
package/src/stream-utils.ts
CHANGED
|
@@ -17,8 +17,9 @@ export interface StreamManagerOptions {
|
|
|
17
17
|
/**
|
|
18
18
|
* Creates a dedicated Baichuan session for streaming.
|
|
19
19
|
* Required to support concurrent main+ext streams on firmwares where streamType overlaps.
|
|
20
|
+
* @param profile The stream profile (main, sub, ext) - used to determine if a new client is needed.
|
|
20
21
|
*/
|
|
21
|
-
createStreamClient: () => Promise<ReolinkBaichuanApi>;
|
|
22
|
+
createStreamClient: (profile?: StreamProfile) => Promise<ReolinkBaichuanApi>;
|
|
22
23
|
getLogger: () => Console;
|
|
23
24
|
/**
|
|
24
25
|
* Credentials to include in the TCP stream (username, password).
|
|
@@ -113,11 +114,15 @@ export async function createRfc4571MediaObjectFromStreamManager(params: {
|
|
|
113
114
|
mso.audio.sampleRate = audio.sampleRate;
|
|
114
115
|
mso.audio.channels = audio.channels;
|
|
115
116
|
}
|
|
116
|
-
|
|
117
|
+
|
|
118
|
+
const url = new URL(`tcp://${host}`);
|
|
117
119
|
url.port = port.toString();
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
if (username) {
|
|
121
|
+
url.username = username;
|
|
122
|
+
}
|
|
123
|
+
if (password) {
|
|
124
|
+
url.password = password;
|
|
125
|
+
}
|
|
121
126
|
|
|
122
127
|
const rfc = {
|
|
123
128
|
url,
|
|
@@ -197,26 +202,46 @@ export class StreamManager {
|
|
|
197
202
|
}
|
|
198
203
|
|
|
199
204
|
private getLogger() {
|
|
200
|
-
return this.opts.getLogger()
|
|
205
|
+
return this.opts.getLogger();
|
|
201
206
|
}
|
|
202
207
|
|
|
203
|
-
private async
|
|
208
|
+
private async ensureRfcServer(
|
|
204
209
|
streamKey: string,
|
|
205
|
-
channel: number,
|
|
206
210
|
profile: StreamProfile,
|
|
207
|
-
expectedVideoType
|
|
211
|
+
expectedVideoType: 'H264' | 'H265' | undefined,
|
|
212
|
+
options: {
|
|
213
|
+
channel?: number;
|
|
214
|
+
compositeOptions?: CompositeStreamPipOptions;
|
|
215
|
+
},
|
|
208
216
|
): Promise<RfcServerInfo> {
|
|
217
|
+
// Check for existing promise first to prevent duplicate server creation
|
|
209
218
|
const existingCreate = this.nativeRfcServerCreatePromises.get(streamKey);
|
|
210
219
|
if (existingCreate) {
|
|
211
220
|
return await existingCreate;
|
|
212
221
|
}
|
|
213
222
|
|
|
223
|
+
// Double-check: if server already exists and is listening, return it immediately
|
|
224
|
+
const existingServer = this.nativeRfcServers.get(streamKey);
|
|
225
|
+
if (existingServer?.server?.listening) {
|
|
226
|
+
if (!expectedVideoType || existingServer.videoType === expectedVideoType) {
|
|
227
|
+
return {
|
|
228
|
+
host: existingServer.host,
|
|
229
|
+
port: existingServer.port,
|
|
230
|
+
sdp: existingServer.sdp,
|
|
231
|
+
audio: existingServer.audio,
|
|
232
|
+
username: existingServer.username || this.opts.credentials.username,
|
|
233
|
+
password: existingServer.password || this.opts.credentials.password,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
214
238
|
const createPromise = (async () => {
|
|
215
239
|
const cached = this.nativeRfcServers.get(streamKey);
|
|
216
240
|
if (cached?.server?.listening) {
|
|
217
241
|
if (expectedVideoType && cached.videoType !== expectedVideoType) {
|
|
242
|
+
const kind = options.channel === undefined ? 'composite' : 'native';
|
|
218
243
|
this.getLogger().warn(
|
|
219
|
-
`Native RFC cache codec mismatch for ${streamKey}: cached=${cached.videoType} expected=${expectedVideoType}; recreating server.`,
|
|
244
|
+
`Native RFC ${kind} cache codec mismatch for ${streamKey}: cached=${cached.videoType} expected=${expectedVideoType}; recreating server.`,
|
|
220
245
|
);
|
|
221
246
|
}
|
|
222
247
|
else {
|
|
@@ -225,8 +250,8 @@ export class StreamManager {
|
|
|
225
250
|
port: cached.port,
|
|
226
251
|
sdp: cached.sdp,
|
|
227
252
|
audio: cached.audio,
|
|
228
|
-
username:
|
|
229
|
-
password:
|
|
253
|
+
username: cached.username || this.opts.credentials.username,
|
|
254
|
+
password: cached.password || this.opts.credentials.password,
|
|
230
255
|
};
|
|
231
256
|
}
|
|
232
257
|
}
|
|
@@ -241,7 +266,7 @@ export class StreamManager {
|
|
|
241
266
|
this.nativeRfcServers.delete(streamKey);
|
|
242
267
|
}
|
|
243
268
|
|
|
244
|
-
const api = await this.opts.createStreamClient();
|
|
269
|
+
const api = await this.opts.createStreamClient(profile);
|
|
245
270
|
const { createRfc4571TcpServer } = await import('@apocaliss92/reolink-baichuan-js');
|
|
246
271
|
|
|
247
272
|
// Use the same credentials as the main connection
|
|
@@ -252,13 +277,14 @@ export class StreamManager {
|
|
|
252
277
|
|
|
253
278
|
const created = await createRfc4571TcpServer({
|
|
254
279
|
api,
|
|
255
|
-
channel,
|
|
280
|
+
channel: options.channel,
|
|
256
281
|
profile,
|
|
257
282
|
logger: this.getLogger(),
|
|
258
283
|
expectedVideoType: expectedVideoType as VideoType | undefined,
|
|
259
284
|
closeApiOnTeardown,
|
|
260
285
|
username,
|
|
261
286
|
password,
|
|
287
|
+
...(options.compositeOptions ? { compositeOptions: options.compositeOptions } : {}),
|
|
262
288
|
});
|
|
263
289
|
|
|
264
290
|
this.nativeRfcServers.set(streamKey, created);
|
|
@@ -272,8 +298,8 @@ export class StreamManager {
|
|
|
272
298
|
port: created.port,
|
|
273
299
|
sdp: created.sdp,
|
|
274
300
|
audio: created.audio,
|
|
275
|
-
username:
|
|
276
|
-
password:
|
|
301
|
+
username: created.username || this.opts.credentials.username,
|
|
302
|
+
password: created.password || this.opts.credentials.password,
|
|
277
303
|
};
|
|
278
304
|
})();
|
|
279
305
|
|
|
@@ -292,7 +318,9 @@ export class StreamManager {
|
|
|
292
318
|
streamKey: string,
|
|
293
319
|
expectedVideoType?: 'H264' | 'H265',
|
|
294
320
|
): Promise<RfcServerInfo> {
|
|
295
|
-
return await this.
|
|
321
|
+
return await this.ensureRfcServer(streamKey, profile, expectedVideoType, {
|
|
322
|
+
channel,
|
|
323
|
+
});
|
|
296
324
|
}
|
|
297
325
|
|
|
298
326
|
async getRfcCompositeStream(
|
|
@@ -300,85 +328,10 @@ export class StreamManager {
|
|
|
300
328
|
streamKey: string,
|
|
301
329
|
expectedVideoType?: 'H264' | 'H265',
|
|
302
330
|
): Promise<RfcServerInfo> {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
const createPromise = (async () => {
|
|
309
|
-
const cached = this.nativeRfcServers.get(streamKey);
|
|
310
|
-
if (cached?.server?.listening) {
|
|
311
|
-
if (expectedVideoType && cached.videoType !== expectedVideoType) {
|
|
312
|
-
this.getLogger().warn(
|
|
313
|
-
`Native RFC composite cache codec mismatch for ${streamKey}: cached=${cached.videoType} expected=${expectedVideoType}; recreating server.`,
|
|
314
|
-
);
|
|
315
|
-
}
|
|
316
|
-
else {
|
|
317
|
-
return {
|
|
318
|
-
host: cached.host,
|
|
319
|
-
port: cached.port,
|
|
320
|
-
sdp: cached.sdp,
|
|
321
|
-
audio: cached.audio,
|
|
322
|
-
username: (cached as any).username || this.opts.credentials.username,
|
|
323
|
-
password: (cached as any).password || this.opts.credentials.password,
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
if (cached) {
|
|
329
|
-
try {
|
|
330
|
-
await cached.close('recreate');
|
|
331
|
-
}
|
|
332
|
-
catch {
|
|
333
|
-
// ignore
|
|
334
|
-
}
|
|
335
|
-
this.nativeRfcServers.delete(streamKey);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
const api = await this.opts.createStreamClient();
|
|
339
|
-
const { createRfc4571TcpServer } = await import('@apocaliss92/reolink-baichuan-js');
|
|
340
|
-
|
|
341
|
-
// Use the same credentials as the main connection
|
|
342
|
-
const { username, password } = this.opts.credentials;
|
|
343
|
-
|
|
344
|
-
// If connection is shared, don't close it when stream teardown happens
|
|
345
|
-
const closeApiOnTeardown = !(this.opts.sharedConnection ?? false);
|
|
346
|
-
|
|
347
|
-
const created = await createRfc4571TcpServer({
|
|
348
|
-
api,
|
|
349
|
-
channel: undefined, // Undefined channel indicates composite stream
|
|
350
|
-
profile,
|
|
351
|
-
logger: this.getLogger(),
|
|
352
|
-
expectedVideoType: expectedVideoType as VideoType | undefined,
|
|
353
|
-
closeApiOnTeardown,
|
|
354
|
-
username,
|
|
355
|
-
password,
|
|
356
|
-
compositeOptions: this.opts.compositeOptions,
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
this.nativeRfcServers.set(streamKey, created);
|
|
360
|
-
created.server.once('close', () => {
|
|
361
|
-
const current = this.nativeRfcServers.get(streamKey);
|
|
362
|
-
if (current?.server === created.server) this.nativeRfcServers.delete(streamKey);
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
return {
|
|
366
|
-
host: created.host,
|
|
367
|
-
port: created.port,
|
|
368
|
-
sdp: created.sdp,
|
|
369
|
-
audio: created.audio,
|
|
370
|
-
username: (created as any).username || this.opts.credentials.username,
|
|
371
|
-
password: (created as any).password || this.opts.credentials.password,
|
|
372
|
-
};
|
|
373
|
-
})();
|
|
374
|
-
|
|
375
|
-
this.nativeRfcServerCreatePromises.set(streamKey, createPromise);
|
|
376
|
-
try {
|
|
377
|
-
return await createPromise;
|
|
378
|
-
}
|
|
379
|
-
finally {
|
|
380
|
-
this.nativeRfcServerCreatePromises.delete(streamKey);
|
|
381
|
-
}
|
|
331
|
+
return await this.ensureRfcServer(streamKey, profile, expectedVideoType, {
|
|
332
|
+
channel: undefined, // Undefined channel indicates composite stream
|
|
333
|
+
compositeOptions: this.opts.compositeOptions,
|
|
334
|
+
});
|
|
382
335
|
}
|
|
383
336
|
|
|
384
337
|
/**
|