use std::{
env::var,
fs::{read_to_string, write},
path::Path,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use {
base64::{Engine, engine::general_purpose::STANDARD},
dotenvy::from_path,
md5::compute,
regex::Regex,
reqwest::{Client, Response, get},
serde::de::DeserializeOwned,
serde_json::from_str,
url::form_urlencoded::byte_serialize,
};
use crate::errors::QobuzApiError::{
self, ApiResponseParseError, DownloadError, HttpError, QobuzApiInitializationError,
};
pub fn get_md5_hash(input: &str) -> String {
format!("{:x}", compute(input.as_bytes()))
}
pub fn to_query_string(params: &[(String, String)]) -> String {
let filtered_params: Vec<String> = params
.iter()
.filter(|(_, value)| !value.is_empty())
.map(|(key, value)| {
byte_serialize(key.as_bytes()).collect::<String>()
+ "="
+ &byte_serialize(value.as_bytes()).collect::<String>()
})
.collect();
filtered_params.join("&")
}
pub fn get_current_timestamp() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Time went backwards")
.as_secs()
.to_string()
}
pub async fn get_web_player_app_id() -> Result<String, QobuzApiError> {
let bundle_content = fetch_bundle_js().await?;
let re =
Regex::new(r#"production:\{api:\{appId:"(?P<appID>[^"]*)",appSecret:"#).map_err(|e| {
QobuzApiInitializationError {
message: format!("Failed to create regex for app ID extraction: {}", e),
}
})?;
if let Some(caps) = re.captures(&bundle_content)
&& let Some(app_id) = caps.name("appID")
{
return Ok(app_id.as_str().to_string());
}
Err(QobuzApiInitializationError {
message: "Failed to extract app_id from bundle.js".to_string(),
})
}
pub async fn get_web_player_app_secret() -> Result<String, QobuzApiError> {
let bundle_content = fetch_bundle_js().await?;
let seed_timezone_re = Regex::new(
r#"\):[a-z]\.initialSeed\("(?P<seed>.*?)",window\.utimezone\.(?P<timezone>[a-z]+)\)"#,
)
.map_err(|e| QobuzApiInitializationError {
message: format!("Failed to create regex for seed/timezone extraction: {}", e),
})?;
let seed_timezone_caps =
seed_timezone_re
.captures(&bundle_content)
.ok_or(QobuzApiInitializationError {
message: "Failed to find seed and timezone in bundle.js".to_string(),
})?;
let seed = seed_timezone_caps
.name("seed")
.map(|m| m.as_str())
.unwrap_or("");
let timezone = seed_timezone_caps
.name("timezone")
.map(|m| m.as_str())
.unwrap_or("");
let title_case_timezone = capitalize_first_letter(timezone);
let info_extras_pattern = format!(r#"name:"[^"]*/{}"[^}}]*"#, title_case_timezone);
let info_extras_re =
Regex::new(&info_extras_pattern).map_err(|e| QobuzApiInitializationError {
message: format!("Failed to create regex for info/extras extraction: {}", e),
})?;
let info_extras_caps =
info_extras_re
.captures(&bundle_content)
.ok_or(QobuzApiInitializationError {
message: "Failed to find info and extras in bundle.js".to_string(),
})?;
let timezone_object_str = info_extras_caps.get(0).map_or("", |m| m.as_str());
let info_re =
Regex::new(r#"info:"(?P<info>[^"]*)""#).map_err(|e| QobuzApiInitializationError {
message: format!("Failed to create regex for info extraction: {}", e),
})?;
let info = info_re
.captures(timezone_object_str)
.and_then(|c| c.name("info"))
.map_or("", |m| m.as_str());
let extras_re =
Regex::new(r#"extras:"(?P<extras>[^"]*)""#).map_err(|e| QobuzApiInitializationError {
message: format!("Failed to create regex for extras extraction: {}", e),
})?;
let extras = extras_re
.captures(timezone_object_str)
.and_then(|c| c.name("extras"))
.map_or("", |m| m.as_str());
let mut base64_encoded_secret = format!("{}{}{}", seed, info, extras);
if base64_encoded_secret.len() > 44 {
base64_encoded_secret.truncate(base64_encoded_secret.len() - 44);
} else {
return Err(QobuzApiInitializationError {
message: "Concatenated string is too short".to_string(),
});
}
let decoded_bytes =
STANDARD
.decode(base64_encoded_secret)
.map_err(|e| QobuzApiInitializationError {
message: format!("Failed to decode base64 encoded secret: {}", e),
})?;
let app_secret = String::from_utf8(decoded_bytes).map_err(|e| QobuzApiInitializationError {
message: format!("Failed to convert decoded bytes to string: {}", e),
})?;
Ok(app_secret)
}
async fn fetch_bundle_js() -> Result<String, QobuzApiError> {
let client = Client::new();
let login_page = client
.get("https://play.qobuz.com/login")
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/110.0",
)
.timeout(Duration::from_secs(30))
.send()
.await
.map_err(|e| QobuzApiInitializationError {
message: format!("Failed to fetch login page: {}", e),
})?
.text()
.await
.map_err(|e| QobuzApiInitializationError {
message: format!("Failed to read login page content: {}", e),
})?;
let bundle_js_re =
Regex::new(r#"<script src="(?P<bundleJS>/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"#)
.map_err(|e| QobuzApiInitializationError {
message: format!("Failed to create regex for bundle.js URL extraction: {}", e),
})?;
let bundle_js_match =
bundle_js_re
.captures(&login_page)
.ok_or(QobuzApiInitializationError {
message: "Failed to find bundle.js URL in login page".to_string(),
})?;
let bundle_js_suffix = bundle_js_match
.name("bundleJS")
.ok_or(QobuzApiInitializationError {
message: "Failed to extract bundle.js suffix".to_string(),
})?
.as_str();
let bundle_url = format!("https://play.qobuz.com{}", bundle_js_suffix);
let bundle_content = client
.get(&bundle_url)
.header(
"User-Agent",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/110.0",
)
.timeout(Duration::from_secs(30))
.send()
.await
.map_err(|e| QobuzApiInitializationError {
message: format!("Failed to fetch bundle.js: {}", e),
})?
.text()
.await
.map_err(|e| QobuzApiInitializationError {
message: format!("Failed to read bundle.js content: {}", e),
})?;
Ok(bundle_content)
}
pub fn capitalize_first_letter(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
}
pub fn sanitize_filename(filename: &str) -> String {
let mut sanitized = filename
.replace(
|c: char| {
c == '<' || c == '>' || c == ':' || c == '"' || c == '|' || c == '?' || c == '*'
},
"_",
)
.replace(['/', '\\', '\0'], "_");
sanitized = sanitized
.trim()
.trim_start_matches('.')
.trim_end_matches('.')
.to_string();
if sanitized.len() > 200 {
sanitized.truncate(200);
sanitized = sanitized.trim_end().to_string();
}
sanitized
}
pub async fn deserialize_response<T>(response: Response) -> Result<T, QobuzApiError>
where
T: DeserializeOwned,
{
let content = response.text().await.map_err(HttpError)?;
if content.trim().is_empty() {
return Err(QobuzApiInitializationError {
message: "Received empty response from API".to_string(),
});
}
from_str::<T>(&content).map_err(|source| ApiResponseParseError {
content: content.clone(),
source,
})
}
pub fn read_app_credentials_from_env() -> Result<(Option<String>, Option<String>), QobuzApiError> {
if Path::new(".env").exists()
&& let Err(e) = from_path(".env")
{
eprintln!("Warning: Failed to load .env file: {}", e);
}
let app_id = var("QOBUZ_APP_ID").ok();
let app_secret = var("QOBUZ_APP_SECRET").ok();
Ok((app_id, app_secret))
}
pub fn write_app_credentials_to_env(app_id: &str, app_secret: &str) -> Result<(), QobuzApiError> {
let env_content = if Path::new(".env").exists() {
read_to_string(".env").map_err(|e| QobuzApiInitializationError {
message: format!("Failed to read .env file: {}", e),
})?
} else {
String::new()
};
let mut lines: Vec<String> = env_content.lines().map(|s| s.to_string()).collect();
let mut app_id_found = false;
let mut app_secret_found = false;
for line in &mut lines {
if line.starts_with("QOBUZ_APP_ID=") {
*line = format!("QOBUZ_APP_ID={}", app_id);
app_id_found = true;
} else if line.starts_with("QOBUZ_APP_SECRET=") {
*line = format!("QOBUZ_APP_SECRET={}", app_secret);
app_secret_found = true;
}
}
if !app_id_found {
lines.push(format!("QOBUZ_APP_ID={}", app_id));
}
if !app_secret_found {
lines.push(format!("QOBUZ_APP_SECRET={}", app_secret));
}
write(".env", lines.join("\n")).map_err(|e| QobuzApiInitializationError {
message: format!("Failed to write to .env file: {}", e),
})?;
Ok(())
}
pub async fn download_image(url: &str) -> Result<Vec<u8>, QobuzApiError> {
let response = get(url).await.map_err(HttpError)?;
if !response.status().is_success() {
return Err(DownloadError {
message: format!("Failed to download image: HTTP {}", response.status()),
});
}
let bytes = response.bytes().await.map_err(HttpError)?;
Ok(bytes.to_vec())
}
pub fn timestamp_to_date_and_year(timestamp: i64) -> (Option<String>, Option<u32>) {
const SECONDS_PER_DAY: i64 = 86_400;
let mut days_since_epoch = timestamp / SECONDS_PER_DAY;
let mut year = 1970;
loop {
let is_leap_year = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
let days_in_current_year = if is_leap_year { 366 } else { 365 };
if days_since_epoch < days_in_current_year {
break; }
days_since_epoch -= days_in_current_year;
year += 1;
}
let month_lengths = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let month_lengths_leap = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let is_current_year_leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
let current_month_lengths = if is_current_year_leap {
&month_lengths_leap
} else {
&month_lengths
};
let mut month = 1;
let mut day = 0;
let mut days_in_months_passed = 0;
for (i, &len) in current_month_lengths.iter().enumerate() {
if days_since_epoch < (days_in_months_passed + len as i64) {
month = i + 1;
day = (days_since_epoch - days_in_months_passed) + 1;
break;
}
days_in_months_passed += len as i64;
}
if day == 0 {
(None, None)
} else {
let date_str = format!("{:04}-{:02}-{:02}", year, month, day);
(Some(date_str), Some(year as u32))
}
}