pub mod manager {
use crate::{
common::types::AnyResult,
config::AppConfig,
protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo, Track, TrackInfo},
sources::{
SourcePlugin,
playable_track::BoxedTrack,
qobuz::{token::QobuzTokenTracker, track::QobuzTrack},
},
};
use async_trait::async_trait;
use serde_json::{Value, json};
use std::sync::Arc;
use tracing::{debug, error};
const API_URL: &str = "https://www.qobuz.com/api.json/0.2/";
fn url_regex() -> &'static regex::Regex {
static REGEX: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
REGEX.get_or_init(|| {
regex::Regex::new(
r"https?://(?:www\.|play\.|open\.)?qobuz\.com/(?:(?:[a-z]{2}-[a-z]{2}/)?(?P<type>album|playlist|track|artist)/(?:.+?/)?(?P<id>[a-zA-Z0-9]+)|(?P<type2>playlist)/(?P<id2>\d+))"
).unwrap()
})
}
pub struct QobuzSource {
client: Arc<reqwest::Client>,
token_tracker: Arc<QobuzTokenTracker>,
search_limit: usize,
album_load_limit: usize,
playlist_load_limit: usize,
artist_load_limit: usize,
}
impl QobuzSource {
pub fn new(config: &AppConfig, client: Arc<reqwest::Client>) -> Result<Self, String> {
let qobuz_config = config.sources.qobuz.clone().unwrap_or_default();
let tracker = Arc::new(QobuzTokenTracker::new(
client.clone(),
qobuz_config.user_token,
qobuz_config.app_id,
qobuz_config.app_secret,
));
tracker.clone().init();
Ok(Self {
client,
token_tracker: tracker,
search_limit: qobuz_config.search_limit,
album_load_limit: qobuz_config.album_load_limit,
playlist_load_limit: qobuz_config.playlist_load_limit,
artist_load_limit: qobuz_config.artist_load_limit,
})
}
pub async fn api_request(
&self,
path: &str,
params: Vec<(&str, String)>,
) -> AnyResult<Value> {
let tokens = self
.token_tracker
.get_tokens()
.await
.ok_or("Failed to get Qobuz tokens")?;
let mut url = reqwest::Url::parse(&format!("{API_URL}{path}"))?;
{
let mut query = url.query_pairs_mut();
for (k, v) in params {
query.append_pair(k, &v);
}
}
let mut request = self
.base_request(self.client.get(url))
.header("Accept", "application/json")
.header("x-app-id", &tokens.app_id);
if let Some(user_token) = &tokens.user_token {
request = request.header("x-user-auth-token", user_token);
}
let resp = request.send().await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(format!("Qobuz API error ({status}): {body}").into());
}
let json: Value = resp.json().await?;
Ok(json)
}
pub fn base_request(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header(reqwest::header::USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36")
}
pub fn parse_qobuz_track(&self, json: &Value) -> QobuzTrack {
let identifier = json["id"]
.as_i64()
.or_else(|| json["id"].as_str().and_then(|s| s.parse::<i64>().ok()))
.unwrap_or(0)
.to_string();
let title = json["title"].as_str().unwrap_or("Unknown Title").to_owned();
let (author, artist_url) = if !json["artist"].is_null() && json["artist"].is_object() {
let name = json["artist"]["name"]["display"]
.as_str()
.or_else(|| json["artist"]["name"].as_str())
.unwrap_or("Unknown Artist")
.to_owned();
let url = json["artist"]["id"]
.as_i64()
.map(|id| format!("https://open.qobuz.com/artist/{id}"));
(name, url)
} else {
let name = json["album"]["artist"]["name"]
.as_str()
.unwrap_or("Unknown Artist")
.to_owned();
let url = json["album"]["artist"]["id"]
.as_i64()
.map(|id| format!("https://open.qobuz.com/artist/{id}"));
(name, url)
};
let length = json["duration"].as_i64().unwrap_or(0) * 1000;
let artwork_url = json["album"]["image"]["large"]
.as_str()
.map(|s| s.to_owned());
let isrc = json["isrc"].as_str().map(|s| s.to_owned());
let uri = format!("https://open.qobuz.com/track/{identifier}");
let album_name = json["album"]["title"].as_str().map(|s| s.to_owned());
let album_url = json["album"]["id"]
.as_i64()
.map(|id| format!("https://open.qobuz.com/album/{id}"));
let artist_artwork_url = if !json["album"]["artist"].is_null()
&& !json["album"]["artist"]["image"].is_null()
{
json["album"]["artist"]["image"]
.as_str()
.map(|s| s.to_owned())
} else {
None
};
QobuzTrack {
info: TrackInfo {
identifier,
is_seekable: true,
author,
length: length as u64,
is_stream: false,
position: 0,
title,
uri: Some(uri),
artwork_url,
isrc,
source_name: "qobuz".to_owned(),
},
album_name,
album_url,
artist_url,
artist_artwork_url,
token_tracker: self.token_tracker.clone(),
client: self.client.clone(),
}
}
async fn handle_search(&self, query: &str) -> LoadResult {
match self
.api_request(
"catalog/search",
vec![
("query", query.to_owned()),
("limit", self.search_limit.to_string()),
("type", "tracks".to_owned()),
],
)
.await
{
Ok(json) => {
let items = json["tracks"]["items"].as_array();
if items.as_ref().map(|a| a.is_empty()).unwrap_or(true) {
return LoadResult::Empty {};
}
let tracks: Vec<Track> = items
.unwrap()
.iter()
.map(|item| Track::new(self.parse_qobuz_track(item).info))
.collect();
LoadResult::Search(tracks)
}
Err(e) => {
error!("Qobuz search error: {e}");
LoadResult::Empty {}
}
}
}
async fn handle_isrc(&self, isrc: &str) -> LoadResult {
match self
.api_request(
"catalog/search",
vec![
("query", isrc.to_owned()),
("limit", "15".to_owned()),
("type", "tracks".to_owned()),
],
)
.await
{
Ok(json) => {
let items = json["tracks"]["items"].as_array();
if items.as_ref().map(|a| a.is_empty()).unwrap_or(true) {
return LoadResult::Empty {};
}
let track = Track::new(self.parse_qobuz_track(&items.unwrap()[0]).info);
LoadResult::Track(track)
}
Err(e) => {
error!("Qobuz ISRC search error: {e}");
LoadResult::Empty {}
}
}
}
async fn handle_recommendations(&self, track_id: &str) -> LoadResult {
let track_json = match self
.api_request("track/get", vec![("track_id", track_id.to_owned())])
.await
{
Ok(j) => j,
Err(_) => return LoadResult::Empty {},
};
let artist_id = track_json["performer"]["id"]
.as_i64()
.or_else(|| track_json["artist"]["id"].as_i64())
.unwrap_or(0);
let track_id_i64 = track_id.parse::<i64>().unwrap_or(0);
let payload = json!({
"limit": 50,
"listened_tracks_ids": [track_id_i64],
"track_to_analysed": [
{
"track_id": track_id_i64,
"artist_id": artist_id
}
]
});
let tokens = match self.token_tracker.get_tokens().await {
Some(t) => t,
None => return LoadResult::Empty {},
};
let mut request = self
.base_request(self.client.post(format!("{API_URL}dynamic/suggest")))
.header("Accept", "application/json")
.header("x-app-id", &tokens.app_id)
.json(&payload);
if let Some(user_token) = &tokens.user_token {
request = request.header("x-user-auth-token", user_token);
}
let resp = match request.send().await {
Ok(r) => r,
Err(e) => {
error!("Qobuz recommendations request error: {e}");
return LoadResult::Empty {};
}
};
if !resp.status().is_success() {
return LoadResult::Empty {};
}
let json: Value = resp.json().await.unwrap_or(json!({}));
let items = json["tracks"]["items"].as_array();
if items.as_ref().map(|a| a.is_empty()).unwrap_or(true) {
return LoadResult::Empty {};
}
let tracks: Vec<Track> = items
.unwrap()
.iter()
.map(|item| Track::new(self.parse_qobuz_track(item).info))
.collect();
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: "Qobuz Recommendations".to_owned(),
selected_track: -1,
},
plugin_info: json!({
"type": "recommendations",
"totalTracks": tracks.len()
}),
tracks,
})
}
async fn handle_album(&self, id: &str) -> LoadResult {
match self
.api_request(
"album/get",
vec![
("album_id", id.to_owned()),
("limit", self.album_load_limit.to_string()),
("offset", "0".to_owned()),
],
)
.await
{
Ok(mut json) => {
let title = json["title"].as_str().unwrap_or("Unknown Album").to_owned();
let author = json["artist"]["name"]
.as_str()
.or_else(|| json["artist"]["name"]["display"].as_str())
.unwrap_or("Unknown Artist")
.to_owned();
let artwork_url = json["image"]["large"].as_str().map(|s| s.to_owned());
let uri = format!("https://open.qobuz.com/album/{id}");
let tracks_json = json["tracks"]["items"].take();
if tracks_json
.as_array()
.as_ref()
.map(|a| a.is_empty())
.unwrap_or(true)
{
return LoadResult::Empty {};
}
let tracks: Vec<Track> = tracks_json
.as_array()
.unwrap()
.iter()
.map(|item| {
let mut item = item.clone();
item["album"] = json.clone();
Track::new(self.parse_qobuz_track(&item).info)
})
.collect();
let track_count = tracks.len();
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: title,
selected_track: -1,
},
plugin_info: json!({
"type": "album",
"url": uri,
"artworkUrl": artwork_url,
"author": author,
"totalTracks": track_count
}),
tracks,
})
}
Err(_) => LoadResult::Empty {},
}
}
async fn handle_playlist(&self, id: &str) -> LoadResult {
match self
.api_request(
"playlist/get",
vec![
("playlist_id", id.to_owned()),
("extra", "tracks".to_owned()),
("limit", self.playlist_load_limit.to_string()),
("offset", "0".to_owned()),
],
)
.await
{
Ok(json) => {
let items = json["tracks"]["items"].as_array();
if items.as_ref().map(|a| a.is_empty()).unwrap_or(true) {
return LoadResult::Empty {};
}
let name = json["name"]
.as_str()
.unwrap_or("Unknown Playlist")
.to_owned();
let author = json["owner"]["name"]
.as_str()
.unwrap_or("Unknown")
.to_owned();
let artwork_url = json["images300"]
.as_array()
.and_then(|a| a.first())
.and_then(|v| v.as_str())
.map(|s| s.to_owned());
let url = json["url"]
.as_str()
.map(|s| s.to_owned())
.unwrap_or_else(|| format!("https://open.qobuz.com/playlist/{id}"));
let tracks: Vec<Track> = items
.unwrap()
.iter()
.map(|item| Track::new(self.parse_qobuz_track(item).info))
.collect();
let track_count = tracks.len();
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name,
selected_track: -1,
},
plugin_info: json!({
"type": "playlist",
"url": url,
"artworkUrl": artwork_url,
"author": author,
"totalTracks": track_count
}),
tracks,
})
}
Err(_) => LoadResult::Empty {},
}
}
async fn handle_artist(&self, id: &str) -> LoadResult {
match self
.api_request("artist/page", vec![("artist_id", id.to_owned())])
.await
{
Ok(json) => {
let top_tracks = json["top_tracks"].as_array();
if top_tracks.as_ref().map(|a| a.is_empty()).unwrap_or(true) {
return LoadResult::Empty {};
}
let name = json["name"]["display"]
.as_str()
.or_else(|| json["name"].as_str())
.unwrap_or("Unknown Artist")
.to_owned();
let artwork_url = json["images"]["potrait"]["hash"]
.as_str()
.filter(|h| !h.is_empty())
.map(|h| {
format!("https://static.qobuz.com/images/artists/covers/large/{h}.jpg")
});
let uri = format!("https://open.qobuz.com/artist/{id}");
let tracks: Vec<Track> = top_tracks
.unwrap()
.iter()
.take(self.artist_load_limit)
.map(|item| Track::new(self.parse_qobuz_track(item).info))
.collect();
let track_count = tracks.len();
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: format!("{name}'s Top Tracks"),
selected_track: -1,
},
plugin_info: json!({
"type": "artist",
"url": uri,
"artworkUrl": artwork_url,
"author": name,
"totalTracks": track_count
}),
tracks,
})
}
Err(_) => LoadResult::Empty {},
}
}
}
#[async_trait]
impl SourcePlugin for QobuzSource {
fn name(&self) -> &str {
"qobuz"
}
fn can_handle(&self, identifier: &str) -> bool {
self.search_prefixes()
.iter()
.any(|p| identifier.starts_with(p))
|| self
.isrc_prefixes()
.iter()
.any(|p| identifier.starts_with(p))
|| self
.rec_prefixes()
.iter()
.any(|p| identifier.starts_with(p))
|| url_regex().is_match(identifier)
}
fn search_prefixes(&self) -> Vec<&str> {
vec!["qbsearch:"]
}
fn isrc_prefixes(&self) -> Vec<&str> {
vec!["qbisrc:"]
}
fn rec_prefixes(&self) -> Vec<&str> {
vec!["qbrec:"]
}
async fn load(
&self,
identifier: &str,
_routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
) -> LoadResult {
if let Some(prefix) = self
.search_prefixes()
.into_iter()
.find(|p| identifier.starts_with(p))
{
return self.handle_search(&identifier[prefix.len()..]).await;
}
if let Some(prefix) = self
.isrc_prefixes()
.into_iter()
.find(|p| identifier.starts_with(p))
{
return self.handle_isrc(&identifier[prefix.len()..]).await;
}
if let Some(prefix) = self
.rec_prefixes()
.into_iter()
.find(|p| identifier.starts_with(p))
{
return self
.handle_recommendations(&identifier[prefix.len()..])
.await;
}
if let Some(caps) = url_regex().captures(identifier) {
let type_ = caps
.name("type")
.or_else(|| caps.name("type2"))
.map(|m| m.as_str())
.unwrap_or("");
let id = caps
.name("id")
.or_else(|| caps.name("id2"))
.map(|m| m.as_str())
.unwrap_or("");
return match type_ {
"track" => {
match self
.api_request("track/get", vec![("track_id", id.to_owned())])
.await
{
Ok(json) => {
LoadResult::Track(Track::new(self.parse_qobuz_track(&json).info))
}
Err(_) => LoadResult::Empty {},
}
}
"album" => self.handle_album(id).await,
"playlist" => self.handle_playlist(id).await,
"artist" => self.handle_artist(id).await,
_ => LoadResult::Empty {},
};
}
LoadResult::Empty {}
}
async fn get_track(
&self,
identifier: &str,
_routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
) -> Option<BoxedTrack> {
let id = if identifier.contains("qobuz.com/track/") {
identifier
.split("track/")
.nth(1)?
.split('/')
.next()?
.split('?')
.next()?
} else {
identifier
};
let tokens = self.token_tracker.get_tokens().await?;
if tokens.user_token.is_none() {
debug!("Qobuz: No user token, returning None to trigger mirroring");
return None;
}
match self
.api_request("track/get", vec![("track_id", id.to_owned())])
.await
{
Ok(json) => Some(Arc::new(self.parse_qobuz_track(&json))),
Err(_) => None,
}
}
}
}
pub mod token {
use base64::{Engine as _, engine::general_purpose};
use regex::Regex;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, error, info};
const WEB_PLAYER_BASE_URL: &str = "https://play.qobuz.com";
pub struct QobuzTokens {
pub app_id: String,
pub app_secret: String,
pub user_token: Option<String>,
}
pub struct QobuzTokenTracker {
client: Arc<reqwest::Client>,
tokens: Arc<RwLock<Option<QobuzTokens>>>,
config_user_token: Option<String>,
config_app_id: Option<String>,
config_app_secret: Option<String>,
}
fn bundle_regex() -> &'static Regex {
static REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r#"<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)""#).unwrap()
})
}
fn app_id_regex() -> &'static Regex {
static REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
REGEX.get_or_init(|| Regex::new(r#"production:\{api:\{appId:"(.*?)""#).unwrap())
}
fn seed_regex() -> &'static Regex {
static REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
REGEX.get_or_init(|| {
Regex::new(r#"\):[a-z]\.initialSeed\("(.*?)",window\.utimezone\.(.*?)\)"#).unwrap()
})
}
fn info_extras_regex(timezone: &str) -> Regex {
Regex::new(&format!(
r#"(?s)timezones:\[.*?name:.*?/{}",info:"(?P<info>.*?)",extras:"(?P<extras>.*?)""#,
regex::escape(timezone)
))
.unwrap()
}
impl QobuzTokenTracker {
pub fn new(
client: Arc<reqwest::Client>,
user_token: Option<String>,
app_id: Option<String>,
app_secret: Option<String>,
) -> Self {
Self {
client,
tokens: Arc::new(RwLock::new(None)),
config_user_token: user_token,
config_app_id: app_id,
config_app_secret: app_secret,
}
}
pub async fn get_tokens(&self) -> Option<Arc<QobuzTokens>> {
{
let tokens = self.tokens.read().await;
if let Some(t) = &*tokens {
return Some(Arc::new(QobuzTokens {
app_id: t.app_id.to_owned(),
app_secret: t.app_secret.to_owned(),
user_token: t.user_token.to_owned(),
}));
}
}
self.refresh_tokens().await
}
async fn refresh_tokens(&self) -> Option<Arc<QobuzTokens>> {
let mut tokens_lock = self.tokens.write().await;
if let Some(t) = &*tokens_lock {
return Some(Arc::new(QobuzTokens {
app_id: t.app_id.to_owned(),
app_secret: t.app_secret.to_owned(),
user_token: t.user_token.to_owned(),
}));
}
let app_id;
let app_secret;
if let (Some(id), Some(secret)) = (&self.config_app_id, &self.config_app_secret) {
app_id = id.to_owned();
app_secret = secret.to_owned();
debug!("Using configured Qobuz app_id and app_secret");
} else {
debug!("Fetching Qobuz bundle.js for credential extraction...");
match self.fetch_credentials_from_web().await {
Ok((id, secret)) => {
app_id = id;
app_secret = secret;
info!("Successfully extracted Qobuz credentials: appId={app_id}");
}
Err(e) => {
error!("Failed to extract Qobuz credentials: {e}");
return None;
}
}
}
let new_tokens = QobuzTokens {
app_id,
app_secret,
user_token: self.config_user_token.to_owned(),
};
let arc_tokens = Arc::new(QobuzTokens {
app_id: new_tokens.app_id.to_owned(),
app_secret: new_tokens.app_secret.to_owned(),
user_token: new_tokens.user_token.to_owned(),
});
*tokens_lock = Some(new_tokens);
Some(arc_tokens)
}
async fn fetch_credentials_from_web(&self) -> Result<(String, String), String> {
let login_page = self
.client
.get(format!("{WEB_PLAYER_BASE_URL}/login"))
.send()
.await
.map_err(|e| e.to_string())?
.text()
.await
.map_err(|e| e.to_string())?;
let bundle_path = bundle_regex()
.captures(&login_page)
.and_then(|c| c.get(1))
.map(|m| m.as_str())
.ok_or_else(|| "Failed to find bundle.js path in Qobuz login page".to_owned())?;
let bundle_js = self
.client
.get(format!("{WEB_PLAYER_BASE_URL}{bundle_path}"))
.send()
.await
.map_err(|e| e.to_string())?
.text()
.await
.map_err(|e| e.to_string())?;
let app_id = app_id_regex()
.captures(&bundle_js)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_owned())
.ok_or_else(|| "Failed to extract appId from bundle.js".to_owned())?;
let seed_captures = seed_regex()
.captures(&bundle_js)
.ok_or_else(|| "Failed to extract seed and timezone from bundle.js".to_owned())?;
let seed = seed_captures.get(1).unwrap().as_str();
let timezone_raw = seed_captures.get(2).unwrap().as_str();
let timezone = format!(
"{}{}",
&timezone_raw[..1].to_uppercase(),
&timezone_raw[1..].to_lowercase()
);
let info_extras = info_extras_regex(&timezone)
.captures(&bundle_js)
.ok_or_else(|| format!("Failed to extract info/extras for timezone {timezone}"))?;
let info = info_extras.name("info").unwrap().as_str();
let extras = info_extras.name("extras").unwrap().as_str();
let mut encoded = format!("{seed}{info}{extras}");
if encoded.len() > 44 {
encoded.truncate(encoded.len() - 44);
}
let decoded = general_purpose::STANDARD
.decode(encoded)
.map_err(|e| format!("Failed to decode appSecret: {e}"))?;
let app_secret = String::from_utf8(decoded)
.map_err(|e| format!("Invalid UTF-8 in appSecret: {e}"))?;
Ok((app_id, app_secret))
}
pub fn init(self: Arc<Self>) {
let this = self.clone();
tokio::spawn(async move {
this.get_tokens().await;
});
}
}
}
pub mod track {
use crate::{
common::types::AnyResult,
protocol::tracks::TrackInfo,
sources::{
http::HttpTrack,
playable_track::{PlayableTrack, ResolvedTrack},
qobuz::token::QobuzTokenTracker,
},
};
use async_trait::async_trait;
use md5::{Digest, Md5};
use std::sync::Arc;
pub struct QobuzTrack {
pub info: TrackInfo,
pub album_name: Option<String>,
pub album_url: Option<String>,
pub artist_url: Option<String>,
pub artist_artwork_url: Option<String>,
pub token_tracker: Arc<QobuzTokenTracker>,
pub client: Arc<reqwest::Client>,
}
#[async_trait]
impl PlayableTrack for QobuzTrack {
async fn resolve(&self) -> Result<ResolvedTrack, String> {
let url = switch_media_url(&self.client, &self.token_tracker, &self.info.identifier)
.await
.map_err(|e| {
format!(
"Qobuz: Failed to resolve media URL for {}: {e}",
self.info.identifier
)
})?
.ok_or_else(|| "Failed to resolve Qobuz media URL".to_string())?;
HttpTrack {
url,
local_addr: None,
proxy: None,
}
.resolve()
.await
}
}
async fn switch_media_url(
client: &Arc<reqwest::Client>,
token_tracker: &QobuzTokenTracker,
track_id: &str,
) -> AnyResult<Option<String>> {
let tokens = token_tracker
.get_tokens()
.await
.ok_or("Failed to get Qobuz tokens")?;
if tokens.user_token.is_none() {
return Ok(None);
}
let unix_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs();
let format_id = "5";
let intent = "stream";
let sig_data = format!(
"trackgetFileUrlformat_id{format_id}intent{intent}track_id{track_id}{unix_ts}{}",
tokens.app_secret
);
let mut hasher = Md5::new();
hasher.update(sig_data.as_bytes());
let sig = hex::encode(hasher.finalize());
let mut url = reqwest::Url::parse("https://www.qobuz.com/api.json/0.2/track/getFileUrl")?;
{
let mut query = url.query_pairs_mut();
query.append_pair("request_ts", &unix_ts.to_string());
query.append_pair("request_sig", &sig);
query.append_pair("track_id", track_id);
query.append_pair("format_id", format_id);
query.append_pair("intent", intent);
}
let mut request = client
.get(url)
.header("Accept", "application/json")
.header("x-app-id", &tokens.app_id);
if let Some(user_token) = &tokens.user_token {
request = request.header("x-user-auth-token", user_token);
}
let resp = request.send().await?;
if !resp.status().is_success() {
return Ok(None);
}
let json: serde_json::Value = resp.json().await?;
if let Some(url) = json.get("url").and_then(|v| v.as_str()) {
let is_sample = json.get("sample").and_then(|v| v.as_bool()).or_else(|| {
json.get("sample")
.and_then(|v| v.as_str())
.map(|s| s == "true")
});
if is_sample == Some(true) {
return Ok(None);
}
return Ok(Some(url.to_owned()));
}
Ok(None)
}
}
pub use manager::*;
pub use token::*;
pub use track::*;