@apocaliss92/scrypted-reolink-native 0.1.32 → 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 -6
- package/src/camera.ts +1 -36
- package/src/common.ts +514 -17
- package/src/debug-options.ts +4 -0
- package/src/main.ts +158 -4
- package/src/multiFocal.ts +1 -29
- package/src/nvr.ts +1 -3
- package/src/stream-utils.ts +24 -7
- package/src/utils.ts +471 -2
- package/logs/composite-stream.txt +0 -16390
- package/logs/lense.txt +0 -44
- package/logs/multifocal.txt +0 -136
- package/logs/multifocal2.txt +0 -3585
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);
|
|
@@ -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
|
@@ -75,7 +75,6 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
|
|
|
75
75
|
this.storageSettings.settings.uid.hide = !this.isBattery;
|
|
76
76
|
|
|
77
77
|
await this.ensureBaichuanClient();
|
|
78
|
-
await this.updateDeviceInfo();
|
|
79
78
|
await this.reportDevices();
|
|
80
79
|
await this.subscribeToEvents();
|
|
81
80
|
} catch (e) {
|
|
@@ -89,27 +88,9 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
|
|
|
89
88
|
}
|
|
90
89
|
}
|
|
91
90
|
|
|
92
|
-
async updateDeviceInfo(): Promise<void> {
|
|
93
|
-
const logger = this.getBaichuanLogger();
|
|
94
|
-
try {
|
|
95
|
-
const api = await this.ensureBaichuanClient();
|
|
96
|
-
const deviceData = await api.getInfo();
|
|
97
|
-
|
|
98
|
-
await updateDeviceInfo({
|
|
99
|
-
device: this,
|
|
100
|
-
deviceData,
|
|
101
|
-
ipAddress: this.storageSettings.values.ipAddress,
|
|
102
|
-
logger,
|
|
103
|
-
});
|
|
104
|
-
} catch (e) {
|
|
105
|
-
logger.warn('Failed to fetch device info', e);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
91
|
getInterfaces(channel: number) {
|
|
110
92
|
const logger = this.getBaichuanLogger();
|
|
111
|
-
const
|
|
112
|
-
const { capabilities: caps, multifocalInfo } = values;
|
|
93
|
+
const { capabilities: caps, multifocalInfo } = this.storageSettings.values;
|
|
113
94
|
const channelInfo = (multifocalInfo as DualLensChannelAnalysis).channels.find(c => c.channel === channel);
|
|
114
95
|
|
|
115
96
|
const capabilities: DeviceCapabilities = {
|
|
@@ -213,15 +194,6 @@ export class ReolinkNativeMultiFocalDevice extends CommonCameraMixin implements
|
|
|
213
194
|
}
|
|
214
195
|
}
|
|
215
196
|
|
|
216
|
-
async getSettings(): Promise<Setting[]> {
|
|
217
|
-
const settings = await this.storageSettings.getSettings();
|
|
218
|
-
return settings;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
async putSetting(key: string, value: SettingValue): Promise<void> {
|
|
222
|
-
return this.storageSettings.putSetting(key, value);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
197
|
async releaseDevice(id: string, nativeId: string) {
|
|
226
198
|
this.cameraNativeMap.delete(nativeId);
|
|
227
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).
|
|
@@ -201,7 +202,7 @@ export class StreamManager {
|
|
|
201
202
|
}
|
|
202
203
|
|
|
203
204
|
private getLogger() {
|
|
204
|
-
return this.opts.getLogger()
|
|
205
|
+
return this.opts.getLogger();
|
|
205
206
|
}
|
|
206
207
|
|
|
207
208
|
private async ensureRfcServer(
|
|
@@ -213,11 +214,27 @@ export class StreamManager {
|
|
|
213
214
|
compositeOptions?: CompositeStreamPipOptions;
|
|
214
215
|
},
|
|
215
216
|
): Promise<RfcServerInfo> {
|
|
217
|
+
// Check for existing promise first to prevent duplicate server creation
|
|
216
218
|
const existingCreate = this.nativeRfcServerCreatePromises.get(streamKey);
|
|
217
219
|
if (existingCreate) {
|
|
218
220
|
return await existingCreate;
|
|
219
221
|
}
|
|
220
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
|
+
|
|
221
238
|
const createPromise = (async () => {
|
|
222
239
|
const cached = this.nativeRfcServers.get(streamKey);
|
|
223
240
|
if (cached?.server?.listening) {
|
|
@@ -233,8 +250,8 @@ export class StreamManager {
|
|
|
233
250
|
port: cached.port,
|
|
234
251
|
sdp: cached.sdp,
|
|
235
252
|
audio: cached.audio,
|
|
236
|
-
username:
|
|
237
|
-
password:
|
|
253
|
+
username: cached.username || this.opts.credentials.username,
|
|
254
|
+
password: cached.password || this.opts.credentials.password,
|
|
238
255
|
};
|
|
239
256
|
}
|
|
240
257
|
}
|
|
@@ -249,7 +266,7 @@ export class StreamManager {
|
|
|
249
266
|
this.nativeRfcServers.delete(streamKey);
|
|
250
267
|
}
|
|
251
268
|
|
|
252
|
-
const api = await this.opts.createStreamClient();
|
|
269
|
+
const api = await this.opts.createStreamClient(profile);
|
|
253
270
|
const { createRfc4571TcpServer } = await import('@apocaliss92/reolink-baichuan-js');
|
|
254
271
|
|
|
255
272
|
// Use the same credentials as the main connection
|
|
@@ -281,8 +298,8 @@ export class StreamManager {
|
|
|
281
298
|
port: created.port,
|
|
282
299
|
sdp: created.sdp,
|
|
283
300
|
audio: created.audio,
|
|
284
|
-
username:
|
|
285
|
-
password:
|
|
301
|
+
username: created.username || this.opts.credentials.username,
|
|
302
|
+
password: created.password || this.opts.credentials.password,
|
|
286
303
|
};
|
|
287
304
|
})();
|
|
288
305
|
|