@camstack/server 1.2.413 → 1.2.415

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.
@@ -2062,6 +2062,15 @@ function createCapRouter_coreBlocks(getProvider, createRemoteProxy) {
2062
2062
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2063
2063
  return p.getCustomization(methodInput);
2064
2064
  }),
2065
+ listComposableCaps: trpc_middleware_js_1.adminProcedure
2066
+ .input(types_34.coreBlocksCapability.methods.listComposableCaps.input.loose())
2067
+ .output(types_34.coreBlocksCapability.methods.listComposableCaps.output)
2068
+ .query(async ({ input, ctx }) => {
2069
+ const { nodeId, ...methodInput } = input;
2070
+ const p = resolveProvider('core-blocks', nodeId, () => getProvider(ctx), createRemoteProxy);
2071
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
2072
+ return p.listComposableCaps(methodInput);
2073
+ }),
2065
2074
  validateComposition: trpc_middleware_js_1.adminProcedure
2066
2075
  .input(types_34.coreBlocksCapability.methods.validateComposition.input.loose())
2067
2076
  .output(types_34.coreBlocksCapability.methods.validateComposition.output)
@@ -7615,6 +7624,14 @@ function createCapRouter_pipelineOrchestrator(getProvider, _createRemoteProxy) {
7615
7624
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
7616
7625
  return p.getCameraStatuses(input);
7617
7626
  }),
7627
+ setCameraSensors: trpc_middleware_js_1.adminProcedure
7628
+ .input(types_91.pipelineOrchestratorCapability.methods.setCameraSensors.input.loose())
7629
+ .output(types_91.pipelineOrchestratorCapability.methods.setCameraSensors.output)
7630
+ .mutation(async ({ input, ctx }) => {
7631
+ const p = requireCapProvider('pipeline-orchestrator', () => getProvider(ctx));
7632
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
7633
+ return p.setCameraSensors(input);
7634
+ }),
7618
7635
  reconcileFromDisk: trpc_middleware_js_1.adminProcedure
7619
7636
  .input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
7620
7637
  .output(types_91.pipelineOrchestratorCapability.methods.reconcileFromDisk.output)
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildStreamProbeArgs = buildStreamProbeArgs;
4
+ exports.probeFoundAStream = probeFoundAStream;
5
+ exports.parseFfmpegStreamInfo = parseFfmpegStreamInfo;
6
+ /** How long the probe waits on a network input, in µs (ffmpeg's unit). */
7
+ const PROBE_INPUT_TIMEOUT_US = '5000000';
8
+ /**
9
+ * The probe argv for `url`, chosen by SCHEME.
10
+ *
11
+ * The ffmpeg CLI, unlike ffprobe, refuses an input option nothing consumed:
12
+ * `-rtsp_transport` on an http or file input is `Option rtsp_transport not
13
+ * found … Error opening input files`, exit 8 (measured on jellyfin 8.1.2).
14
+ * `-timeout` is an http and rtsp option, is unknown to a file, and on rtmp
15
+ * would mean LISTEN mode. So each scheme gets exactly what it consumes.
16
+ */
17
+ function buildStreamProbeArgs(url) {
18
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(url)?.[1]?.toLowerCase() ?? '';
19
+ const input = [];
20
+ if (scheme === 'rtsp' || scheme === 'rtsps') {
21
+ input.push('-rtsp_transport', 'tcp', '-timeout', PROBE_INPUT_TIMEOUT_US);
22
+ }
23
+ else if (scheme === 'http' || scheme === 'https') {
24
+ input.push('-timeout', PROBE_INPUT_TIMEOUT_US);
25
+ }
26
+ return ['-hide_banner', '-nostdin', ...input, '-i', url];
27
+ }
28
+ /** Did the probe find anything at all? An empty result is a failed probe. */
29
+ function probeFoundAStream(meta) {
30
+ return meta.codec !== undefined || meta.audio !== undefined;
31
+ }
32
+ /** Codec aliases normalised to canonical names. */
33
+ const CODEC_ALIASES = {
34
+ hevc: 'h265',
35
+ };
36
+ /** `Stream #0:1[0x2](und): Audio: aac (LC) (mp4a / …), …` → kind + the rest. */
37
+ const STREAM_LINE_RE = /^\s*Stream #\d+:\d+\S*:\s+(Video|Audio):\s+(.*)$/;
38
+ /** `aac (LC) (mp4a / 0x…)` → codec `aac`, profile `LC` (the FIRST parenthesis). */
39
+ const CODEC_RE = /^([\w-]+)(?:\s+\(([^)/]+)\))?/;
40
+ /** Channel layouts ffmpeg names instead of counting. */
41
+ const CHANNEL_LAYOUTS = {
42
+ mono: 1,
43
+ stereo: 2,
44
+ '2.1': 3,
45
+ quad: 4,
46
+ '5.0': 5,
47
+ '5.1': 6,
48
+ '7.1': 8,
49
+ };
50
+ function positive(n) {
51
+ return Number.isFinite(n) && n > 0 ? n : undefined;
52
+ }
53
+ function kbps(body) {
54
+ const m = /(\d+) kb\/s/.exec(body);
55
+ return m === null ? undefined : positive(Number(m[1]));
56
+ }
57
+ function fps(body) {
58
+ // `fps` is the stream's rate; `tbr` is ffmpeg's guess when it has none.
59
+ // ffmpeg prints `90k tbr` for 90 000 — the `k` is a multiplier, not noise.
60
+ const m = /([\d.]+)(k)? fps\b/.exec(body) ?? /([\d.]+)(k)? tbr\b/.exec(body);
61
+ if (m === null)
62
+ return undefined;
63
+ const base = positive(Number(m[1]));
64
+ if (base === undefined)
65
+ return undefined;
66
+ const value = m[2] === 'k' ? base * 1000 : base;
67
+ return Math.round(value * 100) / 100;
68
+ }
69
+ function readVideo(body, meta) {
70
+ const codec = CODEC_RE.exec(body);
71
+ const raw = codec?.[1]?.toLowerCase();
72
+ if (raw !== undefined)
73
+ meta.codec = CODEC_ALIASES[raw] ?? raw;
74
+ const size = /\b(\d{2,5})x(\d{2,5})\b/.exec(body);
75
+ if (size !== null) {
76
+ const width = positive(Number(size[1]));
77
+ const height = positive(Number(size[2]));
78
+ if (width !== undefined)
79
+ meta.width = width;
80
+ if (height !== undefined)
81
+ meta.height = height;
82
+ }
83
+ const rate = fps(body);
84
+ if (rate !== undefined)
85
+ meta.fps = rate;
86
+ const bitrate = kbps(body);
87
+ if (bitrate !== undefined)
88
+ meta.bitrateKbps = bitrate;
89
+ }
90
+ function readAudio(body) {
91
+ const audio = {};
92
+ const codec = CODEC_RE.exec(body);
93
+ if (codec?.[1] !== undefined)
94
+ audio.codec = codec[1].toLowerCase();
95
+ const profile = codec?.[2]?.trim();
96
+ if (profile !== undefined && profile.length > 0)
97
+ audio.profile = profile;
98
+ const hz = /(\d+) Hz/.exec(body);
99
+ if (hz !== null) {
100
+ const sampleRate = positive(Number(hz[1]));
101
+ if (sampleRate !== undefined)
102
+ audio.sampleRate = sampleRate;
103
+ }
104
+ const fields = body.split(',').map((f) => f.trim());
105
+ for (const field of fields) {
106
+ const named = CHANNEL_LAYOUTS[field.replace(/\(.*\)$/, '')];
107
+ if (named !== undefined) {
108
+ audio.channels = named;
109
+ break;
110
+ }
111
+ const counted = /^(\d+) channels$/.exec(field);
112
+ if (counted !== null) {
113
+ audio.channels = Number(counted[1]);
114
+ break;
115
+ }
116
+ }
117
+ const bitrate = kbps(body);
118
+ if (bitrate !== undefined)
119
+ audio.bitrateKbps = bitrate;
120
+ return audio;
121
+ }
122
+ /** The FIRST video and FIRST audio stream of `ffmpeg -i`'s report. */
123
+ function parseFfmpegStreamInfo(stderr) {
124
+ const meta = {};
125
+ // CRLF too: `(.*)$` cannot cross a `\r`, and a Windows build or a captured
126
+ // log hands one over.
127
+ for (const line of stderr.split(/\r?\n/)) {
128
+ const m = STREAM_LINE_RE.exec(line);
129
+ if (m === null)
130
+ continue;
131
+ const kind = m[1];
132
+ const body = m[2] ?? '';
133
+ if (kind === 'Video' && meta.codec === undefined)
134
+ readVideo(body, meta);
135
+ else if (kind === 'Audio' && meta.audio === undefined)
136
+ meta.audio = readAudio(body);
137
+ }
138
+ return meta;
139
+ }
@@ -2,10 +2,9 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.StreamProbeService = void 0;
4
4
  const child_process_1 = require("child_process");
5
- const util_1 = require("util");
6
5
  const ttl_cache_1 = require("./ttl-cache");
7
6
  const types_1 = require("@camstack/types");
8
- const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
7
+ const ffmpeg_stream_info_1 = require("./ffmpeg-stream-info");
9
8
  const CACHE_TTL_MS = 3_600_000; // 1 hour
10
9
  const PROBE_TIMEOUT_MS = 5_000;
11
10
  /**
@@ -18,11 +17,13 @@ const PROBE_TIMEOUT_MS = 5_000;
18
17
  * the ceiling exists so the key space cannot be walked into the OOM.
19
18
  */
20
19
  const CACHE_MAX_ENTRIES = 512;
21
- /** Codec aliases normalised to canonical names. */
22
- const CODEC_ALIASES = {
23
- hevc: 'h265',
24
- };
20
+ /** A probe URL — and ffmpeg's echo of it — carries the camera's credentials;
21
+ * the log never does. */
22
+ function redactUrl(text) {
23
+ return text.replace(/\/\/[^@/\s]*@/g, '//***@');
24
+ }
25
25
  class StreamProbeService {
26
+ resolveFfmpeg;
26
27
  logger;
27
28
  /**
28
29
  * Probe results, bounded by BOTH a swept TTL and an entry ceiling.
@@ -35,7 +36,10 @@ class StreamProbeService {
35
36
  ttlMs: CACHE_TTL_MS,
36
37
  maxEntries: CACHE_MAX_ENTRIES,
37
38
  });
38
- constructor(loggingService) {
39
+ constructor(loggingService,
40
+ /** The node's pinned ffmpeg (D540). Rejects rather than naming PATH. */
41
+ resolveFfmpeg) {
42
+ this.resolveFfmpeg = resolveFfmpeg;
39
43
  this.logger = loggingService.createLogger('StreamProbeService');
40
44
  }
41
45
  // ---------------------------------------------------------------------------
@@ -50,19 +54,24 @@ class StreamProbeService {
50
54
  return cached;
51
55
  }
52
56
  const metadata = await this.runProbe(url);
53
- this.cache.set(url, metadata);
57
+ // Only an ANSWER is cached. An empty result is a probe that failed (no
58
+ // binary, a timeout, a refused dial), and caching it for an hour made one
59
+ // bad moment the answer for an hour.
60
+ if ((0, ffmpeg_stream_info_1.probeFoundAStream)(metadata))
61
+ this.cache.set(url, metadata);
54
62
  return metadata;
55
63
  }
56
64
  /**
57
65
  * Generic field probe: given a field key and value, decides how to probe.
58
- * Stream fields (stream_*) → ffprobe, other URLs → HTTP HEAD check.
66
+ * Stream fields (stream_*) → the pinned ffmpeg's input report, other URLs →
67
+ * HTTP HEAD check.
59
68
  */
60
69
  async probeField(key, value) {
61
70
  const url = String(value ?? '').trim();
62
71
  if (!url) {
63
72
  return { status: 'error', error: 'No URL provided' };
64
73
  }
65
- // Stream fields: use ffprobe
74
+ // Stream fields: ask the pinned ffmpeg what the input carries
66
75
  if (key.startsWith('stream')) {
67
76
  const meta = await this.probe(url, { force: true });
68
77
  const labels = [];
@@ -132,98 +141,31 @@ class StreamProbeService {
132
141
  // Internal
133
142
  // ---------------------------------------------------------------------------
134
143
  async runProbe(url) {
144
+ // The node's PINNED ffmpeg (D540). This was `ffprobe` spawned by name —
145
+ // the host's binary, off PATH, which the pinned build does not even ship.
146
+ let ffmpeg;
135
147
  try {
136
- // Query first video + first audio stream in one ffprobe pass.
137
- // `-show_streams` returns every stream in the container; we filter
138
- // by codec_type when parsing. Fields cover both kinds.
139
- const { stdout } = await execFileAsync('ffprobe', [
140
- '-v',
141
- 'error',
142
- '-rtsp_transport',
143
- 'tcp',
144
- '-timeout',
145
- '5000000',
146
- '-show_entries',
147
- 'stream=codec_type,codec_name,profile,width,height,r_frame_rate,bit_rate,sample_rate,channels',
148
- '-of',
149
- 'json',
150
- url,
151
- ], { timeout: PROBE_TIMEOUT_MS });
152
- return this.parseOutput(stdout);
148
+ ffmpeg = await this.resolveFfmpeg();
153
149
  }
154
150
  catch (err) {
155
- this.logger.error('ffprobe failed', { meta: { url, error: String(err) } });
151
+ this.logger.error('stream probe refused — no pinned ffmpeg on this node (D540)', {
152
+ meta: { url: redactUrl(url), error: (0, types_1.errMsg)(err) },
153
+ });
156
154
  return {};
157
155
  }
158
- }
159
- parseOutput(stdout) {
160
- const parsed = JSON.parse(stdout);
161
- const streams = parsed.streams;
162
- if (!Array.isArray(streams) || streams.length === 0) {
163
- return {};
164
- }
165
- const meta = {};
166
- for (const raw of streams) {
167
- const stream = raw;
168
- const codecType = typeof stream.codec_type === 'string' ? stream.codec_type : undefined;
169
- if (codecType === 'video' && meta.codec === undefined) {
170
- const rawCodec = typeof stream.codec_name === 'string' ? stream.codec_name : undefined;
171
- meta.codec = rawCodec ? (CODEC_ALIASES[rawCodec] ?? rawCodec) : undefined;
172
- if (typeof stream.width === 'number')
173
- meta.width = stream.width;
174
- if (typeof stream.height === 'number')
175
- meta.height = stream.height;
176
- const fps = this.parseFps(stream.r_frame_rate);
177
- if (fps !== undefined)
178
- meta.fps = fps;
179
- const bitrateKbps = this.parseBitrateKbps(stream.bit_rate);
180
- if (bitrateKbps !== undefined)
181
- meta.bitrateKbps = bitrateKbps;
182
- }
183
- else if (codecType === 'audio' && meta.audio === undefined) {
184
- const audio = {};
185
- if (typeof stream.codec_name === 'string')
186
- audio.codec = stream.codec_name;
187
- if (typeof stream.profile === 'string')
188
- audio.profile = stream.profile;
189
- const sampleRate = this.parseIntField(stream.sample_rate);
190
- if (sampleRate !== undefined)
191
- audio.sampleRate = sampleRate;
192
- if (typeof stream.channels === 'number')
193
- audio.channels = stream.channels;
194
- const audioBitrateKbps = this.parseBitrateKbps(stream.bit_rate);
195
- if (audioBitrateKbps !== undefined)
196
- audio.bitrateKbps = audioBitrateKbps;
197
- meta.audio = audio;
198
- }
156
+ // `-i` with no output: ffmpeg reports every input stream on stderr, then
157
+ // exits non-zero for want of an output. That exit is the expected one, so
158
+ // the report is read whatever the code; an empty report is the failure.
159
+ const stderr = await new Promise((resolve) => {
160
+ (0, child_process_1.execFile)(ffmpeg, (0, ffmpeg_stream_info_1.buildStreamProbeArgs)(url), { timeout: PROBE_TIMEOUT_MS, encoding: 'utf8' }, (_err, _stdout, errText) => resolve(errText));
161
+ });
162
+ const metadata = (0, ffmpeg_stream_info_1.parseFfmpegStreamInfo)(stderr);
163
+ if (!(0, ffmpeg_stream_info_1.probeFoundAStream)(metadata)) {
164
+ this.logger.error('stream probe found no stream', {
165
+ meta: { url: redactUrl(url), stderr: redactUrl(stderr.trim().slice(-500)) },
166
+ });
199
167
  }
200
- return meta;
201
- }
202
- parseIntField(value) {
203
- const n = typeof value === 'string' ? Number(value) : typeof value === 'number' ? value : NaN;
204
- if (!Number.isFinite(n) || n <= 0)
205
- return undefined;
206
- return Math.round(n);
207
- }
208
- /** Parse fractional frame rate string like "25/1" → 25. */
209
- parseFps(value) {
210
- if (typeof value !== 'string')
211
- return undefined;
212
- const parts = value.split('/');
213
- if (parts.length !== 2)
214
- return undefined;
215
- const num = Number(parts[0]);
216
- const den = Number(parts[1]);
217
- if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0)
218
- return undefined;
219
- return Math.round((num / den) * 100) / 100;
220
- }
221
- /** Parse bit_rate string (bps) → kbps. */
222
- parseBitrateKbps(value) {
223
- const n = typeof value === 'string' ? Number(value) : typeof value === 'number' ? value : NaN;
224
- if (!Number.isFinite(n) || n <= 0)
225
- return undefined;
226
- return Math.round(n / 1000);
168
+ return metadata;
227
169
  }
228
170
  }
229
171
  exports.StreamProbeService = StreamProbeService;
@@ -139,7 +139,11 @@ async function bootManual(opts) {
139
139
  loggingService.setDestinationEnabledCheck((addonId) => capabilityService.getRegistry()?.isProviderDisabled('log-destination', addonId) !== true);
140
140
  const featureService = new feature_service_1.FeatureService(configService);
141
141
  const authService = new auth_service_1.AuthService(configService);
142
- const streamProbeService = new stream_probe_service_1.StreamProbeService(loggingService);
142
+ // The probe runs the node's PINNED ffmpeg (D540) — resolved through the same
143
+ // deps manager every addon is given, once, and never looked up on PATH.
144
+ // (The manager roots binaries at the NODE's data dir whatever it is given.)
145
+ const probeDeps = new system_1.AddonDepsManager(path.resolve(process.env['CAMSTACK_DATA'] ?? 'camstack-data'), loggingService.createLogger('StreamProbeDeps'));
146
+ const streamProbeService = new stream_probe_service_1.StreamProbeService(loggingService, (0, types_1.memoizePinnedFfmpeg)(() => probeDeps.ensureFfmpeg()));
143
147
  const addonBridgeService = new addon_bridge_service_1.AddonBridgeService(loggingService);
144
148
  const addonSearchService = new addon_search_service_1.AddonSearchService(loggingService);
145
149
  // ---- Layer 2: need Layer 0/1 -------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.413",
3
+ "version": "1.2.415",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "files": [
@@ -37,18 +37,18 @@
37
37
  ]
38
38
  },
39
39
  "dependencies": {
40
- "@camstack/addon-admin-ui": "1.2.288",
41
- "@camstack/addon-agent-ui": "1.2.156",
40
+ "@camstack/addon-admin-ui": "1.2.290",
41
+ "@camstack/addon-agent-ui": "1.2.157",
42
42
  "@camstack/addon-auth": "1.2.123",
43
43
  "@camstack/addon-decoder-nodeav": "1.2.115",
44
44
  "@camstack/addon-notifiers": "1.2.121",
45
- "@camstack/addon-pipeline": "1.2.307",
46
- "@camstack/addon-pipeline-orchestrator": "1.2.264",
47
- "@camstack/addon-post-analysis": "1.2.299",
45
+ "@camstack/addon-pipeline": "1.2.308",
46
+ "@camstack/addon-pipeline-orchestrator": "1.2.265",
47
+ "@camstack/addon-post-analysis": "1.2.300",
48
48
  "@camstack/sdk": "1.2.118",
49
- "@camstack/system": "1.2.321",
50
- "@camstack/types": "1.2.274",
51
- "@camstack/ui-library": "1.2.214",
49
+ "@camstack/system": "1.2.323",
50
+ "@camstack/types": "1.2.276",
51
+ "@camstack/ui-library": "1.2.215",
52
52
  "@fastify/compress": "^9.0.0",
53
53
  "@fastify/cookie": "^11.0.2",
54
54
  "@fastify/cors": "^11.2.0",