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(&params);
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}