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