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}