homebridge-unifi-protect
Version:
Homebridge UniFi Protect plugin providing complete HomeKit integration for the entire UniFi Protect ecosystem with full support for most features including HomeKit Secure Video, multiple controllers, blazing fast performance, and much more.
131 lines • 7.94 kB
JavaScript
import { FfmpegLivestreamProcess, splitMoofMdat } from "homebridge-plugin-utils";
import { ProtectCodecChangeError, ProtectLivestreamUnavailableError } from "unifi-protect";
// Process-local monotonic counter used to mint stable RTSP-debug subscription ids. A counter is sufficient here (no cross-process uniqueness needed) and keeps
// ids cheap, comparable, and greppable in logs.
let rtspSubscriptionCounter = 0;
/**
* The RTSP-debug adapter (Debug.Video.Timeshift.UseRtsp). It implements the plugin's LivestreamSubscription interface over a single FfmpegLivestreamProcess that
* transcodes the camera's RTSP stream into the same fMP4 Segment stream the unifi-protect library's pool produces, feeding the standing timeshift buffer from RTSP
* rather than the native livestream API. This is a pure-FFmpeg plugin concern, so it stays the plugin behind the seam, and it is the mechanical seed of a future
* first-class RTSP-fed-buffer option.
*
* There is deliberately NO recovery loop here: a failed RTSP transcode simply ends. The lifecycle state is correspondingly simple - "connecting" before the init
* segment resolves, "live" after, "closed" after disposal - and it never reports "recovering" (so a consumer's `isRestarting` is always false on this debug
* path, consistent with there being no recovery here). The underlying process consumes its abort signal itself at the spawn level, so disposing the adapter (or
* aborting the signal) tears the process down; the adapter adds no separate signal listener.
*/
export class RtspLivestreamSubscription {
id;
#initPromise;
#initSegment;
#disposed;
#proc;
#signal;
#state;
#videoCodec;
constructor(options) {
this.id = "rtsp-livestream-" + (++rtspSubscriptionCounter).toString();
this.#disposed = false;
this.#initSegment = null;
this.#signal = options.signal;
this.#state = "connecting";
this.#videoCodec = options.videoCodec;
this.#proc = new FfmpegLivestreamProcess(options.ffmpegOptions, {
audio: options.audio,
livestream: { codec: options.videoCodec, enableAudio: options.enableAudio, url: options.url },
segmentLength: options.segmentLength,
signal: options.signal
});
// Drive initSegment and whenEstablished from the process's init segment. We cache the resolved buffer and flip to "live" on success. We attach a no-op catch
// so a construct-then-dispose-without-iterating sequence cannot float an unhandled rejection; the genuine consumers read the resolved value through
// whenEstablished() and the iterator, both of which observe the original promise's outcome.
this.#initPromise = this.#proc.getInitSegment();
this.#initPromise.then((data) => {
if (this.#disposed) {
return;
}
this.#initSegment = { codec: this.#videoCodec, data: data };
this.#state = "live";
}, () => { });
}
// Synchronous peek at the cached fMP4 initialization segment, or null until the process's init segment resolves.
get initSegment() {
return this.#initSegment;
}
// The coarse lifecycle state. "connecting" before the init resolves, "live" after, "closed" after disposal. Never "recovering" - the debug path has no recovery.
get state() {
return this.#state;
}
// Re-decide an in-flight recovery. A no-op here because the debug path has no recovery loop to re-consult: a failed RTSP transcode simply ends rather than
// entering a deferred-stall state that an urgency escalation could shorten. The unifi-protect library's subscription implements this against its recovery FSM.
reassess() { }
// Resolves true once the init segment resolves (the establishment boundary), false if it rejects. This is INIT-keyed, whereas the unifi-protect library's
// whenEstablished is MEDIA-keyed (resolves on first media); both satisfy the consumer's only post-establish need (a populated initSegment), and the debug path has
// no media-keyed liveness gate.
async whenEstablished() {
try {
await this.#initPromise;
return true;
}
catch {
return false;
}
}
// The subscription is its own async iterable, yielding the init Segment first (matching the unifi-protect library's pool, which delivers init before media) then
// the media stream.
[Symbol.asyncIterator]() {
return this.#iterate();
}
// Yield the init Segment, then wrap each fMP4 fragment the process produces into a media Segment. Calling both getInitSegment() and segments() on the process is
// the intended two-views-on-one-drain pattern and does not double-consume.
async *#iterate() {
// The init buffer; this also drives initSegment / whenEstablished via the cached promise above.
const data = await this.#initPromise;
yield { codec: this.#videoCodec, data: data, type: "init" };
for await (const fragment of this.#proc.segments({ signal: this.#signal })) {
const split = splitMoofMdat(fragment);
// splitMoofMdat returns null only on a malformed fragment, which well-formed FFmpeg fMP4 does not produce; degrade safely (consumers read only .data) by
// treating the whole fragment as the mdat with an empty moof view rather than dropping the segment.
const moof = split?.moof ?? fragment.subarray(0, 0);
const mdat = split?.mdat ?? fragment;
yield { data: fragment, mdat: mdat, moof: moof, type: "media" };
}
}
// Dispose the underlying process. Idempotent - subsequent disposes after the first are no-ops.
async [Symbol.asyncDispose]() {
if (this.#disposed) {
return;
}
this.#disposed = true;
this.#state = "closed";
await this.#proc[Symbol.asyncDispose]();
}
}
/**
* Shared classification and logging for errors thrown from a livestream subscription iterator. Used by every consumer so the handling lives in one place.
* `consumer` is the subject of the log sentence (e.g. "Timeshift buffer", "Live streaming"). The `ProtectCodecChangeError` and `ProtectLivestreamUnavailableError`
* typed iterator errors from the unifi-protect library carry a known meaning we phrase for the user rather than surfacing as an unexpected failure: a codec change
* is a benign, self-correcting restart, and an exhausted recovery episode is the give-up the pool throws after repeated reconnect failures. Everything else is
* genuinely unexpected and logged with the error for diagnosis.
*
* @param options.consumer - The subject of the log sentence.
* @param options.error - The error thrown from the iterator.
* @param options.log - The logger to write to.
*/
export function logLivestreamIterationError(options) {
const { consumer, error, log } = options;
// A codec change is the controller renegotiating the stream format mid-flight. The pool tears the session down and re-establishes it on the new codec, so this
// is an expected, self-correcting restart rather than a failure.
if (error instanceof ProtectCodecChangeError) {
log.info(consumer + " is restarting because the livestream video format changed.");
return;
}
// The recovery episode exhausted its reconnect attempts and the policy gave up. We have already done what we can (the consumer's self-heal reboots a wedged
// camera), so we tell the user the stream could not be recovered rather than dumping an unexpected error.
if (error instanceof ProtectLivestreamUnavailableError) {
log.warn(consumer + " could not be recovered after repeated attempts.");
return;
}
log.error(consumer + " iteration terminated unexpectedly.", { error });
}
//# sourceMappingURL=livestream.js.map