homebridge-loxone-proxy
Version:
Homebridge Dynamic Platform Plugin which exposes a Loxone System to Homekit.
386 lines โข 16.8 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.RecordingDelegate = exports.parseFragmentedMP4 = exports.readLength = exports.listenServer = exports.PREBUFFER_LENGTH = void 0;
const child_process_1 = require("child_process");
const events_1 = require("events");
const buffer_1 = require("buffer");
const process_1 = require("process");
const Prebuffer_1 = require("./Prebuffer");
exports.PREBUFFER_LENGTH = 4000;
async function listenServer(server, log) {
let isListening = false;
while (!isListening) {
const port = 10000 + Math.round(Math.random() * 30000);
server.listen(port);
try {
await (0, events_1.once)(server, 'listening');
const address = server.address();
isListening = true;
return address.port;
}
catch (e) {
log.error('Error while listening to the server:', e);
}
}
return 0;
}
exports.listenServer = listenServer;
async function readLength(readable, length) {
if (!length) {
return buffer_1.Buffer.alloc(0);
}
const ret = readable.read(length);
if (ret) {
return ret;
}
return new Promise((resolve, reject) => {
const r = () => {
const data = readable.read(length);
if (data) {
cleanup();
resolve(data);
}
};
const e = () => {
cleanup();
reject(new Error(`stream ended during read for minimum ${length} bytes`));
};
const c = () => {
cleanup();
reject(new Error(`stream closed during read for minimum ${length} bytes`));
};
const err = (error) => {
cleanup();
reject(error);
};
const cleanup = () => {
readable.removeListener('readable', r);
readable.removeListener('end', e);
readable.removeListener('close', c);
readable.removeListener('error', err);
};
readable.on('readable', r);
readable.on('end', e);
readable.on('close', c);
readable.on('error', err);
});
}
exports.readLength = readLength;
async function* parseFragmentedMP4(readable) {
try {
while (true) {
const header = await readLength(readable, 8);
const length = header.readInt32BE(0) - 8;
const type = header.slice(4).toString();
const data = await readLength(readable, length);
yield { header, length, type, data };
}
}
catch (error) {
if (error instanceof Error && (error.message.includes('stream ended') ||
error.message.includes('stream closed'))) {
return;
}
throw error;
}
}
exports.parseFragmentedMP4 = parseFragmentedMP4;
class RecordingDelegate {
constructor(platform, streamUrl, base64auth, cameraName) {
this.platform = platform;
this.activeFFmpegProcesses = new Map();
this.streamAbortControllers = new Map();
this.closedStreams = new Set();
this.recordingActive = false;
this.shuttingDown = false;
this.log = platform.log;
this.hap = platform.api.hap;
this.videoProcessor = 'ffmpeg';
this.streamUrl = streamUrl;
this.base64auth = base64auth;
this.cameraName = cameraName;
platform.api.on("shutdown", () => {
var _a, _b;
this.shuttingDown = true;
this.log.info(`[${this.cameraName}] Shutting down recording delegate`, this.streamUrl);
this.streamAbortControllers.forEach(controller => controller.abort());
this.streamAbortControllers.clear();
this.closedStreams.clear();
this.activeFFmpegProcesses.forEach(proc => {
if (!proc.killed) {
proc.kill('SIGTERM');
}
});
this.activeFFmpegProcesses.clear();
if (((_a = this.preBufferSession) === null || _a === void 0 ? void 0 : _a.process) && !this.preBufferSession.process.killed) {
this.preBufferSession.process.kill('SIGKILL');
}
if ((_b = this.preBufferSession) === null || _b === void 0 ? void 0 : _b.server) {
this.preBufferSession.server.close();
}
});
}
updateRecordingActive(active) {
this.recordingActive = active;
this.log.info(`[${this.cameraName}] Recording active status changed to: ${active}`, this.streamUrl);
if (active) {
void this.startPreBuffer().catch((error) => {
this.log.warn(`[${this.cameraName}] Failed to warm prebuffer: ${error}`, this.streamUrl);
});
}
return Promise.resolve();
}
updateRecordingConfiguration(config) {
this.log.info(`[${this.cameraName}] Recording configuration updated`, this.streamUrl);
this.currentRecordingConfiguration = config;
if (config && this.recordingActive) {
void this.startPreBuffer().catch((error) => {
this.log.warn(`[${this.cameraName}] Failed to warm prebuffer after configuration update: ${error}`, this.streamUrl);
});
}
return Promise.resolve();
}
async *handleRecordingStreamRequest(streamId) {
const streamKey = this.toStreamKey(streamId);
this.closedStreams.delete(streamKey);
this.log.info(`[${this.cameraName}] Recording stream request received for stream ID: ${streamId}`, this.streamUrl);
if (!this.currentRecordingConfiguration) {
this.log.error(`[${this.cameraName}] No recording configuration available`, this.streamUrl);
return;
}
const abortController = new AbortController();
this.streamAbortControllers.set(streamKey, abortController);
let fragmentCount = 0;
try {
await this.startPreBuffer();
if (this.isStreamClosed(streamKey, abortController.signal)) {
this.log.debug(`[${this.cameraName}] Stream ${streamId} aborted before fragments started`, this.streamUrl);
return;
}
const fragmentGenerator = this.handleFragmentsRequests(this.currentRecordingConfiguration, streamKey, abortController.signal);
for await (const fragmentBuffer of fragmentGenerator) {
if (this.isStreamClosed(streamKey, abortController.signal)) {
this.log.debug(`[${this.cameraName}] Stream ${streamId} aborted, stopping fragment yields`, this.streamUrl);
return;
}
fragmentCount++;
yield { data: fragmentBuffer, isLast: false };
}
if (this.isStreamClosed(streamKey, abortController.signal)) {
this.log.debug(`[${this.cameraName}] Stream ${streamId} aborted after fragment loop`, this.streamUrl);
return;
}
this.log.info(`[${this.cameraName}] โ
Recording completed successfully. Total fragments: ${fragmentCount}`, this.streamUrl);
yield { data: buffer_1.Buffer.alloc(0), isLast: true };
}
catch (error) {
if (this.isStreamClosed(streamKey, abortController.signal)) {
this.log.debug(`[${this.cameraName}] Stream ${streamId} aborted while handling recording`, this.streamUrl);
return;
}
this.log.error(`[${this.cameraName}] โ Recording stream error: ${error}`, this.streamUrl);
yield { data: buffer_1.Buffer.alloc(0), isLast: true };
}
finally {
this.streamAbortControllers.delete(streamKey);
this.closedStreams.delete(streamKey);
}
}
closeRecordingStream(streamId, reason) {
var _a;
const streamKey = this.toStreamKey(streamId);
this.log.info(`[${this.cameraName}] Recording stream closed for stream ID: ${streamId}, reason: ${reason}`, this.streamUrl);
this.closedStreams.add(streamKey);
(_a = this.streamAbortControllers.get(streamKey)) === null || _a === void 0 ? void 0 : _a.abort();
this.streamAbortControllers.delete(streamKey);
const process = this.activeFFmpegProcesses.get(streamKey);
if (process && !process.killed) {
process.kill('SIGTERM');
setTimeout(() => {
if (!process.killed) {
process.kill('SIGKILL');
}
}, 1000);
}
this.activeFFmpegProcesses.delete(streamKey);
}
async startPreBuffer() {
if (this.shuttingDown || this.preBufferSession) {
return;
}
if (!this.preBufferInitPromise) {
this.preBufferInitPromise = (async () => {
this.log.info(`[${this.cameraName}] Starting prebuffer for ${this.streamUrl}`);
const ffmpegInput = [
'-use_wallclock_as_timestamps', '1',
'-probesize', '200000',
'-analyzeduration', '0',
'-fflags', '+genpts+nobuffer+igndts',
'-flags', 'low_delay',
'-max_delay', '0',
'-thread_queue_size', '1024',
'-f', 'mjpeg',
'-re',
'-i', this.streamUrl,
];
if (this.base64auth) {
ffmpegInput.unshift('-headers', `Authorization: Basic ${this.base64auth}\r\n`);
}
this.preBuffer = new Prebuffer_1.PreBuffer(ffmpegInput, this.cameraName, this.videoProcessor, this.log);
this.preBufferSession = await this.preBuffer.startPreBuffer();
})()
.finally(() => {
this.preBufferInitPromise = undefined;
});
}
await this.preBufferInitPromise;
}
async *handleFragmentsRequests(config, streamKey, abortSignal) {
var _a;
if (!this.preBuffer) {
throw new Error('No video source configured');
}
if (this.isStreamClosed(streamKey, abortSignal)) {
return;
}
const input = await this.preBuffer.getVideo((_a = config.mediaContainerConfiguration.fragmentLength) !== null && _a !== void 0 ? _a : exports.PREBUFFER_LENGTH);
if (this.isStreamClosed(streamKey, abortSignal)) {
return;
}
const session = await this.startFFMPegFragmetedMP4Session(this.videoProcessor, input);
const { cp, generator } = session;
this.activeFFmpegProcesses.set(streamKey, cp);
const abortHandler = () => {
if (!cp.killed) {
cp.kill('SIGTERM');
setTimeout(() => {
if (!cp.killed) {
cp.kill('SIGKILL');
}
}, 1000);
}
};
abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.addEventListener('abort', abortHandler, { once: true });
let moofBuffer = null;
let pending = [];
let isFirst = true;
let fragmentCount = 0;
try {
this.log.info(`[${this.cameraName}] ๐น Starting video fragments generation for stream ID: ${streamKey}`, this.streamUrl);
for await (const box of generator) {
if (this.isStreamClosed(streamKey, abortSignal)) {
return;
}
pending.push(box.header, box.data);
if (isFirst && box.type === 'moov') {
this.log.info(`[${this.cameraName}] ๐ฆ First moov atom received, yielding initial fragment`, this.streamUrl);
if (this.isStreamClosed(streamKey, abortSignal)) {
return;
}
yield buffer_1.Buffer.concat(pending);
pending = [];
isFirst = false;
}
else if (box.type === 'moof') {
moofBuffer = buffer_1.Buffer.concat([box.header, box.data]);
this.log.debug(`[${this.cameraName}] ๐ฆ moof atom received`, this.streamUrl);
}
else if (box.type === 'mdat' && moofBuffer) {
const fragment = buffer_1.Buffer.concat([moofBuffer, box.header, box.data]);
fragmentCount++;
this.log.debug(`[${this.cameraName}] ๐ฆ Fragment ${fragmentCount} (moof+mdat, ${fragment.length} bytes)`, this.streamUrl);
if (this.isStreamClosed(streamKey, abortSignal)) {
return;
}
yield fragment;
moofBuffer = null;
}
}
this.log.info(`[${this.cameraName}] โ
Recording fragments generation completed. Total fragments: ${fragmentCount}`, this.streamUrl);
}
catch (e) {
if (this.isStreamClosed(streamKey, abortSignal) || cp.killed) {
return;
}
this.log.error(`[${this.cameraName}] โ Recording fragments generation error: ${e}`, this.streamUrl);
throw e;
}
finally {
abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.removeEventListener('abort', abortHandler);
this.log.info(`[${this.cameraName}] ๐งน Cleaning up recording session for stream ID: ${streamKey}`, this.streamUrl);
if (!cp.killed) {
cp.kill('SIGTERM');
setTimeout(() => {
if (!cp.killed) {
cp.kill('SIGKILL');
}
}, 1000);
}
this.activeFFmpegProcesses.delete(streamKey);
}
}
async startFFMPegFragmetedMP4Session(ffmpegPath, input) {
const args = ['-hide_banner', ...input,
'-f', 'mp4',
'-vcodec', 'copy',
'-an',
'-movflags', 'frag_keyframe+empty_moov+default_base_moof+omit_tfhd_offset',
'pipe:1'];
const cp = (0, child_process_1.spawn)(ffmpegPath, args, { env: process_1.env, stdio: ['pipe', 'pipe', 'pipe'] });
let intentionallyKilled = false;
const originalKill = cp.kill.bind(cp);
cp.kill = function (signal) {
intentionallyKilled = true;
return originalKill(signal);
};
cp.on('exit', (code, signal) => {
if (code === 0 || code === null) {
this.log.info(`[${this.cameraName}] [FFmpeg] exited successfully with code ${code}, signal ${signal}`);
}
else if (intentionallyKilled || signal === 'SIGTERM' || signal === 'SIGKILL') {
this.log.debug(`[${this.cameraName}] [FFmpeg] exited with code ${code}, signal ${signal} (intentional kill)`);
}
else {
this.log.error(`[${this.cameraName}] [FFmpeg] exited with code ${code}, signal ${signal}`);
}
});
if (cp.stderr) {
cp.stderr.on('data', data => {
const msg = data.toString();
if (intentionallyKilled && /Immediate exit requested/i.test(msg)) {
this.log.debug(`[${this.cameraName}] [FFmpeg stderr]: ${msg.trim()} (intentional stop)`);
return;
}
if (msg.includes('moov') || msg.toLowerCase().includes('error') || msg.toLowerCase().includes('failed')) {
this.log.warn(`[${this.cameraName}] [FFmpeg stderr]: ${msg.trim()}`);
}
});
}
async function* generator() {
try {
while (true) {
const header = await readLength(cp.stdout, 8);
const length = header.readInt32BE(0) - 8;
const type = header.slice(4).toString();
const data = await readLength(cp.stdout, length);
yield { header, length, type, data };
}
}
catch (error) {
if (cp.killed) {
return;
}
throw error;
}
}
return { cp, generator: generator() };
}
toStreamKey(streamId) {
return String(streamId);
}
isStreamClosed(streamKey, abortSignal) {
return this.closedStreams.has(streamKey) || !!(abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.aborted) || this.shuttingDown;
}
}
exports.RecordingDelegate = RecordingDelegate;
//# sourceMappingURL=RecordingDelegate.js.map