char-slop/tidal-dl

tidal ripper

git clone https://git.t4t.associates/char-slop/tidal-dl

Charlotte Somreencode flacs via ffmpeg because tidal gives us weird onesf146f6e

main
15.0 KiB426 linesraw
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 {
10    pub quality: AudioQuality,
11    pub 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.
14    pub 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");
19    let Options { quality, output, transcode } = opts;
20    let base = match output {
21        Some(o) if !o.is_empty() => o,
22        _ => crate::settings::load_settings().download_path,
23    };
24    for input in &urls {
25        let kind = model::parse_tidal_url(input)?;
26        download_one(&kind, quality, transcode, &base).await?;
27    }
28    Ok(())
29}
30
31async fn download_one(kind: &MediaKind, quality: AudioQuality, transcode: bool, base: &str) -> Result<()> {
32    let (client, _key) = session_client().await?;
33    let http = reqwest::Client::new();
34    match kind {
35        MediaKind::Track(id) => {
36            let track = client.track(*id).await?;
37            let album = if track.album.id != 0 {
38                client.album(track.album.id).await.ok()
39            } else {
40                None
41            };
42            download_track(&client, &http, &track, album.as_ref(), base, quality, transcode).await?;
43        }
44        MediaKind::Album(id) => {
45            let album = client.album(*id).await?;
46            let tracks = client.album_tracks(*id).await?;
47            anyhow::ensure!(!tracks.is_empty(), "album {id} has no downloadable tracks");
48            if let Some(cover) = album.cover.clone() {
49                let dir = album_dir(base, &album);
50                let dest = dir.join("cover.jpg");
51                if !dest.exists() {
52                    if let Some(bytes) = client.cover_bytes(&cover).await {
53                        std::fs::create_dir_all(&dir)?;
54                        std::fs::write(&dest, bytes)?;
55                    }
56                }
57            }
58            for track in &tracks {
59                download_track(&client, &http, track, Some(&album), base, quality, transcode).await?;
60            }
61        }
62    }
63    Ok(())
64}
65
66async fn session_client() -> Result<(TidalClient, ApiKey)> {
67    let key = crate::api_key::load_key();
68    let mut token = crate::settings::load_token();
69    let access = token.access_token.clone().unwrap_or_default();
70    anyhow::ensure!(!access.is_empty(), "not logged in (run `tidal-dl login`)");
71    let country = token.country_code.clone().unwrap_or_else(|| "US".to_string());
72    let http = reqwest::Client::new();
73    if !crate::tidal::verify_access_token(&http, &access).await {
74        let refresh = token.refresh_token.clone().unwrap_or_default();
75        anyhow::ensure!(!refresh.is_empty(), "session expired (run `tidal-dl login`)");
76        let 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;
84        crate::settings::save_token(&token)?;
85        return Ok((
86            TidalClient::new(token.access_token.unwrap(), token.country_code.unwrap()),
87            key,
88        ));
89    }
90    Ok((TidalClient::new(access, country), key))
91}
92
93async fn download_track(
94    client: &TidalClient,
95    http: &reqwest::Client,
96    track: &Track,
97    album: Option<&Album>,
98    base: &str,
99    quality: AudioQuality,
100    transcode: bool,
101) -> Result<PathBuf> {
102    let stream = client.stream_url(track.id, quality).await?;
103    let dest: PathBuf = track_path(base, track, album, &stream);
104    let title = display_track(track);
105
106    if should_skip(&dest) {
107        println!("skip (exists): {title}");
108        return Ok(dest);
109    }
110
111    println!("{} [{}]", title, stream.sound_quality.as_deref().unwrap_or("?"));
112    let part = dest.with_extension("part");
113    if let Some(parent) = dest.parent() {
114        std::fs::create_dir_all(parent)?;
115    }
116    fetch_to(http, &stream.urls, &part).await?;
117
118    if !stream.encryption_key.is_empty() {
119        let (key, nonce) = crate::decrypt::decrypt_security_token(&stream.encryption_key)?;
120        let plain = crate::decrypt::decrypt_bytes(std::fs::read(&part)?, &key, &nonce);
121        std::fs::write(&part, plain)?;
122    }
123
124    if transcode && dest.extension().is_some_and(|e| e == "flac") {
125        reencode_flac(&part, &dest).await?;
126        let _ = std::fs::remove_file(&part);
127    } else {
128        std::fs::rename(&part, &dest)?;
129    }
130
131    let contributors = client.contributors(track.id).await;
132    let lyrics = client.lyrics(track.id).await;
133    let cover = match album.and_then(|a| a.cover.clone()) {
134        Some(c) => client.cover_bytes(&c).await,
135        None => None,
136    };
137    tag_file(&dest, track, album, contributors.as_ref(), lyrics.as_deref(), cover)?;
138
139    let size = std::fs::metadata(&dest).map(|m| m.len()).unwrap_or(0);
140    println!("wrote {} ({})", dest.display(), human_bytes(size));
141    Ok(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<()> {
153    let encoded = dest.with_extension("encoded.flac");
154    let 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?)")?;
162    if !out.status.success() {
163        let _ = std::fs::remove_file(&encoded);
164        anyhow::bail!(
165            "ffmpeg flac encode failed: {}",
166            String::from_utf8_lossy(&out.stderr).trim()
167        );
168    }
169    std::fs::rename(&encoded, dest)?;
170    Ok(())
171}
172
173async fn fetch_to(http: &reqwest::Client, urls: &[String], part: &Path) -> Result<()> {
174    anyhow::ensure!(!urls.is_empty(), "empty stream");
175    if urls.len() == 1 {
176        let mut resp = http.get(&urls[0]).send().await.context("stream request")?;
177        anyhow::ensure!(resp.status().is_success(), "stream http {}", resp.status());
178        let total = resp.content_length().unwrap_or(0);
179        let bar = if total > 0 {
180            let bar = ProgressBar::new(total);
181            bar.set_style(
182                ProgressStyle::with_template("{bar:40} {bytes}/{total_bytes} {bytes_per_sec}")
183                    .unwrap()
184                    .progress_chars("=> "),
185            );
186            bar
187        } else {
188            ProgressBar::new_spinner()
189        };
190        let mut file = tokio::fs::File::create(part).await?;
191        let mut bytes: u64 = 0;
192        use tokio::io::AsyncWriteExt;
193        loop {
194            let chunk = resp.chunk().await.context("stream body")?;
195            let Some(chunk) = chunk else { break; };
196            file.write_all(&chunk).await?;
197            bytes += chunk.len() as u64;
198            if 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();
206        return Ok(());
207    }
208    for url in urls {
209        anyhow::ensure!(url.starts_with("http"), "relative segment URL not supported: {url}");
210    }
211    let bar = ProgressBar::new_spinner();
212    bar.set_style(ProgressStyle::with_template("{spinner} {msg}").unwrap());
213    let mut file = std::fs::File::create(part)?;
214    use std::io::Write;
215    for (i, url) in urls.iter().enumerate() {
216        bar.set_message(format!("segment {}/{}", i + 1, urls.len()));
217        let 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();
221    Ok(())
222}
223
224fn tag_file(
225    path: &Path,
226    track: &Track,
227    album: Option<&Album>,
228    contributors: Option<&serde_json::Value>,
229    lyrics: Option<&str>,
230    cover: Option<Vec<u8>>,
231) -> Result<()> {
232    use lofty::picture::{MimeType, Picture, PictureType};
233    use lofty::prelude::TaggedFileExt;
234    use lofty::tag::{ItemKey, Tag};
235
236    let mut title = track.title.clone().unwrap_or_else(|| format!("Track {}", track.id));
237    if let Some(v) = track.version.clone().filter(|v| !v.is_empty()) {
238        title = format!("{title} ({v})");
239    }
240    let artists = model::artist_names(&track.artists, &track.artist);
241    let album_title = album
242        .and_then(|a| a.title.clone())
243        .or_else(|| track.album.title.clone())
244        .unwrap_or_default();
245    let 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));
248    let date = album
249        .and_then(|a| a.release_date.clone())
250        .or(track.album.release_date.clone())
251        .unwrap_or_default();
252
253    let mut tagged = lofty::read_from_path(path).context("read audio for tagging")?;
254    if tagged.primary_tag().is_none() {
255        tagged.insert_tag(Tag::new(tagged.primary_tag_type()));
256    }
257    let tag = tagged.primary_tag_mut().expect("just ensured");
258    tag.insert_text(ItemKey::TrackTitle, title);
259    if !artists.is_empty() {
260        tag.insert_text(ItemKey::TrackArtist, artists);
261    }
262    if !album_title.is_empty() {
263        tag.insert_text(ItemKey::AlbumTitle, album_title);
264    }
265    if !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());
270    if !date.is_empty() {
271        tag.insert_text(ItemKey::RecordingDate, date);
272    }
273    if let Some(c) = track.copy_right.clone().filter(|c| !c.is_empty()) {
274        tag.insert_text(ItemKey::CopyrightMessage, c);
275    }
276    if let Some(isrc) = track.isrc.clone().filter(|s| !s.is_empty()) {
277        tag.insert_text(ItemKey::Isrc, isrc);
278    }
279    if let Some(composers) = composers(contributors) {
280        tag.insert_text(ItemKey::Composer, composers);
281    }
282    if let Some(lyrics) = lyrics.filter(|l| !l.is_empty()) {
283        tag.insert_text(ItemKey::Lyrics, lyrics.to_string());
284    }
285    if let Some(bytes) = cover {
286        tag.push_picture(Picture::new_unchecked(
287            PictureType::CoverFront,
288            Some(MimeType::Jpeg),
289            None,
290            bytes,
291        ));
292    }
293    use lofty::file::AudioFile;
294    tagged
295        .save_to_path(path, lofty::config::WriteOptions::default())
296        .context("save tags")?;
297    Ok(())
298}
299
300fn composers(contributors: Option<&serde_json::Value>) -> Option<String> {
301    let items = contributors?.get("items")?.as_array()?;
302    let 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();
307    if names.is_empty() {
308        None
309    } else {
310        Some(names.join(", "))
311    }
312}
313
314fn track_path(base: &str, track: &Track, album: Option<&Album>, stream: &StreamUrl) -> PathBuf {
315    let ext = extension_for(stream);
316    let mut title = track.title.clone().unwrap_or_else(|| format!("Track {}", track.id));
317    if let Some(v) = track.version.clone().filter(|v| !v.is_empty()) {
318        title = format!("{title} ({v})");
319    }
320    let explicit = if track.explicit { " (Explicit)" } else { "" };
321    let file = format!("{:02} - {title}{explicit}.{ext}", track.track_number.max(1));
322    match album {
323        Some(album) => {
324            let mut dir = album_dir(base, album);
325            if album.number_of_volumes > 1 {
326                dir.push(format!("CD{}", track.volume_number.max(1)));
327            }
328            dir.join(sanitize_filename(&file))
329        }
330        None => PathBuf::from(base).join(sanitize_filename(&file)),
331    }
332}
333
334fn album_dir(base: &str, album: &Album) -> PathBuf {
335    let title = sanitize_filename(
336        &album.title.clone().unwrap_or_else(|| format!("Album {}", album.id)),
337    );
338    let year = model::album_year(album.release_date.as_deref());
339    Path::new(base).join(format!("{title} [{year}]"))
340}
341
342fn extension_for(stream: &StreamUrl) -> &'static str {
343    if stream.url.contains(".flac") {
344        return "flac";
345    }
346    if stream.url.contains(".mp4") {
347        if stream.codec.contains("ac4") || stream.codec.contains("mha1") {
348            return "mp4";
349        }
350        if stream.codec.contains("flac") {
351            return "flac";
352        }
353        return "m4a";
354    }
355    "m4a"
356}
357
358fn sanitize_filename(s: &str) -> String {
359    let mut out = String::with_capacity(s.len());
360    for c in s.chars() {
361        match c {
362            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => out.push('-'),
363            c if c.is_control() => out.push('-'),
364            c => out.push(c),
365        }
366    }
367    let out = out.trim().trim_matches(['-', '.']).trim().to_string();
368    if out.is_empty() {
369        "untitled".to_string()
370    } else {
371        out
372    }
373}
374
375fn display_track(track: &Track) -> String {
376    let artist = model::artist_names(&track.artists, &track.artist);
377    let title = track.title.clone().unwrap_or_else(|| format!("Track {}", track.id));
378    format!("{artist} - {title}")
379}
380
381fn human_bytes(n: u64) -> String {
382    const UNITS: &[&str] = &["B", "KB", "MB", "GB"];
383    let mut v = n as f64;
384    let mut u = 0;
385    while v >= 1024.0 && u + 1 < UNITS.len() {
386        v /= 1024.0;
387        u += 1;
388    }
389    if u == 0 {
390        format!("{n} B")
391    } else {
392        format!("{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 {
405    use super::*;
406
407    #[test]
408    fn filenames_are_filesystem_safe() {
409        assert_eq!(sanitize_filename("a/b\\c:d*e?f\"g<h>i|j"), "a-b-c-d-e-f-g-h-i-j");
410        assert_eq!(sanitize_filename(""), "untitled");
411        assert_eq!(sanitize_filename("Who's the Clown?"), "Who's the Clown");
412    }
413
414    #[test]
415    fn album_paths_join_cleanly() {
416        let album = Album {
417            title: Some("Who's the Clown?".to_string()),
418            release_date: Some("2025-01-01".to_string()),
419            ..Default::default()
420        };
421        let dir = album_dir("./download/", &album);
422        let s = dir.to_string_lossy();
423        assert!(!s.contains("//"), "{s}");
424        assert!(s.ends_with("Who's the Clown [2025]"), "{s}");
425    }
426}