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