From 3bbe201833a01c4755901ae03566ac4dcbcae139 Mon Sep 17 00:00:00 2001 From: junhaoli3268 <347633432@qq.com> Date: Wed, 3 Jun 2026 14:37:21 +0800 Subject: [PATCH 1/4] feat(plugin-meetings): hydrate recording duration on rejoin - Pass through controls.record duration metadata in controlsUtils/locus-info - Add RecordingController.getRecordingStatus() REST fallback - Defer & retry hydration until LINKS_SERVICES delivers serviceUrl - Preserve duration fields across non-IDLE Locus deltas; clear on stop - Guard hydrate response against in-flight stop race --- .../src/locus-info/controlsUtils.ts | 14 ++ .../plugin-meetings/src/locus-info/index.ts | 5 + .../plugin-meetings/src/meeting/index.ts | 157 ++++++++++++++++- .../src/recording-controller/index.ts | 59 +++++++ .../unit/spec/locus-info/controlsUtils.js | 54 ++++++ .../test/unit/spec/locus-info/index.js | 57 ++++++ .../test/unit/spec/meeting/index.js | 162 ++++++++++++++++++ .../unit/spec/recording-controller/index.js | 103 +++++++++++ 8 files changed, 610 insertions(+), 1 deletion(-) diff --git a/packages/@webex/plugin-meetings/src/locus-info/controlsUtils.ts b/packages/@webex/plugin-meetings/src/locus-info/controlsUtils.ts index 25e26adc387..b7c743c981c 100644 --- a/packages/@webex/plugin-meetings/src/locus-info/controlsUtils.ts +++ b/packages/@webex/plugin-meetings/src/locus-info/controlsUtils.ts @@ -22,6 +22,12 @@ ControlsUtils.parse = (controls: any) => { const parsedControls = {...controls}; if (controls && controls.record) { + // Passthrough any duration fields Locus may send so consumers can compute + // accumulated recording time on rejoin (parity with native's + // RecordingStreamSession::RecordingTime). Falls back to undefined when the + // server does not include them on this payload. + const rawDuration = controls.record.meta?.duration ?? controls.record.duration; + parsedControls.record = { modifiedBy: ControlsUtils.getId(controls), paused: controls.record.paused ? controls.record.paused : false, @@ -29,6 +35,14 @@ ControlsUtils.parse = (controls: any) => { lastModified: controls.record.meta?.lastModified, modifiedByServiceAppName: controls.record.meta?.modifiedByServiceAppName, modifiedByServiceAppId: controls.record.meta?.modifiedByServiceAppId, + // Duration metadata (parity with native iOS/Android). All optional. + // - lastDuration: accumulated recorded ms before the current segment + // - lastTime: server timestamp when the current segment/state started + // - needCalculate: true while RECORDING (UI must add now - lastTime), + // false while PAUSED (UI shows lastDuration as-is) + lastDuration: rawDuration?.lastDuration, + lastTime: rawDuration?.lastTime, + needCalculate: rawDuration?.needCalculate, }; } diff --git a/packages/@webex/plugin-meetings/src/locus-info/index.ts b/packages/@webex/plugin-meetings/src/locus-info/index.ts index bc240a230f4..96cf379da79 100644 --- a/packages/@webex/plugin-meetings/src/locus-info/index.ts +++ b/packages/@webex/plugin-meetings/src/locus-info/index.ts @@ -2100,6 +2100,11 @@ export default class LocusInfo extends EventsScope { lastModified: current.record.lastModified, modifiedByServiceAppName: current.record.modifiedByServiceAppName, modifiedByServiceAppId: current.record.modifiedByServiceAppId, + // Duration metadata (parity with native). All optional — present + // only when Locus includes them in `controls.record.meta.duration`. + lastDuration: current.record.lastDuration, + lastTime: current.record.lastTime, + needCalculate: current.record.needCalculate, } ); } diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index d1007380104..89d92cf1c59 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -686,6 +686,7 @@ export default class Meeting extends StatelessWebexPlugin { recording: any; remoteMediaManager: RemoteMediaManager | null; recordingController: RecordingController; + pendingRecordingHydrationEvent?: string; controlsOptionsManager: ControlsOptionsManager; requiredCaptcha: any; receiveSlotManager: ReceiveSlotManager; @@ -2809,6 +2810,84 @@ export default class Meeting extends StatelessWebexPlugin { }); } + /** + * Locus deltas do not include the accumulated recording duration. Native + * clients fetch it from the recording stream service + * (`GET {serviceUrl}/loci/{locusId}/resource`). This helper performs the + * same fetch and, on success, merges the duration metadata into + * `this.recording` and re-emits the supplied event so the consumer can + * hydrate its timer with the correct elapsed time (especially on rejoin + * while the meeting is paused). + * + * @param {string} event - the recording event that was just fired + * @returns {Promise} + * @private + * @memberof Meeting + */ + private async hydrateRecordingDuration(event: string): Promise { + if (!this.recordingController) { + return; + } + + // The recording stream service URL is set asynchronously via the + // LINKS_SERVICES locus event. On initial join into an already RECORDING + // / PAUSED meeting, the controls update can fire before that. In that + // case, defer the hydration: it will be retried from + // `setUpLocusServicesListener` once the URL is available. + if (!this.recordingController.getServiceUrl() || !this.recordingController.getLocusId()) { + LoggerProxy.logger.debug( + `Meeting:index#hydrateRecordingDuration --> deferred (event=${event}); serviceUrl/locusId not yet available` + ); + this.pendingRecordingHydrationEvent = event; + + return; + } + + this.pendingRecordingHydrationEvent = undefined; + + try { + const duration = await this.recordingController.getRecordingStatus(); + + LoggerProxy.logger.debug( + `Meeting:index#hydrateRecordingDuration --> event=${event} duration=${JSON.stringify( + duration + )}` + ); + + if (!duration || (duration.lastDuration === undefined && duration.lastTime === undefined)) { + return; + } + + // Guard against a race: if the recording was stopped while the request + // was in flight, do not resurrect stale duration metadata onto the now + // IDLE state — that would re-trigger the timer on the next start. + if (this.recording?.state === RECORDING_STATE.IDLE) { + return; + } + + this.recording = { + ...this.recording, + lastDuration: duration.lastDuration, + lastTime: duration.lastTime, + needCalculate: duration.needCalculate, + }; + + Trigger.trigger( + this, + { + file: 'meeting/index', + function: 'hydrateRecordingDuration', + }, + event, + this.recording + ); + } catch (error) { + LoggerProxy.logger.warn( + `Meeting:index#hydrateRecordingDuration --> failed to fetch duration: ${error}` + ); + } + } + /** * Set up the locus info recording update listener * update recording value for the meeting @@ -2830,7 +2909,16 @@ export default class Meeting extends StatelessWebexPlugin { private setupLocusControlsListener() { this.locusInfo.on( LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, - ({state, modifiedBy, lastModified, modifiedByServiceAppName, modifiedByServiceAppId}) => { + ({ + state, + modifiedBy, + lastModified, + modifiedByServiceAppName, + modifiedByServiceAppId, + lastDuration, + lastTime, + needCalculate, + }) => { let event; switch (state) { @@ -2851,6 +2939,39 @@ export default class Meeting extends StatelessWebexPlugin { break; } + // Locus deltas do NOT carry duration data. To avoid wiping the values + // we previously hydrated from the recording stream service (which + // would cause the UI to briefly flash 00:00:00 on every state change + // such as PAUSED -> RECORDING), preserve any prior duration metadata + // until hydrate runs again with fresh server data. EXCEPT when we + // transition into IDLE (recording fully stopped) — in that case the + // previous segment's duration is no longer meaningful, and keeping it + // around would cause the next START to briefly show the stale value. + const previousRecording = this.recording || {}; + const isStopping = state === RECORDING_STATE.IDLE; + const pickDurationField = ( + incoming: T | undefined, + previous: T | undefined + ): T | undefined => { + if (isStopping) { + return undefined; + } + if (incoming !== undefined) { + return incoming; + } + + return previous; + }; + const resolvedLastDuration = pickDurationField( + lastDuration, + previousRecording.lastDuration + ); + const resolvedLastTime = pickDurationField(lastTime, previousRecording.lastTime); + const resolvedNeedCalculate = pickDurationField( + needCalculate, + previousRecording.needCalculate + ); + // `RESUMED` state should be converted to `RECORDING` after triggering the event this.recording = { state: state === RECORDING_STATE.RESUMED ? RECORDING_STATE.RECORDING : state, @@ -2858,6 +2979,13 @@ export default class Meeting extends StatelessWebexPlugin { lastModified, modifiedByServiceAppName, modifiedByServiceAppId, + // Duration metadata for timer hydration on rejoin. Optional — + // present only when Locus includes `controls.record.meta.duration`, + // which today it never does. We therefore fall back to whatever we + // already had so the UI does not lose the timer baseline. + lastDuration: resolvedLastDuration, + lastTime: resolvedLastTime, + needCalculate: resolvedNeedCalculate, }; Trigger.trigger( this, @@ -2868,6 +2996,23 @@ export default class Meeting extends StatelessWebexPlugin { event, this.recording ); + + // Locus deltas do not carry the accumulated recording duration. + // When the meeting transitions into RECORDING or PAUSED (which also + // covers the case where we join a meeting that is already in one of + // those states), fetch the duration metadata from the recording + // stream service to mirror what native clients do. Re-emit the same + // event with the hydrated values so the UI can show the correct + // elapsed time instead of `00:00:00`. + if ( + event && + (state === RECORDING_STATE.RECORDING || + state === RECORDING_STATE.RESUMED || + state === RECORDING_STATE.PAUSED) && + (lastDuration === undefined || lastTime === undefined) + ) { + this.hydrateRecordingDuration(event); + } } ); @@ -3539,6 +3684,16 @@ export default class Meeting extends StatelessWebexPlugin { this.annotation.approvalUrlUpdate(payload?.services?.approval?.url); this.simultaneousInterpretation.approvalUrlUpdate(payload?.services?.approval?.url); this.aiEnableRequest.approvalUrlUpdate(payload?.services?.approval?.url); + + // If a recording event fired before the recording stream service URL + // was known (e.g. joining an already RECORDING/PAUSED meeting), retry + // the deferred hydration now that the URL is available. + if (this.pendingRecordingHydrationEvent) { + const event = this.pendingRecordingHydrationEvent; + + this.pendingRecordingHydrationEvent = undefined; + this.hydrateRecordingDuration(event); + } }); } diff --git a/packages/@webex/plugin-meetings/src/recording-controller/index.ts b/packages/@webex/plugin-meetings/src/recording-controller/index.ts index a721318993d..29f5639b423 100644 --- a/packages/@webex/plugin-meetings/src/recording-controller/index.ts +++ b/packages/@webex/plugin-meetings/src/recording-controller/index.ts @@ -341,4 +341,63 @@ export default class RecordingController { public resumeRecording(): Promise { return this.recordingFacade(RecordingAction.Resume); } + + /** + * Fetches the current recording resource from the recording stream service. + * The response carries the accumulated recording duration metadata that the + * native clients (iOS / Android / Desktop) use to hydrate the timer when + * (re)joining a meeting that is already RECORDING or PAUSED. Locus deltas + * do not include this data, so this REST call is the only source. + * + * Endpoint mirrors native: + * GET {serviceUrl}/loci/{locusId}/resource + * + * @returns {Promise} duration metadata or undefined + * @public + * @memberof RecordingController + */ + public async getRecordingStatus(): Promise< + | { + lastDuration?: number; + lastTime?: string; + needCalculate?: boolean; + status?: string; + } + | undefined + > { + if (!this.serviceUrl || !this.locusId) { + LoggerProxy.logger.info( + 'RecordingController:index#getRecordingStatus --> skipped, missing serviceUrl or locusId' + ); + + return undefined; + } + + try { + // @ts-ignore + const response = await this.request.request({ + uri: `${this.serviceUrl}/loci/${this.locusId}/resource`, + method: HTTP_VERBS.GET, + }); + + // Response shape (matches native parseResource): + // { recording: [ { status, duration: { lastDuration, lastTime, needCalculate }, ... } ] } + const body = response?.body ?? response; + const entry = Array.isArray(body?.recording) ? body.recording[0] : undefined; + const duration = entry?.duration; + + return { + status: entry?.status, + lastDuration: duration?.lastDuration, + lastTime: duration?.lastTime, + needCalculate: duration?.needCalculate, + }; + } catch (error) { + LoggerProxy.logger.warn( + `RecordingController:index#getRecordingStatus --> request failed: ${error}` + ); + + return undefined; + } + } } diff --git a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/controlsUtils.js b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/controlsUtils.js index f7a02e4ed21..4ca284aa4e2 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/controlsUtils.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/controlsUtils.js @@ -274,6 +274,60 @@ describe('plugin-meetings', () => { assert.isUndefined(parsedControls.record.modifiedByServiceAppId); }); + it('should passthrough recording duration metadata from record.meta.duration', () => { + const newControls = { + record: { + recording: true, + paused: false, + meta: { + duration: { + lastDuration: 12345, + lastTime: '2026-06-03T10:00:00Z', + needCalculate: true, + }, + }, + }, + }; + + const parsedControls = ControlsUtils.parse(newControls); + + assert.equal(parsedControls.record.lastDuration, 12345); + assert.equal(parsedControls.record.lastTime, '2026-06-03T10:00:00Z'); + assert.equal(parsedControls.record.needCalculate, true); + }); + + it('should fall back to record.duration when record.meta.duration is absent', () => { + const newControls = { + record: { + recording: false, + paused: true, + duration: { + lastDuration: 7777, + lastTime: '2026-06-03T11:00:00Z', + needCalculate: false, + }, + }, + }; + + const parsedControls = ControlsUtils.parse(newControls); + + assert.equal(parsedControls.record.lastDuration, 7777); + assert.equal(parsedControls.record.lastTime, '2026-06-03T11:00:00Z'); + assert.equal(parsedControls.record.needCalculate, false); + }); + + it('should leave duration fields undefined when neither source is present', () => { + const newControls = { + record: {recording: true, paused: false, meta: {lastModified: '2026-01-01'}}, + }; + + const parsedControls = ControlsUtils.parse(newControls); + + assert.isUndefined(parsedControls.record.lastDuration); + assert.isUndefined(parsedControls.record.lastTime); + assert.isUndefined(parsedControls.record.needCalculate); + }); + describe('videoEnabled', () => { it('returns expected', () => { const result = ControlsUtils.parse({video: {enabled: true}}); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js index 3c98fcee319..a7d649d9c66 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/index.js @@ -1365,6 +1365,9 @@ describe('plugin-meetings', () => { lastModified: 'TODAY', modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, } ); }); @@ -1401,6 +1404,9 @@ describe('plugin-meetings', () => { lastModified: 'TODAY', modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, } ); }); @@ -1438,6 +1444,9 @@ describe('plugin-meetings', () => { lastModified: 'TODAY', modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, } ); }); @@ -1476,6 +1485,9 @@ describe('plugin-meetings', () => { lastModified: 'TODAY', modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, } ); }); @@ -1513,6 +1525,9 @@ describe('plugin-meetings', () => { lastModified: 'TODAY', modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, } ); }); @@ -1549,6 +1564,48 @@ describe('plugin-meetings', () => { lastModified: 'TODAY', modifiedByServiceAppName: 'My Bot', modifiedByServiceAppId: 'app-id-123', + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, + } + ); + }); + + it('should include recording duration metadata in the event when present', () => { + locusInfo.controls = { + record: { + recording: false, + paused: false, + meta: {lastModified: 'TODAY', modifiedBy: 'George Kittle'}, + }, + shareControl: {}, + transcribe: {}, + }; + newControls.record.recording = true; + newControls.record.meta.duration = { + lastDuration: 12345, + lastTime: '2026-06-03T10:00:00Z', + needCalculate: true, + }; + locusInfo.emitScoped = sinon.stub(); + locusInfo.updateControls(newControls); + + assert.calledWith( + locusInfo.emitScoped, + { + file: 'locus-info', + function: 'updateControls', + }, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.RECORDING, + modifiedBy: 'George Kittle', + lastModified: 'TODAY', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + lastDuration: 12345, + lastTime: '2026-06-03T10:00:00Z', + needCalculate: true, } ); }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js index 8b19f6e74c1..aaf9640fe86 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -11721,6 +11721,9 @@ describe('plugin-meetings', () => { lastModified, modifiedByServiceAppName: undefined, modifiedByServiceAppId: undefined, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, }); assert.calledWith( @@ -11751,6 +11754,9 @@ describe('plugin-meetings', () => { lastModified, modifiedByServiceAppName, modifiedByServiceAppId, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, }); assert.calledWith( @@ -11762,6 +11768,162 @@ describe('plugin-meetings', () => { ); }); + describe('recording duration metadata', () => { + beforeEach(() => { + meeting.recordingController = { + getServiceUrl: sinon.stub().returns(undefined), + getLocusId: sinon.stub().returns(undefined), + getRecordingStatus: sinon.stub().resolves(undefined), + }; + }); + + it('passes through duration fields from the event onto meeting.recording', async () => { + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.RECORDING, + modifiedBy: 'u', + lastModified: 't', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + lastDuration: 5000, + lastTime: '2026-06-03T10:00:00Z', + needCalculate: true, + } + ); + + assert.equal(meeting.recording.lastDuration, 5000); + assert.equal(meeting.recording.lastTime, '2026-06-03T10:00:00Z'); + assert.equal(meeting.recording.needCalculate, true); + }); + + it('preserves prior duration metadata across deltas that omit them (non-IDLE)', async () => { + meeting.recording = { + state: RECORDING_STATE.RECORDING, + lastDuration: 9999, + lastTime: 'prev-time', + needCalculate: true, + }; + + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.PAUSED, + modifiedBy: 'u', + lastModified: 't', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, + } + ); + + assert.equal(meeting.recording.lastDuration, 9999); + assert.equal(meeting.recording.lastTime, 'prev-time'); + assert.equal(meeting.recording.needCalculate, true); + assert.equal(meeting.recording.state, RECORDING_STATE.PAUSED); + }); + + it('clears duration metadata when transitioning to IDLE', async () => { + meeting.recording = { + state: RECORDING_STATE.RECORDING, + lastDuration: 9999, + lastTime: 'prev-time', + needCalculate: true, + }; + + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.IDLE, + modifiedBy: 'u', + lastModified: 't', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, + } + ); + + assert.isUndefined(meeting.recording.lastDuration); + assert.isUndefined(meeting.recording.lastTime); + assert.isUndefined(meeting.recording.needCalculate); + assert.equal(meeting.recording.state, RECORDING_STATE.IDLE); + }); + + it('defers hydration when serviceUrl/locusId are unavailable on RECORDING', async () => { + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.RECORDING, + modifiedBy: 'u', + lastModified: 't', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + } + ); + + assert.notCalled(meeting.recordingController.getRecordingStatus); + assert.equal(meeting.pendingRecordingHydrationEvent, EVENT_TRIGGERS.MEETING_STARTED_RECORDING); + }); + + it('hydrates immediately when serviceUrl/locusId are available and merges the response', async () => { + meeting.recordingController.getServiceUrl.returns('svc'); + meeting.recordingController.getLocusId.returns('locus-id'); + meeting.recordingController.getRecordingStatus.resolves({ + status: 'recording', + lastDuration: 12345, + lastTime: '2026-06-03T10:00:00Z', + needCalculate: true, + }); + + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.PAUSED, + modifiedBy: 'u', + lastModified: 't', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + } + ); + // allow microtask queue to flush hydrate promise + await new Promise((r) => setImmediate(r)); + + assert.calledOnce(meeting.recordingController.getRecordingStatus); + assert.equal(meeting.recording.lastDuration, 12345); + assert.equal(meeting.recording.lastTime, '2026-06-03T10:00:00Z'); + assert.equal(meeting.recording.needCalculate, true); + assert.isUndefined(meeting.pendingRecordingHydrationEvent); + }); + + it('does not hydrate when transitioning into IDLE', async () => { + meeting.recordingController.getServiceUrl.returns('svc'); + meeting.recordingController.getLocusId.returns('locus-id'); + + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.IDLE, + modifiedBy: 'u', + lastModified: 't', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + } + ); + + assert.notCalled(meeting.recordingController.getRecordingStatus); + }); + }); + it('listens to the locus interpretation update event', () => { const interpretation = { siLanguages: [{languageCode: 20, languageName: 'en'}], diff --git a/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/index.js b/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/index.js index 7677b6e35b7..be1d7ac4533 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/recording-controller/index.js @@ -358,6 +358,109 @@ describe('plugin-meetings', () => { }); }); }); + + describe('getRecordingStatus', () => { + let controller; + + beforeEach(() => { + request = { + request: sinon.stub(), + }; + controller = new RecordingController(request); + controller.set({ + serviceUrl: 'test', + sessionId: 'testId', + locusUrl: 'test/id', + displayHints: [], + }); + }); + + it('returns undefined when serviceUrl is missing', async () => { + const noUrlController = new RecordingController({request: sinon.stub()}); + + const result = await noUrlController.getRecordingStatus(); + + assert.isUndefined(result); + }); + + it('issues a GET to the recording stream resource endpoint', async () => { + request.request.resolves({body: {recording: []}}); + + await controller.getRecordingStatus(); + + assert.calledWith(request.request, { + uri: 'test/loci/id/resource', + method: HTTP_VERBS.GET, + }); + }); + + it('parses status and duration from the first recording entry', async () => { + request.request.resolves({ + body: { + recording: [ + { + status: 'recording', + duration: { + lastDuration: 12345, + lastTime: '2026-06-03T10:00:00Z', + needCalculate: true, + }, + }, + ], + }, + }); + + const result = await controller.getRecordingStatus(); + + assert.deepEqual(result, { + status: 'recording', + lastDuration: 12345, + lastTime: '2026-06-03T10:00:00Z', + needCalculate: true, + }); + }); + + it('handles a response without a body wrapper', async () => { + request.request.resolves({ + recording: [ + { + status: 'paused', + duration: {lastDuration: 100, lastTime: '2026-06-03T11:00:00Z', needCalculate: false}, + }, + ], + }); + + const result = await controller.getRecordingStatus(); + + assert.deepEqual(result, { + status: 'paused', + lastDuration: 100, + lastTime: '2026-06-03T11:00:00Z', + needCalculate: false, + }); + }); + + it('returns an object with all undefined fields when recording array is empty', async () => { + request.request.resolves({body: {recording: []}}); + + const result = await controller.getRecordingStatus(); + + assert.deepEqual(result, { + status: undefined, + lastDuration: undefined, + lastTime: undefined, + needCalculate: undefined, + }); + }); + + it('returns undefined when the request rejects', async () => { + request.request.rejects(new Error('boom')); + + const result = await controller.getRecordingStatus(); + + assert.isUndefined(result); + }); + }); }); }); }); From 1d9b46615f869dd0c2aaaaa0c189318367a641c2 Mon Sep 17 00:00:00 2001 From: junhaoli3268 <347633432@qq.com> Date: Wed, 3 Jun 2026 15:51:21 +0800 Subject: [PATCH 2/4] fix: codex fix suggestion --- .../plugin-meetings/src/meeting/index.ts | 30 +++++++- .../test/unit/spec/meeting/index.js | 73 +++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index 89d92cf1c59..2c2e246c49e 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -687,6 +687,12 @@ export default class Meeting extends StatelessWebexPlugin { remoteMediaManager: RemoteMediaManager | null; recordingController: RecordingController; pendingRecordingHydrationEvent?: string; + // Monotonic counter bumped on every recording state transition. Used to + // discard stale `getRecordingStatus` responses that resolve after the + // recording has been stopped (and possibly restarted) — preventing the + // previous segment's `lastDuration`/`lastTime` from being merged onto + // the new RECORDING state. + private recordingHydrationEpoch = 0; controlsOptionsManager: ControlsOptionsManager; requiredCaptcha: any; receiveSlotManager: ReceiveSlotManager; @@ -2845,6 +2851,12 @@ export default class Meeting extends StatelessWebexPlugin { this.pendingRecordingHydrationEvent = undefined; + // Capture the current epoch before issuing the request. If the recording + // state changes (e.g. stop, or stop -> start of a new segment) before the + // response arrives, the epoch bumps and we drop the response so we don't + // apply the previous segment's duration onto the new RECORDING state. + const epochAtRequest = this.recordingHydrationEpoch; + try { const duration = await this.recordingController.getRecordingStatus(); @@ -2854,13 +2866,20 @@ export default class Meeting extends StatelessWebexPlugin { )}` ); + if (this.recordingHydrationEpoch !== epochAtRequest) { + LoggerProxy.logger.debug( + `Meeting:index#hydrateRecordingDuration --> dropping stale response (event=${event}); epoch changed` + ); + + return; + } + if (!duration || (duration.lastDuration === undefined && duration.lastTime === undefined)) { return; } - // Guard against a race: if the recording was stopped while the request - // was in flight, do not resurrect stale duration metadata onto the now - // IDLE state — that would re-trigger the timer on the next start. + // Belt-and-suspenders: even if the epoch is unchanged, never resurrect + // duration metadata onto an IDLE state. if (this.recording?.state === RECORDING_STATE.IDLE) { return; } @@ -2972,6 +2991,11 @@ export default class Meeting extends StatelessWebexPlugin { previousRecording.needCalculate ); + // Bump the hydration epoch on every recording state update so that + // any in-flight `getRecordingStatus` request from the previous state + // is discarded when it resolves. + this.recordingHydrationEpoch += 1; + // `RESUMED` state should be converted to `RECORDING` after triggering the event this.recording = { state: state === RECORDING_STATE.RESUMED ? RECORDING_STATE.RECORDING : state, diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js index aaf9640fe86..bedcbdc0a2d 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -11922,6 +11922,79 @@ describe('plugin-meetings', () => { assert.notCalled(meeting.recordingController.getRecordingStatus); }); + + it('drops a stale hydrate response when the recording state changes mid-flight', async () => { + meeting.recordingController.getServiceUrl.returns('svc'); + meeting.recordingController.getLocusId.returns('locus-id'); + + // First request: hangs until we resolve it. + let resolveFirst; + const firstPending = new Promise((resolve) => { + resolveFirst = resolve; + }); + meeting.recordingController.getRecordingStatus.onFirstCall().returns(firstPending); + + // Kick off the first hydration (segment A). + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.RECORDING, + modifiedBy: 'u', + lastModified: 't1', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + } + ); + + // Stop, then start a fresh segment B before A's response resolves. + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.IDLE, + modifiedBy: 'u', + lastModified: 't2', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + } + ); + + meeting.recordingController.getRecordingStatus.onSecondCall().resolves({ + status: 'recording', + lastDuration: 0, + lastTime: '2026-06-03T11:00:00Z', + needCalculate: true, + }); + + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.RECORDING, + modifiedBy: 'u', + lastModified: 't3', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + } + ); + // allow segment B's hydrate to flush + await new Promise((r) => setImmediate(r)); + + // Now resolve segment A's stale response with a large duration — + // it must NOT overwrite segment B. + resolveFirst({ + status: 'recording', + lastDuration: 99999, + lastTime: '2026-06-03T10:00:00Z', + needCalculate: true, + }); + await new Promise((r) => setImmediate(r)); + + assert.equal(meeting.recording.state, RECORDING_STATE.RECORDING); + assert.equal(meeting.recording.lastDuration, 0); + assert.equal(meeting.recording.lastTime, '2026-06-03T11:00:00Z'); + }); }); it('listens to the locus interpretation update event', () => { From 14d20ff4be2d966b0d499d16aa402a9bae3303f3 Mon Sep 17 00:00:00 2001 From: junhaoli3268 <347633432@qq.com> Date: Wed, 3 Jun 2026 16:51:44 +0800 Subject: [PATCH 3/4] fix: suggestion fix --- packages/@webex/plugin-meetings/README.md | 1 + packages/@webex/plugin-meetings/src/constants.ts | 1 + .../@webex/plugin-meetings/src/meeting/index.ts | 15 ++++++++++----- .../test/unit/spec/meeting/index.js | 10 ++++++++++ 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/@webex/plugin-meetings/README.md b/packages/@webex/plugin-meetings/README.md index 6b0ba929d47..527a23d375c 100644 --- a/packages/@webex/plugin-meetings/README.md +++ b/packages/@webex/plugin-meetings/README.md @@ -990,6 +990,7 @@ meeting.on(...) | `meeting:recording:stopped` | Fired when member stops recording | | `meeting:recording:paused` | Fired when member pauses recording | | `meeting:recording:resumed` | Fired when member resumes recording | +| `meeting:recording:durationUpdated` | Fired after the SDK hydrates the recording duration from the recording stream service (e.g. on rejoin). Distinct from the transition events above so consumers can update timer state without re-running side effects tied to start/pause/resume. | | `meeting:receiveTranscription:started` | Fired when transcription is received | | `meeting:receiveTranscription:stopped` | Fired when transcription has stopped from being received | | `meeting:meetingContainer:update` | Fired when the meetingContainerUrl is updated | diff --git a/packages/@webex/plugin-meetings/src/constants.ts b/packages/@webex/plugin-meetings/src/constants.ts index 90d235f66f7..035495fa32d 100644 --- a/packages/@webex/plugin-meetings/src/constants.ts +++ b/packages/@webex/plugin-meetings/src/constants.ts @@ -296,6 +296,7 @@ export const EVENT_TRIGGERS = { MEETING_RECEIVE_REACTIONS: 'meeting:receiveReactions', MEETING_PAUSED_RECORDING: 'meeting:recording:paused', MEETING_RESUMED_RECORDING: 'meeting:recording:resumed', + MEETING_RECORDING_DURATION_UPDATED: 'meeting:recording:durationUpdated', MEETING_ADDED: 'meeting:added', MEETING_REMOVED: 'meeting:removed', MEETING_RINGING: 'meeting:ringing', diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index 2c2e246c49e..a3e1a9997d6 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -2821,11 +2821,16 @@ export default class Meeting extends StatelessWebexPlugin { * clients fetch it from the recording stream service * (`GET {serviceUrl}/loci/{locusId}/resource`). This helper performs the * same fetch and, on success, merges the duration metadata into - * `this.recording` and re-emits the supplied event so the consumer can - * hydrate its timer with the correct elapsed time (especially on rejoin - * while the meeting is paused). + * `this.recording` and emits a dedicated `meeting:recording:durationUpdated` + * event so the consumer can hydrate its timer with the correct elapsed + * time (especially on rejoin while the meeting is paused). The original + * transition event (started / paused / resumed) is intentionally NOT + * replayed — it has already fired from `setupLocusControlsListener` and + * replaying it would cause notification / analytics consumers to process + * the same transition twice. * - * @param {string} event - the recording event that was just fired + * @param {string} event - the recording transition event that triggered + * this hydration (used only for logging) * @returns {Promise} * @private * @memberof Meeting @@ -2897,7 +2902,7 @@ export default class Meeting extends StatelessWebexPlugin { file: 'meeting/index', function: 'hydrateRecordingDuration', }, - event, + EVENT_TRIGGERS.MEETING_RECORDING_DURATION_UPDATED, this.recording ); } catch (error) { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js index bedcbdc0a2d..2ea10bf7dfb 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -11902,6 +11902,16 @@ describe('plugin-meetings', () => { assert.equal(meeting.recording.lastTime, '2026-06-03T10:00:00Z'); assert.equal(meeting.recording.needCalculate, true); assert.isUndefined(meeting.pendingRecordingHydrationEvent); + + // Hydration should fire the dedicated duration-updated event, + // NOT replay the original transition event. + assert.calledWith( + TriggerProxy.trigger, + meeting, + {file: 'meeting/index', function: 'hydrateRecordingDuration'}, + EVENT_TRIGGERS.MEETING_RECORDING_DURATION_UPDATED, + meeting.recording + ); }); it('does not hydrate when transitioning into IDLE', async () => { From 6cd0d0743ef86afce958a9958b46e5c65a732f13 Mon Sep 17 00:00:00 2001 From: junhaoli3268 <347633432@qq.com> Date: Thu, 4 Jun 2026 13:31:30 +0800 Subject: [PATCH 4/4] fix: ai suggestion --- .../plugin-meetings/src/meeting/index.ts | 17 ++++++++ .../test/unit/spec/meeting/index.js | 42 ++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index a3e1a9997d6..b4a16831079 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -2883,6 +2883,23 @@ export default class Meeting extends StatelessWebexPlugin { return; } + // The recording stream service is eventually consistent with Locus. + // After a quick pause/resume or stop/start, the response can still + // describe the previous segment's state and carry its `lastDuration`. + // Drop the response if its status does not match our current local + // recording state so we never seed the new timer with a stale baseline. + if ( + duration.status && + this.recording?.state && + duration.status.toLowerCase() !== this.recording.state.toLowerCase() + ) { + LoggerProxy.logger.debug( + `Meeting:index#hydrateRecordingDuration --> dropping stale response (event=${event}); status=${duration.status} state=${this.recording.state}` + ); + + return; + } + // Belt-and-suspenders: even if the epoch is unchanged, never resurrect // duration metadata onto an IDLE state. if (this.recording?.state === RECORDING_STATE.IDLE) { diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js index 2ea10bf7dfb..acc4e088a24 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -11877,7 +11877,7 @@ describe('plugin-meetings', () => { meeting.recordingController.getServiceUrl.returns('svc'); meeting.recordingController.getLocusId.returns('locus-id'); meeting.recordingController.getRecordingStatus.resolves({ - status: 'recording', + status: 'paused', lastDuration: 12345, lastTime: '2026-06-03T10:00:00Z', needCalculate: true, @@ -11933,6 +11933,46 @@ describe('plugin-meetings', () => { assert.notCalled(meeting.recordingController.getRecordingStatus); }); + it('drops a hydrate response whose status does not match the current recording state', async () => { + meeting.recordingController.getServiceUrl.returns('svc'); + meeting.recordingController.getLocusId.returns('locus-id'); + + // Resource service still reports the previous segment's PAUSED + // status while Locus has already transitioned to RECORDING. + meeting.recordingController.getRecordingStatus.resolves({ + status: 'paused', + lastDuration: 99999, + lastTime: '2026-06-03T10:00:00Z', + needCalculate: true, + }); + + await meeting.locusInfo.emitScoped( + {function: 'test', file: 'test'}, + LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, + { + state: RECORDING_STATE.RECORDING, + modifiedBy: 'u', + lastModified: 't', + modifiedByServiceAppName: undefined, + modifiedByServiceAppId: undefined, + } + ); + await new Promise((r) => setImmediate(r)); + + assert.calledOnce(meeting.recordingController.getRecordingStatus); + assert.equal(meeting.recording.state, RECORDING_STATE.RECORDING); + assert.isUndefined(meeting.recording.lastDuration); + assert.isUndefined(meeting.recording.lastTime); + + assert.neverCalledWith( + TriggerProxy.trigger, + meeting, + {file: 'meeting/index', function: 'hydrateRecordingDuration'}, + EVENT_TRIGGERS.MEETING_RECORDING_DURATION_UPDATED, + sinon.match.any + ); + }); + it('drops a stale hydrate response when the recording state changes mid-flight', async () => { meeting.recordingController.getServiceUrl.returns('svc'); meeting.recordingController.getLocusId.returns('locus-id');