use anyhow::{Context, Result}; use indicatif::{ProgressBar, ProgressStyle}; use std::path::{Path, PathBuf}; use crate::api::TidalClient; use crate::api_key::ApiKey; use crate::model::{self, Album, AudioQuality, MediaKind, StreamUrl, Track}; pub struct Options { pub quality: AudioQuality, pub output: Option, /// Re-encode flac through libav's encoder so the result is a plain, /// universally playable flac stream. Off keeps TIDAL's bytes verbatim. pub transcode: bool, } pub async fn run(urls: Vec, opts: Options) -> Result<()> { anyhow::ensure!(!urls.is_empty(), "give at least one tidal URL"); let Options { quality, output, transcode } = opts; let base = match output { Some(o) if !o.is_empty() => o, _ => crate::settings::load_settings().download_path, }; for input in &urls { let kind = model::parse_tidal_url(input)?; download_one(&kind, quality, transcode, &base).await?; } Ok(()) } async fn download_one(kind: &MediaKind, quality: AudioQuality, transcode: bool, base: &str) -> Result<()> { let (client, _key) = session_client().await?; let http = reqwest::Client::new(); match kind { MediaKind::Track(id) => { let track = client.track(*id).await?; let album = if track.album.id != 0 { client.album(track.album.id).await.ok() } else { None }; download_track(&client, &http, &track, album.as_ref(), base, quality, transcode).await?; } MediaKind::Album(id) => { let album = client.album(*id).await?; let tracks = client.album_tracks(*id).await?; anyhow::ensure!(!tracks.is_empty(), "album {id} has no downloadable tracks"); if let Some(cover) = album.cover.clone() { let dir = album_dir(base, &album); let dest = dir.join("cover.jpg"); if !dest.exists() { if let Some(bytes) = client.cover_bytes(&cover).await { std::fs::create_dir_all(&dir)?; std::fs::write(&dest, bytes)?; } } } for track in &tracks { download_track(&client, &http, track, Some(&album), base, quality, transcode).await?; } } } Ok(()) } async fn session_client() -> Result<(TidalClient, ApiKey)> { let key = crate::api_key::load_key(); let mut token = crate::settings::load_token(); let access = token.access_token.clone().unwrap_or_default(); anyhow::ensure!(!access.is_empty(), "not logged in (run `tidal-dl login`)"); let country = token.country_code.clone().unwrap_or_else(|| "US".to_string()); let http = reqwest::Client::new(); if !crate::tidal::verify_access_token(&http, &access).await { let refresh = token.refresh_token.clone().unwrap_or_default(); anyhow::ensure!(!refresh.is_empty(), "session expired (run `tidal-dl login`)"); let sess = crate::tidal::refresh_token(&http, &key, &refresh) .await .context("session expired and refresh failed (run `tidal-dl login`)")?; token.userid = Some(sess.user_id); token.country_code = Some(sess.country_code.clone()); token.access_token = Some(sess.access_token.clone()); token.refresh_token = sess.refresh_token; token.expires_after = now() + sess.expires_in; crate::settings::save_token(&token)?; return Ok(( TidalClient::new(token.access_token.unwrap(), token.country_code.unwrap()), key, )); } Ok((TidalClient::new(access, country), key)) } async fn download_track( client: &TidalClient, http: &reqwest::Client, track: &Track, album: Option<&Album>, base: &str, quality: AudioQuality, transcode: bool, ) -> Result { let stream = client.stream_url(track.id, quality).await?; let dest: PathBuf = track_path(base, track, album, &stream); let title = display_track(track); if should_skip(&dest) { println!("skip (exists): {title}"); return Ok(dest); } println!("{} [{}]", title, stream.sound_quality.as_deref().unwrap_or("?")); let part = dest.with_extension("part"); if let Some(parent) = dest.parent() { std::fs::create_dir_all(parent)?; } fetch_to(http, &stream.urls, &part).await?; if !stream.encryption_key.is_empty() { let (key, nonce) = crate::decrypt::decrypt_security_token(&stream.encryption_key)?; let plain = crate::decrypt::decrypt_bytes(std::fs::read(&part)?, &key, &nonce); std::fs::write(&part, plain)?; } if transcode && dest.extension().is_some_and(|e| e == "flac") { reencode_flac(&part, &dest).await?; let _ = std::fs::remove_file(&part); } else { std::fs::rename(&part, &dest)?; } let contributors = client.contributors(track.id).await; let lyrics = client.lyrics(track.id).await; let cover = match album.and_then(|a| a.cover.clone()) { Some(c) => client.cover_bytes(&c).await, None => None, }; tag_file(&dest, track, album, contributors.as_ref(), lyrics.as_deref(), cover)?; let size = std::fs::metadata(&dest).map(|m| m.len()).unwrap_or(0); println!("wrote {} ({})", dest.display(), human_bytes(size)); Ok(dest) } /// Buffer is assembled under a `.part` name, so anything at `dest` is complete. fn should_skip(dest: &Path) -> bool { std::fs::metadata(dest).map(|m| m.len()).unwrap_or(0) > 0 } /// TIDAL hands out some flac in an MP4 container, and its streamed flac carries /// headers that other players choke on. A full lossless encode through libav's /// flac encoder normalizes both into a plain flac stream. async fn reencode_flac(src: &Path, dest: &Path) -> Result<()> { let encoded = dest.with_extension("encoded.flac"); let out = tokio::process::Command::new("ffmpeg") .args(["-hide_banner", "-nostdin", "-loglevel", "error", "-y", "-i"]) .arg(src) .args(["-map", "0:a:0", "-map_metadata", "-1", "-c:a", "flac"]) .arg(&encoded) .output() .await .context("run ffmpeg to write flac (is ffmpeg installed?)")?; if !out.status.success() { let _ = std::fs::remove_file(&encoded); anyhow::bail!( "ffmpeg flac encode failed: {}", String::from_utf8_lossy(&out.stderr).trim() ); } std::fs::rename(&encoded, dest)?; Ok(()) } async fn fetch_to(http: &reqwest::Client, urls: &[String], part: &Path) -> Result<()> { anyhow::ensure!(!urls.is_empty(), "empty stream"); if urls.len() == 1 { let mut resp = http.get(&urls[0]).send().await.context("stream request")?; anyhow::ensure!(resp.status().is_success(), "stream http {}", resp.status()); let total = resp.content_length().unwrap_or(0); let bar = if total > 0 { let bar = ProgressBar::new(total); bar.set_style( ProgressStyle::with_template("{bar:40} {bytes}/{total_bytes} {bytes_per_sec}") .unwrap() .progress_chars("=> "), ); bar } else { ProgressBar::new_spinner() }; let mut file = tokio::fs::File::create(part).await?; let mut bytes: u64 = 0; use tokio::io::AsyncWriteExt; loop { let chunk = resp.chunk().await.context("stream body")?; let Some(chunk) = chunk else { break; }; file.write_all(&chunk).await?; bytes += chunk.len() as u64; if total > 0 { bar.set_position(bytes); } else { bar.set_message(human_bytes(bytes)); bar.tick(); } } bar.finish_and_clear(); return Ok(()); } for url in urls { anyhow::ensure!(url.starts_with("http"), "relative segment URL not supported: {url}"); } let bar = ProgressBar::new_spinner(); bar.set_style(ProgressStyle::with_template("{spinner} {msg}").unwrap()); let mut file = std::fs::File::create(part)?; use std::io::Write; for (i, url) in urls.iter().enumerate() { bar.set_message(format!("segment {}/{}", i + 1, urls.len())); let bytes = http.get(url).send().await.context("segment request")?.bytes().await.context("segment body")?; file.write_all(&bytes)?; } bar.finish_and_clear(); Ok(()) } fn tag_file( path: &Path, track: &Track, album: Option<&Album>, contributors: Option<&serde_json::Value>, lyrics: Option<&str>, cover: Option>, ) -> Result<()> { use lofty::picture::{MimeType, Picture, PictureType}; use lofty::prelude::TaggedFileExt; use lofty::tag::{ItemKey, Tag}; let mut title = track.title.clone().unwrap_or_else(|| format!("Track {}", track.id)); if let Some(v) = track.version.clone().filter(|v| !v.is_empty()) { title = format!("{title} ({v})"); } let artists = model::artist_names(&track.artists, &track.artist); let album_title = album .and_then(|a| a.title.clone()) .or_else(|| track.album.title.clone()) .unwrap_or_default(); let album_artists = album .map(|a| model::artist_names(&a.artists, &a.artist)) .unwrap_or_else(|| model::artist_names(&track.album.artists, &track.album.artist)); let date = album .and_then(|a| a.release_date.clone()) .or(track.album.release_date.clone()) .unwrap_or_default(); let mut tagged = lofty::read_from_path(path).context("read audio for tagging")?; if tagged.primary_tag().is_none() { tagged.insert_tag(Tag::new(tagged.primary_tag_type())); } let tag = tagged.primary_tag_mut().expect("just ensured"); tag.insert_text(ItemKey::TrackTitle, title); if !artists.is_empty() { tag.insert_text(ItemKey::TrackArtist, artists); } if !album_title.is_empty() { tag.insert_text(ItemKey::AlbumTitle, album_title); } if !album_artists.is_empty() { tag.insert_text(ItemKey::AlbumArtist, album_artists); } tag.insert_text(ItemKey::TrackNumber, track.track_number.to_string()); tag.insert_text(ItemKey::DiscNumber, track.volume_number.max(1).to_string()); if !date.is_empty() { tag.insert_text(ItemKey::RecordingDate, date); } if let Some(c) = track.copy_right.clone().filter(|c| !c.is_empty()) { tag.insert_text(ItemKey::CopyrightMessage, c); } if let Some(isrc) = track.isrc.clone().filter(|s| !s.is_empty()) { tag.insert_text(ItemKey::Isrc, isrc); } if let Some(composers) = composers(contributors) { tag.insert_text(ItemKey::Composer, composers); } if let Some(lyrics) = lyrics.filter(|l| !l.is_empty()) { tag.insert_text(ItemKey::Lyrics, lyrics.to_string()); } if let Some(bytes) = cover { tag.push_picture(Picture::new_unchecked( PictureType::CoverFront, Some(MimeType::Jpeg), None, bytes, )); } use lofty::file::AudioFile; tagged .save_to_path(path, lofty::config::WriteOptions::default()) .context("save tags")?; Ok(()) } fn composers(contributors: Option<&serde_json::Value>) -> Option { let items = contributors?.get("items")?.as_array()?; let names: Vec<_> = items .iter() .filter(|i| i.get("role").and_then(|r| r.as_str()) == Some("Composer")) .filter_map(|i| i.get("name").and_then(|n| n.as_str())) .collect(); if names.is_empty() { None } else { Some(names.join(", ")) } } fn track_path(base: &str, track: &Track, album: Option<&Album>, stream: &StreamUrl) -> PathBuf { let ext = extension_for(stream); let mut title = track.title.clone().unwrap_or_else(|| format!("Track {}", track.id)); if let Some(v) = track.version.clone().filter(|v| !v.is_empty()) { title = format!("{title} ({v})"); } let explicit = if track.explicit { " (Explicit)" } else { "" }; let file = format!("{:02} - {title}{explicit}.{ext}", track.track_number.max(1)); match album { Some(album) => { let mut dir = album_dir(base, album); if album.number_of_volumes > 1 { dir.push(format!("CD{}", track.volume_number.max(1))); } dir.join(sanitize_filename(&file)) } None => PathBuf::from(base).join(sanitize_filename(&file)), } } fn album_dir(base: &str, album: &Album) -> PathBuf { let title = sanitize_filename( &album.title.clone().unwrap_or_else(|| format!("Album {}", album.id)), ); let year = model::album_year(album.release_date.as_deref()); Path::new(base).join(format!("{title} [{year}]")) } fn extension_for(stream: &StreamUrl) -> &'static str { if stream.url.contains(".flac") { return "flac"; } if stream.url.contains(".mp4") { if stream.codec.contains("ac4") || stream.codec.contains("mha1") { return "mp4"; } if stream.codec.contains("flac") { return "flac"; } return "m4a"; } "m4a" } fn sanitize_filename(s: &str) -> String { let mut out = String::with_capacity(s.len()); for c in s.chars() { match c { '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => out.push('-'), c if c.is_control() => out.push('-'), c => out.push(c), } } let out = out.trim().trim_matches(['-', '.']).trim().to_string(); if out.is_empty() { "untitled".to_string() } else { out } } fn display_track(track: &Track) -> String { let artist = model::artist_names(&track.artists, &track.artist); let title = track.title.clone().unwrap_or_else(|| format!("Track {}", track.id)); format!("{artist} - {title}") } fn human_bytes(n: u64) -> String { const UNITS: &[&str] = &["B", "KB", "MB", "GB"]; let mut v = n as f64; let mut u = 0; while v >= 1024.0 && u + 1 < UNITS.len() { v /= 1024.0; u += 1; } if u == 0 { format!("{n} B") } else { format!("{v:.1} {}", UNITS[u]) } } fn now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } #[cfg(test)] mod tests { use super::*; #[test] fn filenames_are_filesystem_safe() { assert_eq!(sanitize_filename("a/b\\c:d*e?f\"gi|j"), "a-b-c-d-e-f-g-h-i-j"); assert_eq!(sanitize_filename(""), "untitled"); assert_eq!(sanitize_filename("Who's the Clown?"), "Who's the Clown"); } #[test] fn album_paths_join_cleanly() { let album = Album { title: Some("Who's the Clown?".to_string()), release_date: Some("2025-01-01".to_string()), ..Default::default() }; let dir = album_dir("./download/", &album); let s = dir.to_string_lossy(); assert!(!s.contains("//"), "{s}"); assert!(s.ends_with("Who's the Clown [2025]"), "{s}"); } }