qobuz_api_rust/utils.rs
1use std::{
2 env::var,
3 fs::{read_to_string, write},
4 path::Path,
5 time::{Duration, SystemTime, UNIX_EPOCH},
6};
7
8use {
9 base64::{Engine, engine::general_purpose::STANDARD},
10 dotenvy::from_path,
11 md5::compute,
12 regex::Regex,
13 reqwest::{Client, Response, get},
14 serde::de::DeserializeOwned,
15 serde_json::from_str,
16 url::form_urlencoded::byte_serialize,
17};
18
19use crate::errors::QobuzApiError::{
20 self, ApiResponseParseError, DownloadError, HttpError, QobuzApiInitializationError,
21};
22
23/// Computes the MD5 hash of the input string.
24///
25/// This function takes a string slice and returns its MD5 hash as a hexadecimal string.
26/// MD5 hashing is commonly used for generating unique identifiers or for basic data
27/// integrity verification.
28///
29/// # Arguments
30///
31/// * `input` - A string slice that holds the input to be hashed
32///
33/// # Returns
34///
35/// A `String` containing the hexadecimal representation of the MD5 hash
36///
37/// # Examples
38///
39/// ```
40/// use qobuz_api_rust::utils::get_md5_hash;
41///
42/// let hash = get_md5_hash("hello world");
43/// assert_eq!(hash, "5eb63bbbe01eeed093cb22bb8f5acdc3");
44/// ```
45pub fn get_md5_hash(input: &str) -> String {
46 format!("{:x}", compute(input.as_bytes()))
47}
48
49/// Builds a query string from a collection of key-value pairs.
50///
51/// This function takes a slice of tuples containing string keys and values, filters out
52/// any pairs with empty values, URL-encodes the keys and values, and joins them with
53/// ampersands to form a valid query string. This is commonly used when constructing
54/// API requests that require query parameters.
55///
56/// # Arguments
57///
58/// * `params` - A slice of tuples containing key-value pairs as strings
59///
60/// # Returns
61///
62/// A `String` containing the URL-encoded query string
63///
64/// # Examples
65///
66/// ```
67/// use qobuz_api_rust::utils::to_query_string;
68///
69/// let params = vec![
70/// ("name".to_string(), "John".to_string()),
71/// ("age".to_string(), "30".to_string()),
72/// ("city".to_string(), "".to_string()), // This will be filtered out
73/// ];
74/// let query_string = to_query_string(¶ms);
75/// assert_eq!(query_string, "name=John&age=30");
76/// ```
77pub fn to_query_string(params: &[(String, String)]) -> String {
78 let filtered_params: Vec<String> = params
79 .iter()
80 .filter(|(_, value)| !value.is_empty())
81 .map(|(key, value)| {
82 byte_serialize(key.as_bytes()).collect::<String>()
83 + "="
84 + &byte_serialize(value.as_bytes()).collect::<String>()
85 })
86 .collect();
87
88 filtered_params.join("&")
89}
90
91/// Gets the current Unix timestamp as a string.
92///
93/// This function returns the current time as a Unix timestamp (number of seconds
94/// since January 1, 1970 UTC) formatted as a string. Unix timestamps are commonly
95/// used in API requests that require time-based parameters or for generating
96/// unique identifiers based on time.
97///
98/// # Returns
99///
100/// A `String` containing the current Unix timestamp
101///
102/// # Examples
103///
104/// ```
105/// use qobuz_api_rust::utils::get_current_timestamp;
106/// use std::thread::sleep;
107/// use std::time::Duration;
108///
109/// let timestamp1 = get_current_timestamp();
110/// sleep(Duration::from_millis(1000)); // Sleep for 1 second
111/// let timestamp2 = get_current_timestamp();
112/// // The timestamps should be different (or the same if called in the same second)
113/// ```
114pub fn get_current_timestamp() -> String {
115 SystemTime::now()
116 .duration_since(UNIX_EPOCH)
117 .expect("Time went backwards")
118 .as_secs()
119 .to_string()
120}
121
122/// Extracts the app ID from Qobuz Web Player's bundle.js file.
123///
124/// This asynchronous function fetches the Qobuz Web Player's JavaScript bundle file
125/// and extracts the application ID using regular expressions. The app ID is required
126/// for authenticating with the Qobuz API. This function is useful when you don't have
127/// a pre-configured app ID and need to extract it dynamically from the web player.
128///
129/// # Returns
130///
131/// * `Ok(String)` - The extracted app ID if found in the bundle
132/// * `Err(Box<dyn Error>)` - If the bundle couldn't be fetched or the app ID couldn't be extracted
133///
134/// # Errors
135///
136/// This function will return an error if:
137/// - The web request to fetch the bundle.js fails
138/// - The regular expression pattern fails to match
139/// - The app ID cannot be extracted from the bundle content
140///
141/// # Examples
142///
143/// ```no_run
144/// use qobuz_api_rust::utils::get_web_player_app_id;
145///
146/// #[tokio::main]
147/// async fn main() -> Result<(), qobuz_api_rust::QobuzApiError> {
148/// let app_id = get_web_player_app_id().await?;
149/// println!("App ID: {}", app_id);
150/// Ok(())
151/// }
152/// ```
153pub async fn get_web_player_app_id() -> Result<String, QobuzApiError> {
154 let bundle_content = fetch_bundle_js().await?;
155
156 // Extract app_id from bundle.js using regex
157 let re =
158 Regex::new(r#"production:\{api:\{appId:"(?P<appID>[^"]*)",appSecret:"#).map_err(|e| {
159 QobuzApiInitializationError {
160 message: format!("Failed to create regex for app ID extraction: {}", e),
161 }
162 })?;
163 if let Some(caps) = re.captures(&bundle_content)
164 && let Some(app_id) = caps.name("appID")
165 {
166 return Ok(app_id.as_str().to_string());
167 }
168
169 Err(QobuzApiInitializationError {
170 message: "Failed to extract app_id from bundle.js".to_string(),
171 })
172}
173
174/// Extracts the app secret from Qobuz Web Player's bundle.js file.
175///
176/// This asynchronous function fetches the Qobuz Web Player's JavaScript bundle file
177/// and extracts the application secret using a complex multi-step process involving
178/// regular expressions and base64 decoding. The app secret is required for
179/// authenticating with the Qobuz API. This function is useful when you don't have
180/// a pre-configured app secret and need to extract it dynamically from the web player.
181///
182/// The extraction process involves:
183/// 1. Finding seed and timezone information in the bundle
184/// 2. Processing timezone information to find relevant sections
185/// 3. Extracting info and extras data
186/// 4. Combining and truncating the data
187/// 5. Base64 decoding the result to get the app secret
188///
189/// # Returns
190///
191/// * `Ok(String)` - The extracted app secret if found in the bundle
192/// * `Err(Box<dyn Error>)` - If the bundle couldn't be fetched or the app secret couldn't be extracted
193///
194/// # Errors
195///
196/// This function will return an error if:
197/// - The web request to fetch the bundle.js fails
198/// - Any of the regular expression patterns fail to match
199/// - The concatenated string is too short for processing
200/// - Base64 decoding fails
201/// - UTF-8 conversion of the decoded bytes fails
202///
203/// # Examples
204///
205/// ```no_run
206/// use qobuz_api_rust::utils::get_web_player_app_secret;
207///
208/// #[tokio::main]
209/// async fn main() -> Result<(), qobuz_api_rust::QobuzApiError> {
210/// let app_secret = get_web_player_app_secret().await?;
211/// println!("App Secret: {}", app_secret);
212/// Ok(())
213/// }
214/// ```
215pub async fn get_web_player_app_secret() -> Result<String, QobuzApiError> {
216 let bundle_content = fetch_bundle_js().await?;
217
218 // Extract seed and timezone from bundle.js
219 let seed_timezone_re = Regex::new(
220 r#"\):[a-z]\.initialSeed\("(?P<seed>.*?)",window\.utimezone\.(?P<timezone>[a-z]+)\)"#,
221 )
222 .map_err(|e| QobuzApiInitializationError {
223 message: format!("Failed to create regex for seed/timezone extraction: {}", e),
224 })?;
225 let seed_timezone_caps =
226 seed_timezone_re
227 .captures(&bundle_content)
228 .ok_or(QobuzApiInitializationError {
229 message: "Failed to find seed and timezone in bundle.js".to_string(),
230 })?;
231
232 let seed = seed_timezone_caps
233 .name("seed")
234 .map(|m| m.as_str())
235 .unwrap_or("");
236 let timezone = seed_timezone_caps
237 .name("timezone")
238 .map(|m| m.as_str())
239 .unwrap_or("");
240 let title_case_timezone = capitalize_first_letter(timezone);
241
242 // Extract info and extras for the production timezone
243 let info_extras_pattern = format!(r#"name:"[^"]*/{}"[^}}]*"#, title_case_timezone);
244 let info_extras_re =
245 Regex::new(&info_extras_pattern).map_err(|e| QobuzApiInitializationError {
246 message: format!("Failed to create regex for info/extras extraction: {}", e),
247 })?;
248 let info_extras_caps =
249 info_extras_re
250 .captures(&bundle_content)
251 .ok_or(QobuzApiInitializationError {
252 message: "Failed to find info and extras in bundle.js".to_string(),
253 })?;
254
255 let timezone_object_str = info_extras_caps.get(0).map_or("", |m| m.as_str());
256
257 let info_re =
258 Regex::new(r#"info:"(?P<info>[^"]*)""#).map_err(|e| QobuzApiInitializationError {
259 message: format!("Failed to create regex for info extraction: {}", e),
260 })?;
261 let info = info_re
262 .captures(timezone_object_str)
263 .and_then(|c| c.name("info"))
264 .map_or("", |m| m.as_str());
265
266 let extras_re =
267 Regex::new(r#"extras:"(?P<extras>[^"]*)""#).map_err(|e| QobuzApiInitializationError {
268 message: format!("Failed to create regex for extras extraction: {}", e),
269 })?;
270 let extras = extras_re
271 .captures(timezone_object_str)
272 .and_then(|c| c.name("extras"))
273 .map_or("", |m| m.as_str());
274
275 // Concatenate seed, info, and extras, then remove last 44 characters
276 let mut base64_encoded_secret = format!("{}{}{}", seed, info, extras);
277 if base64_encoded_secret.len() > 44 {
278 base64_encoded_secret.truncate(base64_encoded_secret.len() - 44);
279 } else {
280 return Err(QobuzApiInitializationError {
281 message: "Concatenated string is too short".to_string(),
282 });
283 }
284
285 // Decode base64 to get the app secret
286 let decoded_bytes =
287 STANDARD
288 .decode(base64_encoded_secret)
289 .map_err(|e| QobuzApiInitializationError {
290 message: format!("Failed to decode base64 encoded secret: {}", e),
291 })?;
292 let app_secret = String::from_utf8(decoded_bytes).map_err(|e| QobuzApiInitializationError {
293 message: format!("Failed to convert decoded bytes to string: {}", e),
294 })?;
295
296 Ok(app_secret)
297}
298
299/// Helper function to fetch bundle.js content from Qobuz Web Player.
300///
301/// This internal asynchronous function retrieves the JavaScript bundle file from
302/// the Qobuz Web Player by first fetching the login page to find the bundle URL,
303/// then downloading the actual bundle file. This is used by other functions to
304/// extract API credentials from the web player.
305///
306/// # Returns
307///
308/// * `Ok(String)` - The content of the bundle.js file if successfully fetched
309/// * `Err(Box<dyn Error>)` - If the web requests fail or the bundle URL cannot be found
310///
311/// # Errors
312///
313/// This function will return an error if:
314/// - The request to the login page fails
315/// - The bundle.js URL cannot be found in the login page
316/// - The request to the bundle.js file fails
317/// - The response cannot be converted to text
318async fn fetch_bundle_js() -> Result<String, QobuzApiError> {
319 let client = Client::new();
320
321 // Get the login page to find the bundle.js URL
322 let login_page = client
323 .get("https://play.qobuz.com/login")
324 .header(
325 "User-Agent",
326 "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/110.0",
327 )
328 .timeout(Duration::from_secs(30))
329 .send()
330 .await
331 .map_err(|e| QobuzApiInitializationError {
332 message: format!("Failed to fetch login page: {}", e),
333 })?
334 .text()
335 .await
336 .map_err(|e| QobuzApiInitializationError {
337 message: format!("Failed to read login page content: {}", e),
338 })?;
339
340 // Extract the bundle.js URL from the HTML
341 let bundle_js_re =
342 Regex::new(r#"<script src="(?P<bundleJS>/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"#)
343 .map_err(|e| QobuzApiInitializationError {
344 message: format!("Failed to create regex for bundle.js URL extraction: {}", e),
345 })?;
346 let bundle_js_match =
347 bundle_js_re
348 .captures(&login_page)
349 .ok_or(QobuzApiInitializationError {
350 message: "Failed to find bundle.js URL in login page".to_string(),
351 })?;
352
353 let bundle_js_suffix = bundle_js_match
354 .name("bundleJS")
355 .ok_or(QobuzApiInitializationError {
356 message: "Failed to extract bundle.js suffix".to_string(),
357 })?
358 .as_str();
359
360 // Fetch the actual bundle.js content
361 let bundle_url = format!("https://play.qobuz.com{}", bundle_js_suffix);
362 let bundle_content = client
363 .get(&bundle_url)
364 .header(
365 "User-Agent",
366 "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/110.0",
367 )
368 .timeout(Duration::from_secs(30))
369 .send()
370 .await
371 .map_err(|e| QobuzApiInitializationError {
372 message: format!("Failed to fetch bundle.js: {}", e),
373 })?
374 .text()
375 .await
376 .map_err(|e| QobuzApiInitializationError {
377 message: format!("Failed to read bundle.js content: {}", e),
378 })?;
379
380 Ok(bundle_content)
381}
382
383/// Helper function to capitalize the first letter of a string.
384///
385/// This internal function takes a string and returns a new string with the first
386/// character converted to uppercase while leaving the rest of the string unchanged.
387/// This is used in the app secret extraction process to properly format timezone names.
388///
389/// # Arguments
390///
391/// * `s` - A string slice to capitalize
392///
393/// # Returns
394///
395/// A `String` with the first character capitalized (if any)
396///
397/// # Examples
398///
399/// ```
400/// # use qobuz_api_rust::utils::capitalize_first_letter;
401/// #
402/// assert_eq!(capitalize_first_letter("hello"), "Hello");
403/// assert_eq!(capitalize_first_letter("world"), "World");
404/// assert_eq!(capitalize_first_letter(""), "");
405/// ```
406pub fn capitalize_first_letter(s: &str) -> String {
407 let mut chars = s.chars();
408 match chars.next() {
409 None => String::new(),
410 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
411 }
412}
413
414/// Sanitizes a string to be used as a filename by removing or replacing invalid characters.
415///
416/// This function takes a filename string and sanitizes it by replacing characters
417/// that are invalid in filenames across different operating systems. It also trims
418/// leading/trailing spaces and periods, and limits the length to prevent filesystem issues.
419/// This is particularly useful when saving files with user-provided names or names
420/// derived from API responses.
421///
422/// # Arguments
423///
424/// * `filename` - A string slice containing the filename to sanitize
425///
426/// # Returns
427///
428/// A `String` containing the sanitized filename
429///
430/// # Examples
431///
432/// ```
433/// use qobuz_api_rust::utils::sanitize_filename;
434///
435/// assert_eq!(sanitize_filename("valid_filename.txt"), "valid_filename.txt");
436/// assert_eq!(sanitize_filename("invalid<char>.txt"), "invalid_char_.txt");
437/// assert_eq!(sanitize_filename(" spaced name "), "spaced name");
438/// ```
439pub fn sanitize_filename(filename: &str) -> String {
440 // Replace invalid characters for filenames with safe alternatives
441 // Windows and Unix systems have different restrictions, so we use the more restrictive set
442 let mut sanitized = filename
443 .replace(
444 |c: char| {
445 c == '<' || c == '>' || c == ':' || c == '"' || c == '|' || c == '?' || c == '*'
446 },
447 "_",
448 )
449 .replace(['/', '\\', '\0'], "_"); // null character
450
451 // Remove leading/trailing spaces and periods that may cause issues
452 sanitized = sanitized
453 .trim()
454 .trim_start_matches('.')
455 .trim_end_matches('.')
456 .to_string();
457
458 // Limit length to avoid filesystem issues (most filesystems support up to 255 bytes)
459 if sanitized.len() > 200 {
460 sanitized.truncate(200);
461 // Ensure we don't end up with a trailing space or period after truncation
462 sanitized = sanitized.trim_end().to_string();
463 }
464
465 sanitized
466}
467
468/// Deserializes an HTTP response to the expected type.
469///
470/// This asynchronous function reads the text content from an HTTP response and
471/// attempts to deserialize it into the specified type using serde. This is a
472/// utility function used throughout the library to convert API responses into
473/// Rust data structures. It handles both the reading of the response body and
474/// the deserialization process, providing appropriate error handling for both steps.
475///
476/// # Type Parameters
477///
478/// * `T` - The type to deserialize the response into, must implement `DeserializeOwned`
479///
480/// # Arguments
481///
482/// * `response` - The HTTP response to deserialize
483///
484/// # Returns
485///
486/// * `Ok(T)` - The deserialized data if successful
487/// * `Err(QobuzApiError)` - If reading the response or deserializing fails
488///
489/// # Errors
490///
491/// This function will return an error if:
492/// - Reading the response body fails
493/// - Deserializing the response body to the target type fails
494///
495/// # Examples
496///
497/// ```no_run
498/// use qobuz_api_rust::utils::deserialize_response;
499/// use serde_json::Value;
500/// use reqwest::get;
501///
502/// #[tokio::main]
503/// async fn main() -> Result<(), qobuz_api_rust::QobuzApiError> {
504/// let response = get("https://httpbin.org/json").await.map_err(qobuz_api_rust::QobuzApiError::HttpError)?;
505/// let data: Value = deserialize_response(response).await?;
506/// println!("{:?}", data);
507/// Ok(())
508/// }
509/// ```
510pub async fn deserialize_response<T>(response: Response) -> Result<T, QobuzApiError>
511where
512 T: DeserializeOwned,
513{
514 let content = response.text().await.map_err(HttpError)?;
515
516 // Check if the response is empty, which might indicate an issue
517 if content.trim().is_empty() {
518 return Err(QobuzApiInitializationError {
519 message: "Received empty response from API".to_string(),
520 });
521 }
522
523 from_str::<T>(&content).map_err(|source| ApiResponseParseError {
524 content: content.clone(),
525 source,
526 })
527}
528
529/// Reads app credentials from a .env file.
530///
531/// This function attempts to read Qobuz API credentials (app ID and app secret)
532/// from environment variables, loading them from a .env file if it exists.
533/// The credentials are expected to be stored in environment variables named
534/// `QOBUZ_APP_ID` and `QOBUZ_APP_SECRET`. This function is useful for initializing
535/// the Qobuz API service with stored credentials.
536///
537/// # Returns
538///
539/// * `Ok((Option<String>, Option<String>))` - A tuple containing the app ID and app secret,
540/// with `None` for each if not found in environment variables
541/// * `Err(Box<dyn Error>)` - If there's an issue reading the .env file
542///
543/// # Examples
544///
545/// ```no_run
546/// use qobuz_api_rust::utils::read_app_credentials_from_env;
547///
548/// match read_app_credentials_from_env() {
549/// Ok((Some(app_id), Some(app_secret))) => {
550/// println!("Found credentials: {}, {}", app_id, app_secret);
551/// }
552/// Ok((None, None)) => {
553/// println!("No credentials found in environment");
554/// }
555/// Ok((Some(app_id), None)) => {
556/// println!("Found app ID but no app secret: {}", app_id);
557/// }
558/// Ok((None, Some(_))) => {
559/// println!("Found app secret but no app ID");
560/// }
561/// Err(e) => {
562/// eprintln!("Error reading credentials: {}", e);
563/// }
564/// }
565/// ```
566pub fn read_app_credentials_from_env() -> Result<(Option<String>, Option<String>), QobuzApiError> {
567 // Try to load from .env file
568 if Path::new(".env").exists()
569 && let Err(e) = from_path(".env")
570 {
571 eprintln!("Warning: Failed to load .env file: {}", e);
572 }
573
574 let app_id = var("QOBUZ_APP_ID").ok();
575 let app_secret = var("QOBUZ_APP_SECRET").ok();
576
577 Ok((app_id, app_secret))
578}
579
580/// Writes app credentials to a .env file.
581///
582/// This function saves Qobuz API credentials (app ID and app secret) to a .env file.
583/// If the file already exists, it updates the existing entries; otherwise, it creates
584/// a new file. The credentials are stored in environment variables named
585/// `QOBUZ_APP_ID` and `QOBUZ_APP_SECRET`. This function is useful for caching
586/// credentials retrieved from the web player for future use.
587///
588/// # Arguments
589///
590/// * `app_id` - The app ID to save
591/// * `app_secret` - The app secret to save
592///
593/// # Returns
594///
595/// * `Ok(())` - If the credentials were successfully written to the file
596/// * `Err(Box<dyn Error>)` - If there's an issue reading or writing the .env file
597///
598/// # Examples
599///
600/// ```no_run
601/// use qobuz_api_rust::utils::write_app_credentials_to_env;
602///
603/// # async fn example() -> Result<(), qobuz_api_rust::QobuzApiError> {
604/// let result = write_app_credentials_to_env("my_app_id", "my_app_secret");
605/// match result {
606/// Ok(()) => println!("Credentials saved successfully"),
607/// Err(e) => eprintln!("Error saving credentials: {}", e),
608/// }
609/// # Ok(())
610/// # }
611/// ```
612pub fn write_app_credentials_to_env(app_id: &str, app_secret: &str) -> Result<(), QobuzApiError> {
613 // Read existing content or start with empty string
614 let env_content = if Path::new(".env").exists() {
615 read_to_string(".env").map_err(|e| QobuzApiInitializationError {
616 message: format!("Failed to read .env file: {}", e),
617 })?
618 } else {
619 String::new()
620 };
621
622 // Parse existing content to avoid duplicating entries
623 let mut lines: Vec<String> = env_content.lines().map(|s| s.to_string()).collect();
624 let mut app_id_found = false;
625 let mut app_secret_found = false;
626
627 for line in &mut lines {
628 if line.starts_with("QOBUZ_APP_ID=") {
629 *line = format!("QOBUZ_APP_ID={}", app_id);
630 app_id_found = true;
631 } else if line.starts_with("QOBUZ_APP_SECRET=") {
632 *line = format!("QOBUZ_APP_SECRET={}", app_secret);
633 app_secret_found = true;
634 }
635 }
636
637 // Add missing entries
638 if !app_id_found {
639 lines.push(format!("QOBUZ_APP_ID={}", app_id));
640 }
641 if !app_secret_found {
642 lines.push(format!("QOBUZ_APP_SECRET={}", app_secret));
643 }
644
645 // Write back to .env file
646 write(".env", lines.join("\n")).map_err(|e| QobuzApiInitializationError {
647 message: format!("Failed to write to .env file: {}", e),
648 })?;
649
650 Ok(())
651}
652
653/// Downloads an image from a URL asynchronously.
654///
655/// This function retrieves an image from the specified URL and returns the
656/// image data as a vector of bytes. It's commonly used to download album art,
657/// artist images, or other media associated with Qobuz content. The function
658/// checks the HTTP response status and returns an error if the request fails.
659///
660/// # Arguments
661///
662/// * `url` - A string slice containing the URL of the image to download
663///
664/// # Returns
665///
666/// * `Ok(Vec<u8>)` - The image data as a vector of bytes if the download is successful
667/// * `Err(Box<dyn Error>)` - If the HTTP request fails or the response status is not successful
668///
669/// # Errors
670///
671/// This function will return an error if:
672/// - The HTTP request fails
673/// - The response status is not a success (2xx status code)
674/// - Reading the response body fails
675///
676/// # Examples
677///
678/// ```no_run
679/// use qobuz_api_rust::utils::download_image;
680///
681/// #[tokio::main]
682/// async fn main() -> Result<(), qobuz_api_rust::QobuzApiError> {
683/// let image_data = download_image("https://example.com/image.jpg").await?;
684/// println!("Downloaded {} bytes", image_data.len());
685/// Ok(())
686/// }
687/// ```
688pub async fn download_image(url: &str) -> Result<Vec<u8>, QobuzApiError> {
689 let response = get(url).await.map_err(HttpError)?;
690 if !response.status().is_success() {
691 return Err(DownloadError {
692 message: format!("Failed to download image: HTTP {}", response.status()),
693 });
694 }
695 let bytes = response.bytes().await.map_err(HttpError)?;
696 Ok(bytes.to_vec())
697}
698
699/// Converts a Unix timestamp to a "YYYY-MM-DD" string and extracts the year.
700///
701/// This function provides a basic conversion from a Unix timestamp (seconds since epoch)
702/// to a formatted date string ("YYYY-MM-DD") and the corresponding year.
703/// This implementation is a simplified version and does not account for timezones
704/// or complex calendar rules (like leap seconds, historical calendar changes).
705/// It assumes the timestamp is in UTC and performs a basic calculation to derive
706/// the date components.
707///
708/// # Arguments
709///
710/// * `timestamp` - The Unix timestamp (seconds since January 1, 1970 UTC)
711///
712/// # Returns
713///
714/// A tuple containing:
715/// - `Option<String>`: The formatted date string "YYYY-MM-DD", or `None` if the conversion fails.
716/// - `Option<u32>`: The year as a `u32`, or `None` if the conversion fails.
717///
718/// # Examples
719///
720/// ```
721/// use qobuz_api_rust::utils::timestamp_to_date_and_year;
722///
723/// // Example timestamp for 2023-10-27 10:00:00 UTC
724/// let timestamp = 1698393600;
725/// let (date_str, year) = timestamp_to_date_and_year(timestamp);
726/// assert_eq!(date_str, Some("2023-10-27".to_string()));
727/// assert_eq!(year, Some(2023));
728/// ```
729pub fn timestamp_to_date_and_year(timestamp: i64) -> (Option<String>, Option<u32>) {
730 // Number of seconds in a day
731 const SECONDS_PER_DAY: i64 = 86_400;
732
733 // Unix epoch starts on January 1, 1970
734 let mut days_since_epoch = timestamp / SECONDS_PER_DAY;
735 let mut year = 1970;
736
737 // Determine the year
738 loop {
739 let is_leap_year = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
740 let days_in_current_year = if is_leap_year { 366 } else { 365 };
741
742 if days_since_epoch < days_in_current_year {
743 break; // Found the correct year
744 }
745
746 days_since_epoch -= days_in_current_year;
747 year += 1;
748 }
749
750 // Now days_since_epoch holds the day of the year (0-indexed)
751 // Month lengths (non-leap year)
752 let month_lengths = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
753 let month_lengths_leap = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
754
755 let is_current_year_leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
756 let current_month_lengths = if is_current_year_leap {
757 &month_lengths_leap
758 } else {
759 &month_lengths
760 };
761
762 let mut month = 1;
763 let mut day = 0;
764 let mut days_in_months_passed = 0;
765
766 for (i, &len) in current_month_lengths.iter().enumerate() {
767 if days_since_epoch < (days_in_months_passed + len as i64) {
768 month = i + 1;
769 day = (days_since_epoch - days_in_months_passed) + 1;
770 break;
771 }
772 days_in_months_passed += len as i64;
773 }
774
775 if day == 0 {
776 // Fallback or error case if day calculation fails
777 (None, None)
778 } else {
779 let date_str = format!("{:04}-{:02}-{:02}", year, month, day);
780 (Some(date_str), Some(year as u32))
781 }
782}