@camstack/server 1.0.0 → 1.0.1
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/{src/agent-status-page.ts → dist/agent-status-page.js} +30 -45
- package/dist/api/addon-upload.js +441 -0
- package/dist/api/addons-custom.router.js +91 -0
- package/dist/api/auth-whoami.js +55 -0
- package/dist/api/bridge-addons.router.js +109 -0
- package/dist/api/capabilities.router.js +229 -0
- package/dist/api/core/addon-settings.router.js +117 -0
- package/dist/api/core/agents.router.js +73 -0
- package/dist/api/core/auth.router.js +286 -0
- package/dist/api/core/bulk-update-coordinator.js +229 -0
- package/dist/api/core/cap-providers.js +1124 -0
- package/dist/api/core/capabilities.router.js +138 -0
- package/dist/api/core/collection-preference.js +17 -0
- package/dist/api/core/event-bus-proxy.router.js +45 -0
- package/dist/api/core/hwaccel.router.js +91 -0
- package/dist/api/core/live-events.router.js +61 -0
- package/dist/api/core/logs.router.js +172 -0
- package/dist/api/core/notifications.router.js +67 -0
- package/dist/api/core/repl.router.js +35 -0
- package/dist/api/core/settings-backend.router.js +121 -0
- package/dist/api/core/stream-probe.router.js +58 -0
- package/dist/api/core/system-events.router.js +100 -0
- package/dist/api/health/health.routes.js +68 -0
- package/{src/api/oauth2/consent-page.ts → dist/api/oauth2/consent-page.js} +11 -20
- package/dist/api/oauth2/oauth2-routes.js +219 -0
- package/dist/api/trpc/cap-mount-helpers.js +194 -0
- package/dist/api/trpc/cap-route-error-formatter.js +133 -0
- package/dist/api/trpc/client-ip.js +147 -0
- package/dist/api/trpc/core-cap-bridge.js +115 -0
- package/dist/api/trpc/generated-cap-mounts.js +388 -0
- package/dist/api/trpc/generated-cap-routers.js +7635 -0
- package/dist/api/trpc/scope-access.js +93 -0
- package/dist/api/trpc/trpc.context.js +184 -0
- package/dist/api/trpc/trpc.middleware.js +139 -0
- package/dist/api/trpc/trpc.router.js +188 -0
- package/dist/auth/session-cookie.js +47 -0
- package/dist/boot/boot-config.js +241 -0
- package/dist/boot/integration-id-backfill.js +76 -0
- package/dist/boot/post-boot.service.js +85 -0
- package/dist/core/addon/addon-call-gateway.js +99 -0
- package/dist/core/addon/addon-package.service.js +1560 -0
- package/dist/core/addon/addon-registry.service.js +2739 -0
- package/{src/core/addon/addon-row-manifest.ts → dist/core/addon/addon-row-manifest.js} +5 -5
- package/dist/core/addon/addon-search.service.js +62 -0
- package/dist/core/addon/addon-settings-provider.js +102 -0
- package/dist/core/addon/addon.tokens.js +5 -0
- package/dist/core/addon-bridge/addon-bridge.service.js +145 -0
- package/dist/core/addon-pages/addon-pages.service.js +107 -0
- package/dist/core/addon-widgets/addon-widgets.service.js +120 -0
- package/dist/core/agent/agent-registry.service.js +477 -0
- package/dist/core/auth/auth.service.js +10 -0
- package/dist/core/capability/capability.service.js +58 -0
- package/dist/core/config/config.schema.js +7 -0
- package/dist/core/config/config.service.js +10 -0
- package/dist/core/events/event-bus.service.js +83 -0
- package/dist/core/feature/feature.service.js +10 -0
- package/dist/core/lifecycle/lifecycle-state-machine.js +6 -0
- package/dist/core/logging/log-ring-buffer.js +6 -0
- package/dist/core/logging/logging.service.js +130 -0
- package/dist/core/logging/scoped-logger.js +6 -0
- package/dist/core/moleculer/cap-call-fn.js +50 -0
- package/dist/core/moleculer/cap-route-authority.js +122 -0
- package/dist/core/moleculer/moleculer.service.js +898 -0
- package/dist/core/network/network-quality.service.js +7 -0
- package/dist/core/notification/notification-wrapper.service.js +33 -0
- package/dist/core/notification/toast-wrapper.service.js +25 -0
- package/dist/core/provider/provider.tokens.js +4 -0
- package/dist/core/repl/repl-engine.service.js +140 -0
- package/dist/core/storage/fs-storage-backend.js +6 -0
- package/dist/core/storage/storage-location-manager.js +6 -0
- package/dist/core/storage/storage.service.js +7 -0
- package/dist/core/streaming/stream-probe.service.js +209 -0
- package/dist/core/topology/topology-emitter.service.js +106 -0
- package/dist/launcher.js +325 -0
- package/dist/main.js +1098 -0
- package/dist/manual-boot.js +227 -0
- package/package.json +5 -1
- package/src/__tests__/addon-install-e2e.test.ts +0 -74
- package/src/__tests__/addon-pages-e2e.test.ts +0 -200
- package/src/__tests__/addon-route-session.test.ts +0 -17
- package/src/__tests__/addon-settings-router.spec.ts +0 -67
- package/src/__tests__/addon-upload.spec.ts +0 -475
- package/src/__tests__/agent-registry.spec.ts +0 -179
- package/src/__tests__/agent-status-page.spec.ts +0 -82
- package/src/__tests__/auth-session-cookie.test.ts +0 -48
- package/src/__tests__/bulk-update-coordinator.spec.ts +0 -303
- package/src/__tests__/cap-ownership-authority.spec.ts +0 -431
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +0 -206
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +0 -37
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +0 -110
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +0 -292
- package/src/__tests__/cap-providers-bulk-update.spec.ts +0 -408
- package/src/__tests__/cap-route-adapter.spec.ts +0 -302
- package/src/__tests__/cap-routers/_meta.spec.ts +0 -199
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +0 -115
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +0 -177
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +0 -125
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +0 -68
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +0 -137
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +0 -194
- package/src/__tests__/cap-routers/harness.ts +0 -163
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +0 -133
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +0 -64
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +0 -159
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +0 -291
- package/src/__tests__/capability-e2e.test.ts +0 -384
- package/src/__tests__/cli-e2e.test.ts +0 -150
- package/src/__tests__/core-cap-bridge.spec.ts +0 -91
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +0 -40
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +0 -280
- package/src/__tests__/embedded-deps-e2e.test.ts +0 -125
- package/src/__tests__/event-bus-proxy-router.spec.ts +0 -75
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +0 -37
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +0 -37
- package/src/__tests__/fixtures/mock-log-addon.ts +0 -37
- package/src/__tests__/fixtures/mock-storage-addon.ts +0 -40
- package/src/__tests__/framework-allowlist.spec.ts +0 -96
- package/src/__tests__/framework-installer-defer-restart.spec.ts +0 -165
- package/src/__tests__/https-e2e.test.ts +0 -124
- package/src/__tests__/lifecycle-e2e.test.ts +0 -189
- package/src/__tests__/live-events-subscription.spec.ts +0 -149
- package/src/__tests__/moleculer/uds-readiness.spec.ts +0 -150
- package/src/__tests__/moleculer/uds-topology.spec.ts +0 -418
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +0 -383
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +0 -273
- package/src/__tests__/native-cap-route.spec.ts +0 -427
- package/src/__tests__/oauth2-account-linking.spec.ts +0 -867
- package/src/__tests__/post-boot-restart.spec.ts +0 -161
- package/src/__tests__/singleton-contention.test.ts +0 -499
- package/src/__tests__/streaming-diagnostic.test.ts +0 -615
- package/src/__tests__/streaming-scale.test.ts +0 -314
- package/src/__tests__/uds-addon-call-wiring.spec.ts +0 -242
- package/src/__tests__/uds-log-ingest.spec.ts +0 -183
- package/src/api/__tests__/addons-custom.spec.ts +0 -148
- package/src/api/__tests__/capabilities.router.test.ts +0 -56
- package/src/api/addon-upload.ts +0 -529
- package/src/api/addons-custom.router.ts +0 -101
- package/src/api/auth-whoami.ts +0 -101
- package/src/api/bridge-addons.router.ts +0 -122
- package/src/api/capabilities.router.ts +0 -265
- package/src/api/core/__tests__/auth-router-totp.spec.ts +0 -297
- package/src/api/core/__tests__/integration-markers.spec.ts +0 -10
- package/src/api/core/addon-settings.router.ts +0 -127
- package/src/api/core/agents.router.ts +0 -86
- package/src/api/core/auth.router.ts +0 -322
- package/src/api/core/bulk-update-coordinator.ts +0 -305
- package/src/api/core/cap-providers.ts +0 -1339
- package/src/api/core/capabilities.router.ts +0 -149
- package/src/api/core/collection-preference.ts +0 -40
- package/src/api/core/event-bus-proxy.router.ts +0 -45
- package/src/api/core/hwaccel.router.ts +0 -108
- package/src/api/core/live-events.router.ts +0 -67
- package/src/api/core/logs.router.ts +0 -195
- package/src/api/core/notifications.router.ts +0 -66
- package/src/api/core/repl.router.ts +0 -39
- package/src/api/core/settings-backend.router.ts +0 -140
- package/src/api/core/stream-probe.router.ts +0 -57
- package/src/api/core/system-events.router.ts +0 -125
- package/src/api/health/health.routes.ts +0 -117
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +0 -62
- package/src/api/oauth2/oauth2-routes.ts +0 -281
- package/src/api/trpc/__tests__/client-ip.spec.ts +0 -146
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +0 -268
- package/src/api/trpc/__tests__/scope-access.spec.ts +0 -102
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +0 -136
- package/src/api/trpc/cap-mount-helpers.ts +0 -245
- package/src/api/trpc/cap-route-error-formatter.ts +0 -171
- package/src/api/trpc/client-ip.ts +0 -147
- package/src/api/trpc/core-cap-bridge.ts +0 -154
- package/src/api/trpc/generated-cap-mounts.ts +0 -1240
- package/src/api/trpc/generated-cap-routers.ts +0 -11523
- package/src/api/trpc/scope-access.ts +0 -110
- package/src/api/trpc/trpc.context.ts +0 -258
- package/src/api/trpc/trpc.middleware.ts +0 -146
- package/src/api/trpc/trpc.router.ts +0 -389
- package/src/auth/session-cookie.ts +0 -54
- package/src/boot/__tests__/integration-id-backfill.spec.ts +0 -131
- package/src/boot/boot-config.ts +0 -259
- package/src/boot/integration-id-backfill.ts +0 -109
- package/src/boot/post-boot.service.ts +0 -105
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +0 -62
- package/src/core/addon/__tests__/addon-row-manifest.spec.ts +0 -62
- package/src/core/addon/addon-call-gateway.ts +0 -171
- package/src/core/addon/addon-package.service.ts +0 -1787
- package/src/core/addon/addon-registry.service.ts +0 -3130
- package/src/core/addon/addon-search.service.ts +0 -91
- package/src/core/addon/addon-settings-provider.ts +0 -220
- package/src/core/addon/addon.tokens.ts +0 -2
- package/src/core/addon-bridge/addon-bridge.service.ts +0 -130
- package/src/core/addon-pages/addon-pages.service.spec.ts +0 -117
- package/src/core/addon-pages/addon-pages.service.ts +0 -82
- package/src/core/addon-widgets/addon-widgets.service.ts +0 -95
- package/src/core/agent/agent-registry.service.ts +0 -529
- package/src/core/auth/auth.service.spec.ts +0 -86
- package/src/core/auth/auth.service.ts +0 -8
- package/src/core/capability/capability.service.ts +0 -66
- package/src/core/config/config.schema.ts +0 -3
- package/src/core/config/config.service.spec.ts +0 -175
- package/src/core/config/config.service.ts +0 -7
- package/src/core/events/event-bus.service.spec.ts +0 -235
- package/src/core/events/event-bus.service.ts +0 -89
- package/src/core/feature/feature.service.spec.ts +0 -99
- package/src/core/feature/feature.service.ts +0 -8
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +0 -166
- package/src/core/lifecycle/lifecycle-state-machine.ts +0 -3
- package/src/core/logging/log-ring-buffer.ts +0 -3
- package/src/core/logging/logging.service.spec.ts +0 -287
- package/src/core/logging/logging.service.ts +0 -143
- package/src/core/logging/scoped-logger.ts +0 -3
- package/src/core/moleculer/cap-call-fn.spec.ts +0 -173
- package/src/core/moleculer/cap-call-fn.ts +0 -107
- package/src/core/moleculer/cap-route-authority.ts +0 -194
- package/src/core/moleculer/moleculer.service.ts +0 -1072
- package/src/core/network/network-quality.service.spec.ts +0 -53
- package/src/core/network/network-quality.service.ts +0 -5
- package/src/core/notification/notification-wrapper.service.ts +0 -34
- package/src/core/notification/toast-wrapper.service.ts +0 -27
- package/src/core/provider/provider.tokens.ts +0 -1
- package/src/core/repl/repl-engine.service.spec.ts +0 -444
- package/src/core/repl/repl-engine.service.ts +0 -155
- package/src/core/storage/fs-storage-backend.spec.ts +0 -70
- package/src/core/storage/fs-storage-backend.ts +0 -3
- package/src/core/storage/storage-location-manager.spec.ts +0 -130
- package/src/core/storage/storage-location-manager.ts +0 -3
- package/src/core/storage/storage.service.spec.ts +0 -73
- package/src/core/storage/storage.service.ts +0 -3
- package/src/core/streaming/stream-probe.service.ts +0 -221
- package/src/core/topology/topology-emitter.service.ts +0 -105
- package/src/launcher.ts +0 -314
- package/src/main.ts +0 -1245
- package/src/manual-boot.ts +0 -301
- package/tsconfig.build.json +0 -8
- package/tsconfig.json +0 -33
- package/vitest.config.ts +0 -26
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
import { ReplEngine } from '@camstack/core'
|
|
2
|
-
import type { IReplContextProvider } from '@camstack/core'
|
|
3
|
-
import { SystemMirror, type SystemMirrorApi, type DeviceProxy } from '@camstack/types'
|
|
4
|
-
import { AddonRegistryService } from '../addon/addon-registry.service'
|
|
5
|
-
import { EventBusService } from '../events/event-bus.service'
|
|
6
|
-
import { LoggingService } from '../logging/logging.service'
|
|
7
|
-
|
|
8
|
-
export class ReplEngineService extends ReplEngine {
|
|
9
|
-
/**
|
|
10
|
-
* Lazily-instantiated `SystemMirror` shared across REPL sessions.
|
|
11
|
-
* Holds a single warm-boot mirror that every `sm.getDeviceById(id)`
|
|
12
|
-
* lookup serves from. Init runs at first access (Promise cached so
|
|
13
|
-
* concurrent sessions don't double-fetch).
|
|
14
|
-
*/
|
|
15
|
-
private static systemMirror: SystemMirror | null = null
|
|
16
|
-
private static systemMirrorInit: Promise<SystemMirror> | null = null
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Build a `SystemMirrorApi` for in-process server use. The cap-method
|
|
20
|
-
* surface (deviceManager / deviceState queries) goes through the
|
|
21
|
-
* standard broker tRPC client — those resolve locally via
|
|
22
|
-
* `localProviderLink`. The `live.onEvent` channel is NOT a cap, so
|
|
23
|
-
* the broker can't route it; we synthesize the same shape over the
|
|
24
|
-
* local `EventBusService` so SystemMirror subscriptions work without
|
|
25
|
-
* crossing the network boundary or polling the broker for a
|
|
26
|
-
* non-existent `live` service.
|
|
27
|
-
*/
|
|
28
|
-
private static buildInProcessApi(
|
|
29
|
-
addonRegistry: AddonRegistryService,
|
|
30
|
-
eventBus: EventBusService,
|
|
31
|
-
): SystemMirrorApi {
|
|
32
|
-
const baseApi = addonRegistry.getBrokerApi() as unknown as SystemMirrorApi
|
|
33
|
-
return {
|
|
34
|
-
...baseApi,
|
|
35
|
-
live: {
|
|
36
|
-
onEvent: {
|
|
37
|
-
subscribe: (input, opts) => {
|
|
38
|
-
const off = eventBus.subscribe({ category: input.category }, (evt) => {
|
|
39
|
-
try {
|
|
40
|
-
opts.onData({ data: evt.data })
|
|
41
|
-
} catch (err) {
|
|
42
|
-
opts.onError?.(err)
|
|
43
|
-
}
|
|
44
|
-
})
|
|
45
|
-
return { unsubscribe: off }
|
|
46
|
-
},
|
|
47
|
-
},
|
|
48
|
-
},
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
private static getOrInitSystemMirror(
|
|
53
|
-
addonRegistry: AddonRegistryService,
|
|
54
|
-
eventBus: EventBusService,
|
|
55
|
-
): Promise<SystemMirror> {
|
|
56
|
-
if (this.systemMirror) return Promise.resolve(this.systemMirror)
|
|
57
|
-
if (!this.systemMirrorInit) {
|
|
58
|
-
const api = this.buildInProcessApi(addonRegistry, eventBus)
|
|
59
|
-
const sm = new SystemMirror(api)
|
|
60
|
-
this.systemMirrorInit = sm.init().then(() => {
|
|
61
|
-
this.systemMirror = sm
|
|
62
|
-
return sm
|
|
63
|
-
})
|
|
64
|
-
}
|
|
65
|
-
return this.systemMirrorInit
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
constructor(
|
|
69
|
-
addonRegistry: AddonRegistryService,
|
|
70
|
-
eventBus: EventBusService,
|
|
71
|
-
_loggingService: LoggingService,
|
|
72
|
-
) {
|
|
73
|
-
const contextProvider: IReplContextProvider = {
|
|
74
|
-
async getSystemSandbox() {
|
|
75
|
-
const integrationRegistry = addonRegistry.getIntegrationRegistry()
|
|
76
|
-
const deviceRegistry = addonRegistry.getDeviceRegistry()
|
|
77
|
-
// Warm-boot the SystemMirror so `sm.getDeviceById(id)` is sync
|
|
78
|
-
// for the first user expression.
|
|
79
|
-
const sm = await ReplEngineService.getOrInitSystemMirror(addonRegistry, eventBus)
|
|
80
|
-
return {
|
|
81
|
-
// ── New canonical API ───────────────────────────────────────
|
|
82
|
-
/**
|
|
83
|
-
* SystemMirror — the cap-driven, reactive view of every
|
|
84
|
-
* device. Sync `getDeviceById(id)`, typed `state.<cap>.value`
|
|
85
|
-
* reads, full method dispatch, query helpers.
|
|
86
|
-
*/
|
|
87
|
-
sm,
|
|
88
|
-
// ── Legacy (kept for backward-compat) ──────────────────────
|
|
89
|
-
addonRegistry,
|
|
90
|
-
eventBus,
|
|
91
|
-
integrationRegistry,
|
|
92
|
-
devices: () => deviceRegistry.getAll(),
|
|
93
|
-
integrations: async () => (await integrationRegistry?.listIntegrations()) ?? [],
|
|
94
|
-
addons: () => addonRegistry.listAddons(),
|
|
95
|
-
getDevice: (id: number) => deviceRegistry.getById(id),
|
|
96
|
-
getIntegration: async (id: string) =>
|
|
97
|
-
(await integrationRegistry?.getIntegration(id)) ?? null,
|
|
98
|
-
getSystemMirror: () => Promise.resolve(sm),
|
|
99
|
-
}
|
|
100
|
-
},
|
|
101
|
-
async getDeviceSandbox(deviceId: number) {
|
|
102
|
-
const deviceRegistry = addonRegistry.getDeviceRegistry()
|
|
103
|
-
const rawDevice = deviceRegistry.getById(deviceId)
|
|
104
|
-
const sm = await ReplEngineService.getOrInitSystemMirror(addonRegistry, eventBus)
|
|
105
|
-
// `device` is the typed DeviceProxy backed by the SystemMirror
|
|
106
|
-
// mirror — same shape as `sm.getDeviceById(deviceId)`. Sync
|
|
107
|
-
// state reads + cap-method dispatch via the wrapper chain.
|
|
108
|
-
const device: DeviceProxy | null = sm.getDeviceById(deviceId)
|
|
109
|
-
return {
|
|
110
|
-
/** SystemMirror — full cluster view. */
|
|
111
|
-
sm,
|
|
112
|
-
/**
|
|
113
|
-
* The current device as a DeviceProxy. Sync state reads
|
|
114
|
-
* (`device.state.battery.value`) + async cap methods
|
|
115
|
-
* (`await device.snapshot.getSnapshot({})`).
|
|
116
|
-
*/
|
|
117
|
-
device,
|
|
118
|
-
/** Numeric device id (same as URL). */
|
|
119
|
-
deviceId,
|
|
120
|
-
/** Device metadata (name, addonId, type, online, …). */
|
|
121
|
-
info: sm.getDeviceInfo(deviceId),
|
|
122
|
-
/** Raw IDevice instance — escape hatch for legacy access.
|
|
123
|
-
* Prefer `device` (DeviceProxy) for new code. */
|
|
124
|
-
rawDevice,
|
|
125
|
-
}
|
|
126
|
-
},
|
|
127
|
-
getProviderSandbox(addonId: string) {
|
|
128
|
-
const integrationRegistry = addonRegistry.getIntegrationRegistry()
|
|
129
|
-
const deviceRegistry = addonRegistry.getDeviceRegistry()
|
|
130
|
-
const devices = deviceRegistry.getAllForAddon(addonId)
|
|
131
|
-
return {
|
|
132
|
-
getIntegration: () =>
|
|
133
|
-
integrationRegistry?.getIntegration(addonId) ?? Promise.resolve(null),
|
|
134
|
-
devices,
|
|
135
|
-
}
|
|
136
|
-
},
|
|
137
|
-
getAddonSandbox(addonId: string) {
|
|
138
|
-
// REPL exposes addon metadata only — never the live in-process
|
|
139
|
-
// instance. Direct addon access only works for hub-local addons
|
|
140
|
-
// and breaks for remote-agent addons. To invoke addon behaviour
|
|
141
|
-
// from the REPL, use the cap router via tRPC instead.
|
|
142
|
-
const entry = addonRegistry.listAddons().find((e) => e.manifest.id === addonId)
|
|
143
|
-
return {
|
|
144
|
-
manifest: entry?.manifest,
|
|
145
|
-
declaration: entry?.declaration,
|
|
146
|
-
source: entry?.source,
|
|
147
|
-
installSource: entry?.installSource,
|
|
148
|
-
process: entry?.process,
|
|
149
|
-
}
|
|
150
|
-
},
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
super(contextProvider)
|
|
154
|
-
}
|
|
155
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
// server/backend/src/core/storage/fs-storage-backend.spec.ts
|
|
2
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
3
|
-
import { mkdirSync, rmSync } from 'node:fs'
|
|
4
|
-
import { join } from 'node:path'
|
|
5
|
-
import { tmpdir } from 'node:os'
|
|
6
|
-
import { FsStorageBackend } from './fs-storage-backend'
|
|
7
|
-
|
|
8
|
-
describe('FsStorageBackend', () => {
|
|
9
|
-
let tempDir: string
|
|
10
|
-
let backend: FsStorageBackend
|
|
11
|
-
|
|
12
|
-
beforeEach(() => {
|
|
13
|
-
tempDir = join(tmpdir(), `fs-backend-test-${Date.now()}`)
|
|
14
|
-
mkdirSync(tempDir, { recursive: true })
|
|
15
|
-
backend = new FsStorageBackend(tempDir)
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
afterEach(() => {
|
|
19
|
-
rmSync(tempDir, { recursive: true, force: true })
|
|
20
|
-
})
|
|
21
|
-
|
|
22
|
-
it('type is local', () => {
|
|
23
|
-
expect(backend.type).toBe('local')
|
|
24
|
-
})
|
|
25
|
-
|
|
26
|
-
it('basePath is resolved to absolute', () => {
|
|
27
|
-
const relative = new FsStorageBackend('./relative/path')
|
|
28
|
-
expect(relative.basePath).toContain('/')
|
|
29
|
-
expect(relative.basePath).not.toContain('./')
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
it('resolve joins subpath', () => {
|
|
33
|
-
expect(backend.resolve('models/yolov8n.onnx')).toBe(join(tempDir, 'models/yolov8n.onnx'))
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
it('resolve returns absolute paths as-is', () => {
|
|
37
|
-
expect(backend.resolve('/absolute/path')).toBe('/absolute/path')
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('isAvailable returns true for existing writable dir', () => {
|
|
41
|
-
expect(backend.isAvailable()).toBe(true)
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
it('isAvailable returns false when no existing ancestor is writable', () => {
|
|
45
|
-
// Nothing under /nonexistent exists, so no writable ancestor →
|
|
46
|
-
// the backend cannot be created when needed.
|
|
47
|
-
const missing = new FsStorageBackend('/nonexistent/path/xyz')
|
|
48
|
-
expect(missing.isAvailable()).toBe(false)
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
it('isAvailable returns true for a not-yet-existing dir whose parent is writable', () => {
|
|
52
|
-
// Lazy creation: the backend reports itself as available when the
|
|
53
|
-
// nearest existing ancestor (tempDir) is writable, because we can
|
|
54
|
-
// `mkdir -p` the missing segments on first write.
|
|
55
|
-
const notYet = new FsStorageBackend(join(tempDir, 'sub', 'dir'))
|
|
56
|
-
expect(notYet.isAvailable()).toBe(true)
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it('initialize is a no-op — directory is created lazily on first write', async () => {
|
|
60
|
-
const { existsSync } = await import('node:fs')
|
|
61
|
-
const newPath = join(tempDir, 'sub', 'dir')
|
|
62
|
-
const newBackend = new FsStorageBackend(newPath)
|
|
63
|
-
// The base dir doesn't exist yet, but the ancestor chain is
|
|
64
|
-
// writable — so the backend is available and initialize is a
|
|
65
|
-
// no-op (doesn't create the dir eagerly).
|
|
66
|
-
expect(newBackend.isAvailable()).toBe(true)
|
|
67
|
-
await newBackend.initialize()
|
|
68
|
-
expect(existsSync(newPath)).toBe(false)
|
|
69
|
-
})
|
|
70
|
-
})
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
// server/backend/src/core/storage/storage-location-manager.spec.ts
|
|
2
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
3
|
-
import { existsSync, mkdirSync, rmSync } from 'node:fs'
|
|
4
|
-
import { join } from 'node:path'
|
|
5
|
-
import { tmpdir } from 'node:os'
|
|
6
|
-
import { StorageLocationManager, type StorageLocationName } from './storage-location-manager'
|
|
7
|
-
|
|
8
|
-
describe('StorageLocationManager', () => {
|
|
9
|
-
let tempDir: string
|
|
10
|
-
let manager: StorageLocationManager
|
|
11
|
-
|
|
12
|
-
beforeEach(() => {
|
|
13
|
-
tempDir = join(tmpdir(), `slm-test-${Date.now()}`)
|
|
14
|
-
mkdirSync(tempDir, { recursive: true })
|
|
15
|
-
manager = new StorageLocationManager(tempDir)
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
afterEach(() => {
|
|
19
|
-
rmSync(tempDir, { recursive: true, force: true })
|
|
20
|
-
rmSync('/tmp/camstack-cache', { recursive: true, force: true })
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
it('initializeDefaults registers all 6 location backends', async () => {
|
|
24
|
-
await manager.initializeDefaults()
|
|
25
|
-
const names = manager.getLocationNames()
|
|
26
|
-
expect(names).toHaveLength(6)
|
|
27
|
-
expect(names).toContain('data')
|
|
28
|
-
expect(names).toContain('media')
|
|
29
|
-
expect(names).toContain('recordings')
|
|
30
|
-
expect(names).toContain('models')
|
|
31
|
-
expect(names).toContain('cache')
|
|
32
|
-
expect(names).toContain('logs')
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('initializeDefaults does NOT create the location directories on disk (lazy)', async () => {
|
|
36
|
-
await manager.initializeDefaults()
|
|
37
|
-
// None of the 6 location paths should exist — directories are
|
|
38
|
-
// created on first write by the filesystem storage provider, not
|
|
39
|
-
// eagerly at boot. This is the anti-bloat invariant: a fresh
|
|
40
|
-
// installation should not materialise empty `recordings/`,
|
|
41
|
-
// `media/`, `logs/` folders just because StorageLocationManager
|
|
42
|
-
// was initialized.
|
|
43
|
-
expect(existsSync(join(tempDir, 'db'))).toBe(false)
|
|
44
|
-
expect(existsSync(join(tempDir, 'media'))).toBe(false)
|
|
45
|
-
expect(existsSync(join(tempDir, 'recordings'))).toBe(false)
|
|
46
|
-
expect(existsSync(join(tempDir, 'models'))).toBe(false)
|
|
47
|
-
expect(existsSync(join(tempDir, 'logs'))).toBe(false)
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it('initializeDefaults reports all locations as available (parent dir writable)', async () => {
|
|
51
|
-
await manager.initializeDefaults()
|
|
52
|
-
const status = manager.getStatus()
|
|
53
|
-
// A backend is "available" as long as it can be created when
|
|
54
|
-
// needed (nearest existing ancestor is writable). Since `tempDir`
|
|
55
|
-
// exists and is writable, every location underneath it is
|
|
56
|
-
// available even though the location dir itself does not yet
|
|
57
|
-
// exist.
|
|
58
|
-
for (const entry of status) {
|
|
59
|
-
expect(entry.available).toBe(true)
|
|
60
|
-
}
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
it('getLocationNames returns all 6 location names', async () => {
|
|
64
|
-
await manager.initializeDefaults()
|
|
65
|
-
const names = manager.getLocationNames()
|
|
66
|
-
const expected: StorageLocationName[] = [
|
|
67
|
-
'data',
|
|
68
|
-
'media',
|
|
69
|
-
'recordings',
|
|
70
|
-
'models',
|
|
71
|
-
'cache',
|
|
72
|
-
'logs',
|
|
73
|
-
]
|
|
74
|
-
expect(names.toSorted()).toEqual(expected.toSorted())
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
it('resolve joins subpath correctly within a location', async () => {
|
|
78
|
-
await manager.initializeDefaults()
|
|
79
|
-
const resolved = manager.resolve('data', 'camstack.db')
|
|
80
|
-
expect(resolved).toBe(join(tempDir, 'db', 'camstack.db'))
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
it('resolve for cache uses /tmp/camstack-cache base', async () => {
|
|
84
|
-
await manager.initializeDefaults()
|
|
85
|
-
const resolved = manager.resolve('cache', 'thumbnails')
|
|
86
|
-
expect(resolved).toBe('/tmp/camstack-cache/thumbnails')
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
it('setLocationPath overrides a location with absolute path', async () => {
|
|
90
|
-
await manager.initializeDefaults()
|
|
91
|
-
const newPath = join(tempDir, 'custom-media')
|
|
92
|
-
await manager.setLocationPath('media', newPath)
|
|
93
|
-
const backend = manager.getBackend('media')
|
|
94
|
-
expect(backend.basePath).toBe(newPath)
|
|
95
|
-
// Still lazy — overriding the path doesn't eagerly create it.
|
|
96
|
-
expect(backend.isAvailable()).toBe(true)
|
|
97
|
-
expect(existsSync(newPath)).toBe(false)
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
it('setLocationPath overrides a location with relative path (resolved against dataPath)', async () => {
|
|
101
|
-
await manager.initializeDefaults()
|
|
102
|
-
await manager.setLocationPath('logs', 'custom-logs')
|
|
103
|
-
const backend = manager.getBackend('logs')
|
|
104
|
-
expect(backend.basePath).toBe(join(tempDir, 'custom-logs'))
|
|
105
|
-
expect(backend.isAvailable()).toBe(true)
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
it('getStatus reports all locations with name, available, path', async () => {
|
|
109
|
-
await manager.initializeDefaults()
|
|
110
|
-
const status = manager.getStatus()
|
|
111
|
-
expect(status).toHaveLength(6)
|
|
112
|
-
for (const entry of status) {
|
|
113
|
-
expect(entry).toHaveProperty('name')
|
|
114
|
-
expect(entry).toHaveProperty('available')
|
|
115
|
-
expect(entry).toHaveProperty('path')
|
|
116
|
-
expect(typeof entry.available).toBe('boolean')
|
|
117
|
-
}
|
|
118
|
-
})
|
|
119
|
-
|
|
120
|
-
it('missing location throws error', () => {
|
|
121
|
-
// not initialized
|
|
122
|
-
expect(() => manager.getBackend('data')).toThrow('Storage location "data" not initialized')
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
it('resolve on uninitialized location throws error', () => {
|
|
126
|
-
expect(() => manager.resolve('media', 'file.mp4')).toThrow(
|
|
127
|
-
'Storage location "media" not initialized',
|
|
128
|
-
)
|
|
129
|
-
})
|
|
130
|
-
})
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
-
import { StorageService } from './storage.service'
|
|
3
|
-
import type { ICoreStorageProvider as IStorageProvider, IStorageLocation } from '@camstack/core'
|
|
4
|
-
|
|
5
|
-
const createMockProvider = (): IStorageProvider => {
|
|
6
|
-
const mockLocation: IStorageLocation = { structured: undefined, files: undefined }
|
|
7
|
-
return {
|
|
8
|
-
initialize: vi.fn(),
|
|
9
|
-
shutdown: vi.fn(),
|
|
10
|
-
getLocation: vi.fn().mockReturnValue(mockLocation),
|
|
11
|
-
export: vi.fn(),
|
|
12
|
-
import: vi.fn(),
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
describe('StorageService', () => {
|
|
17
|
-
const createService = (): StorageService => {
|
|
18
|
-
return new StorageService()
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
it('delegates getLocation to active provider', () => {
|
|
22
|
-
const service = createService()
|
|
23
|
-
const provider = createMockProvider()
|
|
24
|
-
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
25
|
-
service.setProvider(provider)
|
|
26
|
-
|
|
27
|
-
const result = service.getLocation('data')
|
|
28
|
-
|
|
29
|
-
expect(provider.getLocation).toHaveBeenCalledWith('data')
|
|
30
|
-
expect(result).toBeDefined()
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
it('maps legacy location names to new names', () => {
|
|
34
|
-
const service = createService()
|
|
35
|
-
const provider = createMockProvider()
|
|
36
|
-
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
37
|
-
service.setProvider(provider)
|
|
38
|
-
|
|
39
|
-
// Legacy 'config' and 'events' both map to 'data'.
|
|
40
|
-
// StorageManager.getLocation accepts `StorageLocationName | string`,
|
|
41
|
-
// so string literals flow through without casts.
|
|
42
|
-
service.getLocation('config')
|
|
43
|
-
expect(provider.getLocation).toHaveBeenCalledWith('data')
|
|
44
|
-
|
|
45
|
-
service.getLocation('events')
|
|
46
|
-
expect(provider.getLocation).toHaveBeenNthCalledWith(2, 'data')
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
it('throws if no provider set', () => {
|
|
50
|
-
const service = createService()
|
|
51
|
-
|
|
52
|
-
expect(() => service.getLocation('data')).toThrow('No storage provider configured')
|
|
53
|
-
expect(() => service.getProvider()).toThrow('No storage provider configured')
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('setProvider changes the active provider', () => {
|
|
57
|
-
const service = createService()
|
|
58
|
-
const provider1 = createMockProvider()
|
|
59
|
-
const provider2 = createMockProvider()
|
|
60
|
-
|
|
61
|
-
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
62
|
-
service.setProvider(provider1)
|
|
63
|
-
expect(service.getProvider()).toBe(provider1)
|
|
64
|
-
|
|
65
|
-
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
|
66
|
-
service.setProvider(provider2)
|
|
67
|
-
expect(service.getProvider()).toBe(provider2)
|
|
68
|
-
|
|
69
|
-
service.getLocation('data')
|
|
70
|
-
expect(provider2.getLocation).toHaveBeenCalledWith('data')
|
|
71
|
-
expect(provider1.getLocation).not.toHaveBeenCalled()
|
|
72
|
-
})
|
|
73
|
-
})
|
|
@@ -1,221 +0,0 @@
|
|
|
1
|
-
import { execFile } from 'child_process'
|
|
2
|
-
import { promisify } from 'util'
|
|
3
|
-
|
|
4
|
-
import { LoggingService } from '../logging/logging.service'
|
|
5
|
-
import type { StreamMetadata, StreamAudioMetadata } from '@camstack/types'
|
|
6
|
-
import type { IScopedLogger } from '@camstack/types'
|
|
7
|
-
import { errMsg } from '@camstack/types'
|
|
8
|
-
|
|
9
|
-
const execFileAsync = promisify(execFile)
|
|
10
|
-
|
|
11
|
-
const CACHE_TTL_MS = 3_600_000 // 1 hour
|
|
12
|
-
const PROBE_TIMEOUT_MS = 5_000
|
|
13
|
-
|
|
14
|
-
/** Codec aliases normalised to canonical names. */
|
|
15
|
-
const CODEC_ALIASES: Readonly<Record<string, string>> = {
|
|
16
|
-
hevc: 'h265',
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
interface CacheEntry {
|
|
20
|
-
readonly metadata: StreamMetadata
|
|
21
|
-
readonly timestamp: number
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export class StreamProbeService {
|
|
25
|
-
private readonly logger: IScopedLogger
|
|
26
|
-
private readonly cache = new Map<string, CacheEntry>()
|
|
27
|
-
|
|
28
|
-
constructor(loggingService: LoggingService) {
|
|
29
|
-
this.logger = loggingService.createLogger('StreamProbeService')
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
// Public API
|
|
34
|
-
// ---------------------------------------------------------------------------
|
|
35
|
-
|
|
36
|
-
/** Probe a stream URL and return its metadata (cached for 1 hour). */
|
|
37
|
-
async probe(url: string, options?: { force?: boolean }): Promise<StreamMetadata> {
|
|
38
|
-
const force = options?.force ?? false
|
|
39
|
-
|
|
40
|
-
if (!force) {
|
|
41
|
-
const cached = this.cache.get(url)
|
|
42
|
-
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
|
43
|
-
return cached.metadata
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const metadata = await this.runProbe(url)
|
|
48
|
-
this.cache.set(url, { metadata, timestamp: Date.now() })
|
|
49
|
-
return metadata
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Generic field probe: given a field key and value, decides how to probe.
|
|
54
|
-
* Stream fields (stream_*) → ffprobe, other URLs → HTTP HEAD check.
|
|
55
|
-
*/
|
|
56
|
-
async probeField(
|
|
57
|
-
key: string,
|
|
58
|
-
value: unknown,
|
|
59
|
-
): Promise<{ status: 'ok' | 'error'; labels?: string[]; error?: string }> {
|
|
60
|
-
const url = String(value ?? '').trim()
|
|
61
|
-
if (!url) {
|
|
62
|
-
return { status: 'error', error: 'No URL provided' }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Stream fields: use ffprobe
|
|
66
|
-
if (key.startsWith('stream')) {
|
|
67
|
-
const meta = await this.probe(url, { force: true })
|
|
68
|
-
const labels: string[] = []
|
|
69
|
-
if (meta.width && meta.height) labels.push(`${meta.width}\u00d7${meta.height}`)
|
|
70
|
-
if (meta.codec) {
|
|
71
|
-
const codecDisplay: Record<string, string> = { h265: 'H.265', h264: 'H.264', hevc: 'H.265' }
|
|
72
|
-
labels.push(codecDisplay[meta.codec] ?? meta.codec.toUpperCase())
|
|
73
|
-
}
|
|
74
|
-
if (meta.fps) labels.push(`${Math.round(meta.fps)}fps`)
|
|
75
|
-
if (meta.audio?.codec) {
|
|
76
|
-
const audioCodec = meta.audio.codec.toUpperCase()
|
|
77
|
-
const audioBits: string[] = [audioCodec]
|
|
78
|
-
if (meta.audio.sampleRate) audioBits.push(`${Math.round(meta.audio.sampleRate / 1000)}kHz`)
|
|
79
|
-
if (meta.audio.channels === 1) audioBits.push('mono')
|
|
80
|
-
else if (meta.audio.channels === 2) audioBits.push('stereo')
|
|
81
|
-
else if (meta.audio.channels) audioBits.push(`${meta.audio.channels}ch`)
|
|
82
|
-
labels.push(`audio: ${audioBits.join(' ')}`)
|
|
83
|
-
}
|
|
84
|
-
if (labels.length === 0) {
|
|
85
|
-
return { status: 'error', error: 'No video streams found at URL' }
|
|
86
|
-
}
|
|
87
|
-
return { status: 'ok', labels }
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Other URL fields (snapshot, etc.): GET with early abort to check reachability + content-type
|
|
91
|
-
try {
|
|
92
|
-
const controller = new AbortController()
|
|
93
|
-
const timeout = AbortSignal.timeout(5000)
|
|
94
|
-
timeout.addEventListener('abort', () => controller.abort(timeout.reason))
|
|
95
|
-
const response = await fetch(url, { method: 'GET', signal: controller.signal })
|
|
96
|
-
if (!response.ok) {
|
|
97
|
-
controller.abort()
|
|
98
|
-
return { status: 'error', error: `HTTP ${response.status} ${response.statusText}` }
|
|
99
|
-
}
|
|
100
|
-
const contentType = response.headers.get('content-type') ?? ''
|
|
101
|
-
controller.abort() // Don't download the full body
|
|
102
|
-
const labels: string[] = ['Reachable']
|
|
103
|
-
if (contentType.startsWith('image/')) {
|
|
104
|
-
labels.push(contentType.replace('image/', '').toUpperCase())
|
|
105
|
-
}
|
|
106
|
-
return { status: 'ok', labels }
|
|
107
|
-
} catch (err) {
|
|
108
|
-
if (err instanceof Error && err.name === 'AbortError') {
|
|
109
|
-
// We aborted intentionally after reading headers — that's success
|
|
110
|
-
return { status: 'ok', labels: ['Reachable'] }
|
|
111
|
-
}
|
|
112
|
-
const msg = errMsg(err)
|
|
113
|
-
return { status: 'error', error: `Unreachable: ${msg}` }
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/** Clear cached metadata for a single URL or all URLs. */
|
|
118
|
-
clearCache(url?: string): void {
|
|
119
|
-
if (url) {
|
|
120
|
-
this.cache.delete(url)
|
|
121
|
-
} else {
|
|
122
|
-
this.cache.clear()
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// ---------------------------------------------------------------------------
|
|
127
|
-
// Internal
|
|
128
|
-
// ---------------------------------------------------------------------------
|
|
129
|
-
|
|
130
|
-
private async runProbe(url: string): Promise<StreamMetadata> {
|
|
131
|
-
try {
|
|
132
|
-
// Query first video + first audio stream in one ffprobe pass.
|
|
133
|
-
// `-show_streams` returns every stream in the container; we filter
|
|
134
|
-
// by codec_type when parsing. Fields cover both kinds.
|
|
135
|
-
const { stdout } = await execFileAsync(
|
|
136
|
-
'ffprobe',
|
|
137
|
-
[
|
|
138
|
-
'-v',
|
|
139
|
-
'error',
|
|
140
|
-
'-rtsp_transport',
|
|
141
|
-
'tcp',
|
|
142
|
-
'-timeout',
|
|
143
|
-
'5000000',
|
|
144
|
-
'-show_entries',
|
|
145
|
-
'stream=codec_type,codec_name,profile,width,height,r_frame_rate,bit_rate,sample_rate,channels',
|
|
146
|
-
'-of',
|
|
147
|
-
'json',
|
|
148
|
-
url,
|
|
149
|
-
],
|
|
150
|
-
{ timeout: PROBE_TIMEOUT_MS },
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
return this.parseOutput(stdout)
|
|
154
|
-
} catch (err) {
|
|
155
|
-
this.logger.error('ffprobe failed', { meta: { url, error: String(err) } })
|
|
156
|
-
return {}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
private parseOutput(stdout: string): StreamMetadata {
|
|
161
|
-
const parsed: unknown = JSON.parse(stdout)
|
|
162
|
-
const streams = (parsed as { streams?: unknown[] }).streams
|
|
163
|
-
if (!Array.isArray(streams) || streams.length === 0) {
|
|
164
|
-
return {}
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const meta: StreamMetadata = {}
|
|
168
|
-
|
|
169
|
-
for (const raw of streams) {
|
|
170
|
-
const stream = raw as Record<string, unknown>
|
|
171
|
-
const codecType = typeof stream.codec_type === 'string' ? stream.codec_type : undefined
|
|
172
|
-
|
|
173
|
-
if (codecType === 'video' && meta.codec === undefined) {
|
|
174
|
-
const rawCodec = typeof stream.codec_name === 'string' ? stream.codec_name : undefined
|
|
175
|
-
meta.codec = rawCodec ? (CODEC_ALIASES[rawCodec] ?? rawCodec) : undefined
|
|
176
|
-
if (typeof stream.width === 'number') meta.width = stream.width
|
|
177
|
-
if (typeof stream.height === 'number') meta.height = stream.height
|
|
178
|
-
const fps = this.parseFps(stream.r_frame_rate)
|
|
179
|
-
if (fps !== undefined) meta.fps = fps
|
|
180
|
-
const bitrateKbps = this.parseBitrateKbps(stream.bit_rate)
|
|
181
|
-
if (bitrateKbps !== undefined) meta.bitrateKbps = bitrateKbps
|
|
182
|
-
} else if (codecType === 'audio' && meta.audio === undefined) {
|
|
183
|
-
const audio: StreamAudioMetadata = {}
|
|
184
|
-
if (typeof stream.codec_name === 'string') audio.codec = stream.codec_name
|
|
185
|
-
if (typeof stream.profile === 'string') audio.profile = stream.profile
|
|
186
|
-
const sampleRate = this.parseIntField(stream.sample_rate)
|
|
187
|
-
if (sampleRate !== undefined) audio.sampleRate = sampleRate
|
|
188
|
-
if (typeof stream.channels === 'number') audio.channels = stream.channels
|
|
189
|
-
const audioBitrateKbps = this.parseBitrateKbps(stream.bit_rate)
|
|
190
|
-
if (audioBitrateKbps !== undefined) audio.bitrateKbps = audioBitrateKbps
|
|
191
|
-
meta.audio = audio
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return meta
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
private parseIntField(value: unknown): number | undefined {
|
|
199
|
-
const n = typeof value === 'string' ? Number(value) : typeof value === 'number' ? value : NaN
|
|
200
|
-
if (!Number.isFinite(n) || n <= 0) return undefined
|
|
201
|
-
return Math.round(n)
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
/** Parse fractional frame rate string like "25/1" → 25. */
|
|
205
|
-
private parseFps(value: unknown): number | undefined {
|
|
206
|
-
if (typeof value !== 'string') return undefined
|
|
207
|
-
const parts = value.split('/')
|
|
208
|
-
if (parts.length !== 2) return undefined
|
|
209
|
-
const num = Number(parts[0])
|
|
210
|
-
const den = Number(parts[1])
|
|
211
|
-
if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return undefined
|
|
212
|
-
return Math.round((num / den) * 100) / 100
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** Parse bit_rate string (bps) → kbps. */
|
|
216
|
-
private parseBitrateKbps(value: unknown): number | undefined {
|
|
217
|
-
const n = typeof value === 'string' ? Number(value) : typeof value === 'number' ? value : NaN
|
|
218
|
-
if (!Number.isFinite(n) || n <= 0) return undefined
|
|
219
|
-
return Math.round(n / 1000)
|
|
220
|
-
}
|
|
221
|
-
}
|