Skip to content

Commit 0fa99bd

Browse files
authored
Merge pull request #100 from zoom/dev
Release 1.0.3
2 parents 85dc98a + 76633a6 commit 0fa99bd

10 files changed

Lines changed: 318 additions & 99 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,21 @@ All notable changes to the Zoom RTMS SDK will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.0.3] - 2026-02-17
9+
10+
### Added
11+
12+
- **Webinar support**: Added `webinar_uuid` parameter to `join()` for Zoom Webinar events (`webinar.rtms_started`); priority: `meeting_uuid` > `webinar_uuid` > `session_id`
13+
- **`onMediaConnectionInterrupted` callback**: New high-level callback for `EVENT_MEDIA_CONNECTION_INTERRUPTED` events with auto-subscription (Node.js and Python)
14+
15+
### Fixed
16+
17+
- **Release crash on ended sessions**: `release()` no longer throws when the meeting has already ended externally; cleanup always completes and `RTMS_SDK_NOT_EXIST` is treated as success ([#93](https://github.com/zoom/rtms/issues/93))
18+
- **Video params ignored after audio params**: Calling `setAudioParams()` before `setVideoParams()` no longer overwrites video configuration with defaults; individual param setters skip the default-filling logic ([#94](https://github.com/zoom/rtms/issues/94))
19+
- **Express middleware compatibility**: `createWebhookHandler` now reads from `req.body` when pre-parsed by middleware (e.g., `express.json()`), preventing hangs when the request stream has already been consumed ([#96](https://github.com/zoom/rtms/issues/96))
20+
- **Webhook schema validation**: Webhook handlers (Node.js and Python) now validate that the `event` field is present in the payload and return 400 Bad Request for invalid payloads ([#95](https://github.com/zoom/rtms/issues/95))
21+
- **Python event callback coexistence**: Refactored to shared event dispatcher so `onParticipantEvent`, `onActiveSpeakerEvent`, and `onSharingEvent` can all be registered simultaneously (previously each overwrote the last)
22+
823
## [1.0.2] - 2026-01-30
924

1025
### Added

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ For detailed contribution guidelines, build instructions, and troubleshooting, s
141141

142142
## Usage
143143

144+
> **Speaker Identification with Mixed Audio**
145+
>
146+
> When using `AUDIO_MIXED_STREAM` (the default), all participants are mixed into a single audio stream and the audio callback's metadata will **not** identify the current speaker. To identify who is speaking, register an `onActiveSpeakerEvent` callback — it fires whenever the active speaker changes. See [Troubleshooting #7](#7-identifying-speakers-with-mixed-audio-streams) and [#80](https://github.com/zoom/rtms/issues/80) for details.
147+
144148
### Node.js - Webhook Integration
145149

146150
Easily respond to Zoom webhooks and connect to RTMS streams:

index.ts

Lines changed: 68 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,8 @@ function exposeNumberConstants(nativeModule: any): Record<string, number> {
246246
'MEDIA_TYPE_TRANSCRIPT', 'MEDIA_TYPE_CHAT', 'MEDIA_TYPE_ALL',
247247
// Session events
248248
'SESSION_EVENT_ADD', 'SESSION_EVENT_STOP', 'SESSION_EVENT_PAUSE', 'SESSION_EVENT_RESUME',
249+
// User events
250+
'USER_JOIN', 'USER_LEAVE',
249251
// Event types (for subscribeEvent/onEventEx)
250252
'EVENT_UNDEFINED', 'EVENT_FIRST_PACKET_TIMESTAMP', 'EVENT_ACTIVE_SPEAKER_CHANGE',
251253
'EVENT_PARTICIPANT_JOIN', 'EVENT_PARTICIPANT_LEAVE',
@@ -453,23 +455,28 @@ export function createWebhookHandler(callback: WebhookCallbackUnion, path: strin
453455
return;
454456
}
455457

456-
let body = '';
457-
req.on('data', chunk => body += chunk.toString());
458-
459-
req.on('end', () => {
458+
const processPayload = (body: string) => {
460459
try {
461460
Logger.debug('webhook', `Received webhook request: ${req.url}`);
462461
const payload = JSON.parse(body);
463-
462+
463+
// Validate required webhook fields
464+
if (!payload.event || typeof payload.event !== 'string') {
465+
Logger.warn('webhook', 'Received webhook payload missing required "event" field');
466+
res.writeHead(400, headers);
467+
res.end(JSON.stringify({ error: 'Invalid webhook payload: missing required "event" field' }));
468+
return;
469+
}
470+
464471
// Log the webhook event
465472
Logger.info('webhook', `Received event: ${payload.event || 'unknown'}`, {
466473
eventType: payload.event,
467474
payloadSize: body.length
468475
});
469-
476+
470477
// Check if this is a raw webhook callback
471478
const isRawCallback = isRawWebhookCallback(callback);
472-
479+
473480
if (isRawCallback) {
474481
// For raw callbacks, pass req and res objects
475482
process.nextTick(() => {
@@ -493,7 +500,7 @@ export function createWebhookHandler(callback: WebhookCallbackUnion, path: strin
493500
Logger.error('webhook', `Error in webhook callback: ${err instanceof Error ? err.message : 'Unknown error'}`);
494501
}
495502
});
496-
503+
497504
res.writeHead(200, headers);
498505
res.end(JSON.stringify({ status: 'ok' }));
499506
}
@@ -502,7 +509,19 @@ export function createWebhookHandler(callback: WebhookCallbackUnion, path: strin
502509
res.writeHead(400, headers);
503510
res.end(JSON.stringify({ error: 'Invalid JSON received' }));
504511
}
505-
});
512+
};
513+
514+
// Check if body was already parsed by middleware (e.g., express.json())
515+
if ((req as any).body !== undefined) {
516+
const body = typeof (req as any).body === 'string'
517+
? (req as any).body
518+
: JSON.stringify((req as any).body);
519+
processPayload(body);
520+
} else {
521+
let body = '';
522+
req.on('data', chunk => body += chunk.toString());
523+
req.on('end', () => processPayload(body));
524+
}
506525
};
507526
}
508527

@@ -845,6 +864,7 @@ class Client extends nativeRtms.Client {
845864
private activeSpeakerEventCallback: ((timestamp: number, userId: number, userName: string) => void) | null = null;
846865
private sharingEventCallback: ((event: 'start' | 'stop', timestamp: number, userId?: number, userName?: string) => void) | null = null;
847866
private rawEventCallback: ((eventData: string) => void) | null = null;
867+
private mediaConnectionInterruptedCallback: ((timestamp: number) => void) | null = null;
848868
private eventHandlerRegistered: boolean = false;
849869

850870
constructor() {
@@ -920,6 +940,12 @@ class Client extends nativeRtms.Client {
920940
);
921941
}
922942
break;
943+
944+
case nativeRtms.EVENT_MEDIA_CONNECTION_INTERRUPTED:
945+
if (this.mediaConnectionInterruptedCallback) {
946+
this.mediaConnectionInterruptedCallback(data.timestamp || 0);
947+
}
948+
break;
923949
}
924950
} catch (e) {
925951
Logger.error('client', `Failed to parse event: ${e}`);
@@ -947,6 +973,7 @@ class Client extends nativeRtms.Client {
947973

948974
const {
949975
meeting_uuid,
976+
webinar_uuid,
950977
session_id,
951978
rtms_stream_id,
952979
server_urls,
@@ -957,20 +984,20 @@ class Client extends nativeRtms.Client {
957984
pollInterval = 0
958985
} = options;
959986

960-
// Use meeting_uuid for Meeting SDK events, session_id for Video SDK events
961-
const instance_id = meeting_uuid || session_id;
987+
// Use meeting_uuid for Meeting SDK, webinar_uuid for Webinar, session_id for Video SDK
988+
const instance_id = meeting_uuid || webinar_uuid || session_id;
962989

963990
this.pollRate = pollInterval;
964991

965-
Logger.info('client', `Joining ${meeting_uuid ? 'meeting' : 'session'}: ${instance_id}`, {
992+
Logger.info('client', `Joining ${meeting_uuid ? 'meeting' : webinar_uuid ? 'webinar' : 'session'}: ${instance_id}`, {
966993
streamId: rtms_stream_id,
967994
serverUrls: server_urls,
968995
timeout: providedTimeout,
969996
pollInterval
970997
});
971998

972999
if (!instance_id) {
973-
throw new Error('Either meeting_uuid or session_id must be provided');
1000+
throw new Error('Either meeting_uuid, webinar_uuid, or session_id must be provided');
9741001
}
9751002

9761003
const finalSignature = providedSignature || generateSignature({
@@ -1124,6 +1151,34 @@ class Client extends nativeRtms.Client {
11241151
return true;
11251152
}
11261153

1154+
/**
1155+
* Register a callback for media connection interrupted events
1156+
*
1157+
* This automatically subscribes to EVENT_MEDIA_CONNECTION_INTERRUPTED.
1158+
*
1159+
* @param callback Function called when the media connection is interrupted
1160+
* @returns true if registration succeeds
1161+
*
1162+
* @example
1163+
* ```typescript
1164+
* client.onMediaConnectionInterrupted((timestamp) => {
1165+
* console.log(`Media connection interrupted at ${timestamp}`);
1166+
* });
1167+
* ```
1168+
*/
1169+
onMediaConnectionInterrupted(callback: (timestamp: number) => void): boolean {
1170+
this.mediaConnectionInterruptedCallback = callback;
1171+
this.setupEventHandler();
1172+
1173+
try {
1174+
super.subscribeEvent([nativeRtms.EVENT_MEDIA_CONNECTION_INTERRUPTED]);
1175+
} catch (e) {
1176+
Logger.warn('client', `Failed to auto-subscribe to media connection interrupted events: ${e}`);
1177+
}
1178+
1179+
return true;
1180+
}
1181+
11271182
/**
11281183
* Register a callback for raw event data
11291184
*

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@zoom/rtms",
3-
"version": "1.0.2",
3+
"version": "1.0.3",
44
"description": "Node.js Wrapper for the Zoom RTMS C SDK",
55
"main": "./build/Release/index.js",
66
"types": "rtms.d.ts",

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build-backend = "scikit_build_core.build"
55

66
[project]
77
name = "rtms"
8-
version = "1.0.2"
8+
version = "1.0.3"
99
description = "Python bindings for the Zoom RTMS C SDK - Real-Time Media Streaming"
1010
readme = "README.md"
1111
requires-python = ">=3.10"

rtms.d.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,14 +305,17 @@ export interface DeskshareParams {
305305
* Parameters for joining a Zoom RTMS session
306306
*
307307
* For Meeting SDK events (meeting.rtms_started), use meeting_uuid.
308+
* For Webinar events (webinar.rtms_started), use webinar_uuid.
308309
* For Video SDK events (session.rtms_started), use session_id.
309-
* If both are provided, meeting_uuid takes precedence.
310+
* Priority: meeting_uuid > webinar_uuid > session_id.
310311
*
311312
* @category Common Interfaces
312313
*/
313314
export interface JoinParams {
314315
/** The UUID of the Zoom meeting (for Meeting SDK events) */
315316
meeting_uuid?: string;
317+
/** The UUID of the Zoom webinar (for Webinar events) */
318+
webinar_uuid?: string;
316319
/** The session ID (for Video SDK events) - used when meeting_uuid is not provided */
317320
session_id?: string;
318321
/** The RTMS stream ID for this connection */
@@ -876,6 +879,23 @@ export class Client {
876879
*/
877880
onSharingEvent(callback: SharingEventCallback): boolean;
878881

882+
/**
883+
* Sets a callback for media connection interrupted events
884+
*
885+
* This automatically subscribes to EVENT_MEDIA_CONNECTION_INTERRUPTED.
886+
*
887+
* @param callback The callback function to invoke with the event timestamp
888+
* @returns true if the callback was set successfully
889+
*
890+
* @example
891+
* ```typescript
892+
* client.onMediaConnectionInterrupted((timestamp) => {
893+
* console.log(`Media connection interrupted at ${timestamp}`);
894+
* });
895+
* ```
896+
*/
897+
onMediaConnectionInterrupted(callback: (timestamp: number) => void): boolean;
898+
879899
/**
880900
* Sets a callback for raw event data
881901
*

src/rtms.cpp

Lines changed: 33 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -461,33 +461,37 @@ void Client::uninitialize() {
461461
rtms_uninit();
462462
}
463463

464-
void Client::configure(const MediaParams& params, int media_types, bool enable_application_layer_encryption) {
464+
void Client::configure(const MediaParams& params, int media_types, bool enable_application_layer_encryption, bool apply_defaults) {
465465
// Store the media parameters for future use
466466
media_params_ = params;
467467
enabled_media_types_ = media_types;
468468
media_params_updated_ = true;
469469

470470
// If media types are enabled but no params were set, use sensible defaults
471471
// This ensures proper metadata attribution (e.g., AUDIO_MULTI_STREAMS for audio)
472-
if ((media_types & MediaType::AUDIO) && !media_params_.hasAudioParams()) {
472+
// Skip defaults when called from setAudioParams/setVideoParams/setDeskshareParams
473+
// to avoid prematurely filling in default params for other media types
474+
if (apply_defaults) {
475+
if ((media_types & MediaType::AUDIO) && !media_params_.hasAudioParams()) {
473476
#ifdef RTMS_DEBUG
474-
cerr << "[DEBUG CONFIG] Audio enabled but no params set, using defaults (AUDIO_MULTI_STREAMS)" << endl;
477+
cerr << "[DEBUG CONFIG] Audio enabled but no params set, using defaults (AUDIO_MULTI_STREAMS)" << endl;
475478
#endif
476-
media_params_.setAudioParams(AudioParams());
477-
}
479+
media_params_.setAudioParams(AudioParams());
480+
}
478481

479-
if ((media_types & MediaType::VIDEO) && !media_params_.hasVideoParams()) {
482+
if ((media_types & MediaType::VIDEO) && !media_params_.hasVideoParams()) {
480483
#ifdef RTMS_DEBUG
481-
cerr << "[DEBUG CONFIG] Video enabled but no params set, using defaults" << endl;
484+
cerr << "[DEBUG CONFIG] Video enabled but no params set, using defaults" << endl;
482485
#endif
483-
media_params_.setVideoParams(VideoParams());
484-
}
486+
media_params_.setVideoParams(VideoParams());
487+
}
485488

486-
if ((media_types & MediaType::DESKSHARE) && !media_params_.hasDeskshareParams()) {
489+
if ((media_types & MediaType::DESKSHARE) && !media_params_.hasDeskshareParams()) {
487490
#ifdef RTMS_DEBUG
488-
cerr << "[DEBUG CONFIG] Deskshare enabled but no params set, using defaults" << endl;
491+
cerr << "[DEBUG CONFIG] Deskshare enabled but no params set, using defaults" << endl;
489492
#endif
490-
media_params_.setDeskshareParams(DeskshareParams());
493+
media_params_.setDeskshareParams(DeskshareParams());
494+
}
491495
}
492496

493497
if (!media_params_.hasAudioParams() && !media_params_.hasVideoParams() && !media_params_.hasDeskshareParams()) {
@@ -568,8 +572,11 @@ void Client::setOnSessionUpdate(SessionUpdateFn callback) {
568572
}
569573

570574
void Client::setOnUserUpdate(UserUpdateFn callback) {
571-
lock_guard<mutex> lock(mutex_);
572-
user_update_callback_ = std::move(callback);
575+
{
576+
lock_guard<mutex> lock(mutex_);
577+
user_update_callback_ = std::move(callback);
578+
}
579+
subscribeEvent({EVENT_ACTIVE_SPEAKER_CHANGE, EVENT_PARTICIPANT_JOIN, EVENT_PARTICIPANT_LEAVE});
573580
}
574581

575582
void Client::setOnDeskshareData(DsDataFn callback){
@@ -674,9 +681,10 @@ void Client::setDeskshareParams(const DeskshareParams& ds_params)
674681
media_params_.setDeskshareParams(ds_params);
675682

676683
// If deskshare is already enabled, reconfigure with the new params
684+
// Pass apply_defaults=false to avoid filling in defaults for other media types
677685
if (enabled_media_types_ & MediaType::DESKSHARE) {
678686
try {
679-
configure(media_params_, enabled_media_types_, false);
687+
configure(media_params_, enabled_media_types_, false, false);
680688
} catch (const Exception& e) {
681689
cerr << "Warning: Failed to reconfigure deskshare params: " << e.what() << endl;
682690
}
@@ -691,9 +699,10 @@ void Client::setVideoParams(const VideoParams& video_params)
691699
media_params_.setVideoParams(video_params);
692700

693701
// If video is already enabled, reconfigure with the new params
702+
// Pass apply_defaults=false to avoid filling in defaults for other media types
694703
if (enabled_media_types_ & MediaType::VIDEO) {
695704
try {
696-
configure(media_params_, enabled_media_types_, false);
705+
configure(media_params_, enabled_media_types_, false, false);
697706
} catch (const Exception& e) {
698707
cerr << "Warning: Failed to reconfigure video params: " << e.what() << endl;
699708
}
@@ -708,9 +717,10 @@ void Client::setAudioParams(const AudioParams& audio_params)
708717
media_params_.setAudioParams(audio_params);
709718

710719
// If audio is already enabled, reconfigure with the new params
720+
// Pass apply_defaults=false to avoid filling in defaults for other media types
711721
if (enabled_media_types_ & MediaType::AUDIO) {
712722
try {
713-
configure(media_params_, enabled_media_types_, false);
723+
configure(media_params_, enabled_media_types_, false, false);
714724
} catch (const Exception& e) {
715725
cerr << "Warning: Failed to reconfigure audio params: " << e.what() << endl;
716726
}
@@ -758,8 +768,8 @@ void Client::poll() {
758768

759769
void Client::release() {
760770
int result = rtms_release(sdk_);
761-
throwIfError(result, "release");
762771

772+
// Always clean up, even if release returned an error
763773
{
764774
lock_guard<mutex> lock(registry_mutex_);
765775
sdk_registry_.erase(sdk_);
@@ -774,6 +784,11 @@ void Client::release() {
774784
}
775785

776786
sdk_ = nullptr;
787+
788+
// RTMS_SDK_NOT_EXIST means the resource was already released — that's fine
789+
if (result != RTMS_SDK_OK && result != RTMS_SDK_NOT_EXIST) {
790+
throwIfError(result, "release");
791+
}
777792
}
778793

779794
string Client::uuid() const {

src/rtms.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ class Client {
266266

267267
static void initialize(const string& ca, int is_verify_cert = 1, const char* agent = nullptr);
268268
static void uninitialize();
269-
void configure(const MediaParams& params, int media_types, bool enable_application_layer_encryption = false);
269+
void configure(const MediaParams& params, int media_types, bool enable_application_layer_encryption = false, bool apply_defaults = true);
270270

271271
void enableVideo(bool enable);
272272
void enableAudio(bool enable);

0 commit comments

Comments
 (0)