char-slop/tidal-dl
tidal ripper
git clone https://git.t4t.associates/char-slop/tidal-dl
f146f6e
main
1use anyhow::{ Context , Result }; 2use indicatif::{ ProgressBar , ProgressStyle }; 3use std:: path::{ Path , PathBuf }; 4 5use crate :: api:: TidalClient ; 6use crate :: api_key:: ApiKey ; 7use crate :: model::{ self , Album , AudioQuality , MediaKind , StreamUrl , Track }; 8 9pub struct Options { 10pub quality : AudioQuality , 11pub output : Option < String >, 12/// Re-encode flac through libav's encoder so the result is a plain, 13/// universally playable flac stream. Off keeps TIDAL's bytes verbatim. 14pub transcode : bool , 15} 16 17pub async fn run ( urls : Vec < String >, opts : Options ) ->Result <()> { 18 anyhow:: ensure!( !urls. is_empty (), "give at least one tidal URL" ); 19let Options { quality, output, transcode} = opts; 20let base =match output{ 21Some ( o) if !o. is_empty () => o, 22 _ =>crate :: settings:: load_settings (). download_path , 23}; 24for inputin & urls{ 25let kind = model:: parse_tidal_url ( input) ?; 26download_one ( & kind, quality, transcode, & base). await ?; 27} 28Ok (()) 29} 30 31async fn download_one ( kind : & MediaKind , quality : AudioQuality , transcode : bool , base : & str ) ->Result <()> { 32let ( client, _key) =session_client (). await ?; 33let http = reqwest:: Client :: new (); 34match kind{ 35MediaKind :: Track ( id) =>{ 36let track = client. track ( * id). await ?; 37let album =if track. album . id !=0 { 38 client. album ( track. album . id ). await . ok () 39} else { 40None 41}; 42download_track ( & client, & http, & track, album. as_ref (), base, quality, transcode). await ?; 43} 44MediaKind :: Album ( id) =>{ 45let album = client. album ( * id). await ?; 46let tracks = client. album_tracks ( * id). await ?; 47 anyhow:: ensure!( !tracks. is_empty (), "album {id} has no downloadable tracks" ); 48if let Some ( cover) = album. cover . clone () { 49let dir =album_dir ( base, & album); 50let dest = dir. join ( "cover.jpg" ); 51if !dest. exists () { 52if let Some ( bytes) = client. cover_bytes ( & cover). await { 53 std:: fs:: create_dir_all ( & dir) ?; 54 std:: fs:: write ( & dest, bytes) ?; 55} 56} 57} 58for trackin & tracks{ 59download_track ( & client, & http, track, Some ( & album), base, quality, transcode). await ?; 60} 61} 62} 63Ok (()) 64} 65 66async fn session_client () ->Result <( TidalClient , ApiKey )> { 67let key =crate :: api_key:: load_key (); 68let mut token =crate :: settings:: load_token (); 69let access = token. access_token . clone (). unwrap_or_default (); 70 anyhow:: ensure!( !access. is_empty (), "not logged in (run `tidal-dl login`)" ); 71let country = token. country_code . clone (). unwrap_or_else ( ||"US" . to_string ()); 72let http = reqwest:: Client :: new (); 73if !crate :: tidal:: verify_access_token ( & http, & access). await { 74let refresh = token. refresh_token . clone (). unwrap_or_default (); 75 anyhow:: ensure!( !refresh. is_empty (), "session expired (run `tidal-dl login`)" ); 76let sess =crate :: tidal:: refresh_token ( & http, & key, & refresh) 77. await 78. context ( "session expired and refresh failed (run `tidal-dl login`)" ) ?; 79 token. userid =Some ( sess. user_id ); 80 token. country_code =Some ( sess. country_code . clone ()); 81 token. access_token =Some ( sess. access_token . clone ()); 82 token. refresh_token = sess. refresh_token ; 83 token. expires_after =now () + sess. expires_in ; 84crate :: settings:: save_token ( & token) ?; 85return Ok (( 86TidalClient :: new ( token. access_token . unwrap (), token. country_code . unwrap ()), 87 key, 88)); 89} 90Ok (( TidalClient :: new ( access, country), key)) 91} 92 93async fn download_track ( 94client : & TidalClient , 95http : & reqwest:: Client , 96track : & Track , 97album : Option < & Album >, 98base : & str , 99quality : AudioQuality , 100transcode : bool , 101) ->Result < PathBuf > { 102let stream = client. stream_url ( track. id , quality). await ?; 103let dest: PathBuf =track_path ( base, track, album, & stream); 104let title =display_track ( track); 105 106if should_skip ( & dest) { 107println! ( "skip (exists): {title}" ); 108return Ok ( dest); 109} 110 111println! ( "{} [{}]" , title, stream. sound_quality . as_deref (). unwrap_or ( "?" )); 112let part = dest. with_extension ( "part" ); 113if let Some ( parent) = dest. parent () { 114 std:: fs:: create_dir_all ( parent) ?; 115} 116fetch_to ( http, & stream. urls , & part). await ?; 117 118if !stream. encryption_key . is_empty () { 119let ( key, nonce) =crate :: decrypt:: decrypt_security_token ( & stream. encryption_key ) ?; 120let plain =crate :: decrypt:: decrypt_bytes ( std:: fs:: read ( & part) ?, & key, & nonce); 121 std:: fs:: write ( & part, plain) ?; 122} 123 124if transcode && dest. extension (). is_some_and ( |e| e =="flac" ) { 125reencode_flac ( & part, & dest). await ?; 126let _ = std:: fs:: remove_file ( & part); 127} else { 128 std:: fs:: rename ( & part, & dest) ?; 129} 130 131let contributors = client. contributors ( track. id ). await ; 132let lyrics = client. lyrics ( track. id ). await ; 133let cover =match album. and_then ( |a| a. cover . clone ()) { 134Some ( c) => client. cover_bytes ( & c). await , 135None =>None , 136}; 137tag_file ( & dest, track, album, contributors. as_ref (), lyrics. as_deref (), cover) ?; 138 139let size = std:: fs:: metadata ( & dest). map ( |m| m. len ()). unwrap_or ( 0 ); 140println! ( "wrote {} ({})" , dest. display (), human_bytes ( size)); 141Ok ( dest) 142} 143 144/// Buffer is assembled under a `.part` name, so anything at `dest` is complete. 145fn should_skip ( dest : & Path ) ->bool { 146 std:: fs:: metadata ( dest). map ( |m| m. len ()). unwrap_or ( 0 ) >0 147} 148 149/// TIDAL hands out some flac in an MP4 container, and its streamed flac carries 150/// headers that other players choke on. A full lossless encode through libav's 151/// flac encoder normalizes both into a plain flac stream. 152async fn reencode_flac ( src : & Path , dest : & Path ) ->Result <()> { 153let encoded = dest. with_extension ( "encoded.flac" ); 154let out = tokio:: process:: Command :: new ( "ffmpeg" ) 155. args ([ "-hide_banner" , "-nostdin" , "-loglevel" , "error" , "-y" , "-i" ]) 156. arg ( src) 157. args ([ "-map" , "0:a:0" , "-map_metadata" , "-1" , "-c:a" , "flac" ]) 158. arg ( & encoded) 159. output () 160. await 161. context ( "run ffmpeg to write flac (is ffmpeg installed?)" ) ?; 162if !out. status . success () { 163let _ = std:: fs:: remove_file ( & encoded); 164 anyhow:: bail!( 165"ffmpeg flac encode failed: {}" , 166String :: from_utf8_lossy ( & out. stderr ). trim () 167); 168} 169 std:: fs:: rename ( & encoded, dest) ?; 170Ok (()) 171} 172 173async fn fetch_to ( http : & reqwest:: Client , urls : & [ String ], part : & Path ) ->Result <()> { 174 anyhow:: ensure!( !urls. is_empty (), "empty stream" ); 175if urls. len () ==1 { 176let mut resp = http. get ( & urls[ 0 ]). send (). await . context ( "stream request" ) ?; 177 anyhow:: ensure!( resp. status (). is_success (), "stream http {}" , resp. status ()); 178let total = resp. content_length (). unwrap_or ( 0 ); 179let bar =if total >0 { 180let bar =ProgressBar :: new ( total); 181 bar. set_style ( 182ProgressStyle :: with_template ( "{bar:40} {bytes}/{total_bytes} {bytes_per_sec}" ) 183. unwrap () 184. progress_chars ( "=> " ), 185); 186 bar 187} else { 188ProgressBar :: new_spinner () 189}; 190let mut file = tokio:: fs:: File :: create ( part). await ?; 191let mut bytes: u64 =0 ; 192use tokio:: io:: AsyncWriteExt ; 193loop { 194let chunk = resp. chunk (). await . context ( "stream body" ) ?; 195let Some ( chunk) = chunkelse { break ; }; 196 file. write_all ( & chunk). await ?; 197 bytes += chunk. len () as u64 ; 198if total >0 { 199 bar. set_position ( bytes); 200} else { 201 bar. set_message ( human_bytes ( bytes)); 202 bar. tick (); 203} 204} 205 bar. finish_and_clear (); 206return Ok (()); 207} 208for urlin urls{ 209 anyhow:: ensure!( url. starts_with ( "http" ), "relative segment URL not supported: {url}" ); 210} 211let bar =ProgressBar :: new_spinner (); 212 bar. set_style ( ProgressStyle :: with_template ( "{spinner} {msg}" ). unwrap ()); 213let mut file = std:: fs:: File :: create ( part) ?; 214use std:: io:: Write ; 215for ( i, url) in urls. iter (). enumerate () { 216 bar. set_message ( format! ( "segment {}/{}" , i +1 , urls. len ())); 217let bytes = http. get ( url). send (). await . context ( "segment request" ) ?. bytes (). await . context ( "segment body" ) ?; 218 file. write_all ( & bytes) ?; 219} 220 bar. finish_and_clear (); 221Ok (()) 222} 223 224fn tag_file ( 225path : & Path , 226track : & Track , 227album : Option < & Album >, 228contributors : Option < & serde_json:: Value >, 229lyrics : Option < & str >, 230cover : Option < Vec < u8 >>, 231) ->Result <()> { 232use lofty:: picture::{ MimeType , Picture , PictureType }; 233use lofty:: prelude:: TaggedFileExt ; 234use lofty:: tag::{ ItemKey , Tag }; 235 236let mut title = track. title . clone (). unwrap_or_else ( ||format! ( "Track {}" , track. id )); 237if let Some ( v) = track. version . clone (). filter ( |v| !v. is_empty ()) { 238 title =format! ( "{title} ({v})" ); 239} 240let artists = model:: artist_names ( & track. artists , & track. artist ); 241let album_title = album 242. and_then ( |a| a. title . clone ()) 243. or_else ( || track. album . title . clone ()) 244. unwrap_or_default (); 245let album_artists = album 246. map ( |a| model:: artist_names ( & a. artists , & a. artist )) 247. unwrap_or_else ( || model:: artist_names ( & track. album . artists , & track. album . artist )); 248let date = album 249. and_then ( |a| a. release_date . clone ()) 250. or ( track. album . release_date . clone ()) 251. unwrap_or_default (); 252 253let mut tagged = lofty:: read_from_path ( path). context ( "read audio for tagging" ) ?; 254if tagged. primary_tag (). is_none () { 255 tagged. insert_tag ( Tag :: new ( tagged. primary_tag_type ())); 256} 257let tag = tagged. primary_tag_mut (). expect ( "just ensured" ); 258 tag. insert_text ( ItemKey :: TrackTitle , title); 259if !artists. is_empty () { 260 tag. insert_text ( ItemKey :: TrackArtist , artists); 261} 262if !album_title. is_empty () { 263 tag. insert_text ( ItemKey :: AlbumTitle , album_title); 264} 265if !album_artists. is_empty () { 266 tag. insert_text ( ItemKey :: AlbumArtist , album_artists); 267} 268 tag. insert_text ( ItemKey :: TrackNumber , track. track_number . to_string ()); 269 tag. insert_text ( ItemKey :: DiscNumber , track. volume_number . max ( 1 ). to_string ()); 270if !date. is_empty () { 271 tag. insert_text ( ItemKey :: RecordingDate , date); 272} 273if let Some ( c) = track. copy_right . clone (). filter ( |c| !c. is_empty ()) { 274 tag. insert_text ( ItemKey :: CopyrightMessage , c); 275} 276if let Some ( isrc) = track. isrc . clone (). filter ( |s| !s. is_empty ()) { 277 tag. insert_text ( ItemKey :: Isrc , isrc); 278} 279if let Some ( composers) =composers ( contributors) { 280 tag. insert_text ( ItemKey :: Composer , composers); 281} 282if let Some ( lyrics) = lyrics. filter ( |l| !l. is_empty ()) { 283 tag. insert_text ( ItemKey :: Lyrics , lyrics. to_string ()); 284} 285if let Some ( bytes) = cover{ 286 tag. push_picture ( Picture :: new_unchecked ( 287PictureType :: CoverFront , 288Some ( MimeType :: Jpeg ), 289None , 290 bytes, 291)); 292} 293use lofty:: file:: AudioFile ; 294 tagged 295. save_to_path ( path, lofty:: config:: WriteOptions :: default ()) 296. context ( "save tags" ) ?; 297Ok (()) 298} 299 300fn composers ( contributors : Option < & serde_json:: Value >) ->Option < String > { 301let items = contributors?. get ( "items" ) ?. as_array () ?; 302let names: Vec < _ > = items 303. iter () 304. filter ( |i| i. get ( "role" ). and_then ( |r| r. as_str ()) ==Some ( "Composer" )) 305. filter_map ( |i| i. get ( "name" ). and_then ( |n| n. as_str ())) 306. collect (); 307if names. is_empty () { 308None 309} else { 310Some ( names. join ( ", " )) 311} 312} 313 314fn track_path ( base : & str , track : & Track , album : Option < & Album >, stream : & StreamUrl ) ->PathBuf { 315let ext =extension_for ( stream); 316let mut title = track. title . clone (). unwrap_or_else ( ||format! ( "Track {}" , track. id )); 317if let Some ( v) = track. version . clone (). filter ( |v| !v. is_empty ()) { 318 title =format! ( "{title} ({v})" ); 319} 320let explicit =if track. explicit { " (Explicit)" } else { "" }; 321let file =format! ( "{:02} - {title}{explicit}.{ext}" , track. track_number . max ( 1 )); 322match album{ 323Some ( album) =>{ 324let mut dir =album_dir ( base, album); 325if album. number_of_volumes >1 { 326 dir. push ( format! ( "CD{}" , track. volume_number . max ( 1 ))); 327} 328 dir. join ( sanitize_filename ( & file)) 329} 330None =>PathBuf :: from ( base). join ( sanitize_filename ( & file)), 331} 332} 333 334fn album_dir ( base : & str , album : & Album ) ->PathBuf { 335let title =sanitize_filename ( 336& album. title . clone (). unwrap_or_else ( ||format! ( "Album {}" , album. id )), 337); 338let year = model:: album_year ( album. release_date . as_deref ()); 339Path :: new ( base). join ( format! ( "{title} [{year}]" )) 340} 341 342fn extension_for ( stream : & StreamUrl ) ->&' static str { 343if stream. url . contains ( ".flac" ) { 344return "flac" ; 345} 346if stream. url . contains ( ".mp4" ) { 347if stream. codec . contains ( "ac4" ) || stream. codec . contains ( "mha1" ) { 348return "mp4" ; 349} 350if stream. codec . contains ( "flac" ) { 351return "flac" ; 352} 353return "m4a" ; 354} 355"m4a" 356} 357 358fn sanitize_filename ( s : & str ) ->String { 359let mut out =String :: with_capacity ( s. len ()); 360for cin s. chars () { 361match c{ 362'/' |'\\' |':' |'*' |'?' |'"' |'<' |'>' |'|' => out. push ( '-' ), 363 cif c. is_control () => out. push ( '-' ), 364 c => out. push ( c), 365} 366} 367let out = out. trim (). trim_matches ([ '-' , '.' ]). trim (). to_string (); 368if out. is_empty () { 369"untitled" . to_string () 370} else { 371 out 372} 373} 374 375fn display_track ( track : & Track ) ->String { 376let artist = model:: artist_names ( & track. artists , & track. artist ); 377let title = track. title . clone (). unwrap_or_else ( ||format! ( "Track {}" , track. id )); 378format! ( "{artist} - {title}" ) 379} 380 381fn human_bytes ( n : u64 ) ->String { 382const UNITS : & [ & str ] =& [ "B" , "KB" , "MB" , "GB" ]; 383let mut v = nas f64 ; 384let mut u =0 ; 385while v >=1024.0 && u +1 <UNITS . len () { 386 v /=1024.0 ; 387 u +=1 ; 388} 389if u ==0 { 390format! ( "{n} B" ) 391} else { 392format! ( "{v:.1} {}" , UNITS [ u]) 393} 394} 395 396fn now () ->i64 { 397 std:: time:: SystemTime :: now () 398. duration_since ( std:: time:: UNIX_EPOCH ) 399. map ( |d| d. as_secs () as i64 ) 400. unwrap_or ( 0 ) 401} 402 403# [ cfg ( test )] 404mod tests{ 405use super :: * ; 406 407# [ test ] 408fn filenames_are_filesystem_safe () { 409assert_eq! ( sanitize_filename ( "a/b\\c:d*e?f\"g<h>i|j" ), "a-b-c-d-e-f-g-h-i-j" ); 410assert_eq! ( sanitize_filename ( "" ), "untitled" ); 411assert_eq! ( sanitize_filename ( "Who's the Clown?" ), "Who's the Clown" ); 412} 413 414# [ test ] 415fn album_paths_join_cleanly () { 416let album =Album { 417title : Some ( "Who's the Clown?" . to_string ()), 418release_date : Some ( "2025-01-01" . to_string ()), 419 ..Default :: default () 420}; 421let dir =album_dir ( "./download/" , & album); 422let s = dir. to_string_lossy (); 423assert! ( !s. contains ( "//" ), "{s}" ); 424assert! ( s. ends_with ( "Who's the Clown [2025]" ), "{s}" ); 425} 426}