videosdk-server-sdk 0.1.0

Rust server SDK for the VideoSDK v2 REST APIs
Documentation
//! The transcription API.
//!
//! Today this exposes live transcription at
//! [`TranscriptionResource::realtime`]. Post-recording transcription is
//! configured on the recording's start options.

use std::collections::HashMap;
use std::sync::Arc;

use futures_util::Stream;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};

use crate::client::{CallOptions, Client};
use crate::error::{Error, Result};
use crate::pagination::{auto_page, paginate, ItemMapper, ListParams, Page, PageFetcher};
use crate::query::QueryBuilder;
use crate::resources::escape;

const PATH: &str = "/ai/v1/realtime-transcriptions";

/// A post-transcription summary configuration.
#[derive(Debug, Clone, Default, Serialize)]
pub struct RealtimeSummaryConfig {
    /// Whether to generate a summary.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub enabled: Option<bool>,
    /// A prompt steering the summary.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub prompt: Option<String>,
}

/// The parameters for [`RealtimeTranscriptionResource::start`].
#[derive(Debug, Clone, Default)]
pub struct StartRealtimeTranscriptionParams {
    /// A webhook to notify about the transcription.
    pub webhook_url: Option<String>,
    /// A provider-specific transcription model id.
    pub model_id: Option<String>,
    /// A BCP-47 language hint.
    pub language: Option<String>,
    /// An optional post-session summary.
    pub summary: Option<RealtimeSummaryConfig>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct StartTranscriptionWire<'a> {
    room_id: &'a str,
    #[serde(skip_serializing_if = "Option::is_none")]
    webhook_url: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    model_id: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    language: Option<&'a str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    summary: Option<&'a RealtimeSummaryConfig>,
}

/// The nested configuration of a realtime transcription's extension.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RealtimeTranscriptionExtensionConfig {
    /// The room the transcription runs in.
    pub room_id: Option<String>,
    /// The session the transcription runs in.
    pub session_id: Option<String>,
}

/// The extension sub-object on a realtime transcription.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RealtimeTranscriptionExtension {
    /// Whether the extension is enabled.
    pub enabled: Option<bool>,
    /// The transcription provider.
    pub provider: Option<String>,
    /// The extension's configuration.
    pub extension_config: Option<RealtimeTranscriptionExtensionConfig>,
}

/// A realtime transcription job. Its `id` is the transcription id.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RealtimeTranscription {
    /// The transcription id.
    pub id: String,
    /// The room the transcription runs in.
    ///
    /// The list and get endpoints bury this inside `extension.extensionConfig`;
    /// the SDK lifts it here.
    pub room_id: Option<String>,
    /// The transcription's status.
    pub status: Option<String>,
    /// The transcription's extension configuration.
    pub extension: Option<RealtimeTranscriptionExtension>,
    /// Output file URLs, keyed by format, e.g. `txt`.
    #[serde(default, deserialize_with = "crate::common::null_to_default")]
    pub transcription_file_paths: HashMap<String, String>,
    /// Summary file URLs, keyed by format.
    #[serde(default, deserialize_with = "crate::common::null_to_default")]
    pub summarized_file_paths: HashMap<String, String>,
    /// When the transcription started.
    pub start: Option<String>,
    /// When the transcription ended.
    pub end: Option<String>,
    /// Any fields the server returned that this SDK does not model yet.
    #[serde(flatten)]
    pub extra: Map<String, Value>,
}

/// Lifts the room id out of `extension.extensionConfig`, falling back to a
/// caller-supplied one.
fn with_room_id(
    mut transcription: RealtimeTranscription,
    fallback: Option<&str>,
) -> RealtimeTranscription {
    if transcription.room_id.is_some() {
        return transcription;
    }
    let nested = transcription
        .extension
        .as_ref()
        .and_then(|extension| extension.extension_config.as_ref())
        .and_then(|config| config.room_id.clone());

    transcription.room_id = nested.or_else(|| fallback.map(str::to_string));
    transcription
}

/// The query parameters for [`RealtimeTranscriptionResource::list`].
///
/// Listing cannot filter by room id — the results do not carry one.
#[derive(Debug, Clone, Default)]
pub struct ListRealtimeTranscriptionsParams {
    /// The 1-based page number.
    pub page: Option<u32>,
    /// Items per page.
    pub per_page: Option<u32>,
    /// An opaque cursor from a previous page.
    pub cursor: Option<String>,
    /// Filters by transcription status.
    pub status: Option<String>,
}

impl ListRealtimeTranscriptionsParams {
    fn pagination(&self) -> ListParams {
        ListParams {
            page: self.page,
            per_page: self.per_page,
            cursor: self.cursor.clone(),
        }
    }
}

fn transcription_item_mapper() -> ItemMapper<RealtimeTranscription> {
    Arc::new(|value: Value| {
        let transcription: RealtimeTranscription = serde_json::from_value(value)
            .map_err(|e| Error::decode("realtime transcription list item", e))?;
        Ok(with_room_id(transcription, None))
    })
}

/// Live transcription against a room's active session. Reached via
/// [`TranscriptionResource::realtime`].
#[derive(Debug, Clone, Copy)]
pub struct RealtimeTranscriptionResource<'a> {
    client: &'a Client,
}

impl<'a> RealtimeTranscriptionResource<'a> {
    /// Begins realtime transcription on a room's active session.
    pub async fn start(
        &self,
        room_id: &str,
        params: StartRealtimeTranscriptionParams,
    ) -> Result<RealtimeTranscription> {
        let body = StartTranscriptionWire {
            room_id,
            webhook_url: params.webhook_url.as_deref(),
            model_id: params.model_id.as_deref(),
            language: params.language.as_deref(),
            summary: params.summary.as_ref(),
        };
        let path = format!("{PATH}/start");
        let transcription: RealtimeTranscription = self
            .client
            .json(Method::POST, &path, CallOptions::json(&body)?)
            .await?;
        Ok(with_room_id(transcription, Some(room_id)))
    }

    /// Stops the transcription running in a room.
    pub async fn stop(&self, room_id: &str) -> Result<RealtimeTranscription> {
        if room_id.is_empty() {
            return Err(Error::validation(
                "transcription.realtime().stop() needs a room_id",
            ));
        }
        let body = serde_json::json!({ "roomId": room_id });
        let path = format!("{PATH}/end");
        self.client
            .json(Method::POST, &path, CallOptions::json(&body)?)
            .await
    }

    /// Fetches a realtime transcription by its transcription id.
    pub async fn get(&self, transcription_id: &str) -> Result<RealtimeTranscription> {
        let path = format!("{PATH}/{}", escape(transcription_id));
        let transcription: RealtimeTranscription = self
            .client
            .json(Method::GET, &path, CallOptions::new())
            .await?;
        Ok(with_room_id(transcription, None))
    }

    /// Lists realtime transcriptions, one page at a time.
    pub async fn list(
        &self,
        params: ListRealtimeTranscriptionsParams,
    ) -> Result<Page<RealtimeTranscription>> {
        paginate(
            self.fetcher(&params),
            &params.pagination(),
            "transcriptions",
            Some(transcription_item_mapper()),
        )
        .await
    }

    /// Lists realtime transcriptions, transparently fetching every page.
    pub fn list_stream(
        &self,
        params: ListRealtimeTranscriptionsParams,
    ) -> impl Stream<Item = Result<RealtimeTranscription>> + Send {
        auto_page(
            self.fetcher(&params),
            params.pagination(),
            "transcriptions",
            Some(transcription_item_mapper()),
        )
    }

    fn fetcher(&self, params: &ListRealtimeTranscriptionsParams) -> PageFetcher {
        let client = self.client.clone();
        let status = params.status.clone();
        Arc::new(move |page, per_page| {
            let client = client.clone();
            let status = status.clone();
            Box::pin(async move {
                let query = QueryBuilder::new()
                    .opt("page", page)
                    .opt("perPage", per_page)
                    .opt_str("status", status.as_deref())
                    .into_pairs();
                client
                    .json::<Value>(Method::GET, PATH, CallOptions::new().query(query))
                    .await
            })
        })
    }
}

/// The transcription API. Reached via [`Client::transcription`].
#[derive(Debug, Clone, Copy)]
pub struct TranscriptionResource<'a> {
    client: &'a Client,
}

impl<'a> TranscriptionResource<'a> {
    pub(crate) fn new(client: &'a Client) -> Self {
        Self { client }
    }

    /// Live transcription.
    pub fn realtime(&self) -> RealtimeTranscriptionResource<'a> {
        RealtimeTranscriptionResource {
            client: self.client,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn transcription(value: Value) -> RealtimeTranscription {
        serde_json::from_value(value).unwrap()
    }

    #[test]
    fn the_room_id_is_lifted_out_of_the_extension_config() {
        let lifted = with_room_id(
            transcription(json!({
                "id": "t-1",
                "extension": {"extensionConfig": {"roomId": "r-9"}},
            })),
            None,
        );
        assert_eq!(lifted.room_id.as_deref(), Some("r-9"));
    }

    #[test]
    fn a_top_level_room_id_is_never_overwritten() {
        let lifted = with_room_id(
            transcription(json!({
                "id": "t-1",
                "roomId": "real",
                "extension": {"extensionConfig": {"roomId": "nested"}},
            })),
            Some("fallback"),
        );
        assert_eq!(lifted.room_id.as_deref(), Some("real"));
    }

    #[test]
    fn the_fallback_applies_only_when_nothing_else_resolves() {
        let lifted = with_room_id(transcription(json!({"id": "t-1"})), Some("fallback"));
        assert_eq!(lifted.room_id.as_deref(), Some("fallback"));

        let lifted = with_room_id(transcription(json!({"id": "t-1"})), None);
        assert_eq!(lifted.room_id, None);
    }
}