@camstack/addon-pipeline 1.2.372 → 1.2.374
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audio-analyzer/index.js +3 -3
- package/dist/audio-analyzer/index.mjs +3 -3
- package/dist/{custom-model-catalog-C6C2cSwd.mjs → custom-model-catalog-1LDuDw8S.mjs} +2 -2
- package/dist/{custom-model-catalog-n9v8MUL6.js → custom-model-catalog-W0DHDhfo.js} +2 -2
- package/dist/detection-pipeline/index.js +4 -4
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-DmuX7IFJ.js → dist-CBEUVjTS.js} +197 -29
- package/dist/{dist-8AEiKOFO.mjs → dist-CmdLCb_F.mjs} +192 -30
- package/dist/{model-catalogs-BSWAHZJh.mjs → model-catalogs-BnCdB2A3.mjs} +1 -1
- package/dist/{model-catalogs-DO4aA1qo.js → model-catalogs-D5YksA5A.js} +1 -1
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-Bd4va7c-.mjs → node-C3r8kiOo.mjs} +1 -1
- package/dist/{node-DqA6nD1z.js → node-DMgi6pND.js} +1 -1
- package/dist/pipeline-runner/index.js +3 -3
- package/dist/pipeline-runner/index.mjs +3 -3
- package/dist/{process-memory-2OQ2oxrz.js → process-memory-GFR7fxpJ.js} +1 -1
- package/dist/{process-memory-BvS2Z88w.mjs → process-memory-X0ws1SGJ.mjs} +1 -1
- package/dist/recorder/index.js +267 -94
- package/dist/recorder/index.mjs +265 -92
- package/dist/{segment-demux-js-BkGK1rhu.js → segment-demux-js-CSQVhbFI.js} +1 -1
- package/dist/{segment-demux-js-By9MLn_g.mjs → segment-demux-js-IKEO1XBz.mjs} +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DjoAY42S.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-DPFTlVoW.mjs} +2 -2
- package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-C3ssSNKh.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-B7wsxLrK.mjs} +1 -1
- package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-nua-KuDg.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-BohOb2Cl.mjs} +1 -1
- package/dist/stream-broker/demux-worker-child.js +1 -1
- package/dist/stream-broker/demux-worker-child.mjs +1 -1
- package/dist/stream-broker/{hostInit-DPiGNc5u.mjs → hostInit-BOp1_Hhk.mjs} +2 -2
- package/dist/stream-broker/index.js +1087 -334
- package/dist/stream-broker/index.mjs +1087 -334
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +5 -1
- package/dist/retire-root-keys-Csb8z2f3.js +0 -338
- package/dist/retire-root-keys-DZvlso2l.mjs +0 -327
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { r as __toESM } from "../chunk-DnnnRqeS.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-
|
|
2
|
+
import { An as errMsg, At as gifFromMp4Args, Bt as memoizePinnedFfmpeg, D as G711_SCALE_CORRECTION_DB, Dn as isSoftwareDecode, Dt as ffmpegRate, En as invocationFromEncodeProfile, Ft as loadContributionCapability, Gn as isSecretMarker, I as RATE_CONTROL_RELAXED, It as logChannelsCapability, J as addonWidgetsSourceCapability, Jn as maskUrlCredentials, Kn as makeProfileBrokerId, L as RATE_CONTROL_TIGHT, Mn as BaseAddon, Nn as CAM_PROFILE_ORDER, On as DeviceFeature, Pn as CamProfileSchema, Qt as rankStreams, Rn as createEvent, Vn as expandAudioChunkToF32le, W as RingBuffer, Wn as isEvent, Xn as parseProfileBrokerId, Yn as nodePin, Z as armRemovalReconcile, an as resolveEgressDecodeHwAccel, ar as literal, bn as webrtcSessionCapability, bt as egressTransportFromRequest, c as CLIP_STILL_REASON_CODE_HEADER, dr as union, dt as declareLogChannel, er as _enum, fr as EventCategory, gn as streamBrokerCapability, it as cameraStreamsCapability, jn as BOOT_RECOVERY_BACKOFF_MS, kn as DeviceType, kt as gifDeriveSpeed, lr as record, lt as createHwAccelCache, nr as boolean, o as BatteryStatusSchema, or as number, ot as clipExportFailureOf, pt as deriveBatteryPresence, qn as makeSourceBrokerId, rr as discriminatedUnion, s as CLIP_BROKER_PACED_RATES, sr as object, tr as array, tt as batteryCapability, ur as string, ut as createLogChannelsProvider, vt as deviceRemovalHookCapability, w as EncodeProfileSchema, wn as buildFfmpegArgs, xn as AUDIO_PRESETS, yt as egressTranscodeSharingKey } from "../dist-CmdLCb_F.mjs";
|
|
3
|
+
import { i as NO_COST_CLAIM, n as Fmp4FragmentChild, o as probeFfmpegBinary, r as Fmp4FragmentPlane, t as ChildCostRegistry } from "../node-C3r8kiOo.mjs";
|
|
4
4
|
import { a as parseRestreamPath, d as PREBUFFER_MAX_MS, f as capPreRollRequestMs, i as isLiveEdgeOnlyDial, m as resolvePrebuffer, n as NO_DECLARATION, p as prebufferRetentionMs, s as sdpVariantFor, u as withSentryIntent } from "../restream-intent-DyGmLAU9.mjs";
|
|
5
5
|
import { n as profileForStreamId } from "../remote-restream-BeHi78PZ.mjs";
|
|
6
|
-
import { n as
|
|
7
|
-
import { a as require_mp4box_all, c as parseNalLengthSize, i as runFfmpegOnce, n as demuxSegmentPreferJs, o as avccSampleToAnnexB, r as FfmpegFallbackRefusedError, s as buildKeyframeAnnexB, t as demuxSegmentFullJs } from "../segment-demux-js-By9MLn_g.mjs";
|
|
6
|
+
import { a as require_mp4box_all, c as parseNalLengthSize, i as runFfmpegOnce, n as demuxSegmentPreferJs, o as avccSampleToAnnexB, r as FfmpegFallbackRefusedError, s as buildKeyframeAnnexB, t as demuxSegmentFullJs } from "../segment-demux-js-IKEO1XBz.mjs";
|
|
8
7
|
import { createRequire } from "node:module";
|
|
9
8
|
import * as os$1 from "node:os";
|
|
10
9
|
import { networkInterfaces, tmpdir } from "node:os";
|
|
@@ -360,207 +359,253 @@ function formatProbedSummary(declared, snapshot, opts) {
|
|
|
360
359
|
return parts.join(SEP);
|
|
361
360
|
}
|
|
362
361
|
//#endregion
|
|
363
|
-
//#region src/stream-broker/broker-
|
|
364
|
-
/**
|
|
365
|
-
* The stream-broker's
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
* The row survives either way: it is also where `BaseAddon.resolveConfig` reads
|
|
390
|
-
* the operator's config fields from. The problem was a 33 KB row, not a blob.
|
|
391
|
-
*/
|
|
392
|
-
/**
|
|
393
|
-
* The addon id — the settings-store namespace whose `root` row the flattened
|
|
394
|
-
* keys are being retired from. Matches `packages/addon-pipeline/package.json`
|
|
395
|
-
* (`camstack.addons[].id`).
|
|
396
|
-
*/
|
|
397
|
-
var STREAM_BROKER_ADDON_ID = "stream-broker";
|
|
398
|
-
/**
|
|
399
|
-
* Every one of these collections is keyed by the BROKER ID
|
|
400
|
-
* (`<deviceId>/<camStreamId>`) or by the numeric deviceId. `brokerDeviceId`
|
|
401
|
-
* recovers the numeric device from either, for the projected column that makes
|
|
402
|
-
* a per-camera question an indexed query — and for `tags: { deviceId }` on
|
|
403
|
-
* every line these stores log.
|
|
404
|
-
*
|
|
405
|
-
* Returns `null` rather than guessing: a key whose head is not a number is a
|
|
406
|
-
* key this addon did not write, and inventing a device for it would put a row
|
|
407
|
-
* on a camera at random.
|
|
408
|
-
*/
|
|
409
|
-
function brokerDeviceId(key) {
|
|
410
|
-
const head = key.includes("/") ? key.slice(0, key.indexOf("/")) : key;
|
|
411
|
-
const id = Number(head);
|
|
412
|
-
return Number.isInteger(id) && id > 0 ? id : null;
|
|
413
|
-
}
|
|
414
|
-
/**
|
|
415
|
-
* Bound for a whole-map read. `settings-store.query` silently caps an unbounded
|
|
416
|
-
* read at 2 000 rows and answers as if that were everything; at 974 devices ×
|
|
417
|
-
* up to 5 streams the default is HALF the margin, so every read here names its
|
|
418
|
-
* own ceiling and a truncation is reported rather than shrunk.
|
|
419
|
-
*/
|
|
420
|
-
var BROKER_ROWS_LIMIT = 2e4;
|
|
421
|
-
/**
|
|
422
|
-
* @durable class=mirror owner=stream-broker
|
|
423
|
-
* write="one row per broker (`<deviceId>/<camStreamId>`), replaced wholesale from the
|
|
424
|
-
* manager's live probe map by the throttled metrics flush and once on shutdown"
|
|
425
|
-
* retention="the flush is authoritative: a broker the manager no longer holds loses its
|
|
426
|
-
* row. Losing the whole collection costs the settings 'Probed' field showing 'not yet
|
|
427
|
-
* probed' until the stream is pulled again — it is re-derived from the next probe."
|
|
428
|
-
*/
|
|
429
|
-
var BROKER_PROBE_SNAPSHOTS_COLLECTION = "stream-broker:probe-snapshots";
|
|
430
|
-
var BROKER_PROBE_SNAPSHOTS_SPEC = {
|
|
431
|
-
collection: BROKER_PROBE_SNAPSHOTS_COLLECTION,
|
|
432
|
-
schema: ProbedSnapshotSchema,
|
|
433
|
-
columns: [{
|
|
434
|
-
name: "deviceId",
|
|
435
|
-
type: "INTEGER"
|
|
436
|
-
}, {
|
|
437
|
-
name: "at",
|
|
438
|
-
type: "INTEGER",
|
|
439
|
-
notNull: true
|
|
440
|
-
}],
|
|
441
|
-
indexes: [{
|
|
442
|
-
name: "idx_stream_broker_probe_device",
|
|
443
|
-
columns: ["deviceId"]
|
|
444
|
-
}],
|
|
445
|
-
project: (key, value) => ({
|
|
446
|
-
deviceId: brokerDeviceId(key),
|
|
447
|
-
at: value.at
|
|
448
|
-
}),
|
|
449
|
-
deviceIdColumn: "deviceId",
|
|
450
|
-
loadLimit: BROKER_ROWS_LIMIT
|
|
451
|
-
};
|
|
452
|
-
function probeSnapshotsStore(store, logger) {
|
|
453
|
-
return new RowMapStore({
|
|
454
|
-
spec: BROKER_PROBE_SNAPSHOTS_SPEC,
|
|
455
|
-
store,
|
|
456
|
-
logger
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
/**
|
|
460
|
-
* The token, wrapped in an object rather than stored bare.
|
|
461
|
-
*
|
|
462
|
-
* A bare string in a `JSON` column round-trips only because the engine's
|
|
463
|
-
* decoder JSON-parses a stored string ONLY when it starts with `{` or `[`. That
|
|
464
|
-
* is a heuristic, not a contract; a token is opaque hex today and a future
|
|
465
|
-
* format that starts with a brace would come back as an object. One field costs
|
|
466
|
-
* nothing and makes the row self-describing.
|
|
467
|
-
*/
|
|
468
|
-
var RtspTokenRowSchema = object({ token: string().min(1) });
|
|
469
|
-
/**
|
|
470
|
-
* @durable class=ledger owner=stream-broker
|
|
471
|
-
* write="one row per broker (`<deviceId>/<camStreamId>`), upserted when the restream
|
|
472
|
-
* server mints a token. MERGE only — a token is stable forever per stream unless it
|
|
473
|
-
* is explicitly regenerated"
|
|
474
|
-
* retention="nothing deletes a row. The persister receives only the CURRENTLY registered
|
|
475
|
-
* restreamers, so a replace would erase the token of an unregistered broker and turn
|
|
476
|
-
* an external NVR URL into a dead 404 at the next idle or reboot."
|
|
477
|
-
*/
|
|
478
|
-
var BROKER_RTSP_TOKENS_COLLECTION = "stream-broker:rtsp-tokens";
|
|
479
|
-
var BROKER_RTSP_TOKENS_SPEC = {
|
|
480
|
-
collection: BROKER_RTSP_TOKENS_COLLECTION,
|
|
481
|
-
schema: RtspTokenRowSchema,
|
|
482
|
-
columns: [{
|
|
483
|
-
name: "deviceId",
|
|
484
|
-
type: "INTEGER"
|
|
485
|
-
}],
|
|
486
|
-
indexes: [{
|
|
487
|
-
name: "idx_stream_broker_rtsp_tokens_device",
|
|
488
|
-
columns: ["deviceId"]
|
|
489
|
-
}],
|
|
490
|
-
project: (key) => ({ deviceId: brokerDeviceId(key) }),
|
|
491
|
-
deviceIdColumn: "deviceId",
|
|
492
|
-
loadLimit: BROKER_ROWS_LIMIT
|
|
493
|
-
};
|
|
494
|
-
function rtspTokensStore(store, logger) {
|
|
495
|
-
return new RowMapStore({
|
|
496
|
-
spec: BROKER_RTSP_TOKENS_SPEC,
|
|
497
|
-
store,
|
|
498
|
-
logger
|
|
499
|
-
});
|
|
500
|
-
}
|
|
501
|
-
/** One camera's profile → camStreamId assignment, plus whether it is automatic. */
|
|
362
|
+
//#region src/stream-broker/broker-device-state.ts
|
|
363
|
+
/**
|
|
364
|
+
* The stream-broker's per-camera state, kept as per-device settings (D870).
|
|
365
|
+
*
|
|
366
|
+
* Three maps used to be collections of their own (`stream-broker:probe-snapshots`,
|
|
367
|
+
* `:profile-map`, `:rtsp-tokens`), each keyed by the camera. A camera's data in
|
|
368
|
+
* a side table is a row somebody must remember to delete when the camera goes;
|
|
369
|
+
* as a KEY of the camera's own settings it is cleared by the central device
|
|
370
|
+
* delete (D865) with everything else.
|
|
371
|
+
*
|
|
372
|
+
* | key | value for one camera |
|
|
373
|
+
* | ---------------- | --------------------------------------------------------- |
|
|
374
|
+
* | `probeSnapshots` | `{ [camStreamId]: ProbedSnapshot }` - what the broker last probed |
|
|
375
|
+
* | `brokerProfile` | `{ map, auto }` - the profile -> camStreamId assignment |
|
|
376
|
+
* | `rtspTokens` | JSON text of `{ [camStreamId]: token }` - a SECRET: declared in `secretStatePaths`, so the settings view stores a keychain marker and the text lives in the keychain |
|
|
377
|
+
*
|
|
378
|
+
* Failure direction: a load that cannot ask the store THROWS (D49). The writers
|
|
379
|
+
* built on {@link DeviceKeyMirror} refuse to write until a load has succeeded,
|
|
380
|
+
* because a replace driven by an empty "known" set would otherwise drop or
|
|
381
|
+
* shrink rows it never saw.
|
|
382
|
+
*/
|
|
383
|
+
/** Per-device settings keys. */
|
|
384
|
+
var PROBE_SNAPSHOTS_KEY = "probeSnapshots";
|
|
385
|
+
var BROKER_PROFILE_KEY = "brokerProfile";
|
|
386
|
+
var RTSP_TOKENS_KEY = "rtspTokens";
|
|
387
|
+
/** One camera's profile -> camStreamId assignment, plus whether it is automatic. */
|
|
502
388
|
var ProfileAssignmentSchema = object({
|
|
503
389
|
map: record(string(), string()),
|
|
504
390
|
auto: boolean()
|
|
505
391
|
});
|
|
392
|
+
var ProbeSnapshotsOfDeviceSchema = record(string(), ProbedSnapshotSchema);
|
|
393
|
+
var RtspTokensOfDeviceSchema = record(string(), string().min(1));
|
|
506
394
|
/**
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
|
|
395
|
+
* A broker id is `<deviceId>/<camStreamId>`. Returns `null` rather than
|
|
396
|
+
* guessing: a key whose head is not a number is a key this addon did not
|
|
397
|
+
* write, and inventing a device for it would put a value on a camera at random.
|
|
398
|
+
*/
|
|
399
|
+
function splitBrokerId(brokerId) {
|
|
400
|
+
const slash = brokerId.indexOf("/");
|
|
401
|
+
if (slash <= 0) return null;
|
|
402
|
+
const deviceId = Number(brokerId.slice(0, slash));
|
|
403
|
+
if (!Number.isInteger(deviceId) || deviceId <= 0) return null;
|
|
404
|
+
return {
|
|
405
|
+
deviceId,
|
|
406
|
+
camStreamId: brokerId.slice(slash + 1)
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
/** Group `<deviceId>/<camStreamId>` -> value into deviceId -> camStreamId -> value. */
|
|
410
|
+
function groupByDevice(flat) {
|
|
411
|
+
const entries = flat instanceof Map ? [...flat.entries()] : Object.entries(flat);
|
|
412
|
+
const out = /* @__PURE__ */ new Map();
|
|
413
|
+
for (const [brokerId, value] of entries) {
|
|
414
|
+
const parts = splitBrokerId(brokerId);
|
|
415
|
+
if (parts === null) continue;
|
|
416
|
+
const group = out.get(parts.deviceId) ?? {};
|
|
417
|
+
group[parts.camStreamId] = value;
|
|
418
|
+
out.set(parts.deviceId, group);
|
|
419
|
+
}
|
|
420
|
+
return out;
|
|
421
|
+
}
|
|
422
|
+
/** The inverse of {@link groupByDevice}. */
|
|
423
|
+
function flattenByDevice(grouped) {
|
|
424
|
+
const out = /* @__PURE__ */ new Map();
|
|
425
|
+
for (const [deviceId, group] of grouped) for (const [camStreamId, value] of Object.entries(group)) out.set(`${deviceId}/${camStreamId}`, value);
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
/** A stored value that exists but cannot be read now (see `DeviceKeyMirrorDeps.decode`). */
|
|
429
|
+
var HELD_UNAVAILABLE = Symbol("held-unavailable");
|
|
430
|
+
/**
|
|
431
|
+
* One settings key across the fleet: loaded once, then written by diff.
|
|
432
|
+
*
|
|
433
|
+
* `replace` is authoritative (a camera the caller no longer holds loses the
|
|
434
|
+
* key, but only if this mirror has SEEN it - it never deletes what it did not
|
|
435
|
+
* load); `merge` only ever adds and changes.
|
|
436
|
+
*/
|
|
437
|
+
var DeviceKeyMirror = class {
|
|
438
|
+
deps;
|
|
439
|
+
/** The JSON of what the store holds per device, as of the last load or write. */
|
|
440
|
+
held = /* @__PURE__ */ new Map();
|
|
441
|
+
values = /* @__PURE__ */ new Map();
|
|
442
|
+
blocked = /* @__PURE__ */ new Map();
|
|
443
|
+
/** Cameras whose stored value is GONE: a load never re-blocks them. */
|
|
444
|
+
released = /* @__PURE__ */ new Set();
|
|
445
|
+
loaded = false;
|
|
446
|
+
constructor(deps) {
|
|
447
|
+
this.deps = deps;
|
|
448
|
+
}
|
|
449
|
+
/** Read the key of every camera that has it. THROWS when the store cannot be asked. */
|
|
450
|
+
async load() {
|
|
451
|
+
const ids = await this.deps.settings.listDeviceStoreIds();
|
|
452
|
+
const blobs = await this.deps.settings.readDeviceStoreBatch(ids);
|
|
453
|
+
const out = /* @__PURE__ */ new Map();
|
|
454
|
+
this.held.clear();
|
|
455
|
+
this.values.clear();
|
|
456
|
+
this.loaded = false;
|
|
457
|
+
this.blocked.clear();
|
|
458
|
+
for (const [deviceId, blob] of blobs) {
|
|
459
|
+
const stored = blob[this.deps.key];
|
|
460
|
+
if (stored === void 0) continue;
|
|
461
|
+
const value = this.deps.decode(stored);
|
|
462
|
+
if (value === HELD_UNAVAILABLE) {
|
|
463
|
+
if (!this.released.has(deviceId)) this.blocked.set(deviceId, stored);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (value === null) {
|
|
467
|
+
this.deps.logger.warn("broker device state skipped - it does not parse", {
|
|
468
|
+
tags: { deviceId },
|
|
469
|
+
meta: { key: this.deps.key }
|
|
470
|
+
});
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
out.set(deviceId, value);
|
|
474
|
+
this.values.set(deviceId, value);
|
|
475
|
+
this.held.set(deviceId, JSON.stringify(this.deps.encode(value)));
|
|
476
|
+
}
|
|
477
|
+
this.loaded = true;
|
|
478
|
+
return out;
|
|
479
|
+
}
|
|
480
|
+
/** Cameras whose stored value exists but could not be read: never written, never replaced. */
|
|
481
|
+
heldDevices() {
|
|
482
|
+
return this.blocked;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Stop protecting a camera whose stored value is GONE (the secret was deleted
|
|
486
|
+
* or the keychain restored without it): there is nothing left to overwrite.
|
|
487
|
+
*/
|
|
488
|
+
release(deviceId) {
|
|
489
|
+
this.blocked.delete(deviceId);
|
|
490
|
+
this.released.add(deviceId);
|
|
491
|
+
}
|
|
492
|
+
/** Is this camera's token write (and restream URL) blocked right now? */
|
|
493
|
+
isBlocked(deviceId) {
|
|
494
|
+
return this.blocked.has(deviceId);
|
|
495
|
+
}
|
|
496
|
+
assertLoaded() {
|
|
497
|
+
if (!this.loaded) throw new Error(`broker device state "${this.deps.key}" was never loaded - refusing to write`);
|
|
498
|
+
}
|
|
499
|
+
async writeChanged(next) {
|
|
500
|
+
let written = 0;
|
|
501
|
+
for (const [deviceId, value] of next) {
|
|
502
|
+
if (this.blocked.has(deviceId)) continue;
|
|
503
|
+
const encoded = this.deps.encode(value);
|
|
504
|
+
const text = JSON.stringify(encoded);
|
|
505
|
+
if (this.held.get(deviceId) === text) continue;
|
|
506
|
+
await this.deps.settings.writeDeviceStore(deviceId, { [this.deps.key]: encoded });
|
|
507
|
+
this.held.set(deviceId, text);
|
|
508
|
+
this.values.set(deviceId, value);
|
|
509
|
+
written += 1;
|
|
510
|
+
}
|
|
511
|
+
return written;
|
|
512
|
+
}
|
|
513
|
+
/** Make the store hold exactly `next` for the cameras this mirror knows. */
|
|
514
|
+
async replace(next) {
|
|
515
|
+
this.assertLoaded();
|
|
516
|
+
const written = await this.writeChanged(next);
|
|
517
|
+
let removed = 0;
|
|
518
|
+
for (const deviceId of [...this.held.keys()]) {
|
|
519
|
+
if (next.has(deviceId) || this.blocked.has(deviceId)) continue;
|
|
520
|
+
await this.deps.settings.writeDeviceStore(deviceId, { [this.deps.key]: void 0 });
|
|
521
|
+
this.held.delete(deviceId);
|
|
522
|
+
this.values.delete(deviceId);
|
|
523
|
+
removed += 1;
|
|
524
|
+
}
|
|
525
|
+
return {
|
|
526
|
+
written,
|
|
527
|
+
removed
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
/** Add or change; never remove. */
|
|
531
|
+
async merge(next) {
|
|
532
|
+
this.assertLoaded();
|
|
533
|
+
return this.writeChanged(next);
|
|
534
|
+
}
|
|
535
|
+
/** Drop one camera's key (a device that changed hardware under its number). */
|
|
536
|
+
async remove(deviceId) {
|
|
537
|
+
this.assertLoaded();
|
|
538
|
+
if (!this.held.has(deviceId) || this.blocked.has(deviceId)) return false;
|
|
539
|
+
await this.deps.settings.writeDeviceStore(deviceId, { [this.deps.key]: void 0 });
|
|
540
|
+
this.held.delete(deviceId);
|
|
541
|
+
this.values.delete(deviceId);
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
/** What the store holds for the camera as of the last load or write. */
|
|
545
|
+
current(deviceId) {
|
|
546
|
+
return this.values.get(deviceId);
|
|
547
|
+
}
|
|
548
|
+
/** Whether a load has answered; a writer that was never loaded refuses. */
|
|
549
|
+
get isLoaded() {
|
|
550
|
+
return this.loaded;
|
|
551
|
+
}
|
|
534
552
|
};
|
|
535
|
-
function
|
|
536
|
-
return
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
553
|
+
function parseWith(schema) {
|
|
554
|
+
return (stored) => {
|
|
555
|
+
const parsed = schema.safeParse(stored);
|
|
556
|
+
return parsed.success ? parsed.data : null;
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
function probeSnapshotsMirror(settings, logger) {
|
|
560
|
+
return new DeviceKeyMirror({
|
|
561
|
+
settings,
|
|
562
|
+
key: PROBE_SNAPSHOTS_KEY,
|
|
563
|
+
logger,
|
|
564
|
+
decode: parseWith(ProbeSnapshotsOfDeviceSchema),
|
|
565
|
+
encode: (value) => value
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
function profileMapMirror(settings, logger) {
|
|
569
|
+
return new DeviceKeyMirror({
|
|
570
|
+
settings,
|
|
571
|
+
key: BROKER_PROFILE_KEY,
|
|
572
|
+
logger,
|
|
573
|
+
decode: parseWith(ProfileAssignmentSchema),
|
|
574
|
+
encode: (value) => value
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
/** The tokens of one camera are ONE keychain-held text: JSON of `{ camStreamId: token }`. */
|
|
578
|
+
function rtspTokensMirror(settings, logger) {
|
|
579
|
+
return new DeviceKeyMirror({
|
|
580
|
+
settings,
|
|
581
|
+
key: RTSP_TOKENS_KEY,
|
|
582
|
+
logger,
|
|
583
|
+
decode: (stored) => {
|
|
584
|
+
if (isSecretMarker(stored)) return HELD_UNAVAILABLE;
|
|
585
|
+
if (typeof stored !== "string") return null;
|
|
586
|
+
try {
|
|
587
|
+
const parsed = RtspTokensOfDeviceSchema.safeParse(JSON.parse(stored));
|
|
588
|
+
return parsed.success ? parsed.data : null;
|
|
589
|
+
} catch {
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
},
|
|
593
|
+
encode: (value) => JSON.stringify(value)
|
|
540
594
|
});
|
|
541
595
|
}
|
|
542
596
|
/**
|
|
543
|
-
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
597
|
+
* Fold freshly minted tokens (`<deviceId>/<camStreamId>` -> token) into what
|
|
598
|
+
* each camera already holds. Only ever adds or changes: a token is stable
|
|
599
|
+
* forever per stream unless explicitly regenerated.
|
|
546
600
|
*/
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
successor: BROKER_RTSP_TOKENS_COLLECTION,
|
|
556
|
-
reason: "one row per broker since the flatten; as a blob a single minted token rewrote 33 KB"
|
|
557
|
-
},
|
|
558
|
-
{
|
|
559
|
-
key: "profileMap",
|
|
560
|
-
successor: BROKER_PROFILE_MAP_COLLECTION,
|
|
561
|
-
reason: "one row per camera since the flatten; a per-DEVICE map, so as a blob it grew with the fleet — 2.3 KB at 29 cameras, ~77 KB at 974"
|
|
562
|
-
}
|
|
563
|
-
];
|
|
601
|
+
function mergeRtspTokens(mirror, minted) {
|
|
602
|
+
const out = /* @__PURE__ */ new Map();
|
|
603
|
+
for (const [deviceId, group] of groupByDevice(minted)) out.set(deviceId, {
|
|
604
|
+
...mirror.current(deviceId),
|
|
605
|
+
...group
|
|
606
|
+
});
|
|
607
|
+
return out;
|
|
608
|
+
}
|
|
564
609
|
//#endregion
|
|
565
610
|
//#region src/stream-broker/live-viewers.ts
|
|
566
611
|
/**
|
|
@@ -3085,6 +3130,223 @@ var RtpPacketizer = class {
|
|
|
3085
3130
|
};
|
|
3086
3131
|
}
|
|
3087
3132
|
};
|
|
3133
|
+
function isTransferable(buf) {
|
|
3134
|
+
return typeof Reflect.get(buf, "transfer") === "function";
|
|
3135
|
+
}
|
|
3136
|
+
var Slab = class {
|
|
3137
|
+
now;
|
|
3138
|
+
buffer;
|
|
3139
|
+
offset = 0;
|
|
3140
|
+
holds = 0;
|
|
3141
|
+
state = "active";
|
|
3142
|
+
releasedAt = 0;
|
|
3143
|
+
constructor(buffer, now) {
|
|
3144
|
+
this.now = now;
|
|
3145
|
+
this.buffer = buffer;
|
|
3146
|
+
}
|
|
3147
|
+
};
|
|
3148
|
+
/** ArrayBuffer -> slab, for every slab ArrayBuffer currently in service. */
|
|
3149
|
+
var slabOf = /* @__PURE__ */ new WeakMap();
|
|
3150
|
+
var slabsLive = 0;
|
|
3151
|
+
var slabsFree = 0;
|
|
3152
|
+
var slabAllocs = 0;
|
|
3153
|
+
var slabRecycles = 0;
|
|
3154
|
+
function readSlabTotals() {
|
|
3155
|
+
return {
|
|
3156
|
+
live: slabsLive,
|
|
3157
|
+
free: slabsFree,
|
|
3158
|
+
allocs: slabAllocs,
|
|
3159
|
+
recycles: slabRecycles
|
|
3160
|
+
};
|
|
3161
|
+
}
|
|
3162
|
+
/** True when `buf` is a view of a pool slab. */
|
|
3163
|
+
function isSlabView(buf) {
|
|
3164
|
+
return slabOf.has(buf.buffer);
|
|
3165
|
+
}
|
|
3166
|
+
/** A retaining structure keeps `buf`: its slab cannot be recycled until released. */
|
|
3167
|
+
function holdPacket(buf) {
|
|
3168
|
+
const slab = slabOf.get(buf.buffer);
|
|
3169
|
+
if (slab === void 0) return;
|
|
3170
|
+
slab.holds += 1;
|
|
3171
|
+
if (slab.state === "quarantined") slab.state = "held";
|
|
3172
|
+
}
|
|
3173
|
+
/** The matching drop. Safe on a non-slab buffer and on a stale (recycled) view. */
|
|
3174
|
+
function releasePacket(buf) {
|
|
3175
|
+
const slab = slabOf.get(buf.buffer);
|
|
3176
|
+
if (slab === void 0 || slab.holds === 0) return;
|
|
3177
|
+
slab.holds -= 1;
|
|
3178
|
+
if (slab.holds === 0 && slab.state === "held") {
|
|
3179
|
+
slab.state = "quarantined";
|
|
3180
|
+
slab.releasedAt = slab.now();
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
function releasePackets(bufs) {
|
|
3184
|
+
for (const b of bufs) releasePacket(b);
|
|
3185
|
+
}
|
|
3186
|
+
/**
|
|
3187
|
+
* A consumer's own hold on the last `limit` packets it forwarded.
|
|
3188
|
+
*
|
|
3189
|
+
* werift keeps the last RTP_HISTORY_SIZE (4096) packets of each sender for NACK
|
|
3190
|
+
* retransmission, and those packets' payloads are views of the broker's slabs.
|
|
3191
|
+
* The session mirrors that window here: each forwarded packet is held, and
|
|
3192
|
+
* the one falling out of the window is released, so a retransmit asked for
|
|
3193
|
+
* within the history is served from bytes nobody has recycled.
|
|
3194
|
+
*/
|
|
3195
|
+
var HeldPacketWindow = class {
|
|
3196
|
+
limit;
|
|
3197
|
+
slots;
|
|
3198
|
+
next = 0;
|
|
3199
|
+
constructor(limit) {
|
|
3200
|
+
this.limit = limit;
|
|
3201
|
+
this.slots = new Array(limit).fill(void 0);
|
|
3202
|
+
}
|
|
3203
|
+
hold(buf) {
|
|
3204
|
+
holdPacket(buf);
|
|
3205
|
+
const evicted = this.slots[this.next];
|
|
3206
|
+
if (evicted !== void 0) releasePacket(evicted);
|
|
3207
|
+
this.slots[this.next] = buf;
|
|
3208
|
+
this.next = (this.next + 1) % this.limit;
|
|
3209
|
+
}
|
|
3210
|
+
releaseAll() {
|
|
3211
|
+
for (let i = 0; i < this.slots.length; i++) {
|
|
3212
|
+
const held = this.slots[i];
|
|
3213
|
+
if (held !== void 0) releasePacket(held);
|
|
3214
|
+
this.slots[i] = void 0;
|
|
3215
|
+
}
|
|
3216
|
+
this.next = 0;
|
|
3217
|
+
}
|
|
3218
|
+
};
|
|
3219
|
+
var PacketSlabPool = class {
|
|
3220
|
+
slabBytes;
|
|
3221
|
+
maxPacket;
|
|
3222
|
+
quarantineMs;
|
|
3223
|
+
maxFree;
|
|
3224
|
+
now;
|
|
3225
|
+
active = null;
|
|
3226
|
+
/** Sealed slabs, oldest first; entries may have been re-held since. */
|
|
3227
|
+
sealed = [];
|
|
3228
|
+
free = [];
|
|
3229
|
+
allocs = 0;
|
|
3230
|
+
recycles = 0;
|
|
3231
|
+
ownBuffers = 0;
|
|
3232
|
+
constructor(options = {}) {
|
|
3233
|
+
this.slabBytes = options.slabBytes ?? 65536;
|
|
3234
|
+
this.maxPacket = this.slabBytes >> 2;
|
|
3235
|
+
this.quarantineMs = options.quarantineMs ?? 2e3;
|
|
3236
|
+
this.maxFree = options.maxFree ?? 2;
|
|
3237
|
+
this.now = options.now ?? Date.now;
|
|
3238
|
+
}
|
|
3239
|
+
/**
|
|
3240
|
+
* A copy of `src[start, end)` in this pool's active slab. An oversized
|
|
3241
|
+
* packet gets its own buffer (it owns its ArrayBuffer, like the old
|
|
3242
|
+
* `copyOwned`).
|
|
3243
|
+
*/
|
|
3244
|
+
copy(src, start, end) {
|
|
3245
|
+
const length = end - start;
|
|
3246
|
+
recordCopyOwned(length);
|
|
3247
|
+
if (length > this.maxPacket) {
|
|
3248
|
+
this.ownBuffers += 1;
|
|
3249
|
+
const own = Buffer.allocUnsafeSlow(length);
|
|
3250
|
+
src.copy(own, 0, start, end);
|
|
3251
|
+
return own;
|
|
3252
|
+
}
|
|
3253
|
+
let slab = this.active;
|
|
3254
|
+
if (slab === null || slab.offset + length > this.slabBytes) slab = this.rotate();
|
|
3255
|
+
const view = Buffer.from(slab.buffer, slab.offset, length);
|
|
3256
|
+
src.copy(view, 0, start, end);
|
|
3257
|
+
slab.offset += length;
|
|
3258
|
+
return view;
|
|
3259
|
+
}
|
|
3260
|
+
/**
|
|
3261
|
+
* `buf` itself when it is already owned (a slab view or a whole-ArrayBuffer
|
|
3262
|
+
* buffer), else a copy in the active slab. Used for packets that arrive from
|
|
3263
|
+
* paths that do not cut through {@link copy} (push-rtp).
|
|
3264
|
+
*/
|
|
3265
|
+
adopt(buf) {
|
|
3266
|
+
if (isSlabView(buf)) return buf;
|
|
3267
|
+
if (buf.byteOffset === 0 && buf.buffer.byteLength === buf.length) return buf;
|
|
3268
|
+
return this.copy(buf, 0, buf.length);
|
|
3269
|
+
}
|
|
3270
|
+
stats() {
|
|
3271
|
+
return {
|
|
3272
|
+
live: this.sealed.length + (this.active === null ? 0 : 1),
|
|
3273
|
+
free: this.free.length,
|
|
3274
|
+
allocs: this.allocs,
|
|
3275
|
+
recycles: this.recycles,
|
|
3276
|
+
ownBuffers: this.ownBuffers
|
|
3277
|
+
};
|
|
3278
|
+
}
|
|
3279
|
+
/** Drop every slab of this pool from the process counters (stream torn down). */
|
|
3280
|
+
dispose() {
|
|
3281
|
+
const live = this.sealed.length + (this.active === null ? 0 : 1);
|
|
3282
|
+
slabsLive -= live;
|
|
3283
|
+
slabsFree -= this.free.length;
|
|
3284
|
+
this.sealed = [];
|
|
3285
|
+
this.free = [];
|
|
3286
|
+
this.active = null;
|
|
3287
|
+
}
|
|
3288
|
+
rotate() {
|
|
3289
|
+
const previous = this.active;
|
|
3290
|
+
if (previous !== null) {
|
|
3291
|
+
this.sealed.push(previous);
|
|
3292
|
+
if (previous.holds === 0) {
|
|
3293
|
+
previous.state = "quarantined";
|
|
3294
|
+
previous.releasedAt = this.now();
|
|
3295
|
+
} else previous.state = "held";
|
|
3296
|
+
}
|
|
3297
|
+
this.reclaim();
|
|
3298
|
+
const next = this.free.pop() ?? this.allocate();
|
|
3299
|
+
if (next.state === "free") {
|
|
3300
|
+
slabsFree -= 1;
|
|
3301
|
+
slabsLive += 1;
|
|
3302
|
+
}
|
|
3303
|
+
next.state = "active";
|
|
3304
|
+
next.offset = 0;
|
|
3305
|
+
next.holds = 0;
|
|
3306
|
+
this.active = next;
|
|
3307
|
+
return next;
|
|
3308
|
+
}
|
|
3309
|
+
allocate() {
|
|
3310
|
+
this.allocs += 1;
|
|
3311
|
+
slabAllocs += 1;
|
|
3312
|
+
slabsLive += 1;
|
|
3313
|
+
const slab = new Slab(new ArrayBuffer(this.slabBytes), this.now);
|
|
3314
|
+
slabOf.set(slab.buffer, slab);
|
|
3315
|
+
return slab;
|
|
3316
|
+
}
|
|
3317
|
+
/** Move quarantined slabs whose time is up to the free list. */
|
|
3318
|
+
reclaim() {
|
|
3319
|
+
if (this.sealed.length === 0) return;
|
|
3320
|
+
const cutoff = this.now() - this.quarantineMs;
|
|
3321
|
+
const keep = [];
|
|
3322
|
+
for (const slab of this.sealed) if (slab.state === "quarantined" && slab.releasedAt <= cutoff) this.recycle(slab);
|
|
3323
|
+
else keep.push(slab);
|
|
3324
|
+
this.sealed = keep;
|
|
3325
|
+
}
|
|
3326
|
+
recycle(slab) {
|
|
3327
|
+
slabsLive -= 1;
|
|
3328
|
+
if (this.free.length >= this.maxFree) {
|
|
3329
|
+
slabOf.delete(slab.buffer);
|
|
3330
|
+
slab.state = "free";
|
|
3331
|
+
return;
|
|
3332
|
+
}
|
|
3333
|
+
const old = slab.buffer;
|
|
3334
|
+
slabOf.delete(old);
|
|
3335
|
+
slab.buffer = isTransferable(old) ? old.transfer() : new ArrayBuffer(this.slabBytes);
|
|
3336
|
+
if (!isTransferable(old)) {
|
|
3337
|
+
this.allocs += 1;
|
|
3338
|
+
slabAllocs += 1;
|
|
3339
|
+
}
|
|
3340
|
+
slabOf.set(slab.buffer, slab);
|
|
3341
|
+
slab.state = "free";
|
|
3342
|
+
slab.offset = 0;
|
|
3343
|
+
slab.holds = 0;
|
|
3344
|
+
this.free.push(slab);
|
|
3345
|
+
slabsFree += 1;
|
|
3346
|
+
this.recycles += 1;
|
|
3347
|
+
slabRecycles += 1;
|
|
3348
|
+
}
|
|
3349
|
+
};
|
|
3088
3350
|
//#endregion
|
|
3089
3351
|
//#region src/stream-broker/stream-broker/owned-buffer.ts
|
|
3090
3352
|
/**
|
|
@@ -3111,10 +3373,20 @@ function ownsBackingStore(buf) {
|
|
|
3111
3373
|
}
|
|
3112
3374
|
/**
|
|
3113
3375
|
* What retaining `buf` really costs: its own length when it owns its
|
|
3114
|
-
* ArrayBuffer
|
|
3376
|
+
* ArrayBuffer (a packet slab is densely packed, so a slab view counts as its
|
|
3377
|
+
* own length too), the whole shared slab when it is a view of a pooled one.
|
|
3378
|
+
* The diagnostic `BackingTally` still sums the distinct ArrayBuffers.
|
|
3115
3379
|
*/
|
|
3116
3380
|
function backingStoreBytes(buf) {
|
|
3117
|
-
return ownsBackingStore(buf) ? buf.length : buf.buffer.byteLength;
|
|
3381
|
+
return ownsBackingStore(buf) || isSlabView(buf) ? buf.length : buf.buffer.byteLength;
|
|
3382
|
+
}
|
|
3383
|
+
/**
|
|
3384
|
+
* What `buf` PINS: its own length when it is the whole of its ArrayBuffer, the
|
|
3385
|
+
* whole ArrayBuffer otherwise — a packet slab view included. This is the
|
|
3386
|
+
* diagnostic figure (`BackingTally`); caps charge {@link backingStoreBytes}.
|
|
3387
|
+
*/
|
|
3388
|
+
function pinnedBackingBytes(buf) {
|
|
3389
|
+
return buf.byteOffset === 0 && buf.buffer.byteLength === buf.length ? buf.length : buf.buffer.byteLength;
|
|
3118
3390
|
}
|
|
3119
3391
|
/**
|
|
3120
3392
|
* `buf` itself when it already owns its ArrayBuffer (decided by inspecting the
|
|
@@ -3163,7 +3435,7 @@ var BackingTally = class {
|
|
|
3163
3435
|
}
|
|
3164
3436
|
this.entries += 1;
|
|
3165
3437
|
this.payload += buf.length;
|
|
3166
|
-
const backing =
|
|
3438
|
+
const backing = pinnedBackingBytes(buf);
|
|
3167
3439
|
this.naive += backing;
|
|
3168
3440
|
if (!this.seen.has(buf.buffer)) {
|
|
3169
3441
|
this.seen.add(buf.buffer);
|
|
@@ -3456,6 +3728,7 @@ function formatBrokerMem(input) {
|
|
|
3456
3728
|
const w = input.worker;
|
|
3457
3729
|
if (w !== void 0 && w !== null) parts.push(`workers=${w.workers}`, `workerHeapMb=${mb(w.heapBytes)}`, `workerPhysicalMb=${mb(w.physicalBytes)}`, `workerUsedMb=${mb(w.usedBytes)}`, `workerAgeS=${Math.round(w.maxAgeMs / 1e3)}`, `workerIdleExit=${w.idleTerminates ? "yes" : "never"}`);
|
|
3458
3730
|
parts.push(`brokers=${samples.length}`, `ingesting=${ingesting}`, `annexBPlanes=${planes}`, `rtsp=${rtsp}`, `webrtc=${input.webrtcSessions}`, `encoded=${kindList(consumers)}`, `audioSubs=${audio}`, `pipeClients=${pipe}`, `egress=${input.egressTranscodes}`, `children=${input.childProcesses}`, `opened=${churn.opened}`, `closed=${churn.closed}`, `openedKinds=${kindList(churn.openedByKind)}`, `closedKinds=${kindList(churn.closedByKind)}`, `preBufferMb=${mb(pre)}`, `clipMb=${mb(clip)}`, `rtpRingMb=${mb(rtp)}`, `keyframeMb=${mb(kf)}`, `writeQueueSumMb=${mb(qSum)}`, `writeQueueMaxMb=${mb(qMax)}`, `copiedPackets=${copied.packets}`, `copiedMb=${mb(copied.bytes)}`, `topRingMb=${top === "" ? "none" : top}`);
|
|
3731
|
+
if (input.slabs !== void 0) parts.push(`slabs=${input.slabs.live}/${input.slabs.free}`, `slabAllocs=${input.slabs.allocsDelta}`);
|
|
3459
3732
|
const sessions = input.sessions;
|
|
3460
3733
|
if (sessions !== void 0) parts.push(...formatSessionMem(sessions, input));
|
|
3461
3734
|
return parts.join(" ");
|
|
@@ -3845,8 +4118,7 @@ var RtspRestreamer = class {
|
|
|
3845
4118
|
this.mutedSdp = null;
|
|
3846
4119
|
this.deframer = null;
|
|
3847
4120
|
this.detectedCodec = null;
|
|
3848
|
-
this.
|
|
3849
|
-
this.lastKeyframeRtpBytes = 0;
|
|
4121
|
+
this.dropKeyframeRtpCache();
|
|
3850
4122
|
this._rtpPassthroughMode = false;
|
|
3851
4123
|
this.collectingKeyframeRtp = false;
|
|
3852
4124
|
this.egressAudioBlock = null;
|
|
@@ -4199,21 +4471,21 @@ var RtspRestreamer = class {
|
|
|
4199
4471
|
if (isKeyframe || isParameterSet) {
|
|
4200
4472
|
if (!this.collectingKeyframeRtp) {
|
|
4201
4473
|
this.collectingKeyframeRtp = true;
|
|
4202
|
-
this.
|
|
4203
|
-
this.lastKeyframeRtpBytes = 0;
|
|
4474
|
+
this.dropKeyframeRtpCache();
|
|
4204
4475
|
}
|
|
4205
4476
|
}
|
|
4206
4477
|
if (this.collectingKeyframeRtp && !isKeyframe && !isParameterSet) this.collectingKeyframeRtp = false;
|
|
4207
4478
|
if (this.collectingKeyframeRtp) {
|
|
4479
|
+
const cached = isSlabView(rtpData) ? rtpData : detachFromPool(rtpData);
|
|
4480
|
+
holdPacket(cached);
|
|
4208
4481
|
this.lastKeyframeRtpPackets.push({
|
|
4209
|
-
data:
|
|
4482
|
+
data: cached,
|
|
4210
4483
|
marker: rtpPacket.marker
|
|
4211
4484
|
});
|
|
4212
4485
|
this.lastKeyframeRtpBytes += rtpData.length;
|
|
4213
4486
|
if (this.lastKeyframeRtpBytes > MAX_KEYFRAME_CACHE_BYTES) {
|
|
4214
4487
|
this.warnCacheCapExceeded("rtp", this.lastKeyframeRtpBytes, this.lastKeyframeRtpPackets.length);
|
|
4215
|
-
this.
|
|
4216
|
-
this.lastKeyframeRtpBytes = 0;
|
|
4488
|
+
this.dropKeyframeRtpCache();
|
|
4217
4489
|
this.collectingKeyframeRtp = false;
|
|
4218
4490
|
}
|
|
4219
4491
|
}
|
|
@@ -4651,6 +4923,7 @@ var RtspRestreamer = class {
|
|
|
4651
4923
|
}
|
|
4652
4924
|
destroy() {
|
|
4653
4925
|
this._onSessionCountChanged = null;
|
|
4926
|
+
this.dropKeyframeRtpCache();
|
|
4654
4927
|
this.sentryAudioSilence?.release("restreamer destroyed");
|
|
4655
4928
|
this.abortAllReplays();
|
|
4656
4929
|
this.deframer?.flush();
|
|
@@ -4659,6 +4932,12 @@ var RtspRestreamer = class {
|
|
|
4659
4932
|
this.packetizer = null;
|
|
4660
4933
|
this.deframer = null;
|
|
4661
4934
|
}
|
|
4935
|
+
/** Empty the keyframe RTP cache and let go of the slabs it pinned. */
|
|
4936
|
+
dropKeyframeRtpCache() {
|
|
4937
|
+
for (const cached of this.lastKeyframeRtpPackets) releasePacket(cached.data);
|
|
4938
|
+
this.lastKeyframeRtpPackets = [];
|
|
4939
|
+
this.lastKeyframeRtpBytes = 0;
|
|
4940
|
+
}
|
|
4662
4941
|
/** Called by deframer for each complete NAL unit. */
|
|
4663
4942
|
handleNal(nal, isKeyframe) {
|
|
4664
4943
|
if (!this.codecParams) this.tryInitCodec(nal);
|
|
@@ -4788,6 +5067,13 @@ function resolveJoinBurstBound(input) {
|
|
|
4788
5067
|
};
|
|
4789
5068
|
}
|
|
4790
5069
|
//#endregion
|
|
5070
|
+
//#region src/stream-broker/stream-broker/held-write.ts
|
|
5071
|
+
/** Write `payload`, holding its slab until the write completes (or fails). */
|
|
5072
|
+
function writeHeld(sink, payload) {
|
|
5073
|
+
holdPacket(payload);
|
|
5074
|
+
return sink.write(payload, () => releasePacket(payload));
|
|
5075
|
+
}
|
|
5076
|
+
//#endregion
|
|
4791
5077
|
//#region src/stream-broker/rtsp/rtsp-session.ts
|
|
4792
5078
|
/**
|
|
4793
5079
|
* Handles one RTSP client connection.
|
|
@@ -5103,15 +5389,17 @@ var RtspSession = class {
|
|
|
5103
5389
|
*
|
|
5104
5390
|
* ## Why sharing the payload is safe, and what would make it unsafe
|
|
5105
5391
|
*
|
|
5106
|
-
* The payload
|
|
5107
|
-
*
|
|
5108
|
-
*
|
|
5109
|
-
*
|
|
5110
|
-
*
|
|
5111
|
-
*
|
|
5112
|
-
*
|
|
5113
|
-
*
|
|
5114
|
-
*
|
|
5392
|
+
* The payload is a view of the broker's packet slab (`slab-pool.ts`, D868),
|
|
5393
|
+
* never of the reader's socket buffer, which advances on the next read.
|
|
5394
|
+
* Nothing in the broker writes into a captured packet: every mutation in this
|
|
5395
|
+
* path targets a freshly allocated buffer. A slab is RECYCLED once the
|
|
5396
|
+
* broker's rings let go of it, and a queued socket write outlives that: Node
|
|
5397
|
+
* keeps the chunk behind a full kernel buffer for as long as the peer takes to
|
|
5398
|
+
* drain (seconds, for a recorder or a LAN decoder), and libuv reads the bytes
|
|
5399
|
+
* only when it writes. So the payload goes out through `writeHeld`, which
|
|
5400
|
+
* holds its slab until Node's write callback (success, error or destroy). A
|
|
5401
|
+
* write that skipped the hold would send a header promising N bytes followed
|
|
5402
|
+
* by zeros.
|
|
5115
5403
|
*
|
|
5116
5404
|
* The rule to keep: **`socket.write` forbids MUTATION before the write
|
|
5117
5405
|
* completes, not retention.** So the header, which IS built per call, must
|
|
@@ -5148,7 +5436,7 @@ var RtspSession = class {
|
|
|
5148
5436
|
process.nextTick(this.uncorkAccessUnit);
|
|
5149
5437
|
}
|
|
5150
5438
|
this.socket.write(header);
|
|
5151
|
-
this.socket
|
|
5439
|
+
writeHeld(this.socket, payload);
|
|
5152
5440
|
if (endOfAccessUnit) this.uncorkAccessUnit();
|
|
5153
5441
|
this.lastRtpAt = Date.now();
|
|
5154
5442
|
this.bytesSent += 4 + payload.length;
|
|
@@ -5285,6 +5573,18 @@ var RtspListenServer = class RtspListenServer {
|
|
|
5285
5573
|
brokerTokens = /* @__PURE__ */ new Map();
|
|
5286
5574
|
/** token → brokerId (reverse map) */
|
|
5287
5575
|
tokenBrokers = /* @__PURE__ */ new Map();
|
|
5576
|
+
/**
|
|
5577
|
+
* brokerId → INTERNAL loopback-only alias token (D870). A camera whose
|
|
5578
|
+
* external restream token is held in an unreadable keychain has NO external
|
|
5579
|
+
* URL, but the broker's own consumers (recording, decode, snapshots, grid,
|
|
5580
|
+
* egress) still ride its restream: they get this ephemeral token, which is
|
|
5581
|
+
* never persisted, never handed out through the consumer URL APIs, and only
|
|
5582
|
+
* answers loopback / local-network peers.
|
|
5583
|
+
*/
|
|
5584
|
+
internalAliases = /* @__PURE__ */ new Map();
|
|
5585
|
+
internalAliasTokens = /* @__PURE__ */ new Set();
|
|
5586
|
+
/** Which peers may dial an internal alias; replaceable for tests. */
|
|
5587
|
+
aliasPeerPolicy = isClusterLocalAddress;
|
|
5288
5588
|
/** brokerId → enabled (default true) */
|
|
5289
5589
|
brokerEnabled = /* @__PURE__ */ new Map();
|
|
5290
5590
|
/** brokerId → warm-up closure registered alongside the restreamer */
|
|
@@ -5315,7 +5615,37 @@ var RtspListenServer = class RtspListenServer {
|
|
|
5315
5615
|
if (sentryOpener) this.brokerPrestreamOpeners.set(brokerId, sentryOpener);
|
|
5316
5616
|
return token;
|
|
5317
5617
|
}
|
|
5618
|
+
/**
|
|
5619
|
+
* Register (or refresh) the internal loopback-only alias of a broker. Returns
|
|
5620
|
+
* its token. The external token map is untouched, so the alias can never be
|
|
5621
|
+
* listed, persisted or surfaced as a consumer URL.
|
|
5622
|
+
*/
|
|
5623
|
+
registerInternalAlias(brokerId, restreamer, warmUp, sentryOpener) {
|
|
5624
|
+
const token = this.internalAliases.get(brokerId) ?? RtspListenServer.generateToken();
|
|
5625
|
+
this.restreamers.set(token, restreamer);
|
|
5626
|
+
this.tokenBrokers.set(token, brokerId);
|
|
5627
|
+
this.internalAliases.set(brokerId, token);
|
|
5628
|
+
this.internalAliasTokens.add(token);
|
|
5629
|
+
if (warmUp) this.brokerWarmUps.set(brokerId, warmUp);
|
|
5630
|
+
if (sentryOpener) this.brokerPrestreamOpeners.set(brokerId, sentryOpener);
|
|
5631
|
+
return token;
|
|
5632
|
+
}
|
|
5633
|
+
/** Replace the peer policy of the internal aliases (tests only). */
|
|
5634
|
+
setAliasPeerPolicy(policy) {
|
|
5635
|
+
this.aliasPeerPolicy = policy;
|
|
5636
|
+
}
|
|
5637
|
+
/** The token an IN-PROCESS consumer dials: the internal alias when there is one, else the external token. */
|
|
5638
|
+
getInternalToken(brokerId) {
|
|
5639
|
+
return this.internalAliases.get(brokerId) ?? this.brokerTokens.get(brokerId);
|
|
5640
|
+
}
|
|
5318
5641
|
unregisterRestreamer(brokerId) {
|
|
5642
|
+
const alias = this.internalAliases.get(brokerId);
|
|
5643
|
+
if (alias !== void 0) {
|
|
5644
|
+
this.restreamers.delete(alias);
|
|
5645
|
+
this.tokenBrokers.delete(alias);
|
|
5646
|
+
this.internalAliasTokens.delete(alias);
|
|
5647
|
+
this.internalAliases.delete(brokerId);
|
|
5648
|
+
}
|
|
5319
5649
|
const token = this.brokerTokens.get(brokerId);
|
|
5320
5650
|
if (token) {
|
|
5321
5651
|
this.restreamers.delete(token);
|
|
@@ -5337,6 +5667,17 @@ var RtspListenServer = class RtspListenServer {
|
|
|
5337
5667
|
this.restreamers.set(newToken, restreamer);
|
|
5338
5668
|
this.brokerTokens.set(brokerId, newToken);
|
|
5339
5669
|
this.tokenBrokers.set(newToken, brokerId);
|
|
5670
|
+
const alias = this.internalAliases.get(brokerId);
|
|
5671
|
+
if (alias !== void 0) {
|
|
5672
|
+
this.restreamers.delete(alias);
|
|
5673
|
+
this.tokenBrokers.delete(alias);
|
|
5674
|
+
this.internalAliasTokens.delete(alias);
|
|
5675
|
+
const newAlias = RtspListenServer.generateToken();
|
|
5676
|
+
this.restreamers.set(newAlias, restreamer);
|
|
5677
|
+
this.tokenBrokers.set(newAlias, brokerId);
|
|
5678
|
+
this.internalAliases.set(brokerId, newAlias);
|
|
5679
|
+
this.internalAliasTokens.add(newAlias);
|
|
5680
|
+
}
|
|
5340
5681
|
return newToken;
|
|
5341
5682
|
}
|
|
5342
5683
|
/** Get the current token for a broker. */
|
|
@@ -5420,6 +5761,11 @@ var RtspListenServer = class RtspListenServer {
|
|
|
5420
5761
|
socket.destroy();
|
|
5421
5762
|
return;
|
|
5422
5763
|
}
|
|
5764
|
+
if (this.internalAliasTokens.has(lookupPath) && !this.aliasPeerPolicy(socket.remoteAddress)) {
|
|
5765
|
+
socket.write(`RTSP/1.0 403 Forbidden\r\nCSeq: 1\r\n\r\n`);
|
|
5766
|
+
socket.destroy();
|
|
5767
|
+
return;
|
|
5768
|
+
}
|
|
5423
5769
|
const brokerId = this.tokenBrokers.get(lookupPath);
|
|
5424
5770
|
if (brokerId && !this.isEnabled(brokerId)) {
|
|
5425
5771
|
socket.write(`RTSP/1.0 403 Forbidden\r\nCSeq: 1\r\n\r\n`);
|
|
@@ -5478,6 +5824,24 @@ var RtspListenServer = class RtspListenServer {
|
|
|
5478
5824
|
});
|
|
5479
5825
|
}
|
|
5480
5826
|
};
|
|
5827
|
+
/**
|
|
5828
|
+
* Is the peer on the loopback interface or the local network (RFC 1918, link-local,
|
|
5829
|
+
* ULA)? The internal alias answers only these: a recorder or decoder on another
|
|
5830
|
+
* node of the cluster is a legitimate consumer, the public internet is not.
|
|
5831
|
+
*/
|
|
5832
|
+
function isClusterLocalAddress(address) {
|
|
5833
|
+
if (address === void 0) return false;
|
|
5834
|
+
const v4 = address.startsWith("::ffff:") ? address.slice(7) : address;
|
|
5835
|
+
if (v4 === "::1") return true;
|
|
5836
|
+
const m = /^(\d+)\.(\d+)\.\d+\.\d+$/.exec(v4);
|
|
5837
|
+
if (m !== null) {
|
|
5838
|
+
const a = Number(m[1]);
|
|
5839
|
+
const b = Number(m[2]);
|
|
5840
|
+
return a === 127 || a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a === 169 && b === 254 || a === 100 && b >= 64 && b <= 127;
|
|
5841
|
+
}
|
|
5842
|
+
const lower = address.toLowerCase();
|
|
5843
|
+
return lower.startsWith("fe80:") || lower.startsWith("fc") || lower.startsWith("fd");
|
|
5844
|
+
}
|
|
5481
5845
|
//#endregion
|
|
5482
5846
|
//#region src/stream-broker/rtsp/rtsp-restream-provider.ts
|
|
5483
5847
|
/**
|
|
@@ -5559,6 +5923,24 @@ var RtspRestreamProviderImpl = class {
|
|
|
5559
5923
|
enabled: this.server.isEnabled(brokerId)
|
|
5560
5924
|
};
|
|
5561
5925
|
}
|
|
5926
|
+
/**
|
|
5927
|
+
* The entry an IN-PROCESS consumer dials (recording, decode, snapshots, grid,
|
|
5928
|
+
* egress): the loopback-only internal alias when the camera's external token
|
|
5929
|
+
* is held back (D870), else the external entry. NEVER hand this out through a
|
|
5930
|
+
* consumer URL API - use {@link getEntry} for that.
|
|
5931
|
+
*/
|
|
5932
|
+
getInternalEntry(brokerId, hostname) {
|
|
5933
|
+
const token = this.server.getInternalToken(brokerId);
|
|
5934
|
+
if (!token) return null;
|
|
5935
|
+
const port = this.server.getPort();
|
|
5936
|
+
const host = hostname ?? "127.0.0.1";
|
|
5937
|
+
return {
|
|
5938
|
+
brokerId,
|
|
5939
|
+
url: `rtsp://${host}:${port}/${token}`,
|
|
5940
|
+
mutedUrl: `rtsp://${host}:${port}/${token}/muted`,
|
|
5941
|
+
enabled: this.server.isEnabled(brokerId)
|
|
5942
|
+
};
|
|
5943
|
+
}
|
|
5562
5944
|
regenerateToken(brokerId) {
|
|
5563
5945
|
const token = this.server.regenerateToken(brokerId);
|
|
5564
5946
|
if (token) {
|
|
@@ -10511,6 +10893,7 @@ var Rfc4571Reader = class {
|
|
|
10511
10893
|
options;
|
|
10512
10894
|
callbacks;
|
|
10513
10895
|
logger;
|
|
10896
|
+
allocatePacket;
|
|
10514
10897
|
host;
|
|
10515
10898
|
port;
|
|
10516
10899
|
authLine;
|
|
@@ -10524,6 +10907,7 @@ var Rfc4571Reader = class {
|
|
|
10524
10907
|
constructor(options, callbacks) {
|
|
10525
10908
|
this.options = options;
|
|
10526
10909
|
this.callbacks = callbacks;
|
|
10910
|
+
this.allocatePacket = options.packetAllocator ?? copyOwned;
|
|
10527
10911
|
this.logger = options.logger;
|
|
10528
10912
|
const parsed = new URL(options.url);
|
|
10529
10913
|
this.host = parsed.hostname;
|
|
@@ -10588,7 +10972,7 @@ var Rfc4571Reader = class {
|
|
|
10588
10972
|
while (this.readBuffer.length >= 2) {
|
|
10589
10973
|
const length = this.readBuffer.readUInt16BE(0);
|
|
10590
10974
|
if (this.readBuffer.length < 2 + length) return;
|
|
10591
|
-
const rtpPacket =
|
|
10975
|
+
const rtpPacket = this.allocatePacket(this.readBuffer, 2, 2 + length);
|
|
10592
10976
|
this.readBuffer = this.readBuffer.subarray(2 + length);
|
|
10593
10977
|
this.dispatchRtp(rtpPacket);
|
|
10594
10978
|
}
|
|
@@ -11109,6 +11493,7 @@ var NativeRtspClient = class {
|
|
|
11109
11493
|
keepaliveIntervalMs;
|
|
11110
11494
|
logger;
|
|
11111
11495
|
callbacks;
|
|
11496
|
+
allocatePacket;
|
|
11112
11497
|
/** Parsed SDP from DESCRIBE response. */
|
|
11113
11498
|
parsedSdp = null;
|
|
11114
11499
|
rawSdpText = null;
|
|
@@ -11135,6 +11520,7 @@ var NativeRtspClient = class {
|
|
|
11135
11520
|
tornDown = false;
|
|
11136
11521
|
constructor(options, callbacks) {
|
|
11137
11522
|
this.callbacks = callbacks;
|
|
11523
|
+
this.allocatePacket = options.packetAllocator ?? copyOwned;
|
|
11138
11524
|
this.logger = options.logger;
|
|
11139
11525
|
this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
11140
11526
|
this.keepaliveIntervalMs = options.keepaliveIntervalMs ?? DEFAULT_KEEPALIVE_INTERVAL_MS;
|
|
@@ -11307,7 +11693,7 @@ var NativeRtspClient = class {
|
|
|
11307
11693
|
const channel = this.readBuffer[1];
|
|
11308
11694
|
const length = this.readBuffer.readUInt16BE(2);
|
|
11309
11695
|
if (this.readBuffer.length < 4 + length) return;
|
|
11310
|
-
const payload =
|
|
11696
|
+
const payload = this.allocatePacket(this.readBuffer, 4, 4 + length);
|
|
11311
11697
|
this.readBuffer = this.readBuffer.subarray(4 + length);
|
|
11312
11698
|
this.handleInterleavedPacket(channel, payload);
|
|
11313
11699
|
} else {
|
|
@@ -11762,7 +12148,7 @@ var AudioCodecSession = class {
|
|
|
11762
12148
|
processPacket(rtpData) {
|
|
11763
12149
|
if (this.closed) return;
|
|
11764
12150
|
if (rtpData.length <= RTP_HEADER_LENGTH) return;
|
|
11765
|
-
const payload = rtpData.subarray(RTP_HEADER_LENGTH);
|
|
12151
|
+
const payload = Buffer.from(rtpData.subarray(RTP_HEADER_LENGTH));
|
|
11766
12152
|
this.packetsIn++;
|
|
11767
12153
|
this.ensureSession().then((sid) => {
|
|
11768
12154
|
if (!sid || this.closed) return;
|
|
@@ -13380,6 +13766,13 @@ var StreamBroker = class StreamBroker {
|
|
|
13380
13766
|
* Only populated for RTP sources (`isRtpSource()`).
|
|
13381
13767
|
*/
|
|
13382
13768
|
rtpRing = [];
|
|
13769
|
+
/**
|
|
13770
|
+
* Where this stream's source packets are cut (`slab-pool.ts`, D868): the
|
|
13771
|
+
* readers copy into the pool's slabs, the ring and the restreamer's keyframe
|
|
13772
|
+
* cache hold/release them, and a slab no ring holds is recycled.
|
|
13773
|
+
*/
|
|
13774
|
+
packetPool = new PacketSlabPool();
|
|
13775
|
+
packetAllocator = (src, start, end) => this.packetPool.copy(src, start, end);
|
|
13383
13776
|
rtpRingCurAuStart = 0;
|
|
13384
13777
|
rtpRingPrevMarker = true;
|
|
13385
13778
|
rtpRingHasKeyframe = false;
|
|
@@ -13900,6 +14293,8 @@ var StreamBroker = class StreamBroker {
|
|
|
13900
14293
|
this.annexBAssembler.reset();
|
|
13901
14294
|
this.encodedCallbacks.clear();
|
|
13902
14295
|
this.audioChunkPlane.dispose();
|
|
14296
|
+
this.resetRtpPreBuffer();
|
|
14297
|
+
this.packetPool.dispose();
|
|
13903
14298
|
}
|
|
13904
14299
|
/** Whether the broker is currently suspended (idle, no RTSP connection). */
|
|
13905
14300
|
get suspended() {
|
|
@@ -14654,7 +15049,8 @@ var StreamBroker = class StreamBroker {
|
|
|
14654
15049
|
captureRtpForPreBuffer(rtpData) {
|
|
14655
15050
|
const marker = rtpData.length > 1 && (rtpData[1] & 128) !== 0;
|
|
14656
15051
|
if (this.rtpRingPrevMarker || this.rtpRing.length === 0) this.rtpRingCurAuStart = this.rtpRing.length;
|
|
14657
|
-
const owned =
|
|
15052
|
+
const owned = this.packetPool.adopt(rtpData);
|
|
15053
|
+
holdPacket(owned);
|
|
14658
15054
|
this.rtpRing.push(owned);
|
|
14659
15055
|
this.rtpRingBytes += backingStoreBytes(owned);
|
|
14660
15056
|
if (this._lastNalParamSet || this._lastNalKeyframe) {
|
|
@@ -14750,6 +15146,7 @@ var StreamBroker = class StreamBroker {
|
|
|
14750
15146
|
* non-decodable slice instead of waiting for the new dial's first keyframe.
|
|
14751
15147
|
*/
|
|
14752
15148
|
resetRtpPreBuffer() {
|
|
15149
|
+
releasePackets(this.rtpRing);
|
|
14753
15150
|
this.rtpRing = [];
|
|
14754
15151
|
this.rtpRingBytes = 0;
|
|
14755
15152
|
this.rtpRingCurAuStart = 0;
|
|
@@ -14846,6 +15243,7 @@ var StreamBroker = class StreamBroker {
|
|
|
14846
15243
|
if (cut <= 0) return;
|
|
14847
15244
|
const dropped = this.rtpRing.splice(0, cut);
|
|
14848
15245
|
for (const b of dropped) this.rtpRingBytes -= backingStoreBytes(b);
|
|
15246
|
+
releasePackets(dropped);
|
|
14849
15247
|
this.rtpRingKeyframeMarks = this.rtpRingKeyframeMarks.filter((mark) => mark.index >= cut).map((mark) => ({
|
|
14850
15248
|
index: mark.index - cut,
|
|
14851
15249
|
ts: mark.ts
|
|
@@ -15526,7 +15924,7 @@ var StreamBroker = class StreamBroker {
|
|
|
15526
15924
|
this.audioRtpDecoder?.processPacket(rtpData);
|
|
15527
15925
|
if (this.hasAudioConsumer()) this.audioCodecSession?.processPacket(rtpData);
|
|
15528
15926
|
if ((this.audioCodec === "PCMU" || this.audioCodec === "PCMA") && this.encodedCallbacks.size > 0 && rtpData.length > 12) {
|
|
15529
|
-
const payload = rtpData.subarray(12);
|
|
15927
|
+
const payload = Buffer.from(rtpData.subarray(12));
|
|
15530
15928
|
if (payload.length > 0) {
|
|
15531
15929
|
const rtpTimestamp = rtpData.readUInt32BE(4);
|
|
15532
15930
|
const pts = Math.floor(rtpTimestamp / 8);
|
|
@@ -15589,7 +15987,8 @@ var StreamBroker = class StreamBroker {
|
|
|
15589
15987
|
this.logger?.info("starting native RTSP reader");
|
|
15590
15988
|
const client = new NativeRtspClient({
|
|
15591
15989
|
url: source.url,
|
|
15592
|
-
logger: this.logger?.child("rtsp-native")
|
|
15990
|
+
logger: this.logger?.child("rtsp-native"),
|
|
15991
|
+
packetAllocator: this.packetAllocator
|
|
15593
15992
|
}, this.buildRtpStreamCallbacks("native RTSP", () => this.destroyNativeClient(), () => this.nativeClient !== client));
|
|
15594
15993
|
this.nativeClient = client;
|
|
15595
15994
|
client.connect().catch((err) => {
|
|
@@ -16096,7 +16495,8 @@ var StreamBroker = class StreamBroker {
|
|
|
16096
16495
|
const reader = new Rfc4571Reader({
|
|
16097
16496
|
url: source.url,
|
|
16098
16497
|
sdp,
|
|
16099
|
-
...this.logger ? { logger: this.logger.child("rfc4571") } : {}
|
|
16498
|
+
...this.logger ? { logger: this.logger.child("rfc4571") } : {},
|
|
16499
|
+
packetAllocator: this.packetAllocator
|
|
16100
16500
|
}, this.buildRtpStreamCallbacks("rfc4571", () => this.destroyRfc4571Reader(), () => this.rfc4571Reader !== reader));
|
|
16101
16501
|
this.rfc4571Reader = reader;
|
|
16102
16502
|
reader.connect().catch((err) => {
|
|
@@ -17274,6 +17674,14 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
17274
17674
|
rtspProvider;
|
|
17275
17675
|
persistedTokens = /* @__PURE__ */ new Map();
|
|
17276
17676
|
/**
|
|
17677
|
+
* True for a camera whose restream tokens are held in an unreadable keychain
|
|
17678
|
+
* (D870). Such a camera gets NO restream URL - minting a temporary token would
|
|
17679
|
+
* hand consumers a URL that dies as soon as the real one is restored - while
|
|
17680
|
+
* every other camera, and the camera's own streams and recording, are
|
|
17681
|
+
* untouched.
|
|
17682
|
+
*/
|
|
17683
|
+
restreamGate = () => false;
|
|
17684
|
+
/**
|
|
17277
17685
|
* Last runtime probe per broker, captured while the broker was streaming.
|
|
17278
17686
|
* Survives the stream going idle so the per-stream "Probed" settings field
|
|
17279
17687
|
* stays populated (with an "as of" tag). In-memory only — repopulates on the
|
|
@@ -17473,7 +17881,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
17473
17881
|
hwaccel: this.hwaccel ?? void 0,
|
|
17474
17882
|
cameraStreamLookup: (deviceId) => this.getCameraStreamsForDevice(deviceId),
|
|
17475
17883
|
rtspEntryLookup: (brokerId) => {
|
|
17476
|
-
const entry = this.rtspProvider.
|
|
17884
|
+
const entry = this.rtspProvider.getInternalEntry(brokerId);
|
|
17477
17885
|
return entry ? {
|
|
17478
17886
|
url: entry.url,
|
|
17479
17887
|
mutedUrl: entry.mutedUrl
|
|
@@ -17532,7 +17940,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
17532
17940
|
cameraStreamLookup: (deviceId) => this.getCameraStreamsForDevice(deviceId),
|
|
17533
17941
|
resolveProfileSource: (deviceId, profile) => this.assignments.get(deviceId)?.map[profile] ?? null,
|
|
17534
17942
|
rtspEntryLookup: (brokerId) => {
|
|
17535
|
-
const entry = this.rtspProvider.
|
|
17943
|
+
const entry = this.rtspProvider.getInternalEntry(brokerId);
|
|
17536
17944
|
return entry ? { url: entry.url } : null;
|
|
17537
17945
|
},
|
|
17538
17946
|
sourceHasMedia: (brokerId) => {
|
|
@@ -18596,6 +19004,20 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
18596
19004
|
loadPersistedTokens(tokens) {
|
|
18597
19005
|
this.persistedTokens = new Map(tokens);
|
|
18598
19006
|
}
|
|
19007
|
+
/** See {@link restreamGate}. */
|
|
19008
|
+
setRestreamGate(gate) {
|
|
19009
|
+
this.restreamGate = gate;
|
|
19010
|
+
}
|
|
19011
|
+
/**
|
|
19012
|
+
* Register the restream URLs of ONE camera now (its tokens became readable, or
|
|
19013
|
+
* were issued fresh): each of its source brokers and the profile restreamers
|
|
19014
|
+
* over them. No broker is torn down or recreated, so nothing stops recording.
|
|
19015
|
+
*/
|
|
19016
|
+
registerRestreamersForDevice(deviceId) {
|
|
19017
|
+
if (this.restreamGate(deviceId)) return;
|
|
19018
|
+
for (const [brokerId, broker] of this.brokers) if (brokerId.startsWith(`${deviceId}/`)) this.registerSourceRestreamer(brokerId, broker);
|
|
19019
|
+
this.syncProfileRestreamers(deviceId);
|
|
19020
|
+
}
|
|
18599
19021
|
/** Upsert a single SOURCE (non-derived) cam-stream into the registry
|
|
18600
19022
|
* WITHOUT ranking or broker creation. Returns the prior entry (if any)
|
|
18601
19023
|
* so callers can detect url refresh / already-existed. */
|
|
@@ -19339,6 +19761,20 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
19339
19761
|
return { success: true };
|
|
19340
19762
|
}
|
|
19341
19763
|
/**
|
|
19764
|
+
* Drop a DELETED camera's whole override entry and persist (D870). The
|
|
19765
|
+
* device-removal hook's one reach into the override map; `forgetDeviceHardware`
|
|
19766
|
+
* keeps the entry's non-hardware fields, a deleted camera keeps nothing.
|
|
19767
|
+
*/
|
|
19768
|
+
dropDeviceOverride(deviceId) {
|
|
19769
|
+
if (!this.deviceOverrides.delete(deviceId)) return false;
|
|
19770
|
+
this.deviceOverridePersister?.(this.deviceOverrides);
|
|
19771
|
+
return true;
|
|
19772
|
+
}
|
|
19773
|
+
/** Cameras that have an override entry. */
|
|
19774
|
+
listDeviceOverrideIds() {
|
|
19775
|
+
return [...this.deviceOverrides.keys()];
|
|
19776
|
+
}
|
|
19777
|
+
/**
|
|
19342
19778
|
* The hardware behind `deviceId` was replaced (`deviceManager.migrateDevice`)
|
|
19343
19779
|
* — forget everything this broker knows that described the OLD box. See the
|
|
19344
19780
|
* cap doc for the inventory. What deliberately SURVIVES is the role-scoped
|
|
@@ -20117,6 +20553,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
20117
20553
|
const sourceBrokerId = brokerIdFor(deviceId, camStreamId);
|
|
20118
20554
|
const sourceBroker = this.brokers.get(sourceBrokerId);
|
|
20119
20555
|
if (!sourceBroker) continue;
|
|
20556
|
+
if (this.restreamGate(deviceId)) continue;
|
|
20120
20557
|
const existingToken = this.persistedTokens.get(profileBrokerId);
|
|
20121
20558
|
const warmUp = () => sourceBroker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION);
|
|
20122
20559
|
const openPrestreamWindow = () => sourceBroker.openPrestreamWindow();
|
|
@@ -21776,11 +22213,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
21776
22213
|
}
|
|
21777
22214
|
this.brokers.set(brokerId, broker);
|
|
21778
22215
|
this.applyClipRetention(deviceId);
|
|
21779
|
-
|
|
21780
|
-
const warmUp = () => broker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION);
|
|
21781
|
-
const openPrestreamWindow = () => broker.openPrestreamWindow();
|
|
21782
|
-
this.rtspServer.registerRestreamer(brokerId, broker.getRtspRestreamer(), warmUp, existingToken, openPrestreamWindow);
|
|
21783
|
-
if (!existingToken) this.rtspProvider.persistTokens();
|
|
22216
|
+
this.registerSourceRestreamer(brokerId, broker);
|
|
21784
22217
|
await this.fanOutRegistration(deviceId, brokerId, cam, broker);
|
|
21785
22218
|
this.syncProfileRestreamers(deviceId);
|
|
21786
22219
|
if (cam.kind === "derived") {
|
|
@@ -21789,6 +22222,28 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
21789
22222
|
}
|
|
21790
22223
|
}
|
|
21791
22224
|
/**
|
|
22225
|
+
* Register one source broker's RTSP restreamer with its persisted token (or a
|
|
22226
|
+
* freshly minted one). A camera whose tokens are held in an unreadable keychain
|
|
22227
|
+
* gets no EXTERNAL token, only the internal loopback-only alias.
|
|
22228
|
+
*/
|
|
22229
|
+
registerSourceRestreamer(brokerId, broker) {
|
|
22230
|
+
const deviceId = parseBrokerId(brokerId)?.deviceId;
|
|
22231
|
+
if (deviceId === void 0) return;
|
|
22232
|
+
if (this.restreamGate(deviceId)) {
|
|
22233
|
+
this.logger.warn("external restream URL withheld - the tokens are held but unreadable", {
|
|
22234
|
+
tags: { deviceId },
|
|
22235
|
+
meta: { brokerId }
|
|
22236
|
+
});
|
|
22237
|
+
this.rtspServer.registerInternalAlias(brokerId, broker.getRtspRestreamer(), () => broker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION), () => broker.openPrestreamWindow());
|
|
22238
|
+
return;
|
|
22239
|
+
}
|
|
22240
|
+
const existingToken = this.persistedTokens.get(brokerId);
|
|
22241
|
+
const warmUp = () => broker.onEncodedData(() => {}, RTSP_DESCRIBE_WARMUP_ATTRIBUTION);
|
|
22242
|
+
const openPrestreamWindow = () => broker.openPrestreamWindow();
|
|
22243
|
+
this.rtspServer.registerRestreamer(brokerId, broker.getRtspRestreamer(), warmUp, existingToken, openPrestreamWindow);
|
|
22244
|
+
if (!existingToken) this.rtspProvider.persistTokens();
|
|
22245
|
+
}
|
|
22246
|
+
/**
|
|
21792
22247
|
* Tear down the broker for a `(deviceId, camStreamId)` pair. No-op
|
|
21793
22248
|
* when no broker exists. Called by `retractCameraStream` and by the
|
|
21794
22249
|
* teardown half of `restartBroker`.
|
|
@@ -21908,7 +22363,7 @@ var StreamBrokerManager = class StreamBrokerManager {
|
|
|
21908
22363
|
};
|
|
21909
22364
|
} else {
|
|
21910
22365
|
const sourceBrokerId = brokerIdFor(deviceId, request.camStreamId);
|
|
21911
|
-
const entry = this.rtspProvider.
|
|
22366
|
+
const entry = this.rtspProvider.getInternalEntry(sourceBrokerId, "127.0.0.1");
|
|
21912
22367
|
if (!entry) throw new Error(`derived source camStream loopback unavailable: ${sourceBrokerId}`);
|
|
21913
22368
|
loopbackUrl = entry.url;
|
|
21914
22369
|
release = async () => {};
|
|
@@ -22686,19 +23141,19 @@ var H265Repacketizer = class {
|
|
|
22686
23141
|
return this.codecInfo;
|
|
22687
23142
|
}
|
|
22688
23143
|
updateVps(vps) {
|
|
22689
|
-
this.ensureCodecInfo().vps = vps;
|
|
23144
|
+
this.ensureCodecInfo().vps = Buffer.from(vps);
|
|
22690
23145
|
}
|
|
22691
23146
|
updateSps(sps) {
|
|
22692
|
-
this.ensureCodecInfo().sps = sps;
|
|
23147
|
+
this.ensureCodecInfo().sps = Buffer.from(sps);
|
|
22693
23148
|
}
|
|
22694
23149
|
updatePps(pps) {
|
|
22695
|
-
this.ensureCodecInfo().pps = pps;
|
|
23150
|
+
this.ensureCodecInfo().pps = Buffer.from(pps);
|
|
22696
23151
|
}
|
|
22697
23152
|
updateSeiPrefix(sei) {
|
|
22698
|
-
this.ensureCodecInfo().seiPrefix = sei;
|
|
23153
|
+
this.ensureCodecInfo().seiPrefix = Buffer.from(sei);
|
|
22699
23154
|
}
|
|
22700
23155
|
updateSeiSuffix(sei) {
|
|
22701
|
-
this.ensureCodecInfo().seiSuffix = sei;
|
|
23156
|
+
this.ensureCodecInfo().seiSuffix = Buffer.from(sei);
|
|
22702
23157
|
}
|
|
22703
23158
|
shouldFilter(_nalType) {
|
|
22704
23159
|
return false;
|
|
@@ -23009,6 +23464,48 @@ var H265Repacketizer = class {
|
|
|
23009
23464
|
}
|
|
23010
23465
|
}
|
|
23011
23466
|
};
|
|
23467
|
+
//#endregion
|
|
23468
|
+
//#region src/stream-broker/webrtc/output-arena.ts
|
|
23469
|
+
/**
|
|
23470
|
+
* A bump allocator for the buffers the WebRTC send path builds per packet
|
|
23471
|
+
* (repacketizer outputs, STAP-A aggregates).
|
|
23472
|
+
*
|
|
23473
|
+
* `Buffer.from([...])` and `Buffer.concat` below 4 KiB are served from Node's
|
|
23474
|
+
* shared 8 KiB pool: one ~1.2 KB fragment per pool slab share, interleaved
|
|
23475
|
+
* with every other short-lived pooled buffer in the process. werift keeps the
|
|
23476
|
+
* last RTP_HISTORY_SIZE (4096) outputs of a sender for NACK, so each pinned
|
|
23477
|
+
* the shared slab it came from and everything beside it. Here every output is
|
|
23478
|
+
* a dense view of an arena that holds nothing else.
|
|
23479
|
+
*
|
|
23480
|
+
* Nothing is ever reused: an arena is left to the GC once full, and lives as
|
|
23481
|
+
* long as the last view of it (a NACK slot, a datagram still in flight). That
|
|
23482
|
+
* is why there is no hold/release protocol here, unlike `slab-pool.ts`.
|
|
23483
|
+
*/
|
|
23484
|
+
/** Bytes per arena: ~50 packets of ~1.2 KB per allocation instead of one per 6. */
|
|
23485
|
+
var OUTPUT_ARENA_BYTES = 64 * 1024;
|
|
23486
|
+
/** A request larger than a quarter of an arena gets its own buffer. */
|
|
23487
|
+
var OUTPUT_ARENA_MAX_REQUEST = 16384;
|
|
23488
|
+
var OutputArena = class {
|
|
23489
|
+
chunk = Buffer.allocUnsafeSlow(0);
|
|
23490
|
+
offset = 0;
|
|
23491
|
+
chunks = 0;
|
|
23492
|
+
/** `size` writable bytes (contents unspecified: the caller fills all of them). */
|
|
23493
|
+
take(size) {
|
|
23494
|
+
if (size > OUTPUT_ARENA_MAX_REQUEST) return Buffer.allocUnsafeSlow(size);
|
|
23495
|
+
if (this.offset + size > this.chunk.length) {
|
|
23496
|
+
this.chunk = Buffer.allocUnsafeSlow(OUTPUT_ARENA_BYTES);
|
|
23497
|
+
this.chunks += 1;
|
|
23498
|
+
this.offset = 0;
|
|
23499
|
+
}
|
|
23500
|
+
const out = this.chunk.subarray(this.offset, this.offset + size);
|
|
23501
|
+
this.offset += size;
|
|
23502
|
+
return out;
|
|
23503
|
+
}
|
|
23504
|
+
/** ArrayBuffers allocated by this arena so far. */
|
|
23505
|
+
get allocations() {
|
|
23506
|
+
return this.chunks;
|
|
23507
|
+
}
|
|
23508
|
+
};
|
|
23012
23509
|
var NAL_HEADER_SIZE = 1;
|
|
23013
23510
|
var FU_A_HEADER_SIZE = 2;
|
|
23014
23511
|
var LENGTH_FIELD_SIZE = 2;
|
|
@@ -23053,6 +23550,8 @@ var H264Repacketizer = class {
|
|
|
23053
23550
|
pendingFuA;
|
|
23054
23551
|
stapa;
|
|
23055
23552
|
fuaMin;
|
|
23553
|
+
/** Dense home for every buffer this repacketizer builds (`output-arena.ts`). */
|
|
23554
|
+
arena = new OutputArena();
|
|
23056
23555
|
constructor(console, maxPacketSize, codecInfo, jitterBuffer = new JitterBuffer(console, 4)) {
|
|
23057
23556
|
this.console = console;
|
|
23058
23557
|
this.maxPacketSize = maxPacketSize;
|
|
@@ -23070,70 +23569,78 @@ var H264Repacketizer = class {
|
|
|
23070
23569
|
return this.codecInfo;
|
|
23071
23570
|
}
|
|
23072
23571
|
updateSps(sps) {
|
|
23073
|
-
this.ensureCodecInfo().sps = sps;
|
|
23572
|
+
this.ensureCodecInfo().sps = Buffer.from(sps);
|
|
23074
23573
|
}
|
|
23075
23574
|
updatePps(pps) {
|
|
23076
|
-
this.ensureCodecInfo().pps = pps;
|
|
23575
|
+
this.ensureCodecInfo().pps = Buffer.from(pps);
|
|
23077
23576
|
}
|
|
23078
23577
|
updateSei(sei) {
|
|
23079
|
-
this.ensureCodecInfo().sei = sei;
|
|
23578
|
+
this.ensureCodecInfo().sei = Buffer.from(sei);
|
|
23080
23579
|
}
|
|
23081
23580
|
shouldFilter(_nalType) {
|
|
23082
23581
|
return false;
|
|
23083
23582
|
}
|
|
23084
23583
|
packetizeFuA(data, noStart, noEnd) {
|
|
23584
|
+
let nalHeader;
|
|
23585
|
+
let bodyStart;
|
|
23085
23586
|
if ((data[0] & 31) === 28) {
|
|
23086
|
-
const fnri = data[0] & 224;
|
|
23087
|
-
const originalNalType = data[1] & 31;
|
|
23088
23587
|
const isFuStart = !!(data[1] & 128);
|
|
23089
23588
|
const isFuEnd = !!(data[1] & 64);
|
|
23090
|
-
|
|
23091
|
-
|
|
23092
|
-
data = Buffer.concat([originalNalHeader, data.subarray(FU_A_HEADER_SIZE)]);
|
|
23589
|
+
nalHeader = data[0] & 224 | data[1] & 31;
|
|
23590
|
+
bodyStart = FU_A_HEADER_SIZE;
|
|
23093
23591
|
if (isFuStart) noEnd = true;
|
|
23094
23592
|
else if (isFuEnd) noStart = true;
|
|
23095
|
-
else
|
|
23593
|
+
else {
|
|
23096
23594
|
noStart = true;
|
|
23097
23595
|
noEnd = true;
|
|
23098
23596
|
}
|
|
23597
|
+
} else {
|
|
23598
|
+
nalHeader = data[0];
|
|
23599
|
+
bodyStart = NAL_HEADER_SIZE;
|
|
23099
23600
|
}
|
|
23100
|
-
const
|
|
23101
|
-
const
|
|
23102
|
-
const
|
|
23103
|
-
const
|
|
23104
|
-
const fuHeaderStart = noStart ? fuHeaderMiddle : Buffer.from([fuIndicator, nalType | 128]);
|
|
23105
|
-
const fuHeaderEnd = noEnd ? fuHeaderMiddle : Buffer.from([fuIndicator, nalType | 64]);
|
|
23106
|
-
let fuHeader = fuHeaderStart;
|
|
23601
|
+
const nalType = nalHeader & 31;
|
|
23602
|
+
const fuIndicator = nalHeader & 224 | 28;
|
|
23603
|
+
const startHeader = noStart ? nalType : nalType | 128;
|
|
23604
|
+
const endHeader = noEnd ? nalType : nalType | 64;
|
|
23107
23605
|
const packages = [];
|
|
23108
|
-
let offset =
|
|
23606
|
+
let offset = bodyStart;
|
|
23607
|
+
let first = true;
|
|
23109
23608
|
while (offset < data.length) {
|
|
23110
23609
|
const packageSize = Math.min(this.fuaMax, data.length - offset);
|
|
23111
|
-
const
|
|
23610
|
+
const last = offset + packageSize === data.length;
|
|
23611
|
+
const out = this.arena.take(FU_A_HEADER_SIZE + packageSize);
|
|
23612
|
+
out[0] = fuIndicator;
|
|
23613
|
+
out[1] = last ? endHeader : first ? startHeader : nalType;
|
|
23614
|
+
data.copy(out, FU_A_HEADER_SIZE, offset, offset + packageSize);
|
|
23615
|
+
packages.push(out);
|
|
23112
23616
|
offset += packageSize;
|
|
23113
|
-
|
|
23114
|
-
packages.push(Buffer.concat([fuHeader, payload]));
|
|
23115
|
-
fuHeader = fuHeaderMiddle;
|
|
23617
|
+
first = false;
|
|
23116
23618
|
}
|
|
23117
23619
|
return packages;
|
|
23118
23620
|
}
|
|
23119
23621
|
packetizeOneStapA(datas) {
|
|
23120
|
-
const payload = [];
|
|
23121
23622
|
if (!datas.length) throw new Error("packetizeOneStapA requires at least one NAL");
|
|
23122
|
-
|
|
23623
|
+
const taken = [];
|
|
23123
23624
|
let availableSize = this.maxPacketSize - STAP_A_HEADER_SIZE;
|
|
23124
23625
|
const stapHeader = 24;
|
|
23125
|
-
while (datas.length && datas[0].length + LENGTH_FIELD_SIZE <= availableSize &&
|
|
23626
|
+
while (datas.length && datas[0].length + LENGTH_FIELD_SIZE <= availableSize && taken.length < 9) {
|
|
23126
23627
|
const nalu = datas.shift();
|
|
23127
23628
|
availableSize -= LENGTH_FIELD_SIZE + nalu.length;
|
|
23128
|
-
|
|
23129
|
-
|
|
23130
|
-
|
|
23131
|
-
|
|
23629
|
+
taken.push(nalu);
|
|
23630
|
+
}
|
|
23631
|
+
if (taken.length === 0) return datas.shift();
|
|
23632
|
+
if (taken.length === 1) return taken[0];
|
|
23633
|
+
let total = 1;
|
|
23634
|
+
for (const nalu of taken) total += LENGTH_FIELD_SIZE + nalu.length;
|
|
23635
|
+
const out = this.arena.take(total);
|
|
23636
|
+
out[0] = stapHeader;
|
|
23637
|
+
let offset = 1;
|
|
23638
|
+
for (const nalu of taken) {
|
|
23639
|
+
out.writeUInt16BE(nalu.length, offset);
|
|
23640
|
+
nalu.copy(out, offset + LENGTH_FIELD_SIZE);
|
|
23641
|
+
offset += LENGTH_FIELD_SIZE + nalu.length;
|
|
23132
23642
|
}
|
|
23133
|
-
|
|
23134
|
-
if (counter === 1) return payload[1];
|
|
23135
|
-
payload.unshift(Buffer.from([stapHeader]));
|
|
23136
|
-
return Buffer.concat(payload);
|
|
23643
|
+
return out;
|
|
23137
23644
|
}
|
|
23138
23645
|
packetizeStapA(datas) {
|
|
23139
23646
|
const ret = [];
|
|
@@ -23165,12 +23672,15 @@ var H264Repacketizer = class {
|
|
|
23165
23672
|
const originalNalType = first.payload[1] & 31;
|
|
23166
23673
|
const hasFuStart = !!(first.payload[1] & 128);
|
|
23167
23674
|
const hasFuEnd = !!(last.payload[1] & 64);
|
|
23168
|
-
const
|
|
23169
|
-
const originalNalHeader = Buffer.from([fnri | originalNalType]);
|
|
23675
|
+
const originalNalHeader = first.payload[0] & 224 | originalNalType;
|
|
23170
23676
|
const getDefragmentedPendingFua = () => {
|
|
23171
|
-
|
|
23172
|
-
|
|
23173
|
-
|
|
23677
|
+
let total = NAL_HEADER_SIZE;
|
|
23678
|
+
for (const packet of pending) total += packet.payload.length - FU_A_HEADER_SIZE;
|
|
23679
|
+
const out = this.arena.take(total);
|
|
23680
|
+
out[0] = originalNalHeader;
|
|
23681
|
+
let offset = NAL_HEADER_SIZE;
|
|
23682
|
+
for (const packet of pending) offset += packet.payload.copy(out, offset, FU_A_HEADER_SIZE);
|
|
23683
|
+
return out;
|
|
23174
23684
|
};
|
|
23175
23685
|
if (originalNalType === 7) {
|
|
23176
23686
|
const splits = splitH264NaluStartCode(getDefragmentedPendingFua());
|
|
@@ -23695,6 +24205,8 @@ function selectAudioClock(mimeType) {
|
|
|
23695
24205
|
*/
|
|
23696
24206
|
/** A REMB unchanged this long is no estimate (`RembWatch`). */
|
|
23697
24207
|
var REMB_STALE_MS = 3e4;
|
|
24208
|
+
/** werift's NACK history depth (patched RTP_HISTORY_SIZE, `patches/werift+0.22.9.patch`). */
|
|
24209
|
+
var SOURCE_RTP_HOLD_PACKETS = 4096;
|
|
23698
24210
|
var _werift;
|
|
23699
24211
|
async function loadWerift() {
|
|
23700
24212
|
if (_werift) return _werift;
|
|
@@ -24242,6 +24754,8 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
24242
24754
|
* recovery). While active, live source-RTP packets are enqueued behind
|
|
24243
24755
|
* the replay tail to preserve packet order. Aborted on close/rebind. */
|
|
24244
24756
|
activeRtpReplay = null;
|
|
24757
|
+
/** The source packets werift's NACK history may still read (`slab-pool.ts`). */
|
|
24758
|
+
sourceRtpHold = new HeldPacketWindow(SOURCE_RTP_HOLD_PACKETS);
|
|
24245
24759
|
/**
|
|
24246
24760
|
* Trickle ICE: the server's locally-gathered candidates, buffered as werift
|
|
24247
24761
|
* surfaces them via `onIceCandidate`. The client polls `getIceCandidates`
|
|
@@ -25120,6 +25634,7 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
25120
25634
|
this.activeRtpReplay.abort();
|
|
25121
25635
|
this.activeRtpReplay = null;
|
|
25122
25636
|
}
|
|
25637
|
+
this.sourceRtpHold.releaseAll();
|
|
25123
25638
|
await this.releaseTranscodeEgress();
|
|
25124
25639
|
try {
|
|
25125
25640
|
await this.source.return(void 0);
|
|
@@ -25253,6 +25768,7 @@ var AdaptiveSession = class AdaptiveSession {
|
|
|
25253
25768
|
} });
|
|
25254
25769
|
return false;
|
|
25255
25770
|
}
|
|
25771
|
+
this.sourceRtpHold.hold(rtpData);
|
|
25256
25772
|
if (this.sourceVideoSsrc === null) this.sourceVideoSsrc = srcPkt.header.ssrc;
|
|
25257
25773
|
const pt = this.videoSender.codec?.payloadType ?? (codec === "H265" ? 97 : 96);
|
|
25258
25774
|
let outPkts;
|
|
@@ -37858,6 +38374,16 @@ function dialAnswerFromError(err) {
|
|
|
37858
38374
|
}
|
|
37859
38375
|
//#endregion
|
|
37860
38376
|
//#region src/stream-broker/broker-mem-report.ts
|
|
38377
|
+
function readSlabReading() {
|
|
38378
|
+
const totals = readSlabTotals();
|
|
38379
|
+
const allocsDelta = totals.allocs - lastSlabAllocs;
|
|
38380
|
+
lastSlabAllocs = totals.allocs;
|
|
38381
|
+
return {
|
|
38382
|
+
live: totals.live,
|
|
38383
|
+
free: totals.free,
|
|
38384
|
+
allocsDelta
|
|
38385
|
+
};
|
|
38386
|
+
}
|
|
37861
38387
|
/** Linux only: elsewhere (or on a read error) the native view is simply absent. */
|
|
37862
38388
|
async function readProc() {
|
|
37863
38389
|
if (process.platform !== "linux") return {
|
|
@@ -37870,6 +38396,8 @@ async function readProc() {
|
|
|
37870
38396
|
maps
|
|
37871
38397
|
};
|
|
37872
38398
|
}
|
|
38399
|
+
/** `slabAllocs` is a delta: the totals the previous line saw. */
|
|
38400
|
+
var lastSlabAllocs = readSlabTotals().allocs;
|
|
37873
38401
|
async function emitOnce(deps) {
|
|
37874
38402
|
const now = Date.now();
|
|
37875
38403
|
const [proc, worker] = await Promise.all([readProc(), readDemuxWorkerHeaps(now)]);
|
|
@@ -37882,6 +38410,7 @@ async function emitOnce(deps) {
|
|
|
37882
38410
|
proc,
|
|
37883
38411
|
churn: consumerChurn.drain(),
|
|
37884
38412
|
copied: readCopyOwnedTotals(),
|
|
38413
|
+
slabs: readSlabReading(),
|
|
37885
38414
|
v8: readV8Heap(),
|
|
37886
38415
|
worker,
|
|
37887
38416
|
sessions: deps.sessionMem(),
|
|
@@ -37969,18 +38498,177 @@ function subscribeDeviceRemoval(subscribe, deps) {
|
|
|
37969
38498
|
});
|
|
37970
38499
|
}
|
|
37971
38500
|
//#endregion
|
|
38501
|
+
//#region src/stream-broker/device-removal-hook.ts
|
|
38502
|
+
/**
|
|
38503
|
+
* The stream-broker's `device-removal-hook` (D870).
|
|
38504
|
+
*
|
|
38505
|
+
* What the broker keeps PER CAMERA is a key of the camera's own settings
|
|
38506
|
+
* (`probeSnapshots`, `brokerProfile`, `rtspTokens` - see `broker-device-state.ts`),
|
|
38507
|
+
* and the central device delete (D865) clears those for every addon. What is
|
|
38508
|
+
* left is the broker's live state and its one addon-level map keyed by camera,
|
|
38509
|
+
* `deviceOverrides`: the hook retracts the camera's streams, purges its slot
|
|
38510
|
+
* assignments and drops its override entry. Idempotent - a camera with nothing
|
|
38511
|
+
* left answers zero counts.
|
|
38512
|
+
*/
|
|
38513
|
+
function createBrokerRemovalHook(deps) {
|
|
38514
|
+
return {
|
|
38515
|
+
onDeviceRemoved: async (input) => {
|
|
38516
|
+
const { deviceId } = input;
|
|
38517
|
+
try {
|
|
38518
|
+
await deps.retractDevice(deviceId);
|
|
38519
|
+
} catch (err) {
|
|
38520
|
+
deps.logger.warn("stream-broker: retract of a deleted device failed", {
|
|
38521
|
+
tags: { deviceId },
|
|
38522
|
+
meta: { error: errMsg(err) }
|
|
38523
|
+
});
|
|
38524
|
+
throw err;
|
|
38525
|
+
}
|
|
38526
|
+
const overrides = deps.dropDeviceOverride(deviceId) ? 1 : 0;
|
|
38527
|
+
if (overrides > 0) deps.logger.info("stream-broker: removed the override of a deleted device", {
|
|
38528
|
+
tags: { deviceId },
|
|
38529
|
+
meta: { deletedAt: input.deletedAt }
|
|
38530
|
+
});
|
|
38531
|
+
return { counts: { "device-overrides": overrides } };
|
|
38532
|
+
},
|
|
38533
|
+
listDeviceData: async () => deps.overrideDeviceIds().filter((deviceId) => Number.isInteger(deviceId) && deviceId > 0).map((deviceId) => ({
|
|
38534
|
+
store: "device-overrides",
|
|
38535
|
+
deviceId,
|
|
38536
|
+
count: 1
|
|
38537
|
+
}))
|
|
38538
|
+
};
|
|
38539
|
+
}
|
|
38540
|
+
/** Classify every held camera against the keychain: wait for it, or give up on the value. */
|
|
38541
|
+
async function classifyHeld(held, secrets) {
|
|
38542
|
+
const refs = /* @__PURE__ */ new Map();
|
|
38543
|
+
for (const [deviceId, stored] of held) if (isSecretMarker(stored)) refs.set(deviceId, stored.$secret);
|
|
38544
|
+
if (refs.size === 0) return [];
|
|
38545
|
+
const resolved = await secrets.resolve([...refs.values()]);
|
|
38546
|
+
const out = [];
|
|
38547
|
+
for (const [deviceId, ref] of refs) {
|
|
38548
|
+
const entry = resolved[ref];
|
|
38549
|
+
if (entry === void 0 || entry.kind === "unavailable") out.push({
|
|
38550
|
+
deviceId,
|
|
38551
|
+
ref,
|
|
38552
|
+
kind: "unavailable",
|
|
38553
|
+
reason: entry?.kind === "unavailable" ? entry.reason : null
|
|
38554
|
+
});
|
|
38555
|
+
else if (entry.kind === "missing") out.push({
|
|
38556
|
+
deviceId,
|
|
38557
|
+
ref,
|
|
38558
|
+
kind: "missing",
|
|
38559
|
+
reason: null
|
|
38560
|
+
});
|
|
38561
|
+
else if (entry.kind === "forbidden") out.push({
|
|
38562
|
+
deviceId,
|
|
38563
|
+
ref,
|
|
38564
|
+
kind: "unavailable",
|
|
38565
|
+
reason: "forbidden"
|
|
38566
|
+
});
|
|
38567
|
+
}
|
|
38568
|
+
return out;
|
|
38569
|
+
}
|
|
38570
|
+
var UNAVAILABLE_TEXT = "The camera's RTSP restream URLs are unavailable (consumers such as Home Assistant or an NVR get errors) until the keychain is readable. Its streams and recording are not affected.";
|
|
38571
|
+
var issuedText = (deviceId) => `New RTSP restream URLs were issued for camera ${deviceId} - update the consumers (NVR, Home Assistant). The previous tokens were lost: the keychain no longer holds them.`;
|
|
38572
|
+
var TokenRecovery = class {
|
|
38573
|
+
deps;
|
|
38574
|
+
/** Cameras alerted as unavailable: the alert is cleared when the value resolves. */
|
|
38575
|
+
waiting = /* @__PURE__ */ new Set();
|
|
38576
|
+
/** Consecutive `missing` reads per camera (D49: two before the value is given up). */
|
|
38577
|
+
missingReads = /* @__PURE__ */ new Map();
|
|
38578
|
+
timer = null;
|
|
38579
|
+
running = false;
|
|
38580
|
+
constructor(deps) {
|
|
38581
|
+
this.deps = deps;
|
|
38582
|
+
}
|
|
38583
|
+
/** Cameras currently blocked. */
|
|
38584
|
+
blocked() {
|
|
38585
|
+
return [...this.deps.mirror.heldDevices().keys()].toSorted((a, b) => a - b);
|
|
38586
|
+
}
|
|
38587
|
+
/** Check now, then on a timer while any camera is still held. */
|
|
38588
|
+
start() {
|
|
38589
|
+
if (this.timer !== null) return;
|
|
38590
|
+
this.tick();
|
|
38591
|
+
this.timer = setInterval(() => void this.tick(), this.deps.recheckMs ?? 3e4);
|
|
38592
|
+
this.timer.unref?.();
|
|
38593
|
+
}
|
|
38594
|
+
stop() {
|
|
38595
|
+
if (this.timer !== null) clearInterval(this.timer);
|
|
38596
|
+
this.timer = null;
|
|
38597
|
+
}
|
|
38598
|
+
/** One scheduled / change-triggered check; never overlaps itself, never throws. */
|
|
38599
|
+
async tick() {
|
|
38600
|
+
if (this.running) return;
|
|
38601
|
+
this.running = true;
|
|
38602
|
+
try {
|
|
38603
|
+
await this.check();
|
|
38604
|
+
} catch (err) {
|
|
38605
|
+
this.deps.logger.warn("restream-token re-check failed", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
38606
|
+
} finally {
|
|
38607
|
+
this.running = false;
|
|
38608
|
+
}
|
|
38609
|
+
if (this.blocked().length === 0 && this.waiting.size === 0) this.stop();
|
|
38610
|
+
}
|
|
38611
|
+
/** See the module doc. Idempotent. */
|
|
38612
|
+
async check() {
|
|
38613
|
+
const before = new Set(this.deps.mirror.heldDevices().keys());
|
|
38614
|
+
if (before.size === 0 && this.waiting.size === 0) return;
|
|
38615
|
+
const tokens = await this.deps.mirror.load();
|
|
38616
|
+
const now = new Set(this.deps.mirror.heldDevices().keys());
|
|
38617
|
+
const classified = await classifyHeld(this.deps.mirror.heldDevices(), this.deps.secrets);
|
|
38618
|
+
const released = [];
|
|
38619
|
+
for (const camera of classified) {
|
|
38620
|
+
if (camera.kind === "missing") {
|
|
38621
|
+
const reads = (this.missingReads.get(camera.deviceId) ?? 0) + 1;
|
|
38622
|
+
this.missingReads.set(camera.deviceId, reads);
|
|
38623
|
+
if (reads < 2) continue;
|
|
38624
|
+
this.deps.mirror.release(camera.deviceId);
|
|
38625
|
+
this.missingReads.delete(camera.deviceId);
|
|
38626
|
+
if (this.waiting.delete(camera.deviceId)) await this.deps.alerts.clear(camera.deviceId);
|
|
38627
|
+
released.push(camera.deviceId);
|
|
38628
|
+
continue;
|
|
38629
|
+
}
|
|
38630
|
+
this.missingReads.delete(camera.deviceId);
|
|
38631
|
+
if (this.waiting.has(camera.deviceId)) continue;
|
|
38632
|
+
this.waiting.add(camera.deviceId);
|
|
38633
|
+
this.deps.logger.error("restream tokens held but not readable", {
|
|
38634
|
+
tags: { deviceId: camera.deviceId },
|
|
38635
|
+
meta: {
|
|
38636
|
+
reason: camera.reason,
|
|
38637
|
+
ref: camera.ref
|
|
38638
|
+
}
|
|
38639
|
+
});
|
|
38640
|
+
await this.deps.alerts.raise({
|
|
38641
|
+
deviceId: camera.deviceId,
|
|
38642
|
+
kind: "unavailable",
|
|
38643
|
+
message: UNAVAILABLE_TEXT
|
|
38644
|
+
});
|
|
38645
|
+
}
|
|
38646
|
+
const recovered = [...before].filter((id) => !now.has(id) && !released.includes(id));
|
|
38647
|
+
for (const deviceId of recovered) if (this.waiting.delete(deviceId)) await this.deps.alerts.clear(deviceId);
|
|
38648
|
+
if (recovered.length > 0) this.deps.logger.info("restream tokens readable again", { meta: { cameras: recovered.length } });
|
|
38649
|
+
const cameras = [...recovered, ...released];
|
|
38650
|
+
if (cameras.length > 0) await this.deps.onRegister(tokens, cameras);
|
|
38651
|
+
for (const deviceId of released) {
|
|
38652
|
+
this.deps.logger.error("restream tokens lost - new URLs issued", { tags: { deviceId } });
|
|
38653
|
+
await this.deps.alerts.raise({
|
|
38654
|
+
deviceId,
|
|
38655
|
+
kind: "missing",
|
|
38656
|
+
message: issuedText(deviceId)
|
|
38657
|
+
});
|
|
38658
|
+
}
|
|
38659
|
+
}
|
|
38660
|
+
};
|
|
38661
|
+
//#endregion
|
|
37972
38662
|
//#region src/stream-broker/addon.ts
|
|
37973
38663
|
/**
|
|
37974
|
-
* Hydrate the persisted RTSP restream state. Tokens come from
|
|
37975
|
-
*
|
|
37976
|
-
*
|
|
37977
|
-
*
|
|
37978
|
-
*
|
|
38664
|
+
* Hydrate the persisted RTSP restream state. Tokens come from the cameras' own
|
|
38665
|
+
* settings; `rtspEnabled` is still a key of the settings row. An unreadable
|
|
38666
|
+
* token set THROWS (the caller refuses to write tokens afterwards - a
|
|
38667
|
+
* replacement minted over a token that was merely unreadable would turn every
|
|
38668
|
+
* external NVR URL into a 404), and an empty set keeps first-boot silence.
|
|
37979
38669
|
*/
|
|
37980
38670
|
async function hydratePersistedRtspState(deps) {
|
|
37981
|
-
const
|
|
37982
|
-
const tokens = /* @__PURE__ */ new Map();
|
|
37983
|
-
for (const [brokerId, row] of rows) tokens.set(brokerId, row.token);
|
|
38671
|
+
const tokens = flattenByDevice(await deps.tokensMirror.load());
|
|
37984
38672
|
if (tokens.size > 0) {
|
|
37985
38673
|
deps.loadTokens(tokens);
|
|
37986
38674
|
deps.logger.info("Loaded persisted RTSP tokens", { meta: { tokenCount: tokens.size } });
|
|
@@ -38049,6 +38737,8 @@ var PROBE_SNAPSHOTS_PERSIST_INTERVAL_MS = 6e4;
|
|
|
38049
38737
|
* the sample as absent (the RTCP Receiver-Report path drives the ladder anyway). */
|
|
38050
38738
|
var NETWORK_QUALITY_STATS_TIMEOUT_MS = 2e3;
|
|
38051
38739
|
var StreamBrokerAddon = class extends BaseAddon {
|
|
38740
|
+
/** The restream tokens of a camera are a per-device setting held in the keychain (D870). */
|
|
38741
|
+
secretStatePaths = ["rtspTokens"];
|
|
38052
38742
|
brokerManager = null;
|
|
38053
38743
|
/** The node's pinned ffmpeg, mirrored here so the recorded-playback demuxers
|
|
38054
38744
|
* (segment-demux/segment-audio) spawn the same binary as the transcode
|
|
@@ -38105,6 +38795,7 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
38105
38795
|
* restarts). Loaded on activate, flushed throttled from the metrics loop and
|
|
38106
38796
|
* once on shutdown. */
|
|
38107
38797
|
probeSnapshots = null;
|
|
38798
|
+
removalHook = null;
|
|
38108
38799
|
lastProbePersistAt = 0;
|
|
38109
38800
|
constructor() {
|
|
38110
38801
|
super({
|
|
@@ -38167,24 +38858,26 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
38167
38858
|
logger: this.ctx.logger
|
|
38168
38859
|
});
|
|
38169
38860
|
await this.ffmpegWiring.load();
|
|
38170
|
-
|
|
38171
|
-
await this.purgeFlattenedSettingsKeys();
|
|
38861
|
+
const deviceSettings = this.deviceSettings();
|
|
38172
38862
|
const rtspProvider = this.brokerManager.getRtspRestreamProvider();
|
|
38173
38863
|
const brokerManager = this.brokerManager;
|
|
38174
|
-
const rtspTokens =
|
|
38864
|
+
const rtspTokens = rtspTokensMirror(deviceSettings, this.ctx.logger);
|
|
38175
38865
|
const rtspEnabledState = this.state("rtspEnabled", RtspEnabledSchema, {});
|
|
38176
38866
|
try {
|
|
38177
38867
|
await hydratePersistedRtspState({
|
|
38178
|
-
|
|
38868
|
+
tokensMirror: rtspTokens,
|
|
38179
38869
|
enabledState: rtspEnabledState,
|
|
38180
38870
|
loadTokens: (tokens) => brokerManager.loadPersistedTokens(tokens),
|
|
38181
38871
|
loadEnabled: (states) => rtspProvider.loadPersistedEnabled(states),
|
|
38182
38872
|
logger: this.ctx.logger
|
|
38183
38873
|
});
|
|
38184
|
-
} catch {
|
|
38874
|
+
} catch (err) {
|
|
38875
|
+
this.ctx.logger.warn("Failed to load persisted RTSP state - tokens will not be written", { meta: { error: errMsg(err) } });
|
|
38876
|
+
}
|
|
38877
|
+
this.startTokenRecovery(rtspTokens, brokerManager);
|
|
38185
38878
|
rtspProvider.setTokenPersister(async (tokens) => {
|
|
38186
38879
|
try {
|
|
38187
|
-
|
|
38880
|
+
await rtspTokens.merge(mergeRtspTokens(rtspTokens, tokens));
|
|
38188
38881
|
this.ctx.logger.info("Persisted RTSP tokens", { meta: { current: tokens.size } });
|
|
38189
38882
|
} catch (err) {
|
|
38190
38883
|
this.ctx.logger.warn("Failed to persist RTSP tokens", { meta: { error: errMsg(err) } });
|
|
@@ -38217,24 +38910,22 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
38217
38910
|
this.ctx.logger.warn("Failed to persist device overrides", { meta: { error: errMsg(err) } });
|
|
38218
38911
|
});
|
|
38219
38912
|
});
|
|
38220
|
-
const profileMap =
|
|
38913
|
+
const profileMap = profileMapMirror(deviceSettings, this.ctx.logger);
|
|
38221
38914
|
const profileMapPersister = (assignments) => {
|
|
38222
38915
|
const next = /* @__PURE__ */ new Map();
|
|
38223
|
-
for (const [deviceId, assignment] of assignments) next.set(
|
|
38916
|
+
for (const [deviceId, assignment] of assignments) next.set(deviceId, {
|
|
38224
38917
|
map: { ...assignment.map },
|
|
38225
38918
|
auto: assignment.auto
|
|
38226
38919
|
});
|
|
38227
|
-
profileMap.
|
|
38920
|
+
profileMap.replace(next).catch((err) => {
|
|
38228
38921
|
this.ctx.logger.warn("Failed to persist profile map", { meta: { error: errMsg(err) } });
|
|
38229
38922
|
});
|
|
38230
38923
|
};
|
|
38231
38924
|
this.brokerManager.setProfileMapPersister(profileMapPersister);
|
|
38232
38925
|
try {
|
|
38233
|
-
const rows = await profileMap.
|
|
38926
|
+
const rows = await profileMap.load();
|
|
38234
38927
|
const loaded = /* @__PURE__ */ new Map();
|
|
38235
|
-
for (const [
|
|
38236
|
-
const deviceId = Number(rawKey);
|
|
38237
|
-
if (!Number.isInteger(deviceId)) continue;
|
|
38928
|
+
for (const [deviceId, val] of rows) {
|
|
38238
38929
|
const map = {};
|
|
38239
38930
|
for (const profile of PROFILE_KEYS) {
|
|
38240
38931
|
const v = val.map[profile];
|
|
@@ -38250,18 +38941,18 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
38250
38941
|
} catch (err) {
|
|
38251
38942
|
this.ctx.logger.warn("Failed to load profile map", { meta: { error: errMsg(err) } });
|
|
38252
38943
|
}
|
|
38253
|
-
const probeSnapshots =
|
|
38944
|
+
const probeSnapshots = probeSnapshotsMirror(deviceSettings, this.ctx.logger);
|
|
38254
38945
|
this.probeSnapshots = probeSnapshots;
|
|
38255
38946
|
try {
|
|
38256
|
-
const rows = await probeSnapshots.
|
|
38257
|
-
const blob = Object.fromEntries(rows);
|
|
38947
|
+
const rows = await probeSnapshots.load();
|
|
38948
|
+
const blob = Object.fromEntries(flattenByDevice(rows));
|
|
38258
38949
|
this.brokerManager.loadProbeSnapshots(blob);
|
|
38259
|
-
this.ctx.logger.info("Loaded persisted probe snapshots", { meta: { streamCount:
|
|
38950
|
+
this.ctx.logger.info("Loaded persisted probe snapshots", { meta: { streamCount: Object.keys(blob).length } });
|
|
38260
38951
|
} catch (err) {
|
|
38261
38952
|
this.ctx.logger.warn("Failed to load probe snapshots", { meta: { error: errMsg(err) } });
|
|
38262
38953
|
}
|
|
38263
38954
|
this.brokerManager.setDeviceHardwareForgetHook(async (deviceId) => {
|
|
38264
|
-
const rtspTokenRowsDeleted = await rtspTokens.
|
|
38955
|
+
const rtspTokenRowsDeleted = await rtspTokens.remove(deviceId) ? 1 : 0;
|
|
38265
38956
|
this.flushProbeSnapshots(Date.now(), true);
|
|
38266
38957
|
return { rtspTokenRowsDeleted };
|
|
38267
38958
|
});
|
|
@@ -38519,6 +39210,14 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
38519
39210
|
} catch (err) {
|
|
38520
39211
|
this.ctx.logger.warn("stream-broker: client-log ingest failed to serve", { meta: { error: errMsg(err) } });
|
|
38521
39212
|
}
|
|
39213
|
+
this.removalHook = createBrokerRemovalHook({
|
|
39214
|
+
logger: this.ctx.logger,
|
|
39215
|
+
retractDevice: async (deviceId) => {
|
|
39216
|
+
await retractRemovedDevice(deviceId, this.deviceRemovalDeps());
|
|
39217
|
+
},
|
|
39218
|
+
dropDeviceOverride: (deviceId) => brokerManager.dropDeviceOverride(deviceId),
|
|
39219
|
+
overrideDeviceIds: () => brokerManager.listDeviceOverrideIds()
|
|
39220
|
+
});
|
|
38522
39221
|
return [
|
|
38523
39222
|
{
|
|
38524
39223
|
capability: streamBrokerCapability,
|
|
@@ -38537,9 +39236,90 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
38537
39236
|
provider: widgetsProvider
|
|
38538
39237
|
},
|
|
38539
39238
|
logChannelsRegistration,
|
|
38540
|
-
loadContributionRegistration
|
|
39239
|
+
loadContributionRegistration,
|
|
39240
|
+
{
|
|
39241
|
+
capability: deviceRemovalHookCapability,
|
|
39242
|
+
provider: this.removalHook
|
|
39243
|
+
}
|
|
38541
39244
|
];
|
|
38542
39245
|
}
|
|
39246
|
+
/**
|
|
39247
|
+
* Watch the cameras whose restream tokens are held but unreadable (D870): no
|
|
39248
|
+
* restream URL is registered for them (the manager's gate), an alert says so,
|
|
39249
|
+
* and when a value resolves ONLY that camera's restreamers are registered with
|
|
39250
|
+
* the stored tokens - no teardown. A lost secret is released after two
|
|
39251
|
+
* consistent reads and gets new, persisted tokens.
|
|
39252
|
+
*/
|
|
39253
|
+
startTokenRecovery(mirror, brokerManager) {
|
|
39254
|
+
const secrets = this.ctx.secrets;
|
|
39255
|
+
if (secrets === void 0) {
|
|
39256
|
+
if (mirror.heldDevices().size > 0) this.ctx.logger.error("restream tokens look held but this addon has no keychain access", { meta: { cameras: mirror.heldDevices().size } });
|
|
39257
|
+
return;
|
|
39258
|
+
}
|
|
39259
|
+
brokerManager.setRestreamGate((deviceId) => mirror.isBlocked(deviceId));
|
|
39260
|
+
if (mirror.heldDevices().size === 0) return;
|
|
39261
|
+
const alertId = (deviceId) => `stream-broker-tokens:${deviceId}`;
|
|
39262
|
+
const warn = (what) => (err) => this.ctx.logger.warn(what, { meta: { error: errMsg(err) } });
|
|
39263
|
+
const recovery = new TokenRecovery({
|
|
39264
|
+
mirror,
|
|
39265
|
+
secrets,
|
|
39266
|
+
logger: this.ctx.logger,
|
|
39267
|
+
alerts: {
|
|
39268
|
+
raise: async ({ deviceId, kind, message }) => {
|
|
39269
|
+
const now = Date.now();
|
|
39270
|
+
await this.ctx.api.alerts.emit.mutate({
|
|
39271
|
+
id: alertId(deviceId),
|
|
39272
|
+
category: "streams",
|
|
39273
|
+
severity: "warning",
|
|
39274
|
+
title: kind === "missing" ? "New RTSP restream URLs issued" : "RTSP restream URLs unavailable",
|
|
39275
|
+
message,
|
|
39276
|
+
status: "active",
|
|
39277
|
+
read: false,
|
|
39278
|
+
createdAt: now,
|
|
39279
|
+
updatedAt: now,
|
|
39280
|
+
source: {
|
|
39281
|
+
type: "device",
|
|
39282
|
+
id: String(deviceId)
|
|
39283
|
+
},
|
|
39284
|
+
metadata: {
|
|
39285
|
+
deviceId,
|
|
39286
|
+
kind
|
|
39287
|
+
}
|
|
39288
|
+
}).catch(warn("could not raise the restream-token alert"));
|
|
39289
|
+
},
|
|
39290
|
+
clear: async (deviceId) => {
|
|
39291
|
+
await this.ctx.api.alerts.update.mutate({
|
|
39292
|
+
alertId: alertId(deviceId),
|
|
39293
|
+
patch: {
|
|
39294
|
+
status: "completed",
|
|
39295
|
+
updatedAt: Date.now()
|
|
39296
|
+
}
|
|
39297
|
+
}).catch(warn("could not clear the restream-token alert"));
|
|
39298
|
+
}
|
|
39299
|
+
},
|
|
39300
|
+
onRegister: async (tokens, cameras) => {
|
|
39301
|
+
brokerManager.loadPersistedTokens(flattenByDevice(tokens));
|
|
39302
|
+
for (const deviceId of cameras) brokerManager.registerRestreamersForDevice(deviceId);
|
|
39303
|
+
}
|
|
39304
|
+
});
|
|
39305
|
+
recovery.start();
|
|
39306
|
+
const unsubscribe = secrets.onChange((ref) => {
|
|
39307
|
+
if (ref.startsWith("secret://stream-broker/device/")) recovery.tick();
|
|
39308
|
+
});
|
|
39309
|
+
this.ctx.addDisposer(() => {
|
|
39310
|
+
recovery.stop();
|
|
39311
|
+
unsubscribe();
|
|
39312
|
+
});
|
|
39313
|
+
}
|
|
39314
|
+
async onHubReachable() {
|
|
39315
|
+
const removalHook = this.removalHook;
|
|
39316
|
+
if (removalHook === null) return;
|
|
39317
|
+
this.ctx.addDisposer(armRemovalReconcile({
|
|
39318
|
+
api: this.ctx.api,
|
|
39319
|
+
logger: this.ctx.logger,
|
|
39320
|
+
remove: (input) => removalHook.onDeviceRemoved(input)
|
|
39321
|
+
}));
|
|
39322
|
+
}
|
|
38543
39323
|
async onShutdown() {
|
|
38544
39324
|
this.logChannels?.stop();
|
|
38545
39325
|
this.logChannels = null;
|
|
@@ -38640,41 +39420,14 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
38640
39420
|
this.flushProbeSnapshots(timestamp, false);
|
|
38641
39421
|
}
|
|
38642
39422
|
/**
|
|
38643
|
-
*
|
|
38644
|
-
*
|
|
38645
|
-
*
|
|
38646
|
-
*
|
|
38647
|
-
* Not swallowed: a broker whose `stream-broker:rtsp-tokens` is undeclared
|
|
38648
|
-
* mints a new token for every stream and every external NVR URL in the
|
|
38649
|
-
* installation goes dead. Crashing loudly is bounded by `CrashSupervisor`
|
|
38650
|
-
* (D6); silently reissuing tokens is not.
|
|
39423
|
+
* The per-device settings, as the broker persists through them. Resolved
|
|
39424
|
+
* reads/writes (keychain markers never reach this code), and a context with
|
|
39425
|
+
* no settings is a broker that cannot start.
|
|
38651
39426
|
*/
|
|
38652
|
-
|
|
38653
|
-
const
|
|
38654
|
-
|
|
38655
|
-
|
|
38656
|
-
await rtspTokensStore(store, logger).declare();
|
|
38657
|
-
await profileMapStore(store, logger).declare();
|
|
38658
|
-
}
|
|
38659
|
-
/**
|
|
38660
|
-
* Drop the flattened keys from `stream-broker:addon-settings` / `root`, each
|
|
38661
|
-
* gated on its own successor collection being non-empty. `rtspEnabled` and
|
|
38662
|
-
* `deviceOverrides` stay — see `broker-row-stores.ts` for why.
|
|
38663
|
-
*
|
|
38664
|
-
* Best-effort: a purge that throws must not take the broker down. The keys
|
|
38665
|
-
* are dead weight, not a fault.
|
|
38666
|
-
*/
|
|
38667
|
-
async purgeFlattenedSettingsKeys() {
|
|
38668
|
-
try {
|
|
38669
|
-
await retireRootKeys({
|
|
38670
|
-
store: this.ctx.api.settingsStore,
|
|
38671
|
-
addonId: STREAM_BROKER_ADDON_ID,
|
|
38672
|
-
logger: this.ctx.logger,
|
|
38673
|
-
specs: STREAM_BROKER_RETIRED_ROOT_KEYS
|
|
38674
|
-
});
|
|
38675
|
-
} catch (err) {
|
|
38676
|
-
this.ctx.logger.warn("stream-broker: retiring the flattened settings keys failed", { meta: { error: errMsg(err) } });
|
|
38677
|
-
}
|
|
39427
|
+
deviceSettings() {
|
|
39428
|
+
const settings = this.ctx.settings;
|
|
39429
|
+
if (settings === void 0) throw new Error("stream-broker: device settings are not available");
|
|
39430
|
+
return settings;
|
|
38678
39431
|
}
|
|
38679
39432
|
/**
|
|
38680
39433
|
* Persist the manager's probe map to the durable handle. Throttled to
|
|
@@ -38687,7 +39440,7 @@ var StreamBrokerAddon = class extends BaseAddon {
|
|
|
38687
39440
|
if (!store || !manager) return;
|
|
38688
39441
|
if (!force && now - this.lastProbePersistAt < PROBE_SNAPSHOTS_PERSIST_INTERVAL_MS) return;
|
|
38689
39442
|
this.lastProbePersistAt = now;
|
|
38690
|
-
store.
|
|
39443
|
+
store.replace(groupByDevice(manager.getProbeSnapshots())).catch((err) => {
|
|
38691
39444
|
this.ctx.logger.warn("Failed to persist probe snapshots", { meta: { error: errMsg(err) } });
|
|
38692
39445
|
});
|
|
38693
39446
|
}
|