mirror of
https://github.com/koush/scrypted.git
synced 2026-05-01 20:20:28 +01:00
794 lines
26 KiB
TypeScript
794 lines
26 KiB
TypeScript
|
|
import { MixinProvider, ScryptedDeviceType, ScryptedInterface, MediaObject, VideoCamera, MediaStreamOptions, Settings, Setting, ScryptedMimeTypes, FFMpegInput, RequestMediaStreamOptions } from '@scrypted/sdk';
|
|
import sdk from '@scrypted/sdk';
|
|
import { once } from 'events';
|
|
import { SettingsMixinDeviceBase } from "../../../common/src/settings-mixin";
|
|
import { createRebroadcaster, ParserOptions, ParserSession, startParserSession } from '@scrypted/common/src/ffmpeg-rebroadcast';
|
|
import { probeVideoCamera } from '@scrypted/common/src/media-helpers';
|
|
import { createMpegTsParser, createFragmentedMp4Parser, StreamChunk, createPCMParser, StreamParser } from '@scrypted/common/src/stream-parser';
|
|
import { AutoenableMixinProvider } from '@scrypted/common/src/autoenable-mixin-provider';
|
|
|
|
const { mediaManager, log, systemManager, deviceManager } = sdk;
|
|
|
|
const defaultPrebufferDuration = 10000;
|
|
const PREBUFFER_DURATION_MS = 'prebufferDuration';
|
|
const SEND_KEYFRAME = 'sendKeyframe';
|
|
const AUDIO_CONFIGURATION_TEMPLATE = 'audioConfiguration';
|
|
const DEFAULT_AUDIO = 'Default';
|
|
const COMPATIBLE_AUDIO = 'AAC or No Audio';
|
|
const COMPATIBLE_AUDIO_DESCRIPTION = `${COMPATIBLE_AUDIO} (Copy)`;
|
|
const LEGACY_AUDIO = 'MP2/MP3 Audio'
|
|
const LEGACY_AUDIO_DESCRIPTION = `${LEGACY_AUDIO} (Copy)`;
|
|
const OTHER_AUDIO = 'Other Audio';
|
|
const OTHER_AUDIO_DESCRIPTION = `${OTHER_AUDIO} (Transcode)`;
|
|
const PCM_AUDIO = 'PCM or G.711 Audio';
|
|
const PCM_AUDIO_DESCRIPTION = `${PCM_AUDIO} (Copy, Unstable)`;
|
|
const compatibleAudio = ['aac', 'mp3', 'mp2', 'AAC', 'MP3', 'MP2', 'opus', 'OPUS', '', undefined, null];
|
|
|
|
interface PrebufferStreamChunk {
|
|
chunk: StreamChunk;
|
|
time: number;
|
|
}
|
|
|
|
interface Prebuffers {
|
|
mp4: PrebufferStreamChunk[];
|
|
mpegts: PrebufferStreamChunk[];
|
|
s16le: PrebufferStreamChunk[];
|
|
}
|
|
|
|
type PrebufferParsers = "mpegts" | "mp4" | "s16le";
|
|
const PrebufferParserValues: PrebufferParsers[] = ['mpegts', 'mp4', 's16le'];
|
|
|
|
class PrebufferSession {
|
|
|
|
parserSessionPromise: Promise<ParserSession<PrebufferParsers>>;
|
|
parserSession: ParserSession<PrebufferParsers>;
|
|
prebuffers: Prebuffers = {
|
|
mp4: [],
|
|
mpegts: [],
|
|
s16le: [],
|
|
};
|
|
parsers: { [container: string]: StreamParser };
|
|
|
|
detectedIdrInterval = 0;
|
|
prevIdr = 0;
|
|
incompatibleDetected = false;
|
|
legacyDetected = false;
|
|
audioDisabled = false;
|
|
|
|
mixinDevice: VideoCamera;
|
|
console: Console;
|
|
storage: Storage;
|
|
|
|
activeClients = 0;
|
|
|
|
AUDIO_CONFIGURATION = AUDIO_CONFIGURATION_TEMPLATE + '-' + this.streamId;
|
|
|
|
constructor(public mixin: PrebufferMixin, public streamName: string, public streamId: string, public stopInactive: boolean) {
|
|
this.storage = mixin.storage;
|
|
this.console = mixin.console;
|
|
this.mixinDevice = mixin.mixinDevice;
|
|
}
|
|
|
|
clearPrebuffers() {
|
|
this.prebuffers.mp4 = [];
|
|
this.prebuffers.mpegts = [];
|
|
this.prebuffers.s16le = [];
|
|
}
|
|
|
|
ensurePrebufferSession() {
|
|
if (this.parserSessionPromise || this.mixin.released)
|
|
return;
|
|
this.console.log(this.streamName, 'prebuffer session started');
|
|
this.parserSessionPromise = this.startPrebufferSession();
|
|
this.parserSessionPromise.catch(() => this.parserSessionPromise = undefined);
|
|
}
|
|
|
|
getAudioConfig(): {
|
|
audioConfig: string,
|
|
pcmAudio: boolean,
|
|
legacyAudio: boolean,
|
|
reencodeAudio: boolean,
|
|
} {
|
|
const audioConfig = this.storage.getItem(this.AUDIO_CONFIGURATION) || '';
|
|
// pcm audio only used when explicitly set.
|
|
const pcmAudio = audioConfig.indexOf(PCM_AUDIO) !== -1;
|
|
const legacyAudio = audioConfig.indexOf(LEGACY_AUDIO) !== -1;
|
|
// reencode audio will be used if explicitly set.
|
|
const reencodeAudio = audioConfig.indexOf(OTHER_AUDIO) !== -1;
|
|
return {
|
|
audioConfig,
|
|
pcmAudio,
|
|
legacyAudio,
|
|
reencodeAudio,
|
|
}
|
|
}
|
|
|
|
async getMixinSettings(): Promise<Setting[]> {
|
|
const settings: Setting[] = [];
|
|
|
|
const session = this.parserSession;
|
|
|
|
let total = 0;
|
|
let start = 0;
|
|
for (const prebuffer of this.prebuffers.mp4) {
|
|
start = start || prebuffer.time;
|
|
for (const chunk of prebuffer.chunk.chunks) {
|
|
total += chunk.byteLength;
|
|
}
|
|
}
|
|
const elapsed = Date.now() - start;
|
|
const bitrate = Math.round(total / elapsed * 8);
|
|
|
|
const group = this.streamName ? `Rebroadcast: ${this.streamName}` : 'Rebroadcast';
|
|
|
|
settings.push(
|
|
{
|
|
title: 'Audio Codec Transcoding',
|
|
group,
|
|
description: 'Configuring your camera to output AAC, MP3, MP2, or Opus is recommended. PCM/G711 cameras should set this to Reencode.',
|
|
type: 'string',
|
|
key: this.AUDIO_CONFIGURATION,
|
|
value: this.storage.getItem(this.AUDIO_CONFIGURATION) || DEFAULT_AUDIO,
|
|
choices: [
|
|
DEFAULT_AUDIO,
|
|
COMPATIBLE_AUDIO_DESCRIPTION,
|
|
LEGACY_AUDIO_DESCRIPTION,
|
|
OTHER_AUDIO_DESCRIPTION,
|
|
PCM_AUDIO_DESCRIPTION,
|
|
],
|
|
},
|
|
);
|
|
|
|
if (session) {
|
|
settings.push(
|
|
{
|
|
key: 'detectedResolution',
|
|
group,
|
|
title: 'Detected Resolution and Bitrate',
|
|
readonly: true,
|
|
value: `${session?.inputVideoResolution?.[0] || "unknown"} @ ${bitrate || "unknown"} Kb/s`,
|
|
description: 'Configuring your camera to 1920x1080, 2000Kb/S, Variable Bit Rate, is recommended.',
|
|
},
|
|
{
|
|
key: 'detectedCodec',
|
|
group,
|
|
title: 'Detected Video/Audio Codecs',
|
|
readonly: true,
|
|
value: (session?.inputVideoCodec?.toString() || 'unknown') + '/' + (session?.inputAudioCodec?.toString() || 'unknown'),
|
|
description: 'Configuring your camera to H264 video and AAC/MP3/MP2/Opus audio is recommended.'
|
|
},
|
|
{
|
|
key: 'detectedKeyframe',
|
|
group,
|
|
title: 'Detected Keyframe Interval',
|
|
description: "Configuring your camera to 4 seconds is recommended (IDR aka Frame Interval = FPS * 4 seconds).",
|
|
readonly: true,
|
|
value: ((this.detectedIdrInterval || 0) / 1000).toString() || 'none',
|
|
},
|
|
);
|
|
}
|
|
else {
|
|
settings.push(
|
|
{
|
|
title: 'Status',
|
|
group,
|
|
key: 'status',
|
|
description: 'Rebroadcast is currently idle and will be started automatically on demand.',
|
|
value: 'Idle',
|
|
readonly: true,
|
|
},
|
|
)
|
|
}
|
|
|
|
return settings;
|
|
}
|
|
|
|
async startPrebufferSession() {
|
|
this.prebuffers.mp4 = [];
|
|
this.prebuffers.mpegts = [];
|
|
this.prebuffers.s16le = [];
|
|
const prebufferDurationMs = parseInt(this.storage.getItem(PREBUFFER_DURATION_MS)) || defaultPrebufferDuration;
|
|
|
|
// todo: there's a bug here where probe's noAudio check looks at the first media stream option entry
|
|
const probe = await probeVideoCamera(this.mixinDevice);
|
|
let mso: MediaStreamOptions;
|
|
if (probe.options) {
|
|
mso = probe.options.find(mso => mso.id === this.streamId);
|
|
}
|
|
const probeAudioCodec = mso?.audio?.codec;
|
|
this.incompatibleDetected = this.incompatibleDetected || (probeAudioCodec && !compatibleAudio.includes(probeAudioCodec));
|
|
if (this.incompatibleDetected)
|
|
this.console.warn('configure your camera to output aac, mp3, mp2, or opus audio. incompatible audio codec detected', probeAudioCodec);
|
|
|
|
const mo = await this.mixinDevice.getVideoStream(mso);
|
|
const moBuffer = await mediaManager.convertMediaObjectToBuffer(mo, ScryptedMimeTypes.FFmpegInput);
|
|
const ffmpegInput = JSON.parse(moBuffer.toString()) as FFMpegInput;
|
|
|
|
const { audioConfig, pcmAudio, reencodeAudio, legacyAudio } = this.getAudioConfig();
|
|
const isUsingDefaultAudioConfig = !audioConfig || audioConfig === DEFAULT_AUDIO;
|
|
const forceNoAudio = this.incompatibleDetected && isUsingDefaultAudioConfig;
|
|
|
|
this.audioDisabled = false;
|
|
let acodec: string[];
|
|
if (probe.noAudio || forceNoAudio) {
|
|
// no audio? explicitly disable it.
|
|
acodec = ['-an'];
|
|
this.audioDisabled = true;
|
|
}
|
|
else if (pcmAudio) {
|
|
acodec = ['-an'];
|
|
}
|
|
else if (reencodeAudio) {
|
|
// setting no audio codec will allow ffmpeg to do an implicit conversion.
|
|
acodec = [
|
|
'-bsf:a', 'aac_adtstoasc',
|
|
'-acodec', 'libfdk_aac',
|
|
'-profile:a', 'aac_low',
|
|
'-flags', '+global_header',
|
|
'-ar', `8k`,
|
|
'-b:a', `100k`,
|
|
'-ac', `1`,
|
|
];
|
|
}
|
|
else {
|
|
// NOTE: if there is no audio track, this will still work fine.
|
|
acodec = [
|
|
'-acodec',
|
|
'copy',
|
|
...(legacyAudio || this.legacyDetected ? [] : ['-bsf:a', 'aac_adtstoasc']),
|
|
];
|
|
}
|
|
|
|
const vcodec = [
|
|
'-vcodec',
|
|
'copy',
|
|
];
|
|
|
|
const rbo: ParserOptions<PrebufferParsers> = {
|
|
console: this.console,
|
|
timeout: 60000,
|
|
parsers: {
|
|
mp4: createFragmentedMp4Parser({
|
|
vcodec,
|
|
acodec,
|
|
}),
|
|
mpegts: createMpegTsParser({
|
|
vcodec,
|
|
acodec,
|
|
}),
|
|
},
|
|
};
|
|
|
|
// if pcm prebuffer is requested, create the the parser. don't do it if
|
|
// the camera wants to mute the audio though.
|
|
if (!probe.noAudio && !forceNoAudio && pcmAudio) {
|
|
rbo.parsers.s16le = createPCMParser();
|
|
}
|
|
|
|
this.parsers = rbo.parsers;
|
|
|
|
// create missing pts from dts so mpegts and mp4 muxing does not fail
|
|
ffmpegInput.inputArguments.unshift('-fflags', '+genpts');
|
|
|
|
const session = await startParserSession(ffmpegInput, rbo);
|
|
this.parserSession = session;
|
|
|
|
// cloud streams need a periodic token refresh.
|
|
if (ffmpegInput.mediaStreamOptions?.refreshAt) {
|
|
let mso = ffmpegInput.mediaStreamOptions;
|
|
let refreshTimeout: NodeJS.Timeout;
|
|
|
|
const refreshStream = async () => {
|
|
if (!session.isActive)
|
|
return;
|
|
const mo = await this.mixinDevice.getVideoStream(mso);
|
|
const moBuffer = await mediaManager.convertMediaObjectToBuffer(mo, ScryptedMimeTypes.FFmpegInput);
|
|
const ffmpegInput = JSON.parse(moBuffer.toString()) as FFMpegInput;
|
|
mso = ffmpegInput.mediaStreamOptions
|
|
|
|
scheduleRefresh(ffmpegInput);
|
|
};
|
|
|
|
const scheduleRefresh = (ffmpegInput: FFMpegInput) => {
|
|
const when = ffmpegInput.mediaStreamOptions.refreshAt - Date.now() - 30000;
|
|
this.console.log('refreshing media stream in', when);
|
|
refreshTimeout = setTimeout(refreshStream, when);
|
|
}
|
|
|
|
scheduleRefresh(ffmpegInput);
|
|
session.once('killed', () => clearTimeout(refreshTimeout));
|
|
}
|
|
|
|
session.once('killed', () => {
|
|
this.parserSessionPromise = undefined;
|
|
});
|
|
|
|
if (!session.inputAudioCodec) {
|
|
this.console.warn('no audio detected.');
|
|
}
|
|
else if (!compatibleAudio.includes(session.inputAudioCodec)) {
|
|
this.console.error('Detected audio codec is not mp4/mpegts compatible.', session.inputAudioCodec);
|
|
// show an alert if no audio config was explicitly specified. Force the user to choose/experiment.
|
|
if (isUsingDefaultAudioConfig && !probe.noAudio) {
|
|
log.a(`${this.mixin.name} is using the ${session.inputAudioCodec} audio codec and has had its audio disabled. Select Disable Audio on your Camera or select Reencode Audio in Rebroadcast Settings Audio Configuration to suppress this alert.`);
|
|
this.incompatibleDetected = true;
|
|
// this will probably crash ffmpeg due to mp4/mpegts not being a valid container for pcm,
|
|
// and then it will automatically restart with pcm handling.
|
|
}
|
|
}
|
|
else if (session.inputAudioCodec?.toLowerCase() !== 'aac') {
|
|
this.console.error('Detected audio codec was not AAC.', session.inputAudioCodec);
|
|
if (!legacyAudio) {
|
|
log.a(`${this.mixin.name} is using ${session.inputAudioCodec} audio. Enable MP2/MP3 Audio in Rebroadcast Settings Audio Configuration to suppress this alert.`);
|
|
this.legacyDetected = true;
|
|
// this will probably crash ffmpeg due to mp2/mp3/opus not supporting the aac bit stream filters,
|
|
// and then it will automatically restart with legacy handling.
|
|
}
|
|
}
|
|
|
|
if (session.inputVideoCodec !== 'h264') {
|
|
this.console.error(`video codec is not h264. If there are errors, try changing your camera's encoder output.`);
|
|
}
|
|
|
|
// s16le will be a no-op if there's no pcm, no harm.
|
|
for (const container of PrebufferParserValues) {
|
|
let shifts = 0;
|
|
|
|
session.on(container, (chunk: StreamChunk) => {
|
|
const prebufferContainer: PrebufferStreamChunk[] = this.prebuffers[container];
|
|
const now = Date.now();
|
|
|
|
// this is only valid for mp4, so its no op for everything else
|
|
// used to detect idr interval.
|
|
if (chunk.type === 'mdat') {
|
|
if (this.prevIdr)
|
|
this.detectedIdrInterval = now - this.prevIdr;
|
|
this.prevIdr = now;
|
|
}
|
|
|
|
prebufferContainer.push({
|
|
time: now,
|
|
chunk,
|
|
});
|
|
|
|
while (prebufferContainer.length && prebufferContainer[0].time < now - prebufferDurationMs) {
|
|
prebufferContainer.shift();
|
|
shifts++;
|
|
}
|
|
|
|
if (shifts > 1000) {
|
|
this.prebuffers[container] = prebufferContainer.slice();
|
|
shifts = 0;
|
|
}
|
|
});
|
|
}
|
|
|
|
return session;
|
|
}
|
|
|
|
printActiveClients() {
|
|
this.console.log(this.streamName, 'active rebroadcast clients:', this.activeClients);
|
|
}
|
|
|
|
inactivityCheck(session: ParserSession<PrebufferParsers>) {
|
|
this.printActiveClients();
|
|
if (!this.stopInactive)
|
|
return;
|
|
if (this.activeClients)
|
|
return;
|
|
|
|
setTimeout(() => {
|
|
if (this.activeClients)
|
|
return;
|
|
this.console.log(this.streamName, 'terminating rebroadcast due to inactivity');
|
|
session.kill();
|
|
}, 30000);
|
|
}
|
|
|
|
async getVideoStream(options?: MediaStreamOptions): Promise<MediaObject> {
|
|
this.ensurePrebufferSession();
|
|
|
|
const session = await this.parserSessionPromise;
|
|
|
|
const sendKeyframe = this.storage.getItem(SEND_KEYFRAME) !== 'false';
|
|
const requestedPrebuffer = options?.prebuffer || (sendKeyframe ? Math.max(4000, (this.detectedIdrInterval || 4000)) * 1.5 : 0);
|
|
|
|
this.console.log(this.streamName, 'prebuffer request started');
|
|
|
|
const createContainerServer = async (container: PrebufferParsers) => {
|
|
const prebufferContainer: PrebufferStreamChunk[] = this.prebuffers[container];
|
|
|
|
const { server, port } = await createRebroadcaster({
|
|
connect: (writeData, destroy) => {
|
|
this.activeClients++;
|
|
this.printActiveClients();
|
|
|
|
server.close();
|
|
const now = Date.now();
|
|
|
|
const safeWriteData = (chunk: StreamChunk) => {
|
|
const buffered = writeData(chunk);
|
|
if (buffered > 100000000) {
|
|
this.console.log('more than 100MB has been buffered, did downstream die? killing connection.', this.streamName);
|
|
cleanup();
|
|
}
|
|
}
|
|
|
|
const cleanup = () => {
|
|
destroy();
|
|
this.console.log(this.streamName, 'prebuffer request ended');
|
|
session.removeListener(container, safeWriteData);
|
|
session.removeListener('killed', cleanup);
|
|
}
|
|
|
|
session.on(container, safeWriteData);
|
|
session.once('killed', cleanup);
|
|
|
|
if (true) {
|
|
for (const prebuffer of prebufferContainer) {
|
|
if (prebuffer.time < now - requestedPrebuffer)
|
|
continue;
|
|
|
|
safeWriteData(prebuffer.chunk);
|
|
}
|
|
}
|
|
else {
|
|
// for some reason this doesn't work as well as simply guessing and dumping.
|
|
const parser = this.parsers[container];
|
|
const availablePrebuffers = parser.findSyncFrame(prebufferContainer.filter(pb => pb.time >= now - requestedPrebuffer).map(pb => pb.chunk));
|
|
for (const prebuffer of availablePrebuffers) {
|
|
safeWriteData(prebuffer);
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
this.activeClients--;
|
|
this.inactivityCheck(session);
|
|
cleanup();
|
|
};
|
|
}
|
|
})
|
|
|
|
setTimeout(() => server.close(), 30000);
|
|
|
|
return port;
|
|
}
|
|
|
|
const container: PrebufferParsers = PrebufferParserValues.find(parser => parser === options?.container) || 'mpegts';
|
|
|
|
const mediaStreamOptions: MediaStreamOptions = Object.assign({}, session.mediaStreamOptions);
|
|
|
|
mediaStreamOptions.prebuffer = requestedPrebuffer;
|
|
|
|
const { audioConfig, pcmAudio, reencodeAudio } = this.getAudioConfig();
|
|
|
|
if (this.audioDisabled) {
|
|
mediaStreamOptions.audio = null;
|
|
}
|
|
else if (reencodeAudio) {
|
|
mediaStreamOptions.audio = {
|
|
codec: 'aac',
|
|
encoder: 'libfdk_aac',
|
|
profile: 'aac_eld',
|
|
}
|
|
}
|
|
else {
|
|
mediaStreamOptions.audio = {
|
|
codec: session?.inputAudioCodec,
|
|
}
|
|
}
|
|
|
|
if (mediaStreamOptions.video && session.inputVideoResolution?.[2] && session.inputVideoResolution?.[3]) {
|
|
Object.assign(mediaStreamOptions.video, {
|
|
width: parseInt(session.inputVideoResolution[2]),
|
|
height: parseInt(session.inputVideoResolution[3]),
|
|
})
|
|
}
|
|
|
|
const now = Date.now();
|
|
let available = 0;
|
|
const prebufferContainer: PrebufferStreamChunk[] = this.prebuffers[container];
|
|
for (const prebuffer of prebufferContainer) {
|
|
if (prebuffer.time < now - requestedPrebuffer)
|
|
continue;
|
|
for (const chunk of prebuffer.chunk.chunks) {
|
|
available += chunk.length;
|
|
}
|
|
}
|
|
|
|
const length = Math.max(500000, available).toString();
|
|
|
|
const url = `tcp://127.0.0.1:${await createContainerServer(container)}`;
|
|
const ffmpegInput: FFMpegInput = {
|
|
url,
|
|
container,
|
|
inputArguments: [
|
|
'-analyzeduration', '0', '-probesize', length,
|
|
'-f', container,
|
|
'-i', url,
|
|
],
|
|
mediaStreamOptions,
|
|
}
|
|
|
|
if (pcmAudio) {
|
|
ffmpegInput.inputArguments.push(
|
|
'-analyzeduration', '0', '-probesize', length,
|
|
'-f', 's16le',
|
|
'-i', `tcp://127.0.0.1:${await createContainerServer('s16le')}`,
|
|
)
|
|
}
|
|
|
|
const mo = mediaManager.createFFmpegMediaObject(ffmpegInput);
|
|
return mo;
|
|
}
|
|
}
|
|
|
|
class PrebufferMixin extends SettingsMixinDeviceBase<VideoCamera> implements VideoCamera, Settings {
|
|
released = false;
|
|
sessions = new Map<string, PrebufferSession>();
|
|
|
|
constructor(mixinDevice: VideoCamera & Settings, mixinDeviceInterfaces: ScryptedInterface[], mixinDeviceState: { [key: string]: any }, providerNativeId: string) {
|
|
super(mixinDevice, mixinDeviceState, {
|
|
providerNativeId,
|
|
mixinDeviceInterfaces,
|
|
group: "Prebuffer Settings",
|
|
groupKey: "prebuffer",
|
|
});
|
|
|
|
this.delayStart();
|
|
}
|
|
|
|
delayStart() {
|
|
this.console.log('prebuffer sessions starting in 5 seconds');
|
|
// to prevent noisy startup/reload/shutdown, delay the prebuffer starting.
|
|
setTimeout(() => this.ensurePrebufferSessions(), 5000);
|
|
}
|
|
|
|
async getVideoStream(options?: RequestMediaStreamOptions): Promise<MediaObject> {
|
|
await this.ensurePrebufferSessions();
|
|
|
|
const id = options?.id;
|
|
let session = this.sessions.get(id);
|
|
if (!session || options?.directMediaStream)
|
|
return this.mixinDevice.getVideoStream(options);
|
|
session.ensurePrebufferSession();
|
|
await session.parserSessionPromise;
|
|
session = this.sessions.get(id);
|
|
if (!session)
|
|
return this.mixinDevice.getVideoStream(options);
|
|
return session.getVideoStream(options);
|
|
}
|
|
|
|
async ensurePrebufferSessions() {
|
|
const msos = await this.mixinDevice.getVideoStreamOptions();
|
|
const enabled = this.getEnabledMediaStreamOptions(msos);
|
|
const enabledIds = enabled ? enabled.map(mso => mso.id) : [undefined];
|
|
const ids = msos?.map(mso => mso.id) || [undefined];
|
|
|
|
const isBatteryPowered = this.mixinDeviceInterfaces.includes(ScryptedInterface.Battery);
|
|
|
|
let active = 0;
|
|
const total = ids.length;
|
|
for (const id of ids) {
|
|
let session = this.sessions.get(id);
|
|
if (!session) {
|
|
const mso = msos?.find(mso => mso.id === id);
|
|
if (mso?.prebuffer) {
|
|
log.a(`Prebuffer is already available on ${this.name}. If this is a grouped device, disable the Rebroadcast extension.`)
|
|
}
|
|
const name = mso?.name;
|
|
const notEnabled = !enabledIds.includes(id)
|
|
const stopInactive = isBatteryPowered || notEnabled;
|
|
session = new PrebufferSession(this, name, id, stopInactive);
|
|
this.sessions.set(id, session);
|
|
if (id === msos?.[0]?.id)
|
|
this.sessions.set(undefined, session);
|
|
|
|
if (isBatteryPowered) {
|
|
this.console.log('camera is battery powered, prebuffering and rebroadcasting will only work on demand.');
|
|
continue;
|
|
}
|
|
|
|
if (notEnabled) {
|
|
this.console.log('stream', name, 'will be rebroadcast on demand.');
|
|
continue;
|
|
}
|
|
|
|
(async () => {
|
|
while (this.sessions.get(id) === session && !this.released) {
|
|
session.ensurePrebufferSession();
|
|
try {
|
|
const ps = await session.parserSessionPromise;
|
|
active++;
|
|
this.online = active == total;
|
|
await once(ps, 'killed');
|
|
this.console.error('prebuffer session ended');
|
|
}
|
|
catch (e) {
|
|
this.console.error('prebuffer session ended with error', e);
|
|
}
|
|
finally {
|
|
active--;
|
|
this.online = active == total;
|
|
}
|
|
this.console.log('restarting prebuffer session in 5 seconds');
|
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
|
}
|
|
this.console.log('exiting prebuffer session (released or restarted with new configuration)');
|
|
})();
|
|
}
|
|
}
|
|
deviceManager.onMixinEvent(this.id, this.mixinProviderNativeId, ScryptedInterface.Settings, undefined);
|
|
}
|
|
|
|
async getMixinSettings(): Promise<Setting[]> {
|
|
const settings: Setting[] = [];
|
|
|
|
try {
|
|
const msos = await this.mixinDevice.getVideoStreamOptions();
|
|
const enabledStreams = this.getEnabledMediaStreamOptions(msos);
|
|
if (enabledStreams && msos?.length > 1) {
|
|
settings.push(
|
|
{
|
|
title: 'Prebuffered Streams',
|
|
description: 'The streams to prebuffer. Enable only as necessary to reduce traffic.',
|
|
key: 'enabledStreams',
|
|
value: enabledStreams.map(mso => mso.name || ''),
|
|
choices: msos.map(mso => mso.name),
|
|
multiple: true,
|
|
},
|
|
)
|
|
}
|
|
}
|
|
catch (e) {
|
|
this.console.error('error in getVideoStreamOptions', e);
|
|
throw e;
|
|
}
|
|
|
|
|
|
settings.push(
|
|
{
|
|
title: 'Prebuffer Duration',
|
|
description: 'Duration of the prebuffer in milliseconds.',
|
|
type: 'number',
|
|
key: PREBUFFER_DURATION_MS,
|
|
value: this.storage.getItem(PREBUFFER_DURATION_MS) || defaultPrebufferDuration.toString(),
|
|
},
|
|
{
|
|
title: 'Start at Previous Keyframe',
|
|
description: 'Start live streams from the previous key frame. Improves startup time.',
|
|
type: 'boolean',
|
|
key: SEND_KEYFRAME,
|
|
value: (this.storage.getItem(SEND_KEYFRAME) !== 'false').toString(),
|
|
},
|
|
);
|
|
|
|
|
|
for (const session of new Set([...this.sessions.values()])) {
|
|
if (!session)
|
|
continue;
|
|
try {
|
|
settings.push(...await session.getMixinSettings());
|
|
}
|
|
catch (e) {
|
|
this.console.error('error in prebuffer session getMixinSettings', e);
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
return settings;
|
|
}
|
|
|
|
async putMixinSetting(key: string, value: string | number | boolean): Promise<void> {
|
|
const sessions = this.sessions;
|
|
this.sessions = new Map();
|
|
if (key === 'enabledStreams') {
|
|
this.storage.setItem(key, JSON.stringify(value));
|
|
}
|
|
else {
|
|
this.storage.setItem(key, value.toString());
|
|
}
|
|
for (const session of sessions.values()) {
|
|
session?.parserSessionPromise?.then(session => session.kill());
|
|
}
|
|
this.ensurePrebufferSessions();
|
|
}
|
|
|
|
getEnabledMediaStreamOptions(msos?: MediaStreamOptions[]) {
|
|
if (!msos || !msos.length)
|
|
return;
|
|
|
|
try {
|
|
const parsed: any[] = JSON.parse(this.storage.getItem('enabledStreams'));
|
|
const filtered = msos.filter(mso => parsed.includes(mso.name));
|
|
return filtered;
|
|
}
|
|
catch (e) {
|
|
}
|
|
return [msos[0]];
|
|
}
|
|
|
|
async getVideoStreamOptions(): Promise<MediaStreamOptions[]> {
|
|
const ret: MediaStreamOptions[] = await this.mixinDevice.getVideoStreamOptions() || [];
|
|
let enabledStreams = this.getEnabledMediaStreamOptions(ret);
|
|
|
|
const prebuffer = parseInt(this.storage.getItem(PREBUFFER_DURATION_MS)) || defaultPrebufferDuration;
|
|
|
|
if (!enabledStreams) {
|
|
ret.push({
|
|
id: 'default',
|
|
name: 'Default',
|
|
prebuffer,
|
|
});
|
|
}
|
|
else {
|
|
for (const enabledStream of enabledStreams) {
|
|
enabledStream.prebuffer = prebuffer;
|
|
}
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
release() {
|
|
this.console.log('prebuffer releasing if started');
|
|
this.released = true;
|
|
for (const session of this.sessions.values()) {
|
|
if (!session)
|
|
continue;
|
|
session.clearPrebuffers();
|
|
session.parserSessionPromise?.then(parserSession => {
|
|
this.console.log('prebuffer released');
|
|
parserSession.kill();
|
|
session.clearPrebuffers();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function millisUntilMidnight() {
|
|
var midnight = new Date();
|
|
midnight.setHours(24);
|
|
midnight.setMinutes(0);
|
|
midnight.setSeconds(0);
|
|
midnight.setMilliseconds(0);
|
|
return (midnight.getTime() - new Date().getTime());
|
|
}
|
|
|
|
class PrebufferProvider extends AutoenableMixinProvider implements MixinProvider {
|
|
constructor(nativeId?: string) {
|
|
super(nativeId);
|
|
|
|
// trigger the prebuffer.
|
|
for (const id of Object.keys(systemManager.getSystemState())) {
|
|
const device = systemManager.getDeviceById<VideoCamera>(id);
|
|
if (!device.mixins?.includes(this.id))
|
|
continue;
|
|
device.getVideoStreamOptions();
|
|
}
|
|
|
|
// schedule restarts at 2am
|
|
const midnight = millisUntilMidnight();
|
|
const twoAM = midnight + 2 * 60 * 60 * 1000;
|
|
this.log.i(`Rebroadcaster scheduled for restart at 2AM: ${Math.round(twoAM / 1000 / 60)} minutes`)
|
|
setTimeout(() => deviceManager.requestRestart(), twoAM);
|
|
}
|
|
|
|
async canMixin(type: ScryptedDeviceType, interfaces: string[]): Promise<string[]> {
|
|
if (!interfaces.includes(ScryptedInterface.VideoCamera))
|
|
return null;
|
|
return [ScryptedInterface.VideoCamera, ScryptedInterface.Settings, ScryptedInterface.Online];
|
|
}
|
|
|
|
async getMixin(mixinDevice: any, mixinDeviceInterfaces: ScryptedInterface[], mixinDeviceState: { [key: string]: any }) {
|
|
this.setHasEnabledMixin(mixinDeviceState.id);
|
|
return new PrebufferMixin(mixinDevice, mixinDeviceInterfaces, mixinDeviceState, this.nativeId);
|
|
}
|
|
async releaseMixin(id: string, mixinDevice: any) {
|
|
mixinDevice.online = true;
|
|
mixinDevice.release();
|
|
}
|
|
}
|
|
|
|
export default new PrebufferProvider();
|