char-slop/tidal-dl
tidal ripper
git clone https://git.t4t.associates/char-slop/tidal-dl
3841d9b
main
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 { 16pub verifier : String , 17pub challenge : String , 18pub state : String , 19pub client_unique_key : String , 20} 21 22pub fn new_session () ->PkceSession { 23let verifier =new_verifier (); 24PkceSession { 25challenge : challenge_for ( & verifier), 26 verifier, 27state : new_state (), 28client_unique_key : uuid:: Uuid :: new_v4 (). simple (). to_string (), 29} 30} 31 32fn new_verifier () ->String { 33let mut bytes =[ 0u8 ; 64 ]; 34 rand:: thread_rng (). fill_bytes ( & mut bytes); 35URL_SAFE_NO_PAD . encode ( bytes) 36} 37 38fn challenge_for ( verifier : & str ) ->String { 39let digest =Sha256 :: digest ( verifier. as_bytes ()); 40URL_SAFE_NO_PAD . encode ( digest) 41} 42 43fn new_state () ->String { 44let mut bytes =[ 0u8 ; 16 ]; 45 rand:: thread_rng (). fill_bytes ( & mut bytes); 46format! ( "TIDAL_{}" , hex_encode ( bytes)) 47} 48 49pub fn build_authorize_url ( 50client_id : & str , 51redirect_uri : & str , 52scope : & str , 53session : & PkceSession , 54) ->String { 55let scope_param = scope. replace ( ' ' , "+" ); 56let 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 ); 67format! ( 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 >) { 76let text = text. trim (). trim_matches ( '"' ). trim_matches ( '\'' ); 77if text. is_empty () { 78return ( None , None ); 79} 80if text. contains ( "code=" ) { 81let query =match url:: Url :: parse ( text) { 82Ok ( url) => url. query (). unwrap_or ( "" ). to_string (), 83Err ( _) =>{ 84if let Some (( _, q)) = text. split_once ( '?' ) { 85 q. to_string () 86} else { 87 text. to_string () 88} 89} 90}; 91let mut code =None ; 92let mut state =None ; 93for ( k, v) in url:: form_urlencoded:: parse ( query. as_bytes ()) { 94match 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 { 115home_dir (). join ( ".tidal-dl.authcb.json" ) 116} 117 118fn scheme_marker_path () ->PathBuf { 119home_dir (). join ( ".tidal-dl.scheme" ) 120} 121 122fn authcode_path () ->PathBuf { 123home_dir (). join ( ".tidal-dl.authcode" ) 124} 125 126pub fn clear_authcode () { 127let _ = std:: fs:: remove_file ( authcode_path ()); 128} 129 130fn write_authcode ( code : & str , state : Option < & str >) ->bool { 131let 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 > { 136let text = std:: fs:: read_to_string ( authcode_path ()). ok () ?; 137let value: serde_json:: Value = serde_json:: from_str ( & text). ok () ?; 138let code = value. get ( "code" ) ?. as_str () ?; 139if code. is_empty () { 140return None ; 141} 142if let Some ( expected) = expected_state{ 143match value. get ( "state" ). and_then ( |s| s. as_str ()) { 144Some ( state) if state == expected =>Some ( code. to_string ()), 145 _ =>None , 146} 147} else { 148Some ( code. to_string ()) 149} 150} 151 152# [ derive ( Debug , Clone , serde :: Serialize , serde :: Deserialize )] 153struct CallbackInfo { 154port : u16 , 155state : Option < String >, 156} 157 158fn write_callback_info ( port : u16 , state : Option < & str >) { 159let info =CallbackInfo { 160 port, 161state : state. map ( str:: to_string), 162}; 163let _ = std:: fs:: write ( 164callback_info_path (), 165 serde_json:: to_string ( & info). unwrap (), 166); 167} 168 169fn read_callback_info () ->Option < CallbackInfo > { 170let text = std:: fs:: read_to_string ( callback_info_path ()). ok () ?; 171 serde_json:: from_str ( & text). ok () 172} 173 174fn clear_callback_info () { 175let _ = std:: fs:: remove_file ( callback_info_path ()); 176} 177 178pub struct CallbackServer { 179port : u16 , 180code : Arc < Mutex < Option < String >>>, 181notify : Arc < Notify >, 182_handle : tokio:: task:: JoinHandle <()>, 183} 184 185impl CallbackServer { 186pub async fn start ( port : u16 , expected_state : Option < String >) ->Result < Self > { 187let listener = tokio:: net:: TcpListener :: bind (( "127.0.0.1" , port)) 188. await 189. with_context ( ||format! ( "bind loopback port {port}" )) ?; 190let bound_port = listener. local_addr (). map ( |a| a. port ()). unwrap_or ( port); 191let code =Arc :: new ( Mutex :: new ( None ::< String >)); 192let notify =Arc :: new ( Notify :: new ()); 193let expected = expected_state. clone (); 194 195let accept_code = code. clone (); 196let accept_notify = notify. clone (); 197let handle = tokio:: spawn ( async move { 198loop { 199let Ok (( mut socket, _)) = listener. accept (). await else { 200continue ; 201}; 202let code = accept_code. clone (); 203let notify = accept_notify. clone (); 204let expected = expected. clone (); 205 tokio:: spawn ( async move { 206serve_one ( & mut socket, & code, & notify, expected. as_deref ()). await ; 207}); 208} 209}); 210 211write_callback_info ( bound_port, expected_state. as_deref ()); 212Ok ( Self { 213port : bound_port, 214 code, 215 notify, 216_handle : handle, 217}) 218} 219 220pub async fn wait_for_code ( 221& self , 222timeout_secs : u64 , 223expected_state : Option < & str >, 224) ->Option < String > { 225let deadline = 226 tokio:: time:: Instant :: now () + tokio:: time:: Duration :: from_secs ( timeout_secs); 227loop { 228if let Some ( code) =self . code . lock (). await . clone () { 229return Some ( code); 230} 231if let Some ( code) =read_authcode ( expected_state) { 232return Some ( code); 233} 234if tokio:: time:: Instant :: now () >= deadline{ 235return self . code . lock (). await . clone (); 236} 237let remaining = deadline - tokio:: time:: Instant :: now (); 238let tick = std:: time:: Duration :: from_millis ( 300 ). min ( remaining. into ()); 239let _ = tokio:: time:: timeout ( tick. into (), self . notify . notified ()). await ; 240} 241} 242 243pub fn port ( & self ) ->u16 { 244self . port 245} 246} 247 248impl Drop for CallbackServer { 249fn drop ( & mut self ) { 250self . _handle . abort (); 251clear_callback_info (); 252} 253} 254 255async fn serve_one ( 256socket : & mut tokio:: net:: TcpStream , 257code : & Arc < Mutex < Option < String >>>, 258notify : & Arc < Notify >, 259expected_state : Option < & str >, 260) { 261let mut buf =vec! [ 0u8 ; 8192 ]; 262let Ok ( n) = socket. read ( & mut buf). await else { 263return ; 264}; 265let request =String :: from_utf8_lossy ( & buf[ ..n]); 266let path = request 267. lines () 268. next () 269. and_then ( |line| line. split_whitespace (). nth ( 1 )) 270. unwrap_or ( "/" ); 271let query = path. split_once ( '?' ). map ( |( _, q) | q). unwrap_or ( "" ); 272let mut got_code =None ; 273let mut got_state =None ; 274for ( k, v) in url:: form_urlencoded:: parse ( query. as_bytes ()) { 275match k. as_ref () { 276"code" => got_code =Some ( v. into_owned ()), 277"state" => got_state =Some ( v. into_owned ()), 278 _ =>{} 279} 280} 281let ok =match ( & got_code, expected_state) { 282( Some ( _), None ) =>true , 283( Some ( _), Some ( expected)) => got_state. as_deref () ==Some ( expected), 284 _ =>false , 285}; 286if ok{ 287* code. lock (). await = got_code; 288 notify. notify_waiters (); 289respond ( socket, 200 , "TIDAL login complete. You may close this tab." ). await ; 290} else { 291respond ( socket, 400 , "Login callback missing or invalid." ). await ; 292} 293} 294 295async fn respond ( socket : & mut tokio:: net:: TcpStream , status : u16 , message : & str ) { 296let reason =if status ==200 { "OK" } else { "Bad Request" }; 297let body =format! ( "<html><body><h2>{message}</h2></body></html>" ); 298let 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); 302let _ = 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 { 307let ( code, state) =parse_code ( url); 308let Some ( code) = codeelse { return false }; 309write_authcode ( & code, state. as_deref ()); 310let 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 ); 315let mut target =format! ( "http://127.0.0.1:{port}/callback?code={}" , url_encode ( & code)); 316if let Some ( state) = state{ 317 target. push_str ( & format! ( "&state={}" , url_encode ( & state))); 318} 319let _ = reqwest:: get ( & target). await ; 320true 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 { 328if is_termux () { 329if let Ok ( opener) =which ( "termux-open-url" ) { 330return std:: process:: Command :: new ( opener) 331. arg ( url) 332. spawn () 333. is_ok (); 334} 335return 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 , ()> { 347let paths = std:: env:: var ( "PATH" ). map_err ( |_|()) ?; 348for dirin std:: env:: split_paths ( & paths) { 349let candidate = dir. join ( name); 350if candidate. is_file () { 351return Ok ( candidate. to_string_lossy (). into_owned ()); 352} 353} 354Err (()) 355} 356 357pub fn register_scheme ( force : bool ) ->bool { 358let marker =scheme_marker_path (); 359if !force && marker. exists () { 360return true ; 361} 362let ok =do_register (). unwrap_or ( false ); 363let _ = std:: fs:: write ( & marker, format! ( "{}\n" , std:: env:: consts:: OS )); 364 ok 365} 366 367# [ cfg ( target_os = "linux" )] 368fn do_register () ->Result < bool > { 369use std:: process:: Command ; 370 371let apps_dir =home_dir (). join ( ".local/share/applications" ); 372 std:: fs:: create_dir_all ( & apps_dir) ?; 373let exe = std:: env:: current_exe () ?. to_string_lossy (). into_owned (); 374let exec_line =format! ( "\"{exe}\" --auth-callback \"%u\"" ); 375let 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) ?; 379let _ =Command :: new ( "xdg-mime" ) 380. args ([ "default" , "tidal-dl.desktop" , "x-scheme-handler/tidal" ]) 381. status (); 382let _ =Command :: new ( "update-desktop-database" ) 383. arg ( & apps_dir) 384. status (); 385Ok ( true ) 386} 387 388# [ cfg ( target_os = "macos" )] 389fn do_register () ->Result < bool > { 390use std:: os:: unix:: fs:: PermissionsExt ; 391use std:: process:: Command ; 392 393const 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"# ; 405let exe = std:: env:: current_exe () ?. to_string_lossy (). into_owned (); 406let app =home_dir (). join ( "Applications/tidal-dl-scheme.app" ); 407let 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 ) ?; 410let 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 )) ?; 413const LSREGISTER : & str ="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" ; 414if std:: path:: Path :: new ( LSREGISTER ). exists () { 415let _ =Command :: new ( LSREGISTER ). args ([ "-R" , "-f" ]). arg ( & app). status (); 416} 417Ok ( true ) 418} 419 420# [ cfg ( target_os = "windows" )] 421fn do_register () ->Result < bool > { 422Ok ( false ) 423} 424 425# [ cfg ( not ( any ( target_os = "linux" , target_os = "macos" , target_os = "windows" )))] 426fn do_register () ->Result < bool > { 427Ok ( false ) 428} 429 430# [ cfg ( test )] 431mod tests{ 432use super :: * ; 433 434# [ test ] 435fn parse_code_handles_redirect_and_bare_code () { 436let ( code, state) =parse_code ( "tidal://login/auth?code=abc&state=TIDAL_xyz" ); 437assert_eq! ( code. as_deref (), Some ( "abc" )); 438assert_eq! ( state. as_deref (), Some ( "TIDAL_xyz" )); 439 440let ( code, _) =parse_code ( "abc123" ); 441assert_eq! ( code. as_deref (), Some ( "abc123" )); 442 443let ( code, _) =parse_code ( "" ); 444assert! ( code. is_none ()); 445} 446 447# [ test ] 448fn authorize_url_carries_pkce_params () { 449let session =new_session (); 450let url =build_authorize_url ( "cid" , "tidal://login/auth" , "r_usr w_usr" , & session); 451assert! ( url. starts_with ( "https://login.tidal.com/authorize?" )); 452assert! ( url. contains ( "code_challenge=" )); 453assert! ( url. contains ( "code_challenge_method=S256" )); 454assert! ( url. contains ( "scope=r_usr+w_usr" )); 455} 456}