Skip to main content

typecast_rust/
client.rs

1//! Typecast API client
2//!
3//! This module contains the main client for interacting with the Typecast API.
4
5use crate::composer::SpeechComposer;
6use crate::errors::{Result, TypecastError};
7use crate::models::{
8    Age, AudioFormat, CustomVoice, ErrorResponse, Gender, GenerateToFileRequest, RecommendedVoice,
9    SubscriptionResponse, TTSModel, TTSRequest, TTSRequestStream, TTSResponse, UseCase, VoiceV2,
10    VoicesV2Filter, CLONING_MAX_FILE_SIZE, NAME_MAX_LENGTH, NAME_MIN_LENGTH,
11};
12use bytes::Bytes;
13use futures_util::stream::{Stream, StreamExt};
14use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE, USER_AGENT};
15use std::env;
16use std::fs;
17use std::path::Path;
18use std::pin::Pin;
19use std::time::Duration;
20
21/// Boxed asynchronous stream of audio chunks returned by the streaming TTS endpoint.
22pub type AudioByteStream = Pin<Box<dyn Stream<Item = Result<Bytes>> + Send>>;
23
24/// Convert a [`TTSModel`] into the wire format string used in query parameters.
25fn model_query_value(model: TTSModel) -> &'static str {
26    match model {
27        TTSModel::SsfmV30 => "ssfm-v30",
28        TTSModel::SsfmV21 => "ssfm-v21",
29    }
30}
31
32/// Convert a [`Gender`] into the wire format string used in query parameters.
33fn gender_query_value(gender: Gender) -> &'static str {
34    match gender {
35        Gender::Male => "male",
36        Gender::Female => "female",
37    }
38}
39
40/// Convert an [`Age`] into the wire format string used in query parameters.
41fn age_query_value(age: Age) -> &'static str {
42    match age {
43        Age::Child => "child",
44        Age::Teenager => "teenager",
45        Age::YoungAdult => "young_adult",
46        Age::MiddleAge => "middle_age",
47        Age::Elder => "elder",
48    }
49}
50
51/// Convert a [`UseCase`] into the wire format string used in query parameters.
52fn use_case_query_value(use_case: UseCase) -> &'static str {
53    match use_case {
54        UseCase::Announcer => "Announcer",
55        UseCase::Anime => "Anime",
56        UseCase::Audiobook => "Audiobook",
57        UseCase::Conversational => "Conversational",
58        UseCase::Documentary => "Documentary",
59        UseCase::ELearning => "E-learning",
60        UseCase::Rapper => "Rapper",
61        UseCase::Game => "Game",
62        UseCase::TikTokReels => "Tiktok/Reels",
63        UseCase::News => "News",
64        UseCase::Podcast => "Podcast",
65        UseCase::Voicemail => "Voicemail",
66        UseCase::Ads => "Ads",
67    }
68}
69
70fn infer_audio_format_from_path(path: &Path) -> Option<AudioFormat> {
71    match path.extension().and_then(|extension| extension.to_str()) {
72        Some(extension) if extension.eq_ignore_ascii_case("mp3") => Some(AudioFormat::Mp3),
73        Some(extension) if extension.eq_ignore_ascii_case("wav") => Some(AudioFormat::Wav),
74        _ => None,
75    }
76}
77
78/// Default API base URL
79pub const DEFAULT_BASE_URL: &str = "https://api.typecast.ai";
80
81/// Default request timeout in seconds
82pub const DEFAULT_TIMEOUT_SECS: u64 = 60;
83
84/// Configuration for the Typecast client
85#[derive(Debug, Clone)]
86pub struct ClientConfig {
87    /// API key for authentication. Optional when using a proxy base URL.
88    pub api_key: String,
89    /// Base URL for the API (defaults to <https://api.typecast.ai>)
90    pub base_url: String,
91    /// Request timeout duration
92    pub timeout: Duration,
93}
94
95impl Default for ClientConfig {
96    fn default() -> Self {
97        Self {
98            api_key: env::var("TYPECAST_API_KEY").unwrap_or_default(),
99            base_url: env::var("TYPECAST_API_HOST")
100                .unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()),
101            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
102        }
103    }
104}
105
106impl ClientConfig {
107    /// Create a new configuration with an API key
108    pub fn new(api_key: impl Into<String>) -> Self {
109        Self {
110            api_key: api_key.into(),
111            ..Default::default()
112        }
113    }
114
115    /// Set a custom base URL
116    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
117        self.base_url = base_url.into();
118        self
119    }
120
121    /// Set a custom timeout
122    pub fn timeout(mut self, timeout: Duration) -> Self {
123        self.timeout = timeout;
124        self
125    }
126}
127
128/// The main Typecast API client
129#[derive(Debug, Clone)]
130pub struct TypecastClient {
131    client: reqwest::Client,
132    base_url: String,
133    api_key: String,
134}
135
136impl TypecastClient {
137    /// Create a new TypecastClient with the given configuration
138    pub fn new(config: ClientConfig) -> Result<Self> {
139        let api_key = config.api_key.trim().to_string();
140        let base_url = config.base_url.trim().trim_end_matches('/').to_string();
141        if api_key.is_empty() && is_default_base_url(&base_url) {
142            return Err(TypecastError::Unauthorized {
143                detail: "API key is required for the default Typecast API host".to_string(),
144            });
145        }
146
147        let mut headers = HeaderMap::new();
148        headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
149        headers.insert(
150            USER_AGENT,
151            HeaderValue::from_str(&build_user_agent(&base_url, config.timeout))
152                .expect("SDK-generated User-Agent is valid ASCII"),
153        );
154        if !api_key.is_empty() {
155            headers.insert(
156                "X-API-KEY",
157                HeaderValue::from_str(&api_key).map_err(|_| TypecastError::BadRequest {
158                    detail: "Invalid API key format".to_string(),
159                })?,
160            );
161        }
162
163        // `reqwest::Client::builder().build()` only fails if TLS init fails,
164        // which is not something we can usefully recover from at this layer.
165        let client = reqwest::Client::builder()
166            .default_headers(headers)
167            .timeout(config.timeout)
168            .build()
169            .expect("reqwest client builder should not fail");
170
171        Ok(Self {
172            client,
173            base_url,
174            api_key,
175        })
176    }
177
178    /// Create a new TypecastClient from environment variables
179    ///
180    /// Reads TYPECAST_API_KEY and optionally TYPECAST_API_HOST
181    pub fn from_env() -> Result<Self> {
182        Self::new(ClientConfig::default())
183    }
184
185    /// Create a new TypecastClient with just an API key
186    pub fn with_api_key(api_key: impl Into<String>) -> Result<Self> {
187        Self::new(ClientConfig::new(api_key))
188    }
189
190    /// Get the base URL
191    pub fn base_url(&self) -> &str {
192        &self.base_url
193    }
194
195    /// Get the API key (masked)
196    pub fn api_key_masked(&self) -> String {
197        if self.api_key.len() > 8 {
198            format!(
199                "{}...{}",
200                &self.api_key[..4],
201                &self.api_key[self.api_key.len() - 4..]
202            )
203        } else {
204            "****".to_string()
205        }
206    }
207
208    /// Create a builder for composed speech with multiple speech segments and pauses.
209    pub fn compose_speech(&self) -> SpeechComposer<'_> {
210        SpeechComposer::new(self)
211    }
212
213    fn with_auth_header(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
214        if self.api_key.is_empty() {
215            request
216        } else {
217            request.header("X-API-KEY", &self.api_key)
218        }
219    }
220
221    /// Build a URL with optional query parameters.
222    ///
223    /// Callers must pass `None` when there are no query parameters; passing
224    /// `Some(vec![])` is not supported and will produce a trailing `?`.
225    fn build_url(&self, path: &str, params: Option<Vec<(&str, String)>>) -> String {
226        let base = format!("{}{}", self.base_url, path);
227        match params {
228            Some(params) => {
229                let query: Vec<String> = params
230                    .into_iter()
231                    .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
232                    .collect();
233                format!("{}?{}", base, query.join("&"))
234            }
235            None => base,
236        }
237    }
238
239    /// Handle an error response
240    async fn handle_error_response(&self, response: reqwest::Response) -> TypecastError {
241        let status_code = response.status().as_u16();
242        let error_response: Option<ErrorResponse> = response.json().await.ok();
243        TypecastError::from_response(status_code, error_response)
244    }
245
246    /// Convert text to speech
247    ///
248    /// # Arguments
249    ///
250    /// * `request` - The TTS request containing text, voice_id, model, and optional settings
251    ///
252    /// # Returns
253    ///
254    /// Returns a `TTSResponse` containing the audio data, duration, and format
255    ///
256    /// # Example
257    ///
258    /// ```no_run
259    /// use typecast_rust::{TypecastClient, TTSRequest, TTSModel, ClientConfig};
260    ///
261    /// # async fn example() -> typecast_rust::Result<()> {
262    /// let client = TypecastClient::from_env()?;
263    /// let request = TTSRequest::new(
264    ///     "tc_60e5426de8b95f1d3000d7b5",
265    ///     "Hello, world!",
266    ///     TTSModel::SsfmV30,
267    /// );
268    /// let response = client.text_to_speech(&request).await?;
269    /// println!("Audio duration: {} seconds", response.duration);
270    /// # Ok(())
271    /// # }
272    /// ```
273    pub async fn text_to_speech(&self, request: &TTSRequest) -> Result<TTSResponse> {
274        let url = self.build_url("/v1/text-to-speech", None);
275
276        let response = self.client.post(&url).json(request).send().await?;
277
278        if !response.status().is_success() {
279            return Err(self.handle_error_response(response).await);
280        }
281
282        // Parse content type for format
283        let content_type = response
284            .headers()
285            .get(CONTENT_TYPE)
286            .and_then(|v| v.to_str().ok())
287            .unwrap_or("audio/wav");
288
289        let format = if content_type.contains("mp3") || content_type.contains("mpeg") {
290            AudioFormat::Mp3
291        } else {
292            AudioFormat::Wav
293        };
294
295        // Parse duration from header
296        let duration = response
297            .headers()
298            .get("X-Audio-Duration")
299            .and_then(|v| v.to_str().ok())
300            .and_then(|v| v.parse::<f64>().ok())
301            .unwrap_or(0.0);
302
303        let audio_data = response.bytes().await?.to_vec();
304
305        Ok(TTSResponse {
306            audio_data,
307            duration,
308            format,
309        })
310    }
311
312    /// Convert text to speech and write the audio bytes to a file.
313    ///
314    /// If `request.output.audio_format` is omitted, the format is inferred from
315    /// a `.mp3` or `.wav` file extension.
316    pub async fn generate_to_file(
317        &self,
318        path: impl AsRef<Path>,
319        request: GenerateToFileRequest,
320    ) -> Result<TTSResponse> {
321        let path_ref = path.as_ref();
322        let mut tts_request = request.into_tts_request();
323        let inferred = infer_audio_format_from_path(path_ref);
324        match tts_request.output.as_mut() {
325            Some(output) => {
326                if output.audio_format.is_none() {
327                    output.audio_format = inferred;
328                }
329            }
330            None => {
331                if let Some(format) = inferred {
332                    tts_request.output = Some(crate::models::Output::new().audio_format(format));
333                }
334            }
335        }
336
337        let response = self.text_to_speech(&tts_request).await?;
338        fs::write(path_ref, &response.audio_data)
339            .map_err(|e| TypecastError::IoError(e.to_string()))?;
340        Ok(response)
341    }
342
343    /// Convert text to speech as a streaming response
344    ///
345    /// Returns a stream of audio byte chunks. For `wav` output the first chunk
346    /// contains the WAV header followed by PCM samples; for `mp3` output each
347    /// chunk is independently decodable.
348    ///
349    /// # Arguments
350    ///
351    /// * `request` - The streaming TTS request
352    ///
353    /// # Returns
354    ///
355    /// A pinned boxed [`Stream`] yielding [`Result<Bytes>`] chunks.
356    ///
357    /// # Example
358    ///
359    /// ```no_run
360    /// use futures_util::StreamExt;
361    /// use typecast_rust::{TypecastClient, TTSRequestStream, TTSModel};
362    ///
363    /// # async fn example() -> typecast_rust::Result<()> {
364    /// let client = TypecastClient::from_env()?;
365    /// let request = TTSRequestStream::new(
366    ///     "tc_60e5426de8b95f1d3000d7b5",
367    ///     "Hello, world!",
368    ///     TTSModel::SsfmV30,
369    /// );
370    /// let mut stream = client.text_to_speech_stream(&request).await?;
371    /// while let Some(chunk) = stream.next().await {
372    ///     let bytes = chunk?;
373    ///     // write bytes to file or audio sink
374    ///     let _ = bytes;
375    /// }
376    /// # Ok(())
377    /// # }
378    /// ```
379    pub async fn text_to_speech_stream(
380        &self,
381        request: &TTSRequestStream,
382    ) -> Result<AudioByteStream> {
383        let url = self.build_url("/v1/text-to-speech/stream", None);
384
385        let response = self.client.post(&url).json(request).send().await?;
386
387        if !response.status().is_success() {
388            return Err(self.handle_error_response(response).await);
389        }
390
391        let stream = response
392            .bytes_stream()
393            .map(|item| item.map_err(TypecastError::from));
394        Ok(Box::pin(stream))
395    }
396
397    /// Get voices with enhanced metadata (V2 API)
398    ///
399    /// # Arguments
400    ///
401    /// * `filter` - Optional filter for voices (model, gender, age, use_cases)
402    ///
403    /// # Returns
404    ///
405    /// Returns a list of `VoiceV2` with enhanced metadata
406    ///
407    /// # Example
408    ///
409    /// ```no_run
410    /// use typecast_rust::{TypecastClient, VoicesV2Filter, TTSModel, Gender, ClientConfig};
411    ///
412    /// # async fn example() -> typecast_rust::Result<()> {
413    /// let client = TypecastClient::from_env()?;
414    ///
415    /// // Get all voices
416    /// let voices = client.get_voices_v2(None).await?;
417    ///
418    /// // Get filtered voices
419    /// let filter = VoicesV2Filter::new()
420    ///     .model(TTSModel::SsfmV30)
421    ///     .gender(Gender::Female);
422    /// let filtered_voices = client.get_voices_v2(Some(filter)).await?;
423    /// # Ok(())
424    /// # }
425    /// ```
426    pub async fn get_voices_v2(&self, filter: Option<VoicesV2Filter>) -> Result<Vec<VoiceV2>> {
427        let mut params = Vec::new();
428
429        if let Some(f) = filter {
430            if let Some(model) = f.model {
431                params.push(("model", model_query_value(model).to_string()));
432            }
433            if let Some(gender) = f.gender {
434                params.push(("gender", gender_query_value(gender).to_string()));
435            }
436            if let Some(age) = f.age {
437                params.push(("age", age_query_value(age).to_string()));
438            }
439            if let Some(use_cases) = f.use_cases {
440                params.push(("use_cases", use_case_query_value(use_cases).to_string()));
441            }
442        }
443
444        let url = self.build_url(
445            "/v2/voices",
446            if params.is_empty() {
447                None
448            } else {
449                Some(params)
450            },
451        );
452
453        let response = self.client.get(&url).send().await?;
454
455        if !response.status().is_success() {
456            return Err(self.handle_error_response(response).await);
457        }
458
459        let voices: Vec<VoiceV2> = response.json().await?;
460        Ok(voices)
461    }
462
463    /// Get a specific voice by ID with enhanced metadata (V2 API)
464    ///
465    /// # Arguments
466    ///
467    /// * `voice_id` - The voice ID (e.g., 'tc_60e5426de8b95f1d3000d7b5')
468    ///
469    /// # Returns
470    ///
471    /// Returns a `VoiceV2` with enhanced metadata
472    ///
473    /// # Example
474    ///
475    /// ```no_run
476    /// use typecast_rust::{TypecastClient, ClientConfig};
477    ///
478    /// # async fn example() -> typecast_rust::Result<()> {
479    /// let client = TypecastClient::from_env()?;
480    /// let voice = client.get_voice_v2("tc_60e5426de8b95f1d3000d7b5").await?;
481    /// println!("Voice: {} ({})", voice.voice_name, voice.voice_id);
482    /// # Ok(())
483    /// # }
484    /// ```
485    pub async fn get_voice_v2(&self, voice_id: &str) -> Result<VoiceV2> {
486        let url = self.build_url(&format!("/v2/voices/{}", voice_id), None);
487
488        let response = self.client.get(&url).send().await?;
489
490        if !response.status().is_success() {
491            return Err(self.handle_error_response(response).await);
492        }
493
494        let voice: VoiceV2 = response.json().await?;
495        Ok(voice)
496    }
497
498    /// Recommend voices from a text description.
499    ///
500    /// Results only contain `voice_id`, `voice_name`, and `score`. Use
501    /// `get_voice_v2` or `get_voices_v2` when you need detailed metadata for
502    /// the returned voice IDs.
503    pub async fn recommend_voices(
504        &self,
505        query: &str,
506        count: Option<u8>,
507    ) -> Result<Vec<RecommendedVoice>> {
508        let count = count.unwrap_or(5);
509        if !(1..=10).contains(&count) {
510            return Err(TypecastError::ValidationError {
511                detail: "count must be between 1 and 10".to_string(),
512            });
513        }
514
515        let url = self.build_url(
516            "/v1/voices/recommendations",
517            Some(vec![
518                ("query", query.to_string()),
519                ("count", count.to_string()),
520            ]),
521        );
522
523        let response = self.client.get(&url).send().await?;
524
525        if !response.status().is_success() {
526            return Err(self.handle_error_response(response).await);
527        }
528
529        let voices: Vec<RecommendedVoice> = response.json().await?;
530        Ok(voices)
531    }
532
533    /// Convert text to speech with word- and/or character-level timestamps.
534    ///
535    /// # Arguments
536    ///
537    /// * `request` - The TTS request with timestamps parameters.
538    /// * `granularity` - Optional granularity: `None` (both), `"word"`, or `"char"`.
539    ///
540    /// # Returns
541    ///
542    /// A [`crate::timestamps::TTSWithTimestampsResponse`] containing the Base64-encoded audio
543    /// and alignment segment arrays.  Use the response's `.to_srt()` / `.to_vtt()` methods to
544    /// generate subtitle files.
545    ///
546    /// # Example
547    ///
548    /// ```no_run
549    /// use typecast_rust::{TypecastClient, TTSModel};
550    /// use typecast_rust::timestamps::TTSRequestWithTimestamps;
551    ///
552    /// # async fn example() -> typecast_rust::Result<()> {
553    /// let client = TypecastClient::from_env()?;
554    /// let request = TTSRequestWithTimestamps::new(
555    ///     "tc_60e5426de8b95f1d3000d7b5",
556    ///     "Hello, world!",
557    ///     TTSModel::SsfmV30,
558    /// );
559    /// let response = client.text_to_speech_with_timestamps(&request, None).await?;
560    /// let srt = response.to_srt()?;
561    /// println!("{}", srt);
562    /// # Ok(())
563    /// # }
564    /// ```
565    pub async fn text_to_speech_with_timestamps(
566        &self,
567        request: &crate::timestamps::TTSRequestWithTimestamps,
568        granularity: Option<&str>,
569    ) -> Result<crate::timestamps::TTSWithTimestampsResponse> {
570        if let Some(g) = granularity {
571            if g != "word" && g != "char" {
572                return Err(TypecastError::ValidationError {
573                    detail: format!(
574                        "granularity must be None, \"word\", or \"char\"; got {:?}",
575                        g
576                    ),
577                });
578            }
579        }
580
581        let url = match granularity {
582            Some(g) => self.build_url(
583                "/v1/text-to-speech/with-timestamps",
584                Some(vec![("granularity", g.to_string())]),
585            ),
586            None => self.build_url("/v1/text-to-speech/with-timestamps", None),
587        };
588
589        let response = self.client.post(&url).json(request).send().await?;
590
591        if !response.status().is_success() {
592            return Err(self.handle_error_response(response).await);
593        }
594
595        let parsed: crate::timestamps::TTSWithTimestampsResponse = response
596            .json()
597            .await
598            .map_err(|e| TypecastError::DecodeError(e.to_string()))?;
599        Ok(parsed)
600    }
601
602    /// Get the authenticated user's subscription
603    ///
604    /// # Returns
605    ///
606    /// Returns a `SubscriptionResponse` containing the user's plan, credits,
607    /// and usage limits.
608    ///
609    /// # Example
610    ///
611    /// ```no_run
612    /// use typecast_rust::TypecastClient;
613    ///
614    /// # async fn example() -> typecast_rust::Result<()> {
615    /// let client = TypecastClient::from_env()?;
616    /// let subscription = client.get_my_subscription().await?;
617    /// println!("Plan: {:?}", subscription.plan);
618    /// println!(
619    ///     "Credits: {}/{}",
620    ///     subscription.credits.used_credits, subscription.credits.plan_credits
621    /// );
622    /// # Ok(())
623    /// # }
624    /// ```
625    pub async fn get_my_subscription(&self) -> Result<SubscriptionResponse> {
626        let url = self.build_url("/v1/users/me/subscription", None);
627
628        let response = self.client.get(&url).send().await?;
629
630        if !response.status().is_success() {
631            return Err(self.handle_error_response(response).await);
632        }
633
634        let subscription: SubscriptionResponse = response.json().await?;
635        Ok(subscription)
636    }
637
638    /// Clone a voice from an audio recording.
639    ///
640    /// Uploads the audio file as `multipart/form-data` to `POST /v1/voices/clone`
641    /// and returns a [`CustomVoice`] representing the newly created voice.
642    ///
643    /// # Arguments
644    ///
645    /// * `audio` - Raw audio bytes (WAV or MP3). Must not exceed 25 MB.
646    /// * `filename` - File name used in the multipart part (e.g. `"sample.wav"`).
647    ///   The extension determines the MIME type sent to the API.
648    /// * `name` - Display name for the custom voice (1–30 characters).
649    /// * `model` - TTS model to use for cloning (e.g. `"ssfm-v30"`).
650    ///
651    /// # Errors
652    ///
653    /// Returns [`TypecastError::ValidationError`] if `name` is outside 1–30 characters
654    /// or if `audio` exceeds the 25 MB limit before any network call is made.
655    ///
656    /// # Example
657    ///
658    /// ```no_run
659    /// use typecast_rust::TypecastClient;
660    ///
661    /// # async fn example() -> typecast_rust::Result<()> {
662    /// let client = TypecastClient::from_env()?;
663    /// let audio = std::fs::read("sample.wav").unwrap();
664    /// let voice = client.clone_voice(audio, "sample.wav", "My Voice", "ssfm-v30").await?;
665    /// println!("Cloned voice ID: {}", voice.voice_id);
666    /// # Ok(())
667    /// # }
668    /// ```
669    pub async fn clone_voice(
670        &self,
671        audio: Vec<u8>,
672        filename: &str,
673        name: &str,
674        model: &str,
675    ) -> Result<CustomVoice> {
676        let name_len = name.chars().count();
677        if !(NAME_MIN_LENGTH..=NAME_MAX_LENGTH).contains(&name_len) {
678            return Err(TypecastError::ValidationError {
679                detail: format!(
680                    "name must be {}-{} characters; got {}",
681                    NAME_MIN_LENGTH, NAME_MAX_LENGTH, name_len
682                ),
683            });
684        }
685        if audio.len() > CLONING_MAX_FILE_SIZE {
686            return Err(TypecastError::ValidationError {
687                detail: format!("audio file exceeds 25MB limit; got {} bytes", audio.len()),
688            });
689        }
690
691        let mime = guess_audio_mime(filename);
692        let part = reqwest::multipart::Part::bytes(audio)
693            .file_name(filename.to_string())
694            .mime_str(mime)
695            .expect("guess_audio_mime only returns valid MIME constants");
696        let form = reqwest::multipart::Form::new()
697            .text("name", name.to_string())
698            .text("model", model.to_string())
699            .part("file", part);
700
701        let url = self.build_url("/v1/voices/clone", None);
702        let response = self
703            .with_auth_header(self.client.post(&url))
704            .multipart(form)
705            .send()
706            .await?;
707
708        if !response.status().is_success() {
709            return Err(self.handle_error_response(response).await);
710        }
711
712        let voice: CustomVoice = response.json().await?;
713        Ok(voice)
714    }
715
716    /// Delete a custom (cloned) voice by its ID.
717    ///
718    /// Sends `DELETE /v1/voices/{voice_id}`. A 204 No Content response is
719    /// treated as success; any other non-2xx status is mapped to a
720    /// [`TypecastError`].
721    ///
722    /// # Arguments
723    ///
724    /// * `voice_id` - The voice ID returned by [`TypecastClient::clone_voice`].
725    ///
726    /// # Example
727    ///
728    /// ```no_run
729    /// use typecast_rust::TypecastClient;
730    ///
731    /// # async fn example() -> typecast_rust::Result<()> {
732    /// let client = TypecastClient::from_env()?;
733    /// client.delete_voice("cv_abc123").await?;
734    /// println!("Voice deleted.");
735    /// # Ok(())
736    /// # }
737    /// ```
738    pub async fn delete_voice(&self, voice_id: &str) -> Result<()> {
739        let url = self.build_url(&format!("/v1/voices/{}", voice_id), None);
740        let response = self
741            .with_auth_header(self.client.delete(&url))
742            .send()
743            .await?;
744
745        let status = response.status();
746        if !status.is_success() {
747            return Err(self.handle_error_response(response).await);
748        }
749        Ok(())
750    }
751}
752
753fn build_user_agent(base_url: &str, timeout: Duration) -> String {
754    let base = if is_default_base_url(base_url) {
755        "default"
756    } else {
757        "custom"
758    };
759    let timeout_value = if timeout == Duration::from_secs(DEFAULT_TIMEOUT_SECS) {
760        "default".to_string()
761    } else {
762        format!("{}ms", timeout.as_millis())
763    };
764    format!(
765        "typecast-rust/{} Rust/{} reqwest (base={}; timeout={}; os={}; arch={}; sdk_env=rust; platform=server)",
766        env!("CARGO_PKG_VERSION"),
767        rust_version(),
768        base,
769        timeout_value,
770        os_name(),
771        arch_name()
772    )
773}
774
775fn rust_version() -> &'static str {
776    "unknown"
777}
778
779fn os_name() -> &'static str {
780    normalize_os_name(env::consts::OS)
781}
782
783fn normalize_os_name(os: &str) -> &'static str {
784    match os {
785        "macos" => "macos",
786        "windows" => "windows",
787        "linux" => "linux",
788        "ios" => "ios",
789        "android" => "android",
790        _ => "unknown",
791    }
792}
793
794fn arch_name() -> &'static str {
795    normalize_arch_name(env::consts::ARCH)
796}
797
798fn normalize_arch_name(arch: &str) -> &'static str {
799    match arch {
800        "x86_64" => "x64",
801        "aarch64" => "arm64",
802        "x86" => "x86",
803        "arm" => "arm",
804        _ => "unknown",
805    }
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811
812    #[test]
813    fn user_agent_includes_sdk_metadata_and_base_timeout_context() {
814        let default_user_agent =
815            build_user_agent(DEFAULT_BASE_URL, Duration::from_secs(DEFAULT_TIMEOUT_SECS));
816        assert!(default_user_agent.starts_with("typecast-rust/"));
817        assert!(default_user_agent.contains("base=default"));
818        assert!(default_user_agent.contains("timeout=default"));
819        assert!(default_user_agent.contains("sdk_env=rust; platform=server"));
820
821        let custom_user_agent = build_user_agent("https://proxy.example", Duration::from_secs(5));
822        assert!(custom_user_agent.contains("base=custom"));
823        assert!(custom_user_agent.contains("timeout=5000ms"));
824    }
825
826    #[test]
827    fn platform_metadata_normalizes_known_and_unknown_values() {
828        assert_eq!(normalize_os_name("macos"), "macos");
829        assert_eq!(normalize_os_name("windows"), "windows");
830        assert_eq!(normalize_os_name("linux"), "linux");
831        assert_eq!(normalize_os_name("ios"), "ios");
832        assert_eq!(normalize_os_name("android"), "android");
833        assert_eq!(normalize_os_name("solaris"), "unknown");
834
835        assert_eq!(normalize_arch_name("x86_64"), "x64");
836        assert_eq!(normalize_arch_name("aarch64"), "arm64");
837        assert_eq!(normalize_arch_name("x86"), "x86");
838        assert_eq!(normalize_arch_name("arm"), "arm");
839        assert_eq!(normalize_arch_name("mips"), "unknown");
840    }
841}
842
843/// Infer an audio MIME type from a filename extension.
844///
845/// Defaults to `application/octet-stream` for unrecognised extensions.
846fn guess_audio_mime(filename: &str) -> &'static str {
847    let lower = filename.to_lowercase();
848    if lower.ends_with(".wav") {
849        "audio/wav"
850    } else if lower.ends_with(".mp3") {
851        "audio/mpeg"
852    } else if lower.ends_with(".ogg") {
853        "audio/ogg"
854    } else if lower.ends_with(".flac") {
855        "audio/flac"
856    } else if lower.ends_with(".m4a") {
857        "audio/mp4"
858    } else {
859        "application/octet-stream"
860    }
861}
862
863fn is_default_base_url(base_url: &str) -> bool {
864    base_url.eq_ignore_ascii_case(DEFAULT_BASE_URL)
865}
866
867/// URL encoding helper
868mod urlencoding {
869    pub fn encode(s: &str) -> String {
870        url_encode(s)
871    }
872
873    fn url_encode(s: &str) -> String {
874        let mut result = String::new();
875        for c in s.chars() {
876            match c {
877                'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => {
878                    result.push(c);
879                }
880                _ => {
881                    for b in c.to_string().as_bytes() {
882                        result.push_str(&format!("%{:02X}", b));
883                    }
884                }
885            }
886        }
887        result
888    }
889}