ffmpeg-camera: new ffmpeg camera plugin, refactor rtsp on top of it

This commit is contained in:
Koushik Dutta
2021-12-06 13:31:39 -08:00
parent 0f8c966385
commit 83ed7e899c
12 changed files with 811 additions and 258 deletions

4
plugins/ffmpeg-camera/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.DS_Store
out/
node_modules/
dist/

View File

@@ -0,0 +1,8 @@
.DS_Store
out/
node_modules/
*.map
fs
src
.vscode
dist/*.js

View File

@@ -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": [
"<node_internals>/**"
],
"preLaunchTask": "scrypted: deploy+debug",
"sourceMaps": true,
"localRoot": "${workspaceFolder}/out",
"remoteRoot": "/plugin/",
"type": "pwa-node"
}
]
}

View File

@@ -0,0 +1,4 @@
{
"scrypted.debugHost": "127.0.0.1",
}

View File

@@ -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}",
},
]
}

View File

@@ -0,0 +1,15 @@
# RTSP Cameras and Streams Plugin
## npm commands
* npm run scrypted-webpack
* npm run scrypted-deploy <ipaddress>
* npm run scrypted-debug <ipaddress>
## 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.

264
plugins/ffmpeg-camera/package-lock.json generated Normal file
View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}

View File

@@ -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<T extends MediaStreamOptions> extends ScryptedDeviceBase implements Camera, VideoCamera, Settings {
snapshotAuth: AxiosDigestAuth;
pendingPicture: Promise<MediaObject>;
constructor(nativeId: string, public provider: CameraProviderBase<T>) {
super(nativeId);
}
getSnapshotUrl() {
return this.storage.getItem('snapshotUrl');
}
async takePicture(option?: PictureOptions): Promise<MediaObject> {
if (!this.pendingPicture) {
this.pendingPicture = this.takePictureThrottled(option);
this.pendingPicture.finally(() => this.pendingPicture = undefined);
}
return this.pendingPicture;
}
async takePictureThrottled(option?: PictureOptions): Promise<MediaObject> {
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<PictureOptions[]> {
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<T[]> {
let vsos = this.getRawVideoStreamOptions();
return this.getDefaultOrderedVideoStreamOptions(vsos);
}
abstract getRawVideoStreamOptions(): T[];
isAudioDisabled() {
return this.storage.getItem('noAudio') === 'true';
}
async getVideoStream(options?: T): Promise<MediaObject> {
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<MediaObject>;
async getSnapshotUrlSettings(): Promise<Setting[]> {
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<Setting[]> {
return [
...await this.getSnapshotUrlSettings(),
];
}
getUsername() {
return this.storage.getItem('username');
}
getPassword() {
return this.storage.getItem('password');
}
async getOtherSettings(): Promise<Setting[]> {
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<Setting[]> {
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<Setting[]> {
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<T extends MediaStreamOptions> extends ScryptedDeviceBase implements DeviceProvider, DeviceCreator {
devices = new Map<string, any>();
constructor(nativeId?: string) {
super(nativeId);
for (const camId of deviceManager.getNativeIds()) {
if (camId)
this.getDevice(camId);
}
recommendRebroadcast();
}
async createDevice(settings: DeviceCreatorSettings): Promise<string> {
const nativeId = randomBytes(4).toString('hex');
const name = settings.newCamera.toString();
await this.updateDevice(nativeId, name, this.getInterfaces());
return nativeId;
}
async getCreateDeviceSettings(): Promise<Setting[]> {
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<T>;
getDevice(nativeId: string) {
let ret = this.devices.get(nativeId);
if (!ret) {
ret = this.createCamera(nativeId);
if (ret)
this.devices.set(nativeId, ret);
}
return ret;
}
}

View File

@@ -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<UrlMediaStreamOptions> {
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<Setting[]> {
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<Setting[]> {
return [
...await this.getSnapshotUrlSettings(),
...await this.getFFmpegInputSettings(),
];
}
async createVideoStream(options?: UrlMediaStreamOptions): Promise<MediaObject> {
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<UrlMediaStreamOptions> {
createCamera(nativeId: string): FFmpegCamera {
return new FFmpegCamera(nativeId, this);
}
}
export default new FFmpegProvider();

View File

@@ -0,0 +1,7 @@
import { alertRecommendedPlugins } from "@scrypted/common/src/alert-recommended-plugins";
export async function recommendRebroadcast() {
alertRecommendedPlugins({
'@scrypted/prebuffer-mixin': 'Rebroadcast',
});
}

View File

@@ -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<MediaObject>;
constructor(nativeId: string, public provider: RtspProvider) {
super(nativeId);
}
getSnapshotUrl() {
return this.storage.getItem('snapshotUrl');
}
async takePicture(option?: PictureOptions): Promise<MediaObject> {
if (!this.pendingPicture) {
this.pendingPicture = this.takeRtspSnapshot(option);
this.pendingPicture.finally(() => this.pendingPicture = undefined);
}
return this.pendingPicture;
}
async takeRtspSnapshot(option?: PictureOptions): Promise<MediaObject> {
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<PictureOptions[]> {
return;
}
export class RtspCamera extends CameraBase<UrlMediaStreamOptions> {
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<RtspMediaStreamOptions[]> {
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<MediaObject> {
const vsos = await this.getVideoStreamOptions();
const vso = vsos?.find(s => s.id === options?.id) || this.getDefaultStream(vsos);
async createVideoStream(vso: UrlMediaStreamOptions): Promise<MediaObject> {
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<Setting[]> {
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<Setting[]> {
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<Setting[]> {
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<Setting[]> {
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<Setting[]> {
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<Setting[]> {
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<RtspMediaStreamOptions[]>;
abstract getConstructedVideoStreamOptions(): Promise<UrlMediaStreamOptions[]>;
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<RtspMediaStreamOptions[]> {
async getVideoStreamOptions(): Promise<UrlMediaStreamOptions[]> {
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<string, any>();
constructor(nativeId?: string) {
super(nativeId);
for (const camId of deviceManager.getNativeIds()) {
if (camId)
this.getDevice(camId);
}
recommendRebroadcast();
}
async createDevice(settings: DeviceCreatorSettings): Promise<string> {
const nativeId = randomBytes(4).toString('hex');
const name = settings.newCamera.toString();
await this.updateDevice(nativeId, name, this.getInterfaces());
return nativeId;
}
async getCreateDeviceSettings(): Promise<Setting[]> {
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<UrlMediaStreamOptions> {
createCamera(nativeId: string): RtspCamera {
return new RtspCamera(nativeId, this);
}
}