@memberjunction/server 5.42.0 → 5.44.0
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/agentSessions/SessionJanitor.d.ts.map +1 -1
- package/dist/agentSessions/SessionJanitor.js +5 -1
- package/dist/agentSessions/SessionJanitor.js.map +1 -1
- package/dist/agentSessions/SessionManager.d.ts +15 -2
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +53 -11
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/agentSessions/index.d.ts.map +1 -1
- package/dist/agentSessions/index.js +3 -1
- package/dist/agentSessions/index.js.map +1 -1
- package/dist/generated/generated.d.ts +1180 -51
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +35564 -29262
- package/dist/generated/generated.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -1
- package/dist/index.js.map +1 -1
- package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +20 -1
- package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -1
- package/dist/resolvers/ExecuteRemoteOperationResolver.js +61 -5
- package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -1
- package/dist/resolvers/FileResolver.d.ts +74 -0
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +211 -2
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +98 -29
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +6 -4
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +29 -14
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts +17 -1
- package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeBridgeResolver.js +78 -7
- package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +81 -2
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +293 -17
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +17 -2
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +12 -3
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +37 -15
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunTemplateResolver.d.ts +8 -0
- package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTemplateResolver.js +8 -0
- package/dist/resolvers/RunTemplateResolver.js.map +1 -1
- package/dist/resolvers/meetingRecordingRegistration.d.ts +124 -0
- package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -0
- package/dist/resolvers/meetingRecordingRegistration.js +311 -0
- package/dist/resolvers/meetingRecordingRegistration.js.map +1 -0
- package/dist/resolvers/peaksSidecar.d.ts +30 -0
- package/dist/resolvers/peaksSidecar.d.ts.map +1 -0
- package/dist/resolvers/peaksSidecar.js +51 -0
- package/dist/resolvers/peaksSidecar.js.map +1 -0
- package/dist/rest/MediaAccessKeys.d.ts +68 -0
- package/dist/rest/MediaAccessKeys.d.ts.map +1 -0
- package/dist/rest/MediaAccessKeys.js +96 -0
- package/dist/rest/MediaAccessKeys.js.map +1 -0
- package/dist/rest/MediaStreamHandler.d.ts +26 -0
- package/dist/rest/MediaStreamHandler.d.ts.map +1 -0
- package/dist/rest/MediaStreamHandler.js +192 -0
- package/dist/rest/MediaStreamHandler.js.map +1 -0
- package/dist/rest/mediaRange.d.ts +41 -0
- package/dist/rest/mediaRange.d.ts.map +1 -0
- package/dist/rest/mediaRange.js +60 -0
- package/dist/rest/mediaRange.js.map +1 -0
- package/package.json +83 -82
- package/src/__tests__/FileResolverPeaks.test.ts +64 -0
- package/src/__tests__/MediaAccessKeys.test.ts +68 -0
- package/src/__tests__/MediaStreamHandler.test.ts +91 -0
- package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -1
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +69 -0
- package/src/__tests__/RealtimeCoAgentAppAwareness.integration.test.ts +191 -0
- package/src/__tests__/RemoteBrowserSnapshot.test.ts +123 -0
- package/src/__tests__/SessionManager.test.ts +2 -0
- package/src/__tests__/meetingRecordingRegistration.test.ts +248 -0
- package/src/agentSessions/SessionJanitor.ts +15 -1
- package/src/agentSessions/SessionManager.ts +62 -10
- package/src/agentSessions/index.ts +3 -1
- package/src/generated/generated.ts +17659 -13322
- package/src/index.ts +15 -1
- package/src/resolvers/ExecuteRemoteOperationResolver.ts +60 -4
- package/src/resolvers/FileResolver.ts +199 -1
- package/src/resolvers/IntegrationDiscoveryResolver.ts +108 -30
- package/src/resolvers/QueryResolver.ts +33 -19
- package/src/resolvers/RealtimeBridgeResolver.ts +82 -7
- package/src/resolvers/RealtimeClientSessionResolver.ts +326 -9
- package/src/resolvers/RemoteBrowserActionResolver.ts +18 -2
- package/src/resolvers/RunAIAgentResolver.ts +44 -11
- package/src/resolvers/RunTemplateResolver.ts +8 -0
- package/src/resolvers/meetingRecordingRegistration.ts +432 -0
- package/src/resolvers/peaksSidecar.ts +52 -0
- package/src/rest/MediaAccessKeys.ts +121 -0
- package/src/rest/MediaStreamHandler.ts +220 -0
- package/src/rest/mediaRange.ts +76 -0
|
@@ -376,7 +376,12 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
376
376
|
conversationId?: string,
|
|
377
377
|
/** Optional external ref the caller can read to observe the agent run as it becomes available
|
|
378
378
|
* (used by the fire-and-forget liveness pulse to enrich heartbeats with the run id/status). */
|
|
379
|
-
runRef?: { current: MJAIAgentRunEntityExtended | null }
|
|
379
|
+
runRef?: { current: MJAIAgentRunEntityExtended | null },
|
|
380
|
+
/** Per-request Plan Mode toggle — threaded into ExecuteAgentParams.planMode (root-agent HITL gate). */
|
|
381
|
+
planMode?: boolean,
|
|
382
|
+
/** Skill IDs the user requested (via `/skill-name`) — threaded into ExecuteAgentParams.requestedSkillIDs.
|
|
383
|
+
* The framework intersects them with the agent's accepted skills AND the user's Run permission. */
|
|
384
|
+
requestedSkillIDs?: string[]
|
|
380
385
|
): Promise<AIAgentRunResult> {
|
|
381
386
|
const startTime = Date.now();
|
|
382
387
|
|
|
@@ -428,6 +433,8 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
428
433
|
lastRunId: lastRunId,
|
|
429
434
|
autoPopulateLastRunPayload: autoPopulateLastRunPayload,
|
|
430
435
|
configurationId: configurationId,
|
|
436
|
+
planMode: planMode,
|
|
437
|
+
requestedSkillIDs: requestedSkillIDs,
|
|
431
438
|
data: parsedData,
|
|
432
439
|
context: {
|
|
433
440
|
dataSource: dataSource
|
|
@@ -614,7 +621,12 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
614
621
|
@Arg('createNotification', { nullable: true }) createNotification?: boolean,
|
|
615
622
|
@Arg('sourceArtifactId', { nullable: true }) sourceArtifactId?: string,
|
|
616
623
|
@Arg('sourceArtifactVersionId', { nullable: true }) sourceArtifactVersionId?: string,
|
|
617
|
-
@Arg('fireAndForget', { nullable: true }) fireAndForget?: boolean
|
|
624
|
+
@Arg('fireAndForget', { nullable: true }) fireAndForget?: boolean,
|
|
625
|
+
/** Per-request Plan Mode toggle — symmetric with RunAIAgentFromConversationDetail. */
|
|
626
|
+
@Arg('planMode', { nullable: true }) planMode?: boolean,
|
|
627
|
+
/** Skill IDs the user requested — symmetric with RunAIAgentFromConversationDetail. Intersected
|
|
628
|
+
* server-side with the agent's accepted skills AND the user's Run permission. */
|
|
629
|
+
@Arg('requestedSkillIDs', () => [String], { nullable: true }) requestedSkillIDs?: string[]
|
|
618
630
|
): Promise<AIAgentRunResult> {
|
|
619
631
|
// Check API key scope authorization for agent execution
|
|
620
632
|
await this.CheckAPIKeyScopeAuthorization('agent:execute', agentId, userPayload);
|
|
@@ -628,7 +640,7 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
628
640
|
p, dataSource, agentId, userPayload, messagesJson, sessionId, pubSub,
|
|
629
641
|
data, payload, lastRunId, autoPopulateLastRunPayload, configurationId,
|
|
630
642
|
conversationDetailId, createArtifacts || false, createNotification || false,
|
|
631
|
-
sourceArtifactId, sourceArtifactVersionId
|
|
643
|
+
sourceArtifactId, sourceArtifactVersionId, undefined /*conversationId*/, planMode, requestedSkillIDs
|
|
632
644
|
);
|
|
633
645
|
|
|
634
646
|
LogStatus(`🔥 Fire-and-forget: Agent ${agentId} execution started in background for session ${sessionId}`);
|
|
@@ -658,7 +670,11 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
658
670
|
createArtifacts || false,
|
|
659
671
|
createNotification || false,
|
|
660
672
|
sourceArtifactId,
|
|
661
|
-
sourceArtifactVersionId
|
|
673
|
+
sourceArtifactVersionId,
|
|
674
|
+
undefined, // conversationId (not pre-resolved on this path)
|
|
675
|
+
undefined, // runRef
|
|
676
|
+
planMode,
|
|
677
|
+
requestedSkillIDs
|
|
662
678
|
);
|
|
663
679
|
}
|
|
664
680
|
|
|
@@ -684,7 +700,11 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
684
700
|
@Arg('createArtifacts', { nullable: true }) createArtifacts?: boolean,
|
|
685
701
|
@Arg('createNotification', { nullable: true }) createNotification?: boolean,
|
|
686
702
|
@Arg('sourceArtifactId', { nullable: true }) sourceArtifactId?: string,
|
|
687
|
-
@Arg('sourceArtifactVersionId', { nullable: true }) sourceArtifactVersionId?: string
|
|
703
|
+
@Arg('sourceArtifactVersionId', { nullable: true }) sourceArtifactVersionId?: string,
|
|
704
|
+
/** Per-request Plan Mode toggle — symmetric with the other run mutations. */
|
|
705
|
+
@Arg('planMode', { nullable: true }) planMode?: boolean,
|
|
706
|
+
/** User-requested skill IDs — symmetric with the other run mutations. */
|
|
707
|
+
@Arg('requestedSkillIDs', () => [String], { nullable: true }) requestedSkillIDs?: string[]
|
|
688
708
|
): Promise<AIAgentRunResult> {
|
|
689
709
|
const p = GetReadWriteProvider(providers);
|
|
690
710
|
return this.executeAIAgent(
|
|
@@ -705,7 +725,11 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
705
725
|
createArtifacts || false,
|
|
706
726
|
createNotification || false,
|
|
707
727
|
sourceArtifactId,
|
|
708
|
-
sourceArtifactVersionId
|
|
728
|
+
sourceArtifactVersionId,
|
|
729
|
+
undefined, // conversationId (not pre-resolved on this path)
|
|
730
|
+
undefined, // runRef
|
|
731
|
+
planMode,
|
|
732
|
+
requestedSkillIDs
|
|
709
733
|
);
|
|
710
734
|
}
|
|
711
735
|
|
|
@@ -932,7 +956,9 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
932
956
|
@Arg('createNotification', { nullable: true }) createNotification?: boolean,
|
|
933
957
|
@Arg('sourceArtifactId', { nullable: true }) sourceArtifactId?: string,
|
|
934
958
|
@Arg('sourceArtifactVersionId', { nullable: true }) sourceArtifactVersionId?: string,
|
|
935
|
-
@Arg('fireAndForget', { nullable: true }) fireAndForget?: boolean
|
|
959
|
+
@Arg('fireAndForget', { nullable: true }) fireAndForget?: boolean,
|
|
960
|
+
@Arg('planMode', { nullable: true }) planMode?: boolean,
|
|
961
|
+
@Arg('requestedSkillIDs', () => [String], { nullable: true }) requestedSkillIDs?: string[]
|
|
936
962
|
): Promise<AIAgentRunResult> {
|
|
937
963
|
// Check API key scope authorization for agent execution
|
|
938
964
|
await this.CheckAPIKeyScopeAuthorization('agent:execute', agentId, userPayload);
|
|
@@ -981,7 +1007,7 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
981
1007
|
p, dataSource, agentId, userPayload, messagesJson, sessionId, pubSub,
|
|
982
1008
|
data, payload, lastRunId, autoPopulateLastRunPayload, configurationId,
|
|
983
1009
|
conversationDetailId, createArtifacts || false, createNotification || false,
|
|
984
|
-
sourceArtifactId, sourceArtifactVersionId, conversationId
|
|
1010
|
+
sourceArtifactId, sourceArtifactVersionId, conversationId, planMode, requestedSkillIDs
|
|
985
1011
|
);
|
|
986
1012
|
|
|
987
1013
|
LogStatus(`🔥 Fire-and-forget: Agent ${agentId} execution started in background for session ${sessionId}`);
|
|
@@ -1012,7 +1038,10 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
1012
1038
|
createNotification || false,
|
|
1013
1039
|
sourceArtifactId,
|
|
1014
1040
|
sourceArtifactVersionId,
|
|
1015
|
-
conversationId // LATENCY OPT #2: pass pre-resolved conversationId
|
|
1041
|
+
conversationId, // LATENCY OPT #2: pass pre-resolved conversationId
|
|
1042
|
+
undefined, // runRef
|
|
1043
|
+
planMode,
|
|
1044
|
+
requestedSkillIDs
|
|
1016
1045
|
);
|
|
1017
1046
|
} catch (error) {
|
|
1018
1047
|
const errorMessage = (error as Error).message || 'Unknown error loading conversation history';
|
|
@@ -1227,7 +1256,11 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
1227
1256
|
sourceArtifactId?: string,
|
|
1228
1257
|
sourceArtifactVersionId?: string,
|
|
1229
1258
|
/** LATENCY OPT #2: Pre-resolved conversationId avoids redundant DB load in AgentRunner */
|
|
1230
|
-
conversationId?: string
|
|
1259
|
+
conversationId?: string,
|
|
1260
|
+
/** Per-request Plan Mode toggle — threaded through to ExecuteAgentParams.planMode. */
|
|
1261
|
+
planMode?: boolean,
|
|
1262
|
+
/** Skill IDs the user requested — threaded through to ExecuteAgentParams.requestedSkillIDs. */
|
|
1263
|
+
requestedSkillIDs?: string[]
|
|
1231
1264
|
): void {
|
|
1232
1265
|
// Ref the liveness pulse reads to enrich heartbeats once the run is created.
|
|
1233
1266
|
const runRef: { current: MJAIAgentRunEntityExtended | null } = { current: null };
|
|
@@ -1245,7 +1278,7 @@ export class RunAIAgentResolver extends ResolverBase {
|
|
|
1245
1278
|
p, dataSource, agentId, userPayload, messagesJson, sessionId, pubSub,
|
|
1246
1279
|
data, payload, undefined, lastRunId, autoPopulateLastRunPayload,
|
|
1247
1280
|
configurationId, conversationDetailId, createArtifacts, createNotification,
|
|
1248
|
-
sourceArtifactId, sourceArtifactVersionId, conversationId, runRef
|
|
1281
|
+
sourceArtifactId, sourceArtifactVersionId, conversationId, runRef, planMode, requestedSkillIDs
|
|
1249
1282
|
).catch((error: unknown) => {
|
|
1250
1283
|
// Background execution failed unexpectedly (executeAIAgent has its own try-catch,
|
|
1251
1284
|
// so this would only fire for truly unexpected errors).
|
|
@@ -21,8 +21,16 @@ export class TemplateRunResult {
|
|
|
21
21
|
executionTimeMs?: number;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* @deprecated Prefer the `Template.Run` Remote Operation (`@memberjunction/templates-base-types`
|
|
26
|
+
* `TemplateRunOperation`) — one typed call site that routes over the generic `ExecuteRemoteOperation`
|
|
27
|
+
* transport on the client and in-process on the server. This bespoke `RunTemplate` mutation is retained
|
|
28
|
+
* only for backward compatibility with existing external API consumers and may be removed in a future
|
|
29
|
+
* major version.
|
|
30
|
+
*/
|
|
24
31
|
@Resolver()
|
|
25
32
|
export class RunTemplateResolver extends ResolverBase {
|
|
33
|
+
/** @deprecated Use the `Template.Run` Remote Operation instead. Retained for backcompat. */
|
|
26
34
|
@Mutation(() => TemplateRunResult)
|
|
27
35
|
async RunTemplate(
|
|
28
36
|
@Arg('templateId') templateId: string,
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Meeting-Room recording registration — the server-side path that closes the LiveKit
|
|
3
|
+
* meeting-room recording loop. After a room's composite egress completes, this module:
|
|
4
|
+
*
|
|
5
|
+
* 1. Resolves the **Meeting-Room Conversation** for the room (prefer by `EgressID`, fall back to the
|
|
6
|
+
* room name → `ExternalID` + `Type='Meeting Room'`, else create one via the sink's pattern).
|
|
7
|
+
* 2. Resolves the **meeting-recording storage account** — a configured `MJStorage` account that points
|
|
8
|
+
* at the same sink LiveKit egress wrote the MP4 to, so MJ can read/stream it.
|
|
9
|
+
* 3. Creates the `MJ: Files` row for the recording (v1 points DIRECTLY at the egress output — no byte
|
|
10
|
+
* copy — so playback streams straight from the sink). An OPTIONAL copy-to-canonical path (OFF by
|
|
11
|
+
* default, behind config) reads the bytes and re-uploads into a separate canonical provider.
|
|
12
|
+
* 4. Stamps `Conversation.RecordingFileID` (+ `EgressID`) and saves.
|
|
13
|
+
*
|
|
14
|
+
* Lives in `@memberjunction/server` (the one package that depends on the egress service, MJStorage, and
|
|
15
|
+
* core-entities). It is invoked from `RealtimeBridgeResolver.StopLiveKitRecording`. Everything is
|
|
16
|
+
* best-effort: any failure returns a non-throwing failure result and logs — a recording that fails to
|
|
17
|
+
* register never crashes the stop-recording mutation.
|
|
18
|
+
*
|
|
19
|
+
* ## Required configuration
|
|
20
|
+
*
|
|
21
|
+
* The egress sink and the MJStorage account must point at the **same** bucket/container so MJ can read the
|
|
22
|
+
* file LiveKit wrote. Configure the **provider** whose accounts target that sink via either:
|
|
23
|
+
*
|
|
24
|
+
* - env: `MJ_MEETING_RECORDING_STORAGE_PROVIDER=<MJ: File Storage Providers ID>`
|
|
25
|
+
* - config (`mj.config.cjs`): `meetingRecordingStorageProviderID: '<provider id>'`
|
|
26
|
+
*
|
|
27
|
+
* When unset, registration returns a clear, non-throwing failure explaining the config is required (the
|
|
28
|
+
* recording still stopped; it just isn't registered as a `MJ: Files` row).
|
|
29
|
+
*
|
|
30
|
+
* ### Optional copy-to-canonical ("copy into Box")
|
|
31
|
+
*
|
|
32
|
+
* To copy the MP4 OUT of the egress sink into a separate canonical provider (e.g. Box) instead of pointing
|
|
33
|
+
* the Files row at the sink, set a DIFFERENT provider via:
|
|
34
|
+
*
|
|
35
|
+
* - env: `MJ_MEETING_RECORDING_CANONICAL_STORAGE_PROVIDER=<provider id>`
|
|
36
|
+
* - config: `meetingRecordingCanonicalStorageProviderID: '<provider id>'`
|
|
37
|
+
*
|
|
38
|
+
* When set and different from the sink provider, the bytes are read from the sink and uploaded into the
|
|
39
|
+
* canonical provider, and the Files row points there. OFF by default.
|
|
40
|
+
*
|
|
41
|
+
* @module @memberjunction/server
|
|
42
|
+
* @author MemberJunction.com
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { IMetadataProvider, UserInfo, RunView, LogError, LogStatus } from '@memberjunction/core';
|
|
46
|
+
import { MJConversationEntity, MJFileEntity } from '@memberjunction/core-entities';
|
|
47
|
+
import { FileStorageEngine } from '@memberjunction/storage';
|
|
48
|
+
|
|
49
|
+
const CONVERSATION_ENTITY = 'MJ: Conversations';
|
|
50
|
+
const FILES_ENTITY = 'MJ: Files';
|
|
51
|
+
const MEETING_ROOM_CONVERSATION_TYPE = 'Meeting Room';
|
|
52
|
+
|
|
53
|
+
/** The egress output a completed recording produced — the subset of `RecordingInfo` registration needs. */
|
|
54
|
+
export interface MeetingRecordingEgressResult {
|
|
55
|
+
/** The LiveKit egress session id (correlates the recording back to the conversation). */
|
|
56
|
+
EgressID: string;
|
|
57
|
+
/** The room the recording belongs to (the LiveKit room name = the Conversation's `ExternalID`). */
|
|
58
|
+
RoomName: string;
|
|
59
|
+
/** The output MP4's path/key in the egress storage sink. Required to register; `undefined` if egress is still in progress. */
|
|
60
|
+
OutputLocation?: string;
|
|
61
|
+
/** The output file size in bytes, when the egress reported it. */
|
|
62
|
+
OutputSizeBytes?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The outcome of registering a meeting recording. */
|
|
66
|
+
export interface MeetingRecordingRegistrationResult {
|
|
67
|
+
/** Whether the recording was registered (Files row created + Conversation stamped). */
|
|
68
|
+
Success: boolean;
|
|
69
|
+
/** A clear, human-readable explanation when {@link Success} is false (config missing, no output, save failed). */
|
|
70
|
+
ErrorMessage?: string;
|
|
71
|
+
/** The newly created `MJ: Files` row id, when {@link Success} is true. */
|
|
72
|
+
RecordingFileID?: string;
|
|
73
|
+
/** The resolved Meeting-Room Conversation id, when one was resolved/created. */
|
|
74
|
+
ConversationID?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Config inputs for registration — env vars are read here; a host may override via the optional params. */
|
|
78
|
+
export interface MeetingRecordingRegistrationConfig {
|
|
79
|
+
/**
|
|
80
|
+
* The `MJ: File Storage Providers` id whose accounts target the egress sink (where LiveKit wrote the
|
|
81
|
+
* MP4). Falls back to `process.env.MJ_MEETING_RECORDING_STORAGE_PROVIDER`.
|
|
82
|
+
*/
|
|
83
|
+
sinkStorageProviderID?: string;
|
|
84
|
+
/**
|
|
85
|
+
* OPTIONAL canonical provider id — when set AND different from the sink, the MP4 is copied into it and
|
|
86
|
+
* the Files row points there (the "copy into Box" option). Falls back to
|
|
87
|
+
* `process.env.MJ_MEETING_RECORDING_CANONICAL_STORAGE_PROVIDER`. OFF by default.
|
|
88
|
+
*/
|
|
89
|
+
canonicalStorageProviderID?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Registers a completed meeting recording: resolves the Meeting-Room Conversation, creates the `MJ: Files`
|
|
94
|
+
* row for the egress MP4, and stamps `Conversation.RecordingFileID` (+ `EgressID`). Best-effort — never
|
|
95
|
+
* throws; returns a failure result with a clear message instead.
|
|
96
|
+
*
|
|
97
|
+
* @param egress The completed egress result (must carry an `OutputLocation`).
|
|
98
|
+
* @param contextUser The authenticated user (for DB writes + credential access).
|
|
99
|
+
* @param provider The metadata provider servicing this request.
|
|
100
|
+
* @param config Optional config overrides (otherwise read from env).
|
|
101
|
+
*/
|
|
102
|
+
export async function registerMeetingRecordingFile(
|
|
103
|
+
egress: MeetingRecordingEgressResult,
|
|
104
|
+
contextUser: UserInfo,
|
|
105
|
+
provider: IMetadataProvider,
|
|
106
|
+
config?: MeetingRecordingRegistrationConfig,
|
|
107
|
+
): Promise<MeetingRecordingRegistrationResult> {
|
|
108
|
+
try {
|
|
109
|
+
if (!egress.OutputLocation) {
|
|
110
|
+
return {
|
|
111
|
+
Success: false,
|
|
112
|
+
ErrorMessage:
|
|
113
|
+
'The recording has no output yet (egress still in progress) — nothing to register. ' + 'Register only after the egress has stopped/completed.',
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const sinkProviderID = config?.sinkStorageProviderID ?? process.env.MJ_MEETING_RECORDING_STORAGE_PROVIDER;
|
|
118
|
+
if (!sinkProviderID) {
|
|
119
|
+
return {
|
|
120
|
+
Success: false,
|
|
121
|
+
ErrorMessage:
|
|
122
|
+
'No meeting-recording storage provider is configured. Set MJ_MEETING_RECORDING_STORAGE_PROVIDER ' +
|
|
123
|
+
'(or meetingRecordingStorageProviderID in mj.config.cjs) to the MJ: File Storage Providers id ' +
|
|
124
|
+
'whose accounts target the same sink LiveKit egress writes to.',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await FileStorageEngine.Instance.Config(false, contextUser, provider);
|
|
129
|
+
|
|
130
|
+
const sinkAccountID = resolveStorageAccountForProvider(sinkProviderID);
|
|
131
|
+
if (!sinkAccountID) {
|
|
132
|
+
return {
|
|
133
|
+
Success: false,
|
|
134
|
+
ErrorMessage:
|
|
135
|
+
`No active MJStorage account is linked to the configured meeting-recording provider '${sinkProviderID}'. ` +
|
|
136
|
+
'Create a File Storage Account for that provider pointing at the egress sink bucket/container.',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const conversationID = await resolveMeetingConversation(egress, contextUser, provider);
|
|
141
|
+
if (!conversationID) {
|
|
142
|
+
return { Success: false, ErrorMessage: 'Could not resolve or create the Meeting-Room Conversation for the recording.' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// v1: point the Files row directly at the egress output in the sink account (no byte copy). The
|
|
146
|
+
// OPTIONAL copy-to-canonical path overrides both the provider+key when a differing canonical
|
|
147
|
+
// provider is configured.
|
|
148
|
+
const fileLocation = await resolveFileLocation(egress, sinkProviderID, sinkAccountID, contextUser, config);
|
|
149
|
+
if (!fileLocation.Success) {
|
|
150
|
+
return { Success: false, ErrorMessage: fileLocation.ErrorMessage, ConversationID: conversationID };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const fileID = await createRecordingFileRow(egress, fileLocation.ProviderID, fileLocation.ProviderKey, contextUser, provider);
|
|
154
|
+
if (!fileID) {
|
|
155
|
+
return { Success: false, ErrorMessage: 'Failed to create the MJ: Files row for the recording.', ConversationID: conversationID };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const stamped = await stampConversationRecording(conversationID, fileID, egress.EgressID, contextUser, provider);
|
|
159
|
+
if (!stamped) {
|
|
160
|
+
return {
|
|
161
|
+
Success: false,
|
|
162
|
+
ErrorMessage: 'Created the recording file but failed to stamp it onto the Conversation.',
|
|
163
|
+
ConversationID: conversationID,
|
|
164
|
+
RecordingFileID: fileID,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
LogStatus(`registerMeetingRecordingFile: registered recording ${fileID} for room '${egress.RoomName}' on conversation ${conversationID}.`);
|
|
169
|
+
return { Success: true, RecordingFileID: fileID, ConversationID: conversationID };
|
|
170
|
+
} catch (error) {
|
|
171
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
172
|
+
LogError(`registerMeetingRecordingFile failed: ${msg}`);
|
|
173
|
+
return { Success: false, ErrorMessage: msg };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Best-effort correlation when a recording STARTS: stamps `Conversation.EgressID` onto the room's
|
|
179
|
+
* Meeting-Room Conversation (if it already exists) so a live recording is tracked against the room. If the
|
|
180
|
+
* conversation doesn't exist yet, this resolves to `false` silently — the stop-flow will resolve/create it.
|
|
181
|
+
* Never throws.
|
|
182
|
+
*
|
|
183
|
+
* @param roomName The LiveKit room name (= the Conversation's `ExternalID`).
|
|
184
|
+
* @param egressID The egress id that just started.
|
|
185
|
+
* @param contextUser The authenticated user.
|
|
186
|
+
* @param provider The metadata provider.
|
|
187
|
+
* @returns `true` when an existing conversation was stamped, else `false`.
|
|
188
|
+
*/
|
|
189
|
+
export async function correlateRecordingStart(roomName: string, egressID: string, contextUser: UserInfo, provider: IMetadataProvider): Promise<boolean> {
|
|
190
|
+
try {
|
|
191
|
+
const conversationID = await findConversationByRoomName(roomName, contextUser, provider);
|
|
192
|
+
if (!conversationID) {
|
|
193
|
+
return false; // not created yet — stop-flow resolves/creates it
|
|
194
|
+
}
|
|
195
|
+
const conversation = await provider.GetEntityObject<MJConversationEntity>(CONVERSATION_ENTITY, contextUser);
|
|
196
|
+
if (!(await conversation.Load(conversationID))) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
conversation.EgressID = egressID;
|
|
200
|
+
if (await conversation.Save()) {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
LogError(`correlateRecordingStart: failed to stamp EgressID: ${conversation.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
204
|
+
return false;
|
|
205
|
+
} catch (error) {
|
|
206
|
+
// Never fail the recording start on correlation.
|
|
207
|
+
LogError(`correlateRecordingStart (best-effort) failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Resolves the Meeting-Room Conversation for a completed recording: prefer matching by `EgressID` (set on
|
|
214
|
+
* start), fall back to the room name (`ExternalID` + `Type`), else create one following the transcript
|
|
215
|
+
* sink's pattern (scoped `Application` so it stays out of the normal chat list).
|
|
216
|
+
*/
|
|
217
|
+
export async function resolveMeetingConversation(
|
|
218
|
+
egress: MeetingRecordingEgressResult,
|
|
219
|
+
contextUser: UserInfo,
|
|
220
|
+
provider: IMetadataProvider,
|
|
221
|
+
): Promise<string | null> {
|
|
222
|
+
const byEgress = await findConversationByEgressID(egress.EgressID, contextUser, provider);
|
|
223
|
+
if (byEgress) {
|
|
224
|
+
return byEgress;
|
|
225
|
+
}
|
|
226
|
+
const byRoom = await findConversationByRoomName(egress.RoomName, contextUser, provider);
|
|
227
|
+
if (byRoom) {
|
|
228
|
+
return byRoom;
|
|
229
|
+
}
|
|
230
|
+
return createMeetingConversation(egress.RoomName, contextUser, provider);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Finds a non-archived Meeting-Room Conversation by its `EgressID`. */
|
|
234
|
+
async function findConversationByEgressID(egressID: string, contextUser: UserInfo, provider: IMetadataProvider): Promise<string | null> {
|
|
235
|
+
if (!egressID) {
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
const rv = RunView.FromMetadataProvider(provider);
|
|
239
|
+
const found = await rv.RunView<{ ID: string }>(
|
|
240
|
+
{
|
|
241
|
+
EntityName: CONVERSATION_ENTITY,
|
|
242
|
+
ExtraFilter: `EgressID='${escapeSql(egressID)}' AND Type='${escapeSql(MEETING_ROOM_CONVERSATION_TYPE)}' AND (IsArchived IS NULL OR IsArchived=0)`,
|
|
243
|
+
Fields: ['ID'],
|
|
244
|
+
OrderBy: '__mj_CreatedAt DESC',
|
|
245
|
+
MaxRows: 1,
|
|
246
|
+
ResultType: 'simple',
|
|
247
|
+
},
|
|
248
|
+
contextUser,
|
|
249
|
+
);
|
|
250
|
+
return found.Success && found.Results.length > 0 ? found.Results[0].ID : null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Finds a non-archived Meeting-Room Conversation by room name (`ExternalID` + `Type`). */
|
|
254
|
+
async function findConversationByRoomName(roomName: string, contextUser: UserInfo, provider: IMetadataProvider): Promise<string | null> {
|
|
255
|
+
if (!roomName) {
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
const rv = RunView.FromMetadataProvider(provider);
|
|
259
|
+
const found = await rv.RunView<{ ID: string }>(
|
|
260
|
+
{
|
|
261
|
+
EntityName: CONVERSATION_ENTITY,
|
|
262
|
+
ExtraFilter: `ExternalID='${escapeSql(roomName)}' AND Type='${escapeSql(MEETING_ROOM_CONVERSATION_TYPE)}' AND (IsArchived IS NULL OR IsArchived=0)`,
|
|
263
|
+
Fields: ['ID'],
|
|
264
|
+
OrderBy: '__mj_CreatedAt DESC',
|
|
265
|
+
MaxRows: 1,
|
|
266
|
+
ResultType: 'simple',
|
|
267
|
+
},
|
|
268
|
+
contextUser,
|
|
269
|
+
);
|
|
270
|
+
return found.Success && found.Results.length > 0 ? found.Results[0].ID : null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Creates a Meeting-Room Conversation for a room (scoped `Application`, keyed by room name) — mirrors the transcript sink. */
|
|
274
|
+
async function createMeetingConversation(roomName: string, contextUser: UserInfo, provider: IMetadataProvider): Promise<string | null> {
|
|
275
|
+
const conversation = await provider.GetEntityObject<MJConversationEntity>(CONVERSATION_ENTITY, contextUser);
|
|
276
|
+
conversation.NewRecord();
|
|
277
|
+
conversation.UserID = contextUser.ID;
|
|
278
|
+
conversation.Name = `Meeting Room ${roomName}`;
|
|
279
|
+
conversation.Type = MEETING_ROOM_CONVERSATION_TYPE;
|
|
280
|
+
conversation.ExternalID = roomName;
|
|
281
|
+
conversation.ApplicationScope = 'Application';
|
|
282
|
+
if (await conversation.Save()) {
|
|
283
|
+
return conversation.ID;
|
|
284
|
+
}
|
|
285
|
+
LogError(
|
|
286
|
+
`registerMeetingRecordingFile: failed to create Meeting-Room conversation for room '${roomName}': ${conversation.LatestResult?.CompleteMessage ?? 'unknown error'}`,
|
|
287
|
+
);
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Result of resolving where the Files row should point (sink directly, or copied into a canonical provider). */
|
|
292
|
+
interface FileLocationResult {
|
|
293
|
+
Success: boolean;
|
|
294
|
+
ErrorMessage?: string;
|
|
295
|
+
ProviderID: string;
|
|
296
|
+
ProviderKey: string;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Resolves the `(ProviderID, ProviderKey)` the Files row should point at. v1 default: the egress output in
|
|
301
|
+
* the sink provider (no copy). When a DIFFERING canonical provider is configured, copies the bytes into it
|
|
302
|
+
* and points there instead.
|
|
303
|
+
*/
|
|
304
|
+
async function resolveFileLocation(
|
|
305
|
+
egress: MeetingRecordingEgressResult,
|
|
306
|
+
sinkProviderID: string,
|
|
307
|
+
sinkAccountID: string,
|
|
308
|
+
contextUser: UserInfo,
|
|
309
|
+
config?: MeetingRecordingRegistrationConfig,
|
|
310
|
+
): Promise<FileLocationResult> {
|
|
311
|
+
const outputLocation = egress.OutputLocation!;
|
|
312
|
+
const canonicalProviderID = config?.canonicalStorageProviderID ?? process.env.MJ_MEETING_RECORDING_CANONICAL_STORAGE_PROVIDER;
|
|
313
|
+
|
|
314
|
+
// Default (v1): point directly at the sink output — playback streams straight from it, no byte copy.
|
|
315
|
+
if (!canonicalProviderID || canonicalProviderID === sinkProviderID) {
|
|
316
|
+
return { Success: true, ProviderID: sinkProviderID, ProviderKey: outputLocation };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// OPTIONAL "copy into Box": a separate canonical provider IS configured and differs from the sink —
|
|
320
|
+
// read the bytes from the sink and upload them into the canonical provider, then point there.
|
|
321
|
+
const canonicalKey = await copyEgressOutputToCanonical(outputLocation, sinkAccountID, canonicalProviderID, contextUser);
|
|
322
|
+
if (!canonicalKey.Success) {
|
|
323
|
+
return { Success: false, ErrorMessage: canonicalKey.ErrorMessage, ProviderID: '', ProviderKey: '' };
|
|
324
|
+
}
|
|
325
|
+
return { Success: true, ProviderID: canonicalProviderID, ProviderKey: canonicalKey.ProviderKey };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Result of a canonical copy. */
|
|
329
|
+
interface CanonicalCopyResult {
|
|
330
|
+
Success: boolean;
|
|
331
|
+
ErrorMessage?: string;
|
|
332
|
+
ProviderKey: string;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* OPTIONAL — copies the egress MP4 OUT of the sink account into a configured canonical provider (the "copy
|
|
337
|
+
* into Box" option). Reads the bytes via the sink driver's `GetObject({ fullPath })` and re-uploads them
|
|
338
|
+
* into the canonical provider via `FileStorageEngine.UploadFile`. Returns the canonical `ProviderKey`.
|
|
339
|
+
*/
|
|
340
|
+
export async function copyEgressOutputToCanonical(
|
|
341
|
+
outputLocation: string,
|
|
342
|
+
sinkAccountID: string,
|
|
343
|
+
canonicalProviderID: string,
|
|
344
|
+
contextUser: UserInfo,
|
|
345
|
+
): Promise<CanonicalCopyResult> {
|
|
346
|
+
const engine = FileStorageEngine.Instance;
|
|
347
|
+
const canonicalAccountID = resolveStorageAccountForProvider(canonicalProviderID);
|
|
348
|
+
if (!canonicalAccountID) {
|
|
349
|
+
return {
|
|
350
|
+
Success: false,
|
|
351
|
+
ErrorMessage: `No active MJStorage account is linked to the configured canonical provider '${canonicalProviderID}'.`,
|
|
352
|
+
ProviderKey: '',
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const sinkDriver = await engine.GetDriver(sinkAccountID, contextUser);
|
|
357
|
+
const bytes = await sinkDriver.GetObject({ fullPath: outputLocation });
|
|
358
|
+
|
|
359
|
+
const fileName = outputLocation.split('/').pop() || `recording-${Date.now()}.mp4`;
|
|
360
|
+
const uploaded = await engine.UploadFile({
|
|
361
|
+
content: bytes,
|
|
362
|
+
fileName,
|
|
363
|
+
mimeType: 'video/mp4',
|
|
364
|
+
contextUser,
|
|
365
|
+
storageAccountId: canonicalAccountID,
|
|
366
|
+
pathPrefix: `meeting-recordings/${new Date().toISOString().slice(0, 10)}`,
|
|
367
|
+
});
|
|
368
|
+
return { Success: true, ProviderKey: uploaded.StoragePath };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Creates the `MJ: Files` row pointing at the recording (no byte copy in the default v1 path). */
|
|
372
|
+
async function createRecordingFileRow(
|
|
373
|
+
egress: MeetingRecordingEgressResult,
|
|
374
|
+
providerID: string,
|
|
375
|
+
providerKey: string,
|
|
376
|
+
contextUser: UserInfo,
|
|
377
|
+
provider: IMetadataProvider,
|
|
378
|
+
): Promise<string | null> {
|
|
379
|
+
const file = await provider.GetEntityObject<MJFileEntity>(FILES_ENTITY, contextUser);
|
|
380
|
+
file.NewRecord();
|
|
381
|
+
file.Name = `Meeting Recording — ${egress.RoomName} — ${new Date().toISOString().slice(0, 10)}`;
|
|
382
|
+
file.ContentType = 'video/mp4';
|
|
383
|
+
file.ProviderID = providerID;
|
|
384
|
+
file.ProviderKey = providerKey;
|
|
385
|
+
file.Status = 'Uploaded';
|
|
386
|
+
// NOTE: `MJ: Files` has no size column, so `egress.OutputSizeBytes` is not persisted here (it is logged
|
|
387
|
+
// and used by the player's range-streaming on demand). If a size column is added, set it here.
|
|
388
|
+
if (await file.Save()) {
|
|
389
|
+
return file.ID;
|
|
390
|
+
}
|
|
391
|
+
LogError(`registerMeetingRecordingFile: failed to save MJ: Files row: ${file.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Loads the Conversation and stamps `RecordingFileID` (+ `EgressID` if unset), then saves. */
|
|
396
|
+
async function stampConversationRecording(
|
|
397
|
+
conversationID: string,
|
|
398
|
+
fileID: string,
|
|
399
|
+
egressID: string,
|
|
400
|
+
contextUser: UserInfo,
|
|
401
|
+
provider: IMetadataProvider,
|
|
402
|
+
): Promise<boolean> {
|
|
403
|
+
const conversation = await provider.GetEntityObject<MJConversationEntity>(CONVERSATION_ENTITY, contextUser);
|
|
404
|
+
if (!(await conversation.Load(conversationID))) {
|
|
405
|
+
LogError(`registerMeetingRecordingFile: could not load conversation ${conversationID} to stamp the recording.`);
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
conversation.RecordingFileID = fileID;
|
|
409
|
+
if (!conversation.EgressID) {
|
|
410
|
+
conversation.EgressID = egressID;
|
|
411
|
+
}
|
|
412
|
+
if (await conversation.Save()) {
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
415
|
+
LogError(`registerMeetingRecordingFile: failed to stamp the Conversation: ${conversation.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Resolves an MJStorage account linked to a provider id (the first one). `IsActive` lives on the
|
|
421
|
+
* *provider*, not the account, so provider-level inactivity is handled upstream by the engine's driver
|
|
422
|
+
* cache; here we just pick the provider's account.
|
|
423
|
+
*/
|
|
424
|
+
function resolveStorageAccountForProvider(providerID: string): string | null {
|
|
425
|
+
const accounts = FileStorageEngine.Instance.GetAccountsByProviderID(providerID);
|
|
426
|
+
return accounts.length > 0 ? accounts[0].ID : null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Escapes single quotes for safe embedding in an `ExtraFilter` literal. */
|
|
430
|
+
function escapeSql(value: string): string {
|
|
431
|
+
return value.replace(/'/g, "''");
|
|
432
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Pure helpers for the `peaks.json` waveform sidecar that rides alongside a stored
|
|
3
|
+
* recording. Kept free of type-graphql / resolver / DB dependencies so they can be unit-tested in
|
|
4
|
+
* isolation (the resolver class itself can't be imported under vitest without standing up the full
|
|
5
|
+
* decorated schema). {@link FileResolver.tryReadPeaksSidecar} composes these with a storage driver.
|
|
6
|
+
*
|
|
7
|
+
* @module @memberjunction/server/resolvers/peaksSidecar
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Derives the `peaks.json` sidecar path that sits in the SAME folder as a file's `ProviderKey`:
|
|
12
|
+
* the final path segment of `providerKey` is replaced with `peaks.json`. Returns `null` when
|
|
13
|
+
* `providerKey` is empty (no folder to derive from).
|
|
14
|
+
*
|
|
15
|
+
* @example deriveSidecarPath('realtime-recordings/sess-1/recording.wav') === 'realtime-recordings/sess-1/peaks.json'
|
|
16
|
+
* @example deriveSidecarPath('recording.wav') === 'peaks.json'
|
|
17
|
+
*/
|
|
18
|
+
export function deriveSidecarPath(providerKey: string | null | undefined): string | null {
|
|
19
|
+
if (!providerKey) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const lastSlash = providerKey.lastIndexOf('/');
|
|
23
|
+
return lastSlash >= 0 ? `${providerKey.slice(0, lastSlash)}/peaks.json` : 'peaks.json';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Generous ceiling on the number of peaks returned — matches the capture-side cap. */
|
|
27
|
+
export const MAX_PEAKS = 4096;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parses + sanitizes a `peaks.json` payload (UTF-8 bytes). The content must be a JSON array of
|
|
31
|
+
* finite numbers; each value is clamped to `[0, 1]` and the array is capped at {@link MAX_PEAKS}
|
|
32
|
+
* entries so a malformed/hostile sidecar can't bloat the response. Returns `undefined` for anything
|
|
33
|
+
* that isn't a non-empty array of finite numbers, and NEVER throws (a parse error → `undefined`).
|
|
34
|
+
*
|
|
35
|
+
* @param bytes The raw sidecar bytes (e.g. from a storage `GetObject`).
|
|
36
|
+
* @returns Sanitized `0..1` peaks, or `undefined`.
|
|
37
|
+
*/
|
|
38
|
+
export function parsePeaksSidecar(bytes: Buffer): number[] | undefined {
|
|
39
|
+
try {
|
|
40
|
+
const parsed: unknown = JSON.parse(bytes.toString('utf8'));
|
|
41
|
+
if (!Array.isArray(parsed) || parsed.length === 0) {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const cleaned = parsed
|
|
45
|
+
.slice(0, MAX_PEAKS)
|
|
46
|
+
.filter((v): v is number => typeof v === 'number' && Number.isFinite(v))
|
|
47
|
+
.map((v) => (v < 0 ? 0 : v > 1 ? 1 : v));
|
|
48
|
+
return cleaned.length > 0 ? cleaned : undefined;
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|