Compare commits

..

3 Commits

Author SHA1 Message Date
Koushik Dutta
7614d12363 prepublish 2023-02-25 14:25:24 -08:00
Koushik Dutta
3189317b2d server: additional admin login traps for HA ingress 2023-02-25 14:25:16 -08:00
Koushik Dutta
410d11248f snapshot: improve failure resiliency with prebuffer fallback and ignoring ffpmeg errors when buffers are returned 2023-02-25 13:41:41 -08:00
7 changed files with 194 additions and 151 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/snapshot",
"version": "0.0.43",
"version": "0.0.45",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/snapshot",
"version": "0.0.43",
"version": "0.0.45",
"dependencies": {
"@koush/axios-digest-auth": "^0.8.5",
"@types/node": "^16.6.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/snapshot",
"version": "0.0.43",
"version": "0.0.45",
"description": "Snapshot Plugin for Scrypted",
"scripts": {
"scrypted-setup-project": "scrypted-setup-project",
@@ -22,6 +22,7 @@
"camera"
],
"scrypted": {
"realfs": true,
"name": "Snapshot Plugin",
"type": "API",
"interfaces": [

View File

@@ -1,3 +1,4 @@
import fs from 'fs';
import { addVideoFilterArguments } from '@scrypted/common/src/ffmpeg-helpers';
import { ffmpegLogInitialOutput, safeKillFFmpeg, safePrintFFmpegArguments } from '@scrypted/common/src/media-helpers';
import { sleep } from '@scrypted/common/src/sleep';
@@ -115,7 +116,11 @@ export async function ffmpegFilterImageBuffer(inputJpeg: Buffer, options: FFmpeg
input.write(inputJpeg);
input.end();
return ffmpegFilterImageInternal(cp, options);
return ffmpegFilterImageInternal(cp, options)
.catch(e => {
fs.writeFileSync("/tmp/test.jpg", inputJpeg);
throw e;
})
}
export async function ffmpegFilterImage(inputArguments: string[], options: FFmpegImageFilterOptions) {
@@ -168,14 +173,16 @@ export async function ffmpegFilterImageInternal(cp: ChildProcess, options: FFmpe
cp.stdio[3].on('data', data => buffers.push(data));
const to = options.timeout ? setTimeout(() => {
console.log('ffmpeg stream to image conversion timed out.');
console.log('ffmpeg input to image conversion timed out.');
safeKillFFmpeg(cp);
}, 10000) : undefined;
const [exitCode] = await once(cp, 'exit');
const exit = once(cp, 'exit');
await once(cp.stdio[3], 'end').catch(() => {});
const [exitCode] = await exit;
clearTimeout(to);
if (exitCode && !buffers.length)
throw new Error(`ffmpeg stream to image conversion failed with exit code: ${exitCode}`);
throw new Error(`ffmpeg input to image conversion failed with exit code: ${exitCode}, ${cp.spawnargs.join(' ')}`);
return Buffer.concat(buffers);
}
@@ -207,7 +214,7 @@ export async function ffmpegFilterImageStream(cp: ChildProcess, options: FFmpegI
if (last)
resolve(last);
else
reject(new Error(`ffmpeg stream to image conversion failed with exit code: ${exitCode}`));
reject(new Error(`ffmpeg stream to image conversion failed with exit code: ${exitCode}, ${cp.spawnargs.join(' ')}`));
})
});

View File

@@ -1,10 +1,10 @@
import AxiosDigestAuth from '@koush/axios-digest-auth';
import { AutoenableMixinProvider } from "@scrypted/common/src/autoenable-mixin-provider";
import { createMapPromiseDebouncer, RefreshPromise, singletonPromise, TimeoutError, timeoutPromise } from "@scrypted/common/src/promise-utils";
import { StorageSettings } from "@scrypted/sdk/storage-settings";
import { createMapPromiseDebouncer, RefreshPromise, singletonPromise, TimeoutError } from "@scrypted/common/src/promise-utils";
import { SettingsMixinDeviceBase, SettingsMixinDeviceOptions } from "@scrypted/common/src/settings-mixin";
import sdk, { BufferConverter, BufferConvertorOptions, Camera, FFmpegInput, MediaObject, MixinProvider, RequestMediaStreamOptions, RequestPictureOptions, ResponsePictureOptions, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, ScryptedMimeTypes, Setting, Settings, SettingValue, VideoCamera } from "@scrypted/sdk";
import axios, { Axios } from "axios";
import { StorageSettings } from "@scrypted/sdk/storage-settings";
import axios, { AxiosInstance } from "axios";
import https from 'https';
import path from 'path';
import MimeType from 'whatwg-mimetype';
@@ -72,22 +72,12 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
},
snapshotsFromPrebuffer: {
title: 'Snapshots from Prebuffer',
description: 'Prefer snapshots from the Rebroadcast Plugin prebuffer when available. This setting uses considerable CPU to convert a video stream into a snapshot.',
type: 'boolean',
defaultValue: !this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera),
},
snapshotMode: {
title: 'Snapshot Mode',
description: 'Set the snapshot mode to accomodate cameras with slow snapshots that may hang HomeKit.\nSetting the mode to "Never Wait" will only use recently available snapshots.\nSetting the mode to "Timeout" will cancel slow snapshots.',
description: 'Prefer snapshots from the Rebroadcast Plugin prebuffer when available. This setting uses considerable CPU to convert a video stream into a snapshot. The Default setting will use the camera snapshot and fall back to prebuffer on failure.',
choices: [
'Default',
'Never Wait',
'Timeout',
'Enabled',
'Disabled',
],
mapGet(value) {
// renamed the setting value.
return value === 'Normal' ? 'Default' : value;
},
defaultValue: 'Default',
},
snapshotResolution: {
@@ -107,7 +97,6 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
type: 'clippath',
},
});
axiosClient: Axios | AxiosDigestAuth;
snapshotDebouncer = createMapPromiseDebouncer<Buffer>();
errorPicture: RefreshPromise<Buffer>;
timeoutPicture: RefreshPromise<Buffer>;
@@ -117,6 +106,7 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
lastErrorImagesClear = 0;
static lastGeneratedErrorImageTime = 0;
lastAvailablePicture: Buffer;
psos: ResponsePictureOptions[];
constructor(public plugin: SnapshotPlugin, options: SettingsMixinDeviceOptions<Camera>) {
super(options);
@@ -132,7 +122,40 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
let needSoftwareResize = !!(options?.picture?.width || options?.picture?.height);
let takePicture: (options?: RequestPictureOptions) => Promise<Buffer>;
if (this.storageSettings.values.snapshotsFromPrebuffer) {
const { snapshotsFromPrebuffer } = this.storageSettings.values;
let usePrebufferSnapshots: boolean;
switch (snapshotsFromPrebuffer) {
case 'true':
case 'Enabled':
usePrebufferSnapshots = true;
break;
case 'Disabled':
usePrebufferSnapshots = false;
break;
default:
if (!this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera))
usePrebufferSnapshots = true;
break;
}
// unifi cameras send stale snapshots which are unusable for events,
// so force a prebuffer snapshot in this instance.
// if prebuffer is not available, it will fall back.
if (eventSnapshot && usePrebufferSnapshots !== false) {
try {
const psos = await this.getPictureOptions();
if (psos?.[0]?.staleDuration) {
usePrebufferSnapshots = true;
}
}
catch (e) {
}
}
let takePrebufferPicture: () => Promise<Buffer>;
const preparePrebufferSnapshot = async () => {
if (takePrebufferPicture)
return takePrebufferPicture;
try {
const realDevice = systemManager.getDeviceById<VideoCamera>(this.id);
const msos = await realDevice.getVideoStreamOptions();
@@ -148,114 +171,124 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
request.prebuffer = eventSnapshot ? 1000 : 6000;
if (this.lastAvailablePicture)
request.refresh = false;
takePicture = async () => mediaManager.convertMediaObjectToBuffer(await realDevice.getVideoStream(request), 'image/jpeg');
this.console.log('snapshotting active prebuffer');
takePrebufferPicture = async () => {
// this.console.log('snapshotting active prebuffer');
return mediaManager.convertMediaObjectToBuffer(await realDevice.getVideoStream(request), 'image/jpeg');
};
return takePrebufferPicture;
}
}
catch (e) {
}
}
if (usePrebufferSnapshots) {
takePicture = await preparePrebufferSnapshot();
}
if (!takePicture) {
if (!this.storageSettings.values.snapshotUrl) {
if (this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera)) {
takePicture = async (options?: RequestPictureOptions) => {
const internalTakePicture = async () => {
if (!options?.id && this.storageSettings.values.defaultSnapshotChannel !== 'Camera Default') {
try {
if (!psos)
psos = await this.mixinDevice.getPictureOptions();
const pso = psos.find(pso => pso.name === this.storageSettings.values.defaultSnapshotChannel);
if (!options)
options = {};
options.id = pso.id;
}
catch (e) {
}
}
return this.mixinDevice.takePicture(options).then(mo => mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg'))
}
if (this.storageSettings.values.snapshotUrl) {
let username: string;
let password: string;
// full resolution setging ignores resize.
if (this.storageSettings.values.snapshotResolution === 'Full Resolution') {
if (options)
options.picture = undefined;
return internalTakePicture();
}
// if resize wasn't requested, continue as normal.
const resizeRequested = !!options?.picture;
if (!resizeRequested)
return internalTakePicture();
// resize was requested
// crop and scale needs to operate on the full resolution image.
if (this.storageSettings.values.snapshotCropScale?.length) {
options.picture = undefined;
// resize after the cop and scale.
needSoftwareResize = resizeRequested;
return internalTakePicture();
}
// determine see if that can be handled by camera hardware
let psos: ResponsePictureOptions[];
try {
if (!psos)
psos = await this.mixinDevice.getPictureOptions();
if (!psos?.[0]?.canResize) {
needSoftwareResize = true;
}
}
catch (e) {
}
if (needSoftwareResize)
options.picture = undefined;
return internalTakePicture();
};
if (this.mixinDeviceInterfaces.includes(ScryptedInterface.Settings)) {
const settings = await this.mixinDevice.getSettings();
username = settings?.find(setting => setting.key === 'username')?.value?.toString();
password = settings?.find(setting => setting.key === 'password')?.value?.toString();
}
else if (this.storageSettings.values.snapshotsFromPrebuffer) {
takePicture = async () => {
throw new PrebufferUnavailableError();
}
let axiosClient: AxiosDigestAuth | AxiosInstance;
if (username && password) {
axiosClient = new AxiosDigestAuth({
username,
password,
});
}
else {
takePicture = () => {
throw new Error('Snapshot Unavailable (snapshotUrl empty)');
}
}
}
else {
if (!this.axiosClient) {
let username: string;
let password: string;
if (this.mixinDeviceInterfaces.includes(ScryptedInterface.Settings)) {
const settings = await this.mixinDevice.getSettings();
username = settings?.find(setting => setting.key === 'username')?.value?.toString();
password = settings?.find(setting => setting.key === 'password')?.value?.toString();
}
if (username && password) {
this.axiosClient = new AxiosDigestAuth({
username,
password,
});
}
else {
this.axiosClient = axios;
}
axiosClient = axios;
}
takePicture = () => this.axiosClient.request({
takePicture = () => axiosClient.request({
httpsAgent,
method: "GET",
responseType: 'arraybuffer',
url: this.storageSettings.values.snapshotUrl,
}).then(async (response: { data: any; }) => response.data);
}
else if (this.mixinDeviceInterfaces.includes(ScryptedInterface.Camera)) {
takePicture = async (options?: RequestPictureOptions) => {
const internalTakePicture = async () => {
if (!options?.id && this.storageSettings.values.defaultSnapshotChannel !== 'Camera Default') {
try {
const psos = await this.getPictureOptions();
const pso = psos.find(pso => pso.name === this.storageSettings.values.defaultSnapshotChannel);
if (!options)
options = {};
options.id = pso.id;
}
catch (e) {
}
}
return this.mixinDevice.takePicture(options).then(mo => mediaManager.convertMediaObjectToBuffer(mo, 'image/jpeg'))
}
// full resolution setging ignores resize.
if (this.storageSettings.values.snapshotResolution === 'Full Resolution') {
if (options)
options.picture = undefined;
return internalTakePicture();
}
// if resize wasn't requested, continue as normal.
const resizeRequested = !!options?.picture;
if (!resizeRequested)
return internalTakePicture();
// resize was requested
// crop and scale needs to operate on the full resolution image.
if (this.storageSettings.values.snapshotCropScale?.length) {
options.picture = undefined;
// resize after the cop and scale.
needSoftwareResize = resizeRequested;
return internalTakePicture();
}
// determine see if that can be handled by camera hardware
try {
const psos = await this.getPictureOptions();
if (!psos?.[0]?.canResize) {
needSoftwareResize = true;
}
}
catch (e) {
}
if (needSoftwareResize)
options.picture = undefined;
return internalTakePicture()
.catch(async e => {
// the camera snapshot failed, try to fallback to prebuffer snapshot.
if (usePrebufferSnapshots === false)
throw e;
const fallback = await preparePrebufferSnapshot();
if (!fallback)
throw e;
return fallback();
})
};
}
else if (usePrebufferSnapshots) {
takePicture = async () => {
throw new PrebufferUnavailableError();
}
}
else {
takePicture = () => {
throw new Error('Snapshot Unavailable (snapshotUrl empty)');
}
}
}
const pendingPicture = this.snapshotDebouncer(options, async () => {
@@ -286,34 +319,20 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
}, 60000);
}
catch (e) {
this.console.error('Snapshot failed', e);
// do not mask event snapshots, as they're used for detections and not
// user facing display.
if (eventSnapshot)
throw e;
// allow reusing the current picture to mask errors
picture = await this.createErrorImage(e);
}
return picture;
});
let { snapshotMode } = this.storageSettings.values;
if (eventSnapshot) {
// event snapshots must be fulfilled
snapshotMode = 'Default';
}
else if (snapshotMode === 'Never Wait' && !options?.periodicRequest) {
// non periodic snapshots should use a short timeout.
snapshotMode = 'Timeout';
}
let data: Buffer;
try {
switch (snapshotMode) {
case 'Never Wait':
throw new NeverWaitError();
case 'Timeout':
data = await timeoutPromise(1000, pendingPicture);
break;
default:
data = await pendingPicture;
break;
}
data = await pendingPicture;
}
catch (e) {
// allow reusing the current picture to mask errors
@@ -443,7 +462,9 @@ class SnapshotMixin extends SettingsMixinDeviceBase<Camera> implements Camera {
}
async getPictureOptions() {
return this.mixinDevice.getPictureOptions();
if (!this.psos)
this.psos = await this.mixinDevice.getPictureOptions();
return this.psos;
}
getMixinSettings(): Promise<Setting[]> {

View File

@@ -1,12 +1,12 @@
{
"name": "@scrypted/server",
"version": "0.6.22",
"version": "0.6.23",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "@scrypted/server",
"version": "0.6.22",
"version": "0.6.23",
"license": "ISC",
"dependencies": {
"@ffmpeg-installer/ffmpeg": "^1.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "@scrypted/server",
"version": "0.6.23",
"version": "0.6.24",
"description": "",
"dependencies": {
"@ffmpeg-installer/ffmpeg": "^1.1.0",

View File

@@ -196,7 +196,7 @@ async function start() {
const checkToken = (token: string) => {
if (process.env.SCRYPTED_ADMIN_USERNAME && process.env.SCRYPTED_ADMIN_TOKEN === token) {
res.locals.username = process.env.SCRYPTED_ADMIN_USERNAME;
res.locals.aclId = process.env.SCRYPTED_ADMIN_TOKEN;
res.locals.aclId = undefined;
return;
}
@@ -550,8 +550,31 @@ async function start() {
await checkResetLogin();
const hostname = os.hostname()?.split('.')?.[0];
const addresses = ((await scrypted.addressSettings.getLocalAddresses()) || getHostAddresses(true, true)).map(address => `https://${address}:${SCRYPTED_SECURE_PORT}`);
// env/header based admin login
if (res.locals.username && res.locals.username === process.env.SCRYPTED_ADMIN_USERNAME) {
res.send({
username: res.locals.username,
token: process.env.SCRYPTED_ADMIN_TOKEN,
addresses,
hostname,
});
return;
}
// env based anon admin login
if (process.env.SCRYPTED_DISABLE_AUTHENTICATION === 'true') {
res.send({
expiration: ONE_DAY_MILLISECONDS,
username: 'anonymous',
addresses,
hostname,
})
return;
}
// basic auth
if (req.protocol === 'https' && req.headers.authorization) {
const username = await new Promise(resolve => {
const basicChecker = basicAuth.check((req) => {
@@ -576,16 +599,7 @@ async function start() {
return;
}
if (process.env.SCRYPTED_DISABLE_AUTHENTICATION === 'true') {
res.send({
expiration: ONE_DAY_MILLISECONDS,
username: 'anonymous',
addresses,
hostname,
})
return;
}
// cookie auth
try {
const login_user_token = getSignedLoginUserTokenRawValue(req);
if (!login_user_token)