char-slop/tidal-dl
tidal ripper
git clone https://git.t4t.associates/char-slop/tidal-dl
3d04234
main
1use anyhow::{ Context , Result }; 2 3use crate :: model::{ Album , AudioQuality , StreamManifest , StreamUrl , Track }; 4 5const API_BASE : & str ="https://api.tidalhifi.com/v1/" ; 6 7pub struct TidalClient { 8http : reqwest:: Client , 9token : String , 10country_code : String , 11} 12 13impl TidalClient { 14pub fn new ( token : String , country_code : String ) ->Self { 15Self { 16http : reqwest:: Client :: new (), 17 token, 18 country_code, 19} 20} 21 22async fn get ( & self , path : & str , params : & [( & str , String )]) ->Result < serde_json:: Value > { 23let mut query: Vec <( & str , String )> = params. to_vec (); 24 query. push (( "countryCode" , self . country_code . clone ())); 25let mut last_err =String :: new (); 26for _in 0 ..3 { 27let resp =self 28. http 29. get ( format! ( "{API_BASE}{path}" )) 30. bearer_auth ( & self . token ) 31. query ( & query) 32. send () 33. await ; 34let resp =match resp{ 35Ok ( r) => r, 36Err ( e) =>{ 37 last_err = e. to_string (); 38continue ; 39} 40}; 41if resp. status () == reqwest:: StatusCode :: TOO_MANY_REQUESTS { 42 tokio:: time:: sleep ( std:: time:: Duration :: from_secs ( 20 )). await ; 43continue ; 44} 45let value: serde_json:: Value = 46 resp. json (). await . context ( "tidal api response" ) ?; 47if let Some ( status) = value. get ( "status" ) { 48let msg = value 49. get ( "userMessage" ) 50. and_then ( |m| m. as_str ()) 51. unwrap_or ( "" ); 52 anyhow:: bail!( "tidal api error {status}: {msg}" ); 53} 54return Ok ( value); 55} 56 anyhow:: bail!( "tidal api request failed: {last_err}" ); 57} 58 59async fn get_items ( & self , path : & str ) ->Result < Vec < serde_json:: Value >> { 60let mut out =Vec :: new (); 61let mut offset =0 ; 62loop { 63let data =self 64. get ( 65 path, 66& [ 67( "limit" , "50" . to_string ()), 68( "offset" , offset. to_string ()), 69], 70) 71. await ?; 72let total = data. get ( "totalNumberOfItems" ). and_then ( |t| t. as_i64 ()); 73let items = data 74. get ( "items" ) 75. and_then ( |i| i. as_array ()) 76. cloned () 77. unwrap_or_default (); 78let n = items. len (); 79 out. extend ( items); 80if n <50 { 81break ; 82} 83if let Some ( total) = total{ 84if out. len () >= totalas usize { 85break ; 86} 87} 88 offset += n; 89} 90Ok ( out) 91} 92 93pub async fn track ( & self , id : u64 ) ->Result < Track > { 94let v =self . get ( & format! ( "tracks/{id}" ), & []). await ?; 95 serde_json:: from_value ( v). context ( "parse track" ) 96} 97 98pub async fn album ( & self , id : u64 ) ->Result < Album > { 99let v =self . get ( & format! ( "albums/{id}" ), & []). await ?; 100 serde_json:: from_value ( v). context ( "parse album" ) 101} 102 103pub async fn album_tracks ( & self , id : u64 ) ->Result < Vec < Track >> { 104let items =self . get_items ( & format! ( "albums/{id}/items" )). await ?; 105let mut tracks =Vec :: new (); 106for entryin items{ 107if entry. get ( "type" ). and_then ( |t| t. as_str ()) !=Some ( "track" ) { 108continue ; 109} 110if let Some ( item) = entry. get ( "item" ) { 111if item. get ( "streamReady" ). and_then ( |s| s. as_bool ()) ==Some ( false ) { 112continue ; 113} 114 tracks. push ( serde_json:: from_value ( item. clone ()). context ( "parse album track" ) ?); 115} 116} 117Ok ( tracks) 118} 119 120pub async fn stream_url ( & self , id : u64 , quality : AudioQuality ) ->Result < StreamUrl > { 121let data =self 122. get ( 123& format! ( "tracks/{id}/playbackinfopostpaywall" ), 124& [ 125( "audioquality" , quality. tidal_value (). to_string ()), 126( "playbackmode" , "STREAM" . to_string ()), 127( "assetpresentation" , "FULL" . to_string ()), 128], 129) 130. await ?; 131let resp: StreamManifest = serde_json:: from_value ( data). context ( "parse stream response" ) ?; 132let mime = resp. manifest_mime_type . clone (). unwrap_or_default (); 133let raw = resp. manifest . clone (). unwrap_or_default (); 134if mime. contains ( "vnd.tidal.bt" ) { 135let manifest: serde_json:: Value = serde_json:: from_slice ( 136& base64_decode ( & raw). context ( "decode bt manifest" ) ?, 137) 138. context ( "parse bt manifest" ) ?; 139let urls = manifest 140. get ( "urls" ) 141. and_then ( |u| u. as_array ()) 142. map ( |a|{ 143 a. iter (). filter_map ( |u| u. as_str (). map ( str:: to_string)). collect ::< Vec < _ >>() 144}) 145. unwrap_or_default (); 146let url = urls. first (). cloned (). unwrap_or_default (); 147 anyhow:: ensure!( !url. is_empty (), "empty stream url in manifest" ); 148return Ok ( StreamUrl { 149url : url. clone (), 150urls : vec! [ url], 151codec : manifest 152. get ( "codecs" ) 153. and_then ( |c| c. as_str ()) 154. unwrap_or ( "" ) 155. to_string (), 156encryption_key : manifest 157. get ( "keyId" ) 158. and_then ( |k| k. as_str ()) 159. unwrap_or ( "" ) 160. to_string (), 161sound_quality : resp. audio_quality , 162}); 163} 164if mime. contains ( "dash+xml" ) { 165let xml =String :: from_utf8 ( base64_decode ( & raw). context ( "decode dash manifest" ) ?) 166. context ( "dash manifest utf-8" ) ?; 167let urls =parse_mpd ( & xml) ?; 168 anyhow:: ensure!( !urls. is_empty (), "no audio segments in dash manifest" ); 169return Ok ( StreamUrl { 170url : urls[ 0 ]. clone (), 171 urls, 172codec : String :: new (), 173encryption_key : String :: new (), 174sound_quality : resp. audio_quality , 175}); 176} 177 anyhow:: bail!( "unsupported manifest type: {mime}" ) 178} 179 180pub async fn contributors ( & self , id : u64 ) ->Option < serde_json:: Value > { 181self . get ( & format! ( "tracks/{id}/contributors" ), & []). await . ok () 182} 183 184pub async fn lyrics ( & self , id : u64 ) ->Option < String > { 185let resp =self 186. http 187. get ( format! ( "https://listen.tidal.com/v1/tracks/{id}/lyrics" )) 188. bearer_auth ( & self . token ) 189. send () 190. await 191. ok () ?; 192let v: serde_json:: Value = resp. json (). await . ok () ?; 193 v. get ( "subtitles" ). and_then ( |s| s. as_str ()). map ( str:: to_string) 194} 195 196pub async fn cover_bytes ( & self , cover : & str ) ->Option < Vec < u8 >> { 197let url =crate :: model:: cover_url ( Some ( cover), 1280 , 1280 ) ?; 198self . http . get ( url). send (). await . ok () ?. bytes (). await . ok (). map ( |b| b. to_vec ()) 199} 200} 201 202fn base64_decode ( s : & str ) ->Result < Vec < u8 >> { 203use base64::{ engine:: general_purpose:: STANDARD , Engine as _}; 204STANDARD . decode ( s). context ( "base64" ) 205} 206 207pub fn parse_mpd ( xml : & str ) ->Result < Vec < String >> { 208use quick_xml:: events:: Event ; 209use quick_xml:: reader:: Reader ; 210 211let xml =strip_xmlns_once ( xml); 212let mut reader =Reader :: from_str ( & xml); 213 reader. config_mut (). trim_text ( true ); 214 215let mut in_audio =false ; 216let mut audio_depth =0usize ; 217let mut depth =0usize ; 218let mut codec =String :: new (); 219let mut init: Option < String > =None ; 220let mut media: Option < String > =None ; 221let mut start_number: u64 =1 ; 222let mut timeline: Vec <( u64 , i64 )> =Vec :: new (); 223 224loop { 225match reader. read_event () { 226Err ( e) => anyhow:: bail!( "mpd parse: {e}" ), 227Ok ( Event :: Eof ) =>break , 228Ok ( Event :: Start ( e)) =>{ 229 depth +=1 ; 230match e. name (). as_ref () { 231b"AdaptationSet" =>{ 232let audio =attr ( & e, "contentType" ) ==Some ( "audio" . to_string ()); 233if audio{ 234 in_audio =true ; 235 audio_depth = depth; 236} 237} 238b"Representation" if in_audio && codec. is_empty () =>{ 239if let Some ( c) =attr ( & e, "codecs" ) { 240let c = c. to_uppercase (); 241 codec =if c. starts_with ( "MP4A" ) { "AAC" . to_string () } else { c}; 242} 243} 244b"SegmentTemplate" if in_audio && init. is_none () =>{ 245 init =attr ( & e, "initialization" ); 246 media =attr ( & e, "media" ); 247 start_number =attr ( & e, "startNumber" ) 248. and_then ( |s| s. parse (). ok ()) 249. unwrap_or ( 1 ); 250} 251b"S" if in_audio && init. is_some () =>{ 252 timeline. push (( 253attr ( & e, "d" ). and_then ( |s| s. parse (). ok ()). unwrap_or ( 0 ), 254attr ( & e, "r" ). and_then ( |s| s. parse (). ok ()). unwrap_or ( 0 ), 255)); 256} 257 _ =>{} 258} 259} 260Ok ( Event :: Empty ( e)) =>match e. name (). as_ref () { 261b"Representation" if in_audio && codec. is_empty () =>{ 262if let Some ( c) =attr ( & e, "codecs" ) { 263let c = c. to_uppercase (); 264 codec =if c. starts_with ( "MP4A" ) { "AAC" . to_string () } else { c}; 265} 266} 267b"SegmentTemplate" if in_audio && init. is_none () =>{ 268 init =attr ( & e, "initialization" ); 269 media =attr ( & e, "media" ); 270 start_number =attr ( & e, "startNumber" ). and_then ( |s| s. parse (). ok ()). unwrap_or ( 1 ); 271} 272b"S" if in_audio && init. is_some () =>{ 273 timeline. push (( 274attr ( & e, "d" ). and_then ( |s| s. parse (). ok ()). unwrap_or ( 0 ), 275attr ( & e, "r" ). and_then ( |s| s. parse (). ok ()). unwrap_or ( 0 ), 276)); 277} 278 _ =>{} 279}, 280Ok ( Event :: End ( e)) =>{ 281if e. name (). as_ref () ==b"AdaptationSet" && in_audio && depth == audio_depth{ 282 in_audio =false ; 283if init. is_some () { 284break ; 285} 286} 287 depth = depth. saturating_sub ( 1 ); 288} 289 _ =>{} 290} 291} 292 293let ( Some ( init), Some ( media)) =( init, media) else { 294 anyhow:: bail!( "no audio SegmentTemplate in mpd" ); 295}; 296let _ = codec; 297let count: usize = timeline. iter (). map ( |( _, r) |( * r +1 ) as usize ). sum (); 298let mut urls =Vec :: with_capacity ( count +1 ); 299 urls. push ( init); 300let mut n = start_number; 301for ( _, r) in & timeline{ 302for _in 0 ..( * r +1 ) { 303 urls. push ( media. replace ( "$Number$" , & n. to_string ())); 304 n +=1 ; 305} 306} 307Ok ( urls) 308} 309 310fn strip_xmlns_once ( xml : & str ) ->String { 311if let Some ( start) = xml. find ( "xmlns=\"" ) { 312if let Some ( end) = xml[ start..]. find ( '"' ). and_then ( |_| xml[ start +7 ..]. find ( '"' )) { 313let end = start +7 + end +1 ; 314let mut out =String :: with_capacity ( xml. len ()); 315 out. push_str ( & xml[ ..start]); 316 out. push_str ( & xml[ end..]); 317return out; 318} 319} 320 xml. to_string () 321} 322 323fn attr ( e : & quick_xml:: events:: BytesStart , name : & str ) ->Option < String > { 324 e. attributes (). find_map ( |a|{ 325let a = a. ok () ?; 326if a. key . as_ref () != name. as_bytes () { 327return None ; 328} 329let raw = a. value . into_owned (); 330let s =String :: from_utf8 ( raw). ok () ?; 331 quick_xml:: escape:: unescape ( & s). ok (). map ( |c| c. into_owned ()) 332}) 333} 334 335# [ cfg ( test )] 336mod tests{ 337use super :: * ; 338 339# [ test ] 340fn mpd_expands_segment_timeline () { 341let xml =r#"<MPD xmlns="urn:mpeg:dash:schema:mpd:2011"><Period><AdaptationSet contentType="audio"><Representation codecs="mp4a.40.2"><SegmentTemplate initialization="init.mp4" media="seg-$Number$.m4s" startNumber="1"><SegmentTimeline><S t="0" d="100" r="2"/></SegmentTimeline></SegmentTemplate></Representation></AdaptationSet></Period></MPD>"# ; 342let urls =parse_mpd ( xml). unwrap (); 343assert_eq! ( urls, vec! [ "init.mp4" , "seg-1.m4s" , "seg-2.m4s" , "seg-3.m4s" ]); 344} 345}