use anyhow::Result; use clap::{Parser, Subcommand}; use tidal_dl::{api_key, download, model, pkce, settings, tidal}; #[derive(Parser)] #[command(name = "tidal-dl", about = "Download Tidal media", arg_required_else_help = true)] struct Cli { /// Invoked by the OS tidal:// handler; forwards the redirect and exits. #[arg(long, value_name = "URL", hide = true)] auth_callback: Option, #[command(subcommand)] command: Option, } #[derive(Subcommand)] enum Command { /// Log in via the browser (OAuth2 PKCE). Login(LoginArgs), /// Forget the stored token. Logout, /// Show key, token and config status. Status(StatusArgs), /// Register the tidal:// login handler on this machine. Register, /// Download tracks or albums as FLAC. Dl(DlArgs), } #[derive(clap::Args)] struct LoginArgs { /// Loopback port for the login callback (default: from config). #[arg(long)] port: Option, /// Print the URL but don't open a browser. #[arg(long)] no_browser: bool, /// Seconds to wait for the browser callback (default: 300). #[arg(long, default_value_t = pkce::AUTH_TIMEOUT_SECS)] timeout: u64, /// Use the device-code flow instead of the browser (no tidal:// redirect; /// works over SSH, but caps quality below Max). #[arg(long)] device: bool, } #[derive(clap::Args)] struct StatusArgs { /// Hit the API to verify the token (otherwise only local expiry is checked). #[arg(long)] verify: bool, } #[derive(clap::Args)] struct DlArgs { /// Tidal track/album URLs (or bare track IDs), separated by spaces. #[arg(required = true)] urls: Vec, /// Audio quality (default: max). #[arg(long, value_enum, default_value_t = model::AudioQuality::Max)] quality: model::AudioQuality, /// Output directory (default: from config). #[arg(long)] output: Option, /// Keep TIDAL's flac bytes verbatim instead of re-encoding them through /// ffmpeg (faster, but some output won't play in other apps). #[arg(long)] no_transcode: bool, } #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); if let Some(url) = cli.auth_callback { pkce::deliver_callback(&url).await; return Ok(()); } match cli.command { Some(Command::Login(args)) => login(args).await, Some(Command::Logout) => logout(), Some(Command::Status(args)) => status(args).await, Some(Command::Register) => register_handler(), Some(Command::Dl(args)) => { download::run( args.urls, download::Options { quality: args.quality, output: args.output, transcode: !args.no_transcode, }, ) .await } None => Ok(()), } } async fn login(args: LoginArgs) -> Result<()> { if args.device { return login_device().await; } let mut config = settings::load_settings(); let key = api_key::load_key(); if key.auth_method != api_key::AuthMethod::Pkce { anyhow::bail!("configured client needs device flow (use `tidal-dl login --device`)"); } pkce::register_scheme(true); let session = pkce::new_session(); let redirect_uri = key.redirect_uri.clone(); let url = pkce::build_authorize_url(&key.client_id, &redirect_uri, &key.scope, &session); let pending = tidal::PendingPkce::from_session(&session, &redirect_uri); pkce::clear_authcode(); let requested = args.port.unwrap_or(config.auth_callback_port); let requested = if requested == 0 { pkce::DEFAULT_PORT } else { requested }; let server = match pkce::CallbackServer::start(requested, Some(session.state.clone())).await { Ok(server) => Some(server), Err(e) => { eprintln!("warning: loopback port {requested} unavailable ({e:#}); falling back to manual paste"); None } }; if let Some(server) = &server { config.auth_callback_port = server.port(); } if is_remote_session() { println!("Looks like you're on a remote machine. Easiest path:"); println!(" `tidal-dl login --device` — shows a link + code, no redirect to catch."); println!(" (Device keys top out below Max quality. For Max, either tunnel or paste:)"); println!(); println!("Tunnel (automatic): reconnect with `ssh -R 8989:localhost:8989 `,"); println!("then on your own computer build this repo and run `tidal-dl register` once."); println!("The browser redirect will then find its way back here by itself."); println!(); println!("Paste (manual):"); println!(" 1. Copy this URL to the browser on your own computer:\n{url}\n"); println!(" 2. Log in there. Your browser will then try to open a tidal:// link and fail —"); println!(" that's expected. Copy that tidal://login/auth?code=... URL:"); println!(" in most browsers you can grab it from the failed-navigation page, and in"); println!(" devtools' Network tab (with Preserve log on) it shows up as a request."); println!(" 3. Paste it below. Keep this terminal open until login completes."); println!("Alternative: run `tidal-dl login` on your own computer, then copy"); println!("~/.tidal-dl.token.json and ~/.tidal-dl.json over here — tokens are portable."); } else { println!("Opening the Tidal login page in your browser...\n{url}\n"); if !args.no_browser { if !pkce::open_browser(&url) { println!("(no browser opened automatically — the link above works the same)"); } } println!( "Waiting up to {} for authorization.", human_duration(args.timeout) ); println!("If the browser doesn't return on its own, paste the tidal:// redirect URL (or code) below."); } let expected = session.state.clone(); let code = match &server { Some(server) => { let paste = tokio::task::spawn_blocking(read_paste); tokio::select! { code = server.wait_for_code(args.timeout, Some(&expected)) => code, pasted = paste => pasted.unwrap_or(None), } } None => tokio::task::spawn_blocking(read_paste).await.unwrap_or(None), }; pkce::clear_authcode(); let Some(code) = code else { anyhow::bail!("no authorization received within {}", human_duration(args.timeout)); }; let (code, _) = pkce::parse_code(&code); let Some(code) = code else { anyhow::bail!("couldn't parse an authorization code from that input"); }; let http = reqwest::Client::new(); let sess = tidal::exchange_code(&http, &key, &pending, &code).await?; println!("Logged in as user {} ({}).", sess.user_id, sess.country_code); settings::save_token(&settings::Token { userid: Some(sess.user_id), country_code: Some(sess.country_code), access_token: Some(sess.access_token), refresh_token: sess.refresh_token, expires_after: now() + sess.expires_in, })?; settings::save_settings(&config)?; Ok(()) } fn logout() -> Result<()> { settings::clear_token()?; println!("Logged out (token cleared)."); Ok(()) } async fn status(args: StatusArgs) -> Result<()> { let mut config = settings::load_settings(); let key = api_key::load_key(); let method = match key.auth_method { api_key::AuthMethod::Pkce => "pkce", api_key::AuthMethod::Device => "device", }; println!("key: {} [{method}] {}", key.name, key.client_id); println!("output: {}", config.download_path); let mut token = settings::load_token(); let Some(access) = token.access_token.clone() else { println!("login: not logged in (run `tidal-dl login`)"); return Ok(()); }; let remaining = token.expires_after - now(); if remaining > 0 { println!("login: user {:?} (token expires in {})", token.userid, human_duration(remaining as u64)); } else if token.expires_after > 0 { println!("login: user {:?} (token expired, refresh on next verify)", token.userid); } else { println!("login: user {:?} (token present)", token.userid); } if args.verify { let http = reqwest::Client::new(); if tidal::verify_access_token(&http, &access).await { println!("verify: token is valid"); } else if let Some(refresh) = token.refresh_token.clone() { println!("verify: token invalid, trying refresh..."); match tidal::refresh_token(&http, &key, &refresh).await { Ok(sess) => { println!("verify: refresh ok (user {})", sess.user_id); token.userid = Some(sess.user_id); token.country_code = Some(sess.country_code); token.access_token = Some(sess.access_token); token.refresh_token = sess.refresh_token; token.expires_after = now() + sess.expires_in; settings::save_token(&token)?; } Err(e) => { eprintln!("verify: refresh failed ({e:#}); clearing token"); settings::clear_token()?; } } } else { eprintln!("verify: token invalid and no refresh token stored"); settings::clear_token()?; } } println!("config: {}", settings::profile_path().display()); let _ = &mut config; Ok(()) } async fn login_device() -> Result<()> { let key = api_key::load_key(); anyhow::ensure!( key.auth_method == api_key::AuthMethod::Device, "configured client needs the browser flow — drop --device" ); anyhow::ensure!( !key.client_secret.is_empty(), "device flow needs a secret (set TIDAL_CLIENT_SECRET)" ); let http = reqwest::Client::new(); let dc = tidal::start_device_code(&http, &key).await?; println!("1. Open this link on any computer:\nhttp://{}/{}\n", dc.verification_uri, dc.user_code); println!(" (link stays valid for about {})", human_duration(dc.expires_in)); println!("2. Enter the code if asked, approve, then wait here."); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(dc.expires_in); loop { if std::time::Instant::now() >= deadline { anyhow::bail!("device login timed out"); } match tidal::poll_device(&http, &key, &dc.device_code).await? { Some(sess) => { println!("Logged in as user {} ({}).", sess.user_id, sess.country_code); settings::save_token(&settings::Token { userid: Some(sess.user_id), country_code: Some(sess.country_code), access_token: Some(sess.access_token), refresh_token: sess.refresh_token, expires_after: now() + sess.expires_in, })?; return Ok(()); } None => tokio::time::sleep(std::time::Duration::from_secs(dc.interval + 1)).await, } } } fn register_handler() -> Result<()> { if pkce::register_scheme(true) { println!("Registered the tidal:// handler for this machine."); } else { println!("Could not register the tidal:// handler (see message above, if any)."); } Ok(()) } fn is_remote_session() -> bool { if std::env::var("SSH_CONNECTION").is_ok() || std::env::var("SSH_CLIENT").is_ok() || std::env::var("SSH_TTY").is_ok() { return true; } #[cfg(target_os = "linux")] if std::env::var("DISPLAY").is_err() && std::env::var("WAYLAND_DISPLAY").is_err() { return true; } false } fn read_paste() -> Option { use std::io::BufRead; let stdin = std::io::stdin(); let mut line = String::new(); if stdin.lock().read_line(&mut line).is_ok() { pkce::parse_code(&line).0 } else { None } } fn human_duration(seconds: u64) -> String { if seconds < 60 { return format!("{seconds}s"); } if seconds < 3600 { return format!("{}m {:02}s", seconds / 60, seconds % 60); } if seconds < 86400 { return format!("{}h {:02}m", seconds / 3600, (seconds % 3600) / 60); } format!("{}d {}h", seconds / 86400, (seconds % 86400) / 3600) } fn now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) }