@camstack/addon-pipeline 1.2.254 → 1.2.256
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 +2 -2
- package/dist/audio-analyzer/index.mjs +2 -2
- package/dist/{default-detection-model-BegEf29d.mjs → default-detection-model-A5DRjEr3.mjs} +1 -1
- package/dist/{default-detection-model-DQj3yiG8.js → default-detection-model-Bbw4nC11.js} +1 -1
- package/dist/detection-pipeline/index.js +4 -4
- package/dist/detection-pipeline/index.mjs +4 -4
- package/dist/{dist-CkGvx3Fy.mjs → dist-MJ4ji7um.mjs} +284 -25
- package/dist/{dist-DQsOgAJp.js → dist-lqqUXDfs.js} +284 -25
- package/dist/motion-wasm/index.js +1 -1
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/{node-Bg7LGOQ3.js → node-80-uOMr8.js} +1 -1
- package/dist/{node-Dwlty0jj.mjs → node-DdXmHLK4.mjs} +1 -1
- package/dist/pipeline-runner/index.js +3 -3
- package/dist/pipeline-runner/index.mjs +3 -3
- package/dist/{process-memory-CzjYHqAz.mjs → process-memory-Cl3ubMxY.mjs} +1 -1
- package/dist/{process-memory-COrQw_pf.js → process-memory-Dac9TXlX.js} +1 -1
- package/dist/recorder/index.js +472 -51
- package/dist/recorder/index.mjs +472 -51
- package/dist/{segment-demux-js-BIrnzZiP.js → segment-demux-js-CWwlKQ3M.js} +1 -1
- package/dist/{segment-demux-js-BAoEDsxT.mjs → segment-demux-js-gIj82O9k.mjs} +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-rumvXmC-.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-pOLAb3B1.mjs} +3 -3
- package/dist/stream-broker/{_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-k114uFcJ.mjs → _virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-CPFjd94C.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-BXK7jbJm.mjs +26 -0
- 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-CFFft0gC.mjs → hostInit-BRnsXyTM.mjs} +3 -3
- package/dist/stream-broker/index.js +3 -3
- package/dist/stream-broker/index.mjs +3 -3
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/package.json +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-BHtcf-Mo.mjs +0 -26
package/dist/recorder/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { $t as hydrateSchema, C as OpsLogEntrySchema, Ct as recordingExportCapability, D as RECORDING_EXPORT_MAX_READ_BYTES, Dt as resolveLocationMode, H as deriveBatteryPresence, I as batteryCapability, Kt as DeviceFeature, Lt as errMsg, Mt as storageOccupancyCapability, O as RecordingConfigSchema, Pt as stripRetiredBandPreBufferSec, Q as evictionPolicyOfLocation, St as recordingCapability, W as deriveRecordingMode, Wt as BaseAddon, Xt as customAction, Y as evaluateSensorEdge, Z as evictionPolicyForMode, Zt as defineCustomActions, _n as record, bn as EventCategory, cn as _enum, ct as mayReadLocation, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dt as modeMayWrite, et as gbToBytes, g as ExportRecordSchema, gn as preprocess, hn as object, i as BatteryStatusSchema, it as loadContributionCapability, jt as storageEvictableCapability, k as RecordingSignalStatusSchema, kt as resolveRecordingProfiles, ln as array, lt as mayWriteToLocation, mn as number, on as selectAssignedProfileSlots, qt as DeviceType, rn as nodePin, rt as isFirstLevelMacroClass, sn as sleep$1, un as boolean, ut as migrateLegacyDeviceSignalConfig, vn as string, wt as recordingSignalCapability } from "../dist-
|
|
2
|
-
import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-
|
|
1
|
+
import { $t as hydrateSchema, C as OpsLogEntrySchema, Ct as recordingExportCapability, D as RECORDING_EXPORT_MAX_READ_BYTES, Dt as resolveLocationMode, H as deriveBatteryPresence, I as batteryCapability, Kt as DeviceFeature, Lt as errMsg, Mt as storageOccupancyCapability, O as RecordingConfigSchema, Pt as stripRetiredBandPreBufferSec, Q as evictionPolicyOfLocation, St as recordingCapability, W as deriveRecordingMode, Wt as BaseAddon, Xt as customAction, Y as evaluateSensorEdge, Z as evictionPolicyForMode, Zt as defineCustomActions, _n as record, bn as EventCategory, cn as _enum, ct as mayReadLocation, d as DEFAULT_EVENTS_BAND_BUFFER_SEC, dt as modeMayWrite, et as gbToBytes, g as ExportRecordSchema, gn as preprocess, hn as object, i as BatteryStatusSchema, it as loadContributionCapability, jt as storageEvictableCapability, k as RecordingSignalStatusSchema, kt as resolveRecordingProfiles, ln as array, lt as mayWriteToLocation, mn as number, on as selectAssignedProfileSlots, qt as DeviceType, rn as nodePin, rt as isFirstLevelMacroClass, sn as sleep$1, un as boolean, ut as migrateLegacyDeviceSignalConfig, vn as string, wt as recordingSignalCapability } from "../dist-MJ4ji7um.mjs";
|
|
2
|
+
import { a as physicalRootOf, i as NO_COST_CLAIM, t as ChildCostRegistry } from "../node-DdXmHLK4.mjs";
|
|
3
3
|
import { t as resolveHubHostname } from "../hub-hostname-cCknRYKj.mjs";
|
|
4
4
|
import { c as PREBUFFER_MAX_MS, r as RECORDING_PRE_ROLL_REQUEST_MS, s as withRecordingIntent } from "../restream-intent-D2VT97-U.mjs";
|
|
5
5
|
import { n as RowMapStore, t as retireRootKeys } from "../retire-root-keys-DZvlso2l.mjs";
|
|
@@ -814,14 +814,14 @@ function parseSegmentPath(relPath) {
|
|
|
814
814
|
//#region src/recorder/recording-index.ts
|
|
815
815
|
/** Hour bucket, the granularity the path convention (`.../YYYY/MM/DD/HH/`)
|
|
816
816
|
* already gives us — so "have I walked this?" costs one Set lookup. */
|
|
817
|
-
var HOUR_MS$
|
|
817
|
+
var HOUR_MS$9 = 36e5;
|
|
818
818
|
/** A device walked end-to-end. Cheaper and clearer than inserting every hour of
|
|
819
819
|
* the retention window into the set. */
|
|
820
820
|
var ALL_HOURS = Symbol("all-hours");
|
|
821
821
|
/** The hour buckets a half-open span touches. */
|
|
822
822
|
function* hourBuckets(fromMs, toMs) {
|
|
823
|
-
const first = Math.floor(fromMs / HOUR_MS$
|
|
824
|
-
for (let h = first; h < toMs; h += HOUR_MS$
|
|
823
|
+
const first = Math.floor(fromMs / HOUR_MS$9) * HOUR_MS$9;
|
|
824
|
+
for (let h = first; h < toMs; h += HOUR_MS$9) yield h;
|
|
825
825
|
}
|
|
826
826
|
/**
|
|
827
827
|
* Index of the first element whose `startMs` is GREATER than `startMs` (upper
|
|
@@ -1115,7 +1115,7 @@ var RecordingIndex = class {
|
|
|
1115
1115
|
changed = true;
|
|
1116
1116
|
}
|
|
1117
1117
|
if (changed) this.invalidateSorted(deviceId);
|
|
1118
|
-
this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$
|
|
1118
|
+
this.markHydrated(deviceId, hourStartMs, hourStartMs + HOUR_MS$9);
|
|
1119
1119
|
}
|
|
1120
1120
|
/**
|
|
1121
1121
|
* Insert/replace one finalized segment (idempotent by path).
|
|
@@ -1761,11 +1761,11 @@ function hourDirRelPath(deviceId, profile, hourStartMs) {
|
|
|
1761
1761
|
const p2 = (n) => String(n).padStart(2, "0");
|
|
1762
1762
|
return `${deviceId}/${profile}/${d.getUTCFullYear()}/${p2(d.getUTCMonth() + 1)}/${p2(d.getUTCDate())}/${p2(d.getUTCHours())}`;
|
|
1763
1763
|
}
|
|
1764
|
-
var HOUR_MS$
|
|
1764
|
+
var HOUR_MS$8 = 36e5;
|
|
1765
1765
|
async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations, logger, ledger, options) {
|
|
1766
1766
|
const verify = options?.verifyClosedHours === true;
|
|
1767
1767
|
if (!verify && index.hydrationOf(deviceId, fromMs, toMs) === "hydrated") return;
|
|
1768
|
-
const liveHour = Math.floor(Date.now() / HOUR_MS$
|
|
1768
|
+
const liveHour = Math.floor(Date.now() / HOUR_MS$8) * HOUR_MS$8;
|
|
1769
1769
|
/** Per (locationId, profile, hour): what the LEDGER claimed, for the diff. */
|
|
1770
1770
|
const claimed = /* @__PURE__ */ new Map();
|
|
1771
1771
|
const claimKey = (locationId, hour) => `${locationId}:${hour}`;
|
|
@@ -1815,15 +1815,15 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
1815
1815
|
});
|
|
1816
1816
|
continue;
|
|
1817
1817
|
}
|
|
1818
|
-
const first = Math.floor(fromMs / HOUR_MS$
|
|
1818
|
+
const first = Math.floor(fromMs / HOUR_MS$8) * HOUR_MS$8;
|
|
1819
1819
|
const reuseBefore = index.hydrateReuseStats();
|
|
1820
1820
|
let hoursRead = 0;
|
|
1821
1821
|
let repaired = 0;
|
|
1822
1822
|
let forgottenSegments = 0;
|
|
1823
1823
|
let forgottenHours = 0;
|
|
1824
1824
|
let sealed = 0;
|
|
1825
|
-
for (let hour = first; hour < toMs; hour += HOUR_MS$
|
|
1826
|
-
if (!verify && hour < liveHour && !unsealed.has(hour) && index.hydrationOf(deviceId, hour, hour + HOUR_MS$
|
|
1825
|
+
for (let hour = first; hour < toMs; hour += HOUR_MS$8) {
|
|
1826
|
+
if (!verify && hour < liveHour && !unsealed.has(hour) && index.hydrationOf(deviceId, hour, hour + HOUR_MS$8) === "hydrated") continue;
|
|
1827
1827
|
for (const profile of profiles) {
|
|
1828
1828
|
const rel = hourDirRelPath(deviceId, profile, hour);
|
|
1829
1829
|
let files;
|
|
@@ -1836,7 +1836,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
1836
1836
|
const locationId = locationForHydratedProfile(aliases, profile).id;
|
|
1837
1837
|
const relPaths = files.filter((f) => f.endsWith(".m4s")).map((f) => `${rel}/${f}`);
|
|
1838
1838
|
index.hydrateHour(deviceId, locationId, hour, relPaths);
|
|
1839
|
-
if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$
|
|
1839
|
+
if (ledger === void 0 || relPaths.length === 0 || hour + HOUR_MS$8 > Date.now()) continue;
|
|
1840
1840
|
const claimedHere = claimed.get(claimKey(locationId, hour));
|
|
1841
1841
|
const outcome = await ledger.adoptWalkedHour(deviceId, profile, locationId, hour, relPaths);
|
|
1842
1842
|
if (outcome === null) continue;
|
|
@@ -1919,7 +1919,7 @@ async function hydrateWindowFromStorage(index, deviceId, fromMs, toMs, locations
|
|
|
1919
1919
|
*/
|
|
1920
1920
|
var DEFAULT_THROTTLE_MBPS = 40;
|
|
1921
1921
|
/** The durable index's bucket, and therefore this mover's batch. */
|
|
1922
|
-
var HOUR_MS$
|
|
1922
|
+
var HOUR_MS$7 = 36e5;
|
|
1923
1923
|
/** Progress callback cadence (files) — cheap enough to fire often. */
|
|
1924
1924
|
var PROGRESS_EVERY_FILES = 25;
|
|
1925
1925
|
/** Free space a destination must keep when it declares no `config.minFreePercent`
|
|
@@ -2616,7 +2616,7 @@ function profilesOfClass(footageClass) {
|
|
|
2616
2616
|
function groupByHour(rows) {
|
|
2617
2617
|
const out = /* @__PURE__ */ new Map();
|
|
2618
2618
|
for (const row of rows) {
|
|
2619
|
-
const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$
|
|
2619
|
+
const key = `${row.deviceId}|${row.profile}|${Math.floor(row.startMs / HOUR_MS$7)}`;
|
|
2620
2620
|
const found = out.get(key);
|
|
2621
2621
|
if (found) found.push(row);
|
|
2622
2622
|
else out.set(key, [row]);
|
|
@@ -3844,7 +3844,7 @@ async function profileHasFootage(root, deviceId, profile) {
|
|
|
3844
3844
|
//#endregion
|
|
3845
3845
|
//#region src/recorder/addon/calendar-index.ts
|
|
3846
3846
|
var DAY_MS$3 = 864e5;
|
|
3847
|
-
var HOUR_MS$
|
|
3847
|
+
var HOUR_MS$6 = 36e5;
|
|
3848
3848
|
/**
|
|
3849
3849
|
* UTC ms → the UTC epoch of LOCAL midnight of the day it falls in.
|
|
3850
3850
|
*
|
|
@@ -3882,8 +3882,8 @@ var RANGE_MERGE_GAP_MS$1 = 5e3;
|
|
|
3882
3882
|
* deploy gap). Unknown hours still fall through to one directory read.
|
|
3883
3883
|
*/
|
|
3884
3884
|
function hourSegmentsFromIndex(index, deviceId, profile, hourStartMs) {
|
|
3885
|
-
const hour = Math.floor(hourStartMs / HOUR_MS$
|
|
3886
|
-
const hourEnd = hour + HOUR_MS$
|
|
3885
|
+
const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
|
|
3886
|
+
const hourEnd = hour + HOUR_MS$6;
|
|
3887
3887
|
if (index.hydrationOf(deviceId, hour, hourEnd) !== "hydrated") return null;
|
|
3888
3888
|
const rows = [];
|
|
3889
3889
|
for (const s of index.segmentsStartingIn(deviceId, profile, hour, hourEnd)) rows.push({
|
|
@@ -4042,7 +4042,7 @@ var CalendarIndex = class {
|
|
|
4042
4042
|
}));
|
|
4043
4043
|
const days = /* @__PURE__ */ new Set();
|
|
4044
4044
|
for (const { day, hours } of perDay) for (const h of hours) {
|
|
4045
|
-
const at = day + h * HOUR_MS$
|
|
4045
|
+
const at = day + h * HOUR_MS$6;
|
|
4046
4046
|
if (at < fromMs || at >= toMs) continue;
|
|
4047
4047
|
days.add(localDayStart(at, tzOffsetMinutes));
|
|
4048
4048
|
}
|
|
@@ -4104,7 +4104,7 @@ var CalendarIndex = class {
|
|
|
4104
4104
|
const day = utcDayOf(utcDayMs);
|
|
4105
4105
|
for (let h = 0; h < 24; h++) {
|
|
4106
4106
|
await this.awaitQuiet();
|
|
4107
|
-
await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$
|
|
4107
|
+
await this.segmentsOf(deviceId, profile, day + h * HOUR_MS$6, true);
|
|
4108
4108
|
}
|
|
4109
4109
|
}
|
|
4110
4110
|
/**
|
|
@@ -4125,7 +4125,7 @@ var CalendarIndex = class {
|
|
|
4125
4125
|
*/
|
|
4126
4126
|
async segmentsOfChecked(deviceId, profile, hourStartMs, background = false) {
|
|
4127
4127
|
if (!background) this.lastForegroundAt = this.deps.now?.() ?? Date.now();
|
|
4128
|
-
const hour = Math.floor(hourStartMs / HOUR_MS$
|
|
4128
|
+
const hour = Math.floor(hourStartMs / HOUR_MS$6) * HOUR_MS$6;
|
|
4129
4129
|
const fromIndex = this.deps.indexHour?.(deviceId, profile, hour);
|
|
4130
4130
|
if (fromIndex !== null && fromIndex !== void 0) return {
|
|
4131
4131
|
segments: fromIndex,
|
|
@@ -4133,7 +4133,7 @@ var CalendarIndex = class {
|
|
|
4133
4133
|
};
|
|
4134
4134
|
const key = `${deviceId}:${profile}:${hour}`;
|
|
4135
4135
|
const now = this.deps.now?.() ?? Date.now();
|
|
4136
|
-
const isCurrentHour = hour === Math.floor(now / HOUR_MS$
|
|
4136
|
+
const isCurrentHour = hour === Math.floor(now / HOUR_MS$6) * HOUR_MS$6;
|
|
4137
4137
|
const cached = this.hourSegs.get(key);
|
|
4138
4138
|
if (cached !== void 0) {
|
|
4139
4139
|
if (!isCurrentHour) return {
|
|
@@ -4197,7 +4197,7 @@ var CalendarIndex = class {
|
|
|
4197
4197
|
*/
|
|
4198
4198
|
async dayRanges(deviceId, profile, utcDayMs) {
|
|
4199
4199
|
const day = utcDayOf(utcDayMs);
|
|
4200
|
-
return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$
|
|
4200
|
+
return mergeRanges((await mapBounded(Array.from({ length: 24 }, (_, h) => day + h * HOUR_MS$6), READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour))).flat(), RANGE_MERGE_GAP_MS$1);
|
|
4201
4201
|
}
|
|
4202
4202
|
/**
|
|
4203
4203
|
* Exact ranges of `[fromMs, toMs)`, scanning ONLY the hours the window
|
|
@@ -4211,9 +4211,9 @@ var CalendarIndex = class {
|
|
|
4211
4211
|
*/
|
|
4212
4212
|
async rangesIn(deviceId, profile, fromMs, toMs) {
|
|
4213
4213
|
if (toMs <= fromMs) return [];
|
|
4214
|
-
const first = Math.floor(fromMs / HOUR_MS$
|
|
4214
|
+
const first = Math.floor(fromMs / HOUR_MS$6) * HOUR_MS$6;
|
|
4215
4215
|
const hours = [];
|
|
4216
|
-
for (let hour = first; hour < toMs; hour += HOUR_MS$
|
|
4216
|
+
for (let hour = first; hour < toMs; hour += HOUR_MS$6) hours.push(hour);
|
|
4217
4217
|
const perHour = await mapBounded(hours, READ_CONCURRENCY, (hour) => this.segmentsOf(deviceId, profile, hour));
|
|
4218
4218
|
const ranges = [];
|
|
4219
4219
|
for (const r of mergeRanges(perHour.flat(), RANGE_MERGE_GAP_MS$1)) {
|
|
@@ -4765,7 +4765,7 @@ function handleStoreCensusAction(census, logger, input) {
|
|
|
4765
4765
|
}
|
|
4766
4766
|
//#endregion
|
|
4767
4767
|
//#region src/recorder/addon/directory-handler.ts
|
|
4768
|
-
var HOUR_MS$
|
|
4768
|
+
var HOUR_MS$5 = 36e5;
|
|
4769
4769
|
/** A window wider than this is refused: it is a client bug, and answering it
|
|
4770
4770
|
* would walk the archive on the read path — the thing this replaces. */
|
|
4771
4771
|
var MAX_WINDOW_MS = 32 * 864e5;
|
|
@@ -4812,9 +4812,9 @@ function withDirectoryRoute(deps, next) {
|
|
|
4812
4812
|
return;
|
|
4813
4813
|
}
|
|
4814
4814
|
const rows = [];
|
|
4815
|
-
const firstHour = Math.floor(fromMs / HOUR_MS$
|
|
4815
|
+
const firstHour = Math.floor(fromMs / HOUR_MS$5) * HOUR_MS$5;
|
|
4816
4816
|
let hydrated = true;
|
|
4817
|
-
for (let hour = firstHour; hour < toMs; hour += HOUR_MS$
|
|
4817
|
+
for (let hour = firstHour; hour < toMs; hour += HOUR_MS$5) {
|
|
4818
4818
|
const { segments, complete } = await deps.source.segmentsOfChecked(deviceId, profile, hour);
|
|
4819
4819
|
if (!complete) hydrated = false;
|
|
4820
4820
|
for (const s of segments) {
|
|
@@ -8565,7 +8565,7 @@ var DurableLedger = class DurableLedger {
|
|
|
8565
8565
|
};
|
|
8566
8566
|
//#endregion
|
|
8567
8567
|
//#region src/recorder/addon/segment-hour-ledger.ts
|
|
8568
|
-
var HOUR_MS$
|
|
8568
|
+
var HOUR_MS$4 = 36e5;
|
|
8569
8569
|
/** `Math.min` where a missing left operand means "nothing yet", not `NaN`. */
|
|
8570
8570
|
function minOf(a, b) {
|
|
8571
8571
|
return a === null ? b : Math.min(a, b);
|
|
@@ -8704,6 +8704,27 @@ var RECORDING_SEGMENT_HOURS_INDEXES = [
|
|
|
8704
8704
|
]
|
|
8705
8705
|
})
|
|
8706
8706
|
];
|
|
8707
|
+
/**
|
|
8708
|
+
* How far into an hour a writer's first finalize may land and the writer still
|
|
8709
|
+
* be the party that KNOWS what the hour holds (D521).
|
|
8710
|
+
*
|
|
8711
|
+
* A seal is a claim of completeness. A writer that was running across the hour
|
|
8712
|
+
* boundary produces its first in-hour segment within ONE segment length of the
|
|
8713
|
+
* boundary — the segment that straddles it belongs to the previous hour by its
|
|
8714
|
+
* `startMs`. So the grace is one segment length at the longest anyone
|
|
8715
|
+
* configures here (60 s), plus 30 s for a keyframe-cut segment overrunning its
|
|
8716
|
+
* nominal `-segment_time` (ffmpeg cuts on the next GOP, never on the clock).
|
|
8717
|
+
*
|
|
8718
|
+
* It is a guess, and this is the shape of being wrong in each direction.
|
|
8719
|
+
* TIGHTER: a normal rollover stops sealing, every hour of the archive is walked
|
|
8720
|
+
* once by its first reader, and D451's "at rest nothing is unsealed" is undone
|
|
8721
|
+
* — at ~3.6 ms per hour directory cold, affordable but pointless. LOOSER: a
|
|
8722
|
+
* runner respawn inside the window seals an hour it entered late, which is
|
|
8723
|
+
* exactly the 2026-09-17 defect; the observed hole there was ~29 minutes, so
|
|
8724
|
+
* anything under a few minutes would have caught it and 90 s is the smallest
|
|
8725
|
+
* number that does not fire on a legitimate rollover.
|
|
8726
|
+
*/
|
|
8727
|
+
var WRITER_HOUR_ENTRY_GRACE_MS = 9e4;
|
|
8707
8728
|
/** The identity of the {@link hourAggregate} fold — an hour holding nothing. */
|
|
8708
8729
|
var EMPTY_HOUR_AGGREGATE = {
|
|
8709
8730
|
bytes: 0,
|
|
@@ -8924,7 +8945,7 @@ var EMPTY_ACCOUNTING = {
|
|
|
8924
8945
|
newestMs: null
|
|
8925
8946
|
};
|
|
8926
8947
|
function hourStartMs(startMs) {
|
|
8927
|
-
return Math.floor(startMs / HOUR_MS$
|
|
8948
|
+
return Math.floor(startMs / HOUR_MS$4) * HOUR_MS$4;
|
|
8928
8949
|
}
|
|
8929
8950
|
function segmentHourKey(deviceId, profile, locationId, hour) {
|
|
8930
8951
|
return `${deviceId}|${profile}|${locationId}|${hour}`;
|
|
@@ -8941,6 +8962,11 @@ function hourOf(row) {
|
|
|
8941
8962
|
function writerKey(deviceId, profile) {
|
|
8942
8963
|
return `${deviceId}|${profile}`;
|
|
8943
8964
|
}
|
|
8965
|
+
/** One writer's hour, WITHOUT the location — the identity a loss is reported
|
|
8966
|
+
* against by a party that only knows the camera, the profile and the clock. */
|
|
8967
|
+
function writerHourKey(deviceId, profile, hour) {
|
|
8968
|
+
return `${deviceId}|${profile}|${hour}`;
|
|
8969
|
+
}
|
|
8944
8970
|
function detailOf(row) {
|
|
8945
8971
|
return {
|
|
8946
8972
|
locationId: row.locationId,
|
|
@@ -9087,6 +9113,13 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
9087
9113
|
writerHours = /* @__PURE__ */ new Map();
|
|
9088
9114
|
/** Hour keys a write was REFUSED for in this process — never sealed by it. */
|
|
9089
9115
|
refused = /* @__PURE__ */ new Set();
|
|
9116
|
+
/**
|
|
9117
|
+
* `${deviceId}|${profile}|${hourStartMs}` a LOSS was reported for, location
|
|
9118
|
+
* left out on purpose: the party that sees the loss (the playlist watcher)
|
|
9119
|
+
* knows the camera and the profile and has no business knowing which disk
|
|
9120
|
+
* the recorder happens to be placing that hour on.
|
|
9121
|
+
*/
|
|
9122
|
+
refusedWriterHours = /* @__PURE__ */ new Set();
|
|
9090
9123
|
constructor(deps) {
|
|
9091
9124
|
this.logger = deps.logger;
|
|
9092
9125
|
this.store = deps.store;
|
|
@@ -9297,6 +9330,10 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
9297
9330
|
*
|
|
9298
9331
|
* `null` when the row is known and its detail could not be read: nothing
|
|
9299
9332
|
* is written from a row whose paths are not resident.
|
|
9333
|
+
*
|
|
9334
|
+
* @seal-authority closed-hour-listing — a directory read of an hour that is
|
|
9335
|
+
* OVER is the one complete answer there is; the caller guarantees the hour is
|
|
9336
|
+
* closed (D451, D521).
|
|
9300
9337
|
*/
|
|
9301
9338
|
async adoptWalkedHour(deviceId, profile, locationId, hour, paths) {
|
|
9302
9339
|
const key = segmentHourKey(deviceId, profile, locationId, hour);
|
|
@@ -9381,11 +9418,65 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
9381
9418
|
markRefused(row) {
|
|
9382
9419
|
this.refused.add(hourOf(row).key);
|
|
9383
9420
|
}
|
|
9421
|
+
/**
|
|
9422
|
+
* Segments a writer produced that never reached {@link recordSegment} — the
|
|
9423
|
+
* watcher's `rolledOff`, the one moment the system KNOWS work was skipped.
|
|
9424
|
+
*
|
|
9425
|
+
* Every hour the loss spans is refused the seal, whatever else this process
|
|
9426
|
+
* believes about them: the ownership grace answers "was I here", and this
|
|
9427
|
+
* answers "did I get everything while I was". Camera 615's respawned writer
|
|
9428
|
+
* passed the first question for the hour it entered at the top and still held
|
|
9429
|
+
* a row short by 175 segments.
|
|
9430
|
+
*
|
|
9431
|
+
* Returns how many hours were marked, so the caller's line reports what its
|
|
9432
|
+
* report bought rather than that it was made.
|
|
9433
|
+
*/
|
|
9434
|
+
markHoursRefused(span) {
|
|
9435
|
+
if (!Number.isFinite(span.fromMs) || !Number.isFinite(span.toMs)) return 0;
|
|
9436
|
+
const first = hourStartMs(Math.min(span.fromMs, span.toMs));
|
|
9437
|
+
const last = hourStartMs(Math.max(span.fromMs, span.toMs));
|
|
9438
|
+
let marked = 0;
|
|
9439
|
+
for (let hour = first; hour <= last && marked < 24; hour += HOUR_MS$4) {
|
|
9440
|
+
this.refusedWriterHours.add(writerHourKey(span.deviceId, span.profile, hour));
|
|
9441
|
+
marked += 1;
|
|
9442
|
+
}
|
|
9443
|
+
if (marked === 0) return 0;
|
|
9444
|
+
this.logger.warn("recorder: segments this writer produced never reached the hour ledger — these hours stay UNSEALED and a reader walks them", {
|
|
9445
|
+
tags: { deviceId: span.deviceId },
|
|
9446
|
+
meta: {
|
|
9447
|
+
profile: span.profile,
|
|
9448
|
+
fromMs: first,
|
|
9449
|
+
toMs: last,
|
|
9450
|
+
hours: marked,
|
|
9451
|
+
segments: span.count,
|
|
9452
|
+
boundHours: 24
|
|
9453
|
+
}
|
|
9454
|
+
});
|
|
9455
|
+
return marked;
|
|
9456
|
+
}
|
|
9384
9457
|
/** One write: the row as held, flagged sealed. Serialised on its key so it
|
|
9385
|
-
* cannot land over an append that read the row before it.
|
|
9458
|
+
* cannot land over an append that read the row before it.
|
|
9459
|
+
*
|
|
9460
|
+
* @seal-authority writer-owned-hour — the writer that LEAVES the hour, and
|
|
9461
|
+
* only when it entered within {@link WRITER_HOUR_ENTRY_GRACE_MS} of the
|
|
9462
|
+
* hour's start and no loss was reported against it (D521). */
|
|
9386
9463
|
async sealHour(target) {
|
|
9387
9464
|
await this.serialized(target.key, async () => {
|
|
9388
|
-
|
|
9465
|
+
const enteredAfterMs = target.entryMs - target.hourStartMs;
|
|
9466
|
+
if (enteredAfterMs > 9e4) {
|
|
9467
|
+
this.logger.warn("recorder: hour left unsealed — this writer entered it late, so it cannot claim the hour is complete", {
|
|
9468
|
+
tags: { deviceId: target.deviceId },
|
|
9469
|
+
meta: {
|
|
9470
|
+
profile: target.profile,
|
|
9471
|
+
locationId: target.locationId,
|
|
9472
|
+
hourStartMs: target.hourStartMs,
|
|
9473
|
+
enteredAfterMs,
|
|
9474
|
+
graceMs: WRITER_HOUR_ENTRY_GRACE_MS
|
|
9475
|
+
}
|
|
9476
|
+
});
|
|
9477
|
+
return;
|
|
9478
|
+
}
|
|
9479
|
+
if (this.refused.has(target.key) || this.refusedWriterHours.has(writerHourKey(target.deviceId, target.profile, target.hourStartMs))) {
|
|
9389
9480
|
this.logger.warn("recorder: hour left unsealed — a ledger write for it was refused, so its row is short and a reader must walk it", {
|
|
9390
9481
|
tags: { deviceId: target.deviceId },
|
|
9391
9482
|
meta: {
|
|
@@ -9405,9 +9496,15 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
9405
9496
|
});
|
|
9406
9497
|
}
|
|
9407
9498
|
/**
|
|
9408
|
-
* Remember which hour this writer is on
|
|
9409
|
-
*
|
|
9410
|
-
*
|
|
9499
|
+
* Remember which hour this writer is on — and how far into it this process
|
|
9500
|
+
* ARRIVED ({@link WriterHour.entryMs}, the first finalize's own start) —
|
|
9501
|
+
* and when it MOVES FORWARD, seal the hour it left. Backwards is a late
|
|
9502
|
+
* finalize (a recovered orphan, a replay) into an hour already accounted for,
|
|
9503
|
+
* and seals nothing.
|
|
9504
|
+
*
|
|
9505
|
+
* The entry stamp is kept from the FIRST finalize into the hour and never
|
|
9506
|
+
* refreshed: the question the seal has to answer is "was this writer here
|
|
9507
|
+
* when the hour began", and the last segment of the hour cannot answer it.
|
|
9411
9508
|
*/
|
|
9412
9509
|
noteWriter(next) {
|
|
9413
9510
|
const id = writerKey(next.deviceId, next.profile);
|
|
@@ -9524,7 +9621,7 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
9524
9621
|
/** The hour a write path may touch: current + previous. Materialised at boot
|
|
9525
9622
|
* so {@link recordSegment}'s dedup gate answers from RAM (D49). */
|
|
9526
9623
|
async materializeLiveTail() {
|
|
9527
|
-
const oldest = hourStartMs(this.now()) - (LIVE_TAIL_HOURS - 1) * HOUR_MS$
|
|
9624
|
+
const oldest = hourStartMs(this.now()) - (LIVE_TAIL_HOURS - 1) * HOUR_MS$4;
|
|
9528
9625
|
const keys = [];
|
|
9529
9626
|
for (const row of this.ledger.snapshot()) if (row.hourStartMs >= oldest && !row.pathsResident) keys.push(row.key);
|
|
9530
9627
|
return (await this.materialize(keys)).length;
|
|
@@ -10032,7 +10129,8 @@ var SegmentHourLedger = class SegmentHourLedger {
|
|
|
10032
10129
|
deviceId: row.deviceId,
|
|
10033
10130
|
profile: row.profile,
|
|
10034
10131
|
locationId: row.locationId,
|
|
10035
|
-
hourStartMs: hour
|
|
10132
|
+
hourStartMs: hour,
|
|
10133
|
+
entryMs: row.startMs
|
|
10036
10134
|
});
|
|
10037
10135
|
await this.serialized(key, async () => {
|
|
10038
10136
|
if (!this.ledger.has(key)) {
|
|
@@ -10418,7 +10516,7 @@ function locationKey(deviceId, locationId) {
|
|
|
10418
10516
|
* Work it does not finish is left for the next pass and REPORTED, never
|
|
10419
10517
|
* silently dropped.
|
|
10420
10518
|
*/
|
|
10421
|
-
var HOUR_MS$
|
|
10519
|
+
var HOUR_MS$3 = 36e5;
|
|
10422
10520
|
/**
|
|
10423
10521
|
* Group resolved locations by PHYSICAL root, so two aliases of one disk are
|
|
10424
10522
|
* probed once and a `true` from each is not mistaken for the file existing in
|
|
@@ -10527,7 +10625,7 @@ var GhostLocationSweeper = class {
|
|
|
10527
10625
|
refused: null
|
|
10528
10626
|
};
|
|
10529
10627
|
}
|
|
10530
|
-
const liveTailFrom = this.deps.now() - HOUR_MS$
|
|
10628
|
+
const liveTailFrom = this.deps.now() - HOUR_MS$3;
|
|
10531
10629
|
let examined = 0;
|
|
10532
10630
|
let probes = 0;
|
|
10533
10631
|
let repointedHours = 0;
|
|
@@ -11109,7 +11207,7 @@ function createRecordingConfigMirror(deps) {
|
|
|
11109
11207
|
}
|
|
11110
11208
|
//#endregion
|
|
11111
11209
|
//#region src/recorder/addon/ledger-walk-reconcile.ts
|
|
11112
|
-
var HOUR_MS$
|
|
11210
|
+
var HOUR_MS$2 = 36e5;
|
|
11113
11211
|
/** Every reason at zero, so a reason that never fired reports as zero rather
|
|
11114
11212
|
* than absent and the summary shape is constant between passes. */
|
|
11115
11213
|
function zeroReasons() {
|
|
@@ -11205,7 +11303,7 @@ var LedgerWalkReconciler = class {
|
|
|
11205
11303
|
ledger,
|
|
11206
11304
|
apply,
|
|
11207
11305
|
anchor: anchor.volumeId,
|
|
11208
|
-
liveTailFrom: hourStartMs(this.deps.now()) - 1 * HOUR_MS$
|
|
11306
|
+
liveTailFrom: hourStartMs(this.deps.now()) - 1 * HOUR_MS$2,
|
|
11209
11307
|
maxHours: this.deps.maxHours ?? 4096,
|
|
11210
11308
|
byDevice: /* @__PURE__ */ new Map(),
|
|
11211
11309
|
skippedByReason: zeroReasons(),
|
|
@@ -11838,6 +11936,240 @@ function pendingMovesSignature(moves) {
|
|
|
11838
11936
|
return moves.map((m) => `${String(m.deviceId)}:${m.profile}>${m.plannedFor}`).join(",");
|
|
11839
11937
|
}
|
|
11840
11938
|
//#endregion
|
|
11939
|
+
//#region src/recorder/addon/hour-adoption-sweep.ts
|
|
11940
|
+
var HOUR_MS$1 = 36e5;
|
|
11941
|
+
/** The profile trees a camera directory is allowed to have. Everything else
|
|
11942
|
+
* under it (`events/`, the retired `strips/`) has no hour tree worth probing. */
|
|
11943
|
+
var PROFILES = [
|
|
11944
|
+
"high",
|
|
11945
|
+
"mid",
|
|
11946
|
+
"low"
|
|
11947
|
+
];
|
|
11948
|
+
function isTwoDigit(name) {
|
|
11949
|
+
return /^\d{2}$/.test(name);
|
|
11950
|
+
}
|
|
11951
|
+
/** One directory level in a stable order. Lexicographic, not numeric: it is the
|
|
11952
|
+
* CURSOR's order, and zero-padded path components (`09`, `17`) already sort
|
|
11953
|
+
* chronologically — device ids do not, and do not need to. */
|
|
11954
|
+
function inCursorOrder(names) {
|
|
11955
|
+
return [...names].toSorted();
|
|
11956
|
+
}
|
|
11957
|
+
/**
|
|
11958
|
+
* One add-only pass over one location's hour directories.
|
|
11959
|
+
*
|
|
11960
|
+
* Never throws: a directory that cannot be read is counted and the pass
|
|
11961
|
+
* continues; a precondition failure is a named refusal and the pass does
|
|
11962
|
+
* nothing at all.
|
|
11963
|
+
*/
|
|
11964
|
+
var HourAdoptionSweeper = class {
|
|
11965
|
+
deps;
|
|
11966
|
+
/** Where the last bounded pass stopped (`rel` of the last hour it walked).
|
|
11967
|
+
* In RAM: a restart re-walks from the top, which costs listings and can
|
|
11968
|
+
* never cost footage. */
|
|
11969
|
+
cursor = null;
|
|
11970
|
+
constructor(deps) {
|
|
11971
|
+
this.deps = deps;
|
|
11972
|
+
}
|
|
11973
|
+
async sweep(request) {
|
|
11974
|
+
const location = this.deps.locations().find((candidate) => candidate.id === request.locationId);
|
|
11975
|
+
if (location === void 0) return this.refusedReport(request.locationId, "location-unknown");
|
|
11976
|
+
if (!locationMayBeRead(location)) return this.refusedReport(location.id, "location-unreadable");
|
|
11977
|
+
const ledger = this.deps.ledger();
|
|
11978
|
+
if (ledger === null) {
|
|
11979
|
+
this.deps.logger.warn("recorder: hour adoption REFUSED — this node holds no durable hour ledger, so there is nothing to adopt into", { meta: { locationId: location.id } });
|
|
11980
|
+
return this.refusedReport(location.id, "no-ledger");
|
|
11981
|
+
}
|
|
11982
|
+
const liveTailFrom = hourStartMs(this.deps.now()) - 1 * HOUR_MS$1;
|
|
11983
|
+
const maxHours = this.deps.maxHours ?? 512;
|
|
11984
|
+
const resume = this.cursor;
|
|
11985
|
+
const byDevice = /* @__PURE__ */ new Map();
|
|
11986
|
+
let listings = 0;
|
|
11987
|
+
let hoursAdopted = 0;
|
|
11988
|
+
let hoursRefusedByLedger = 0;
|
|
11989
|
+
let hoursSkippedLive = 0;
|
|
11990
|
+
let hoursSkippedEmpty = 0;
|
|
11991
|
+
let listingErrors = 0;
|
|
11992
|
+
let segmentsAdded = 0;
|
|
11993
|
+
let hoursCreated = 0;
|
|
11994
|
+
let bounded = false;
|
|
11995
|
+
let last = null;
|
|
11996
|
+
for await (const candidate of this.candidates(location, () => {
|
|
11997
|
+
listingErrors += 1;
|
|
11998
|
+
})) {
|
|
11999
|
+
if (resume !== null && candidate.rel <= resume) continue;
|
|
12000
|
+
if (candidate.hourStartMs >= liveTailFrom) {
|
|
12001
|
+
hoursSkippedLive += 1;
|
|
12002
|
+
continue;
|
|
12003
|
+
}
|
|
12004
|
+
if (listings >= maxHours) {
|
|
12005
|
+
bounded = true;
|
|
12006
|
+
break;
|
|
12007
|
+
}
|
|
12008
|
+
let names;
|
|
12009
|
+
try {
|
|
12010
|
+
names = await this.deps.listDir(`${location.root}/${candidate.rel}`);
|
|
12011
|
+
} catch (err) {
|
|
12012
|
+
listingErrors += 1;
|
|
12013
|
+
this.deps.logger.debug("recorder: hour adoption could not list an hour directory", {
|
|
12014
|
+
tags: { deviceId: candidate.deviceId },
|
|
12015
|
+
meta: {
|
|
12016
|
+
locationId: location.id,
|
|
12017
|
+
rel: candidate.rel,
|
|
12018
|
+
error: errMsg(err)
|
|
12019
|
+
}
|
|
12020
|
+
});
|
|
12021
|
+
continue;
|
|
12022
|
+
}
|
|
12023
|
+
listings += 1;
|
|
12024
|
+
last = candidate.rel;
|
|
12025
|
+
if (listings % 8 === 0) await this.deps.pace();
|
|
12026
|
+
const paths = (names ?? []).filter((name) => name.endsWith(".m4s")).map((name) => `${candidate.rel}/${name}`);
|
|
12027
|
+
if (paths.length === 0) {
|
|
12028
|
+
hoursSkippedEmpty += 1;
|
|
12029
|
+
continue;
|
|
12030
|
+
}
|
|
12031
|
+
const outcome = await ledger.adoptWalkedHour(candidate.deviceId, candidate.profile, location.id, candidate.hourStartMs, paths);
|
|
12032
|
+
if (outcome === null) {
|
|
12033
|
+
hoursRefusedByLedger += 1;
|
|
12034
|
+
continue;
|
|
12035
|
+
}
|
|
12036
|
+
hoursAdopted += 1;
|
|
12037
|
+
segmentsAdded += outcome.added;
|
|
12038
|
+
if (outcome.created) hoursCreated += 1;
|
|
12039
|
+
const tally = byDevice.get(candidate.deviceId) ?? {
|
|
12040
|
+
hoursAdopted: 0,
|
|
12041
|
+
segmentsAdded: 0,
|
|
12042
|
+
hoursCreated: 0
|
|
12043
|
+
};
|
|
12044
|
+
tally.hoursAdopted += 1;
|
|
12045
|
+
tally.segmentsAdded += outcome.added;
|
|
12046
|
+
if (outcome.created) tally.hoursCreated += 1;
|
|
12047
|
+
byDevice.set(candidate.deviceId, tally);
|
|
12048
|
+
if (outcome.added > 0) this.deps.logger.warn("recorder: adopted segments the durable hour row did not name — this hour was under-reported to every reader", {
|
|
12049
|
+
tags: { deviceId: candidate.deviceId },
|
|
12050
|
+
meta: {
|
|
12051
|
+
locationId: location.id,
|
|
12052
|
+
profile: candidate.profile,
|
|
12053
|
+
hourStartMs: candidate.hourStartMs,
|
|
12054
|
+
added: outcome.added,
|
|
12055
|
+
created: outcome.created,
|
|
12056
|
+
onDisk: paths.length
|
|
12057
|
+
}
|
|
12058
|
+
});
|
|
12059
|
+
}
|
|
12060
|
+
this.cursor = bounded ? last ?? resume : null;
|
|
12061
|
+
const report = {
|
|
12062
|
+
locationId: location.id,
|
|
12063
|
+
refused: null,
|
|
12064
|
+
listings,
|
|
12065
|
+
hoursAdopted,
|
|
12066
|
+
hoursRefusedByLedger,
|
|
12067
|
+
hoursSkippedLive,
|
|
12068
|
+
hoursSkippedEmpty,
|
|
12069
|
+
listingErrors,
|
|
12070
|
+
segmentsAdded,
|
|
12071
|
+
hoursCreated,
|
|
12072
|
+
bounded,
|
|
12073
|
+
byDevice: [...byDevice.entries()].map(([deviceId, tally]) => ({
|
|
12074
|
+
deviceId,
|
|
12075
|
+
...tally
|
|
12076
|
+
})).toSorted((a, b) => a.deviceId - b.deviceId)
|
|
12077
|
+
};
|
|
12078
|
+
this.report(report);
|
|
12079
|
+
return report;
|
|
12080
|
+
}
|
|
12081
|
+
/**
|
|
12082
|
+
* Every hour directory under this location, in cursor order.
|
|
12083
|
+
*
|
|
12084
|
+
* Sorted at each level so the cursor is a total order over a tree two passes
|
|
12085
|
+
* see differently: a camera added between passes appears in its place rather
|
|
12086
|
+
* than at the end, and is picked up by the pass after the cursor resets.
|
|
12087
|
+
*/
|
|
12088
|
+
async *candidates(location, onError) {
|
|
12089
|
+
const devices = await this.list(location.root, onError);
|
|
12090
|
+
for (const device of inCursorOrder(devices.filter((name) => /^\d+$/.test(name)))) {
|
|
12091
|
+
const deviceId = Number(device);
|
|
12092
|
+
if (!Number.isSafeInteger(deviceId)) continue;
|
|
12093
|
+
const present = new Set(await this.list(`${location.root}/${device}`, onError));
|
|
12094
|
+
for (const profile of PROFILES) {
|
|
12095
|
+
if (!present.has(profile)) continue;
|
|
12096
|
+
const base = `${device}/${profile}`;
|
|
12097
|
+
for (const year of inCursorOrder((await this.list(`${location.root}/${base}`, onError)).filter((n) => /^\d{4}$/.test(n)))) for (const month of inCursorOrder((await this.list(`${location.root}/${base}/${year}`, onError)).filter(isTwoDigit))) for (const day of inCursorOrder((await this.list(`${location.root}/${base}/${year}/${month}`, onError)).filter(isTwoDigit))) {
|
|
12098
|
+
const dayDir = `${base}/${year}/${month}/${day}`;
|
|
12099
|
+
for (const hour of inCursorOrder((await this.list(`${location.root}/${dayDir}`, onError)).filter(isTwoDigit))) {
|
|
12100
|
+
const startMs = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour));
|
|
12101
|
+
if (!Number.isFinite(startMs)) continue;
|
|
12102
|
+
yield {
|
|
12103
|
+
deviceId,
|
|
12104
|
+
profile,
|
|
12105
|
+
hourStartMs: startMs,
|
|
12106
|
+
rel: `${dayDir}/${hour}`
|
|
12107
|
+
};
|
|
12108
|
+
}
|
|
12109
|
+
}
|
|
12110
|
+
}
|
|
12111
|
+
}
|
|
12112
|
+
}
|
|
12113
|
+
/** One directory's names; `[]` for a directory that is not there, and `[]`
|
|
12114
|
+
* plus a counted error for one that could not be read. */
|
|
12115
|
+
async list(absDir, onError) {
|
|
12116
|
+
try {
|
|
12117
|
+
return await this.deps.listDir(absDir) ?? [];
|
|
12118
|
+
} catch (err) {
|
|
12119
|
+
onError();
|
|
12120
|
+
this.deps.logger.debug("recorder: hour adoption could not list a directory", { meta: {
|
|
12121
|
+
dir: absDir,
|
|
12122
|
+
error: errMsg(err)
|
|
12123
|
+
} });
|
|
12124
|
+
return [];
|
|
12125
|
+
}
|
|
12126
|
+
}
|
|
12127
|
+
refusedReport(locationId, refused) {
|
|
12128
|
+
return {
|
|
12129
|
+
locationId,
|
|
12130
|
+
refused,
|
|
12131
|
+
listings: 0,
|
|
12132
|
+
hoursAdopted: 0,
|
|
12133
|
+
hoursRefusedByLedger: 0,
|
|
12134
|
+
hoursSkippedLive: 0,
|
|
12135
|
+
hoursSkippedEmpty: 0,
|
|
12136
|
+
listingErrors: 0,
|
|
12137
|
+
segmentsAdded: 0,
|
|
12138
|
+
hoursCreated: 0,
|
|
12139
|
+
bounded: false,
|
|
12140
|
+
byDevice: []
|
|
12141
|
+
};
|
|
12142
|
+
}
|
|
12143
|
+
/** One line per camera that gained something, then one for the pass — a pass
|
|
12144
|
+
* that found nothing must still be distinguishable from one that never ran. */
|
|
12145
|
+
report(report) {
|
|
12146
|
+
for (const device of report.byDevice) {
|
|
12147
|
+
if (device.segmentsAdded === 0 && device.hoursCreated === 0) continue;
|
|
12148
|
+
this.deps.logger.info("recorder: hour adoption — camera summary", {
|
|
12149
|
+
tags: { deviceId: device.deviceId },
|
|
12150
|
+
meta: {
|
|
12151
|
+
locationId: report.locationId,
|
|
12152
|
+
hoursAdopted: device.hoursAdopted,
|
|
12153
|
+
segmentsAdded: device.segmentsAdded,
|
|
12154
|
+
hoursCreated: device.hoursCreated
|
|
12155
|
+
}
|
|
12156
|
+
});
|
|
12157
|
+
}
|
|
12158
|
+
this.deps.logger.info("recorder: hour adoption pass complete", { meta: {
|
|
12159
|
+
locationId: report.locationId,
|
|
12160
|
+
listings: report.listings,
|
|
12161
|
+
hoursAdopted: report.hoursAdopted,
|
|
12162
|
+
hoursRefusedByLedger: report.hoursRefusedByLedger,
|
|
12163
|
+
hoursSkippedLive: report.hoursSkippedLive,
|
|
12164
|
+
hoursSkippedEmpty: report.hoursSkippedEmpty,
|
|
12165
|
+
listingErrors: report.listingErrors,
|
|
12166
|
+
segmentsAdded: report.segmentsAdded,
|
|
12167
|
+
hoursCreated: report.hoursCreated,
|
|
12168
|
+
bounded: report.bounded
|
|
12169
|
+
} });
|
|
12170
|
+
}
|
|
12171
|
+
};
|
|
12172
|
+
//#endregion
|
|
11841
12173
|
//#region src/recorder/addon/periodic-pass.ts
|
|
11842
12174
|
function startPeriodicPass(deps) {
|
|
11843
12175
|
const now = () => deps.now?.() ?? Date.now();
|
|
@@ -14197,11 +14529,16 @@ function planFinalizations(body, processed, forceFinal = false) {
|
|
|
14197
14529
|
durMs
|
|
14198
14530
|
});
|
|
14199
14531
|
}
|
|
14532
|
+
const front = entries.length > 0 ? parseEpochStartMs(entries[0].segPath) : null;
|
|
14533
|
+
const longestDurMs = entries.reduce((longest, entry) => Math.max(longest, Math.round(entry.durSeconds * 1e3)), 0);
|
|
14534
|
+
const spanKnown = rolledOff > 0 && front !== null && longestDurMs > 0;
|
|
14200
14535
|
return {
|
|
14201
14536
|
toHandle,
|
|
14202
14537
|
from: hw,
|
|
14203
14538
|
processed: Math.max(hw, finalizable),
|
|
14204
|
-
rolledOff
|
|
14539
|
+
rolledOff,
|
|
14540
|
+
rolledOffFromMs: spanKnown ? front - rolledOff * longestDurMs : null,
|
|
14541
|
+
rolledOffToMs: spanKnown ? front : null
|
|
14205
14542
|
};
|
|
14206
14543
|
}
|
|
14207
14544
|
/**
|
|
@@ -14229,11 +14566,24 @@ function startSegmentWatcher(deps) {
|
|
|
14229
14566
|
return;
|
|
14230
14567
|
}
|
|
14231
14568
|
const plan = planFinalizations(body, processed, forceFinal);
|
|
14232
|
-
if (plan.rolledOff > 0)
|
|
14233
|
-
|
|
14234
|
-
|
|
14235
|
-
|
|
14236
|
-
|
|
14569
|
+
if (plan.rolledOff > 0) {
|
|
14570
|
+
deps.logger.warn("segment playlist rolled past entries this watcher had not handled", {
|
|
14571
|
+
tags: { deviceId: deps.deviceId },
|
|
14572
|
+
meta: {
|
|
14573
|
+
rolledOff: plan.rolledOff,
|
|
14574
|
+
processed,
|
|
14575
|
+
from: plan.from,
|
|
14576
|
+
fromMs: plan.rolledOffFromMs,
|
|
14577
|
+
toMs: plan.rolledOffToMs
|
|
14578
|
+
}
|
|
14579
|
+
});
|
|
14580
|
+
deps.onSegmentsMissed?.({
|
|
14581
|
+
deviceId: deps.deviceId,
|
|
14582
|
+
count: plan.rolledOff,
|
|
14583
|
+
fromMs: plan.rolledOffFromMs,
|
|
14584
|
+
toMs: plan.rolledOffToMs
|
|
14585
|
+
});
|
|
14586
|
+
}
|
|
14237
14587
|
let index = plan.from;
|
|
14238
14588
|
for (const { entry, durMs } of plan.toHandle) {
|
|
14239
14589
|
if (await handleSegmentEntry(deps.outDir, entry, durMs, deps.onFinalized, deps.logger, deps.writerStartedMs) === "pending") {
|
|
@@ -14243,11 +14593,14 @@ function startSegmentWatcher(deps) {
|
|
|
14243
14593
|
waitedMs,
|
|
14244
14594
|
boundMs: 3e4
|
|
14245
14595
|
})) {
|
|
14246
|
-
deps.logger.warn("segment file never landed — skipping", {
|
|
14247
|
-
|
|
14248
|
-
|
|
14249
|
-
|
|
14250
|
-
|
|
14596
|
+
deps.logger.warn("segment file never landed — skipping", {
|
|
14597
|
+
tags: { deviceId: deps.deviceId },
|
|
14598
|
+
meta: {
|
|
14599
|
+
seg: entry.segPath,
|
|
14600
|
+
waitedMs,
|
|
14601
|
+
boundMs: PENDING_GRACE_MS
|
|
14602
|
+
}
|
|
14603
|
+
});
|
|
14251
14604
|
pendingIndex = -1;
|
|
14252
14605
|
pendingSinceMs = 0;
|
|
14253
14606
|
index++;
|
|
@@ -16085,8 +16438,12 @@ var RecordingController = class {
|
|
|
16085
16438
|
writer,
|
|
16086
16439
|
watcher: startSegmentWatcher({
|
|
16087
16440
|
outDir,
|
|
16441
|
+
deviceId,
|
|
16088
16442
|
writerStartedMs,
|
|
16089
16443
|
now: () => this.now(),
|
|
16444
|
+
onSegmentsMissed: (missed) => {
|
|
16445
|
+
this.deps.onSegmentsMissed?.(missed, profile);
|
|
16446
|
+
},
|
|
16090
16447
|
intervalMs: this.deps.watchIntervalMs > 0 ? this.deps.watchIntervalMs : DEFAULT_WATCH_INTERVAL_MS,
|
|
16091
16448
|
onFinalized: async (startMs, durMs, flatAbsPath) => {
|
|
16092
16449
|
this.lastSegmentAt.set(idleKey(deviceId, profile), this.now());
|
|
@@ -17428,6 +17785,20 @@ var GHOST_LOCATION_PROBE_PAUSE_MS = 50;
|
|
|
17428
17785
|
* hung an SSH session on 2026-08-29.
|
|
17429
17786
|
*/
|
|
17430
17787
|
var LEDGER_WALK_PAUSE_MS = 100;
|
|
17788
|
+
/**
|
|
17789
|
+
* How often the ADD-only hour adoption pass runs (D521).
|
|
17790
|
+
*
|
|
17791
|
+
* Daily, for the same reason the ghost sweep is daily: it spends `readdir`
|
|
17792
|
+
* syscalls on the array every live read competes for, and the damage it repairs
|
|
17793
|
+
* is made by a writer teardown — an event measured in days, not minutes. It
|
|
17794
|
+
* does NOT run at boot (`startPeriodicPass` fires on the interval only), which
|
|
17795
|
+
* is when the staging reconcile, the ledger load and the first retention sweep
|
|
17796
|
+
* are already contending for that array.
|
|
17797
|
+
*/
|
|
17798
|
+
var HOUR_ADOPTION_INTERVAL_MS = 1440 * 6e4;
|
|
17799
|
+
/** An adoption pass longer than this is reported with its duration. Bounded
|
|
17800
|
+
* work (512 hour listings, paced) — minutes is normal, an hour is not. */
|
|
17801
|
+
var HOUR_ADOPTION_SLOW_AFTER_MS = 10 * 6e4;
|
|
17431
17802
|
/** What the per-volume usage row reports when the hour ledger is unavailable:
|
|
17432
17803
|
* nothing measured, rather than a total counted off a partial RAM index. */
|
|
17433
17804
|
var EMPTY_ARCHIVE_ACCOUNTING = {
|
|
@@ -17652,6 +18023,7 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
17652
18023
|
/** Daily ghost-location reconcile: durable hour rows stamped with a location
|
|
17653
18024
|
* the storage record no longer has. Armed in `onHubReachable`. */
|
|
17654
18025
|
ghostLocationPass = null;
|
|
18026
|
+
hourAdoptionPass = null;
|
|
17655
18027
|
/** Reversible migration pause lease; never reflected in RecordingConfig. */
|
|
17656
18028
|
storageMigrationLeaseId = null;
|
|
17657
18029
|
/**
|
|
@@ -17893,6 +18265,27 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
17893
18265
|
onWriterClosed: async (deviceId, profile) => {
|
|
17894
18266
|
await this.segmentHours?.sealWriter(deviceId, profile);
|
|
17895
18267
|
},
|
|
18268
|
+
onSegmentsMissed: (missed, profile) => {
|
|
18269
|
+
const ledger = this.segmentHours;
|
|
18270
|
+
if (ledger === null) return;
|
|
18271
|
+
if (missed.fromMs === null || missed.toMs === null) {
|
|
18272
|
+
this.ctx.logger.warn("recorder: segments were missed but the playlist named no span — NO hour could be held unsealed for them", {
|
|
18273
|
+
tags: { deviceId: missed.deviceId },
|
|
18274
|
+
meta: {
|
|
18275
|
+
profile,
|
|
18276
|
+
segments: missed.count
|
|
18277
|
+
}
|
|
18278
|
+
});
|
|
18279
|
+
return;
|
|
18280
|
+
}
|
|
18281
|
+
ledger.markHoursRefused({
|
|
18282
|
+
deviceId: missed.deviceId,
|
|
18283
|
+
profile,
|
|
18284
|
+
fromMs: missed.fromMs,
|
|
18285
|
+
toMs: missed.toMs,
|
|
18286
|
+
count: missed.count
|
|
18287
|
+
});
|
|
18288
|
+
},
|
|
17896
18289
|
spawn,
|
|
17897
18290
|
costRegistry: this.costRegistry,
|
|
17898
18291
|
segmentSeconds: this.config.segmentSeconds,
|
|
@@ -18258,6 +18651,8 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
18258
18651
|
this.redundancySweeper = null;
|
|
18259
18652
|
this.ghostLocationPass?.stop();
|
|
18260
18653
|
this.ghostLocationPass = null;
|
|
18654
|
+
this.hourAdoptionPass?.stop();
|
|
18655
|
+
this.hourAdoptionPass = null;
|
|
18261
18656
|
this.exportEngine = null;
|
|
18262
18657
|
this.recordingProvider = null;
|
|
18263
18658
|
}
|
|
@@ -18509,6 +18904,32 @@ var RecorderV2Addon = class extends BaseAddon {
|
|
|
18509
18904
|
logger: this.ctx.logger
|
|
18510
18905
|
});
|
|
18511
18906
|
}
|
|
18907
|
+
if (this.hourAdoptionPass === null) {
|
|
18908
|
+
const adopter = new HourAdoptionSweeper({
|
|
18909
|
+
ledger: () => this.segmentHours,
|
|
18910
|
+
locations: () => this.resolvedLocations,
|
|
18911
|
+
listDir: async (absDir) => {
|
|
18912
|
+
try {
|
|
18913
|
+
return await promises.readdir(absDir);
|
|
18914
|
+
} catch (err) {
|
|
18915
|
+
if (err.code === "ENOENT") return null;
|
|
18916
|
+
throw err;
|
|
18917
|
+
}
|
|
18918
|
+
},
|
|
18919
|
+
logger: this.ctx.logger,
|
|
18920
|
+
now: () => Date.now(),
|
|
18921
|
+
pace: () => sleep$1(LEDGER_WALK_PAUSE_MS)
|
|
18922
|
+
});
|
|
18923
|
+
this.hourAdoptionPass = startPeriodicPass({
|
|
18924
|
+
name: "hour-adoption",
|
|
18925
|
+
intervalMs: HOUR_ADOPTION_INTERVAL_MS,
|
|
18926
|
+
slowAfterMs: HOUR_ADOPTION_SLOW_AFTER_MS,
|
|
18927
|
+
run: async () => {
|
|
18928
|
+
for (const location of this.resolvedLocations) await adopter.sweep({ locationId: location.id });
|
|
18929
|
+
},
|
|
18930
|
+
logger: this.ctx.logger
|
|
18931
|
+
});
|
|
18932
|
+
}
|
|
18512
18933
|
}
|
|
18513
18934
|
/**
|
|
18514
18935
|
* Print one window of detection-trigger health.
|