@apocaliss92/scrypted-mjpeg-rebroadcast 0.0.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.
@@ -0,0 +1,23 @@
1
+ {
2
+ // Use IntelliSense to learn about possible attributes.
3
+ // Hover to view descriptions of existing attributes.
4
+ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5
+ "version": "0.2.0",
6
+ "configurations": [
7
+ {
8
+ "name": "Scrypted Debugger",
9
+ "address": "${config:scrypted.debugHost}",
10
+ "port": 10081,
11
+ "request": "attach",
12
+ "skipFiles": [
13
+ "**/plugin-remote-worker.*",
14
+ "<node_internals>/**"
15
+ ],
16
+ "preLaunchTask": "scrypted: deploy+debug",
17
+ "sourceMaps": true,
18
+ "localRoot": "${workspaceFolder}/out",
19
+ "remoteRoot": "/plugin/",
20
+ "type": "node"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,4 @@
1
+
2
+ {
3
+ "scrypted.debugHost": "192.168.1.4",
4
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ // See https://go.microsoft.com/fwlink/?LinkId=733558
3
+ // for the documentation about the tasks.json format
4
+ "version": "2.0.0",
5
+ "tasks": [
6
+ {
7
+ "label": "scrypted: deploy+debug",
8
+ "type": "shell",
9
+ "presentation": {
10
+ "echo": true,
11
+ "reveal": "silent",
12
+ "focus": false,
13
+ "panel": "shared",
14
+ "showReuseMessage": true,
15
+ "clear": false
16
+ },
17
+ "command": "npm run scrypted-vscode-launch ${config:scrypted.debugHost}",
18
+ },
19
+ ]
20
+ }
package/README.md ADDED
@@ -0,0 +1,8 @@
1
+ # Scrypted MJPEG Rebroadcast
2
+
3
+ ☕️ If this extension works well for you, consider buying me a coffee. Thanks!
4
+ [Buy me a coffee!](https://buymeacoffee.com/apocaliss92)
5
+
6
+ **Documentation:** [https://advanced-notifier-docs.zentik.app/docs/mjpeg-rebroadcast](https://advanced-notifier-docs.zentik.app/docs/mjpeg-rebroadcast)
7
+
8
+ [For requests and bugs](https://github.com/apocaliss92/scrypted-mjpeg-rebroadcast)
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@apocaliss92/scrypted-mjpeg-rebroadcast",
3
+ "description": "Scrypted mixin plugin for MJPEG live stream rebroadcast per camera",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "https://github.com/apocaliss92/scrypted-mjpeg-rebroadcast"
7
+ },
8
+ "version": "0.0.1",
9
+ "scripts": {
10
+ "scrypted-setup-project": "scrypted-setup-project",
11
+ "prescrypted-setup-project": "scrypted-package-json",
12
+ "build": "scrypted-webpack",
13
+ "prepublishOnly": "NODE_ENV=production scrypted-webpack",
14
+ "prescrypted-vscode-launch": "scrypted-webpack",
15
+ "scrypted-vscode-launch": "scrypted-deploy-debug",
16
+ "scrypted-deploy-debug": "scrypted-deploy-debug",
17
+ "scrypted-debug": "scrypted-debug",
18
+ "scrypted-deploy": "scrypted-deploy",
19
+ "scrypted-readme": "scrypted-readme",
20
+ "scrypted-package-json": "scrypted-package-json"
21
+ },
22
+ "keywords": [
23
+ "scrypted",
24
+ "plugin",
25
+ "mjpeg",
26
+ "rebroadcast",
27
+ "camera",
28
+ "snapshot",
29
+ "mixin"
30
+ ],
31
+ "scrypted": {
32
+ "name": "MJPEG Rebroadcast",
33
+ "type": "API",
34
+ "interfaces": [
35
+ "Settings",
36
+ "MixinProvider",
37
+ "HttpRequestHandler"
38
+ ],
39
+ "pluginDependencies": []
40
+ },
41
+ "dependencies": {
42
+ "@scrypted/sdk": "0.5.53",
43
+ "lodash": "^4.17.21"
44
+ },
45
+ "devDependencies": {
46
+ "@types/lodash": "^4.17.12",
47
+ "@types/node": "^20.11.0"
48
+ }
49
+ }
@@ -0,0 +1 @@
1
+ const admzip = __non_webpack_require__('adm-zip'); module.exports = admzip;
@@ -0,0 +1 @@
1
+ const fakefs = __non_webpack_require__('fakefs'); module.exports = fakefs;
@@ -0,0 +1 @@
1
+ const mdns = __non_webpack_require__('mdns'); module.exports = mdns;
@@ -0,0 +1 @@
1
+ const memfs = __non_webpack_require__('memfs'); module.exports = memfs;
@@ -0,0 +1 @@
1
+ const nodeforge = __non_webpack_require__('node-forge'); module.exports = nodeforge;
@@ -0,0 +1 @@
1
+ const nodeptyprebuiltmultiarch = __non_webpack_require__('node-pty-prebuilt-multiarch'); module.exports = nodeptyprebuiltmultiarch;
@@ -0,0 +1 @@
1
+ const realfs = __non_webpack_require__('realfs'); module.exports = realfs;
@@ -0,0 +1 @@
1
+ const scryptednodepty = __non_webpack_require__('@scrypted/node-pty'); module.exports = scryptednodepty;
@@ -0,0 +1 @@
1
+ const sharp = __non_webpack_require__('sharp'); module.exports = sharp;
@@ -0,0 +1 @@
1
+ const sourcemapsupportregister = __non_webpack_require__('source-map-support/register'); module.exports = sourcemapsupportregister;
@@ -0,0 +1 @@
1
+ const typescript = __non_webpack_require__('typescript'); module.exports = typescript;
@@ -0,0 +1,443 @@
1
+ import sdk, { Setting, SettingValue, Settings } from '@scrypted/sdk';
2
+ import { SettingsMixinDeviceBase, SettingsMixinDeviceOptions } from '@scrypted/sdk/settings-mixin';
3
+ import { StorageSettings } from '@scrypted/sdk/storage-settings';
4
+ import { MjpegProducer } from './mjpegProducer';
5
+ import { FrameHub } from './frameHub';
6
+ import { MjpegSourceEnum } from './types';
7
+ import crypto from 'crypto';
8
+
9
+ import type MjpegRebroadcastPlugin from './main';
10
+
11
+ const { systemManager } = sdk;
12
+
13
+ interface StreamInfo {
14
+ name: string;
15
+ rtspUrl: string;
16
+ /** Path token extracted from the RTSP rebroadcast URL (already secret) */
17
+ pathToken: string;
18
+ }
19
+
20
+ /**
21
+ * Extract the path segment from an RTSP URL to use as stream token.
22
+ * e.g. rtsp://192.168.1.4:38911/abc123 → abc123
23
+ */
24
+ function extractPathToken(rtspUrl: string): string {
25
+ try {
26
+ const url = new URL(rtspUrl);
27
+ return url.pathname.replace(/^\//, '');
28
+ } catch {
29
+ return crypto.randomBytes(16).toString('hex');
30
+ }
31
+ }
32
+
33
+ /** Force-restart FFmpeg after this duration regardless of clients */
34
+ const MAX_RUNTIME_MS = 4 * 60 * 60 * 1000; // 4 hours
35
+ /** Restart FFmpeg early if 0 clients for this long (prebuffer only) */
36
+ const IDLE_RESTART_MS = 30 * 60 * 1000; // 30 minutes
37
+ /** How often to check producer health */
38
+ const HEALTH_CHECK_INTERVAL_MS = 60 * 1000; // 1 minute
39
+
40
+ export class MjpegRebroadcastCameraMixin extends SettingsMixinDeviceBase<any> {
41
+ plugin: MjpegRebroadcastPlugin;
42
+ private logger: {
43
+ log: (...args: any[]) => void;
44
+ debug: (...args: any[]) => void;
45
+ warn: (...args: any[]) => void;
46
+ error: (...args: any[]) => void;
47
+ };
48
+ private producers: Map<string, MjpegProducer> = new Map();
49
+ private producerStartedAt: Map<string, number> = new Map();
50
+ private discoveredStreams: StreamInfo[] = [];
51
+ private healthCheckInterval: NodeJS.Timeout | null = null;
52
+ killed = false;
53
+
54
+ /** Per-stream frame hubs — keyed by pathToken */
55
+ frameHubs: Map<string, FrameHub> = new Map();
56
+ /** Maps pathToken → stream name (for status/display) */
57
+ tokenToName: Map<string, string> = new Map();
58
+
59
+ storageSettings = new StorageSettings(this, {
60
+ mjpegSource: {
61
+ title: 'MJPEG source',
62
+ description: 'Choose how to generate the MJPEG streams',
63
+ type: 'string',
64
+ choices: [
65
+ MjpegSourceEnum.ScryptedSnapshot,
66
+ MjpegSourceEnum.ScryptedRtspFfmpeg,
67
+ ],
68
+ defaultValue: MjpegSourceEnum.ScryptedRtspFfmpeg,
69
+ immediate: true,
70
+ },
71
+ fps: {
72
+ title: 'FPS',
73
+ description: 'Target frames per second',
74
+ type: 'number',
75
+ defaultValue: 5,
76
+ },
77
+ quality: {
78
+ title: 'Quality',
79
+ description: 'JPEG quality (1-100)',
80
+ type: 'number',
81
+ defaultValue: 80,
82
+ },
83
+ width: {
84
+ title: 'Width',
85
+ description: 'Output width (blank for original). Height is auto-calculated.',
86
+ type: 'number',
87
+ },
88
+ prebufferStreams: {
89
+ title: 'Prebuffer streams',
90
+ description: 'Selected streams will have FFmpeg always running (instant playback). Others start on-demand when a client connects.',
91
+ type: 'string',
92
+ multiple: true,
93
+ choices: [],
94
+ immediate: true,
95
+ onPut: async () => {
96
+ if (this.storageSettings.values.serverEnabled) {
97
+ await this.setupStreams();
98
+ }
99
+ },
100
+ },
101
+ serverEnabled: {
102
+ title: 'MJPEG server enabled',
103
+ type: 'boolean',
104
+ defaultValue: true,
105
+ immediate: true,
106
+ onPut: async (_oldValue, newValue) => {
107
+ if (newValue) {
108
+ await this.discoverStreams();
109
+ await this.setupStreams();
110
+ } else {
111
+ await this.teardownStreams();
112
+ }
113
+ },
114
+ },
115
+ debugEvents: {
116
+ title: 'Debug logging',
117
+ description: 'Enable verbose debug logging',
118
+ type: 'boolean',
119
+ defaultValue: false,
120
+ immediate: true,
121
+ },
122
+ });
123
+
124
+ constructor(
125
+ options: SettingsMixinDeviceOptions<any>,
126
+ plugin: MjpegRebroadcastPlugin,
127
+ ) {
128
+ super(options);
129
+ this.plugin = plugin;
130
+ this.plugin.currentMixinsMap[this.id] = this;
131
+ this.logger = {
132
+ log: (message: string, ...args: any[]) =>
133
+ this.console.log(message, ...args),
134
+ debug: (message: string, ...args: any[]) => {
135
+ if (this.storageSettings.values.debugEvents)
136
+ this.console.log(`[DEBUG] ${message}`, ...args);
137
+ },
138
+ warn: (message: string, ...args: any[]) =>
139
+ this.console.warn(message, ...args),
140
+ error: (message: string, ...args: any[]) =>
141
+ this.console.error(message, ...args),
142
+ };
143
+
144
+ setTimeout(() => this.init(), 5000);
145
+ }
146
+
147
+ get activeStreamTokens(): string[] {
148
+ return Array.from(this.frameHubs.keys());
149
+ }
150
+
151
+ private async init() {
152
+ if (this.killed) return;
153
+
154
+ this.console.log(`MJPEG Rebroadcast mixin initialized for ${this.name}`);
155
+
156
+ await this.discoverStreams();
157
+
158
+ if (this.killed) return;
159
+
160
+ if (this.storageSettings.values.serverEnabled) {
161
+ await this.setupStreams();
162
+ }
163
+ }
164
+
165
+ async getMixinSettings(): Promise<Setting[]> {
166
+ const mjpegSource = this.storageSettings.values.mjpegSource;
167
+ const isSnapshot = mjpegSource === MjpegSourceEnum.ScryptedSnapshot;
168
+
169
+ this.storageSettings.settings.width.hide = isSnapshot;
170
+ this.storageSettings.settings.quality.hide = isSnapshot;
171
+ this.storageSettings.settings.prebufferStreams.hide = isSnapshot;
172
+
173
+ if (this.discoveredStreams.length > 0) {
174
+ this.storageSettings.settings.prebufferStreams.choices = this.discoveredStreams.map(s => s.name);
175
+ }
176
+
177
+ const settings = await this.storageSettings.getSettings();
178
+
179
+ if (this.frameHubs.size > 0) {
180
+ try {
181
+ const baseEndpoint = await this.plugin.getEndpointUrl();
182
+
183
+ for (const [token] of this.frameHubs) {
184
+ const streamName = this.tokenToName.get(token) ?? token;
185
+ const url = `${baseEndpoint}/${token}`;
186
+ settings.push({
187
+ key: `streamUrl_${token}`,
188
+ title: `MJPEG Stream Url (${streamName})`,
189
+ description: url,
190
+ value: url,
191
+ type: 'string',
192
+ readonly: true,
193
+ subgroup: 'Stream URLs',
194
+ });
195
+ }
196
+ } catch (e) {
197
+ this.logger.debug(`Could not generate stream URLs: ${(e as Error).message}`);
198
+ }
199
+ }
200
+
201
+ return settings;
202
+ }
203
+
204
+ async putMixinSetting(key: string, value: SettingValue): Promise<void> {
205
+ await this.storageSettings.putSetting(key, value);
206
+ }
207
+
208
+ private async discoverStreams() {
209
+ this.discoveredStreams = [];
210
+
211
+ try {
212
+ const device = systemManager.getDeviceById(this.id) as unknown as Settings;
213
+ if (!device?.getSettings) return;
214
+
215
+ const deviceSettings = await device.getSettings();
216
+ const rtspSettings = deviceSettings.filter(
217
+ (setting) => setting.title === 'RTSP Rebroadcast Url',
218
+ );
219
+
220
+ for (const setting of rtspSettings) {
221
+ const rtspUrl = setting.value as string;
222
+ if (!rtspUrl) continue;
223
+
224
+ const streamName = setting.subgroup?.replace('Stream: ', '') ?? 'Default';
225
+ const pathToken = extractPathToken(rtspUrl);
226
+ this.discoveredStreams.push({
227
+ name: streamName,
228
+ rtspUrl,
229
+ pathToken,
230
+ });
231
+ }
232
+
233
+ this.logger.debug(`${this.name}: found ${this.discoveredStreams.length} RTSP stream(s)`);
234
+ for (const s of this.discoveredStreams) {
235
+ this.logger.debug(` - ${s.name}: ${s.rtspUrl} (token: ${s.pathToken})`);
236
+ }
237
+ } catch (e) {
238
+ this.console.warn(`Failed to discover streams for ${this.name}: ${(e as Error).message}`);
239
+ }
240
+ }
241
+
242
+ private getPrebufferStreamNames(): Set<string> {
243
+ const names = this.storageSettings.values.prebufferStreams as string[] | undefined;
244
+ return new Set(names ?? []);
245
+ }
246
+
247
+ /**
248
+ * Start a producer for a specific stream token.
249
+ * Called lazily when the first client subscribes.
250
+ * Returns a promise that resolves when the producer is ready.
251
+ */
252
+ private async startProducer(token: string) {
253
+ if (this.producers.has(token)) return; // Already running
254
+
255
+ const fps = (this.storageSettings.values.fps as number) || 5;
256
+ const quality = (this.storageSettings.values.quality as number) || 80;
257
+ const width = this.storageSettings.values.width as number;
258
+ const mjpegSource = this.storageSettings.values.mjpegSource;
259
+ const hub = this.frameHubs.get(token);
260
+ if (!hub) return;
261
+
262
+ const streamName = this.tokenToName.get(token) ?? token;
263
+ const prebufferNames = this.getPrebufferStreamNames();
264
+ const isPrebuffer = prebufferNames.has(streamName);
265
+ this.console.log(`${this.name}: starting producer for "${streamName}" (${isPrebuffer ? 'prebuffer' : 'first client connected'})`);
266
+
267
+ const producer = new MjpegProducer(this.console);
268
+ producer.debugLog = (message, ...args) => this.logger.debug(message, ...args);
269
+ producer.onFrame = (frame) => hub.push(frame);
270
+ this.producers.set(token, producer);
271
+ this.producerStartedAt.set(token, Date.now());
272
+
273
+ try {
274
+ if (mjpegSource === MjpegSourceEnum.ScryptedSnapshot) {
275
+ await producer.startSnapshotPolling(this.id, fps);
276
+ } else {
277
+ const stream = this.discoveredStreams.find(s => s.pathToken === token);
278
+ if (stream) {
279
+ await producer.startRtspToMjpeg(stream.rtspUrl, fps, quality, width);
280
+ }
281
+ }
282
+ } catch (e) {
283
+ this.console.error(`Failed to start producer for ${streamName}: ${(e as Error).message}`);
284
+ }
285
+ }
286
+
287
+ /**
288
+ * Stop a producer for a specific stream token.
289
+ * Called when the last client disconnects.
290
+ */
291
+ private stopProducer(token: string) {
292
+ const producer = this.producers.get(token);
293
+ if (!producer) return;
294
+
295
+ const streamName = this.tokenToName.get(token) ?? token;
296
+ this.console.log(`${this.name}: stopping producer for "${streamName}" (no more clients)`);
297
+
298
+ producer.stop();
299
+ this.producers.delete(token);
300
+ this.producerStartedAt.delete(token);
301
+ }
302
+
303
+ /**
304
+ * Restart a running producer (stop + start).
305
+ * For prebuffer streams this is seamless; for on-demand it only restarts if clients are connected.
306
+ */
307
+ private async restartProducer(token: string) {
308
+ const producer = this.producers.get(token);
309
+ if (!producer) return;
310
+
311
+ const streamName = this.tokenToName.get(token) ?? token;
312
+ this.console.log(`${this.name}: restarting producer for "${streamName}"`);
313
+
314
+ producer.stop();
315
+ this.producers.delete(token);
316
+ this.producerStartedAt.delete(token);
317
+
318
+ await this.startProducer(token);
319
+ }
320
+
321
+ /**
322
+ * Periodic health check: restarts producers that have been running too long
323
+ * or that are idle (prebuffer with 0 clients).
324
+ */
325
+ private checkProducerHealth() {
326
+ if (this.killed) return;
327
+
328
+ const now = Date.now();
329
+ const prebufferNames = this.getPrebufferStreamNames();
330
+
331
+ for (const [token] of this.producers) {
332
+ const startedAt = this.producerStartedAt.get(token);
333
+ if (!startedAt) continue;
334
+
335
+ const runtime = now - startedAt;
336
+ const hub = this.frameHubs.get(token);
337
+ const subscribers = hub?.subscriberCount ?? 0;
338
+ const streamName = this.tokenToName.get(token) ?? token;
339
+ const isPrebuffer = prebufferNames.has(streamName);
340
+
341
+ if (runtime >= MAX_RUNTIME_MS) {
342
+ this.console.log(`${this.name}: "${streamName}" max runtime reached (${Math.round(runtime / 60000)}min), restarting`);
343
+ this.restartProducer(token);
344
+ } else if (isPrebuffer && subscribers === 0 && runtime >= IDLE_RESTART_MS) {
345
+ this.console.log(`${this.name}: "${streamName}" idle with 0 clients for ${Math.round(runtime / 60000)}min, restarting`);
346
+ this.restartProducer(token);
347
+ }
348
+ }
349
+ }
350
+
351
+ /**
352
+ * Set up FrameHubs for ALL discovered streams.
353
+ * Prebuffer streams start FFmpeg immediately; others start lazily on first client.
354
+ */
355
+ private async setupStreams() {
356
+ await this.teardownStreams();
357
+
358
+ if (this.discoveredStreams.length === 0 && this.storageSettings.values.mjpegSource === MjpegSourceEnum.ScryptedRtspFfmpeg) {
359
+ this.logger.debug(`No streams found for ${this.name}, trying to discover...`);
360
+ await this.discoverStreams();
361
+ }
362
+
363
+ const mjpegSource = this.storageSettings.values.mjpegSource;
364
+ const prebufferNames = this.getPrebufferStreamNames();
365
+
366
+ try {
367
+ if (mjpegSource === MjpegSourceEnum.ScryptedSnapshot) {
368
+ const token = crypto.randomBytes(16).toString('hex');
369
+ const hub = new FrameHub();
370
+ hub.onFirstSubscriber = () => this.startProducer(token);
371
+ hub.onLastUnsubscribe = () => this.stopProducer(token);
372
+ this.frameHubs.set(token, hub);
373
+ this.tokenToName.set(token, 'Snapshot');
374
+
375
+ this.console.log(`${this.name}: MJPEG snapshot endpoint ready (on-demand)`);
376
+ } else if (mjpegSource === MjpegSourceEnum.ScryptedRtspFfmpeg) {
377
+ if (this.discoveredStreams.length === 0) {
378
+ this.console.warn(`No RTSP streams found for ${this.name}. Make sure the Rebroadcast plugin is installed.`);
379
+ return;
380
+ }
381
+
382
+ const prebufferTokens: string[] = [];
383
+
384
+ for (const stream of this.discoveredStreams) {
385
+ const isPrebuffer = prebufferNames.has(stream.name);
386
+ const hub = new FrameHub();
387
+
388
+ if (isPrebuffer) {
389
+ // Prebuffer: no lazy lifecycle — producer starts immediately
390
+ hub.onFirstSubscriber = null;
391
+ hub.onLastUnsubscribe = null;
392
+ } else {
393
+ // On-demand: lazy start/stop
394
+ hub.onFirstSubscriber = () => this.startProducer(stream.pathToken);
395
+ hub.onLastUnsubscribe = () => this.stopProducer(stream.pathToken);
396
+ }
397
+
398
+ this.frameHubs.set(stream.pathToken, hub);
399
+ this.tokenToName.set(stream.pathToken, stream.name);
400
+
401
+ if (isPrebuffer) {
402
+ prebufferTokens.push(stream.pathToken);
403
+ }
404
+ }
405
+
406
+ // Start prebuffer producers immediately
407
+ for (const token of prebufferTokens) {
408
+ await this.startProducer(token);
409
+ }
410
+
411
+ const onDemandCount = this.discoveredStreams.length - prebufferTokens.length;
412
+ this.console.log(`${this.name}: ${this.discoveredStreams.length} MJPEG endpoint(s) ready (${prebufferTokens.length} prebuffer, ${onDemandCount} on-demand)`);
413
+ }
414
+
415
+ // Start periodic health check for producer restarts
416
+ this.healthCheckInterval = setInterval(() => this.checkProducerHealth(), HEALTH_CHECK_INTERVAL_MS);
417
+ } catch (e) {
418
+ this.console.error(`Failed to setup MJPEG streams for ${this.name}`, (e as Error).message);
419
+ }
420
+ }
421
+
422
+ private async teardownStreams() {
423
+ if (this.healthCheckInterval) {
424
+ clearInterval(this.healthCheckInterval);
425
+ this.healthCheckInterval = null;
426
+ }
427
+ for (const [, producer] of this.producers) {
428
+ producer.stop();
429
+ }
430
+ this.producers.clear();
431
+ this.producerStartedAt.clear();
432
+ this.frameHubs.clear();
433
+ this.tokenToName.clear();
434
+ }
435
+
436
+ async release() {
437
+ if (this.killed) return;
438
+ this.killed = true;
439
+ this.console.log(`Releasing MJPEG mixin for ${this.name}`);
440
+ await this.teardownStreams();
441
+ super.release();
442
+ }
443
+ }
@@ -0,0 +1,72 @@
1
+ const BOUNDARY = 'mjpegboundary';
2
+
3
+ /**
4
+ * Lightweight frame distribution hub for a single stream.
5
+ * Producers push JPEG frames, consumers subscribe via AsyncGenerator.
6
+ * Supports lifecycle callbacks for lazy start/stop of producers.
7
+ */
8
+ export class FrameHub {
9
+ private listeners: Set<(frame: Buffer) => void> = new Set();
10
+
11
+ /** Called when subscriber count goes from 0 to 1. Awaited before streaming starts. */
12
+ onFirstSubscriber: (() => Promise<void> | void) | null = null;
13
+ /** Called when subscriber count goes from 1 to 0 */
14
+ onLastUnsubscribe: (() => void) | null = null;
15
+
16
+ push(frame: Buffer) {
17
+ for (const listener of this.listeners) {
18
+ listener(frame);
19
+ }
20
+ }
21
+
22
+ get subscriberCount(): number {
23
+ return this.listeners.size;
24
+ }
25
+
26
+ /**
27
+ * Create an AsyncGenerator that yields MJPEG multipart chunks.
28
+ * If this is the first subscriber, onFirstSubscriber is awaited before streaming.
29
+ */
30
+ async *subscribe(): AsyncGenerator<Buffer, void> {
31
+ const queue: Buffer[] = [];
32
+ let resolve: (() => void) | null = null;
33
+
34
+ const listener = (frame: Buffer) => {
35
+ const header = `\r\n--${BOUNDARY}\r\nContent-Type: image/jpeg\r\nContent-Length: ${frame.length}\r\n\r\n`;
36
+ const chunk = Buffer.concat([Buffer.from(header), frame]);
37
+ queue.push(chunk);
38
+ if (resolve) {
39
+ resolve();
40
+ resolve = null;
41
+ }
42
+ };
43
+
44
+ const wasEmpty = this.listeners.size === 0;
45
+ this.listeners.add(listener);
46
+
47
+ // Start producer if this is the first subscriber — await it so FFmpeg has time to start
48
+ if (wasEmpty && this.onFirstSubscriber) {
49
+ await this.onFirstSubscriber();
50
+ }
51
+
52
+ try {
53
+ while (true) {
54
+ if (queue.length === 0) {
55
+ await new Promise<void>((r) => {
56
+ resolve = r;
57
+ });
58
+ }
59
+ while (queue.length > 0) {
60
+ yield queue.shift()!;
61
+ }
62
+ }
63
+ } finally {
64
+ this.listeners.delete(listener);
65
+ if (this.listeners.size === 0 && this.onLastUnsubscribe) {
66
+ this.onLastUnsubscribe();
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ export const MJPEG_BOUNDARY = BOUNDARY;
package/src/main.ts ADDED
@@ -0,0 +1,146 @@
1
+ import sdk, {
2
+ HttpRequest,
3
+ HttpRequestHandler,
4
+ HttpResponse,
5
+ MixinProvider,
6
+ ScryptedDeviceBase,
7
+ ScryptedDeviceType,
8
+ ScryptedInterface,
9
+ Setting,
10
+ Settings,
11
+ SettingValue,
12
+ WritableDeviceState,
13
+ } from '@scrypted/sdk';
14
+ import { StorageSettings } from '@scrypted/sdk/storage-settings';
15
+ import { MjpegRebroadcastCameraMixin } from './cameraMixin';
16
+ import { MJPEG_BOUNDARY } from './frameHub';
17
+
18
+ const { endpointManager } = sdk;
19
+
20
+ export default class MjpegRebroadcastPlugin
21
+ extends ScryptedDeviceBase
22
+ implements Settings, MixinProvider, HttpRequestHandler
23
+ {
24
+ currentMixinsMap: Record<string, MjpegRebroadcastCameraMixin> = {};
25
+ private endpointUrlCache: string | null = null;
26
+
27
+ storageSettings = new StorageSettings(this, {});
28
+
29
+ constructor(nativeId: string) {
30
+ super(nativeId);
31
+ this.console.log('MJPEG Rebroadcast plugin loaded');
32
+ }
33
+
34
+ async getEndpointUrl(): Promise<string> {
35
+ if (this.endpointUrlCache) return this.endpointUrlCache;
36
+
37
+ let url = await endpointManager.getLocalEndpoint(undefined, {
38
+ public: true,
39
+ insecure: true,
40
+ });
41
+ url = url.replace(/\/+$/, '');
42
+ this.endpointUrlCache = url;
43
+ return url;
44
+ }
45
+
46
+ async getSettings(): Promise<Setting[]> {
47
+ return this.storageSettings.getSettings();
48
+ }
49
+
50
+ async putSetting(key: string, value: SettingValue): Promise<void> {
51
+ await this.storageSettings.putSetting(key, value);
52
+ }
53
+
54
+ async canMixin(type: ScryptedDeviceType, interfaces: string[]): Promise<string[]> {
55
+ if (
56
+ (type === ScryptedDeviceType.Camera || type === ScryptedDeviceType.Doorbell) &&
57
+ (interfaces.includes(ScryptedInterface.VideoCamera) || interfaces.includes(ScryptedInterface.Camera))
58
+ ) {
59
+ return [ScryptedInterface.Settings];
60
+ }
61
+ return [];
62
+ }
63
+
64
+ async getMixin(
65
+ mixinDevice: any,
66
+ mixinDeviceInterfaces: ScryptedInterface[],
67
+ mixinDeviceState: WritableDeviceState,
68
+ ): Promise<any> {
69
+ // Mixin self-registers in its constructor via this.plugin.currentMixinsMap
70
+ const mixin = new MjpegRebroadcastCameraMixin(
71
+ {
72
+ mixinDevice,
73
+ mixinDeviceInterfaces,
74
+ mixinDeviceState,
75
+ mixinProviderNativeId: this.nativeId,
76
+ group: 'MJPEG Rebroadcast',
77
+ groupKey: 'mjpegRebroadcast',
78
+ },
79
+ this,
80
+ );
81
+
82
+ return mixin;
83
+ }
84
+
85
+ async releaseMixin(id: string, mixinDevice: any): Promise<void> {
86
+ // Do NOT delete from currentMixinsMap here — Scrypted calls releaseMixin
87
+ // for the OLD mixin AFTER getMixin has already stored the NEW one.
88
+ // The new mixin constructor overwrites the map entry.
89
+ try {
90
+ await mixinDevice.release();
91
+ } catch (e) {
92
+ // this.console.warn(`Error releasing mixin ${id}: ${(e as Error).message}`);
93
+ }
94
+ }
95
+
96
+ async onRequest(request: HttpRequest, response: HttpResponse): Promise<void> {
97
+ const requestUrl = request.url || '';
98
+
99
+ // Extract the token: everything after /public/
100
+ const publicIdx = requestUrl.indexOf('/public/');
101
+ const token = publicIdx !== -1 ? requestUrl.slice(publicIdx + '/public/'.length) : '';
102
+
103
+ if (!token) {
104
+ // Global status
105
+ const streams: Record<string, { deviceName: string; streamName: string; subscribers: number }> = {};
106
+ for (const mixin of Object.values(this.currentMixinsMap)) {
107
+ if (mixin.killed) continue;
108
+ for (const [tok] of mixin.frameHubs) {
109
+ streams[tok] = {
110
+ deviceName: mixin.name,
111
+ streamName: mixin.tokenToName.get(tok) ?? tok,
112
+ subscribers: mixin.frameHubs.get(tok)?.subscriberCount ?? 0,
113
+ };
114
+ }
115
+ }
116
+
117
+ response.send(JSON.stringify({ streams }, null, 2), {
118
+ code: 200,
119
+ headers: { 'Content-Type': 'application/json' },
120
+ });
121
+ return;
122
+ }
123
+
124
+ // Find the hub by token across all active mixins
125
+ for (const mixin of Object.values(this.currentMixinsMap)) {
126
+ if (mixin.killed) continue;
127
+ const hub = mixin.frameHubs.get(token);
128
+ if (hub) {
129
+ const generator = hub.subscribe();
130
+ response.sendStream(generator, {
131
+ code: 200,
132
+ headers: {
133
+ 'Content-Type': `multipart/x-mixed-replace; boundary=${MJPEG_BOUNDARY}`,
134
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
135
+ 'Pragma': 'no-cache',
136
+ 'Expires': '0',
137
+ 'Connection': 'keep-alive',
138
+ },
139
+ });
140
+ return;
141
+ }
142
+ }
143
+
144
+ response.send('Not found', { code: 404 });
145
+ }
146
+ }
@@ -0,0 +1,172 @@
1
+ import sdk, { Camera, Settings } from '@scrypted/sdk';
2
+
3
+ const { systemManager, mediaManager } = sdk;
4
+ const { spawn } = require('child_process');
5
+
6
+ export class MjpegProducer {
7
+ private console: Console;
8
+ private snapshotInterval: NodeJS.Timeout | null = null;
9
+ private ffmpegProcess: any = null;
10
+ private running = false;
11
+ debugLog: (message: string, ...args: any[]) => void = () => {};
12
+ onFrame: (frame: Buffer) => void = () => {};
13
+
14
+ constructor(console: Console) {
15
+ this.console = console;
16
+ }
17
+
18
+ async startSnapshotPolling(
19
+ deviceId: string,
20
+ fps: number = 2,
21
+ ): Promise<void> {
22
+ this.stop();
23
+ this.running = true;
24
+
25
+ const device = systemManager.getDeviceById(deviceId) as unknown as Camera;
26
+ if (!device?.takePicture) {
27
+ throw new Error(`Device ${deviceId} does not support Camera (snapshot) interface`);
28
+ }
29
+
30
+ const intervalMs = Math.max(100, Math.round(1000 / fps));
31
+ this.debugLog(`Starting snapshot polling at ${fps} fps (${intervalMs}ms interval)`);
32
+
33
+ const captureFrame = async () => {
34
+ if (!this.running) return;
35
+
36
+ try {
37
+ const picture = await device.takePicture();
38
+ const buffer = await mediaManager.convertMediaObjectToBuffer(picture, 'image/jpeg');
39
+ this.onFrame(Buffer.from(buffer));
40
+ } catch (e) {
41
+ this.console.warn(`Snapshot capture failed: ${(e as Error).message}`);
42
+ }
43
+ };
44
+
45
+ await captureFrame();
46
+ this.snapshotInterval = setInterval(captureFrame, intervalMs);
47
+ }
48
+
49
+ async startRtspToMjpeg(
50
+ rtspUrl: string,
51
+ fps: number = 5,
52
+ quality: number = 80,
53
+ width?: number,
54
+ ): Promise<void> {
55
+ this.stop();
56
+ this.running = true;
57
+
58
+ this.debugLog(`Starting RTSP→MJPEG conversion: ${rtspUrl}`);
59
+ await this.startFfmpegMjpeg(rtspUrl, fps, quality, width);
60
+ }
61
+
62
+ private async startFfmpegMjpeg(
63
+ inputUrl: string,
64
+ fps: number,
65
+ quality: number,
66
+ width?: number,
67
+ ): Promise<void> {
68
+ const ffmpegPath = await this.getFfmpegPath();
69
+
70
+ const args: string[] = [
71
+ '-rtsp_transport', 'tcp',
72
+ '-i', inputUrl,
73
+ '-f', 'image2pipe',
74
+ '-vcodec', 'mjpeg',
75
+ '-r', fps.toString(),
76
+ '-q:v', Math.max(1, Math.min(31, Math.round(31 - (quality / 100) * 30))).toString(),
77
+ ];
78
+
79
+ if (width) {
80
+ args.push('-vf', `scale=${width}:-1`);
81
+ }
82
+
83
+ args.push('-');
84
+
85
+ this.debugLog(`FFmpeg command: ${ffmpegPath} ${args.join(' ')}`);
86
+
87
+ this.ffmpegProcess = spawn(ffmpegPath, args, {
88
+ stdio: ['pipe', 'pipe', 'pipe'],
89
+ });
90
+
91
+ let jpegBuffer = Buffer.alloc(0);
92
+
93
+ this.ffmpegProcess.stdout.on('data', (data: Buffer) => {
94
+ jpegBuffer = Buffer.concat([jpegBuffer, data]);
95
+
96
+ while (true) {
97
+ const startIdx = jpegBuffer.indexOf(Buffer.from([0xFF, 0xD8]));
98
+ if (startIdx === -1) {
99
+ jpegBuffer = Buffer.alloc(0);
100
+ break;
101
+ }
102
+
103
+ const endIdx = jpegBuffer.indexOf(Buffer.from([0xFF, 0xD9]), startIdx + 2);
104
+ if (endIdx === -1) break;
105
+
106
+ const frame = jpegBuffer.subarray(startIdx, endIdx + 2);
107
+ this.onFrame(Buffer.from(frame));
108
+
109
+ jpegBuffer = jpegBuffer.subarray(endIdx + 2);
110
+ }
111
+ });
112
+
113
+ this.ffmpegProcess.stderr.on('data', (data: Buffer) => {
114
+ const msg = data.toString().trim();
115
+ if (msg) {
116
+ this.debugLog(`FFmpeg: ${msg}`);
117
+ }
118
+ });
119
+
120
+ this.ffmpegProcess.on('close', (code: number) => {
121
+ this.console.log(`FFmpeg process exited with code ${code} (running=${this.running})`);
122
+ this.ffmpegProcess = null;
123
+
124
+ if (this.running && code !== 0) {
125
+ this.console.warn('FFmpeg exited unexpectedly, restarting in 5 seconds...');
126
+ setTimeout(() => {
127
+ if (this.running) {
128
+ this.startFfmpegMjpeg(inputUrl, fps, quality, width);
129
+ }
130
+ }, 5000);
131
+ }
132
+ });
133
+
134
+ this.ffmpegProcess.on('error', (err: Error) => {
135
+ this.console.error('FFmpeg process error', err.message);
136
+ });
137
+ }
138
+
139
+ private async getFfmpegPath(): Promise<string> {
140
+ try {
141
+ const ffmpegDevice = systemManager.getDeviceByName('FFmpeg');
142
+ if (ffmpegDevice) {
143
+ const settings = await (ffmpegDevice as unknown as Settings).getSettings();
144
+ const pathSetting = settings.find(s => s.key === 'ffmpegPath');
145
+ if (pathSetting?.value) return pathSetting.value as string;
146
+ }
147
+ } catch { /* ignore */ }
148
+
149
+ return 'ffmpeg';
150
+ }
151
+
152
+ stop() {
153
+ this.running = false;
154
+
155
+ if (this.snapshotInterval) {
156
+ clearInterval(this.snapshotInterval);
157
+ this.snapshotInterval = null;
158
+ }
159
+
160
+ if (this.ffmpegProcess) {
161
+ this.console.log('Sending SIGTERM to FFmpeg process');
162
+ try {
163
+ this.ffmpegProcess.kill('SIGTERM');
164
+ } catch { /* ignore */ }
165
+ this.ffmpegProcess = null;
166
+ }
167
+ }
168
+
169
+ get isRunning(): boolean {
170
+ return this.running;
171
+ }
172
+ }
package/src/types.ts ADDED
@@ -0,0 +1,4 @@
1
+ export enum MjpegSourceEnum {
2
+ ScryptedSnapshot = 'Scrypted Snapshot (polling)',
3
+ ScryptedRtspFfmpeg = 'Scrypted RTSP via FFmpeg',
4
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+
2
+ {
3
+ "compilerOptions": {
4
+ "module": "Node16",
5
+ "target": "ES2021",
6
+ "resolveJsonModule": true,
7
+ "moduleResolution": "Node16",
8
+ "esModuleInterop": true,
9
+ "sourceMap": true
10
+ },
11
+ "include": [
12
+ "src/**/*"
13
+ ]
14
+ }
@@ -0,0 +1,144 @@
1
+ const path = require('path');
2
+ const webpack = require('webpack');
3
+ const TerserPlugin = require("terser-webpack-plugin");
4
+ const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
5
+ const fs = require('fs');
6
+
7
+ let out;
8
+ const cwd = process.cwd();
9
+
10
+ if (process.env.NODE_ENV == 'production') {
11
+ out = path.resolve(cwd, 'dist');
12
+ }
13
+ else {
14
+ out = path.resolve(cwd, 'out');
15
+ }
16
+
17
+ const isProduction = process.env.NODE_ENV == 'production';
18
+
19
+ function ensureAlias(name) {
20
+ const sanitizedName = name.replace(/@/g, '').replace(/\//g, '').replace(/-/g, '');
21
+ const sanitizedPath = path.join(__dirname, 'polyfill', sanitizedName + '.js');
22
+ const contents = `const ${sanitizedName} = __non_webpack_require__('${name}'); module.exports = ${sanitizedName};`
23
+ try {
24
+ if (fs.readFileSync(sanitizedPath).toString() !== contents)
25
+ throw new Error();
26
+ }
27
+ catch (e) {
28
+ fs.writeFileSync(sanitizedPath, contents);
29
+ }
30
+ return sanitizedPath;
31
+ }
32
+
33
+ const plugins = [
34
+ new webpack.DefinePlugin({
35
+ 'process.env.SSDP_COV': false,
36
+ }),
37
+ new webpack.optimize.LimitChunkCountPlugin({
38
+ maxChunks: 1,
39
+ }),
40
+ ];
41
+
42
+ if (process.env.WEBPACK_ANALYZER) {
43
+ plugins.push(
44
+ new BundleAnalyzerPlugin({
45
+ generateStatsFile: true,
46
+ }),
47
+ );
48
+ }
49
+
50
+ const alias = {};
51
+ const polyfills = [
52
+ 'node-pty-prebuilt-multiarch',
53
+ '@scrypted/node-pty',
54
+ 'node-forge',
55
+ 'sharp',
56
+ 'source-map-support/register',
57
+ 'adm-zip',
58
+ "memfs",
59
+ "realfs",
60
+ "fakefs",
61
+ "mdns",
62
+ "typescript",
63
+ ];
64
+
65
+ for (const p of polyfills) {
66
+ alias[p] = ensureAlias(p);
67
+ }
68
+
69
+ module.exports = {
70
+ mode: process.env.NODE_ENV || 'development',
71
+ output: {
72
+ devtoolModuleFilenameTemplate: function (info) {
73
+ return path.relative(out, info.absoluteResourcePath);
74
+ },
75
+
76
+ // export everything to a var "window" which will be an alias for "exports" in Scrypted
77
+ library: {
78
+ name: 'exports',
79
+ type: 'assign-properties',
80
+ },
81
+ },
82
+ module: {
83
+ rules: [
84
+ process.env.SCRYPTED_WEBPACK_BABEL ?
85
+ {
86
+ test: /\.(ts|js)x?$/,
87
+ exclude: /(core-js)/,
88
+ use: {
89
+ loader: 'babel-loader',
90
+ options: {
91
+ "presets": [
92
+ "@babel/preset-typescript",
93
+ ]
94
+ }
95
+ }
96
+ } :
97
+ {
98
+ test: /\.([cm]?ts|tsx)$/,
99
+ loader: "ts-loader",
100
+ },
101
+ {
102
+ test: /\.node$/,
103
+ loader: "node-loader",
104
+ },
105
+ ],
106
+ },
107
+
108
+ node: {
109
+ __dirname: true,
110
+ },
111
+ target: "node",
112
+
113
+ resolveLoader: {
114
+ modules: module.paths,
115
+
116
+ },
117
+ resolve: {
118
+ alias,
119
+ extensions: ['.tsx', '.ts', '.js']
120
+ },
121
+
122
+ stats: {
123
+ colors: true
124
+ },
125
+
126
+ plugins,
127
+
128
+ optimization: {
129
+ minimize: isProduction,
130
+ minimizer: [
131
+ new TerserPlugin(
132
+ {
133
+ terserOptions: {
134
+ compress: {
135
+ typeofs: false,
136
+ }
137
+ }
138
+ },
139
+ ),
140
+ ],
141
+ },
142
+
143
+ devtool: process.env.WEBPACK_DEVTOOL || 'source-map',
144
+ };