Skip to main content

videosdk/resources/
transcription.rs

1//! The transcription API.
2//!
3//! Today this exposes live transcription at
4//! [`TranscriptionResource::realtime`]. Post-recording transcription is
5//! configured on the recording's start options.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use futures_util::Stream;
11use reqwest::Method;
12use serde::{Deserialize, Serialize};
13use serde_json::{Map, Value};
14
15use crate::client::{CallOptions, Client};
16use crate::error::{Error, Result};
17use crate::pagination::{auto_page, paginate, ItemMapper, ListParams, Page, PageFetcher};
18use crate::query::QueryBuilder;
19use crate::resources::escape;
20
21const PATH: &str = "/ai/v1/realtime-transcriptions";
22
23/// A post-transcription summary configuration.
24#[derive(Debug, Clone, Default, Serialize)]
25pub struct RealtimeSummaryConfig {
26    /// Whether to generate a summary.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub enabled: Option<bool>,
29    /// A prompt steering the summary.
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub prompt: Option<String>,
32}
33
34/// The parameters for [`RealtimeTranscriptionResource::start`].
35#[derive(Debug, Clone, Default)]
36pub struct StartRealtimeTranscriptionParams {
37    /// A webhook to notify about the transcription.
38    pub webhook_url: Option<String>,
39    /// A provider-specific transcription model id.
40    pub model_id: Option<String>,
41    /// A BCP-47 language hint.
42    pub language: Option<String>,
43    /// An optional post-session summary.
44    pub summary: Option<RealtimeSummaryConfig>,
45}
46
47#[derive(Debug, Serialize)]
48#[serde(rename_all = "camelCase")]
49struct StartTranscriptionWire<'a> {
50    room_id: &'a str,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    webhook_url: Option<&'a str>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    model_id: Option<&'a str>,
55    #[serde(skip_serializing_if = "Option::is_none")]
56    language: Option<&'a str>,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    summary: Option<&'a RealtimeSummaryConfig>,
59}
60
61/// The nested configuration of a realtime transcription's extension.
62#[derive(Debug, Clone, Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct RealtimeTranscriptionExtensionConfig {
65    /// The room the transcription runs in.
66    pub room_id: Option<String>,
67    /// The session the transcription runs in.
68    pub session_id: Option<String>,
69}
70
71/// The extension sub-object on a realtime transcription.
72#[derive(Debug, Clone, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct RealtimeTranscriptionExtension {
75    /// Whether the extension is enabled.
76    pub enabled: Option<bool>,
77    /// The transcription provider.
78    pub provider: Option<String>,
79    /// The extension's configuration.
80    pub extension_config: Option<RealtimeTranscriptionExtensionConfig>,
81}
82
83/// A realtime transcription job. Its `id` is the transcription id.
84#[derive(Debug, Clone, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct RealtimeTranscription {
87    /// The transcription id.
88    pub id: String,
89    /// The room the transcription runs in.
90    ///
91    /// The list and get endpoints bury this inside `extension.extensionConfig`;
92    /// the SDK lifts it here.
93    pub room_id: Option<String>,
94    /// The transcription's status.
95    pub status: Option<String>,
96    /// The transcription's extension configuration.
97    pub extension: Option<RealtimeTranscriptionExtension>,
98    /// Output file URLs, keyed by format, e.g. `txt`.
99    #[serde(default, deserialize_with = "crate::common::null_to_default")]
100    pub transcription_file_paths: HashMap<String, String>,
101    /// Summary file URLs, keyed by format.
102    #[serde(default, deserialize_with = "crate::common::null_to_default")]
103    pub summarized_file_paths: HashMap<String, String>,
104    /// When the transcription started.
105    pub start: Option<String>,
106    /// When the transcription ended.
107    pub end: Option<String>,
108    /// Any fields the server returned that this SDK does not model yet.
109    #[serde(flatten)]
110    pub extra: Map<String, Value>,
111}
112
113/// Lifts the room id out of `extension.extensionConfig`, falling back to a
114/// caller-supplied one.
115fn with_room_id(
116    mut transcription: RealtimeTranscription,
117    fallback: Option<&str>,
118) -> RealtimeTranscription {
119    if transcription.room_id.is_some() {
120        return transcription;
121    }
122    let nested = transcription
123        .extension
124        .as_ref()
125        .and_then(|extension| extension.extension_config.as_ref())
126        .and_then(|config| config.room_id.clone());
127
128    transcription.room_id = nested.or_else(|| fallback.map(str::to_string));
129    transcription
130}
131
132/// The query parameters for [`RealtimeTranscriptionResource::list`].
133///
134/// Listing cannot filter by room id — the results do not carry one.
135#[derive(Debug, Clone, Default)]
136pub struct ListRealtimeTranscriptionsParams {
137    /// The 1-based page number.
138    pub page: Option<u32>,
139    /// Items per page.
140    pub per_page: Option<u32>,
141    /// An opaque cursor from a previous page.
142    pub cursor: Option<String>,
143    /// Filters by transcription status.
144    pub status: Option<String>,
145}
146
147impl ListRealtimeTranscriptionsParams {
148    fn pagination(&self) -> ListParams {
149        ListParams {
150            page: self.page,
151            per_page: self.per_page,
152            cursor: self.cursor.clone(),
153        }
154    }
155}
156
157fn transcription_item_mapper() -> ItemMapper<RealtimeTranscription> {
158    Arc::new(|value: Value| {
159        let transcription: RealtimeTranscription = serde_json::from_value(value)
160            .map_err(|e| Error::decode("realtime transcription list item", e))?;
161        Ok(with_room_id(transcription, None))
162    })
163}
164
165/// Live transcription against a room's active session. Reached via
166/// [`TranscriptionResource::realtime`].
167#[derive(Debug, Clone, Copy)]
168pub struct RealtimeTranscriptionResource<'a> {
169    client: &'a Client,
170}
171
172impl<'a> RealtimeTranscriptionResource<'a> {
173    /// Begins realtime transcription on a room's active session.
174    pub async fn start(
175        &self,
176        room_id: &str,
177        params: StartRealtimeTranscriptionParams,
178    ) -> Result<RealtimeTranscription> {
179        let body = StartTranscriptionWire {
180            room_id,
181            webhook_url: params.webhook_url.as_deref(),
182            model_id: params.model_id.as_deref(),
183            language: params.language.as_deref(),
184            summary: params.summary.as_ref(),
185        };
186        let path = format!("{PATH}/start");
187        let transcription: RealtimeTranscription = self
188            .client
189            .json(Method::POST, &path, CallOptions::json(&body)?)
190            .await?;
191        Ok(with_room_id(transcription, Some(room_id)))
192    }
193
194    /// Stops the transcription running in a room.
195    pub async fn stop(&self, room_id: &str) -> Result<RealtimeTranscription> {
196        if room_id.is_empty() {
197            return Err(Error::validation(
198                "transcription.realtime().stop() needs a room_id",
199            ));
200        }
201        let body = serde_json::json!({ "roomId": room_id });
202        let path = format!("{PATH}/end");
203        self.client
204            .json(Method::POST, &path, CallOptions::json(&body)?)
205            .await
206    }
207
208    /// Fetches a realtime transcription by its transcription id.
209    pub async fn get(&self, transcription_id: &str) -> Result<RealtimeTranscription> {
210        let path = format!("{PATH}/{}", escape(transcription_id));
211        let transcription: RealtimeTranscription = self
212            .client
213            .json(Method::GET, &path, CallOptions::new())
214            .await?;
215        Ok(with_room_id(transcription, None))
216    }
217
218    /// Lists realtime transcriptions, one page at a time.
219    pub async fn list(
220        &self,
221        params: ListRealtimeTranscriptionsParams,
222    ) -> Result<Page<RealtimeTranscription>> {
223        paginate(
224            self.fetcher(&params),
225            &params.pagination(),
226            "transcriptions",
227            Some(transcription_item_mapper()),
228        )
229        .await
230    }
231
232    /// Lists realtime transcriptions, transparently fetching every page.
233    pub fn list_stream(
234        &self,
235        params: ListRealtimeTranscriptionsParams,
236    ) -> impl Stream<Item = Result<RealtimeTranscription>> + Send {
237        auto_page(
238            self.fetcher(&params),
239            params.pagination(),
240            "transcriptions",
241            Some(transcription_item_mapper()),
242        )
243    }
244
245    fn fetcher(&self, params: &ListRealtimeTranscriptionsParams) -> PageFetcher {
246        let client = self.client.clone();
247        let status = params.status.clone();
248        Arc::new(move |page, per_page| {
249            let client = client.clone();
250            let status = status.clone();
251            Box::pin(async move {
252                let query = QueryBuilder::new()
253                    .opt("page", page)
254                    .opt("perPage", per_page)
255                    .opt_str("status", status.as_deref())
256                    .into_pairs();
257                client
258                    .json::<Value>(Method::GET, PATH, CallOptions::new().query(query))
259                    .await
260            })
261        })
262    }
263}
264
265/// The transcription API. Reached via [`Client::transcription`].
266#[derive(Debug, Clone, Copy)]
267pub struct TranscriptionResource<'a> {
268    client: &'a Client,
269}
270
271impl<'a> TranscriptionResource<'a> {
272    pub(crate) fn new(client: &'a Client) -> Self {
273        Self { client }
274    }
275
276    /// Live transcription.
277    pub fn realtime(&self) -> RealtimeTranscriptionResource<'a> {
278        RealtimeTranscriptionResource {
279            client: self.client,
280        }
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use serde_json::json;
288
289    fn transcription(value: Value) -> RealtimeTranscription {
290        serde_json::from_value(value).unwrap()
291    }
292
293    #[test]
294    fn the_room_id_is_lifted_out_of_the_extension_config() {
295        let lifted = with_room_id(
296            transcription(json!({
297                "id": "t-1",
298                "extension": {"extensionConfig": {"roomId": "r-9"}},
299            })),
300            None,
301        );
302        assert_eq!(lifted.room_id.as_deref(), Some("r-9"));
303    }
304
305    #[test]
306    fn a_top_level_room_id_is_never_overwritten() {
307        let lifted = with_room_id(
308            transcription(json!({
309                "id": "t-1",
310                "roomId": "real",
311                "extension": {"extensionConfig": {"roomId": "nested"}},
312            })),
313            Some("fallback"),
314        );
315        assert_eq!(lifted.room_id.as_deref(), Some("real"));
316    }
317
318    #[test]
319    fn the_fallback_applies_only_when_nothing_else_resolves() {
320        let lifted = with_room_id(transcription(json!({"id": "t-1"})), Some("fallback"));
321        assert_eq!(lifted.room_id.as_deref(), Some("fallback"));
322
323        let lifted = with_room_id(transcription(json!({"id": "t-1"})), None);
324        assert_eq!(lifted.room_id, None);
325    }
326}