pub mod client {
use super::{
error::{TidalError, TidalResult},
token::TidalTokenTracker,
};
use serde_json::Value;
use std::sync::Arc;
const API_BASE: &str = "https://api.tidal.com/v1";
pub struct TidalClient {
pub inner: Arc<reqwest::Client>,
pub token_tracker: Arc<TidalTokenTracker>,
pub country_code: String,
pub quality: String,
}
impl TidalClient {
pub fn new(
inner: Arc<reqwest::Client>,
token_tracker: Arc<TidalTokenTracker>,
country_code: String,
quality: String,
) -> Self {
Self {
inner,
token_tracker,
country_code,
quality,
}
}
pub fn base_request(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder
.header(
reqwest::header::USER_AGENT,
"TIDAL/3704 CFNetwork/1220.1 Darwin/20.3.0",
)
.header("Accept-Language", "en-US")
}
pub async fn get_json(&self, path: &str) -> TidalResult<Value> {
let url = if path.starts_with("http") {
path.to_owned()
} else {
format!("{API_BASE}{path}")
};
let url = if !url.contains("countryCode=") {
if url.contains('?') {
format!("{url}&countryCode={}", self.country_code)
} else {
format!("{url}?countryCode={}", self.country_code)
}
} else {
url
};
let mut req = self.base_request(self.inner.get(&url));
if let Some(t) = self.token_tracker.get_scraper_token().await {
req = req.header("x-tidal-token", t);
} else if let Some(t) = self.token_tracker.get_oauth_token().await {
req = req.header("Authorization", format!("Bearer {t}"));
} else {
return Err(TidalError::NoToken);
}
let resp = req.send().await?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(TidalError::ApiError { status, body });
}
Ok(resp.json().await?)
}
}
}
pub mod error {
use thiserror::Error;
#[derive(Debug, Error)]
pub enum TidalError {
#[error("API request failed: {0}")]
Request(#[from] reqwest::Error),
#[error("API returned {status}: {body}")]
ApiError {
status: reqwest::StatusCode,
body: String,
},
#[error("Failed to parse response: {0}")]
Parse(#[from] serde_json::Error),
#[error("Base64 decode failed: {0}")]
Base64(#[from] base64::DecodeError),
#[error("Authentication failed: {0}")]
Auth(String),
#[error("No token available")]
NoToken,
#[error("No stream URL in manifest")]
NoStreamUrl,
#[error("Other error: {0}")]
Other(String),
}
pub type TidalResult<T> = Result<T, TidalError>;
}
pub mod manager {
use super::{
client::TidalClient,
model::{Manifest, PlaybackInfo},
oauth::TidalOAuth,
token::TidalTokenTracker,
track::TidalTrack,
};
use crate::{
common::types::AudioFormat,
protocol::tracks::{LoadResult, PlaylistData, PlaylistInfo, Track, TrackInfo},
sources::{SourcePlugin, playable_track::BoxedTrack},
};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose};
use regex::Regex;
use serde_json::Value;
use std::sync::{Arc, OnceLock};
use tracing::{debug, warn};
fn url_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"https?://(?:(?:listen|www)\.)?tidal\.com/(?:browse/)?(album|track|playlist|mix|artist)/([a-zA-Z0-9\-]+)(?:/.*)?(?:\?.*)?").unwrap()
})
}
pub struct TidalSource {
pub client: Arc<TidalClient>,
playlist_load_limit: usize,
album_load_limit: usize,
artist_load_limit: usize,
}
impl TidalSource {
pub fn new(
config: Option<crate::config::TidalConfig>,
http_client: Arc<reqwest::Client>,
) -> Result<Self, String> {
let (country, quality, p_limit, a_limit, art_limit, refresh_token, get_oauth_token) =
if let Some(c) = config {
(
c.country_code,
c.quality,
c.playlist_load_limit,
c.album_load_limit,
c.artist_load_limit,
c.refresh_token,
c.get_oauth_token,
)
} else {
(
"US".to_string(),
crate::config::sources::default_tidal_quality(),
0,
0,
0,
None,
false,
)
};
let oauth = Arc::new(TidalOAuth::new(refresh_token));
if get_oauth_token {
let oauth_clone = oauth.clone();
tokio::spawn(async move {
oauth_clone.initialize_access_token().await;
});
}
let token_tracker = Arc::new(TidalTokenTracker::new(http_client.clone(), oauth));
token_tracker.clone().init();
let client = Arc::new(TidalClient::new(
http_client,
token_tracker,
country,
quality,
));
Ok(Self {
client,
playlist_load_limit: p_limit,
album_load_limit: a_limit,
artist_load_limit: art_limit,
})
}
fn parse_track(&self, item: &Value) -> Option<TrackInfo> {
let id = item.get("id")?.as_u64()?.to_string();
let title = item
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Unknown Title")
.to_string();
let artists = item
.get("artists")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.get("name").and_then(|n| n.as_str()))
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_else(|| "Unknown Artist".to_owned());
let length = item.get("duration").and_then(|v| v.as_u64()).unwrap_or(0) * 1000;
let isrc = item
.get("isrc")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_owned());
let artwork_url = item
.get("album")
.and_then(|a| a.get("cover"))
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| {
format!(
"https://resources.tidal.com/images/{}/1280x1280.jpg",
s.replace("-", "/")
)
});
let url = item
.get("url")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.replace("http://", "https://"));
Some(TrackInfo {
title,
author: artists,
length,
identifier: id,
is_stream: false,
uri: url,
artwork_url,
isrc,
source_name: "tidal".to_owned(),
is_seekable: true,
position: 0,
})
}
async fn get_track_data(&self, id: &str) -> LoadResult {
match self.client.get_json(&format!("/tracks/{id}")).await {
Ok(data) => self
.parse_track(&data)
.map(|i| LoadResult::Track(Track::new(i)))
.unwrap_or(LoadResult::Empty {}),
Err(_) => LoadResult::Empty {},
}
}
async fn get_album_or_playlist(&self, id: &str, type_str: &str) -> LoadResult {
let info_data = match self.client.get_json(&format!("/{type_str}s/{id}")).await {
Ok(d) => d,
Err(_) => return LoadResult::Empty {},
};
let title = info_data
.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_owned();
let limit = (if type_str == "album" {
self.album_load_limit
} else {
self.playlist_load_limit
})
.clamp(1, 100);
let tracks_data = match self
.client
.get_json(&format!("/{type_str}s/{id}/tracks?limit={limit}"))
.await
{
Ok(d) => d,
Err(_) => return LoadResult::Empty {},
};
let mut tracks = Vec::new();
if let Some(items) = tracks_data.get("items").and_then(|v| v.as_array()) {
for item in items {
let track_obj = item.get("item").unwrap_or(item);
if let Some(info) = self.parse_track(track_obj) {
tracks.push(Track::new(info));
}
}
}
if tracks.is_empty() {
return LoadResult::Empty {};
}
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: title,
selected_track: -1,
},
plugin_info: serde_json::json!({
"type": type_str,
"url": format!("https://tidal.com/browse/{type_str}/{id}"),
"totalTracks": info_data.get("numberOfTracks").or_else(|| info_data.get("numberOfSongs")).and_then(|v| v.as_u64()).unwrap_or(tracks.len() as u64)
}),
tracks,
})
}
async fn get_mix(&self, id: &str, name_override: Option<String>) -> LoadResult {
let data = match self
.client
.get_json(&format!("/mixes/{id}/items?limit=100"))
.await
{
Ok(d) => d,
Err(_) => return LoadResult::Empty {},
};
let mut tracks = Vec::new();
if let Some(items) = data.get("items").and_then(|v| v.as_array()) {
for item in items {
let track_obj = item.get("item").unwrap_or(item);
if let Some(info) = self.parse_track(track_obj) {
tracks.push(Track::new(info));
}
}
}
if tracks.is_empty() {
return LoadResult::Empty {};
}
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: name_override.unwrap_or_else(|| format!("Mix: {id}")),
selected_track: -1,
},
plugin_info: serde_json::json!({ "type": "playlist", "url": format!("https://tidal.com/browse/mix/{id}"), "totalTracks": tracks.len() }),
tracks,
})
}
async fn resolve_by_isrc(&self, isrc: &str) -> LoadResult {
let token = match self.client.token_tracker.get_oauth_token().await {
Some(t) => t,
None => {
warn!("Tidal ISRC lookup requires an OAuth token; none configured");
return LoadResult::Empty {};
}
};
let url = format!(
"https://openapi.tidal.com/v2/tracks?countryCode={}",
self.client.country_code
);
let resp = self
.client
.base_request(self.client.inner.get(&url))
.query(&[("filter[isrc]", isrc)])
.header("Authorization", format!("Bearer {token}"))
.send()
.await;
let r = match resp {
Ok(r) => r,
Err(e) => {
debug!("Tidal ISRC request failed: {e}");
return LoadResult::Empty {};
}
};
if !r.status().is_success() {
debug!(
"Tidal ISRC response {}: {}",
r.status(),
r.text().await.unwrap_or_default()
);
return LoadResult::Empty {};
}
let data: serde_json::Value = match r.json().await {
Ok(d) => d,
Err(e) => {
debug!("Tidal ISRC response parse error: {e}");
return LoadResult::Empty {};
}
};
match data.pointer("/data/0/id").and_then(|v| v.as_str()) {
Some(id) => self.get_track_data(id).await,
None => {
debug!("Tidal ISRC={isrc} not found in catalog");
LoadResult::Empty {}
}
}
}
async fn search(&self, query: &str) -> LoadResult {
let encoded = urlencoding::encode(query);
match self
.client
.get_json(&format!("/search?query={encoded}&limit=10&types=TRACKS"))
.await
{
Ok(data) => {
let mut tracks = Vec::new();
if let Some(items) = data.pointer("/tracks/items").and_then(|v| v.as_array()) {
for item in items {
if let Some(info) = self.parse_track(item) {
tracks.push(Track::new(info));
}
}
}
if tracks.is_empty() {
LoadResult::Empty {}
} else {
LoadResult::Search(tracks)
}
}
Err(_) => LoadResult::Empty {},
}
}
async fn get_recommendations(&self, id: &str) -> LoadResult {
if let Ok(data) = self.client.get_json(&format!("/tracks/{id}")).await
&& let Some(mix_id) = data.pointer("/mixes/TRACK_MIX").and_then(|v| v.as_str())
{
return self
.get_mix(mix_id, Some("Tidal Recommendations".to_string()))
.await;
}
LoadResult::Empty {}
}
async fn get_artist_top_tracks(&self, id: &str) -> LoadResult {
let info_data = match self.client.get_json(&format!("/artists/{id}")).await {
Ok(d) => d,
Err(_) => return LoadResult::Empty {},
};
let name = info_data
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("Unknown Artist");
let limit = self.artist_load_limit.clamp(1, 10);
let data = match self
.client
.get_json(&format!("/artists/{id}/toptracks?limit={limit}"))
.await
{
Ok(d) => d,
Err(_) => return LoadResult::Empty {},
};
let mut tracks = Vec::new();
if let Some(items) = data.get("items").and_then(|v| v.as_array()) {
for item in items {
if let Some(info) = self.parse_track(item) {
tracks.push(Track::new(info));
}
}
}
if tracks.is_empty() {
return LoadResult::Empty {};
}
LoadResult::Playlist(PlaylistData {
info: PlaylistInfo {
name: format!("{name}'s Top Tracks"),
selected_track: -1,
},
plugin_info: serde_json::json!({ "type": "artist", "url": format!("https://tidal.com/browse/artist/{id}"), "totalTracks": tracks.len() }),
tracks,
})
}
}
#[async_trait]
impl SourcePlugin for TidalSource {
fn name(&self) -> &str {
"tidal"
}
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!["tdsearch:"]
}
fn isrc_prefixes(&self) -> Vec<&str> {
vec!["tdisrc:"]
}
fn rec_prefixes(&self) -> Vec<&str> {
vec!["tdrec:"]
}
fn is_mirror(&self) -> bool {
false
}
async fn load(
&self,
identifier: &str,
_: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
) -> LoadResult {
if let Some(prefix) = self
.search_prefixes()
.iter()
.find(|p| identifier.starts_with(**p))
{
return self.search(&identifier[prefix.len()..]).await;
}
if let Some(prefix) = self
.isrc_prefixes()
.iter()
.find(|p| identifier.starts_with(**p))
{
return self.resolve_by_isrc(&identifier[prefix.len()..]).await;
}
if let Some(prefix) = self
.rec_prefixes()
.iter()
.find(|p| identifier.starts_with(**p))
{
return self.get_recommendations(&identifier[prefix.len()..]).await;
}
if let Some(caps) = url_regex().captures(identifier) {
let type_str = caps.get(1).map_or("", |m| m.as_str());
let id = caps.get(2).map_or("", |m| m.as_str());
return match type_str {
"track" => self.get_track_data(id).await,
"album" | "playlist" => self.get_album_or_playlist(id, type_str).await,
"mix" => self.get_mix(id, None).await,
"artist" => self.get_artist_top_tracks(id).await,
_ => LoadResult::Empty {},
};
}
LoadResult::Empty {}
}
async fn get_track(
&self,
identifier: &str,
_: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
) -> Option<BoxedTrack> {
let id = if let Some(caps) = url_regex().captures(identifier) {
let type_str = caps.get(1).map_or("", |m| m.as_str());
let id = caps.get(2).map_or("", |m| m.as_str());
if type_str != "track" {
return None;
}
id.to_owned()
} else {
identifier.to_owned()
};
let token = match self.client.token_tracker.get_oauth_token().await {
Some(t) => t,
None => {
warn!("Tidal playback requires an OAuth login");
return None;
}
};
let quality = &self.client.quality;
let url = format!(
"https://api.tidal.com/v1/tracks/{}/playbackinfo?audioquality={}&playbackmode=STREAM&assetpresentation=FULL&countryCode={}",
id, quality, self.client.country_code
);
debug!("Tidal: Resolving playback info for {}", id);
let resp = match self
.client
.inner
.get(&url)
.header("Authorization", format!("Bearer {token}"))
.header("User-Agent", "TIDAL/3704 CFNetwork/1220.1 Darwin/20.3.0")
.send()
.await
{
Ok(r) => r,
Err(e) => {
warn!("Tidal: Failed to fetch playback info: {}", e);
return None;
}
};
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
warn!("Tidal: Playback API returned {}: {}", status, body);
return None;
}
let info: PlaybackInfo = match resp.json().await {
Ok(i) => i,
Err(e) => {
warn!("Tidal: Failed to parse playback info: {}", e);
return None;
}
};
let decoded = match general_purpose::STANDARD.decode(&info.manifest) {
Ok(d) => d,
Err(e) => {
warn!("Tidal: Failed to decode manifest: {}", e);
return None;
}
};
let manifest: Manifest = match serde_json::from_slice(&decoded) {
Ok(m) => m,
Err(e) => {
warn!("Tidal: Failed to parse manifest JSON: {}", e);
return None;
}
};
let stream_url = match manifest.urls.first() {
Some(u) => u.clone(),
None => {
warn!("Tidal: No stream URL in manifest");
return None;
}
};
let mut kind = AudioFormat::from_url(&stream_url);
if kind == AudioFormat::Unknown {
if quality == "HI_RES_LOSSLESS" {
kind = AudioFormat::Flac;
} else if quality == "LOSSLESS" {
kind = AudioFormat::Mp4;
} else {
kind = AudioFormat::Aac;
}
}
Some(Arc::new(TidalTrack {
identifier: id,
stream_url,
kind,
client: self.client.clone(),
}))
}
}
}
pub mod model {
use serde::Deserialize;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PlaybackInfo {
pub manifest: String,
pub manifest_mime_type: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Manifest {
pub urls: Vec<String>,
pub mime_type: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceAuthResponse {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub verification_uri_complete: String,
pub expires_in: u64,
pub interval: u64,
}
#[derive(Debug, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_in: u64,
}
#[derive(Clone, Debug)]
pub struct TidalToken {
pub access_token: String,
pub expiry_ms: u64,
}
#[derive(Clone, Debug)]
pub enum TidalAuthToken {
OAuth(String),
Scraper(String),
}
impl TidalAuthToken {
pub fn value(&self) -> &str {
match self {
Self::OAuth(s) | Self::Scraper(s) => s,
}
}
}
}
pub mod oauth {
use super::model::{DeviceAuthResponse, TokenResponse};
use reqwest::Client;
use std::{sync::Arc, time::Duration};
use tokio::sync::RwLock;
use tracing::{error, info};
const CLIENT_ID: &str = "fX2JxdmntZWK0ixT";
const CLIENT_SECRET: &str = "1Nn9AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg=";
pub struct TidalOAuth {
pub client: Client,
pub access_token: RwLock<Option<String>>,
pub refresh_token: RwLock<Option<String>>,
}
impl TidalOAuth {
pub fn new(refresh_token: Option<String>) -> Self {
Self {
client: Client::builder()
.user_agent("TIDAL/3704 CFNetwork/1220.1 Darwin/20.3.0")
.build()
.unwrap_or_default(),
access_token: RwLock::new(None),
refresh_token: RwLock::new(refresh_token),
}
}
pub async fn get_access_token(&self) -> Option<String> {
if let Some(token) = &*self.access_token.read().await {
return Some(token.clone());
}
if self.refresh_oauth_token().await.is_ok() {
return self.access_token.read().await.clone();
}
None
}
pub async fn get_refresh_token(&self) -> Option<String> {
self.refresh_token.read().await.clone()
}
pub async fn initialize_access_token(self: Arc<Self>) {
if self.refresh_token.read().await.is_some() {
return;
}
info!("Starting Tidal device authorization flow...");
let form = [("client_id", CLIENT_ID), ("scope", "r_usr w_usr w_sub")];
let resp = match self
.client
.post("https://auth.tidal.com/v1/oauth2/device_authorization")
.form(&form)
.send()
.await
{
Ok(r) => r,
Err(e) => {
error!("Tidal device authorization request failed: {}", e);
return;
}
};
if !resp.status().is_success() {
error!("Tidal device authorization failed: {}", resp.status());
return;
}
let data: DeviceAuthResponse = match resp.json().await {
Ok(d) => d,
Err(e) => {
error!("Failed to parse device auth response: {}", e);
return;
}
};
crate::log_println!(
"\n ┌────────────────────────────────────────────────────────────┐\n │ TIDAL OAUTH LOGIN │\n ├────────────────────────────────────────────────────────────┤\n │ 1. Visit: {:<48} │\n │ 2. Log in and authorize the application. │\n └────────────────────────────────────────────────────────────┘\n",
data.verification_uri_complete
);
let oauth = self.clone();
tokio::spawn(async move {
oauth.poll_token(data.device_code, data.interval).await;
});
}
async fn poll_token(&self, device_code: String, interval: u64) {
let mut interval_timer = tokio::time::interval(Duration::from_secs(interval.max(1)));
loop {
interval_timer.tick().await;
let form = [
("client_id", CLIENT_ID),
("device_code", &device_code),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
("scope", "r_usr w_usr w_sub"),
];
let resp = match self
.client
.post("https://auth.tidal.com/v1/oauth2/token")
.basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
.form(&form)
.send()
.await
{
Ok(r) => r,
Err(_) => continue,
};
if resp.status().is_success() {
if let Ok(data) = resp.json::<TokenResponse>().await {
let mut at_lock = self.access_token.write().await;
let mut rt_lock = self.refresh_token.write().await;
*at_lock = Some(data.access_token);
*rt_lock = data.refresh_token;
info!("Successfully authorized Tidal OAuth");
if let Some(ref rt) = *rt_lock {
info!("Tidal Refresh Token: {}", rt);
}
return;
}
} else if let Ok(body) = resp.json::<serde_json::Value>().await {
let error = body["error"].as_str().unwrap_or_default();
match error {
"authorization_pending" => continue,
"slow_down" => {
interval_timer =
tokio::time::interval(Duration::from_secs(interval + 3));
}
_ => {
error!("Tidal OAuth polling failed: {}", error);
return;
}
}
}
}
}
pub async fn refresh_oauth_token(&self) -> Result<(), String> {
let refresh_token = self.get_refresh_token().await;
let rt = match refresh_token {
Some(t) => t,
None => return Err("No refresh token available".to_string()),
};
info!("Refreshing Tidal OAuth token...");
let form = [
("client_id", CLIENT_ID),
("refresh_token", &rt),
("grant_type", "refresh_token"),
("scope", "r_usr w_usr w_sub"),
];
let resp = self
.client
.post("https://auth.tidal.com/v1/oauth2/token")
.basic_auth(CLIENT_ID, Some(CLIENT_SECRET))
.form(&form)
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
error!("Tidal token refresh failed ({}): {}", status, body);
return Err(format!("Refresh failed: {}", status));
}
let data: TokenResponse = resp.json().await.map_err(|e| e.to_string())?;
let mut at_lock = self.access_token.write().await;
let mut rt_lock = self.refresh_token.write().await;
*at_lock = Some(data.access_token);
if data.refresh_token.is_some() {
*rt_lock = data.refresh_token;
}
info!("Successfully refreshed Tidal OAuth token");
Ok(())
}
}
}
pub mod token {
use super::{model::TidalToken, oauth::TidalOAuth};
use regex::Regex;
use std::sync::{Arc, OnceLock};
use tokio::sync::RwLock;
use tracing::{error, info};
fn script_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r#"src="(/assets/index-[^"]+\.js)""#).unwrap())
}
fn client_id_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r#"clientId\s*[:=]\s*"([^"]+)""#).unwrap())
}
pub struct TidalTokenTracker {
pub token: RwLock<Option<TidalToken>>,
pub client: Arc<reqwest::Client>,
pub oauth: Arc<TidalOAuth>,
}
impl TidalTokenTracker {
pub fn new(client: Arc<reqwest::Client>, oauth: Arc<TidalOAuth>) -> Self {
Self {
token: RwLock::new(None),
client,
oauth,
}
}
pub async fn get_scraper_token(&self) -> Option<String> {
{
let lock = self.token.read().await;
if let Some(token) = &*lock
&& self.is_valid(token)
{
return Some(token.access_token.clone());
}
}
self.refresh_token().await
}
pub async fn get_oauth_token(&self) -> Option<String> {
self.oauth.get_access_token().await
}
fn is_valid(&self, token: &TidalToken) -> bool {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
token.expiry_ms > now + 10_000
}
async fn refresh_token(&self) -> Option<String> {
info!("Fetching new Tidal API token via scraper...");
let listen_url = "https://listen.tidal.com";
let resp = self.client.get(listen_url).send().await.ok()?;
if !resp.status().is_success() {
error!("Tidal listen page returned status: {}", resp.status());
return None;
}
let html = resp.text().await.unwrap_or_default();
let script_path = script_regex().captures(&html)?.get(1)?.as_str();
let script_url = format!("https://listen.tidal.com{}", script_path);
let js_resp = self.client.get(&script_url).send().await.ok()?;
let js_content = js_resp.text().await.unwrap_or_default();
let mut matches = client_id_regex().captures_iter(&js_content);
matches.next();
let token_str = matches.next()?.get(1)?.as_str().to_owned();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let token = TidalToken {
access_token: token_str.clone(),
expiry_ms: now + (24 * 60 * 60 * 1000),
};
let mut lock = self.token.write().await;
*lock = Some(token);
info!("Successfully refreshed Tidal scraper token");
Some(token_str)
}
pub fn init(self: Arc<Self>) {
let this = self.clone();
tokio::spawn(async move {
this.get_scraper_token().await;
});
}
pub async fn has_oauth_refresh_token(&self) -> bool {
self.oauth.get_refresh_token().await.is_some()
}
}
}
pub mod track {
use super::client::TidalClient;
use crate::{
audio::source::HttpSource,
common::types::AudioFormat,
sources::playable_track::{PlayableTrack, ResolvedTrack},
};
use async_trait::async_trait;
use std::sync::Arc;
use tracing::debug;
pub struct TidalTrack {
pub identifier: String,
pub stream_url: String,
pub kind: AudioFormat,
pub client: Arc<TidalClient>,
}
#[async_trait]
impl PlayableTrack for TidalTrack {
async fn resolve(&self) -> Result<ResolvedTrack, String> {
debug!(
"TidalTrack: resolving {} with quality {}",
self.identifier, self.client.quality
);
let client_inner = (*self.client.inner).clone();
let stream_url = self.stream_url.clone();
let kind = self.kind;
let reader = HttpSource::new(client_inner, &stream_url)
.await
.map(|r| Box::new(r) as Box<dyn symphonia::core::io::MediaSource>)
.map_err(|e| format!("Failed to initialize source: {e}"))?;
Ok(ResolvedTrack::new(reader, Some(kind)))
}
}
}
pub use manager::TidalSource;