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,
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};
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        if !api_key.is_empty() {
150            headers.insert(
151                "X-API-KEY",
152                HeaderValue::from_str(&api_key).map_err(|_| TypecastError::BadRequest {
153                    detail: "Invalid API key format".to_string(),
154                })?,
155            );
156        }
157
158        // `reqwest::Client::builder().build()` only fails if TLS init fails,
159        // which is not something we can usefully recover from at this layer.
160        let client = reqwest::Client::builder()
161            .default_headers(headers)
162            .timeout(config.timeout)
163            .build()
164            .expect("reqwest client builder should not fail");
165
166        Ok(Self {
167            client,
168            base_url,
169            api_key,
170        })
171    }
172
173    /// Create a new TypecastClient from environment variables
174    ///
175    /// Reads TYPECAST_API_KEY and optionally TYPECAST_API_HOST
176    pub fn from_env() -> Result<Self> {
177        Self::new(ClientConfig::default())
178    }
179
180    /// Create a new TypecastClient with just an API key
181    pub fn with_api_key(api_key: impl Into<String>) -> Result<Self> {
182        Self::new(ClientConfig::new(api_key))
183    }
184
185    /// Get the base URL
186    pub fn base_url(&self) -> &str {
187        &self.base_url
188    }
189
190    /// Get the API key (masked)
191    pub fn api_key_masked(&self) -> String {
192        if self.api_key.len() > 8 {
193            format!(
194                "{}...{}",
195                &self.api_key[..4],
196                &self.api_key[self.api_key.len() - 4..]
197            )
198        } else {
199            "****".to_string()
200        }
201    }
202
203    /// Create a builder for composed speech with multiple speech segments and pauses.
204    pub fn compose_speech(&self) -> SpeechComposer<'_> {
205        SpeechComposer::new(self)
206    }
207
208    fn with_auth_header(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
209        if self.api_key.is_empty() {
210            request
211        } else {
212            request.header("X-API-KEY", &self.api_key)
213        }
214    }
215
216    /// Build a URL with optional query parameters.
217    ///
218    /// Callers must pass `None` when there are no query parameters; passing
219    /// `Some(vec![])` is not supported and will produce a trailing `?`.
220    fn build_url(&self, path: &str, params: Option<Vec<(&str, String)>>) -> String {
221        let base = format!("{}{}", self.base_url, path);
222        match params {
223            Some(params) => {
224                let query: Vec<String> = params
225                    .into_iter()
226                    .map(|(k, v)| format!("{}={}", k, urlencoding::encode(&v)))
227                    .collect();
228                format!("{}?{}", base, query.join("&"))
229            }
230            None => base,
231        }
232    }
233
234    /// Handle an error response
235    async fn handle_error_response(&self, response: reqwest::Response) -> TypecastError {
236        let status_code = response.status().as_u16();
237        let error_response: Option<ErrorResponse> = response.json().await.ok();
238        TypecastError::from_response(status_code, error_response)
239    }
240
241    /// Convert text to speech
242    ///
243    /// # Arguments
244    ///
245    /// * `request` - The TTS request containing text, voice_id, model, and optional settings
246    ///
247    /// # Returns
248    ///
249    /// Returns a `TTSResponse` containing the audio data, duration, and format
250    ///
251    /// # Example
252    ///
253    /// ```no_run
254    /// use typecast_rust::{TypecastClient, TTSRequest, TTSModel, ClientConfig};
255    ///
256    /// # async fn example() -> typecast_rust::Result<()> {
257    /// let client = TypecastClient::from_env()?;
258    /// let request = TTSRequest::new(
259    ///     "tc_60e5426de8b95f1d3000d7b5",
260    ///     "Hello, world!",
261    ///     TTSModel::SsfmV30,
262    /// );
263    /// let response = client.text_to_speech(&request).await?;
264    /// println!("Audio duration: {} seconds", response.duration);
265    /// # Ok(())
266    /// # }
267    /// ```
268    pub async fn text_to_speech(&self, request: &TTSRequest) -> Result<TTSResponse> {
269        let url = self.build_url("/v1/text-to-speech", None);
270
271        let response = self.client.post(&url).json(request).send().await?;
272
273        if !response.status().is_success() {
274            return Err(self.handle_error_response(response).await);
275        }
276
277        // Parse content type for format
278        let content_type = response
279            .headers()
280            .get(CONTENT_TYPE)
281            .and_then(|v| v.to_str().ok())
282            .unwrap_or("audio/wav");
283
284        let format = if content_type.contains("mp3") || content_type.contains("mpeg") {
285            AudioFormat::Mp3
286        } else {
287            AudioFormat::Wav
288        };
289
290        // Parse duration from header
291        let duration = response
292            .headers()
293            .get("X-Audio-Duration")
294            .and_then(|v| v.to_str().ok())
295            .and_then(|v| v.parse::<f64>().ok())
296            .unwrap_or(0.0);
297
298        let audio_data = response.bytes().await?.to_vec();
299
300        Ok(TTSResponse {
301            audio_data,
302            duration,
303            format,
304        })
305    }
306
307    /// Convert text to speech and write the audio bytes to a file.
308    ///
309    /// If `request.output.audio_format` is omitted, the format is inferred from
310    /// a `.mp3` or `.wav` file extension.
311    pub async fn generate_to_file(
312        &self,
313        path: impl AsRef<Path>,
314        request: GenerateToFileRequest,
315    ) -> Result<TTSResponse> {
316        let path_ref = path.as_ref();
317        let mut tts_request = request.into_tts_request();
318        let inferred = infer_audio_format_from_path(path_ref);
319        match tts_request.output.as_mut() {
320            Some(output) => {
321                if output.audio_format.is_none() {
322                    output.audio_format = inferred;
323                }
324            }
325            None => {
326                if let Some(format) = inferred {
327                    tts_request.output = Some(crate::models::Output::new().audio_format(format));
328                }
329            }
330        }
331
332        let response = self.text_to_speech(&tts_request).await?;
333        fs::write(path_ref, &response.audio_data)
334            .map_err(|e| TypecastError::IoError(e.to_string()))?;
335        Ok(response)
336    }
337
338    /// Convert text to speech as a streaming response
339    ///
340    /// Returns a stream of audio byte chunks. For `wav` output the first chunk
341    /// contains the WAV header followed by PCM samples; for `mp3` output each
342    /// chunk is independently decodable.
343    ///
344    /// # Arguments
345    ///
346    /// * `request` - The streaming TTS request
347    ///
348    /// # Returns
349    ///
350    /// A pinned boxed [`Stream`] yielding [`Result<Bytes>`] chunks.
351    ///
352    /// # Example
353    ///
354    /// ```no_run
355    /// use futures_util::StreamExt;
356    /// use typecast_rust::{TypecastClient, TTSRequestStream, TTSModel};
357    ///
358    /// # async fn example() -> typecast_rust::Result<()> {
359    /// let client = TypecastClient::from_env()?;
360    /// let request = TTSRequestStream::new(
361    ///     "tc_60e5426de8b95f1d3000d7b5",
362    ///     "Hello, world!",
363    ///     TTSModel::SsfmV30,
364    /// );
365    /// let mut stream = client.text_to_speech_stream(&request).await?;
366    /// while let Some(chunk) = stream.next().await {
367    ///     let bytes = chunk?;
368    ///     // write bytes to file or audio sink
369    ///     let _ = bytes;
370    /// }
371    /// # Ok(())
372    /// # }
373    /// ```
374    pub async fn text_to_speech_stream(
375        &self,
376        request: &TTSRequestStream,
377    ) -> Result<AudioByteStream> {
378        let url = self.build_url("/v1/text-to-speech/stream", None);
379
380        let response = self.client.post(&url).json(request).send().await?;
381
382        if !response.status().is_success() {
383            return Err(self.handle_error_response(response).await);
384        }
385
386        let stream = response
387            .bytes_stream()
388            .map(|item| item.map_err(TypecastError::from));
389        Ok(Box::pin(stream))
390    }
391
392    /// Get voices with enhanced metadata (V2 API)
393    ///
394    /// # Arguments
395    ///
396    /// * `filter` - Optional filter for voices (model, gender, age, use_cases)
397    ///
398    /// # Returns
399    ///
400    /// Returns a list of `VoiceV2` with enhanced metadata
401    ///
402    /// # Example
403    ///
404    /// ```no_run
405    /// use typecast_rust::{TypecastClient, VoicesV2Filter, TTSModel, Gender, ClientConfig};
406    ///
407    /// # async fn example() -> typecast_rust::Result<()> {
408    /// let client = TypecastClient::from_env()?;
409    ///
410    /// // Get all voices
411    /// let voices = client.get_voices_v2(None).await?;
412    ///
413    /// // Get filtered voices
414    /// let filter = VoicesV2Filter::new()
415    ///     .model(TTSModel::SsfmV30)
416    ///     .gender(Gender::Female);
417    /// let filtered_voices = client.get_voices_v2(Some(filter)).await?;
418    /// # Ok(())
419    /// # }
420    /// ```
421    pub async fn get_voices_v2(&self, filter: Option<VoicesV2Filter>) -> Result<Vec<VoiceV2>> {
422        let mut params = Vec::new();
423
424        if let Some(f) = filter {
425            if let Some(model) = f.model {
426                params.push(("model", model_query_value(model).to_string()));
427            }
428            if let Some(gender) = f.gender {
429                params.push(("gender", gender_query_value(gender).to_string()));
430            }
431            if let Some(age) = f.age {
432                params.push(("age", age_query_value(age).to_string()));
433            }
434            if let Some(use_cases) = f.use_cases {
435                params.push(("use_cases", use_case_query_value(use_cases).to_string()));
436            }
437        }
438
439        let url = self.build_url(
440            "/v2/voices",
441            if params.is_empty() {
442                None
443            } else {
444                Some(params)
445            },
446        );
447
448        let response = self.client.get(&url).send().await?;
449
450        if !response.status().is_success() {
451            return Err(self.handle_error_response(response).await);
452        }
453
454        let voices: Vec<VoiceV2> = response.json().await?;
455        Ok(voices)
456    }
457
458    /// Get a specific voice by ID with enhanced metadata (V2 API)
459    ///
460    /// # Arguments
461    ///
462    /// * `voice_id` - The voice ID (e.g., 'tc_60e5426de8b95f1d3000d7b5')
463    ///
464    /// # Returns
465    ///
466    /// Returns a `VoiceV2` with enhanced metadata
467    ///
468    /// # Example
469    ///
470    /// ```no_run
471    /// use typecast_rust::{TypecastClient, ClientConfig};
472    ///
473    /// # async fn example() -> typecast_rust::Result<()> {
474    /// let client = TypecastClient::from_env()?;
475    /// let voice = client.get_voice_v2("tc_60e5426de8b95f1d3000d7b5").await?;
476    /// println!("Voice: {} ({})", voice.voice_name, voice.voice_id);
477    /// # Ok(())
478    /// # }
479    /// ```
480    pub async fn get_voice_v2(&self, voice_id: &str) -> Result<VoiceV2> {
481        let url = self.build_url(&format!("/v2/voices/{}", voice_id), None);
482
483        let response = self.client.get(&url).send().await?;
484
485        if !response.status().is_success() {
486            return Err(self.handle_error_response(response).await);
487        }
488
489        let voice: VoiceV2 = response.json().await?;
490        Ok(voice)
491    }
492
493    /// Convert text to speech with word- and/or character-level timestamps.
494    ///
495    /// # Arguments
496    ///
497    /// * `request` - The TTS request with timestamps parameters.
498    /// * `granularity` - Optional granularity: `None` (both), `"word"`, or `"char"`.
499    ///
500    /// # Returns
501    ///
502    /// A [`crate::timestamps::TTSWithTimestampsResponse`] containing the Base64-encoded audio
503    /// and alignment segment arrays.  Use the response's `.to_srt()` / `.to_vtt()` methods to
504    /// generate subtitle files.
505    ///
506    /// # Example
507    ///
508    /// ```no_run
509    /// use typecast_rust::{TypecastClient, TTSModel};
510    /// use typecast_rust::timestamps::TTSRequestWithTimestamps;
511    ///
512    /// # async fn example() -> typecast_rust::Result<()> {
513    /// let client = TypecastClient::from_env()?;
514    /// let request = TTSRequestWithTimestamps::new(
515    ///     "tc_60e5426de8b95f1d3000d7b5",
516    ///     "Hello, world!",
517    ///     TTSModel::SsfmV30,
518    /// );
519    /// let response = client.text_to_speech_with_timestamps(&request, None).await?;
520    /// let srt = response.to_srt()?;
521    /// println!("{}", srt);
522    /// # Ok(())
523    /// # }
524    /// ```
525    pub async fn text_to_speech_with_timestamps(
526        &self,
527        request: &crate::timestamps::TTSRequestWithTimestamps,
528        granularity: Option<&str>,
529    ) -> Result<crate::timestamps::TTSWithTimestampsResponse> {
530        if let Some(g) = granularity {
531            if g != "word" && g != "char" {
532                return Err(TypecastError::ValidationError {
533                    detail: format!(
534                        "granularity must be None, \"word\", or \"char\"; got {:?}",
535                        g
536                    ),
537                });
538            }
539        }
540
541        let url = match granularity {
542            Some(g) => self.build_url(
543                "/v1/text-to-speech/with-timestamps",
544                Some(vec![("granularity", g.to_string())]),
545            ),
546            None => self.build_url("/v1/text-to-speech/with-timestamps", None),
547        };
548
549        let response = self.client.post(&url).json(request).send().await?;
550
551        if !response.status().is_success() {
552            return Err(self.handle_error_response(response).await);
553        }
554
555        let parsed: crate::timestamps::TTSWithTimestampsResponse = response
556            .json()
557            .await
558            .map_err(|e| TypecastError::DecodeError(e.to_string()))?;
559        Ok(parsed)
560    }
561
562    /// Get the authenticated user's subscription
563    ///
564    /// # Returns
565    ///
566    /// Returns a `SubscriptionResponse` containing the user's plan, credits,
567    /// and usage limits.
568    ///
569    /// # Example
570    ///
571    /// ```no_run
572    /// use typecast_rust::TypecastClient;
573    ///
574    /// # async fn example() -> typecast_rust::Result<()> {
575    /// let client = TypecastClient::from_env()?;
576    /// let subscription = client.get_my_subscription().await?;
577    /// println!("Plan: {:?}", subscription.plan);
578    /// println!(
579    ///     "Credits: {}/{}",
580    ///     subscription.credits.used_credits, subscription.credits.plan_credits
581    /// );
582    /// # Ok(())
583    /// # }
584    /// ```
585    pub async fn get_my_subscription(&self) -> Result<SubscriptionResponse> {
586        let url = self.build_url("/v1/users/me/subscription", None);
587
588        let response = self.client.get(&url).send().await?;
589
590        if !response.status().is_success() {
591            return Err(self.handle_error_response(response).await);
592        }
593
594        let subscription: SubscriptionResponse = response.json().await?;
595        Ok(subscription)
596    }
597
598    /// Clone a voice from an audio recording.
599    ///
600    /// Uploads the audio file as `multipart/form-data` to `POST /v1/voices/clone`
601    /// and returns a [`CustomVoice`] representing the newly created voice.
602    ///
603    /// # Arguments
604    ///
605    /// * `audio` - Raw audio bytes (WAV or MP3). Must not exceed 25 MB.
606    /// * `filename` - File name used in the multipart part (e.g. `"sample.wav"`).
607    ///   The extension determines the MIME type sent to the API.
608    /// * `name` - Display name for the custom voice (1–30 characters).
609    /// * `model` - TTS model to use for cloning (e.g. `"ssfm-v30"`).
610    ///
611    /// # Errors
612    ///
613    /// Returns [`TypecastError::ValidationError`] if `name` is outside 1–30 characters
614    /// or if `audio` exceeds the 25 MB limit before any network call is made.
615    ///
616    /// # Example
617    ///
618    /// ```no_run
619    /// use typecast_rust::TypecastClient;
620    ///
621    /// # async fn example() -> typecast_rust::Result<()> {
622    /// let client = TypecastClient::from_env()?;
623    /// let audio = std::fs::read("sample.wav").unwrap();
624    /// let voice = client.clone_voice(audio, "sample.wav", "My Voice", "ssfm-v30").await?;
625    /// println!("Cloned voice ID: {}", voice.voice_id);
626    /// # Ok(())
627    /// # }
628    /// ```
629    pub async fn clone_voice(
630        &self,
631        audio: Vec<u8>,
632        filename: &str,
633        name: &str,
634        model: &str,
635    ) -> Result<CustomVoice> {
636        let name_len = name.chars().count();
637        if !(NAME_MIN_LENGTH..=NAME_MAX_LENGTH).contains(&name_len) {
638            return Err(TypecastError::ValidationError {
639                detail: format!(
640                    "name must be {}-{} characters; got {}",
641                    NAME_MIN_LENGTH, NAME_MAX_LENGTH, name_len
642                ),
643            });
644        }
645        if audio.len() > CLONING_MAX_FILE_SIZE {
646            return Err(TypecastError::ValidationError {
647                detail: format!("audio file exceeds 25MB limit; got {} bytes", audio.len()),
648            });
649        }
650
651        let mime = guess_audio_mime(filename);
652        let part = reqwest::multipart::Part::bytes(audio)
653            .file_name(filename.to_string())
654            .mime_str(mime)
655            .expect("guess_audio_mime only returns valid MIME constants");
656        let form = reqwest::multipart::Form::new()
657            .text("name", name.to_string())
658            .text("model", model.to_string())
659            .part("file", part);
660
661        let url = self.build_url("/v1/voices/clone", None);
662        let response = self
663            .with_auth_header(self.client.post(&url))
664            .multipart(form)
665            .send()
666            .await?;
667
668        if !response.status().is_success() {
669            return Err(self.handle_error_response(response).await);
670        }
671
672        let voice: CustomVoice = response.json().await?;
673        Ok(voice)
674    }
675
676    /// Delete a custom (cloned) voice by its ID.
677    ///
678    /// Sends `DELETE /v1/voices/{voice_id}`. A 204 No Content response is
679    /// treated as success; any other non-2xx status is mapped to a
680    /// [`TypecastError`].
681    ///
682    /// # Arguments
683    ///
684    /// * `voice_id` - The voice ID returned by [`TypecastClient::clone_voice`].
685    ///
686    /// # Example
687    ///
688    /// ```no_run
689    /// use typecast_rust::TypecastClient;
690    ///
691    /// # async fn example() -> typecast_rust::Result<()> {
692    /// let client = TypecastClient::from_env()?;
693    /// client.delete_voice("cv_abc123").await?;
694    /// println!("Voice deleted.");
695    /// # Ok(())
696    /// # }
697    /// ```
698    pub async fn delete_voice(&self, voice_id: &str) -> Result<()> {
699        let url = self.build_url(&format!("/v1/voices/{}", voice_id), None);
700        let response = self
701            .with_auth_header(self.client.delete(&url))
702            .send()
703            .await?;
704
705        let status = response.status();
706        if !status.is_success() {
707            return Err(self.handle_error_response(response).await);
708        }
709        Ok(())
710    }
711}
712
713/// Infer an audio MIME type from a filename extension.
714///
715/// Defaults to `application/octet-stream` for unrecognised extensions.
716fn guess_audio_mime(filename: &str) -> &'static str {
717    let lower = filename.to_lowercase();
718    if lower.ends_with(".wav") {
719        "audio/wav"
720    } else if lower.ends_with(".mp3") {
721        "audio/mpeg"
722    } else if lower.ends_with(".ogg") {
723        "audio/ogg"
724    } else if lower.ends_with(".flac") {
725        "audio/flac"
726    } else if lower.ends_with(".m4a") {
727        "audio/mp4"
728    } else {
729        "application/octet-stream"
730    }
731}
732
733fn is_default_base_url(base_url: &str) -> bool {
734    base_url.eq_ignore_ascii_case(DEFAULT_BASE_URL)
735}
736
737/// URL encoding helper
738mod urlencoding {
739    pub fn encode(s: &str) -> String {
740        url_encode(s)
741    }
742
743    fn url_encode(s: &str) -> String {
744        let mut result = String::new();
745        for c in s.chars() {
746            match c {
747                'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' | '~' => {
748                    result.push(c);
749                }
750                _ => {
751                    for b in c.to_string().as_bytes() {
752                        result.push_str(&format!("%{:02X}", b));
753                    }
754                }
755            }
756        }
757        result
758    }
759}