char-slop/tidal-dl

tidal ripper

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

Charlotte Somsimplify3841d9b

main
14.4 KiB456 linesraw
1use anyhow::{Context, Result};
2use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
3use rand::RngCore;
4use sha2::{Digest, Sha256};
5use std::path::PathBuf;
6use std::sync::Arc;
7use tokio::io::{AsyncReadExt, AsyncWriteExt};
8use tokio::sync::{Mutex, Notify};
9
10pub const SCHEME: &str = "tidal";
11pub const DEFAULT_PORT: u16 = 8989;
12pub const AUTH_TIMEOUT_SECS: u64 = 300;
13
14#[derive(Debug, Clone)]
15pub struct PkceSession {
16    pub verifier: String,
17    pub challenge: String,
18    pub state: String,
19    pub client_unique_key: String,
20}
21
22pub fn new_session() -> PkceSession {
23    let verifier = new_verifier();
24    PkceSession {
25        challenge: challenge_for(&verifier),
26        verifier,
27        state: new_state(),
28        client_unique_key: uuid::Uuid::new_v4().simple().to_string(),
29    }
30}
31
32fn new_verifier() -> String {
33    let mut bytes = [0u8; 64];
34    rand::thread_rng().fill_bytes(&mut bytes);
35    URL_SAFE_NO_PAD.encode(bytes)
36}
37
38fn challenge_for(verifier: &str) -> String {
39    let digest = Sha256::digest(verifier.as_bytes());
40    URL_SAFE_NO_PAD.encode(digest)
41}
42
43fn new_state() -> String {
44    let mut bytes = [0u8; 16];
45    rand::thread_rng().fill_bytes(&mut bytes);
46    format!("TIDAL_{}", hex_encode(bytes))
47}
48
49pub fn build_authorize_url(
50    client_id: &str,
51    redirect_uri: &str,
52    scope: &str,
53    session: &PkceSession,
54) -> String {
55    let scope_param = scope.replace(' ', "+");
56    let mut query = url::form_urlencoded::Serializer::new(String::new());
57    query.append_pair("appMode", "DESKTOP");
58    query.append_pair("client_id", client_id);
59    query.append_pair("client_unique_key", &session.client_unique_key);
60    query.append_pair("code_challenge", &session.challenge);
61    query.append_pair("code_challenge_method", "S256");
62    query.append_pair("lang", "en");
63    query.append_pair("redirect_uri", redirect_uri);
64    query.append_pair("response_type", "code");
65    query.append_pair("restrictSignup", "true");
66    query.append_pair("state", &session.state);
67    format!(
68        "https://login.tidal.com/authorize?{}&scope={}",
69        query.finish(),
70        scope_param
71    )
72}
73
74/// Extract (code, state) from a redirect URL, a query string, or a bare code.
75pub fn parse_code(text: &str) -> (Option<String>, Option<String>) {
76    let text = text.trim().trim_matches('"').trim_matches('\'');
77    if text.is_empty() {
78        return (None, None);
79    }
80    if text.contains("code=") {
81        let query = match url::Url::parse(text) {
82            Ok(url) => url.query().unwrap_or("").to_string(),
83            Err(_) => {
84                if let Some((_, q)) = text.split_once('?') {
85                    q.to_string()
86                } else {
87                    text.to_string()
88                }
89            }
90        };
91        let mut code = None;
92        let mut state = None;
93        for (k, v) in url::form_urlencoded::parse(query.as_bytes()) {
94            match k.as_ref() {
95                "code" => code = Some(v.into_owned()),
96                "state" => state = Some(v.into_owned()),
97                _ => {}
98            }
99        }
100        (code, state)
101    } else {
102        (Some(text.to_string()), None)
103    }
104}
105
106fn hex_encode(bytes: [u8; 16]) -> String {
107    bytes.iter().map(|b| format!("{b:02x}")).collect()
108}
109
110pub fn home_dir() -> PathBuf {
111    dirs::home_dir().unwrap_or_else(|| ".".into())
112}
113
114fn callback_info_path() -> PathBuf {
115    home_dir().join(".tidal-dl.authcb.json")
116}
117
118fn scheme_marker_path() -> PathBuf {
119    home_dir().join(".tidal-dl.scheme")
120}
121
122fn authcode_path() -> PathBuf {
123    home_dir().join(".tidal-dl.authcode")
124}
125
126pub fn clear_authcode() {
127    let _ = std::fs::remove_file(authcode_path());
128}
129
130fn write_authcode(code: &str, state: Option<&str>) -> bool {
131    let value = serde_json::json!({"code": code, "state": state});
132    std::fs::write(authcode_path(), value.to_string()).is_ok()
133}
134
135pub fn read_authcode(expected_state: Option<&str>) -> Option<String> {
136    let text = std::fs::read_to_string(authcode_path()).ok()?;
137    let value: serde_json::Value = serde_json::from_str(&text).ok()?;
138    let code = value.get("code")?.as_str()?;
139    if code.is_empty() {
140        return None;
141    }
142    if let Some(expected) = expected_state {
143        match value.get("state").and_then(|s| s.as_str()) {
144            Some(state) if state == expected => Some(code.to_string()),
145            _ => None,
146        }
147    } else {
148        Some(code.to_string())
149    }
150}
151
152#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
153struct CallbackInfo {
154    port: u16,
155    state: Option<String>,
156}
157
158fn write_callback_info(port: u16, state: Option<&str>) {
159    let info = CallbackInfo {
160        port,
161        state: state.map(str::to_string),
162    };
163    let _ = std::fs::write(
164        callback_info_path(),
165        serde_json::to_string(&info).unwrap(),
166    );
167}
168
169fn read_callback_info() -> Option<CallbackInfo> {
170    let text = std::fs::read_to_string(callback_info_path()).ok()?;
171    serde_json::from_str(&text).ok()
172}
173
174fn clear_callback_info() {
175    let _ = std::fs::remove_file(callback_info_path());
176}
177
178pub struct CallbackServer {
179    port: u16,
180    code: Arc<Mutex<Option<String>>>,
181    notify: Arc<Notify>,
182    _handle: tokio::task::JoinHandle<()>,
183}
184
185impl CallbackServer {
186    pub async fn start(port: u16, expected_state: Option<String>) -> Result<Self> {
187        let listener = tokio::net::TcpListener::bind(("127.0.0.1", port))
188            .await
189            .with_context(|| format!("bind loopback port {port}"))?;
190        let bound_port = listener.local_addr().map(|a| a.port()).unwrap_or(port);
191        let code = Arc::new(Mutex::new(None::<String>));
192        let notify = Arc::new(Notify::new());
193        let expected = expected_state.clone();
194
195        let accept_code = code.clone();
196        let accept_notify = notify.clone();
197        let handle = tokio::spawn(async move {
198            loop {
199                let Ok((mut socket, _)) = listener.accept().await else {
200                    continue;
201                };
202                let code = accept_code.clone();
203                let notify = accept_notify.clone();
204                let expected = expected.clone();
205                tokio::spawn(async move {
206                    serve_one(&mut socket, &code, &notify, expected.as_deref()).await;
207                });
208            }
209        });
210
211        write_callback_info(bound_port, expected_state.as_deref());
212        Ok(Self {
213            port: bound_port,
214            code,
215            notify,
216            _handle: handle,
217        })
218    }
219
220    pub async fn wait_for_code(
221        &self,
222        timeout_secs: u64,
223        expected_state: Option<&str>,
224    ) -> Option<String> {
225        let deadline =
226            tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs);
227        loop {
228            if let Some(code) = self.code.lock().await.clone() {
229                return Some(code);
230            }
231            if let Some(code) = read_authcode(expected_state) {
232                return Some(code);
233            }
234            if tokio::time::Instant::now() >= deadline {
235                return self.code.lock().await.clone();
236            }
237            let remaining = deadline - tokio::time::Instant::now();
238            let tick = std::time::Duration::from_millis(300).min(remaining.into());
239            let _ = tokio::time::timeout(tick.into(), self.notify.notified()).await;
240        }
241    }
242
243    pub fn port(&self) -> u16 {
244        self.port
245    }
246}
247
248impl Drop for CallbackServer {
249    fn drop(&mut self) {
250        self._handle.abort();
251        clear_callback_info();
252    }
253}
254
255async fn serve_one(
256    socket: &mut tokio::net::TcpStream,
257    code: &Arc<Mutex<Option<String>>>,
258    notify: &Arc<Notify>,
259    expected_state: Option<&str>,
260) {
261    let mut buf = vec![0u8; 8192];
262    let Ok(n) = socket.read(&mut buf).await else {
263        return;
264    };
265    let request = String::from_utf8_lossy(&buf[..n]);
266    let path = request
267        .lines()
268        .next()
269        .and_then(|line| line.split_whitespace().nth(1))
270        .unwrap_or("/");
271    let query = path.split_once('?').map(|(_, q)| q).unwrap_or("");
272    let mut got_code = None;
273    let mut got_state = None;
274    for (k, v) in url::form_urlencoded::parse(query.as_bytes()) {
275        match k.as_ref() {
276            "code" => got_code = Some(v.into_owned()),
277            "state" => got_state = Some(v.into_owned()),
278            _ => {}
279        }
280    }
281    let ok = match (&got_code, expected_state) {
282        (Some(_), None) => true,
283        (Some(_), Some(expected)) => got_state.as_deref() == Some(expected),
284        _ => false,
285    };
286    if ok {
287        *code.lock().await = got_code;
288        notify.notify_waiters();
289        respond(socket, 200, "TIDAL login complete. You may close this tab.").await;
290    } else {
291        respond(socket, 400, "Login callback missing or invalid.").await;
292    }
293}
294
295async fn respond(socket: &mut tokio::net::TcpStream, status: u16, message: &str) {
296    let reason = if status == 200 { "OK" } else { "Bad Request" };
297    let body = format!("<html><body><h2>{message}</h2></body></html>");
298    let response = format!(
299        "HTTP/1.1 {status} {reason}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
300        body.len()
301    );
302    let _ = socket.write_all(response.as_bytes()).await;
303}
304
305/// Forward a captured redirect URL/code to the waiting login session.
306pub async fn deliver_callback(url: &str) -> bool {
307    let (code, state) = parse_code(url);
308    let Some(code) = code else { return false };
309    write_authcode(&code, state.as_deref());
310    let port = std::env::var("TIDAL_CALLBACK_PORT")
311        .ok()
312        .and_then(|s| s.parse().ok())
313        .or_else(|| read_callback_info().map(|info| info.port))
314        .unwrap_or(DEFAULT_PORT);
315    let mut target = format!("http://127.0.0.1:{port}/callback?code={}", url_encode(&code));
316    if let Some(state) = state {
317        target.push_str(&format!("&state={}", url_encode(&state)));
318    }
319    let _ = reqwest::get(&target).await;
320    true
321}
322
323fn url_encode(s: &str) -> String {
324    url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
325}
326
327pub fn open_browser(url: &str) -> bool {
328    if is_termux() {
329        if let Ok(opener) = which("termux-open-url") {
330            return std::process::Command::new(opener)
331                .arg(url)
332                .spawn()
333                .is_ok();
334        }
335        return false;
336    }
337    open::that(url).is_ok()
338}
339
340fn is_termux() -> bool {
341    std::env::var("PREFIX").map(|p| p.contains("com.termux")).unwrap_or(false)
342        || std::env::var("ANDROID_ROOT").is_ok()
343        || which("termux-open-url").is_ok()
344}
345
346fn which(name: &str) -> Result<String, ()> {
347    let paths = std::env::var("PATH").map_err(|_| ())?;
348    for dir in std::env::split_paths(&paths) {
349        let candidate = dir.join(name);
350        if candidate.is_file() {
351            return Ok(candidate.to_string_lossy().into_owned());
352        }
353    }
354    Err(())
355}
356
357pub fn register_scheme(force: bool) -> bool {
358    let marker = scheme_marker_path();
359    if !force && marker.exists() {
360        return true;
361    }
362    let ok = do_register().unwrap_or(false);
363    let _ = std::fs::write(&marker, format!("{}\n", std::env::consts::OS));
364    ok
365}
366
367#[cfg(target_os = "linux")]
368fn do_register() -> Result<bool> {
369    use std::process::Command;
370
371    let apps_dir = home_dir().join(".local/share/applications");
372    std::fs::create_dir_all(&apps_dir)?;
373    let exe = std::env::current_exe()?.to_string_lossy().into_owned();
374    let exec_line = format!("\"{exe}\" --auth-callback \"%u\"");
375    let content = format!(
376        "[Desktop Entry]\nName=tidal-dl\nComment=TIDAL login callback handler\nExec={exec_line}\nType=Application\nTerminal=false\nNoDisplay=true\nMimeType=x-scheme-handler/{SCHEME};\n"
377    );
378    std::fs::write(apps_dir.join("tidal-dl.desktop"), content)?;
379    let _ = Command::new("xdg-mime")
380        .args(["default", "tidal-dl.desktop", "x-scheme-handler/tidal"])
381        .status();
382    let _ = Command::new("update-desktop-database")
383        .arg(&apps_dir)
384        .status();
385    Ok(true)
386}
387
388#[cfg(target_os = "macos")]
389fn do_register() -> Result<bool> {
390    use std::os::unix::fs::PermissionsExt;
391    use std::process::Command;
392
393    const INFO_PLIST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
394<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
395<plist version="1.0"><dict>
396  <key>CFBundleIdentifier</key><string>com.tidal-dl.scheme</string>
397  <key>CFBundleName</key><string>tidal-dl-scheme</string>
398  <key>CFBundleExecutable</key><string>run</string>
399  <key>CFBundleURLTypes</key><array><dict>
400    <key>CFBundleURLName</key><string>com.tidal-dl.scheme</string>
401    <key>CFBundleURLSchemes</key><array><string>tidal</string></array>
402  </dict></array>
403</dict></plist>
404"#;
405    let exe = std::env::current_exe()?.to_string_lossy().into_owned();
406    let app = home_dir().join("Applications/tidal-dl-scheme.app");
407    let macos_dir = app.join("Contents/MacOS");
408    std::fs::create_dir_all(&macos_dir)?;
409    std::fs::write(app.join("Contents/Info.plist"), INFO_PLIST)?;
410    let run = macos_dir.join("run");
411    std::fs::write(&run, format!("#!/bin/sh\n\"{exe}\" --auth-callback \"$1\"\n"))?;
412    std::fs::set_permissions(&run, std::fs::Permissions::from_mode(0o755))?;
413    const LSREGISTER: &str = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister";
414    if std::path::Path::new(LSREGISTER).exists() {
415        let _ = Command::new(LSREGISTER).args(["-R", "-f"]).arg(&app).status();
416    }
417    Ok(true)
418}
419
420#[cfg(target_os = "windows")]
421fn do_register() -> Result<bool> {
422    Ok(false)
423}
424
425#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
426fn do_register() -> Result<bool> {
427    Ok(false)
428}
429
430#[cfg(test)]
431mod tests {
432    use super::*;
433
434    #[test]
435    fn parse_code_handles_redirect_and_bare_code() {
436        let (code, state) = parse_code("tidal://login/auth?code=abc&state=TIDAL_xyz");
437        assert_eq!(code.as_deref(), Some("abc"));
438        assert_eq!(state.as_deref(), Some("TIDAL_xyz"));
439
440        let (code, _) = parse_code("abc123");
441        assert_eq!(code.as_deref(), Some("abc123"));
442
443        let (code, _) = parse_code("");
444        assert!(code.is_none());
445    }
446
447    #[test]
448    fn authorize_url_carries_pkce_params() {
449        let session = new_session();
450        let url = build_authorize_url("cid", "tidal://login/auth", "r_usr w_usr", &session);
451        assert!(url.starts_with("https://login.tidal.com/authorize?"));
452        assert!(url.contains("code_challenge="));
453        assert!(url.contains("code_challenge_method=S256"));
454        assert!(url.contains("scope=r_usr+w_usr"));
455    }
456}