1pub mod manager {
2 use crate::{
3 common::types::AnyResult,
4 config::AppConfig,
5 protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo, Track, TrackInfo},
6 sources::{
7 SourcePlugin,
8 playable_track::BoxedTrack,
9 qobuz::{token::QobuzTokenTracker, track::QobuzTrack},
10 },
11 };
12 use async_trait::async_trait;
13 use serde_json::{Value, json};
14 use std::sync::Arc;
15 use tracing::{debug, error};
16 const API_URL: &str = "https://www.qobuz.com/api.json/0.2/";
17 fn url_regex() -> &'static regex::Regex {
18 static REGEX: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
19 REGEX.get_or_init(|| {
20 regex::Regex::new(
21 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+))"
22 ).unwrap()
23 })
24 }
25 pub struct QobuzSource {
26 client: Arc<reqwest::Client>,
27 token_tracker: Arc<QobuzTokenTracker>,
28 search_limit: usize,
29 album_load_limit: usize,
30 playlist_load_limit: usize,
31 artist_load_limit: usize,
32 }
33 impl QobuzSource {
34 pub fn new(config: &AppConfig, client: Arc<reqwest::Client>) -> Result<Self, String> {
35 let qobuz_config = config.sources.qobuz.clone().unwrap_or_default();
36 let tracker = Arc::new(QobuzTokenTracker::new(
37 client.clone(),
38 qobuz_config.user_token,
39 qobuz_config.app_id,
40 qobuz_config.app_secret,
41 ));
42 tracker.clone().init();
43 Ok(Self {
44 client,
45 token_tracker: tracker,
46 search_limit: qobuz_config.search_limit,
47 album_load_limit: qobuz_config.album_load_limit,
48 playlist_load_limit: qobuz_config.playlist_load_limit,
49 artist_load_limit: qobuz_config.artist_load_limit,
50 })
51 }
52 pub async fn api_request(
53 &self,
54 path: &str,
55 params: Vec<(&str, String)>,
56 ) -> AnyResult<Value> {
57 let tokens = self
58 .token_tracker
59 .get_tokens()
60 .await
61 .ok_or("Failed to get Qobuz tokens")?;
62 let mut url = reqwest::Url::parse(&format!("{API_URL}{path}"))?;
63 {
64 let mut query = url.query_pairs_mut();
65 for (k, v) in params {
66 query.append_pair(k, &v);
67 }
68 }
69 let mut request = self
70 .base_request(self.client.get(url))
71 .header("Accept", "application/json")
72 .header("x-app-id", &tokens.app_id);
73 if let Some(user_token) = &tokens.user_token {
74 request = request.header("x-user-auth-token", user_token);
75 }
76 let resp = request.send().await?;
77 if !resp.status().is_success() {
78 let status = resp.status();
79 let body = resp.text().await.unwrap_or_default();
80 return Err(format!("Qobuz API error ({status}): {body}").into());
81 }
82 let json: Value = resp.json().await?;
83 Ok(json)
84 }
85 pub fn base_request(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
86 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")
87 }
88 pub fn parse_qobuz_track(&self, json: &Value) -> QobuzTrack {
89 let identifier = json["id"]
90 .as_i64()
91 .or_else(|| json["id"].as_str().and_then(|s| s.parse::<i64>().ok()))
92 .unwrap_or(0)
93 .to_string();
94 let title = json["title"].as_str().unwrap_or("Unknown Title").to_owned();
95 let (author, artist_url) = if !json["artist"].is_null() && json["artist"].is_object() {
96 let name = json["artist"]["name"]["display"]
97 .as_str()
98 .or_else(|| json["artist"]["name"].as_str())
99 .unwrap_or("Unknown Artist")
100 .to_owned();
101 let url = json["artist"]["id"]
102 .as_i64()
103 .map(|id| format!("https://open.qobuz.com/artist/{id}"));
104 (name, url)
105 } else {
106 let name = json["album"]["artist"]["name"]
107 .as_str()
108 .unwrap_or("Unknown Artist")
109 .to_owned();
110 let url = json["album"]["artist"]["id"]
111 .as_i64()
112 .map(|id| format!("https://open.qobuz.com/artist/{id}"));
113 (name, url)
114 };
115 let length = json["duration"].as_i64().unwrap_or(0) * 1000;
116 let artwork_url = json["album"]["image"]["large"]
117 .as_str()
118 .map(|s| s.to_owned());
119 let isrc = json["isrc"].as_str().map(|s| s.to_owned());
120 let uri = format!("https://open.qobuz.com/track/{identifier}");
121 let album_name = json["album"]["title"].as_str().map(|s| s.to_owned());
122 let album_url = json["album"]["id"]
123 .as_i64()
124 .map(|id| format!("https://open.qobuz.com/album/{id}"));
125 let artist_artwork_url = if !json["album"]["artist"].is_null()
126 && !json["album"]["artist"]["image"].is_null()
127 {
128 json["album"]["artist"]["image"]
129 .as_str()
130 .map(|s| s.to_owned())
131 } else {
132 None
133 };
134 QobuzTrack {
135 info: TrackInfo {
136 identifier,
137 is_seekable: true,
138 author,
139 length: length as u64,
140 is_stream: false,
141 position: 0,
142 title,
143 uri: Some(uri),
144 artwork_url,
145 isrc,
146 source_name: "qobuz".to_owned(),
147 },
148 album_name,
149 album_url,
150 artist_url,
151 artist_artwork_url,
152 token_tracker: self.token_tracker.clone(),
153 client: self.client.clone(),
154 }
155 }
156 async fn handle_search(&self, query: &str) -> LoadResult {
157 match self
158 .api_request(
159 "catalog/search",
160 vec![
161 ("query", query.to_owned()),
162 ("limit", self.search_limit.to_string()),
163 ("type", "tracks".to_owned()),
164 ],
165 )
166 .await
167 {
168 Ok(json) => {
169 let items = json["tracks"]["items"].as_array();
170 if items.as_ref().map(|a| a.is_empty()).unwrap_or(true) {
171 return LoadResult::Empty {};
172 }
173 let tracks: Vec<Track> = items
174 .unwrap()
175 .iter()
176 .map(|item| Track::new(self.parse_qobuz_track(item).info))
177 .collect();
178 LoadResult::Search(tracks)
179 }
180 Err(e) => {
181 error!("Qobuz search error: {e}");
182 LoadResult::Empty {}
183 }
184 }
185 }
186 async fn handle_isrc(&self, isrc: &str) -> LoadResult {
187 match self
188 .api_request(
189 "catalog/search",
190 vec![
191 ("query", isrc.to_owned()),
192 ("limit", "15".to_owned()),
193 ("type", "tracks".to_owned()),
194 ],
195 )
196 .await
197 {
198 Ok(json) => {
199 let items = json["tracks"]["items"].as_array();
200 if items.as_ref().map(|a| a.is_empty()).unwrap_or(true) {
201 return LoadResult::Empty {};
202 }
203 let track = Track::new(self.parse_qobuz_track(&items.unwrap()[0]).info);
204 LoadResult::Track(track)
205 }
206 Err(e) => {
207 error!("Qobuz ISRC search error: {e}");
208 LoadResult::Empty {}
209 }
210 }
211 }
212 async fn handle_recommendations(&self, track_id: &str) -> LoadResult {
213 let track_json = match self
214 .api_request("track/get", vec![("track_id", track_id.to_owned())])
215 .await
216 {
217 Ok(j) => j,
218 Err(_) => return LoadResult::Empty {},
219 };
220 let artist_id = track_json["performer"]["id"]
221 .as_i64()
222 .or_else(|| track_json["artist"]["id"].as_i64())
223 .unwrap_or(0);
224 let track_id_i64 = track_id.parse::<i64>().unwrap_or(0);
225 let payload = json!({
226 "limit": 50,
227 "listened_tracks_ids": [track_id_i64],
228 "track_to_analysed": [
229 {
230 "track_id": track_id_i64,
231 "artist_id": artist_id
232 }
233 ]
234 });
235 let tokens = match self.token_tracker.get_tokens().await {
236 Some(t) => t,
237 None => return LoadResult::Empty {},
238 };
239 let mut request = self
240 .base_request(self.client.post(format!("{API_URL}dynamic/suggest")))
241 .header("Accept", "application/json")
242 .header("x-app-id", &tokens.app_id)
243 .json(&payload);
244 if let Some(user_token) = &tokens.user_token {
245 request = request.header("x-user-auth-token", user_token);
246 }
247 let resp = match request.send().await {
248 Ok(r) => r,
249 Err(e) => {
250 error!("Qobuz recommendations request error: {e}");
251 return LoadResult::Empty {};
252 }
253 };
254 if !resp.status().is_success() {
255 return LoadResult::Empty {};
256 }
257 let json: Value = resp.json().await.unwrap_or(json!({}));
258 let items = json["tracks"]["items"].as_array();
259 if items.as_ref().map(|a| a.is_empty()).unwrap_or(true) {
260 return LoadResult::Empty {};
261 }
262 let tracks: Vec<Track> = items
263 .unwrap()
264 .iter()
265 .map(|item| Track::new(self.parse_qobuz_track(item).info))
266 .collect();
267 LoadResult::Playlist(PlaylistData {
268 info: PlaylistInfo {
269 name: "Qobuz Recommendations".to_owned(),
270 selected_track: -1,
271 },
272 plugin_info: json!({
273 "type": "recommendations",
274 "totalTracks": tracks.len()
275 }),
276 tracks,
277 })
278 }
279 async fn handle_album(&self, id: &str) -> LoadResult {
280 match self
281 .api_request(
282 "album/get",
283 vec![
284 ("album_id", id.to_owned()),
285 ("limit", self.album_load_limit.to_string()),
286 ("offset", "0".to_owned()),
287 ],
288 )
289 .await
290 {
291 Ok(mut json) => {
292 let title = json["title"].as_str().unwrap_or("Unknown Album").to_owned();
293 let author = json["artist"]["name"]
294 .as_str()
295 .or_else(|| json["artist"]["name"]["display"].as_str())
296 .unwrap_or("Unknown Artist")
297 .to_owned();
298 let artwork_url = json["image"]["large"].as_str().map(|s| s.to_owned());
299 let uri = format!("https://open.qobuz.com/album/{id}");
300 let tracks_json = json["tracks"]["items"].take();
301 if tracks_json
302 .as_array()
303 .as_ref()
304 .map(|a| a.is_empty())
305 .unwrap_or(true)
306 {
307 return LoadResult::Empty {};
308 }
309 let tracks: Vec<Track> = tracks_json
310 .as_array()
311 .unwrap()
312 .iter()
313 .map(|item| {
314 let mut item = item.clone();
315 item["album"] = json.clone();
316 Track::new(self.parse_qobuz_track(&item).info)
317 })
318 .collect();
319 let track_count = tracks.len();
320 LoadResult::Playlist(PlaylistData {
321 info: PlaylistInfo {
322 name: title,
323 selected_track: -1,
324 },
325 plugin_info: json!({
326 "type": "album",
327 "url": uri,
328 "artworkUrl": artwork_url,
329 "author": author,
330 "totalTracks": track_count
331 }),
332 tracks,
333 })
334 }
335 Err(_) => LoadResult::Empty {},
336 }
337 }
338 async fn handle_playlist(&self, id: &str) -> LoadResult {
339 match self
340 .api_request(
341 "playlist/get",
342 vec![
343 ("playlist_id", id.to_owned()),
344 ("extra", "tracks".to_owned()),
345 ("limit", self.playlist_load_limit.to_string()),
346 ("offset", "0".to_owned()),
347 ],
348 )
349 .await
350 {
351 Ok(json) => {
352 let items = json["tracks"]["items"].as_array();
353 if items.as_ref().map(|a| a.is_empty()).unwrap_or(true) {
354 return LoadResult::Empty {};
355 }
356 let name = json["name"]
357 .as_str()
358 .unwrap_or("Unknown Playlist")
359 .to_owned();
360 let author = json["owner"]["name"]
361 .as_str()
362 .unwrap_or("Unknown")
363 .to_owned();
364 let artwork_url = json["images300"]
365 .as_array()
366 .and_then(|a| a.first())
367 .and_then(|v| v.as_str())
368 .map(|s| s.to_owned());
369 let url = json["url"]
370 .as_str()
371 .map(|s| s.to_owned())
372 .unwrap_or_else(|| format!("https://open.qobuz.com/playlist/{id}"));
373 let tracks: Vec<Track> = items
374 .unwrap()
375 .iter()
376 .map(|item| Track::new(self.parse_qobuz_track(item).info))
377 .collect();
378 let track_count = tracks.len();
379 LoadResult::Playlist(PlaylistData {
380 info: PlaylistInfo {
381 name,
382 selected_track: -1,
383 },
384 plugin_info: json!({
385 "type": "playlist",
386 "url": url,
387 "artworkUrl": artwork_url,
388 "author": author,
389 "totalTracks": track_count
390 }),
391 tracks,
392 })
393 }
394 Err(_) => LoadResult::Empty {},
395 }
396 }
397 async fn handle_artist(&self, id: &str) -> LoadResult {
398 match self
399 .api_request("artist/page", vec![("artist_id", id.to_owned())])
400 .await
401 {
402 Ok(json) => {
403 let top_tracks = json["top_tracks"].as_array();
404 if top_tracks.as_ref().map(|a| a.is_empty()).unwrap_or(true) {
405 return LoadResult::Empty {};
406 }
407 let name = json["name"]["display"]
408 .as_str()
409 .or_else(|| json["name"].as_str())
410 .unwrap_or("Unknown Artist")
411 .to_owned();
412 let artwork_url = json["images"]["potrait"]["hash"]
413 .as_str()
414 .filter(|h| !h.is_empty())
415 .map(|h| {
416 format!("https://static.qobuz.com/images/artists/covers/large/{h}.jpg")
417 });
418 let uri = format!("https://open.qobuz.com/artist/{id}");
419 let tracks: Vec<Track> = top_tracks
420 .unwrap()
421 .iter()
422 .take(self.artist_load_limit)
423 .map(|item| Track::new(self.parse_qobuz_track(item).info))
424 .collect();
425 let track_count = tracks.len();
426 LoadResult::Playlist(PlaylistData {
427 info: PlaylistInfo {
428 name: format!("{name}'s Top Tracks"),
429 selected_track: -1,
430 },
431 plugin_info: json!({
432 "type": "artist",
433 "url": uri,
434 "artworkUrl": artwork_url,
435 "author": name,
436 "totalTracks": track_count
437 }),
438 tracks,
439 })
440 }
441 Err(_) => LoadResult::Empty {},
442 }
443 }
444 }
445 #[async_trait]
446 impl SourcePlugin for QobuzSource {
447 fn name(&self) -> &str {
448 "qobuz"
449 }
450 fn can_handle(&self, identifier: &str) -> bool {
451 self.search_prefixes()
452 .iter()
453 .any(|p| identifier.starts_with(p))
454 || self
455 .isrc_prefixes()
456 .iter()
457 .any(|p| identifier.starts_with(p))
458 || self
459 .rec_prefixes()
460 .iter()
461 .any(|p| identifier.starts_with(p))
462 || url_regex().is_match(identifier)
463 }
464 fn search_prefixes(&self) -> Vec<&str> {
465 vec!["qbsearch:"]
466 }
467 fn isrc_prefixes(&self) -> Vec<&str> {
468 vec!["qbisrc:"]
469 }
470 fn rec_prefixes(&self) -> Vec<&str> {
471 vec!["qbrec:"]
472 }
473 async fn load(
474 &self,
475 identifier: &str,
476 _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
477 ) -> LoadResult {
478 if let Some(prefix) = self
479 .search_prefixes()
480 .into_iter()
481 .find(|p| identifier.starts_with(p))
482 {
483 return self.handle_search(&identifier[prefix.len()..]).await;
484 }
485 if let Some(prefix) = self
486 .isrc_prefixes()
487 .into_iter()
488 .find(|p| identifier.starts_with(p))
489 {
490 return self.handle_isrc(&identifier[prefix.len()..]).await;
491 }
492 if let Some(prefix) = self
493 .rec_prefixes()
494 .into_iter()
495 .find(|p| identifier.starts_with(p))
496 {
497 return self
498 .handle_recommendations(&identifier[prefix.len()..])
499 .await;
500 }
501 if let Some(caps) = url_regex().captures(identifier) {
502 let type_ = caps
503 .name("type")
504 .or_else(|| caps.name("type2"))
505 .map(|m| m.as_str())
506 .unwrap_or("");
507 let id = caps
508 .name("id")
509 .or_else(|| caps.name("id2"))
510 .map(|m| m.as_str())
511 .unwrap_or("");
512 return match type_ {
513 "track" => {
514 match self
515 .api_request("track/get", vec![("track_id", id.to_owned())])
516 .await
517 {
518 Ok(json) => {
519 LoadResult::Track(Track::new(self.parse_qobuz_track(&json).info))
520 }
521 Err(_) => LoadResult::Empty {},
522 }
523 }
524 "album" => self.handle_album(id).await,
525 "playlist" => self.handle_playlist(id).await,
526 "artist" => self.handle_artist(id).await,
527 _ => LoadResult::Empty {},
528 };
529 }
530 LoadResult::Empty {}
531 }
532 async fn get_track(
533 &self,
534 identifier: &str,
535 _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
536 ) -> Option<BoxedTrack> {
537 let id = if identifier.contains("qobuz.com/track/") {
538 identifier
539 .split("track/")
540 .nth(1)?
541 .split('/')
542 .next()?
543 .split('?')
544 .next()?
545 } else {
546 identifier
547 };
548 let tokens = self.token_tracker.get_tokens().await?;
549 if tokens.user_token.is_none() {
550 debug!("Qobuz: No user token, returning None to trigger mirroring");
551 return None;
552 }
553 match self
554 .api_request("track/get", vec![("track_id", id.to_owned())])
555 .await
556 {
557 Ok(json) => Some(Arc::new(self.parse_qobuz_track(&json))),
558 Err(_) => None,
559 }
560 }
561 }
562}
563pub mod token {
564 use base64::{Engine as _, engine::general_purpose};
565 use regex::Regex;
566 use std::sync::Arc;
567 use tokio::sync::RwLock;
568 use tracing::{debug, error, info};
569 const WEB_PLAYER_BASE_URL: &str = "https://play.qobuz.com";
570 pub struct QobuzTokens {
571 pub app_id: String,
572 pub app_secret: String,
573 pub user_token: Option<String>,
574 }
575 pub struct QobuzTokenTracker {
576 client: Arc<reqwest::Client>,
577 tokens: Arc<RwLock<Option<QobuzTokens>>>,
578 config_user_token: Option<String>,
579 config_app_id: Option<String>,
580 config_app_secret: Option<String>,
581 }
582 fn bundle_regex() -> &'static Regex {
583 static REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
584 REGEX.get_or_init(|| {
585 Regex::new(r#"<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)""#).unwrap()
586 })
587 }
588 fn app_id_regex() -> &'static Regex {
589 static REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
590 REGEX.get_or_init(|| Regex::new(r#"production:\{api:\{appId:"(.*?)""#).unwrap())
591 }
592 fn seed_regex() -> &'static Regex {
593 static REGEX: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
594 REGEX.get_or_init(|| {
595 Regex::new(r#"\):[a-z]\.initialSeed\("(.*?)",window\.utimezone\.(.*?)\)"#).unwrap()
596 })
597 }
598 fn info_extras_regex(timezone: &str) -> Regex {
599 Regex::new(&format!(
600 r#"(?s)timezones:\[.*?name:.*?/{}",info:"(?P<info>.*?)",extras:"(?P<extras>.*?)""#,
601 regex::escape(timezone)
602 ))
603 .unwrap()
604 }
605 impl QobuzTokenTracker {
606 pub fn new(
607 client: Arc<reqwest::Client>,
608 user_token: Option<String>,
609 app_id: Option<String>,
610 app_secret: Option<String>,
611 ) -> Self {
612 Self {
613 client,
614 tokens: Arc::new(RwLock::new(None)),
615 config_user_token: user_token,
616 config_app_id: app_id,
617 config_app_secret: app_secret,
618 }
619 }
620 pub async fn get_tokens(&self) -> Option<Arc<QobuzTokens>> {
621 {
622 let tokens = self.tokens.read().await;
623 if let Some(t) = &*tokens {
624 return Some(Arc::new(QobuzTokens {
625 app_id: t.app_id.to_owned(),
626 app_secret: t.app_secret.to_owned(),
627 user_token: t.user_token.to_owned(),
628 }));
629 }
630 }
631 self.refresh_tokens().await
632 }
633 async fn refresh_tokens(&self) -> Option<Arc<QobuzTokens>> {
634 let mut tokens_lock = self.tokens.write().await;
635 if let Some(t) = &*tokens_lock {
636 return Some(Arc::new(QobuzTokens {
637 app_id: t.app_id.to_owned(),
638 app_secret: t.app_secret.to_owned(),
639 user_token: t.user_token.to_owned(),
640 }));
641 }
642 let app_id;
643 let app_secret;
644 if let (Some(id), Some(secret)) = (&self.config_app_id, &self.config_app_secret) {
645 app_id = id.to_owned();
646 app_secret = secret.to_owned();
647 debug!("Using configured Qobuz app_id and app_secret");
648 } else {
649 debug!("Fetching Qobuz bundle.js for credential extraction...");
650 match self.fetch_credentials_from_web().await {
651 Ok((id, secret)) => {
652 app_id = id;
653 app_secret = secret;
654 info!("Successfully extracted Qobuz credentials: appId={app_id}");
655 }
656 Err(e) => {
657 error!("Failed to extract Qobuz credentials: {e}");
658 return None;
659 }
660 }
661 }
662 let new_tokens = QobuzTokens {
663 app_id,
664 app_secret,
665 user_token: self.config_user_token.to_owned(),
666 };
667 let arc_tokens = Arc::new(QobuzTokens {
668 app_id: new_tokens.app_id.to_owned(),
669 app_secret: new_tokens.app_secret.to_owned(),
670 user_token: new_tokens.user_token.to_owned(),
671 });
672 *tokens_lock = Some(new_tokens);
673 Some(arc_tokens)
674 }
675 async fn fetch_credentials_from_web(&self) -> Result<(String, String), String> {
676 let login_page = self
677 .client
678 .get(format!("{WEB_PLAYER_BASE_URL}/login"))
679 .send()
680 .await
681 .map_err(|e| e.to_string())?
682 .text()
683 .await
684 .map_err(|e| e.to_string())?;
685 let bundle_path = bundle_regex()
686 .captures(&login_page)
687 .and_then(|c| c.get(1))
688 .map(|m| m.as_str())
689 .ok_or_else(|| "Failed to find bundle.js path in Qobuz login page".to_owned())?;
690 let bundle_js = self
691 .client
692 .get(format!("{WEB_PLAYER_BASE_URL}{bundle_path}"))
693 .send()
694 .await
695 .map_err(|e| e.to_string())?
696 .text()
697 .await
698 .map_err(|e| e.to_string())?;
699 let app_id = app_id_regex()
700 .captures(&bundle_js)
701 .and_then(|c| c.get(1))
702 .map(|m| m.as_str().to_owned())
703 .ok_or_else(|| "Failed to extract appId from bundle.js".to_owned())?;
704 let seed_captures = seed_regex()
705 .captures(&bundle_js)
706 .ok_or_else(|| "Failed to extract seed and timezone from bundle.js".to_owned())?;
707 let seed = seed_captures.get(1).unwrap().as_str();
708 let timezone_raw = seed_captures.get(2).unwrap().as_str();
709 let timezone = format!(
710 "{}{}",
711 &timezone_raw[..1].to_uppercase(),
712 &timezone_raw[1..].to_lowercase()
713 );
714 let info_extras = info_extras_regex(&timezone)
715 .captures(&bundle_js)
716 .ok_or_else(|| format!("Failed to extract info/extras for timezone {timezone}"))?;
717 let info = info_extras.name("info").unwrap().as_str();
718 let extras = info_extras.name("extras").unwrap().as_str();
719 let mut encoded = format!("{seed}{info}{extras}");
720 if encoded.len() > 44 {
721 encoded.truncate(encoded.len() - 44);
722 }
723 let decoded = general_purpose::STANDARD
724 .decode(encoded)
725 .map_err(|e| format!("Failed to decode appSecret: {e}"))?;
726 let app_secret = String::from_utf8(decoded)
727 .map_err(|e| format!("Invalid UTF-8 in appSecret: {e}"))?;
728 Ok((app_id, app_secret))
729 }
730 pub fn init(self: Arc<Self>) {
731 let this = self.clone();
732 tokio::spawn(async move {
733 this.get_tokens().await;
734 });
735 }
736 }
737}
738pub mod track {
739 use crate::{
740 common::types::AnyResult,
741 protocol::tracks::TrackInfo,
742 sources::{
743 http::HttpTrack,
744 playable_track::{PlayableTrack, ResolvedTrack},
745 qobuz::token::QobuzTokenTracker,
746 },
747 };
748 use async_trait::async_trait;
749 use md5::{Digest, Md5};
750 use std::sync::Arc;
751 pub struct QobuzTrack {
752 pub info: TrackInfo,
753 pub album_name: Option<String>,
754 pub album_url: Option<String>,
755 pub artist_url: Option<String>,
756 pub artist_artwork_url: Option<String>,
757 pub token_tracker: Arc<QobuzTokenTracker>,
758 pub client: Arc<reqwest::Client>,
759 }
760 #[async_trait]
761 impl PlayableTrack for QobuzTrack {
762 async fn resolve(&self) -> Result<ResolvedTrack, String> {
763 let url = switch_media_url(&self.client, &self.token_tracker, &self.info.identifier)
764 .await
765 .map_err(|e| {
766 format!(
767 "Qobuz: Failed to resolve media URL for {}: {e}",
768 self.info.identifier
769 )
770 })?
771 .ok_or_else(|| "Failed to resolve Qobuz media URL".to_string())?;
772 HttpTrack {
773 url,
774 local_addr: None,
775 proxy: None,
776 }
777 .resolve()
778 .await
779 }
780 }
781 async fn switch_media_url(
782 client: &Arc<reqwest::Client>,
783 token_tracker: &QobuzTokenTracker,
784 track_id: &str,
785 ) -> AnyResult<Option<String>> {
786 let tokens = token_tracker
787 .get_tokens()
788 .await
789 .ok_or("Failed to get Qobuz tokens")?;
790 if tokens.user_token.is_none() {
791 return Ok(None);
792 }
793 let unix_ts = std::time::SystemTime::now()
794 .duration_since(std::time::UNIX_EPOCH)?
795 .as_secs();
796 let format_id = "5";
797 let intent = "stream";
798 let sig_data = format!(
799 "trackgetFileUrlformat_id{format_id}intent{intent}track_id{track_id}{unix_ts}{}",
800 tokens.app_secret
801 );
802 let mut hasher = Md5::new();
803 hasher.update(sig_data.as_bytes());
804 let sig = hex::encode(hasher.finalize());
805 let mut url = reqwest::Url::parse("https://www.qobuz.com/api.json/0.2/track/getFileUrl")?;
806 {
807 let mut query = url.query_pairs_mut();
808 query.append_pair("request_ts", &unix_ts.to_string());
809 query.append_pair("request_sig", &sig);
810 query.append_pair("track_id", track_id);
811 query.append_pair("format_id", format_id);
812 query.append_pair("intent", intent);
813 }
814 let mut request = client
815 .get(url)
816 .header("Accept", "application/json")
817 .header("x-app-id", &tokens.app_id);
818 if let Some(user_token) = &tokens.user_token {
819 request = request.header("x-user-auth-token", user_token);
820 }
821 let resp = request.send().await?;
822 if !resp.status().is_success() {
823 return Ok(None);
824 }
825 let json: serde_json::Value = resp.json().await?;
826 if let Some(url) = json.get("url").and_then(|v| v.as_str()) {
827 let is_sample = json.get("sample").and_then(|v| v.as_bool()).or_else(|| {
828 json.get("sample")
829 .and_then(|v| v.as_str())
830 .map(|s| s == "true")
831 });
832 if is_sample == Some(true) {
833 return Ok(None);
834 }
835 return Ok(Some(url.to_owned()));
836 }
837 Ok(None)
838 }
839}
840pub use manager::*;
841pub use token::*;
842pub use track::*;