char-slop/tidal-dl

tidal ripper

git clone https://git.t4t.associates/char-slop/tidal-dl

Charlotte Somslop: initial commit3d04234

main
7.1 KiB230 linesraw
1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3
4use crate::api_key::ApiKey;
5use crate::pkce::PkceSession;
6
7const TOKEN_URL: &str = "https://auth.tidal.com/v1/oauth2/token";
8
9#[derive(Debug, Clone, Default)]
10pub struct PendingPkce {
11    pub verifier: String,
12    pub redirect_uri: String,
13    pub client_unique_key: String,
14}
15
16impl PendingPkce {
17    pub fn from_session(session: &PkceSession, redirect_uri: &str) -> Self {
18        Self {
19            verifier: session.verifier.clone(),
20            redirect_uri: redirect_uri.to_string(),
21            client_unique_key: session.client_unique_key.clone(),
22        }
23    }
24}
25
26#[derive(Debug, Clone, Deserialize)]
27struct TokenResponse {
28    access_token: String,
29    refresh_token: Option<String>,
30    expires_in: Option<i64>,
31    user: Option<TokenUser>,
32}
33
34#[derive(Debug, Clone, Deserialize)]
35#[serde(rename_all = "camelCase")]
36struct TokenUser {
37    user_id: u64,
38    country_code: String,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Session {
43    pub user_id: u64,
44    pub country_code: String,
45    pub access_token: String,
46    pub refresh_token: Option<String>,
47    pub expires_in: i64,
48}
49
50pub async fn exchange_code(
51    client: &reqwest::Client,
52    key: &ApiKey,
53    pending: &PendingPkce,
54    code: &str,
55) -> Result<Session> {
56    let redirect_uri = key.redirect_uri.clone();
57    let form = [
58        ("client_id", key.client_id.as_str()),
59        ("client_unique_key", pending.client_unique_key.as_str()),
60        ("code", code),
61        ("code_verifier", pending.verifier.as_str()),
62        ("grant_type", "authorization_code"),
63        ("redirect_uri", redirect_uri.as_str()),
64        ("scope", key.scope.as_str()),
65    ];
66    let resp: serde_json::Value = client
67        .post(TOKEN_URL)
68        .form(&form)
69        .send()
70        .await
71        .context("token exchange request")?
72        .json()
73        .await
74        .context("token exchange body")?;
75    if resp.get("access_token").is_none() {
76        anyhow::bail!("Get token failed: {resp}");
77    }
78    let token: TokenResponse = serde_json::from_value(resp).context("token response")?;
79    Ok(Session {
80        user_id: token.user.as_ref().map(|u| u.user_id).unwrap_or(0),
81        country_code: token.user.map(|u| u.country_code).unwrap_or_default(),
82        access_token: token.access_token,
83        refresh_token: token.refresh_token,
84        expires_in: token.expires_in.unwrap_or(0),
85    })
86}
87
88pub async fn verify_access_token(client: &reqwest::Client, access_token: &str) -> bool {
89    let resp = client
90        .get("https://api.tidal.com/v1/sessions")
91        .bearer_auth(access_token)
92        .send()
93        .await;
94    match resp {
95        Ok(r) => r.status().is_success(),
96        Err(_) => false,
97    }
98}
99
100pub async fn refresh_token(
101    client: &reqwest::Client,
102    key: &ApiKey,
103    refresh_token: &str,
104) -> Result<Session> {
105    let form = [
106        ("client_id", key.client_id.as_str()),
107        ("refresh_token", refresh_token),
108        ("grant_type", "refresh_token"),
109        ("scope", key.scope.as_str()),
110    ];
111    let mut request = client.post(TOKEN_URL);
112    if !key.client_secret.is_empty() {
113        request = request.basic_auth(&key.client_id, Some(&key.client_secret));
114    }
115    let resp: serde_json::Value = request
116        .form(&form)
117        .send()
118        .await
119        .context("refresh request")?
120        .json()
121        .await
122        .context("refresh body")?;
123    if resp.get("access_token").is_none() {
124        anyhow::bail!("Refresh failed: {resp}");
125    }
126    let token: TokenResponse = serde_json::from_value(resp).context("refresh response")?;
127    Ok(Session {
128        user_id: token.user.as_ref().map(|u| u.user_id).unwrap_or(0),
129        country_code: token.user.map(|u| u.country_code).unwrap_or_default(),
130        access_token: token.access_token,
131        refresh_token: token.refresh_token.or(Some(refresh_token.to_string())),
132        expires_in: token.expires_in.unwrap_or(0),
133    })
134}
135
136const DEVICE_AUTH_URL: &str = "https://auth.tidal.com/v1/oauth2/device_authorization";
137pub const DEVICE_SCOPE: &str = "r_usr+w_usr+w_sub";
138
139#[derive(Debug, Clone, serde::Deserialize)]
140#[serde(rename_all = "camelCase")]
141pub struct DeviceCode {
142    pub device_code: String,
143    pub user_code: String,
144    pub verification_uri: String,
145    pub expires_in: u64,
146    pub interval: u64,
147}
148
149pub async fn start_device_code(
150    client: &reqwest::Client,
151    key: &ApiKey,
152) -> Result<DeviceCode> {
153    let resp: serde_json::Value = client
154        .post(DEVICE_AUTH_URL)
155        .form(&[("client_id", key.client_id.as_str()), ("scope", DEVICE_SCOPE)])
156        .send()
157        .await
158        .context("device authorization request")?
159        .json()
160        .await
161        .context("device authorization body")?;
162    if let Some(status) = resp.get("status") {
163        anyhow::bail!("device authorization failed (status {status}) — try another key");
164    }
165    serde_json::from_value(resp).context("device authorization response")
166}
167
168/// Map one device-poll response: `Ok(None)` means still waiting.
169pub fn device_poll_result(resp: serde_json::Value) -> Result<Option<Session>> {
170    if resp.get("access_token").is_some() {
171        let token: TokenResponse = serde_json::from_value(resp).context("device token response")?;
172        return Ok(Some(Session {
173            user_id: token.user.as_ref().map(|u| u.user_id).unwrap_or(0),
174            country_code: token.user.map(|u| u.country_code).unwrap_or_default(),
175            access_token: token.access_token,
176            refresh_token: token.refresh_token,
177            expires_in: token.expires_in.unwrap_or(0),
178        }));
179    }
180    let status = resp.get("status").and_then(|s| s.as_u64()).unwrap_or(0);
181    let sub = resp.get("sub_status").and_then(|s| s.as_u64()).unwrap_or(0);
182    if status == 400 && sub == 1002 {
183        return Ok(None);
184    }
185    anyhow::bail!("device authorization failed: {resp}");
186}
187
188pub async fn poll_device(
189    client: &reqwest::Client,
190    key: &ApiKey,
191    device_code: &str,
192) -> Result<Option<Session>> {
193    let resp: serde_json::Value = client
194        .post(TOKEN_URL)
195        .basic_auth(&key.client_id, Some(&key.client_secret))
196        .form(&[
197            ("client_id", key.client_id.as_str()),
198            ("device_code", device_code),
199            ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
200            ("scope", DEVICE_SCOPE),
201        ])
202        .send()
203        .await
204        .context("device poll request")?
205        .json()
206        .await
207        .context("device poll body")?;
208    device_poll_result(resp)
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    #[test]
216    fn device_poll_maps_pending_done_and_error() {
217        let pending = serde_json::json!({"status": 400, "sub_status": 1002});
218        assert!(device_poll_result(pending).unwrap().is_none());
219
220        let done = serde_json::json!({
221            "access_token": "a", "refresh_token": "r", "expires_in": 3600,
222            "user": {"userId": 7, "countryCode": "US"},
223        });
224        let sess = device_poll_result(done).unwrap().unwrap();
225        assert_eq!(sess.user_id, 7);
226
227        let err = serde_json::json!({"status": 400, "sub_status": 1005});
228        assert!(device_poll_result(err).is_err());
229    }
230}