char-slop/tidal-dl

tidal ripper

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

Charlotte Somslop: initial commit3d04234

main
5.2 KiB180 linesraw
1use serde::Deserialize;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum, Default)]
4pub enum AudioQuality {
5    Normal,
6    High,
7    Hifi,
8    Master,
9    #[default]
10    Max,
11}
12
13impl AudioQuality {
14    pub fn tidal_value(&self) -> &'static str {
15        match self {
16            Self::Normal => "LOW",
17            Self::High => "HIGH",
18            Self::Hifi => "LOSSLESS",
19            Self::Master => "HI_RES",
20            Self::Max => "HI_RES_LOSSLESS",
21        }
22    }
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub enum MediaKind {
27    Track(u64),
28    Album(u64),
29}
30
31pub fn parse_tidal_url(s: &str) -> anyhow::Result<MediaKind> {
32    let s = s.trim();
33    if let Ok(id) = s.parse::<u64>() {
34        return Ok(MediaKind::Track(id));
35    }
36    let lower = s.to_lowercase();
37    anyhow::ensure!(lower.contains("tidal.com"), "not a tidal URL: {s}");
38    for kind in ["track", "album"] {
39        let marker = format!("/{kind}/");
40        if let Some(pos) = lower.find(&marker) {
41            let rest = &s[pos + marker.len()..];
42            let end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
43            let id: u64 = rest[..end]
44                .parse()
45                .map_err(|_| anyhow::anyhow!("couldn't parse {kind} id from {s}"))?;
46            return Ok(match kind {
47                "track" => MediaKind::Track(id),
48                _ => MediaKind::Album(id),
49            });
50        }
51    }
52    anyhow::bail!("couldn't find a track or album id in {s}")
53}
54
55#[derive(Debug, Clone, Deserialize, Default)]
56#[serde(default)]
57pub struct Artist {
58    #[serde(deserialize_with = "de_id")]
59    pub id: u64,
60    pub name: Option<String>,
61}
62
63#[derive(Debug, Clone, Deserialize, Default)]
64#[serde(default, rename_all = "camelCase")]
65pub struct Album {
66    #[serde(deserialize_with = "de_id")]
67    pub id: u64,
68    pub title: Option<String>,
69    pub duration: i64,
70    pub number_of_tracks: i64,
71    pub number_of_volumes: i64,
72    pub release_date: Option<String>,
73    pub cover: Option<String>,
74    pub explicit: bool,
75    pub audio_quality: Option<String>,
76    #[serde(deserialize_with = "de_artist")]
77    pub artist: Artist,
78    #[serde(default, deserialize_with = "de_artists")]
79    pub artists: Vec<Artist>,
80}
81
82#[derive(Debug, Clone, Deserialize, Default)]
83#[serde(default, rename_all = "camelCase")]
84pub struct Track {
85    #[serde(deserialize_with = "de_id")]
86    pub id: u64,
87    pub title: Option<String>,
88    pub duration: i64,
89    pub track_number: i64,
90    pub volume_number: i64,
91    pub version: Option<String>,
92    pub isrc: Option<String>,
93    pub explicit: bool,
94    pub audio_quality: Option<String>,
95    pub copy_right: Option<String>,
96    #[serde(deserialize_with = "de_artist")]
97    pub artist: Artist,
98    #[serde(default, deserialize_with = "de_artists")]
99    pub artists: Vec<Artist>,
100    pub album: Album,
101}
102
103#[derive(Debug, Clone, Deserialize, Default)]
104#[serde(default, rename_all = "camelCase")]
105pub struct StreamManifest {
106    pub track_id: Option<serde_json::Value>,
107    pub audio_quality: Option<String>,
108    pub manifest_mime_type: Option<String>,
109    pub manifest: Option<String>,
110}
111
112#[derive(Debug, Clone)]
113pub struct StreamUrl {
114    pub url: String,
115    pub urls: Vec<String>,
116    pub codec: String,
117    pub encryption_key: String,
118    pub sound_quality: Option<String>,
119}
120
121fn de_id<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u64, D::Error> {
122    let v: Option<serde_json::Value> = serde::Deserialize::deserialize(d)?;
123    match v {
124        None | Some(serde_json::Value::Null) => Ok(0),
125        Some(serde_json::Value::Number(n)) => Ok(n.as_u64().unwrap_or(0)),
126        Some(serde_json::Value::String(s)) => Ok(s.parse().unwrap_or(0)),
127        _ => Ok(0),
128    }
129}
130
131fn de_artist<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Artist, D::Error> {
132    let v: Option<Artist> = serde::Deserialize::deserialize(d)?;
133    Ok(v.unwrap_or_default())
134}
135
136fn de_artists<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<Artist>, D::Error> {
137    let v: Option<Vec<Artist>> = serde::Deserialize::deserialize(d)?;
138    Ok(v.unwrap_or_default())
139}
140
141pub fn artist_names(artists: &[Artist], fallback: &Artist) -> String {
142    if artists.is_empty() {
143        return fallback.name.clone().unwrap_or_default();
144    }
145    artists
146        .iter()
147        .filter_map(|a| a.name.clone())
148        .collect::<Vec<_>>()
149        .join(", ")
150}
151
152pub fn album_year(release_date: Option<&str>) -> String {
153    release_date
154        .and_then(|d| d.split('-').next())
155        .unwrap_or("")
156        .to_string()
157}
158
159pub fn cover_url(cover: Option<&str>, w: u32, h: u32) -> Option<String> {
160    cover.map(|id| format!("https://resources.tidal.com/images/{}/{w}x{h}.jpg", id.replace('-', "/")))
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn parses_track_urls_and_bare_ids() {
169        assert_eq!(
170            parse_tidal_url("https://tidal.com/browse/track/70973230").unwrap(),
171            MediaKind::Track(70973230)
172        );
173        assert_eq!(
174            parse_tidal_url("https://tidal.com/browse/album/58138532").unwrap(),
175            MediaKind::Album(58138532)
176        );
177        assert_eq!(parse_tidal_url("70973230").unwrap(), MediaKind::Track(70973230));
178        assert!(parse_tidal_url("https://example.com/foo").is_err());
179    }
180}