char-slop/tidal-dl

tidal ripper

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

Charlotte Somslop: initial commit3d04234

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