use anyhow::{Context, Result}; use crate::model::{Album, AudioQuality, StreamManifest, StreamUrl, Track}; const API_BASE: &str = "https://api.tidalhifi.com/v1/"; pub struct TidalClient { http: reqwest::Client, token: String, country_code: String, } impl TidalClient { pub fn new(token: String, country_code: String) -> Self { Self { http: reqwest::Client::new(), token, country_code, } } async fn get(&self, path: &str, params: &[(&str, String)]) -> Result { let mut query: Vec<(&str, String)> = params.to_vec(); query.push(("countryCode", self.country_code.clone())); let mut last_err = String::new(); for _ in 0..3 { let resp = self .http .get(format!("{API_BASE}{path}")) .bearer_auth(&self.token) .query(&query) .send() .await; let resp = match resp { Ok(r) => r, Err(e) => { last_err = e.to_string(); continue; } }; if resp.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { tokio::time::sleep(std::time::Duration::from_secs(20)).await; continue; } let value: serde_json::Value = resp.json().await.context("tidal api response")?; if let Some(status) = value.get("status") { let msg = value .get("userMessage") .and_then(|m| m.as_str()) .unwrap_or(""); anyhow::bail!("tidal api error {status}: {msg}"); } return Ok(value); } anyhow::bail!("tidal api request failed: {last_err}"); } async fn get_items(&self, path: &str) -> Result> { let mut out = Vec::new(); let mut offset = 0; loop { let data = self .get( path, &[ ("limit", "50".to_string()), ("offset", offset.to_string()), ], ) .await?; let total = data.get("totalNumberOfItems").and_then(|t| t.as_i64()); let items = data .get("items") .and_then(|i| i.as_array()) .cloned() .unwrap_or_default(); let n = items.len(); out.extend(items); if n < 50 { break; } if let Some(total) = total { if out.len() >= total as usize { break; } } offset += n; } Ok(out) } pub async fn track(&self, id: u64) -> Result { let v = self.get(&format!("tracks/{id}"), &[]).await?; serde_json::from_value(v).context("parse track") } pub async fn album(&self, id: u64) -> Result { let v = self.get(&format!("albums/{id}"), &[]).await?; serde_json::from_value(v).context("parse album") } pub async fn album_tracks(&self, id: u64) -> Result> { let items = self.get_items(&format!("albums/{id}/items")).await?; let mut tracks = Vec::new(); for entry in items { if entry.get("type").and_then(|t| t.as_str()) != Some("track") { continue; } if let Some(item) = entry.get("item") { if item.get("streamReady").and_then(|s| s.as_bool()) == Some(false) { continue; } tracks.push(serde_json::from_value(item.clone()).context("parse album track")?); } } Ok(tracks) } pub async fn stream_url(&self, id: u64, quality: AudioQuality) -> Result { let data = self .get( &format!("tracks/{id}/playbackinfopostpaywall"), &[ ("audioquality", quality.tidal_value().to_string()), ("playbackmode", "STREAM".to_string()), ("assetpresentation", "FULL".to_string()), ], ) .await?; let resp: StreamManifest = serde_json::from_value(data).context("parse stream response")?; let mime = resp.manifest_mime_type.clone().unwrap_or_default(); let raw = resp.manifest.clone().unwrap_or_default(); if mime.contains("vnd.tidal.bt") { let manifest: serde_json::Value = serde_json::from_slice( &base64_decode(&raw).context("decode bt manifest")?, ) .context("parse bt manifest")?; let urls = manifest .get("urls") .and_then(|u| u.as_array()) .map(|a| { a.iter().filter_map(|u| u.as_str().map(str::to_string)).collect::>() }) .unwrap_or_default(); let url = urls.first().cloned().unwrap_or_default(); anyhow::ensure!(!url.is_empty(), "empty stream url in manifest"); return Ok(StreamUrl { url: url.clone(), urls: vec![url], codec: manifest .get("codecs") .and_then(|c| c.as_str()) .unwrap_or("") .to_string(), encryption_key: manifest .get("keyId") .and_then(|k| k.as_str()) .unwrap_or("") .to_string(), sound_quality: resp.audio_quality, }); } if mime.contains("dash+xml") { let xml = String::from_utf8(base64_decode(&raw).context("decode dash manifest")?) .context("dash manifest utf-8")?; let urls = parse_mpd(&xml)?; anyhow::ensure!(!urls.is_empty(), "no audio segments in dash manifest"); return Ok(StreamUrl { url: urls[0].clone(), urls, codec: String::new(), encryption_key: String::new(), sound_quality: resp.audio_quality, }); } anyhow::bail!("unsupported manifest type: {mime}") } pub async fn contributors(&self, id: u64) -> Option { self.get(&format!("tracks/{id}/contributors"), &[]).await.ok() } pub async fn lyrics(&self, id: u64) -> Option { let resp = self .http .get(format!("https://listen.tidal.com/v1/tracks/{id}/lyrics")) .bearer_auth(&self.token) .send() .await .ok()?; let v: serde_json::Value = resp.json().await.ok()?; v.get("subtitles").and_then(|s| s.as_str()).map(str::to_string) } pub async fn cover_bytes(&self, cover: &str) -> Option> { let url = crate::model::cover_url(Some(cover), 1280, 1280)?; self.http.get(url).send().await.ok()?.bytes().await.ok().map(|b| b.to_vec()) } } fn base64_decode(s: &str) -> Result> { use base64::{engine::general_purpose::STANDARD, Engine as _}; STANDARD.decode(s).context("base64") } pub fn parse_mpd(xml: &str) -> Result> { use quick_xml::events::Event; use quick_xml::reader::Reader; let xml = strip_xmlns_once(xml); let mut reader = Reader::from_str(&xml); reader.config_mut().trim_text(true); let mut in_audio = false; let mut audio_depth = 0usize; let mut depth = 0usize; let mut codec = String::new(); let mut init: Option = None; let mut media: Option = None; let mut start_number: u64 = 1; let mut timeline: Vec<(u64, i64)> = Vec::new(); loop { match reader.read_event() { Err(e) => anyhow::bail!("mpd parse: {e}"), Ok(Event::Eof) => break, Ok(Event::Start(e)) => { depth += 1; match e.name().as_ref() { b"AdaptationSet" => { let audio = attr(&e, "contentType") == Some("audio".to_string()); if audio { in_audio = true; audio_depth = depth; } } b"Representation" if in_audio && codec.is_empty() => { if let Some(c) = attr(&e, "codecs") { let c = c.to_uppercase(); codec = if c.starts_with("MP4A") { "AAC".to_string() } else { c }; } } b"SegmentTemplate" if in_audio && init.is_none() => { init = attr(&e, "initialization"); media = attr(&e, "media"); start_number = attr(&e, "startNumber") .and_then(|s| s.parse().ok()) .unwrap_or(1); } b"S" if in_audio && init.is_some() => { timeline.push(( attr(&e, "d").and_then(|s| s.parse().ok()).unwrap_or(0), attr(&e, "r").and_then(|s| s.parse().ok()).unwrap_or(0), )); } _ => {} } } Ok(Event::Empty(e)) => match e.name().as_ref() { b"Representation" if in_audio && codec.is_empty() => { if let Some(c) = attr(&e, "codecs") { let c = c.to_uppercase(); codec = if c.starts_with("MP4A") { "AAC".to_string() } else { c }; } } b"SegmentTemplate" if in_audio && init.is_none() => { init = attr(&e, "initialization"); media = attr(&e, "media"); start_number = attr(&e, "startNumber").and_then(|s| s.parse().ok()).unwrap_or(1); } b"S" if in_audio && init.is_some() => { timeline.push(( attr(&e, "d").and_then(|s| s.parse().ok()).unwrap_or(0), attr(&e, "r").and_then(|s| s.parse().ok()).unwrap_or(0), )); } _ => {} }, Ok(Event::End(e)) => { if e.name().as_ref() == b"AdaptationSet" && in_audio && depth == audio_depth { in_audio = false; if init.is_some() { break; } } depth = depth.saturating_sub(1); } _ => {} } } let (Some(init), Some(media)) = (init, media) else { anyhow::bail!("no audio SegmentTemplate in mpd"); }; let _ = codec; let count: usize = timeline.iter().map(|(_, r)| (*r + 1) as usize).sum(); let mut urls = Vec::with_capacity(count + 1); urls.push(init); let mut n = start_number; for (_, r) in &timeline { for _ in 0..(*r + 1) { urls.push(media.replace("$Number$", &n.to_string())); n += 1; } } Ok(urls) } fn strip_xmlns_once(xml: &str) -> String { if let Some(start) = xml.find("xmlns=\"") { if let Some(end) = xml[start..].find('"').and_then(|_| xml[start + 7..].find('"')) { let end = start + 7 + end + 1; let mut out = String::with_capacity(xml.len()); out.push_str(&xml[..start]); out.push_str(&xml[end..]); return out; } } xml.to_string() } fn attr(e: &quick_xml::events::BytesStart, name: &str) -> Option { e.attributes().find_map(|a| { let a = a.ok()?; if a.key.as_ref() != name.as_bytes() { return None; } let raw = a.value.into_owned(); let s = String::from_utf8(raw).ok()?; quick_xml::escape::unescape(&s).ok().map(|c| c.into_owned()) }) } #[cfg(test)] mod tests { use super::*; #[test] fn mpd_expands_segment_timeline() { let xml = r#""#; let urls = parse_mpd(xml).unwrap(); assert_eq!(urls, vec!["init.mp4", "seg-1.m4s", "seg-2.m4s", "seg-3.m4s"]); } }