Skip to main content

videosdk/resources/
recordings.rs

1//! The recordings API: room, participant, track, composite and merge.
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::{
12    CompositionConfig, OnFailureConfig, ParticipantFileFormat, RecordingFile, RecordingFileFormat,
13    ResourceLinks, TrackFileFormat, TrackKind, TranscriptionConfig, WebhookDeliverySummary,
14};
15use crate::error::{Error, Result};
16use crate::pagination::{auto_page, paginate, ListParams, Page, PageFetcher};
17use crate::query::QueryBuilder;
18use crate::resources::egress::{
19    composition_to_config, to_egress_handle, Composition, EgressHandle, EgressType, StopTarget,
20    StopWire,
21};
22use crate::resources::escape;
23
24const PATH: &str = "/v2/recordings";
25const PARTICIPANT_PATH: &str = "/v2/recordings/participant";
26const TRACK_PATH: &str = "/v2/recordings/participant/track";
27const COMPOSITE_PATH: &str = "/v2/recordings/composite";
28const MERGE_PATH: &str = "/v2/recordings/participant/merge";
29
30/* -------------------------------- response types ------------------------------- */
31
32/// A composited room recording.
33#[derive(Debug, Clone, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct Recording {
36    /// The recording id.
37    pub id: String,
38    /// The room that was recorded.
39    pub room_id: Option<String>,
40    /// The produced file.
41    pub file: Option<RecordingFile>,
42    /// A summary of the webhook deliveries for this recording.
43    pub webhook: Option<WebhookDeliverySummary>,
44    /// When the recording started.
45    pub start: Option<String>,
46    /// When the recording ended, or `None` if it is still running.
47    pub end: Option<String>,
48    /// HATEOAS-style links to related resources.
49    #[serde(default, deserialize_with = "crate::common::null_to_default")]
50    pub links: ResourceLinks,
51    /// Any fields the server returned that this SDK does not model yet.
52    #[serde(flatten)]
53    pub extra: Map<String, Value>,
54}
55
56/// A participant recording, or its per-track variant.
57#[derive(Debug, Clone, Deserialize)]
58#[serde(rename_all = "camelCase")]
59pub struct IndividualRecording {
60    /// The recording id.
61    pub id: String,
62    /// The room that was recorded.
63    pub room_id: Option<String>,
64    /// The recorded participant's display name.
65    pub participant_name: Option<String>,
66    /// The produced files.
67    #[serde(default, deserialize_with = "crate::common::null_to_default")]
68    pub files: Vec<RecordingFile>,
69    /// A summary of the webhook deliveries for this recording.
70    pub webhook: Option<WebhookDeliverySummary>,
71    /// When the recording started.
72    pub start: Option<String>,
73    /// When the recording ended, or `None` if it is still running.
74    pub end: Option<String>,
75    /// Any fields the server returned that this SDK does not model yet.
76    #[serde(flatten)]
77    pub extra: Map<String, Value>,
78}
79
80/// A server-side composed recording.
81#[derive(Debug, Clone, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct CompositeRecording {
84    /// The recording id.
85    pub id: String,
86    /// The legacy alias of [`room_id`](CompositeRecording::room_id).
87    pub meeting_id: Option<String>,
88    /// The room that was recorded.
89    pub room_id: Option<String>,
90    /// The session that was recorded.
91    pub session_id: Option<String>,
92    /// The participants that were composed.
93    #[serde(default, deserialize_with = "crate::common::null_to_default")]
94    pub participants: Vec<Value>,
95    /// The produced file's format.
96    pub file_format: Option<String>,
97    /// When the recording started.
98    pub start: Option<String>,
99    /// When the recording ended, or `None` if it is still running.
100    pub end: Option<String>,
101    /// The produced file's id.
102    pub file_id: Option<String>,
103    /// The produced file.
104    pub file: Option<RecordingFile>,
105    /// Any fields the server returned that this SDK does not model yet.
106    #[serde(flatten)]
107    pub extra: Map<String, Value>,
108}
109
110/// A single channel entry on a merge recording.
111#[derive(Debug, Clone, Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct MergeChannel {
114    /// The source recording.
115    pub recording_id: Option<String>,
116    /// The source participant.
117    pub participant_id: Option<String>,
118}
119
120/// A merged stereo-audio recording.
121#[derive(Debug, Clone, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub struct MergeRecording {
124    /// The merge (muxer) id.
125    pub id: String,
126    /// The left channel's sources.
127    #[serde(default, deserialize_with = "crate::common::null_to_default")]
128    pub channel1: Vec<MergeChannel>,
129    /// The right channel's sources.
130    #[serde(default, deserialize_with = "crate::common::null_to_default")]
131    pub channel2: Vec<MergeChannel>,
132    /// When the merge started.
133    pub start: Option<String>,
134    /// When the merge finished.
135    pub end: Option<String>,
136    /// The merge job's status.
137    pub status: Option<String>,
138    /// The room whose recordings were merged.
139    pub meeting_id: Option<String>,
140    /// The session whose recordings were merged.
141    pub session_id: Option<String>,
142    /// The produced file.
143    pub file: Option<RecordingFile>,
144    /// Any fields the server returned that this SDK does not model yet.
145    #[serde(flatten)]
146    pub extra: Map<String, Value>,
147}
148
149/// The result of starting a merge job.
150#[derive(Debug, Clone, Deserialize)]
151pub struct MergeRecordingResult {
152    /// The server's confirmation message.
153    pub message: String,
154    /// The merge job that was created.
155    pub recording: MergeRecording,
156}
157
158/* -------------------------------- request types -------------------------------- */
159
160/// The parameters for [`RecordingsResource::start`], a room recording.
161#[derive(Debug, Clone, Default)]
162pub struct RecordingStartParams {
163    /// The layout, quality, orientation and theme.
164    pub composition: Option<Composition>,
165    /// Transcribes and optionally summarizes the recording.
166    pub transcription: Option<TranscriptionConfig>,
167    /// What to do if the composition fails.
168    pub on_failure: Option<OnFailureConfig>,
169    /// The output format. Defaults to `mp4`.
170    pub file_format: Option<RecordingFileFormat>,
171    /// A webhook to notify when the recording completes.
172    pub webhook_url: Option<String>,
173    /// The storage path prefix for the output files.
174    pub dir_path: Option<String>,
175    /// A pre-acquired resource-pool unit id.
176    pub resource_id: Option<String>,
177    /// A presigned URL to upload the output to.
178    pub pre_signed_url: Option<String>,
179}
180
181/// `dir_path` becomes `awsDirPath` here. Every other recording endpoint calls the
182/// same field `bucketDirPath` or `dirPath` — the name is per-endpoint.
183#[derive(Debug, Serialize)]
184#[serde(rename_all = "camelCase")]
185struct RecordingStartWire<'a> {
186    room_id: &'a str,
187    #[serde(skip_serializing_if = "Option::is_none")]
188    config: Option<CompositionConfig>,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    template_url: Option<String>,
191    #[serde(skip_serializing_if = "Option::is_none")]
192    aws_dir_path: Option<&'a str>,
193    #[serde(skip_serializing_if = "Option::is_none")]
194    transcription: Option<&'a TranscriptionConfig>,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    on_failure: Option<&'a OnFailureConfig>,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    file_format: Option<RecordingFileFormat>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    webhook_url: Option<&'a str>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    resource_id: Option<&'a str>,
203    /// Lowercase `url` here, unlike composite's `preSignedURL`.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pre_signed_url: Option<&'a str>,
206}
207
208/// The parameters for [`RecordingsResource::get`].
209#[derive(Debug, Clone, Default)]
210pub struct RecordingGetParams {
211    /// Includes transcription data in the response.
212    pub with_transcription: Option<bool>,
213}
214
215/// The parameters for [`RecordingParticipantResource::start`].
216#[derive(Debug, Clone, Default)]
217pub struct ParticipantRecordingStartParams {
218    /// The participant to record. Required.
219    pub participant_id: String,
220    /// The output format. Defaults to `webm`.
221    pub file_format: Option<ParticipantFileFormat>,
222    /// A webhook to notify when the recording completes.
223    pub webhook_url: Option<String>,
224    /// The storage path prefix for the output files.
225    pub dir_path: Option<String>,
226    /// Arbitrary metadata stored with the recording.
227    pub metadata: Option<Value>,
228}
229
230#[derive(Debug, Serialize)]
231#[serde(rename_all = "camelCase")]
232struct ParticipantRecordingStartWire<'a> {
233    room_id: &'a str,
234    participant_id: &'a str,
235    #[serde(skip_serializing_if = "Option::is_none")]
236    file_format: Option<ParticipantFileFormat>,
237    #[serde(skip_serializing_if = "Option::is_none")]
238    webhook_url: Option<&'a str>,
239    #[serde(skip_serializing_if = "Option::is_none")]
240    bucket_dir_path: Option<&'a str>,
241    #[serde(skip_serializing_if = "Option::is_none")]
242    metadata: Option<&'a Value>,
243}
244
245/// The parameters for [`RecordingTrackResource::start`].
246#[derive(Debug, Clone)]
247pub struct TrackRecordingStartParams {
248    /// The participant whose track to record. Required.
249    pub participant_id: String,
250    /// The media kind to record. Required.
251    pub kind: TrackKind,
252    /// The output format. Defaults to `webm`.
253    pub file_format: Option<TrackFileFormat>,
254    /// A webhook to notify when the recording completes.
255    pub webhook_url: Option<String>,
256    /// The storage path prefix for the output files.
257    pub dir_path: Option<String>,
258}
259
260#[derive(Debug, Serialize)]
261#[serde(rename_all = "camelCase")]
262struct TrackRecordingStartWire<'a> {
263    room_id: &'a str,
264    participant_id: &'a str,
265    kind: TrackKind,
266    #[serde(skip_serializing_if = "Option::is_none")]
267    file_format: Option<TrackFileFormat>,
268    #[serde(skip_serializing_if = "Option::is_none")]
269    webhook_url: Option<&'a str>,
270    #[serde(skip_serializing_if = "Option::is_none")]
271    bucket_dir_path: Option<&'a str>,
272}
273
274/// Selects a participant, and optionally which of their tracks, to compose.
275#[derive(Debug, Clone, Serialize)]
276#[serde(rename_all = "camelCase")]
277pub struct CompositeParticipantSelector {
278    /// The participant to compose.
279    pub participant_id: String,
280    /// The media kinds to compose. Defaults to all four when empty.
281    #[serde(skip_serializing_if = "Vec::is_empty")]
282    pub kind: Vec<TrackKind>,
283}
284
285/// A watermark applied to a composite recording.
286#[derive(Debug, Clone, Default, Serialize)]
287#[serde(rename_all = "camelCase")]
288pub struct CompositeWatermark {
289    /// Either `custom` or `pubsub`.
290    #[serde(rename = "type")]
291    pub kind: String,
292    /// The watermark image's URL.
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub image_url: Option<String>,
295    /// The watermark image, base64-encoded.
296    #[serde(skip_serializing_if = "Option::is_none")]
297    pub image_base64: Option<String>,
298    /// Watermark text.
299    #[serde(skip_serializing_if = "Option::is_none")]
300    pub text: Option<String>,
301    /// An IANA timezone, for the `custom` kind.
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub timezone: Option<String>,
304    /// The pub/sub topic, for the `pubsub` kind.
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub topic: Option<String>,
307}
308
309/// The parameters for [`RecordingCompositeResource::start`].
310#[derive(Debug, Clone, Default)]
311pub struct CompositeRecordingStartParams {
312    /// The participants to compose. Empty lets the recorder decide.
313    pub participants: Vec<CompositeParticipantSelector>,
314    /// Up to three watermarks.
315    pub watermarks: Vec<CompositeWatermark>,
316    /// A webhook to notify when the recording completes.
317    pub webhook_url: Option<String>,
318    /// The storage path prefix for the output files.
319    pub dir_path: Option<String>,
320    /// A presigned URL to upload the output to.
321    pub pre_signed_url: Option<String>,
322    /// The output format.
323    pub file_format: Option<String>,
324}
325
326#[derive(Debug, Serialize)]
327#[serde(rename_all = "camelCase")]
328struct CompositeRecordingStartWire<'a> {
329    room_id: &'a str,
330    #[serde(skip_serializing_if = "<[_]>::is_empty")]
331    participants: &'a [CompositeParticipantSelector],
332    #[serde(skip_serializing_if = "<[_]>::is_empty")]
333    watermarks: &'a [CompositeWatermark],
334    #[serde(skip_serializing_if = "Option::is_none")]
335    webhook_url: Option<&'a str>,
336    #[serde(skip_serializing_if = "Option::is_none")]
337    bucket_dir_path: Option<&'a str>,
338    /// Capital `URL` here, unlike the room recording's `preSignedUrl`.
339    #[serde(rename = "preSignedURL", skip_serializing_if = "Option::is_none")]
340    pre_signed_url: Option<&'a str>,
341    #[serde(skip_serializing_if = "Option::is_none")]
342    file_format: Option<&'a str>,
343}
344
345/// One source in a merge channel.
346#[derive(Debug, Clone, Serialize)]
347#[serde(rename_all = "camelCase")]
348pub struct MergeChannelEntry {
349    /// The participant to take audio from.
350    pub participant_id: String,
351    /// The source recording. Resolved from the participant when omitted.
352    #[serde(skip_serializing_if = "Option::is_none")]
353    pub recording_id: Option<String>,
354    /// Either `participant` or `track`.
355    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
356    pub kind: Option<String>,
357}
358
359/// The parameters for [`MergeRecordingResource::create`].
360#[derive(Debug, Clone, Default, Serialize)]
361#[serde(rename_all = "camelCase")]
362pub struct MergeRecordingCreateParams {
363    /// The session whose participant audio to merge. Required.
364    pub session_id: String,
365    /// The left channel's sources. Required, non-empty.
366    pub channel1: Vec<MergeChannelEntry>,
367    /// The right channel's sources. Required, non-empty.
368    pub channel2: Vec<MergeChannelEntry>,
369    /// A global override for each channel entry's type.
370    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
371    pub kind: Option<String>,
372    /// A webhook to notify when the merge completes.
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub webhook_url: Option<String>,
375    /// The storage path prefix. Named `dirPath` here, unlike its siblings.
376    #[serde(skip_serializing_if = "Option::is_none")]
377    pub dir_path: Option<String>,
378}
379
380/* --------------------------------- list params --------------------------------- */
381
382/// The query parameters for [`RecordingsResource::list`].
383#[derive(Debug, Clone, Default)]
384pub struct RecordingListParams {
385    /// The 1-based page number.
386    pub page: Option<u32>,
387    /// Items per page.
388    pub per_page: Option<u32>,
389    /// An opaque cursor from a previous page.
390    pub cursor: Option<String>,
391    /// Filters by room id.
392    pub room_id: Option<String>,
393    /// Filters by session id.
394    pub session_id: Option<String>,
395    /// Scopes to a specific user. Admin tokens only.
396    pub user_id: Option<String>,
397    /// Matches the room's meeting id.
398    pub query: Option<String>,
399    /// Includes transcription data in the response.
400    pub with_transcription: Option<bool>,
401}
402
403/// The query parameters for the participant and track recording lists.
404#[derive(Debug, Clone, Default)]
405pub struct IndividualRecordingListParams {
406    /// The 1-based page number.
407    pub page: Option<u32>,
408    /// Items per page.
409    pub per_page: Option<u32>,
410    /// An opaque cursor from a previous page.
411    pub cursor: Option<String>,
412    /// Filters by room id.
413    pub room_id: Option<String>,
414    /// Filters by session id.
415    pub session_id: Option<String>,
416    /// Filters by participant id.
417    pub participant_id: Option<String>,
418    /// Scopes to a specific user. Admin tokens only.
419    pub user_id: Option<String>,
420}
421
422/// The query parameters for [`RecordingCompositeResource::list`].
423#[derive(Debug, Clone, Default)]
424pub struct CompositeRecordingListParams {
425    /// The 1-based page number.
426    pub page: Option<u32>,
427    /// Items per page.
428    pub per_page: Option<u32>,
429    /// An opaque cursor from a previous page.
430    pub cursor: Option<String>,
431    /// Filters by room id.
432    pub room_id: Option<String>,
433    /// Filters by session id.
434    pub session_id: Option<String>,
435}
436
437/// The query parameters for [`MergeRecordingResource::list`].
438#[derive(Debug, Clone, Default)]
439pub struct MergeRecordingListParams {
440    /// The 1-based page number.
441    pub page: Option<u32>,
442    /// Items per page.
443    pub per_page: Option<u32>,
444    /// An opaque cursor from a previous page.
445    pub cursor: Option<String>,
446    /// Filters by merge status.
447    pub status: Option<String>,
448    /// Filters by room id.
449    pub room_id: Option<String>,
450    /// Filters by session id.
451    pub session_id: Option<String>,
452    /// Filters by muxer id.
453    pub id: Option<String>,
454}
455
456macro_rules! pagination {
457    ($name:ident) => {
458        impl $name {
459            fn pagination(&self) -> ListParams {
460                ListParams {
461                    page: self.page,
462                    per_page: self.per_page,
463                    cursor: self.cursor.clone(),
464                }
465            }
466        }
467    };
468}
469
470pagination!(RecordingListParams);
471pagination!(IndividualRecordingListParams);
472pagination!(CompositeRecordingListParams);
473pagination!(MergeRecordingListParams);
474
475/* -------------------------------- sub-resources -------------------------------- */
476
477/// Records individual participants. Reached via [`RecordingsResource::participant`].
478#[derive(Debug, Clone, Copy)]
479pub struct RecordingParticipantResource<'a> {
480    client: &'a Client,
481}
482
483impl<'a> RecordingParticipantResource<'a> {
484    /// Begins recording a participant, returning the server's confirmation message.
485    pub async fn start(
486        &self,
487        room_id: &str,
488        params: ParticipantRecordingStartParams,
489    ) -> Result<String> {
490        let body = ParticipantRecordingStartWire {
491            room_id,
492            participant_id: &params.participant_id,
493            file_format: params.file_format,
494            webhook_url: params.webhook_url.as_deref(),
495            bucket_dir_path: params.dir_path.as_deref(),
496            metadata: params.metadata.as_ref(),
497        };
498        let path = format!("{PARTICIPANT_PATH}/start");
499        self.client
500            .message(Method::POST, &path, CallOptions::json(&body)?)
501            .await
502    }
503
504    /// Stops a participant recording.
505    pub async fn stop(&self, room_id: &str, participant_id: &str) -> Result<String> {
506        let body = serde_json::json!({"roomId": room_id, "participantId": participant_id});
507        let path = format!("{PARTICIPANT_PATH}/stop");
508        self.client
509            .message(Method::POST, &path, CallOptions::json(&body)?)
510            .await
511    }
512
513    /// Lists participant recordings, one page at a time.
514    pub async fn list(
515        &self,
516        params: IndividualRecordingListParams,
517    ) -> Result<Page<IndividualRecording>> {
518        let fetcher = individual_fetcher(self.client, PARTICIPANT_PATH, &params);
519        paginate(fetcher, &params.pagination(), "data", None).await
520    }
521
522    /// Lists participant recordings, transparently fetching every page.
523    pub fn list_stream(
524        &self,
525        params: IndividualRecordingListParams,
526    ) -> impl Stream<Item = Result<IndividualRecording>> + Send {
527        let fetcher = individual_fetcher(self.client, PARTICIPANT_PATH, &params);
528        auto_page(fetcher, params.pagination(), "data", None)
529    }
530
531    /// Fetches a participant recording by id.
532    pub async fn get(&self, id: &str) -> Result<IndividualRecording> {
533        let path = format!("{PARTICIPANT_PATH}/{}", escape(id));
534        self.client
535            .json(Method::GET, &path, CallOptions::new())
536            .await
537    }
538
539    /// Deletes a participant recording.
540    pub async fn delete(&self, id: &str) -> Result<String> {
541        let path = format!("{PARTICIPANT_PATH}/{}", escape(id));
542        self.client
543            .message(Method::DELETE, &path, CallOptions::new())
544            .await
545    }
546}
547
548/// Records individual tracks. Reached via [`RecordingsResource::track`].
549#[derive(Debug, Clone, Copy)]
550pub struct RecordingTrackResource<'a> {
551    client: &'a Client,
552}
553
554impl<'a> RecordingTrackResource<'a> {
555    /// Begins recording a single track, returning the server's confirmation message.
556    pub async fn start(&self, room_id: &str, params: TrackRecordingStartParams) -> Result<String> {
557        let body = TrackRecordingStartWire {
558            room_id,
559            participant_id: &params.participant_id,
560            kind: params.kind,
561            file_format: params.file_format,
562            webhook_url: params.webhook_url.as_deref(),
563            bucket_dir_path: params.dir_path.as_deref(),
564        };
565        let path = format!("{TRACK_PATH}/start");
566        self.client
567            .message(Method::POST, &path, CallOptions::json(&body)?)
568            .await
569    }
570
571    /// Stops a track recording. All three arguments identify the track.
572    pub async fn stop(
573        &self,
574        room_id: &str,
575        participant_id: &str,
576        kind: TrackKind,
577    ) -> Result<String> {
578        let body = serde_json::json!({
579            "roomId": room_id, "participantId": participant_id, "kind": kind,
580        });
581        let path = format!("{TRACK_PATH}/stop");
582        self.client
583            .message(Method::POST, &path, CallOptions::json(&body)?)
584            .await
585    }
586
587    /// Lists track recordings, one page at a time.
588    pub async fn list(
589        &self,
590        params: IndividualRecordingListParams,
591    ) -> Result<Page<IndividualRecording>> {
592        let fetcher = individual_fetcher(self.client, TRACK_PATH, &params);
593        paginate(fetcher, &params.pagination(), "data", None).await
594    }
595
596    /// Lists track recordings, transparently fetching every page.
597    pub fn list_stream(
598        &self,
599        params: IndividualRecordingListParams,
600    ) -> impl Stream<Item = Result<IndividualRecording>> + Send {
601        let fetcher = individual_fetcher(self.client, TRACK_PATH, &params);
602        auto_page(fetcher, params.pagination(), "data", None)
603    }
604
605    /// Fetches a track recording by id.
606    pub async fn get(&self, id: &str) -> Result<IndividualRecording> {
607        let path = format!("{TRACK_PATH}/{}", escape(id));
608        self.client
609            .json(Method::GET, &path, CallOptions::new())
610            .await
611    }
612
613    /// Deletes a track recording.
614    pub async fn delete(&self, id: &str) -> Result<String> {
615        let path = format!("{TRACK_PATH}/{}", escape(id));
616        self.client
617            .message(Method::DELETE, &path, CallOptions::new())
618            .await
619    }
620}
621
622/// Records a server-side composed mix. Reached via [`RecordingsResource::composite`].
623#[derive(Debug, Clone, Copy)]
624pub struct RecordingCompositeResource<'a> {
625    client: &'a Client,
626}
627
628impl<'a> RecordingCompositeResource<'a> {
629    /// Begins a composite recording. The returned handle's `id` is the `recordingId`
630    /// that [`stop`](RecordingCompositeResource::stop) requires.
631    pub async fn start(
632        &self,
633        room_id: &str,
634        params: CompositeRecordingStartParams,
635    ) -> Result<EgressHandle> {
636        let body = CompositeRecordingStartWire {
637            room_id,
638            participants: &params.participants,
639            watermarks: &params.watermarks,
640            webhook_url: params.webhook_url.as_deref(),
641            bucket_dir_path: params.dir_path.as_deref(),
642            pre_signed_url: params.pre_signed_url.as_deref(),
643            file_format: params.file_format.as_deref(),
644        };
645        let path = format!("{COMPOSITE_PATH}/start");
646        let raw = self
647            .client
648            .maybe_json(Method::POST, &path, CallOptions::json(&body)?)
649            .await?;
650        Ok(to_egress_handle(EgressType::Composite, room_id, raw))
651    }
652
653    /// Stops a composite recording.
654    ///
655    /// Unlike every other egress, this needs the `recordingId`, so it takes the
656    /// handle that [`start`](RecordingCompositeResource::start) returned rather
657    /// than a bare room id.
658    pub async fn stop(&self, handle: &EgressHandle) -> Result<String> {
659        let id = handle
660            .id
661            .as_deref()
662            .filter(|id| !id.is_empty())
663            .ok_or_else(|| {
664                Error::validation(
665                    "recordings.composite().stop() requires a recordingId, from the start handle",
666                )
667            })?;
668        let body = serde_json::json!({"roomId": handle.room_id, "recordingId": id});
669        let path = format!("{COMPOSITE_PATH}/stop");
670        self.client
671            .message(Method::POST, &path, CallOptions::json(&body)?)
672            .await
673    }
674
675    /// Lists composite recordings, one page at a time.
676    pub async fn list(
677        &self,
678        params: CompositeRecordingListParams,
679    ) -> Result<Page<CompositeRecording>> {
680        paginate(self.fetcher(&params), &params.pagination(), "data", None).await
681    }
682
683    /// Lists composite recordings, transparently fetching every page.
684    pub fn list_stream(
685        &self,
686        params: CompositeRecordingListParams,
687    ) -> impl Stream<Item = Result<CompositeRecording>> + Send {
688        auto_page(self.fetcher(&params), params.pagination(), "data", None)
689    }
690
691    /// Fetches a composite recording by id.
692    pub async fn get(&self, id: &str) -> Result<CompositeRecording> {
693        let path = format!("{COMPOSITE_PATH}/{}", escape(id));
694        self.client
695            .json(Method::GET, &path, CallOptions::new())
696            .await
697    }
698
699    /// Deletes a composite recording.
700    pub async fn delete(&self, id: &str) -> Result<String> {
701        let path = format!("{COMPOSITE_PATH}/{}", escape(id));
702        self.client
703            .message(Method::DELETE, &path, CallOptions::new())
704            .await
705    }
706
707    fn fetcher(&self, params: &CompositeRecordingListParams) -> PageFetcher {
708        let client = self.client.clone();
709        let params = params.clone();
710        Arc::new(move |page, per_page| {
711            let client = client.clone();
712            let params = params.clone();
713            Box::pin(async move {
714                let query = QueryBuilder::new()
715                    .opt("page", page)
716                    .opt("perPage", per_page)
717                    .opt_str("roomId", params.room_id.as_deref())
718                    .opt_str("sessionId", params.session_id.as_deref())
719                    .into_pairs();
720                client
721                    .json::<Value>(Method::GET, COMPOSITE_PATH, CallOptions::new().query(query))
722                    .await
723            })
724        })
725    }
726}
727
728/// Muxes individual recordings into stereo channels. Reached via
729/// [`RecordingsResource::merge`].
730#[derive(Debug, Clone, Copy)]
731pub struct MergeRecordingResource<'a> {
732    client: &'a Client,
733}
734
735impl<'a> MergeRecordingResource<'a> {
736    /// Starts a two-channel audio-merge job.
737    pub async fn create(&self, params: MergeRecordingCreateParams) -> Result<MergeRecordingResult> {
738        self.client
739            .json(Method::POST, MERGE_PATH, CallOptions::json(&params)?)
740            .await
741    }
742
743    /// Lists merge recordings, one page at a time.
744    pub async fn list(&self, params: MergeRecordingListParams) -> Result<Page<MergeRecording>> {
745        // This endpoint keys its array `recordings`, not `data`.
746        paginate(
747            self.fetcher(&params),
748            &params.pagination(),
749            "recordings",
750            None,
751        )
752        .await
753    }
754
755    /// Lists merge recordings, transparently fetching every page.
756    pub fn list_stream(
757        &self,
758        params: MergeRecordingListParams,
759    ) -> impl Stream<Item = Result<MergeRecording>> + Send {
760        auto_page(
761            self.fetcher(&params),
762            params.pagination(),
763            "recordings",
764            None,
765        )
766    }
767
768    /// Fetches a merge recording by its muxer id.
769    pub async fn get(&self, muxer_id: &str) -> Result<MergeRecording> {
770        let path = format!("{MERGE_PATH}/{}", escape(muxer_id));
771        self.client
772            .json(Method::GET, &path, CallOptions::new())
773            .await
774    }
775
776    fn fetcher(&self, params: &MergeRecordingListParams) -> PageFetcher {
777        let client = self.client.clone();
778        let params = params.clone();
779        Arc::new(move |page, per_page| {
780            let client = client.clone();
781            let params = params.clone();
782            Box::pin(async move {
783                let query = QueryBuilder::new()
784                    .opt("page", page)
785                    .opt("perPage", per_page)
786                    .opt_str("status", params.status.as_deref())
787                    .opt_str("roomId", params.room_id.as_deref())
788                    .opt_str("sessionId", params.session_id.as_deref())
789                    .opt_str("id", params.id.as_deref())
790                    .into_pairs();
791                client
792                    .json::<Value>(Method::GET, MERGE_PATH, CallOptions::new().query(query))
793                    .await
794            })
795        })
796    }
797}
798
799/// Shared by the participant and track lists, which take identical filters.
800fn individual_fetcher(
801    client: &Client,
802    path: &'static str,
803    params: &IndividualRecordingListParams,
804) -> PageFetcher {
805    let client = client.clone();
806    let params = params.clone();
807    Arc::new(move |page, per_page| {
808        let client = client.clone();
809        let params = params.clone();
810        Box::pin(async move {
811            let query = QueryBuilder::new()
812                .opt("page", page)
813                .opt("perPage", per_page)
814                .opt_str("roomId", params.room_id.as_deref())
815                .opt_str("sessionId", params.session_id.as_deref())
816                .opt_str("participantId", params.participant_id.as_deref())
817                .opt_str("userId", params.user_id.as_deref())
818                .into_pairs();
819            client
820                .json::<Value>(Method::GET, path, CallOptions::new().query(query))
821                .await
822        })
823    })
824}
825
826/* ----------------------------------- facade ------------------------------------ */
827
828/// The recordings API. Reached via [`Client::recordings`].
829///
830/// Its own methods drive the room (composed) recording; the sub-resources cover
831/// the rest. Starting and stopping require a live session.
832#[derive(Debug, Clone, Copy)]
833pub struct RecordingsResource<'a> {
834    client: &'a Client,
835}
836
837impl<'a> RecordingsResource<'a> {
838    pub(crate) fn new(client: &'a Client) -> Self {
839        Self { client }
840    }
841
842    /// Records individual participants.
843    pub fn participant(&self) -> RecordingParticipantResource<'a> {
844        RecordingParticipantResource {
845            client: self.client,
846        }
847    }
848
849    /// Records individual audio, video or screen tracks.
850    pub fn track(&self) -> RecordingTrackResource<'a> {
851        RecordingTrackResource {
852            client: self.client,
853        }
854    }
855
856    /// Records a server-side composed mix.
857    pub fn composite(&self) -> RecordingCompositeResource<'a> {
858        RecordingCompositeResource {
859            client: self.client,
860        }
861    }
862
863    /// Muxes individual recordings into stereo channels.
864    pub fn merge(&self) -> MergeRecordingResource<'a> {
865        MergeRecordingResource {
866            client: self.client,
867        }
868    }
869
870    /// Begins a room (composed) recording. Requires a live session.
871    pub async fn start(&self, room_id: &str, params: RecordingStartParams) -> Result<EgressHandle> {
872        let mapped = composition_to_config(params.composition.as_ref(), None);
873        let body = RecordingStartWire {
874            room_id,
875            config: mapped.config,
876            template_url: mapped.template_url,
877            aws_dir_path: params.dir_path.as_deref(),
878            transcription: params.transcription.as_ref(),
879            on_failure: params.on_failure.as_ref(),
880            file_format: params.file_format,
881            webhook_url: params.webhook_url.as_deref(),
882            resource_id: params.resource_id.as_deref(),
883            pre_signed_url: params.pre_signed_url.as_deref(),
884        };
885        let path = format!("{PATH}/start");
886        let raw = self
887            .client
888            .maybe_json(Method::POST, &path, CallOptions::json(&body)?)
889            .await?;
890        Ok(to_egress_handle(EgressType::Recording, room_id, raw))
891    }
892
893    /// Stops a room recording. Accepts the start handle, or a bare room id.
894    pub async fn stop(&self, target: impl Into<StopTarget>) -> Result<String> {
895        let target = target.into();
896        let path = format!("{PATH}/end");
897        self.client
898            .message(
899                Method::POST,
900                &path,
901                CallOptions::json(StopWire::from(&target))?,
902            )
903            .await
904    }
905
906    /// Lists room recordings, one page at a time.
907    pub async fn list(&self, params: RecordingListParams) -> Result<Page<Recording>> {
908        paginate(self.fetcher(&params), &params.pagination(), "data", None).await
909    }
910
911    /// Lists room recordings, transparently fetching every page.
912    pub fn list_stream(
913        &self,
914        params: RecordingListParams,
915    ) -> impl Stream<Item = Result<Recording>> + Send {
916        auto_page(self.fetcher(&params), params.pagination(), "data", None)
917    }
918
919    /// Fetches a room recording by id.
920    pub async fn get(&self, id: &str, params: RecordingGetParams) -> Result<Recording> {
921        let query = QueryBuilder::new()
922            .opt("withTranscription", params.with_transcription)
923            .into_pairs();
924        let path = format!("{PATH}/{}", escape(id));
925        self.client
926            .json(Method::GET, &path, CallOptions::new().query(query))
927            .await
928    }
929
930    /// Soft-deletes a room recording.
931    pub async fn delete(&self, id: &str) -> Result<String> {
932        let path = format!("{PATH}/{}", escape(id));
933        self.client
934            .message(Method::DELETE, &path, CallOptions::new())
935            .await
936    }
937
938    fn fetcher(&self, params: &RecordingListParams) -> PageFetcher {
939        let client = self.client.clone();
940        let params = params.clone();
941        Arc::new(move |page, per_page| {
942            let client = client.clone();
943            let params = params.clone();
944            Box::pin(async move {
945                let query = QueryBuilder::new()
946                    .opt("page", page)
947                    .opt("perPage", per_page)
948                    .opt_str("roomId", params.room_id.as_deref())
949                    .opt_str("sessionId", params.session_id.as_deref())
950                    .opt_str("userId", params.user_id.as_deref())
951                    .opt_str("query", params.query.as_deref())
952                    .opt("withTranscription", params.with_transcription)
953                    .into_pairs();
954                client
955                    .json::<Value>(Method::GET, PATH, CallOptions::new().query(query))
956                    .await
957            })
958        })
959    }
960}