@camstack/addon-pipeline 1.2.75 → 1.2.77
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/{addon-utils-a_36HH_N.js → addon-utils-bZcwLaVW.js} +1 -1
- package/dist/audio-analyzer/index.js +2 -2
- package/dist/audio-analyzer/index.mjs +1 -1
- package/dist/detection-pipeline/index.js +4 -4
- package/dist/detection-pipeline/index.mjs +2 -2
- package/dist/{dist-6ZJCV0jY.js → dist-DQZzL0Ri.js} +452 -42
- package/dist/{dist-T_8jMw7d.mjs → dist-HhcoV-Ky.mjs} +452 -42
- package/dist/{event-loop-stall-monitor-Cjof8vA1.js → event-loop-stall-monitor-CBOo1oh_.js} +1 -1
- package/dist/{event-loop-stall-monitor-XeGmL4xr.mjs → event-loop-stall-monitor-CqBEWGSZ.mjs} +1 -1
- package/dist/{lazy-sharp-DvwPafeP.js → lazy-sharp-DIC1rpea.js} +1 -1
- package/dist/motion-wasm/index.js +2 -2
- package/dist/motion-wasm/index.mjs +1 -1
- package/dist/pipeline-runner/index.js +278 -15
- package/dist/pipeline-runner/index.mjs +277 -14
- package/dist/recorder/index.js +368 -18
- package/dist/recorder/index.mjs +367 -17
- package/dist/session-decode/decode-worker-child.js +2 -2
- package/dist/session-decode/decode-worker-child.mjs +1 -1
- package/dist/stream-broker/_stub.js +2 -2
- package/dist/stream-broker/{_virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-H2OTmgR6.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_stream_broker_widgets-De_xzvjU.mjs} +3 -3
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.js-DHJSxHH1.mjs +26 -0
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-Dg08SxUW.mjs +26 -0
- package/dist/stream-broker/{hostInit-4XDN_Nt0.mjs → hostInit-CDS5frmk.mjs} +3 -3
- package/dist/stream-broker/index.js +306 -71
- package/dist/stream-broker/index.mjs +306 -71
- package/dist/stream-broker/remoteEntry.js +1 -1
- package/dist/{worker-protocol-SqED7Y9i.mjs → worker-protocol-BNlLGLBV.mjs} +1 -1
- package/dist/{worker-protocol-B0cbTXyw.js → worker-protocol-DZVGVAPU.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_types__loadShare__.js-RYTOS6B-.mjs +0 -26
- package/dist/stream-broker/_virtual_mf___mfe_internal__addon_stream_broker_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DfsLsn2e.mjs +0 -26
package/dist/recorder/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
const require_dist = require("../dist-
|
|
1
|
+
const require_dist = require("../dist-DQZzL0Ri.js");
|
|
2
2
|
const require_hub_hostname = require("../hub-hostname-DAJXlOgV.js");
|
|
3
|
-
const require_addon_utils = require("../addon-utils-
|
|
3
|
+
const require_addon_utils = require("../addon-utils-bZcwLaVW.js");
|
|
4
4
|
let node_crypto = require("node:crypto");
|
|
5
5
|
let node_child_process = require("node:child_process");
|
|
6
6
|
let node_path = require("node:path");
|
|
@@ -1710,7 +1710,7 @@ async function availabilityProfile(root, deviceId) {
|
|
|
1710
1710
|
* NOT profiles (`events/`, plus the `strips/` trees the retired filmstrip tier
|
|
1711
1711
|
* left on disk); a walker that trusts readdir would offer them as playable
|
|
1712
1712
|
* profiles. */
|
|
1713
|
-
var KNOWN_PROFILES = new Set([
|
|
1713
|
+
var KNOWN_PROFILES$1 = new Set([
|
|
1714
1714
|
"high",
|
|
1715
1715
|
"mid",
|
|
1716
1716
|
"low"
|
|
@@ -1718,7 +1718,7 @@ var KNOWN_PROFILES = new Set([
|
|
|
1718
1718
|
/** `<root>/<deviceId>/` PROFILE subdirectories. Empty when absent. */
|
|
1719
1719
|
async function profilesOf(root, deviceId) {
|
|
1720
1720
|
try {
|
|
1721
|
-
return (await node_fs.promises.readdir(node_path.default.join(root, String(deviceId)))).filter((e) => KNOWN_PROFILES.has(e));
|
|
1721
|
+
return (await node_fs.promises.readdir(node_path.default.join(root, String(deviceId)))).filter((e) => KNOWN_PROFILES$1.has(e));
|
|
1722
1722
|
} catch {
|
|
1723
1723
|
return [];
|
|
1724
1724
|
}
|
|
@@ -5665,26 +5665,29 @@ var PENDING_GRACE_MS = 3e4;
|
|
|
5665
5665
|
* segment of every recording would be classified as a previous run's leftover
|
|
5666
5666
|
* and dropped.
|
|
5667
5667
|
*/
|
|
5668
|
-
var PREVIOUS_RUN_SKEW_MS = 1e3;
|
|
5668
|
+
var PREVIOUS_RUN_SKEW_MS$1 = 1e3;
|
|
5669
5669
|
/**
|
|
5670
5670
|
* Decide whether a missing flat file is a late flush or a previous run's entry.
|
|
5671
5671
|
*
|
|
5672
|
-
* ffmpeg
|
|
5673
|
-
* starts at `processed = 0
|
|
5674
|
-
*
|
|
5675
|
-
*
|
|
5676
|
-
* 83% of all retries (median
|
|
5677
|
-
*
|
|
5678
|
-
* that was correctly filed hours
|
|
5672
|
+
* ffmpeg opens `-segment_list` in TRUNCATE mode when it spawns, but not
|
|
5673
|
+
* instantly: a fresh watcher starts at `processed = 0` and, in the window
|
|
5674
|
+
* between spawn and that truncation, reads the PREVIOUS run's playlist body.
|
|
5675
|
+
* Those entries were relocated into the bucket tree long ago; their absence is
|
|
5676
|
+
* the system working. Over 2026-08-10..13 they were 83% of all retries (median
|
|
5677
|
+
* age 1 615 s, max 17.4 h) and produced 68 of the 122 `never landed` warnings —
|
|
5678
|
+
* a warning that reads like data loss for a file that was correctly filed hours
|
|
5679
|
+
* earlier.
|
|
5679
5680
|
*
|
|
5680
|
-
*
|
|
5681
|
-
*
|
|
5682
|
-
*
|
|
5683
|
-
*
|
|
5684
|
-
*
|
|
5681
|
+
* What this is NOT is crash recovery, and an earlier version of this comment
|
|
5682
|
+
* claimed it was. Once ffmpeg truncates, the previous run's un-relocated
|
|
5683
|
+
* segments are named by NOTHING, so no amount of care in this function can
|
|
5684
|
+
* reach them — on 2026-08-14 that had stranded 179 GB across the fleet. They
|
|
5685
|
+
* are recovered from the DIRECTORY instead, by `staging-reconcile.ts`, which
|
|
5686
|
+
* the addon runs once at boot. This function only has to be right about the
|
|
5687
|
+
* entries a playlist does name.
|
|
5685
5688
|
*/
|
|
5686
5689
|
function classifyMissingEntry(input) {
|
|
5687
|
-
return input.segmentStartMs < input.writerStartedMs - PREVIOUS_RUN_SKEW_MS ? "stale-previous-run" : "pending";
|
|
5690
|
+
return input.segmentStartMs < input.writerStartedMs - PREVIOUS_RUN_SKEW_MS$1 ? "stale-previous-run" : "pending";
|
|
5688
5691
|
}
|
|
5689
5692
|
/** Whether a pending entry has outlived its grace. Pure — the caller clocks it. */
|
|
5690
5693
|
function shouldGiveUpOnPending(input) {
|
|
@@ -6979,6 +6982,312 @@ var RetentionSweeper = class {
|
|
|
6979
6982
|
}
|
|
6980
6983
|
};
|
|
6981
6984
|
//#endregion
|
|
6985
|
+
//#region src/recorder/addon/staging-reconcile.ts
|
|
6986
|
+
/**
|
|
6987
|
+
* Recover `.rec-tmp` segments that a writer restart orphaned.
|
|
6988
|
+
*
|
|
6989
|
+
* WHY THIS EXISTS
|
|
6990
|
+
* ───────────────
|
|
6991
|
+
* The segment watcher's ONLY record of what it still owes is ffmpeg's
|
|
6992
|
+
* `-segment_list live.m3u8`. ffmpeg opens that file in TRUNCATE mode on every
|
|
6993
|
+
* spawn, so a writer respawn destroys it. Every segment written but not yet
|
|
6994
|
+
* relocated at that instant becomes permanently unreachable:
|
|
6995
|
+
*
|
|
6996
|
+
* - no playlist names it, so no watcher will ever finalize it;
|
|
6997
|
+
* - `parseSegmentPath` cannot see it — staging is flat and dot-prefixed, kept
|
|
6998
|
+
* deliberately out of the hydrate walk — so retention never counts it;
|
|
6999
|
+
* - the one `.rec-tmp` sweep that exists (`sweepUnassignedStaging`) touches
|
|
7000
|
+
* only locations with ZERO placement assignments, never the live one.
|
|
7001
|
+
*
|
|
7002
|
+
* So the footage is not deleted and not kept: it is stranded, and it accrues
|
|
7003
|
+
* for as long as the install runs.
|
|
7004
|
+
*
|
|
7005
|
+
* `segment-watcher.ts` carries a comment asserting the opposite — that the
|
|
7006
|
+
* playlist "is NOT cleared between runs", which is what its `stale-previous-run`
|
|
7007
|
+
* classification and its documented "that is the crash recovery" path are built
|
|
7008
|
+
* on. The live filesystem refutes it, and a leftover that describes the right
|
|
7009
|
+
* design reads as verification: on 2026-08-14 `/recordings/.rec-tmp/590/high/`
|
|
7010
|
+
* held a `live.m3u8` of 50 entries, every one named at or after that writer's
|
|
7011
|
+
* 20:28:31 start, beside **5 863 older `.m4s` files referenced by nobody** —
|
|
7012
|
+
* the oldest from 2026-07-04. Fleet total: 179 GB, on an array at 90% full.
|
|
7013
|
+
*
|
|
7014
|
+
* WHAT THIS DOES NOT DO
|
|
7015
|
+
* ─────────────────────
|
|
7016
|
+
* It never deletes. Recovery is the same `SegmentStore.onFinalized` the live
|
|
7017
|
+
* path uses — stat, relocate into the UTC bucket, index — so a recovered
|
|
7018
|
+
* segment is indistinguishable from one that was never orphaned, and a segment
|
|
7019
|
+
* it cannot place stays exactly where it is for the next attempt.
|
|
7020
|
+
*/
|
|
7021
|
+
/**
|
|
7022
|
+
* How far before the writer's own start a segment may be named and still count
|
|
7023
|
+
* as belonging to THIS run.
|
|
7024
|
+
*
|
|
7025
|
+
* Mirrors `segment-watcher.PREVIOUS_RUN_SKEW_MS`, and for the same reason:
|
|
7026
|
+
* ffmpeg names segments with whole-second `%s`, so the first segment of a run
|
|
7027
|
+
* carries the second already in progress when the process spawned. Without the
|
|
7028
|
+
* tolerance a recovery pass would reach for the live writer's opening segment —
|
|
7029
|
+
* the one file in the directory that is still being written.
|
|
7030
|
+
*/
|
|
7031
|
+
var PREVIOUS_RUN_SKEW_MS = 1e3;
|
|
7032
|
+
/**
|
|
7033
|
+
* How much longer than nominal an inter-segment gap may be and still be read as
|
|
7034
|
+
* one segment's duration.
|
|
7035
|
+
*
|
|
7036
|
+
* ffmpeg cuts on keyframes, so a real segment overshoots `-segment_time` by up
|
|
7037
|
+
* to a GOP; a gap of twice nominal is still plausibly one segment. Anything
|
|
7038
|
+
* wider is a RUN BOUNDARY — the writer was down in between — and the honest
|
|
7039
|
+
* duration is the nominal one. Trusting the gap there would paint a solid
|
|
7040
|
+
* timeline bar across hours of footage that does not exist, which is a worse
|
|
7041
|
+
* failure than under-reporting by a few seconds.
|
|
7042
|
+
*/
|
|
7043
|
+
var MAX_GAP_FACTOR = 2;
|
|
7044
|
+
/**
|
|
7045
|
+
* Decide which staged files belong to a previous run, and how long each ran.
|
|
7046
|
+
*
|
|
7047
|
+
* Pure — the caller does the I/O. The gate is the file's own name against the
|
|
7048
|
+
* writer's start: a segment named before this ffmpeg spawned cannot be one this
|
|
7049
|
+
* ffmpeg is writing, whatever the playlist says.
|
|
7050
|
+
*/
|
|
7051
|
+
function planStagingRecovery(input) {
|
|
7052
|
+
const nominalMs = input.segmentSeconds * 1e3;
|
|
7053
|
+
const cutoffMs = input.writerStartedMs - PREVIOUS_RUN_SKEW_MS;
|
|
7054
|
+
const owned = input.playlistNames;
|
|
7055
|
+
const candidates = [];
|
|
7056
|
+
for (const name of input.names) {
|
|
7057
|
+
const startMs = parseEpochStartMs(name);
|
|
7058
|
+
if (startMs === null) continue;
|
|
7059
|
+
if (startMs >= cutoffMs) continue;
|
|
7060
|
+
if (owned?.has(name) === true) continue;
|
|
7061
|
+
candidates.push({
|
|
7062
|
+
name,
|
|
7063
|
+
startMs
|
|
7064
|
+
});
|
|
7065
|
+
}
|
|
7066
|
+
candidates.sort((a, b) => a.startMs - b.startMs);
|
|
7067
|
+
const maxGapMs = nominalMs * MAX_GAP_FACTOR;
|
|
7068
|
+
return candidates.map((c, i) => {
|
|
7069
|
+
const next = candidates[i + 1];
|
|
7070
|
+
const gapMs = next === void 0 ? 0 : next.startMs - c.startMs;
|
|
7071
|
+
const durMs = gapMs > 0 && gapMs <= maxGapMs ? gapMs : nominalMs;
|
|
7072
|
+
return {
|
|
7073
|
+
name: c.name,
|
|
7074
|
+
startMs: c.startMs,
|
|
7075
|
+
durMs
|
|
7076
|
+
};
|
|
7077
|
+
});
|
|
7078
|
+
}
|
|
7079
|
+
/** The real filesystem. The only production value of {@link StagingIo}. */
|
|
7080
|
+
var nodeStagingIo = {
|
|
7081
|
+
listDir: (dir) => node_fs.promises.readdir(dir),
|
|
7082
|
+
readPlaylist: (dir) => node_fs.promises.readFile(node_path.default.join(dir, "live.m3u8"), "utf8").catch(() => null)
|
|
7083
|
+
};
|
|
7084
|
+
/** The only `<deviceId>/` subdirectories of staging that hold segments. */
|
|
7085
|
+
var KNOWN_PROFILES = new Set([
|
|
7086
|
+
"high",
|
|
7087
|
+
"mid",
|
|
7088
|
+
"low"
|
|
7089
|
+
]);
|
|
7090
|
+
/** Default orphans per chunk. 590/high alone had 5 863 of them. */
|
|
7091
|
+
var DEFAULT_CHUNK_SIZE = 100;
|
|
7092
|
+
var macrotask = () => new Promise((resolve) => {
|
|
7093
|
+
setTimeout(resolve, 0);
|
|
7094
|
+
});
|
|
7095
|
+
/**
|
|
7096
|
+
* Relocate every previous-run segment left in one staging dir.
|
|
7097
|
+
*
|
|
7098
|
+
* Chunked with a yield between batches: this runs alongside a live recorder and
|
|
7099
|
+
* a backlog is measured in thousands of files over shfs/FUSE — a single
|
|
7100
|
+
* uninterrupted loop is exactly the event-loop stall the recorder has already
|
|
7101
|
+
* paid for once (D128).
|
|
7102
|
+
*
|
|
7103
|
+
* A failure is per-segment and never aborts the pass: the file stays in staging
|
|
7104
|
+
* and the next attach retries it. Every drop is logged with the segment name —
|
|
7105
|
+
* silence reads as "never happened", and this whole class of loss went
|
|
7106
|
+
* unnoticed for six weeks precisely because nothing said anything.
|
|
7107
|
+
*/
|
|
7108
|
+
async function recoverStagingOrphans(dir, input, deps) {
|
|
7109
|
+
let names;
|
|
7110
|
+
try {
|
|
7111
|
+
names = await deps.listDir(dir);
|
|
7112
|
+
} catch (err) {
|
|
7113
|
+
deps.logger.warn("recorder: staging recovery could not list the staging dir", { meta: {
|
|
7114
|
+
dir,
|
|
7115
|
+
error: err instanceof Error ? err.message : String(err)
|
|
7116
|
+
} });
|
|
7117
|
+
return {
|
|
7118
|
+
found: 0,
|
|
7119
|
+
recovered: 0,
|
|
7120
|
+
failed: 0,
|
|
7121
|
+
aborted: false
|
|
7122
|
+
};
|
|
7123
|
+
}
|
|
7124
|
+
let playlistNames;
|
|
7125
|
+
const body = await deps.readPlaylist(dir).catch(() => null);
|
|
7126
|
+
if (body !== null) playlistNames = new Set(parseLivePlaylist(body).map((e) => e.segPath.replace(/^.*\//, "")));
|
|
7127
|
+
const plan = planStagingRecovery({
|
|
7128
|
+
...input,
|
|
7129
|
+
names,
|
|
7130
|
+
playlistNames
|
|
7131
|
+
});
|
|
7132
|
+
if (plan.length === 0) return {
|
|
7133
|
+
found: 0,
|
|
7134
|
+
recovered: 0,
|
|
7135
|
+
failed: 0,
|
|
7136
|
+
aborted: false
|
|
7137
|
+
};
|
|
7138
|
+
const chunkSize = deps.chunkSize ?? DEFAULT_CHUNK_SIZE;
|
|
7139
|
+
const yieldBetween = deps.yieldBetween ?? macrotask;
|
|
7140
|
+
let recovered = 0;
|
|
7141
|
+
let failed = 0;
|
|
7142
|
+
let aborted = false;
|
|
7143
|
+
for (let i = 0; i < plan.length; i++) {
|
|
7144
|
+
const orphan = plan[i];
|
|
7145
|
+
if (deps.shouldStop?.() === true) {
|
|
7146
|
+
aborted = true;
|
|
7147
|
+
deps.logger.warn("recorder: staging recovery stopped short by shutdown", { meta: {
|
|
7148
|
+
dir,
|
|
7149
|
+
recovered,
|
|
7150
|
+
failed,
|
|
7151
|
+
remaining: plan.length - i
|
|
7152
|
+
} });
|
|
7153
|
+
break;
|
|
7154
|
+
}
|
|
7155
|
+
try {
|
|
7156
|
+
await deps.finalize(orphan, `${dir}/${orphan.name}`);
|
|
7157
|
+
recovered += 1;
|
|
7158
|
+
} catch (err) {
|
|
7159
|
+
failed += 1;
|
|
7160
|
+
deps.logger.warn("recorder: staging recovery could not relocate an orphaned segment", { meta: {
|
|
7161
|
+
dir,
|
|
7162
|
+
seg: orphan.name,
|
|
7163
|
+
startMs: orphan.startMs,
|
|
7164
|
+
durMs: orphan.durMs,
|
|
7165
|
+
error: err instanceof Error ? err.message : String(err)
|
|
7166
|
+
} });
|
|
7167
|
+
}
|
|
7168
|
+
if ((i + 1) % chunkSize === 0 && i + 1 < plan.length) await yieldBetween();
|
|
7169
|
+
}
|
|
7170
|
+
deps.logger.info("recorder: recovered segments a writer restart had orphaned in staging", { meta: {
|
|
7171
|
+
dir,
|
|
7172
|
+
found: plan.length,
|
|
7173
|
+
recovered,
|
|
7174
|
+
failed,
|
|
7175
|
+
aborted,
|
|
7176
|
+
oldestMs: plan[0]?.startMs,
|
|
7177
|
+
newestMs: plan[plan.length - 1]?.startMs
|
|
7178
|
+
} });
|
|
7179
|
+
return {
|
|
7180
|
+
found: plan.length,
|
|
7181
|
+
recovered,
|
|
7182
|
+
failed,
|
|
7183
|
+
aborted
|
|
7184
|
+
};
|
|
7185
|
+
}
|
|
7186
|
+
/**
|
|
7187
|
+
* Boot reconcile: relocate every previous-run segment stranded in staging, on
|
|
7188
|
+
* every location, for every camera — attached or not.
|
|
7189
|
+
*
|
|
7190
|
+
* WHY BOOT, AND NOT ATTACH
|
|
7191
|
+
* ────────────────────────
|
|
7192
|
+
* Orphans are created by a writer restart, so by construction they all predate
|
|
7193
|
+
* the runner that finds them; boot is when the whole backlog is visible at
|
|
7194
|
+
* once. Attach looked like the natural hook and is the wrong one twice over: it
|
|
7195
|
+
* only ever reaches cameras that are currently recording (device 1438 had a
|
|
7196
|
+
* staging backlog while attached to nothing), and it puts unbounded filesystem
|
|
7197
|
+
* work on the path that must start recording NOW — measured, it kept microtasks
|
|
7198
|
+
* in flight across `RecordingController.stop()` and made the runner's 5 s
|
|
7199
|
+
* SIGTERM-grace test fail under load.
|
|
7200
|
+
*
|
|
7201
|
+
* `bootMs` is the cutoff, and it is what makes this safe to run while writers
|
|
7202
|
+
* are spawning: a live writer's segments are all named after boot, so the plan
|
|
7203
|
+
* cannot select one. Nothing is deleted, and a segment that cannot be placed is
|
|
7204
|
+
* left exactly where it is for the next boot.
|
|
7205
|
+
*/
|
|
7206
|
+
async function recoverAllStagedOrphans(deps) {
|
|
7207
|
+
let dirs = 0;
|
|
7208
|
+
let found = 0;
|
|
7209
|
+
let recovered = 0;
|
|
7210
|
+
let failed = 0;
|
|
7211
|
+
let aborted = false;
|
|
7212
|
+
for (const location of deps.locations()) {
|
|
7213
|
+
const stagingRoot = `${location.root}/${STAGING_DIR_NAME}`;
|
|
7214
|
+
let deviceDirs;
|
|
7215
|
+
try {
|
|
7216
|
+
deviceDirs = await deps.listDir(stagingRoot);
|
|
7217
|
+
} catch {
|
|
7218
|
+
continue;
|
|
7219
|
+
}
|
|
7220
|
+
for (const deviceDir of deviceDirs) {
|
|
7221
|
+
if (aborted) break;
|
|
7222
|
+
const deviceId = Number(deviceDir);
|
|
7223
|
+
if (!Number.isInteger(deviceId) || deviceId <= 0) continue;
|
|
7224
|
+
let profileDirs;
|
|
7225
|
+
try {
|
|
7226
|
+
profileDirs = await deps.listDir(`${stagingRoot}/${deviceDir}`);
|
|
7227
|
+
} catch {
|
|
7228
|
+
continue;
|
|
7229
|
+
}
|
|
7230
|
+
const segmentSeconds = await deps.segmentSecondsFor(deviceId);
|
|
7231
|
+
for (const profile of profileDirs) {
|
|
7232
|
+
if (aborted) break;
|
|
7233
|
+
if (!KNOWN_PROFILES.has(profile)) continue;
|
|
7234
|
+
const dir = `${stagingRoot}/${deviceDir}/${profile}`;
|
|
7235
|
+
dirs += 1;
|
|
7236
|
+
const report = await recoverStagingOrphans(dir, {
|
|
7237
|
+
writerStartedMs: deps.bootMs,
|
|
7238
|
+
segmentSeconds
|
|
7239
|
+
}, {
|
|
7240
|
+
listDir: deps.listDir,
|
|
7241
|
+
readPlaylist: deps.readPlaylist,
|
|
7242
|
+
finalize: (orphan, flatAbsPath) => deps.finalize({
|
|
7243
|
+
deviceId,
|
|
7244
|
+
profile,
|
|
7245
|
+
locationId: location.id,
|
|
7246
|
+
locationRoot: location.root,
|
|
7247
|
+
flatAbsPath,
|
|
7248
|
+
startMs: orphan.startMs,
|
|
7249
|
+
durMs: orphan.durMs
|
|
7250
|
+
}),
|
|
7251
|
+
logger: withDeviceTag(deps.logger, deviceId),
|
|
7252
|
+
chunkSize: deps.chunkSize,
|
|
7253
|
+
yieldBetween: deps.yieldBetween,
|
|
7254
|
+
shouldStop: deps.shouldStop
|
|
7255
|
+
});
|
|
7256
|
+
found += report.found;
|
|
7257
|
+
recovered += report.recovered;
|
|
7258
|
+
failed += report.failed;
|
|
7259
|
+
if (report.aborted) aborted = true;
|
|
7260
|
+
}
|
|
7261
|
+
}
|
|
7262
|
+
}
|
|
7263
|
+
if (found > 0 || failed > 0) deps.logger.info("recorder: staging boot reconcile complete", { meta: {
|
|
7264
|
+
dirs,
|
|
7265
|
+
found,
|
|
7266
|
+
recovered,
|
|
7267
|
+
failed,
|
|
7268
|
+
aborted
|
|
7269
|
+
} });
|
|
7270
|
+
return {
|
|
7271
|
+
dirs,
|
|
7272
|
+
found,
|
|
7273
|
+
recovered,
|
|
7274
|
+
failed,
|
|
7275
|
+
aborted
|
|
7276
|
+
};
|
|
7277
|
+
}
|
|
7278
|
+
/** Bind `tags: { deviceId }` onto every line a per-dir pass writes. */
|
|
7279
|
+
function withDeviceTag(logger, deviceId) {
|
|
7280
|
+
const tag = (extras) => ({
|
|
7281
|
+
...typeof extras === "object" && extras !== null ? extras : {},
|
|
7282
|
+
tags: { deviceId }
|
|
7283
|
+
});
|
|
7284
|
+
return {
|
|
7285
|
+
info: (message, extras) => logger.info(message, tag(extras)),
|
|
7286
|
+
warn: (message, extras) => logger.warn(message, tag(extras)),
|
|
7287
|
+
debug: (message, extras) => logger.debug(message, tag(extras))
|
|
7288
|
+
};
|
|
7289
|
+
}
|
|
7290
|
+
//#endregion
|
|
6982
7291
|
//#region src/recorder/addon/index.ts
|
|
6983
7292
|
/**
|
|
6984
7293
|
* recorder addon SHELL — recording + storage-evictable providers on the
|
|
@@ -7516,11 +7825,13 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
7516
7825
|
force: true
|
|
7517
7826
|
}).catch(() => {});
|
|
7518
7827
|
await this.sweepUnassignedStaging();
|
|
7828
|
+
const stagingBootMs = Date.now();
|
|
7519
7829
|
try {
|
|
7520
7830
|
await this.controller?.start();
|
|
7521
7831
|
} catch (err) {
|
|
7522
7832
|
this.ctx.logger.warn("recorder: controller start failed", { meta: { error: require_dist.errMsg(err) } });
|
|
7523
7833
|
}
|
|
7834
|
+
this.recoverStagedOrphans(stagingBootMs);
|
|
7524
7835
|
if (this.eventUnsub === null) this.eventUnsub = subscribeEventCapture({
|
|
7525
7836
|
eventBus: this.ctx.eventBus,
|
|
7526
7837
|
map: this.eventMap,
|
|
@@ -7825,6 +8136,45 @@ var RecorderV2Addon = class extends require_dist.BaseAddon {
|
|
|
7825
8136
|
}
|
|
7826
8137
|
}
|
|
7827
8138
|
/**
|
|
8139
|
+
* Boot reconcile for `.rec-tmp`: relocate every segment a previous runner's
|
|
8140
|
+
* writers left stranded, on every location, for every camera.
|
|
8141
|
+
*
|
|
8142
|
+
* The counterpart to {@link sweepUnassignedStaging} above, which only ever
|
|
8143
|
+
* looks at locations nothing is assigned to — so the LIVE root, the one that
|
|
8144
|
+
* actually accumulates, had no pass at all (D148). Nothing here deletes: each
|
|
8145
|
+
* orphan goes through the same `SegmentStore.onFinalized` a live segment
|
|
8146
|
+
* does, and one it cannot place is left on disk for the next boot.
|
|
8147
|
+
*/
|
|
8148
|
+
async recoverStagedOrphans(bootMs) {
|
|
8149
|
+
try {
|
|
8150
|
+
await recoverAllStagedOrphans({
|
|
8151
|
+
locations: () => this.resolvedLocations.map((l) => ({
|
|
8152
|
+
id: l.id,
|
|
8153
|
+
root: l.root
|
|
8154
|
+
})),
|
|
8155
|
+
listDir: nodeStagingIo.listDir,
|
|
8156
|
+
readPlaylist: nodeStagingIo.readPlaylist,
|
|
8157
|
+
finalize: async (input) => {
|
|
8158
|
+
const store = this.segmentStore;
|
|
8159
|
+
if (store === null) throw new Error("recorder is shutting down");
|
|
8160
|
+
await store.onFinalized(input);
|
|
8161
|
+
},
|
|
8162
|
+
logger: this.ctx.logger,
|
|
8163
|
+
bootMs,
|
|
8164
|
+
segmentSecondsFor: async (deviceId) => {
|
|
8165
|
+
try {
|
|
8166
|
+
return (await loadDeviceConfig(this.configStore(), deviceId)).segmentSeconds ?? this.config.segmentSeconds;
|
|
8167
|
+
} catch {
|
|
8168
|
+
return this.config.segmentSeconds;
|
|
8169
|
+
}
|
|
8170
|
+
},
|
|
8171
|
+
shouldStop: () => this.segmentStore === null
|
|
8172
|
+
});
|
|
8173
|
+
} catch (err) {
|
|
8174
|
+
this.ctx.logger.warn("recorder: staging boot reconcile failed", { meta: { error: require_dist.errMsg(err) } });
|
|
8175
|
+
}
|
|
8176
|
+
}
|
|
8177
|
+
/**
|
|
7828
8178
|
* Surface a storage-placement fault on the liveness/alert surface. STRICTLY
|
|
7829
8179
|
* best-effort: the alerts cap is a nice-to-have and an unmounted disk must
|
|
7830
8180
|
* still be shouted about in the log even when it is unreachable.
|