From 83ed7e899cccd3ff214a7e9798ff756bf9ec8428 Mon Sep 17 00:00:00 2001 From: Koushik Dutta Date: Mon, 6 Dec 2021 13:31:39 -0800 Subject: [PATCH] ffmpeg-camera: new ffmpeg camera plugin, refactor rtsp on top of it --- plugins/ffmpeg-camera/.gitignore | 4 + plugins/ffmpeg-camera/.npmignore | 8 + plugins/ffmpeg-camera/.vscode/launch.json | 22 ++ plugins/ffmpeg-camera/.vscode/settings.json | 4 + plugins/ffmpeg-camera/.vscode/tasks.json | 20 ++ plugins/ffmpeg-camera/README.md | 15 + plugins/ffmpeg-camera/package-lock.json | 264 ++++++++++++++++++ plugins/ffmpeg-camera/package.json | 40 +++ plugins/ffmpeg-camera/src/common.ts | 292 ++++++++++++++++++++ plugins/ffmpeg-camera/src/main.ts | 109 ++++++++ plugins/ffmpeg-camera/src/recommend.ts | 7 + plugins/rtsp/src/rtsp.ts | 284 ++----------------- 12 files changed, 811 insertions(+), 258 deletions(-) create mode 100644 plugins/ffmpeg-camera/.gitignore create mode 100644 plugins/ffmpeg-camera/.npmignore create mode 100644 plugins/ffmpeg-camera/.vscode/launch.json create mode 100644 plugins/ffmpeg-camera/.vscode/settings.json create mode 100644 plugins/ffmpeg-camera/.vscode/tasks.json create mode 100644 plugins/ffmpeg-camera/README.md create mode 100644 plugins/ffmpeg-camera/package-lock.json create mode 100644 plugins/ffmpeg-camera/package.json create mode 100644 plugins/ffmpeg-camera/src/common.ts create mode 100644 plugins/ffmpeg-camera/src/main.ts create mode 100644 plugins/ffmpeg-camera/src/recommend.ts diff --git a/plugins/ffmpeg-camera/.gitignore b/plugins/ffmpeg-camera/.gitignore new file mode 100644 index 000000000..9cdb546bf --- /dev/null +++ b/plugins/ffmpeg-camera/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +out/ +node_modules/ +dist/ diff --git a/plugins/ffmpeg-camera/.npmignore b/plugins/ffmpeg-camera/.npmignore new file mode 100644 index 000000000..ff2824293 --- /dev/null +++ b/plugins/ffmpeg-camera/.npmignore @@ -0,0 +1,8 @@ +.DS_Store +out/ +node_modules/ +*.map +fs +src +.vscode +dist/*.js diff --git a/plugins/ffmpeg-camera/.vscode/launch.json b/plugins/ffmpeg-camera/.vscode/launch.json new file mode 100644 index 000000000..0669f79b4 --- /dev/null +++ b/plugins/ffmpeg-camera/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Scrypted Debugger", + "address": "${config:scrypted.debugHost}", + "port": 10081, + "request": "attach", + "skipFiles": [ + "/**" + ], + "preLaunchTask": "scrypted: deploy+debug", + "sourceMaps": true, + "localRoot": "${workspaceFolder}/out", + "remoteRoot": "/plugin/", + "type": "pwa-node" + } + ] +} \ No newline at end of file diff --git a/plugins/ffmpeg-camera/.vscode/settings.json b/plugins/ffmpeg-camera/.vscode/settings.json new file mode 100644 index 000000000..77ccdbd6d --- /dev/null +++ b/plugins/ffmpeg-camera/.vscode/settings.json @@ -0,0 +1,4 @@ + +{ + "scrypted.debugHost": "127.0.0.1", +} \ No newline at end of file diff --git a/plugins/ffmpeg-camera/.vscode/tasks.json b/plugins/ffmpeg-camera/.vscode/tasks.json new file mode 100644 index 000000000..4d922a539 --- /dev/null +++ b/plugins/ffmpeg-camera/.vscode/tasks.json @@ -0,0 +1,20 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "label": "scrypted: deploy+debug", + "type": "shell", + "presentation": { + "echo": true, + "reveal": "silent", + "focus": false, + "panel": "shared", + "showReuseMessage": true, + "clear": false + }, + "command": "npm run scrypted-vscode-launch ${config:scrypted.debugHost}", + }, + ] +} diff --git a/plugins/ffmpeg-camera/README.md b/plugins/ffmpeg-camera/README.md new file mode 100644 index 000000000..688d48314 --- /dev/null +++ b/plugins/ffmpeg-camera/README.md @@ -0,0 +1,15 @@ +# RTSP Cameras and Streams Plugin + +## npm commands + * npm run scrypted-webpack + * npm run scrypted-deploy + * npm run scrypted-debug + +## scrypted distribution via npm + 1. Ensure package.json is set up properly for publishing on npm. + 2. npm publish + +## Visual Studio Code configuration + +* If using a remote server, edit [.vscode/settings.json](blob/master/.vscode/settings.json) to specify the IP Address of the Scrypted server. +* Launch Scrypted Debugger from the launch menu. diff --git a/plugins/ffmpeg-camera/package-lock.json b/plugins/ffmpeg-camera/package-lock.json new file mode 100644 index 000000000..ed1b87784 --- /dev/null +++ b/plugins/ffmpeg-camera/package-lock.json @@ -0,0 +1,264 @@ +{ + "name": "@scrypted/rtsp", + "version": "0.0.3", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "@scrypted/rtsp", + "version": "0.0.3", + "license": "Apache", + "dependencies": { + "@koush/axios-digest-auth": "^0.8.5", + "axios": "^0.23.0", + "url-parse": "^1.4.7" + }, + "devDependencies": { + "@scrypted/common": "file:../../common", + "@scrypted/sdk": "file:../../sdk", + "@types/node": "^16.9.6" + } + }, + "../../common": { + "name": "@scrypted/common", + "version": "1.0.1", + "dev": true, + "license": "ISC", + "dependencies": { + "@scrypted/sdk": "file:../sdk", + "typescript": "^4.4.3" + }, + "devDependencies": { + "@types/node": "^16.9.0" + } + }, + "../../sdk": { + "name": "@scrypted/sdk", + "version": "0.0.123", + "dev": true, + "license": "ISC", + "dependencies": { + "@babel/plugin-proposal-class-properties": "^7.14.5", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.14.5", + "@babel/plugin-proposal-numeric-separator": "^7.14.5", + "@babel/plugin-proposal-optional-chaining": "^7.14.5", + "@babel/plugin-transform-modules-commonjs": "^7.15.4", + "@babel/plugin-transform-typescript": "^7.15.8", + "@babel/preset-typescript": "^7.15.0", + "@types/node": "^16.11.1", + "adm-zip": "^0.4.13", + "axios": "^0.21.4", + "babel-loader": "^8.2.3", + "babel-plugin-const-enum": "^1.1.0", + "esbuild": "^0.13.8", + "ncp": "^2.0.0", + "raw-loader": "^4.0.2", + "rimraf": "^3.0.2", + "stringify-object": "^3.3.0", + "tmp": "^0.2.1", + "ts-loader": "^9.2.6", + "typedoc": "^0.22.8", + "typescript-json-schema": "^0.50.1", + "webpack": "^5.59.0" + }, + "bin": { + "scrypted-debug": "bin/scrypted-debug.js", + "scrypted-deploy": "bin/scrypted-deploy.js", + "scrypted-deploy-debug": "bin/scrypted-deploy-debug.js", + "scrypted-package-json": "bin/scrypted-package-json.js", + "scrypted-readme": "bin/scrypted-readme.js", + "scrypted-webpack": "bin/scrypted-webpack.js" + }, + "devDependencies": { + "@types/stringify-object": "^4.0.0", + "ts-node": "^10.4.0" + } + }, + "../sdk": { + "extraneous": true + }, + "node_modules/@koush/axios-digest-auth": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@koush/axios-digest-auth/-/axios-digest-auth-0.8.5.tgz", + "integrity": "sha512-EZMM0gMJ3hMUD4EuUqSwP6UGt5Vmw2TZtY7Ypec55AnxkExSXM0ySgPtqkAcnL43g1R27yAg/dQL7dRTLMqO3Q==", + "dependencies": { + "auth-header": "^1.0.0", + "axios": "^0.21.4" + } + }, + "node_modules/@koush/axios-digest-auth/node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, + "node_modules/@scrypted/common": { + "resolved": "../../common", + "link": true + }, + "node_modules/@scrypted/sdk": { + "resolved": "../../sdk", + "link": true + }, + "node_modules/@types/node": { + "version": "16.9.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.6.tgz", + "integrity": "sha512-YHUZhBOMTM3mjFkXVcK+WwAcYmyhe1wL4lfqNtzI0b3qAy7yuSetnM7QJazgE5PFmgVTNGiLOgRFfJMqW7XpSQ==", + "dev": true + }, + "node_modules/auth-header": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/auth-header/-/auth-header-1.0.0.tgz", + "integrity": "sha512-CPPazq09YVDUNNVWo4oSPTQmtwIzHusZhQmahCKvIsk0/xH6U3QsMAv3sM+7+Q0B1K2KJ/Q38OND317uXs4NHA==" + }, + "node_modules/axios": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.23.0.tgz", + "integrity": "sha512-NmvAE4i0YAv5cKq8zlDoPd1VLKAqX5oLuZKs8xkJa4qi6RGn0uhCYFjWtHHC9EM/MwOwYWOs53W+V0aqEXq1sg==", + "dependencies": { + "follow-redirects": "^1.14.4" + } + }, + "node_modules/follow-redirects": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz", + "integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/querystringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.1.1.tgz", + "integrity": "sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA==" + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" + }, + "node_modules/url-parse": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz", + "integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + } + }, + "dependencies": { + "@koush/axios-digest-auth": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@koush/axios-digest-auth/-/axios-digest-auth-0.8.5.tgz", + "integrity": "sha512-EZMM0gMJ3hMUD4EuUqSwP6UGt5Vmw2TZtY7Ypec55AnxkExSXM0ySgPtqkAcnL43g1R27yAg/dQL7dRTLMqO3Q==", + "requires": { + "auth-header": "^1.0.0", + "axios": "^0.21.4" + }, + "dependencies": { + "axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "requires": { + "follow-redirects": "^1.14.0" + } + } + } + }, + "@scrypted/common": { + "version": "file:../../common", + "requires": { + "@scrypted/sdk": "file:../sdk", + "@types/node": "^16.9.0", + "typescript": "^4.4.3" + } + }, + "@scrypted/sdk": { + "version": "file:../../sdk", + "requires": { + "@babel/plugin-proposal-class-properties": "^7.14.5", + "@babel/plugin-proposal-nullish-coalescing-operator": "^7.14.5", + "@babel/plugin-proposal-numeric-separator": "^7.14.5", + "@babel/plugin-proposal-optional-chaining": "^7.14.5", + "@babel/plugin-transform-modules-commonjs": "^7.15.4", + "@babel/plugin-transform-typescript": "^7.15.8", + "@babel/preset-typescript": "^7.15.0", + "@types/node": "^16.11.1", + "@types/stringify-object": "^4.0.0", + "adm-zip": "^0.4.13", + "axios": "^0.21.4", + "babel-loader": "^8.2.3", + "babel-plugin-const-enum": "^1.1.0", + "esbuild": "^0.13.8", + "ncp": "^2.0.0", + "raw-loader": "^4.0.2", + "rimraf": "^3.0.2", + "stringify-object": "^3.3.0", + "tmp": "^0.2.1", + "ts-loader": "^9.2.6", + "ts-node": "^10.4.0", + "typedoc": "^0.22.8", + "typescript-json-schema": "^0.50.1", + "webpack": "^5.59.0" + } + }, + "@types/node": { + "version": "16.9.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.9.6.tgz", + "integrity": "sha512-YHUZhBOMTM3mjFkXVcK+WwAcYmyhe1wL4lfqNtzI0b3qAy7yuSetnM7QJazgE5PFmgVTNGiLOgRFfJMqW7XpSQ==", + "dev": true + }, + "auth-header": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/auth-header/-/auth-header-1.0.0.tgz", + "integrity": "sha512-CPPazq09YVDUNNVWo4oSPTQmtwIzHusZhQmahCKvIsk0/xH6U3QsMAv3sM+7+Q0B1K2KJ/Q38OND317uXs4NHA==" + }, + "axios": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.23.0.tgz", + "integrity": "sha512-NmvAE4i0YAv5cKq8zlDoPd1VLKAqX5oLuZKs8xkJa4qi6RGn0uhCYFjWtHHC9EM/MwOwYWOs53W+V0aqEXq1sg==", + "requires": { + "follow-redirects": "^1.14.4" + } + }, + "follow-redirects": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.14.4.tgz", + "integrity": "sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==" + }, + "querystringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.1.1.tgz", + "integrity": "sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA==" + }, + "requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" + }, + "url-parse": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.3.tgz", + "integrity": "sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==", + "requires": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + } + } +} diff --git a/plugins/ffmpeg-camera/package.json b/plugins/ffmpeg-camera/package.json new file mode 100644 index 000000000..5f74facbb --- /dev/null +++ b/plugins/ffmpeg-camera/package.json @@ -0,0 +1,40 @@ +{ + "name": "@scrypted/ffmpeg-camera", + "version": "0.0.3", + "description": "FFmpeg Camera Plugin for Scrypted", + "author": "Scrypted", + "license": "Apache", + "scripts": { + "prepublishOnly": "NODE_ENV=production scrypted-webpack", + "prescrypted-vscode-launch": "scrypted-webpack", + "scrypted-vscode-launch": "scrypted-deploy-debug", + "scrypted-deploy-debug": "scrypted-deploy-debug", + "scrypted-debug": "scrypted-debug", + "scrypted-deploy": "scrypted-deploy", + "scrypted-readme": "scrypted-readme", + "scrypted-package-json": "scrypted-package-json", + "scrypted-webpack": "scrypted-webpack" + }, + "keywords": [ + "scrypted", + "plugin", + "ffmpeg", + "camera" + ], + "scrypted": { + "name": "FFmpeg Camera Plugin", + "type": "DeviceProvider", + "interfaces": [ + "DeviceProvider", + "DeviceCreator" + ] + }, + "devDependencies": { + "@scrypted/common": "file:../../common", + "@scrypted/sdk": "file:../../sdk", + "@types/node": "^16.9.6" + }, + "dependencies": { + "url-parse": "^1.4.7" + } +} diff --git a/plugins/ffmpeg-camera/src/common.ts b/plugins/ffmpeg-camera/src/common.ts new file mode 100644 index 000000000..e98689fc0 --- /dev/null +++ b/plugins/ffmpeg-camera/src/common.ts @@ -0,0 +1,292 @@ +import sdk, { ScryptedDeviceBase, DeviceProvider, Settings, Setting, ScryptedDeviceType, VideoCamera, MediaObject, MediaStreamOptions, ScryptedInterface, FFMpegInput, Camera, PictureOptions, SettingValue, DeviceCreator, DeviceCreatorSettings } from "@scrypted/sdk"; +import { recommendRebroadcast } from "./recommend"; +import AxiosDigestAuth from '@koush/axios-digest-auth'; +import https from 'https'; +import { randomBytes } from "crypto"; + +const { log, deviceManager, mediaManager } = sdk; + +const httpsAgent = new https.Agent({ + rejectUnauthorized: false +}); + +export interface UrlMediaStreamOptions extends MediaStreamOptions { + url: string; +} + +export abstract class CameraBase extends ScryptedDeviceBase implements Camera, VideoCamera, Settings { + snapshotAuth: AxiosDigestAuth; + pendingPicture: Promise; + + constructor(nativeId: string, public provider: CameraProviderBase) { + super(nativeId); + } + + getSnapshotUrl() { + return this.storage.getItem('snapshotUrl'); + } + + async takePicture(option?: PictureOptions): Promise { + if (!this.pendingPicture) { + this.pendingPicture = this.takePictureThrottled(option); + this.pendingPicture.finally(() => this.pendingPicture = undefined); + } + + return this.pendingPicture; + } + + async takePictureThrottled(option?: PictureOptions): Promise { + const snapshotUrl = this.getSnapshotUrl(); + if (!snapshotUrl) { + throw new Error('Camera has no snapshot URL'); + } + + if (!this.snapshotAuth) { + this.snapshotAuth = new AxiosDigestAuth({ + username: this.getUsername(), + password: this.getPassword(), + }); + } + + const response = await this.snapshotAuth.request({ + httpsAgent, + method: "GET", + responseType: 'arraybuffer', + url: snapshotUrl, + }); + + return mediaManager.createMediaObject(Buffer.from(response.data), response.headers['Content-Type'] || 'image/jpeg'); + } + + async getPictureOptions(): Promise { + return; + } + + getDefaultOrderedVideoStreamOptions(vsos: T[]) { + if (!vsos || !vsos.length) + return vsos; + const defaultStream = this.getDefaultStream(vsos); + if (!defaultStream) + return vsos; + vsos = vsos.filter(vso => vso.id !== defaultStream?.id); + vsos.unshift(defaultStream); + return vsos; + } + + async getVideoStreamOptions(): Promise { + let vsos = this.getRawVideoStreamOptions(); + return this.getDefaultOrderedVideoStreamOptions(vsos); + } + + abstract getRawVideoStreamOptions(): T[]; + + isAudioDisabled() { + return this.storage.getItem('noAudio') === 'true'; + } + + async getVideoStream(options?: T): Promise { + const vsos = await this.getVideoStreamOptions(); + const vso = vsos?.find(s => s.id === options?.id) || this.getDefaultStream(vsos); + return this.createVideoStream(vso); + } + + abstract createVideoStream(options?: T): Promise; + + async getSnapshotUrlSettings(): Promise { + return [ + { + key: 'snapshotUrl', + title: 'Snapshot URL', + placeholder: 'http://192.168.1.100[:80]/snapshot.jpg', + value: this.getSnapshotUrl(), + description: 'Optional: The snapshot URL that will returns the current JPEG image.' + }, + ]; + } + + async getUrlSettings(): Promise { + return [ + ...await this.getSnapshotUrlSettings(), + ]; + } + + getUsername() { + return this.storage.getItem('username'); + } + + getPassword() { + return this.storage.getItem('password'); + } + + async getOtherSettings(): Promise { + return []; + } + + getDefaultStream(vsos: T[]) { + let defaultStreamIndex = vsos?.findIndex(vso => vso.id === this.storage.getItem('defaultStream')); + if (defaultStreamIndex === -1) + defaultStreamIndex = 0; + + defaultStreamIndex = defaultStreamIndex || 0; + return vsos?.[defaultStreamIndex]; + } + + async getStreamSettings(): Promise { + try { + const vsos = await this.getVideoStreamOptions(); + if (!vsos?.length || vsos?.length === 1) + return []; + + + const defaultStream = this.getDefaultStream(vsos); + return [ + { + title: 'Default Stream', + key: 'defaultStream', + value: defaultStream?.name, + choices: vsos.map(vso => vso.name), + description: 'The default stream to use when not specified', + } + ]; + } + catch (e) { + return []; + } + } + + getUsernameDescription(): string { + return 'Optional: Username for snapshot http requests.'; + } + + getPasswordDescription(): string { + return 'Optional: Password for snapshot http requests.'; + } + + async getSettings(): Promise { + return [ + { + key: 'username', + title: 'Username', + value: this.getUsername(), + description: this.getUsernameDescription(), + }, + { + key: 'password', + title: 'Password', + value: this.getPassword(), + type: 'password', + description: this.getPasswordDescription(), + }, + ...await this.getUrlSettings(), + ...await this.getStreamSettings(), + ...await this.getOtherSettings(), + { + key: 'noAudio', + title: 'No Audio', + description: 'Enable this setting if the camera does not have audio or to mute audio.', + type: 'boolean', + value: (this.isAudioDisabled()).toString(), + }, + ]; + } + + async putSettingBase(key: string, value: SettingValue) { + if (key === 'defaultStream') { + const vsos = await this.getVideoStreamOptions(); + const stream = vsos.find(vso => vso.name === value); + this.storage.setItem('defaultStream', stream?.id); + } + else { + this.storage.setItem(key, value.toString()); + } + + this.snapshotAuth = undefined; + + this.onDeviceEvent(ScryptedInterface.Settings, undefined); + } + + async putSetting(key: string, value: SettingValue) { + this.putSettingBase(key, value); + + if (key === 'snapshotUrl') { + let interfaces = this.providedInterfaces; + if (!value) + interfaces = interfaces.filter(iface => iface !== ScryptedInterface.Camera) + else + interfaces.push(ScryptedInterface.Camera); + + this.provider.updateDevice(this.nativeId, this.providedName, interfaces); + } + } +} + +export abstract class CameraProviderBase extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator { + devices = new Map(); + + constructor(nativeId?: string) { + super(nativeId); + + for (const camId of deviceManager.getNativeIds()) { + if (camId) + this.getDevice(camId); + } + + recommendRebroadcast(); + } + + async createDevice(settings: DeviceCreatorSettings): Promise { + const nativeId = randomBytes(4).toString('hex'); + const name = settings.newCamera.toString(); + await this.updateDevice(nativeId, name, this.getInterfaces()); + return nativeId; + } + + async getCreateDeviceSettings(): Promise { + return [ + { + key: 'newCamera', + title: 'Add Camera', + placeholder: 'Camera name, e.g.: Back Yard Camera, Baby Camera, etc', + } + ] + } + + getAdditionalInterfaces(): string[] { + return [ + ]; + } + + getInterfaces() { + return [ScryptedInterface.VideoCamera, + ScryptedInterface.Settings, ...this.getAdditionalInterfaces()]; + } + + updateDevice(nativeId: string, name: string, interfaces: string[], type?: ScryptedDeviceType) { + return deviceManager.onDeviceDiscovered({ + nativeId, + name, + interfaces, + type: type || ScryptedDeviceType.Camera, + }); + } + + async putSetting(key: string, value: string | number) { + // generate a random id + const nativeId = randomBytes(4).toString('hex'); + const name = value.toString(); + + this.updateDevice(nativeId, name, this.getInterfaces()); + } + + abstract createCamera(nativeId: string): CameraBase; + + getDevice(nativeId: string) { + let ret = this.devices.get(nativeId); + if (!ret) { + ret = this.createCamera(nativeId); + if (ret) + this.devices.set(nativeId, ret); + } + return ret; + } +} diff --git a/plugins/ffmpeg-camera/src/main.ts b/plugins/ffmpeg-camera/src/main.ts new file mode 100644 index 000000000..a797da4b9 --- /dev/null +++ b/plugins/ffmpeg-camera/src/main.ts @@ -0,0 +1,109 @@ +import sdk, { FFMpegInput, MediaObject, MediaStreamOptions, Setting, SettingValue } from "@scrypted/sdk"; +import { CameraProviderBase, CameraBase, UrlMediaStreamOptions } from "./common"; + +const { log, deviceManager, mediaManager } = sdk; + +class FFmpegCamera extends CameraBase { + createFFmpegMediaStreamOptions(ffmpegInput: string, index: number) { + // this might be usable as a url so check that. + let url: string; + try { + const parsedUrl = new URL(ffmpegInput); + } + catch (e) { + } + + return { + id: `channel${index}`, + name: `Stream ${index + 1}`, + url, + video: { + }, + audio: this.isAudioDisabled() ? null : {}, + }; + } + + getFFmpegInputs() { + let ffmpegInputs: string[] = []; + try { + ffmpegInputs = JSON.parse(this.storage.getItem('ffmpegInputs')); + } + catch (e) { + } + + return ffmpegInputs; + } + + getRawVideoStreamOptions(): UrlMediaStreamOptions[] { + const ffmpegInputs = this.getFFmpegInputs(); + + // filter out empty strings. + const ret = ffmpegInputs + .filter(ffmpegInput => !!ffmpegInput) + .map((ffmpegInput, index) => this.createFFmpegMediaStreamOptions(ffmpegInput, index)); + + if (!ret.length) + return; + return ret; + + } + + async getFFmpegInputSettings(): Promise { + return [ + { + key: 'ffmpegInputs', + title: 'FFmpeg Input Stream Arguments', + description: 'FFmpeg input arguments passed to the command line ffmpeg tool. A camera may have multiple streams with different bitrates.', + placeholder: '-i rtmp://[user:password@]192.168.1.100[:1935]/channel/101', + value: this.getFFmpegInputs(), + multiple: true, + }, + ]; + } + + async putSettingBase(key: string, value: SettingValue) { + if (key === 'ffmpegInputs') { + this.putFFmpegInputs(value as string[]); + } + else { + super.putSettingBase(key, value); + } + } + + async putFFmpegInputs(urls: string[]) { + this.storage.setItem('ffmpegInputs', JSON.stringify(urls.filter(url => !!url))); + } + + async getUrlSettings(): Promise { + return [ + ...await this.getSnapshotUrlSettings(), + ...await this.getFFmpegInputSettings(), + ]; + } + + async createVideoStream(options?: UrlMediaStreamOptions): Promise { + const index = this.getRawVideoStreamOptions()?.findIndex(vso => vso.id === options.id); + const ffmpegInputs = this.getFFmpegInputs(); + const ffmpegInput = ffmpegInputs[index]; + + if (!ffmpegInput) + throw new Error('video streams not set up or no longer exists.'); + + const ret: FFMpegInput = { + url: options.url, + inputArguments: ffmpegInput.split(' '), + mediaStreamOptions: options, + }; + + return mediaManager.createFFmpegMediaObject(ret); + } + +} + +class FFmpegProvider extends CameraProviderBase { + createCamera(nativeId: string): FFmpegCamera { + return new FFmpegCamera(nativeId, this); + } +} + +export default new FFmpegProvider(); diff --git a/plugins/ffmpeg-camera/src/recommend.ts b/plugins/ffmpeg-camera/src/recommend.ts new file mode 100644 index 000000000..07110d5c8 --- /dev/null +++ b/plugins/ffmpeg-camera/src/recommend.ts @@ -0,0 +1,7 @@ +import { alertRecommendedPlugins } from "@scrypted/common/src/alert-recommended-plugins"; + +export async function recommendRebroadcast() { + alertRecommendedPlugins({ + '@scrypted/prebuffer-mixin': 'Rebroadcast', + }); +} diff --git a/plugins/rtsp/src/rtsp.ts b/plugins/rtsp/src/rtsp.ts index 0c7a22fd1..bff8f057c 100644 --- a/plugins/rtsp/src/rtsp.ts +++ b/plugins/rtsp/src/rtsp.ts @@ -4,6 +4,7 @@ import { recommendRebroadcast } from "./recommend"; import AxiosDigestAuth from '@koush/axios-digest-auth'; import https from 'https'; import { randomBytes } from "crypto"; +import { CameraProviderBase, CameraBase, UrlMediaStreamOptions } from "../../ffmpeg-camera/src/common"; const { log, deviceManager, mediaManager } = sdk; @@ -11,58 +12,7 @@ const httpsAgent = new https.Agent({ rejectUnauthorized: false }); -export interface RtspMediaStreamOptions extends MediaStreamOptions { - url: string; -} - -export class RtspCamera extends ScryptedDeviceBase implements Camera, VideoCamera, Settings { - snapshotAuth: AxiosDigestAuth; - pendingPicture: Promise; - - constructor(nativeId: string, public provider: RtspProvider) { - super(nativeId); - } - - getSnapshotUrl() { - return this.storage.getItem('snapshotUrl'); - } - - async takePicture(option?: PictureOptions): Promise { - if (!this.pendingPicture) { - this.pendingPicture = this.takeRtspSnapshot(option); - this.pendingPicture.finally(() => this.pendingPicture = undefined); - } - - return this.pendingPicture; - } - - async takeRtspSnapshot(option?: PictureOptions): Promise { - const snapshotUrl = this.getSnapshotUrl(); - if (!snapshotUrl) { - throw new Error('RTSP Camera has no snapshot URL'); - } - - if (!this.snapshotAuth) { - this.snapshotAuth = new AxiosDigestAuth({ - username: this.getUsername(), - password: this.getPassword(), - }); - } - - const response = await this.snapshotAuth.request({ - httpsAgent, - method: "GET", - responseType: 'arraybuffer', - url: snapshotUrl, - }); - - return mediaManager.createMediaObject(Buffer.from(response.data), response.headers['Content-Type'] || 'image/jpeg'); - } - - async getPictureOptions(): Promise { - return; - } - +export class RtspCamera extends CameraBase { createRtspMediaStreamOptions(url: string, index: number) { return { id: `channel${index}`, @@ -74,23 +24,7 @@ export class RtspCamera extends ScryptedDeviceBase implements Camera, VideoCamer }; } - getDefaultOrderedVideoStreamOptions(vsos: RtspMediaStreamOptions[]) { - if (!vsos || !vsos.length) - return vsos; - const defaultStream = this.getDefaultStream(vsos); - if (!defaultStream) - return vsos; - vsos = vsos.filter(vso => vso.id !== defaultStream?.id); - vsos.unshift(defaultStream); - return vsos; - } - - async getVideoStreamOptions(): Promise { - let vsos = this.getRtspVideoStreamOptions(); - return this.getDefaultOrderedVideoStreamOptions(vsos); - } - - getRtspVideoStreamOptions(): RtspMediaStreamOptions[] { + getRawVideoStreamOptions(): UrlMediaStreamOptions[] { let urls: string[] = []; try { urls = JSON.parse(this.storage.getItem('urls')); @@ -112,13 +46,9 @@ export class RtspCamera extends ScryptedDeviceBase implements Camera, VideoCamer return ret; } - isAudioDisabled() { - return this.storage.getItem('noAudio') === 'true'; - } - - async getVideoStream(options?: MediaStreamOptions): Promise { - const vsos = await this.getVideoStreamOptions(); - const vso = vsos?.find(s => s.id === options?.id) || this.getDefaultStream(vsos); + async createVideoStream(vso: UrlMediaStreamOptions): Promise { + if (!vso) + throw new Error('video streams not set up or no longer exists.'); const url = new URL(vso.url); this.console.log('rtsp stream url', url.toString()); @@ -149,6 +79,16 @@ export class RtspCamera extends ScryptedDeviceBase implements Camera, VideoCamer return mediaManager.createFFmpegMediaObject(ret); } + // hide the description from CameraBase that indicates it is only used for snapshots + getUsernameDescription(): string { + return; + } + + // hide the description from CameraBase that indicates it is only used for snapshots + getPasswordDescription(): string { + return; + } + async getRtspUrlSettings(): Promise { return [ { @@ -156,24 +96,12 @@ export class RtspCamera extends ScryptedDeviceBase implements Camera, VideoCamer title: 'RTSP Stream URL', description: 'An RTSP Stream URL provided by the camera.', placeholder: 'rtsp://192.168.1.100[:554]/channel/101', - value: this.getRtspVideoStreamOptions()?.map(vso => vso.url), + value: this.getRawVideoStreamOptions()?.map(vso => vso.url), multiple: true, }, ]; } - async getSnapshotUrlSettings(): Promise { - return [ - { - key: 'snapshotUrl', - title: 'Snapshot URL', - placeholder: 'http://192.168.1.100[:80]/snapshot.jpg', - value: this.getSnapshotUrl(), - description: 'Optional: The snapshot URL that will returns the current JPEG image.' - }, - ]; - } - async getUrlSettings(): Promise { return [ ...await this.getSnapshotUrlSettings(), @@ -181,76 +109,6 @@ export class RtspCamera extends ScryptedDeviceBase implements Camera, VideoCamer ]; } - getUsername() { - return this.storage.getItem('username'); - } - - getPassword() { - return this.storage.getItem('password'); - } - - async getOtherSettings(): Promise { - return []; - } - - getDefaultStream(vsos: RtspMediaStreamOptions[]) { - let defaultStreamIndex = vsos.findIndex(vso => vso.id === this.storage.getItem('defaultStream')); - if (defaultStreamIndex === -1) - defaultStreamIndex = 0; - - return vsos[defaultStreamIndex]; - } - - - async getStreamSettings(): Promise { - try { - const vsos = await this.getVideoStreamOptions(); - if (!vsos?.length || vsos?.length === 1) - return []; - - - const defaultStream = this.getDefaultStream(vsos); - return [ - { - title: 'Default Stream', - key: 'defaultStream', - value: defaultStream?.name, - choices: vsos.map(vso => vso.name), - description: 'The default stream to use when not specified', - } - ]; - } - catch (e) { - return []; - } - } - - async getSettings(): Promise { - return [ - { - key: 'username', - title: 'Username', - value: this.getUsername(), - }, - { - key: 'password', - title: 'Password', - value: this.getPassword(), - type: 'password', - }, - ...await this.getUrlSettings(), - ...await this.getStreamSettings(), - ...await this.getOtherSettings(), - { - key: 'noAudio', - title: 'No Audio', - description: 'Enable this setting if the camera does not have audio or to mute audio.', - type: 'boolean', - value: (this.isAudioDisabled()).toString(), - }, - ]; - } - async putRtspUrls(urls: string[]) { this.storage.setItem('urls', JSON.stringify(urls.filter(url => !!url))); } @@ -259,31 +117,8 @@ export class RtspCamera extends ScryptedDeviceBase implements Camera, VideoCamer if (key === 'urls') { this.putRtspUrls(value as string[]); } - else if (key === 'defaultStream') { - const vsos = await this.getVideoStreamOptions(); - const stream = vsos.find(vso => vso.name === value); - this.storage.setItem('defaultStream', stream?.id); - } else { - this.storage.setItem(key, value.toString()); - } - - this.snapshotAuth = undefined; - - this.onDeviceEvent(ScryptedInterface.Settings, undefined); - } - - async putSetting(key: string, value: SettingValue) { - this.putSettingBase(key, value); - - if (key === 'snapshotUrl') { - let interfaces = this.providedInterfaces; - if (!value) - interfaces = interfaces.filter(iface => iface !== ScryptedInterface.Camera) - else - interfaces.push(ScryptedInterface.Camera); - - this.provider.updateDevice(this.nativeId, this.providedName, interfaces); + super.putSettingBase(key, value); } } } @@ -329,9 +164,9 @@ export abstract class RtspSmartCamera extends RtspCamera { this.listener.emit('error', new Error("new settings")); } - async takeRtspSnapshot(option?: PictureOptions) { + async takePictureThrottled(option?: PictureOptions) { if (this.showSnapshotUrlOverride() && this.getSnapshotUrl()) { - return super.takeRtspSnapshot(option); + return super.takePictureThrottled(option); } return this.takeSmartCameraPicture(option);; @@ -358,7 +193,7 @@ export abstract class RtspSmartCamera extends RtspCamera { title: 'RTSP Stream URL Override', description: 'Override the RTSP Stream URL provided by the camera.', placeholder: 'rtsp://192.168.1.100[:554]/channel/101', - value: this.getRtspVideoStreamOptions()?.map(vso => vso.url), + value: this.getRawVideoStreamOptions()?.map(vso => vso.url), multiple: true, }, ]; @@ -455,7 +290,7 @@ export abstract class RtspSmartCamera extends RtspCamera { return this.storage.getItem('rtspUrlOverride'); } - abstract getConstructedVideoStreamOptions(): Promise; + abstract getConstructedVideoStreamOptions(): Promise; abstract listenEvents(): EventEmitter & Destroyable; getIPAddress() { @@ -470,7 +305,7 @@ export abstract class RtspSmartCamera extends RtspCamera { return `${this.getIPAddress()}:${this.storage.getItem('rtspPort') || 554}`; } - async getVideoStreamOptions(): Promise { + async getVideoStreamOptions(): Promise { if (this.showRtspUrlOverride()) { const vso = await super.getVideoStreamOptions(); if (vso) @@ -482,75 +317,8 @@ export abstract class RtspSmartCamera extends RtspCamera { } } -export class RtspProvider extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator { - devices = new Map(); - - constructor(nativeId?: string) { - super(nativeId); - - for (const camId of deviceManager.getNativeIds()) { - if (camId) - this.getDevice(camId); - } - - recommendRebroadcast(); - } - - async createDevice(settings: DeviceCreatorSettings): Promise { - const nativeId = randomBytes(4).toString('hex'); - const name = settings.newCamera.toString(); - await this.updateDevice(nativeId, name, this.getInterfaces()); - return nativeId; - } - - async getCreateDeviceSettings(): Promise { - return [ - { - key: 'newCamera', - title: 'Add Camera', - placeholder: 'Camera name, e.g.: Back Yard Camera, Baby Camera, etc', - } - ] - } - - getAdditionalInterfaces(): string[] { - return [ - ]; - } - - getInterfaces() { - return [ScryptedInterface.VideoCamera, - ScryptedInterface.Settings, ...this.getAdditionalInterfaces()]; - } - - updateDevice(nativeId: string, name: string, interfaces: string[], type?: ScryptedDeviceType) { - return deviceManager.onDeviceDiscovered({ - nativeId, - name, - interfaces, - type: type || ScryptedDeviceType.Camera, - }); - } - - async putSetting(key: string, value: string | number) { - // generate a random id - const nativeId = randomBytes(4).toString('hex'); - const name = value.toString(); - - this.updateDevice(nativeId, name, this.getInterfaces()); - } - - createCamera(nativeId: string, provider: RtspProvider): RtspCamera { - return new RtspCamera(nativeId, provider); - } - - getDevice(nativeId: string) { - let ret = this.devices.get(nativeId); - if (!ret) { - ret = this.createCamera(nativeId, this); - if (ret) - this.devices.set(nativeId, ret); - } - return ret; +export class RtspProvider extends CameraProviderBase { + createCamera(nativeId: string): RtspCamera { + return new RtspCamera(nativeId, this); } }