Skip to main content

lavende_core/sources/
qobuz.rs

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::*;