char-slop/tidal-dl

tidal ripper

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

Charlotte Somreencode flacs via ffmpeg because tidal gives us weird onesf146f6e

main
12.5 KiB363 linesraw
1use anyhow::Result;
2use clap::{Parser, Subcommand};
3use tidal_dl::{api_key, download, model, pkce, settings, tidal};
4
5#[derive(Parser)]
6#[command(name = "tidal-dl", about = "Download Tidal media", arg_required_else_help = true)]
7struct Cli {
8    /// Invoked by the OS tidal:// handler; forwards the redirect and exits.
9    #[arg(long, value_name = "URL", hide = true)]
10    auth_callback: Option<String>,
11
12    #[command(subcommand)]
13    command: Option<Command>,
14}
15
16#[derive(Subcommand)]
17enum Command {
18    /// Log in via the browser (OAuth2 PKCE).
19    Login(LoginArgs),
20    /// Forget the stored token.
21    Logout,
22    /// Show key, token and config status.
23    Status(StatusArgs),
24    /// Register the tidal:// login handler on this machine.
25    Register,
26    /// Download tracks or albums as FLAC.
27    Dl(DlArgs),
28}
29
30#[derive(clap::Args)]
31struct LoginArgs {
32    /// Loopback port for the login callback (default: from config).
33    #[arg(long)]
34    port: Option<u16>,
35
36    /// Print the URL but don't open a browser.
37    #[arg(long)]
38    no_browser: bool,
39
40    /// Seconds to wait for the browser callback (default: 300).
41    #[arg(long, default_value_t = pkce::AUTH_TIMEOUT_SECS)]
42    timeout: u64,
43
44    /// Use the device-code flow instead of the browser (no tidal:// redirect;
45    /// works over SSH, but caps quality below Max).
46    #[arg(long)]
47    device: bool,
48}
49
50#[derive(clap::Args)]
51struct StatusArgs {
52    /// Hit the API to verify the token (otherwise only local expiry is checked).
53    #[arg(long)]
54    verify: bool,
55}
56
57#[derive(clap::Args)]
58struct DlArgs {
59    /// Tidal track/album URLs (or bare track IDs), separated by spaces.
60    #[arg(required = true)]
61    urls: Vec<String>,
62
63    /// Audio quality (default: max).
64    #[arg(long, value_enum, default_value_t = model::AudioQuality::Max)]
65    quality: model::AudioQuality,
66
67    /// Output directory (default: from config).
68    #[arg(long)]
69    output: Option<String>,
70
71    /// Keep TIDAL's flac bytes verbatim instead of re-encoding them through
72    /// ffmpeg (faster, but some output won't play in other apps).
73    #[arg(long)]
74    no_transcode: bool,
75}
76
77#[tokio::main]
78async fn main() -> Result<()> {
79    let cli = Cli::parse();
80
81    if let Some(url) = cli.auth_callback {
82        pkce::deliver_callback(&url).await;
83        return Ok(());
84    }
85
86    match cli.command {
87        Some(Command::Login(args)) => login(args).await,
88        Some(Command::Logout) => logout(),
89        Some(Command::Status(args)) => status(args).await,
90        Some(Command::Register) => register_handler(),
91        Some(Command::Dl(args)) => {
92            download::run(
93                args.urls,
94                download::Options {
95                    quality: args.quality,
96                    output: args.output,
97                    transcode: !args.no_transcode,
98                },
99            )
100            .await
101        }
102        None => Ok(()),
103    }
104}
105
106async fn login(args: LoginArgs) -> Result<()> {
107    if args.device {
108        return login_device().await;
109    }
110    let mut config = settings::load_settings();
111    let key = api_key::load_key();
112    if key.auth_method != api_key::AuthMethod::Pkce {
113        anyhow::bail!("configured client needs device flow (use `tidal-dl login --device`)");
114    }
115
116    pkce::register_scheme(true);
117
118    let session = pkce::new_session();
119    let redirect_uri = key.redirect_uri.clone();
120    let url = pkce::build_authorize_url(&key.client_id, &redirect_uri, &key.scope, &session);
121    let pending = tidal::PendingPkce::from_session(&session, &redirect_uri);
122
123    pkce::clear_authcode();
124    let requested = args.port.unwrap_or(config.auth_callback_port);
125    let requested = if requested == 0 {
126        pkce::DEFAULT_PORT
127    } else {
128        requested
129    };
130    let server = match pkce::CallbackServer::start(requested, Some(session.state.clone())).await
131    {
132        Ok(server) => Some(server),
133        Err(e) => {
134            eprintln!("warning: loopback port {requested} unavailable ({e:#}); falling back to manual paste");
135            None
136        }
137    };
138    if let Some(server) = &server {
139        config.auth_callback_port = server.port();
140    }
141
142    if is_remote_session() {
143        println!("Looks like you're on a remote machine. Easiest path:");
144        println!("  `tidal-dl login --device` — shows a link + code, no redirect to catch.");
145        println!("  (Device keys top out below Max quality. For Max, either tunnel or paste:)");
146        println!();
147        println!("Tunnel (automatic): reconnect with `ssh -R 8989:localhost:8989 <host>`,");
148        println!("then on your own computer build this repo and run `tidal-dl register` once.");
149        println!("The browser redirect will then find its way back here by itself.");
150        println!();
151        println!("Paste (manual):");
152        println!("  1. Copy this URL to the browser on your own computer:\n{url}\n");
153        println!("  2. Log in there. Your browser will then try to open a tidal:// link and fail —");
154        println!("     that's expected. Copy that tidal://login/auth?code=... URL:");
155        println!("     in most browsers you can grab it from the failed-navigation page, and in");
156        println!("     devtools' Network tab (with Preserve log on) it shows up as a request.");
157        println!("  3. Paste it below. Keep this terminal open until login completes.");
158        println!("Alternative: run `tidal-dl login` on your own computer, then copy");
159        println!("~/.tidal-dl.token.json and ~/.tidal-dl.json over here — tokens are portable.");
160    } else {
161        println!("Opening the Tidal login page in your browser...\n{url}\n");
162        if !args.no_browser {
163            if !pkce::open_browser(&url) {
164                println!("(no browser opened automatically — the link above works the same)");
165            }
166        }
167        println!(
168            "Waiting up to {} for authorization.",
169            human_duration(args.timeout)
170        );
171        println!("If the browser doesn't return on its own, paste the tidal:// redirect URL (or code) below.");
172    }
173
174    let expected = session.state.clone();
175    let code = match &server {
176        Some(server) => {
177            let paste = tokio::task::spawn_blocking(read_paste);
178            tokio::select! {
179                code = server.wait_for_code(args.timeout, Some(&expected)) => code,
180                pasted = paste => pasted.unwrap_or(None),
181            }
182        }
183        None => tokio::task::spawn_blocking(read_paste).await.unwrap_or(None),
184    };
185    pkce::clear_authcode();
186
187    let Some(code) = code else {
188        anyhow::bail!("no authorization received within {}", human_duration(args.timeout));
189    };
190    let (code, _) = pkce::parse_code(&code);
191    let Some(code) = code else {
192        anyhow::bail!("couldn't parse an authorization code from that input");
193    };
194
195    let http = reqwest::Client::new();
196    let sess = tidal::exchange_code(&http, &key, &pending, &code).await?;
197    println!("Logged in as user {} ({}).", sess.user_id, sess.country_code);
198
199    settings::save_token(&settings::Token {
200        userid: Some(sess.user_id),
201        country_code: Some(sess.country_code),
202        access_token: Some(sess.access_token),
203        refresh_token: sess.refresh_token,
204        expires_after: now() + sess.expires_in,
205    })?;
206    settings::save_settings(&config)?;
207    Ok(())
208}
209
210fn logout() -> Result<()> {
211    settings::clear_token()?;
212    println!("Logged out (token cleared).");
213    Ok(())
214}
215
216async fn status(args: StatusArgs) -> Result<()> {
217    let mut config = settings::load_settings();
218    let key = api_key::load_key();
219    let method = match key.auth_method {
220        api_key::AuthMethod::Pkce => "pkce",
221        api_key::AuthMethod::Device => "device",
222    };
223    println!("key:    {} [{method}] {}", key.name, key.client_id);
224    println!("output: {}", config.download_path);
225
226    let mut token = settings::load_token();
227    let Some(access) = token.access_token.clone() else {
228        println!("login:  not logged in (run `tidal-dl login`)");
229        return Ok(());
230    };
231    let remaining = token.expires_after - now();
232    if remaining > 0 {
233        println!("login:  user {:?} (token expires in {})", token.userid, human_duration(remaining as u64));
234    } else if token.expires_after > 0 {
235        println!("login:  user {:?} (token expired, refresh on next verify)", token.userid);
236    } else {
237        println!("login:  user {:?} (token present)", token.userid);
238    }
239
240    if args.verify {
241        let http = reqwest::Client::new();
242        if tidal::verify_access_token(&http, &access).await {
243            println!("verify: token is valid");
244        } else if let Some(refresh) = token.refresh_token.clone() {
245            println!("verify: token invalid, trying refresh...");
246            match tidal::refresh_token(&http, &key, &refresh).await {
247                Ok(sess) => {
248                    println!("verify: refresh ok (user {})", sess.user_id);
249                    token.userid = Some(sess.user_id);
250                    token.country_code = Some(sess.country_code);
251                    token.access_token = Some(sess.access_token);
252                    token.refresh_token = sess.refresh_token;
253                    token.expires_after = now() + sess.expires_in;
254                    settings::save_token(&token)?;
255                }
256                Err(e) => {
257                    eprintln!("verify: refresh failed ({e:#}); clearing token");
258                    settings::clear_token()?;
259                }
260            }
261        } else {
262            eprintln!("verify: token invalid and no refresh token stored");
263            settings::clear_token()?;
264        }
265    }
266
267    println!("config: {}", settings::profile_path().display());
268    let _ = &mut config;
269    Ok(())
270}
271
272async fn login_device() -> Result<()> {
273    let key = api_key::load_key();
274    anyhow::ensure!(
275        key.auth_method == api_key::AuthMethod::Device,
276        "configured client needs the browser flow — drop --device"
277    );
278    anyhow::ensure!(
279        !key.client_secret.is_empty(),
280        "device flow needs a secret (set TIDAL_CLIENT_SECRET)"
281    );
282
283    let http = reqwest::Client::new();
284    let dc = tidal::start_device_code(&http, &key).await?;
285    println!("1. Open this link on any computer:\nhttp://{}/{}\n", dc.verification_uri, dc.user_code);
286    println!("   (link stays valid for about {})", human_duration(dc.expires_in));
287    println!("2. Enter the code if asked, approve, then wait here.");
288
289    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(dc.expires_in);
290    loop {
291        if std::time::Instant::now() >= deadline {
292            anyhow::bail!("device login timed out");
293        }
294        match tidal::poll_device(&http, &key, &dc.device_code).await? {
295            Some(sess) => {
296                println!("Logged in as user {} ({}).", sess.user_id, sess.country_code);
297                settings::save_token(&settings::Token {
298                    userid: Some(sess.user_id),
299                    country_code: Some(sess.country_code),
300                    access_token: Some(sess.access_token),
301                    refresh_token: sess.refresh_token,
302                    expires_after: now() + sess.expires_in,
303                })?;
304                return Ok(());
305            }
306            None => tokio::time::sleep(std::time::Duration::from_secs(dc.interval + 1)).await,
307        }
308    }
309}
310
311fn register_handler() -> Result<()> {
312    if pkce::register_scheme(true) {
313        println!("Registered the tidal:// handler for this machine.");
314    } else {
315        println!("Could not register the tidal:// handler (see message above, if any).");
316    }
317    Ok(())
318}
319
320fn is_remote_session() -> bool {
321    if std::env::var("SSH_CONNECTION").is_ok()
322        || std::env::var("SSH_CLIENT").is_ok()
323        || std::env::var("SSH_TTY").is_ok()
324    {
325        return true;
326    }
327    #[cfg(target_os = "linux")]
328    if std::env::var("DISPLAY").is_err() && std::env::var("WAYLAND_DISPLAY").is_err() {
329        return true;
330    }
331    false
332}
333
334fn read_paste() -> Option<String> {
335    use std::io::BufRead;
336    let stdin = std::io::stdin();
337    let mut line = String::new();
338    if stdin.lock().read_line(&mut line).is_ok() {
339        pkce::parse_code(&line).0
340    } else {
341        None
342    }
343}
344
345fn human_duration(seconds: u64) -> String {
346    if seconds < 60 {
347        return format!("{seconds}s");
348    }
349    if seconds < 3600 {
350        return format!("{}m {:02}s", seconds / 60, seconds % 60);
351    }
352    if seconds < 86400 {
353        return format!("{}h {:02}m", seconds / 3600, (seconds % 3600) / 60);
354    }
355    format!("{}d {}h", seconds / 86400, (seconds % 86400) / 3600)
356}
357
358fn now() -> i64 {
359    std::time::SystemTime::now()
360        .duration_since(std::time::UNIX_EPOCH)
361        .map(|d| d.as_secs() as i64)
362        .unwrap_or(0)
363}