Skip to main content

videosdk/resources/
sessions.rs

1//! The sessions API: live and past sessions, and their participants.
2
3use std::sync::Arc;
4
5use futures_util::Stream;
6use reqwest::Method;
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value};
9
10use crate::client::{CallOptions, Client};
11use crate::common::{string_enum, ResourceLinks};
12use crate::error::{Error, Result};
13use crate::pagination::{auto_page, paginate, ListParams, Page, PageFetcher};
14use crate::query::QueryBuilder;
15use crate::resources::escape;
16use crate::resources::session_utils::reshape_session_participants;
17
18const PATH: &str = "/v2/sessions";
19
20string_enum! {
21    /// A session's lifecycle state.
22    ///
23    /// This rides on every [`Session`] response, so it is an open string newtype:
24    /// a closed enum would fail to deserialize — taking the whole session with
25    /// it — the moment the API adds a state this SDK predates.
26    SessionStatus {
27        /// The session is live.
28        ONGOING => "ongoing",
29        /// The session has ended.
30        ENDED => "ended",
31    }
32}
33
34/// A loosely-typed quality-of-service statistics payload.
35///
36/// The shape varies by endpoint and is not modelled.
37pub type QualityStats = Value;
38
39/// A single join/leave interval for a participant.
40#[derive(Debug, Clone, Deserialize)]
41pub struct ParticipantTimelog {
42    /// When the participant joined.
43    #[serde(default, deserialize_with = "crate::common::null_to_default")]
44    pub start: String,
45    /// When the participant left, or `None` if they are still present.
46    pub end: Option<String>,
47}
48
49/// A participant within a session.
50#[derive(Debug, Clone, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct Participant {
53    /// The participant's id.
54    pub participant_id: String,
55    /// The participant's display name.
56    #[serde(default, deserialize_with = "crate::common::null_to_default")]
57    pub name: String,
58    /// Every join/leave interval for this participant.
59    #[serde(default, deserialize_with = "crate::common::null_to_default")]
60    pub timelog: Vec<ParticipantTimelog>,
61    /// Any fields the server returned that this SDK does not model yet.
62    #[serde(flatten)]
63    pub extra: Map<String, Value>,
64}
65
66/// A session: a single live occupancy of a room.
67#[derive(Debug, Clone, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct Session {
70    /// The session id.
71    pub id: String,
72    /// The room this session belongs to.
73    ///
74    /// Always populated: [`SessionsResource::end`] folds the server's
75    /// `meetingId` alias into it.
76    #[serde(default, deserialize_with = "crate::common::null_to_default")]
77    pub room_id: String,
78    /// The room's caller-supplied stable id, if one was given.
79    pub custom_room_id: Option<String>,
80    /// When the session started.
81    #[serde(default, deserialize_with = "crate::common::null_to_default")]
82    pub start: String,
83    /// When the session ended, or `None` if it is still live.
84    pub end: Option<String>,
85    /// The session's lifecycle state.
86    pub status: Option<SessionStatus>,
87    /// The participants seen in this session.
88    #[serde(default, deserialize_with = "crate::common::null_to_default")]
89    pub participants: Vec<Participant>,
90    /// The region the session ran in.
91    #[serde(default, deserialize_with = "crate::common::null_to_default")]
92    pub region: String,
93    /// HATEOAS-style links to related resources.
94    #[serde(default, deserialize_with = "crate::common::null_to_default")]
95    pub links: ResourceLinks,
96    /// Any fields the server returned that this SDK does not model yet.
97    #[serde(flatten)]
98    pub extra: Map<String, Value>,
99}
100
101/// The query parameters for [`SessionsResource::list`].
102#[derive(Debug, Clone, Default)]
103pub struct SessionListParams {
104    /// The 1-based page number.
105    pub page: Option<u32>,
106    /// Items per page.
107    pub per_page: Option<u32>,
108    /// An opaque cursor from a previous page. Overrides `page` and `per_page`.
109    pub cursor: Option<String>,
110    /// An exact match on the room's `room_id` or `custom_room_id`.
111    pub query: Option<String>,
112    /// Filters by room id.
113    pub room_id: Option<String>,
114    /// Filters by custom room id.
115    pub custom_room_id: Option<String>,
116    /// Scopes to a specific user. Admin tokens only.
117    pub user_id: Option<String>,
118    /// Filters by session start, in epoch milliseconds. Start of the range.
119    pub start_date: Option<i64>,
120    /// Filters by session start, in epoch milliseconds. End of the range.
121    pub end_date: Option<i64>,
122    /// Filters by lifecycle state.
123    pub status: Option<SessionStatus>,
124}
125
126impl SessionListParams {
127    fn pagination(&self) -> ListParams {
128        ListParams {
129            page: self.page,
130            per_page: self.per_page,
131            cursor: self.cursor.clone(),
132        }
133    }
134}
135
136/// The parameters for [`SessionsResource::end`].
137#[derive(Debug, Clone, Default)]
138pub struct SessionEndParams {
139    /// The room id. One of `room_id` or `meeting_id` is required.
140    pub room_id: Option<String>,
141    /// An alias for `room_id`.
142    pub meeting_id: Option<String>,
143    /// Restricts the action to a specific session.
144    pub session_id: Option<String>,
145    /// Force-kills the session.
146    pub force: Option<bool>,
147    /// Tolerates an already-closed session.
148    pub ignore_closed: Option<bool>,
149}
150
151#[derive(Debug, Serialize)]
152#[serde(rename_all = "camelCase")]
153struct SessionEndWire<'a> {
154    room_id: &'a str,
155    #[serde(skip_serializing_if = "Option::is_none")]
156    session_id: Option<&'a str>,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    ignore_closed: Option<bool>,
159    #[serde(rename = "vsdkForceKill", skip_serializing_if = "Option::is_none")]
160    force: Option<bool>,
161}
162
163/// The parameters for [`SessionsResource::remove_participant`].
164#[derive(Debug, Clone, Default)]
165pub struct SessionRemoveParticipantParams {
166    /// The participant to remove. Required.
167    pub participant_id: String,
168    /// The room id. One of `room_id` or `session_id` is required.
169    pub room_id: Option<String>,
170    /// Restricts the action to a specific session.
171    pub session_id: Option<String>,
172}
173
174#[derive(Debug, Serialize)]
175#[serde(rename_all = "camelCase")]
176struct RemoveParticipantWire<'a> {
177    participant_id: &'a str,
178    #[serde(skip_serializing_if = "Option::is_none")]
179    room_id: Option<&'a str>,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    session_id: Option<&'a str>,
182}
183
184/// The sessions API. Reached via [`Client::sessions`].
185#[derive(Debug, Clone, Copy)]
186pub struct SessionsResource<'a> {
187    client: &'a Client,
188}
189
190impl<'a> SessionsResource<'a> {
191    pub(crate) fn new(client: &'a Client) -> Self {
192        Self { client }
193    }
194
195    /// Lists sessions, one page at a time.
196    pub async fn list(&self, params: SessionListParams) -> Result<Page<Session>> {
197        paginate(self.fetcher(&params), &params.pagination(), "data", None).await
198    }
199
200    /// Lists sessions, transparently fetching every page.
201    pub fn list_stream(
202        &self,
203        params: SessionListParams,
204    ) -> impl Stream<Item = Result<Session>> + Send {
205        auto_page(self.fetcher(&params), params.pagination(), "data", None)
206    }
207
208    /// Fetches a session by id.
209    pub async fn get(&self, session_id: &str) -> Result<Session> {
210        let path = format!("{PATH}/{}", escape(session_id));
211        self.client
212            .json(Method::GET, &path, CallOptions::new())
213            .await
214    }
215
216    /// Lists every participant seen in a session, one page at a time.
217    pub async fn list_participants(
218        &self,
219        session_id: &str,
220        params: ListParams,
221    ) -> Result<Page<Participant>> {
222        let path = format!("{PATH}/{}/participants", escape(session_id));
223        paginate(
224            participants_fetcher(self.client, path),
225            &params,
226            "data",
227            None,
228        )
229        .await
230    }
231
232    /// Lists a session's currently-active participants, one page at a time.
233    pub async fn list_active_participants(
234        &self,
235        session_id: &str,
236        params: ListParams,
237    ) -> Result<Page<Participant>> {
238        let path = format!("{PATH}/{}/participants/active", escape(session_id));
239        paginate(
240            participants_fetcher(self.client, path),
241            &params,
242            "data",
243            None,
244        )
245        .await
246    }
247
248    /// Fetches a single participant within a session.
249    pub async fn get_participant(
250        &self,
251        session_id: &str,
252        participant_id: &str,
253    ) -> Result<Participant> {
254        let path = format!(
255            "{PATH}/{}/participants/{}",
256            escape(session_id),
257            escape(participant_id)
258        );
259        self.client
260            .json(Method::GET, &path, CallOptions::new())
261            .await
262    }
263
264    /// Ends a live session and returns the now-ended session.
265    pub async fn end(&self, params: SessionEndParams) -> Result<Session> {
266        let room_id = params
267            .room_id
268            .as_deref()
269            .or(params.meeting_id.as_deref())
270            .filter(|id| !id.is_empty())
271            .ok_or_else(|| Error::validation("sessions.end requires room_id (or meeting_id)"))?;
272
273        // The server resolves the room only via `roomId`; fold the alias into it.
274        let body = SessionEndWire {
275            room_id,
276            session_id: params.session_id.as_deref(),
277            ignore_closed: params.ignore_closed,
278            force: params.force,
279        };
280
281        let path = format!("{PATH}/end");
282        let session: Session = self
283            .client
284            .json(Method::POST, &path, CallOptions::json(&body)?)
285            .await?;
286        Ok(normalize_ended_session(session))
287    }
288
289    /// Removes (kicks) a participant from a live session, returning the server's
290    /// confirmation message.
291    pub async fn remove_participant(
292        &self,
293        params: SessionRemoveParticipantParams,
294    ) -> Result<String> {
295        let room_id = params.room_id.as_deref().filter(|id| !id.is_empty());
296        let session_id = params.session_id.as_deref().filter(|id| !id.is_empty());
297        if room_id.is_none() && session_id.is_none() {
298            return Err(Error::validation(
299                "sessions.remove_participant requires room_id or session_id to locate the session",
300            ));
301        }
302
303        let body = RemoveParticipantWire {
304            participant_id: &params.participant_id,
305            room_id,
306            session_id,
307        };
308        self.client
309            .message(
310                Method::POST,
311                "/v2/sessions/participants/remove",
312                CallOptions::json(&body)?,
313            )
314            .await
315    }
316
317    /// Fetches aggregated session-level quality stats.
318    pub async fn get_stats(&self, session_id: &str) -> Result<QualityStats> {
319        let path = format!("{PATH}/{}/stats", escape(session_id));
320        self.client
321            .json(Method::GET, &path, CallOptions::new())
322            .await
323    }
324
325    /// Fetches per-participant quality stats, plus resolution usage.
326    pub async fn get_participant_stats(
327        &self,
328        session_id: &str,
329        participant_id: &str,
330    ) -> Result<QualityStats> {
331        let path = format!(
332            "{PATH}/{}/participant/{}/stats",
333            escape(session_id),
334            escape(participant_id)
335        );
336        self.client
337            .json(Method::GET, &path, CallOptions::new())
338            .await
339    }
340
341    fn fetcher(&self, params: &SessionListParams) -> PageFetcher {
342        let client = self.client.clone();
343        let params = params.clone();
344
345        Arc::new(move |page, per_page| {
346            let client = client.clone();
347            let params = params.clone();
348            Box::pin(async move {
349                let query = QueryBuilder::new()
350                    .opt("page", page)
351                    .opt("perPage", per_page)
352                    .opt_str("query", params.query.as_deref())
353                    .opt_str("roomId", params.room_id.as_deref())
354                    .opt_str("customRoomId", params.custom_room_id.as_deref())
355                    .opt_str("userId", params.user_id.as_deref())
356                    .opt("startDate", params.start_date)
357                    .opt("endDate", params.end_date)
358                    .opt_str("status", params.status.as_ref().map(SessionStatus::as_str))
359                    .into_pairs();
360                client
361                    .json::<Value>(Method::GET, PATH, CallOptions::new().query(query))
362                    .await
363            })
364        })
365    }
366}
367
368/// Builds a fetcher that lifts participants out of the session-shaped envelope.
369pub(crate) fn participants_fetcher(client: &Client, path: String) -> PageFetcher {
370    let client = client.clone();
371    Arc::new(move |page, per_page| {
372        let client = client.clone();
373        let path = path.clone();
374        Box::pin(async move {
375            let query = QueryBuilder::new()
376                .opt("page", page)
377                .opt("perPage", per_page)
378                .into_pairs();
379            let raw: Value = client
380                .json(Method::GET, &path, CallOptions::new().query(query))
381                .await?;
382            Ok(reshape_session_participants(raw))
383        })
384    })
385}
386
387/// `POST /v2/sessions/end` answers with the legacy `transformForUser` shape:
388/// `meetingId`/`userMeetingId` instead of `roomId`/`customRoomId`, and no
389/// `status`. Normalize it to match every other session response.
390fn normalize_ended_session(mut session: Session) -> Session {
391    if session.room_id.is_empty() {
392        if let Some(meeting_id) = session.extra.get("meetingId").and_then(Value::as_str) {
393            session.room_id = meeting_id.to_string();
394        }
395    }
396    if session.custom_room_id.is_none() {
397        session.custom_room_id = session
398            .extra
399            .get("userMeetingId")
400            .and_then(Value::as_str)
401            .map(str::to_string);
402    }
403    if session.status.is_none() {
404        session.status = Some(if session.end.is_none() {
405            SessionStatus::ONGOING
406        } else {
407            SessionStatus::ENDED
408        });
409    }
410    session
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use serde_json::json;
417
418    fn session(value: Value) -> Session {
419        serde_json::from_value(value).unwrap()
420    }
421
422    #[test]
423    fn end_wire_renames_force_to_vsdk_force_kill() {
424        let body = serde_json::to_value(SessionEndWire {
425            room_id: "r-1",
426            session_id: None,
427            ignore_closed: None,
428            force: Some(true),
429        })
430        .unwrap();
431        assert_eq!(body, json!({"roomId": "r-1", "vsdkForceKill": true}));
432    }
433
434    #[test]
435    fn end_wire_omits_absent_options() {
436        let body = serde_json::to_value(SessionEndWire {
437            room_id: "r-1",
438            session_id: Some("s-1"),
439            ignore_closed: Some(false),
440            force: None,
441        })
442        .unwrap();
443        assert_eq!(
444            body,
445            json!({"roomId": "r-1", "sessionId": "s-1", "ignoreClosed": false})
446        );
447    }
448
449    #[test]
450    fn ended_session_folds_the_meeting_id_aliases() {
451        let normalized = normalize_ended_session(session(json!({
452            "id": "s-1",
453            "meetingId": "abcd-efgh-ijkl",
454            "userMeetingId": "my-room",
455            "end": "2026-01-01T00:00:00Z",
456        })));
457        assert_eq!(normalized.room_id, "abcd-efgh-ijkl");
458        assert_eq!(normalized.custom_room_id.as_deref(), Some("my-room"));
459        assert_eq!(normalized.status, Some(SessionStatus::ENDED));
460    }
461
462    #[test]
463    fn ended_session_derives_ongoing_when_end_is_absent() {
464        let normalized = normalize_ended_session(session(json!({"id": "s-1", "roomId": "r-1"})));
465        assert_eq!(normalized.status, Some(SessionStatus::ONGOING));
466        assert_eq!(
467            normalized.room_id, "r-1",
468            "a real roomId is not overwritten"
469        );
470    }
471
472    #[test]
473    fn ended_session_keeps_a_server_supplied_status() {
474        let normalized = normalize_ended_session(session(json!({
475            "id": "s-1", "roomId": "r-1", "status": "ongoing", "end": "2026-01-01T00:00:00Z",
476        })));
477        assert_eq!(normalized.status, Some(SessionStatus::ONGOING));
478    }
479
480    #[test]
481    fn session_status_round_trips() {
482        assert_eq!(
483            serde_json::to_value(SessionStatus::ONGOING).unwrap(),
484            json!("ongoing")
485        );
486        assert_eq!(SessionStatus::ENDED.as_str(), "ended");
487    }
488}