1pub mod client {
2 use super::{
3 error::{TidalError, TidalResult},
4 token::TidalTokenTracker,
5 };
6 use serde_json::Value;
7 use std::sync::Arc;
8 const API_BASE: &str = "https://api.tidal.com/v1";
9 pub struct TidalClient {
10 pub inner: Arc<reqwest::Client>,
11 pub token_tracker: Arc<TidalTokenTracker>,
12 pub country_code: String,
13 pub quality: String,
14 }
15 impl TidalClient {
16 pub fn new(
17 inner: Arc<reqwest::Client>,
18 token_tracker: Arc<TidalTokenTracker>,
19 country_code: String,
20 quality: String,
21 ) -> Self {
22 Self {
23 inner,
24 token_tracker,
25 country_code,
26 quality,
27 }
28 }
29 pub fn base_request(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
30 builder
31 .header(
32 reqwest::header::USER_AGENT,
33 "TIDAL/3704 CFNetwork/1220.1 Darwin/20.3.0",
34 )
35 .header("Accept-Language", "en-US")
36 }
37 pub async fn get_json(&self, path: &str) -> TidalResult<Value> {
38 let url = if path.starts_with("http") {
39 path.to_owned()
40 } else {
41 format!("{API_BASE}{path}")
42 };
43 let url = if !url.contains("countryCode=") {
44 if url.contains('?') {
45 format!("{url}&countryCode={}", self.country_code)
46 } else {
47 format!("{url}?countryCode={}", self.country_code)
48 }
49 } else {
50 url
51 };
52 let mut req = self.base_request(self.inner.get(&url));
53 if let Some(t) = self.token_tracker.get_scraper_token().await {
54 req = req.header("x-tidal-token", t);
55 } else if let Some(t) = self.token_tracker.get_oauth_token().await {
56 req = req.header("Authorization", format!("Bearer {t}"));
57 } else {
58 return Err(TidalError::NoToken);
59 }
60 let resp = req.send().await?;
61 if !resp.status().is_success() {
62 let status = resp.status();
63 let body = resp.text().await.unwrap_or_default();
64 return Err(TidalError::ApiError { status, body });
65 }
66 Ok(resp.json().await?)
67 }
68 }
69}
70pub mod error {
71 use thiserror::Error;
72 #[derive(Debug, Error)]
73 pub enum TidalError {
74 #[error("API request failed: {0}")]
75 Request(#[from] reqwest::Error),
76 #[error("API returned {status}: {body}")]
77 ApiError {
78 status: reqwest::StatusCode,
79 body: String,
80 },
81 #[error("Failed to parse response: {0}")]
82 Parse(#[from] serde_json::Error),
83 #[error("Base64 decode failed: {0}")]
84 Base64(#[from] base64::DecodeError),
85 #[error("Authentication failed: {0}")]
86 Auth(String),
87 #[error("No token available")]
88 NoToken,
89 #[error("No stream URL in manifest")]
90 NoStreamUrl,
91 #[error("Other error: {0}")]
92 Other(String),
93 }
94 pub type TidalResult<T> = Result<T, TidalError>;
95}
96pub mod manager {
97 use super::{
98 client::TidalClient,
99 model::{Manifest, PlaybackInfo},
100 oauth::TidalOAuth,
101 token::TidalTokenTracker,
102 track::TidalTrack,
103 };
104 use crate::{
105 common::types::AudioFormat,
106 protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo, Track, TrackInfo},
107 sources::{SourcePlugin, playable_track::BoxedTrack},
108 };
109 use async_trait::async_trait;
110 use base64::{Engine as _, engine::general_purpose};
111 use regex::Regex;
112 use serde_json::Value;
113 use std::sync::{Arc, OnceLock};
114 use tracing::{debug, warn};
115 fn url_regex() -> &'static Regex {
116 static RE: OnceLock<Regex> = OnceLock::new();
117 RE.get_or_init(|| {
118 Regex::new(r"https?://(?:(?:listen|www)\.)?tidal\.com/(?:browse/)?(album|track|playlist|mix|artist)/([a-zA-Z0-9\-]+)(?:/.*)?(?:\?.*)?").unwrap()
119 })
120 }
121 pub struct TidalSource {
122 pub client: Arc<TidalClient>,
123 playlist_load_limit: usize,
124 album_load_limit: usize,
125 artist_load_limit: usize,
126 }
127 impl TidalSource {
128 pub fn new(
129 config: Option<crate::config::TidalConfig>,
130 http_client: Arc<reqwest::Client>,
131 ) -> Result<Self, String> {
132 let (country, quality, p_limit, a_limit, art_limit, refresh_token, get_oauth_token) =
133 if let Some(c) = config {
134 (
135 c.country_code,
136 c.quality,
137 c.playlist_load_limit,
138 c.album_load_limit,
139 c.artist_load_limit,
140 c.refresh_token,
141 c.get_oauth_token,
142 )
143 } else {
144 (
145 "US".to_string(),
146 crate::config::sources::default_tidal_quality(),
147 0,
148 0,
149 0,
150 None,
151 false,
152 )
153 };
154 let oauth = Arc::new(TidalOAuth::new(refresh_token));
155 if get_oauth_token {
156 let oauth_clone = oauth.clone();
157 tokio::spawn(async move {
158 oauth_clone.initialize_access_token().await;
159 });
160 }
161 let token_tracker = Arc::new(TidalTokenTracker::new(http_client.clone(), oauth));
162 token_tracker.clone().init();
163 let client = Arc::new(TidalClient::new(
164 http_client,
165 token_tracker,
166 country,
167 quality,
168 ));
169 Ok(Self {
170 client,
171 playlist_load_limit: p_limit,
172 album_load_limit: a_limit,
173 artist_load_limit: art_limit,
174 })
175 }
176 fn parse_track(&self, item: &Value) -> Option<TrackInfo> {
177 let id = item.get("id")?.as_u64()?.to_string();
178 let title = item
179 .get("title")
180 .and_then(|v| v.as_str())
181 .unwrap_or("Unknown Title")
182 .to_string();
183 let artists = item
184 .get("artists")
185 .and_then(|v| v.as_array())
186 .map(|a| {
187 a.iter()
188 .filter_map(|x| x.get("name").and_then(|n| n.as_str()))
189 .collect::<Vec<_>>()
190 .join(", ")
191 })
192 .unwrap_or_else(|| "Unknown Artist".to_owned());
193 let length = item.get("duration").and_then(|v| v.as_u64()).unwrap_or(0) * 1000;
194 let isrc = item
195 .get("isrc")
196 .and_then(|v| v.as_str())
197 .filter(|s| !s.is_empty())
198 .map(|s| s.to_owned());
199 let artwork_url = item
200 .get("album")
201 .and_then(|a| a.get("cover"))
202 .and_then(|v| v.as_str())
203 .filter(|s| !s.is_empty())
204 .map(|s| {
205 format!(
206 "https://resources.tidal.com/images/{}/1280x1280.jpg",
207 s.replace("-", "/")
208 )
209 });
210 let url = item
211 .get("url")
212 .and_then(|v| v.as_str())
213 .filter(|s| !s.is_empty())
214 .map(|s| s.replace("http://", "https://"));
215 Some(TrackInfo {
216 title,
217 author: artists,
218 length,
219 identifier: id,
220 is_stream: false,
221 uri: url,
222 artwork_url,
223 isrc,
224 source_name: "tidal".to_owned(),
225 is_seekable: true,
226 position: 0,
227 })
228 }
229 async fn get_track_data(&self, id: &str) -> LoadResult {
230 match self.client.get_json(&format!("/tracks/{id}")).await {
231 Ok(data) => self
232 .parse_track(&data)
233 .map(|i| LoadResult::Track(Track::new(i)))
234 .unwrap_or(LoadResult::Empty {}),
235 Err(_) => LoadResult::Empty {},
236 }
237 }
238 async fn get_album_or_playlist(&self, id: &str, type_str: &str) -> LoadResult {
239 let info_data = match self.client.get_json(&format!("/{type_str}s/{id}")).await {
240 Ok(d) => d,
241 Err(_) => return LoadResult::Empty {},
242 };
243 let title = info_data
244 .get("title")
245 .and_then(|v| v.as_str())
246 .unwrap_or("Unknown")
247 .to_owned();
248 let limit = (if type_str == "album" {
249 self.album_load_limit
250 } else {
251 self.playlist_load_limit
252 })
253 .clamp(1, 100);
254 let tracks_data = match self
255 .client
256 .get_json(&format!("/{type_str}s/{id}/tracks?limit={limit}"))
257 .await
258 {
259 Ok(d) => d,
260 Err(_) => return LoadResult::Empty {},
261 };
262 let mut tracks = Vec::new();
263 if let Some(items) = tracks_data.get("items").and_then(|v| v.as_array()) {
264 for item in items {
265 let track_obj = item.get("item").unwrap_or(item);
266 if let Some(info) = self.parse_track(track_obj) {
267 tracks.push(Track::new(info));
268 }
269 }
270 }
271 if tracks.is_empty() {
272 return LoadResult::Empty {};
273 }
274 LoadResult::Playlist(PlaylistData {
275 info: PlaylistInfo {
276 name: title,
277 selected_track: -1,
278 },
279 plugin_info: serde_json::json!({
280 "type": type_str,
281 "url": format!("https://tidal.com/browse/{type_str}/{id}"),
282 "totalTracks": info_data.get("numberOfTracks").or_else(|| info_data.get("numberOfSongs")).and_then(|v| v.as_u64()).unwrap_or(tracks.len() as u64)
283 }),
284 tracks,
285 })
286 }
287 async fn get_mix(&self, id: &str, name_override: Option<String>) -> LoadResult {
288 let data = match self
289 .client
290 .get_json(&format!("/mixes/{id}/items?limit=100"))
291 .await
292 {
293 Ok(d) => d,
294 Err(_) => return LoadResult::Empty {},
295 };
296 let mut tracks = Vec::new();
297 if let Some(items) = data.get("items").and_then(|v| v.as_array()) {
298 for item in items {
299 let track_obj = item.get("item").unwrap_or(item);
300 if let Some(info) = self.parse_track(track_obj) {
301 tracks.push(Track::new(info));
302 }
303 }
304 }
305 if tracks.is_empty() {
306 return LoadResult::Empty {};
307 }
308 LoadResult::Playlist(PlaylistData {
309 info: PlaylistInfo {
310 name: name_override.unwrap_or_else(|| format!("Mix: {id}")),
311 selected_track: -1,
312 },
313 plugin_info: serde_json::json!({ "type": "playlist", "url": format!("https://tidal.com/browse/mix/{id}"), "totalTracks": tracks.len() }),
314 tracks,
315 })
316 }
317 async fn resolve_by_isrc(&self, isrc: &str) -> LoadResult {
318 let token = match self.client.token_tracker.get_oauth_token().await {
319 Some(t) => t,
320 None => {
321 warn!("Tidal ISRC lookup requires an OAuth token; none configured");
322 return LoadResult::Empty {};
323 }
324 };
325 let url = format!(
326 "https://openapi.tidal.com/v2/tracks?countryCode={}",
327 self.client.country_code
328 );
329 let resp = self
330 .client
331 .base_request(self.client.inner.get(&url))
332 .query(&[("filter[isrc]", isrc)])
333 .header("Authorization", format!("Bearer {token}"))
334 .send()
335 .await;
336 let r = match resp {
337 Ok(r) => r,
338 Err(e) => {
339 debug!("Tidal ISRC request failed: {e}");
340 return LoadResult::Empty {};
341 }
342 };
343 if !r.status().is_success() {
344 debug!(
345 "Tidal ISRC response {}: {}",
346 r.status(),
347 r.text().await.unwrap_or_default()
348 );
349 return LoadResult::Empty {};
350 }
351 let data: serde_json::Value = match r.json().await {
352 Ok(d) => d,
353 Err(e) => {
354 debug!("Tidal ISRC response parse error: {e}");
355 return LoadResult::Empty {};
356 }
357 };
358 match data.pointer("/data/0/id").and_then(|v| v.as_str()) {
359 Some(id) => self.get_track_data(id).await,
360 None => {
361 debug!("Tidal ISRC={isrc} not found in catalog");
362 LoadResult::Empty {}
363 }
364 }
365 }
366 async fn search(&self, query: &str) -> LoadResult {
367 let encoded = urlencoding::encode(query);
368 match self
369 .client
370 .get_json(&format!("/search?query={encoded}&limit=10&types=TRACKS"))
371 .await
372 {
373 Ok(data) => {
374 let mut tracks = Vec::new();
375 if let Some(items) = data.pointer("/tracks/items").and_then(|v| v.as_array()) {
376 for item in items {
377 if let Some(info) = self.parse_track(item) {
378 tracks.push(Track::new(info));
379 }
380 }
381 }
382 if tracks.is_empty() {
383 LoadResult::Empty {}
384 } else {
385 LoadResult::Search(tracks)
386 }
387 }
388 Err(_) => LoadResult::Empty {},
389 }
390 }
391 async fn get_recommendations(&self, id: &str) -> LoadResult {
392 if let Ok(data) = self.client.get_json(&format!("/tracks/{id}")).await
393 && let Some(mix_id) = data.pointer("/mixes/TRACK_MIX").and_then(|v| v.as_str())
394 {
395 return self
396 .get_mix(mix_id, Some("Tidal Recommendations".to_string()))
397 .await;
398 }
399 LoadResult::Empty {}
400 }
401 async fn get_artist_top_tracks(&self, id: &str) -> LoadResult {
402 let info_data = match self.client.get_json(&format!("/artists/{id}")).await {
403 Ok(d) => d,
404 Err(_) => return LoadResult::Empty {},
405 };
406 let name = info_data
407 .get("name")
408 .and_then(|v| v.as_str())
409 .unwrap_or("Unknown Artist");
410 let limit = self.artist_load_limit.clamp(1, 10);
411 let data = match self
412 .client
413 .get_json(&format!("/artists/{id}/toptracks?limit={limit}"))
414 .await
415 {
416 Ok(d) => d,
417 Err(_) => return LoadResult::Empty {},
418 };
419 let mut tracks = Vec::new();
420 if let Some(items) = data.get("items").and_then(|v| v.as_array()) {
421 for item in items {
422 if let Some(info) = self.parse_track(item) {
423 tracks.push(Track::new(info));
424 }
425 }
426 }
427 if tracks.is_empty() {
428 return LoadResult::Empty {};
429 }
430 LoadResult::Playlist(PlaylistData {
431 info: PlaylistInfo {
432 name: format!("{name}'s Top Tracks"),
433 selected_track: -1,
434 },
435 plugin_info: serde_json::json!({ "type": "artist", "url": format!("https://tidal.com/browse/artist/{id}"), "totalTracks": tracks.len() }),
436 tracks,
437 })
438 }
439 }
440 #[async_trait]
441 impl SourcePlugin for TidalSource {
442 fn name(&self) -> &str {
443 "tidal"
444 }
445 fn can_handle(&self, identifier: &str) -> bool {
446 self.search_prefixes()
447 .iter()
448 .any(|p| identifier.starts_with(p))
449 || self
450 .isrc_prefixes()
451 .iter()
452 .any(|p| identifier.starts_with(p))
453 || self
454 .rec_prefixes()
455 .iter()
456 .any(|p| identifier.starts_with(p))
457 || url_regex().is_match(identifier)
458 }
459 fn search_prefixes(&self) -> Vec<&str> {
460 vec!["tdsearch:"]
461 }
462 fn isrc_prefixes(&self) -> Vec<&str> {
463 vec!["tdisrc:"]
464 }
465 fn rec_prefixes(&self) -> Vec<&str> {
466 vec!["tdrec:"]
467 }
468 fn is_mirror(&self) -> bool {
469 false
470 }
471 async fn load(
472 &self,
473 identifier: &str,
474 _: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
475 ) -> LoadResult {
476 if let Some(prefix) = self
477 .search_prefixes()
478 .iter()
479 .find(|p| identifier.starts_with(**p))
480 {
481 return self.search(&identifier[prefix.len()..]).await;
482 }
483 if let Some(prefix) = self
484 .isrc_prefixes()
485 .iter()
486 .find(|p| identifier.starts_with(**p))
487 {
488 return self.resolve_by_isrc(&identifier[prefix.len()..]).await;
489 }
490 if let Some(prefix) = self
491 .rec_prefixes()
492 .iter()
493 .find(|p| identifier.starts_with(**p))
494 {
495 return self.get_recommendations(&identifier[prefix.len()..]).await;
496 }
497 if let Some(caps) = url_regex().captures(identifier) {
498 let type_str = caps.get(1).map_or("", |m| m.as_str());
499 let id = caps.get(2).map_or("", |m| m.as_str());
500 return match type_str {
501 "track" => self.get_track_data(id).await,
502 "album" | "playlist" => self.get_album_or_playlist(id, type_str).await,
503 "mix" => self.get_mix(id, None).await,
504 "artist" => self.get_artist_top_tracks(id).await,
505 _ => LoadResult::Empty {},
506 };
507 }
508 LoadResult::Empty {}
509 }
510 async fn get_track(
511 &self,
512 identifier: &str,
513 _: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
514 ) -> Option<BoxedTrack> {
515 let id = if let Some(caps) = url_regex().captures(identifier) {
516 let type_str = caps.get(1).map_or("", |m| m.as_str());
517 let id = caps.get(2).map_or("", |m| m.as_str());
518 if type_str != "track" {
519 return None;
520 }
521 id.to_owned()
522 } else {
523 identifier.to_owned()
524 };
525 let token = match self.client.token_tracker.get_oauth_token().await {
526 Some(t) => t,
527 None => {
528 warn!("Tidal playback requires an OAuth login");
529 return None;
530 }
531 };
532 let quality = &self.client.quality;
533 let url = format!(
534 "https://api.tidal.com/v1/tracks/{}/playbackinfo?audioquality={}&playbackmode=STREAM&assetpresentation=FULL&countryCode={}",
535 id, quality, self.client.country_code
536 );
537 debug!("Tidal: Resolving playback info for {}", id);
538 let resp = match self
539 .client
540 .inner
541 .get(&url)
542 .header("Authorization", format!("Bearer {token}"))
543 .header("User-Agent", "TIDAL/3704 CFNetwork/1220.1 Darwin/20.3.0")
544 .send()
545 .await
546 {
547 Ok(r) => r,
548 Err(e) => {
549 warn!("Tidal: Failed to fetch playback info: {}", e);
550 return None;
551 }
552 };
553 if !resp.status().is_success() {
554 let status = resp.status();
555 let body = resp.text().await.unwrap_or_default();
556 warn!("Tidal: Playback API returned {}: {}", status, body);
557 return None;
558 }
559 let info: PlaybackInfo = match resp.json().await {
560 Ok(i) => i,
561 Err(e) => {
562 warn!("Tidal: Failed to parse playback info: {}", e);
563 return None;
564 }
565 };
566 let decoded = match general_purpose::STANDARD.decode(&info.manifest) {
567 Ok(d) => d,
568 Err(e) => {
569 warn!("Tidal: Failed to decode manifest: {}", e);
570 return None;
571 }
572 };
573 let manifest: Manifest = match serde_json::from_slice(&decoded) {
574 Ok(m) => m,
575 Err(e) => {
576 warn!("Tidal: Failed to parse manifest JSON: {}", e);
577 return None;
578 }
579 };
580 let stream_url = match manifest.urls.first() {
581 Some(u) => u.clone(),
582 None => {
583 warn!("Tidal: No stream URL in manifest");
584 return None;
585 }
586 };
587 let mut kind = AudioFormat::from_url(&stream_url);
588 if kind == AudioFormat::Unknown {
589 if quality == "HI_RES_LOSSLESS" {
590 kind = AudioFormat::Flac;
591 } else if quality == "LOSSLESS" {
592 kind = AudioFormat::Mp4;
593 } else {
594 kind = AudioFormat::Aac;
595 }
596 }
597 Some(Arc::new(TidalTrack {
598 identifier: id,
599 stream_url,
600 kind,
601 client: self.client.clone(),
602 }))
603 }
604 }
605}
606pub mod model {
607 use serde::Deserialize;
608 #[derive(Debug, Deserialize)]
609 #[serde(rename_all = "camelCase")]
610 pub struct PlaybackInfo {
611 pub manifest: String,
612 pub manifest_mime_type: String,
613 }
614 #[derive(Debug, Deserialize)]
615 #[serde(rename_all = "camelCase")]
616 pub struct Manifest {
617 pub urls: Vec<String>,
618 pub mime_type: Option<String>,
619 }
620 #[derive(Debug, Deserialize)]
621 #[serde(rename_all = "camelCase")]
622 pub struct DeviceAuthResponse {
623 pub device_code: String,
624 pub user_code: String,
625 pub verification_uri: String,
626 pub verification_uri_complete: String,
627 pub expires_in: u64,
628 pub interval: u64,
629 }
630 #[derive(Debug, Deserialize)]
631 pub struct TokenResponse {
632 pub access_token: String,
633 pub refresh_token: Option<String>,
634 pub expires_in: u64,
635 }
636 #[derive(Clone, Debug)]
637 pub struct TidalToken {
638 pub access_token: String,
639 pub expiry_ms: u64,
640 }
641 #[derive(Clone, Debug)]
642 pub enum TidalAuthToken {
643 OAuth(String),
644 Scraper(String),
645 }
646 impl TidalAuthToken {
647 pub fn value(&self) -> &str {
648 match self {
649 Self::OAuth(s) | Self::Scraper(s) => s,
650 }
651 }
652 }
653}
654pub mod oauth {
655 use super::model::{DeviceAuthResponse, TokenResponse};
656 use reqwest::Client;
657 use std::{sync::Arc, time::Duration};
658 use tokio::sync::RwLock;
659 use tracing::{error, info};
660 const CLIENT_ID: &str = "fX2JxdmntZWK0ixT";
661 const CLIENT_SECRET: &str = "1Nn9AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg=";
662 pub struct TidalOAuth {
663 pub client: Client,
664 pub access_token: RwLock<Option<String>>,
665 pub refresh_token: RwLock<Option<String>>,
666 }
667 impl TidalOAuth {
668 pub fn new(refresh_token: Option<String>) -> Self {
669 Self {
670 client: Client::builder()
671 .user_agent("TIDAL/3704 CFNetwork/1220.1 Darwin/20.3.0")
672 .build()
673 .unwrap_or_default(),
674 access_token: RwLock::new(None),
675 refresh_token: RwLock::new(refresh_token),
676 }
677 }
678 pub async fn get_access_token(&self) -> Option<String> {
679 if let Some(token) = &*self.access_token.read().await {
680 return Some(token.clone());
681 }
682 if self.refresh_oauth_token().await.is_ok() {
683 return self.access_token.read().await.clone();
684 }
685 None
686 }
687 pub async fn get_refresh_token(&self) -> Option<String> {
688 self.refresh_token.read().await.clone()
689 }
690 pub async fn initialize_access_token(self: Arc<Self>) {
691 if self.refresh_token.read().await.is_some() {
692 return;
693 }
694 info!("Starting Tidal device authorization flow...");
695 let form = [("client_id", CLIENT_ID), ("scope", "r_usr w_usr w_sub")];
696 let resp = match self
697 .client
698 .post("https://auth.tidal.com/v1/oauth2/device_authorization")
699 .form(&form)
700 .send()
701 .await
702 {
703 Ok(r) => r,
704 Err(e) => {
705 error!("Tidal device authorization request failed: {}", e);
706 return;
707 }
708 };
709 if !resp.status().is_success() {
710 error!("Tidal device authorization failed: {}", resp.status());
711 return;
712 }
713 let data: DeviceAuthResponse = match resp.json().await {
714 Ok(d) => d,
715 Err(e) => {
716 error!("Failed to parse device auth response: {}", e);
717 return;
718 }
719 };
720 crate::log_println!(
721 "\n ┌────────────────────────────────────────────────────────────┐\n │ TIDAL OAUTH LOGIN │\n ├────────────────────────────────────────────────────────────┤\n │ 1. Visit: {:<48} │\n │ 2. Log in and authorize the application. │\n └────────────────────────────────────────────────────────────┘\n",
722 data.verification_uri_complete
723 );
724 let oauth = self.clone();
725 tokio::spawn(async move {
726 oauth.poll_token(data.device_code, data.interval).await;
727 });
728 }
729 async fn poll_token(&self, device_code: String, interval: u64) {
730 let mut interval_timer = tokio::time::interval(Duration::from_secs(interval.max(1)));
731 loop {
732 interval_timer.tick().await;
733 let form = [
734 ("client_id", CLIENT_ID),
735 ("device_code", &device_code),
736 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
737 ("scope", "r_usr w_usr w_sub"),
738 ];
739 let resp = match self
740 .client
741 .post("https://auth.tidal.com/v1/oauth2/token")
742 .basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
743 .form(&form)
744 .send()
745 .await
746 {
747 Ok(r) => r,
748 Err(_) => continue,
749 };
750 if resp.status().is_success() {
751 if let Ok(data) = resp.json::<TokenResponse>().await {
752 let mut at_lock = self.access_token.write().await;
753 let mut rt_lock = self.refresh_token.write().await;
754 *at_lock = Some(data.access_token);
755 *rt_lock = data.refresh_token;
756 info!("Successfully authorized Tidal OAuth");
757 if let Some(ref rt) = *rt_lock {
758 info!("Tidal Refresh Token: {}", rt);
759 }
760 return;
761 }
762 } else if let Ok(body) = resp.json::<serde_json::Value>().await {
763 let error = body["error"].as_str().unwrap_or_default();
764 match error {
765 "authorization_pending" => continue,
766 "slow_down" => {
767 interval_timer =
768 tokio::time::interval(Duration::from_secs(interval + 3));
769 }
770 _ => {
771 error!("Tidal OAuth polling failed: {}", error);
772 return;
773 }
774 }
775 }
776 }
777 }
778 pub async fn refresh_oauth_token(&self) -> Result<(), String> {
779 let refresh_token = self.get_refresh_token().await;
780 let rt = match refresh_token {
781 Some(t) => t,
782 None => return Err("No refresh token available".to_string()),
783 };
784 info!("Refreshing Tidal OAuth token...");
785 let form = [
786 ("client_id", CLIENT_ID),
787 ("refresh_token", &rt),
788 ("grant_type", "refresh_token"),
789 ("scope", "r_usr w_usr w_sub"),
790 ];
791 let resp = self
792 .client
793 .post("https://auth.tidal.com/v1/oauth2/token")
794 .basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
795 .form(&form)
796 .send()
797 .await
798 .map_err(|e| e.to_string())?;
799 if !resp.status().is_success() {
800 let status = resp.status();
801 let body = resp.text().await.unwrap_or_default();
802 error!("Tidal token refresh failed ({}): {}", status, body);
803 return Err(format!("Refresh failed: {}", status));
804 }
805 let data: TokenResponse = resp.json().await.map_err(|e| e.to_string())?;
806 let mut at_lock = self.access_token.write().await;
807 let mut rt_lock = self.refresh_token.write().await;
808 *at_lock = Some(data.access_token);
809 if data.refresh_token.is_some() {
810 *rt_lock = data.refresh_token;
811 }
812 info!("Successfully refreshed Tidal OAuth token");
813 Ok(())
814 }
815 }
816}
817pub mod token {
818 use super::{model::TidalToken, oauth::TidalOAuth};
819 use regex::Regex;
820 use std::sync::{Arc, OnceLock};
821 use tokio::sync::RwLock;
822 use tracing::{error, info};
823 fn script_regex() -> &'static Regex {
824 static RE: OnceLock<Regex> = OnceLock::new();
825 RE.get_or_init(|| Regex::new(r#"src="(/assets/index-[^"]+\.js)""#).unwrap())
826 }
827 fn client_id_regex() -> &'static Regex {
828 static RE: OnceLock<Regex> = OnceLock::new();
829 RE.get_or_init(|| Regex::new(r#"clientId\s*[:=]\s*"([^"]+)""#).unwrap())
830 }
831 pub struct TidalTokenTracker {
832 pub token: RwLock<Option<TidalToken>>,
833 pub client: Arc<reqwest::Client>,
834 pub oauth: Arc<TidalOAuth>,
835 }
836 impl TidalTokenTracker {
837 pub fn new(client: Arc<reqwest::Client>, oauth: Arc<TidalOAuth>) -> Self {
838 Self {
839 token: RwLock::new(None),
840 client,
841 oauth,
842 }
843 }
844 pub async fn get_scraper_token(&self) -> Option<String> {
845 {
846 let lock = self.token.read().await;
847 if let Some(token) = &*lock
848 && self.is_valid(token)
849 {
850 return Some(token.access_token.clone());
851 }
852 }
853 self.refresh_token().await
854 }
855 pub async fn get_oauth_token(&self) -> Option<String> {
856 self.oauth.get_access_token().await
857 }
858 fn is_valid(&self, token: &TidalToken) -> bool {
859 let now = std::time::SystemTime::now()
860 .duration_since(std::time::UNIX_EPOCH)
861 .unwrap()
862 .as_millis() as u64;
863 token.expiry_ms > now + 10_000
864 }
865 async fn refresh_token(&self) -> Option<String> {
866 info!("Fetching new Tidal API token via scraper...");
867 let listen_url = "https://listen.tidal.com";
868 let resp = self.client.get(listen_url).send().await.ok()?;
869 if !resp.status().is_success() {
870 error!("Tidal listen page returned status: {}", resp.status());
871 return None;
872 }
873 let html = resp.text().await.unwrap_or_default();
874 let script_path = script_regex().captures(&html)?.get(1)?.as_str();
875 let script_url = format!("https://listen.tidal.com{}", script_path);
876 let js_resp = self.client.get(&script_url).send().await.ok()?;
877 let js_content = js_resp.text().await.unwrap_or_default();
878 let mut matches = client_id_regex().captures_iter(&js_content);
879 matches.next();
880 let token_str = matches.next()?.get(1)?.as_str().to_owned();
881 let now = std::time::SystemTime::now()
882 .duration_since(std::time::UNIX_EPOCH)
883 .unwrap()
884 .as_millis() as u64;
885 let token = TidalToken {
886 access_token: token_str.clone(),
887 expiry_ms: now + (24 * 60 * 60 * 1000),
888 };
889 let mut lock = self.token.write().await;
890 *lock = Some(token);
891 info!("Successfully refreshed Tidal scraper token");
892 Some(token_str)
893 }
894 pub fn init(self: Arc<Self>) {
895 let this = self.clone();
896 tokio::spawn(async move {
897 this.get_scraper_token().await;
898 });
899 }
900 pub async fn has_oauth_refresh_token(&self) -> bool {
901 self.oauth.get_refresh_token().await.is_some()
902 }
903 }
904}
905pub mod track {
906 use super::client::TidalClient;
907 use crate::{
908 audio::source::HttpSource,
909 common::types::AudioFormat,
910 sources::playable_track::{PlayableTrack, ResolvedTrack},
911 };
912 use async_trait::async_trait;
913 use std::sync::Arc;
914 use tracing::debug;
915 pub struct TidalTrack {
916 pub identifier: String,
917 pub stream_url: String,
918 pub kind: AudioFormat,
919 pub client: Arc<TidalClient>,
920 }
921 #[async_trait]
922 impl PlayableTrack for TidalTrack {
923 async fn resolve(&self) -> Result<ResolvedTrack, String> {
924 debug!(
925 "TidalTrack: resolving {} with quality {}",
926 self.identifier, self.client.quality
927 );
928 let client_inner = (*self.client.inner).clone();
929 let stream_url = self.stream_url.clone();
930 let kind = self.kind;
931 let reader = HttpSource::new(client_inner, &stream_url)
932 .await
933 .map(|r| Box::new(r) as Box<dyn symphonia::core::io::MediaSource>)
934 .map_err(|e| format!("Failed to initialize source: {e}"))?;
935 Ok(ResolvedTrack::new(reader, Some(kind)))
936 }
937 }
938}
939pub use manager::TidalSource;