use std::fmt;
use std::sync::Arc;
use std::time::Duration;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use quick_xml::XmlVersion;
use quick_xml::events::{BytesStart, Event};
use quick_xml::reader::Reader;
use serde::Deserialize;
use tidal_player_core::AudioQuality;
use crate::SUB_STATUS_NOT_AVAILABLE;
use crate::auth::{AuthError, Authenticator};
pub const RESOLVE_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Codec {
Flac,
AacLc,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Segment {
pub number: u64,
pub url: String,
pub start: u64,
pub duration: u64,
pub timescale: u32,
}
impl Segment {
pub fn start_time(&self) -> Duration {
ticks_to_duration(self.start, self.timescale)
}
pub fn duration_time(&self) -> Duration {
ticks_to_duration(self.duration, self.timescale)
}
pub fn end_time(&self) -> Duration {
ticks_to_duration(self.start + self.duration, self.timescale)
}
}
fn ticks_to_duration(ticks: u64, timescale: u32) -> Duration {
if timescale == 0 {
return Duration::ZERO;
}
let nanos = u128::from(ticks) * 1_000_000_000 / u128::from(timescale);
Duration::from_nanos(u64::try_from(nanos).unwrap_or(u64::MAX))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StreamPlan {
Single { url: String, codec: Codec },
Segmented {
init_url: String,
segments: Vec<Segment>,
codec: Codec,
},
}
impl StreamPlan {
pub fn codec(&self) -> Codec {
match self {
Self::Single { codec, .. } | Self::Segmented { codec, .. } => *codec,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedStream {
pub track_id: u64,
pub quality: AudioQuality,
pub bit_depth: Option<u8>,
pub sample_rate: Option<u32>,
pub plan: StreamPlan,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Unsupported {
Encrypted,
Manifest(String),
AudioMode(String),
Codec(String),
Quality(String),
}
impl fmt::Display for Unsupported {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Encrypted => f.write_str("the stream is encrypted"),
Self::Manifest(mime) => write!(f, "unknown manifest type {mime:?}"),
Self::AudioMode(mode) => write!(f, "audio mode {mode} is not supported"),
Self::Codec(codec) => write!(f, "codec {codec} is not supported"),
Self::Quality(quality) => write!(f, "unknown audio quality {quality:?}"),
}
}
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum StreamError {
#[error("the track is not available")]
NotAvailable,
#[error("the track was not found, or cannot be streamed in this country")]
NotFound,
#[error("the track is only available as a preview")]
PreviewOnly,
#[error("Tidal server error (HTTP {0})")]
Server(u16),
#[error("not playable: {0}")]
Unsupported(Unsupported),
#[error("malformed stream response: {0}")]
Malformed(&'static str),
#[error(transparent)]
Auth(#[from] AuthError),
}
#[derive(Debug, Clone)]
pub struct StreamResolver {
auth: Arc<Authenticator>,
timeout: Duration,
}
impl StreamResolver {
pub fn new(auth: Arc<Authenticator>) -> Self {
Self {
auth,
timeout: RESOLVE_TIMEOUT,
}
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub async fn resolve_stream(
&self,
track_id: u64,
max_quality: AudioQuality,
) -> Result<ResolvedStream, StreamError> {
let (_, country) = self.auth.account().await;
let path = format!("tracks/{track_id}/playbackinfopostpaywall");
let query = [
("audioquality", max_quality.tidal_name()),
("playbackmode", "STREAM"),
("assetpresentation", "FULL"),
("countryCode", country.as_str()),
];
let response = self.auth.get(&path, &query, Some(self.timeout)).await?;
if response.status().is_success() {
return parse_playback_info(response.body());
}
Err(map_error(response.status().as_u16(), response.sub_status()))
}
}
const SUB_STATUS_NOT_FOUND: u64 = 999;
fn map_error(status: u16, sub_status: Option<u64>) -> StreamError {
match (status, sub_status) {
(401, Some(SUB_STATUS_NOT_AVAILABLE)) => StreamError::NotAvailable,
(500, Some(SUB_STATUS_NOT_FOUND)) => StreamError::NotFound,
(500, _) => StreamError::Server(500),
(status, _) => StreamError::Auth(AuthError::Http { status, code: None }),
}
}
const MIME_BTS: &str = "application/vnd.tidal.bts";
const MIME_DASH: &str = "application/dash+xml";
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct PlaybackInfo {
track_id: u64,
asset_presentation: String,
audio_mode: String,
audio_quality: String,
manifest_mime_type: String,
manifest: String,
bit_depth: Option<u8>,
sample_rate: Option<u32>,
}
pub fn parse_playback_info(body: &[u8]) -> Result<ResolvedStream, StreamError> {
let info: PlaybackInfo = serde_json::from_slice(body)
.map_err(|_| StreamError::Malformed("playback info is not the expected JSON"))?;
if info.asset_presentation != "FULL" {
return Err(StreamError::PreviewOnly);
}
if info.audio_mode != "STEREO" {
return Err(StreamError::Unsupported(Unsupported::AudioMode(
info.audio_mode,
)));
}
let quality = serde_json::from_value(serde_json::Value::String(info.audio_quality.clone()))
.map_err(|_| StreamError::Unsupported(Unsupported::Quality(info.audio_quality)))?;
let plan = match info.manifest_mime_type.as_str() {
MIME_BTS => parse_bts(&decode_manifest(&info.manifest)?)?,
MIME_DASH => {
let xml = String::from_utf8(decode_manifest(&info.manifest)?)
.map_err(|_| StreamError::Malformed("MPD is not UTF-8"))?;
parse_mpd(&xml)?
}
_ => {
return Err(StreamError::Unsupported(Unsupported::Manifest(
info.manifest_mime_type,
)));
}
};
Ok(ResolvedStream {
track_id: info.track_id,
quality,
bit_depth: info.bit_depth,
sample_rate: info.sample_rate,
plan,
})
}
fn decode_manifest(manifest: &str) -> Result<Vec<u8>, StreamError> {
BASE64
.decode(manifest)
.map_err(|_| StreamError::Malformed("manifest is not base64"))
}
fn codec(codecs: &str) -> Result<Codec, StreamError> {
match codecs.trim().to_ascii_lowercase().as_str() {
"flac" => Ok(Codec::Flac),
"mp4a.40.2" => Ok(Codec::AacLc),
_ => Err(StreamError::Unsupported(Unsupported::Codec(
codecs.to_owned(),
))),
}
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Bts {
codecs: String,
encryption_type: String,
urls: Vec<String>,
}
fn parse_bts(manifest: &[u8]) -> Result<StreamPlan, StreamError> {
let bts: Bts = serde_json::from_slice(manifest)
.map_err(|_| StreamError::Malformed("BTS manifest is not the expected JSON"))?;
if bts.encryption_type != "NONE" {
return Err(StreamError::Unsupported(Unsupported::Encrypted));
}
let codec = codec(&bts.codecs)?;
let url = bts
.urls
.into_iter()
.next()
.ok_or(StreamError::Malformed("BTS manifest has no URL"))?;
Ok(StreamPlan::Single { url, codec })
}
const MAX_SEGMENTS: u64 = 100_000;
struct TimelineEntry {
t: Option<u64>,
d: u64,
r: u64,
}
#[derive(Default)]
struct Mpd {
encrypted: bool,
representations: usize,
adaptation_codecs: Option<String>,
representation_codecs: Option<String>,
timescale: Option<u32>,
initialization: Option<String>,
media: Option<String>,
start_number: Option<u64>,
timeline: Vec<TimelineEntry>,
}
fn parse_mpd(xml: &str) -> Result<StreamPlan, StreamError> {
let mpd = read_mpd(xml)?;
if mpd.encrypted {
return Err(StreamError::Unsupported(Unsupported::Encrypted));
}
if mpd.representations != 1 {
return Err(StreamError::Malformed(
"MPD must have exactly one Representation",
));
}
let codecs = mpd
.representation_codecs
.or(mpd.adaptation_codecs)
.ok_or(StreamError::Malformed("MPD has no codecs"))?;
let codec = codec(&codecs)?;
let init_url = mpd
.initialization
.ok_or(StreamError::Malformed("MPD has no initialization segment"))?;
let media = mpd
.media
.filter(|m| m.contains("$Number$"))
.ok_or(StreamError::Malformed(
"MPD has no media template with $Number$",
))?;
let timescale = mpd.timescale.unwrap_or(1);
if timescale == 0 {
return Err(StreamError::Malformed("MPD timescale is 0"));
}
if mpd.timeline.is_empty() {
return Err(StreamError::Malformed("MPD has no SegmentTimeline"));
}
let total: u64 = mpd
.timeline
.iter()
.map(|s| s.r.saturating_add(1))
.fold(0, u64::saturating_add);
if total > MAX_SEGMENTS {
return Err(StreamError::Malformed("MPD has too many segments"));
}
let mut segments = Vec::with_capacity(total as usize);
let mut time = 0u64;
let mut number = mpd.start_number.unwrap_or(1);
for entry in &mpd.timeline {
if let Some(t) = entry.t {
time = t;
}
for _ in 0..=entry.r {
segments.push(Segment {
number,
url: media.replace("$Number$", &number.to_string()),
start: time,
duration: entry.d,
timescale,
});
time = time
.checked_add(entry.d)
.ok_or(StreamError::Malformed("MPD timeline overflows"))?;
number += 1;
}
}
Ok(StreamPlan::Segmented {
init_url,
segments,
codec,
})
}
fn read_mpd(xml: &str) -> Result<Mpd, StreamError> {
const BAD_XML: StreamError = StreamError::Malformed("MPD is not well-formed XML");
let mut reader = Reader::from_str(xml);
let mut mpd = Mpd::default();
loop {
let element = match reader.read_event().map_err(|_| BAD_XML)? {
Event::Start(e) | Event::Empty(e) => e,
Event::Eof => break,
_ => continue,
};
let attrs = attributes(&element)?;
let get = |name: &str| {
attrs
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.clone())
};
match element.local_name().as_ref() {
"ContentProtection" => mpd.encrypted = true,
"AdaptationSet" => mpd.adaptation_codecs = get("codecs"),
"Representation" => {
mpd.representations += 1;
mpd.representation_codecs = get("codecs");
}
"SegmentTemplate" => {
mpd.timescale = get("timescale").map(|v| number(&v)).transpose()?;
mpd.initialization = get("initialization");
mpd.media = get("media");
mpd.start_number = get("startNumber").map(|v| number(&v)).transpose()?;
}
"S" => {
let d = get("d").ok_or(StreamError::Malformed("MPD segment has no duration"))?;
let d: u64 = number(&d)?;
if d == 0 {
return Err(StreamError::Malformed("MPD segment has no duration"));
}
mpd.timeline.push(TimelineEntry {
t: get("t").map(|v| number(&v)).transpose()?,
d,
r: get("r").map(|v| number(&v)).transpose()?.unwrap_or(0),
});
}
_ => {}
}
}
Ok(mpd)
}
fn attributes(element: &BytesStart<'_>) -> Result<Vec<(String, String)>, StreamError> {
const BAD_ATTR: StreamError = StreamError::Malformed("MPD has a malformed attribute");
element
.attributes()
.map(|attr| {
let attr = attr.map_err(|_| BAD_ATTR)?;
let key = attr.key.local_name().as_ref().to_owned();
let value = attr
.normalized_value(XmlVersion::Implicit1_0)
.map_err(|_| BAD_ATTR)?
.into_owned();
Ok((key, value))
})
.collect()
}
fn number<T: std::str::FromStr>(value: &str) -> Result<T, StreamError> {
value
.trim()
.parse()
.map_err(|_| StreamError::Malformed("MPD has a malformed number"))
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use serde_json::Value;
const HIRES: &str = include_str!("../tests/fixtures/stream/playbackinfo_hires_dash.json");
const LOSSLESS: &str = include_str!("../tests/fixtures/stream/playbackinfo_lossless_dash.json");
const HIGH: &str = include_str!("../tests/fixtures/stream/playbackinfo_high_bts.json");
const BTS_FLAC: &str =
include_str!("../tests/fixtures/stream/playbackinfo_lossless_bts_flac.json");
const MPD_HIRES: &str = include_str!("../tests/fixtures/stream/manifest_hires_24_96.mpd");
const MPD_LOSSLESS: &str = include_str!("../tests/fixtures/stream/manifest_lossless_16_44.mpd");
fn edit(fixture: &str, fields: &[(&str, Value)]) -> Vec<u8> {
let mut info: Value = serde_json::from_str(fixture).unwrap();
for (key, value) in fields {
info[*key] = value.clone();
}
serde_json::to_vec(&info).unwrap()
}
fn manifest(fixture: &str) -> String {
let info: Value = serde_json::from_str(fixture).unwrap();
let bytes = STANDARD.decode(info["manifest"].as_str().unwrap()).unwrap();
String::from_utf8(bytes).unwrap()
}
fn edit_manifest(fixture: &str, change: impl Fn(String) -> String) -> Vec<u8> {
let encoded = STANDARD.encode(change(manifest(fixture)));
edit(fixture, &[("manifest", Value::String(encoded))])
}
fn bts_with(field: &str, value: Value) -> Vec<u8> {
edit_manifest(HIGH, |m| {
let mut bts: Value = serde_json::from_str(&m).unwrap();
bts[field] = value.clone();
bts.to_string()
})
}
fn unsupported(what: Unsupported) -> StreamError {
StreamError::Unsupported(what)
}
#[test]
fn ac2_bts_manifest() {
let rows: Vec<(&str, Vec<u8>, Result<StreamPlan, StreamError>)> = vec![
(
"FLAC",
BTS_FLAC.as_bytes().to_vec(),
Ok(StreamPlan::Single {
url: "https://amz-pr-fa.audio.tidal.com/FAKE.flac?token=FAKE".into(),
codec: Codec::Flac,
}),
),
(
"AAC-LC",
HIGH.as_bytes().to_vec(),
Ok(StreamPlan::Single {
url: "https://amz-pr-fa.audio.tidal.com/FAKE.mp4?token=FAKE".into(),
codec: Codec::AacLc,
}),
),
(
"encrypted BTS",
bts_with("encryptionType", "OLD_AES".into()),
Err(unsupported(Unsupported::Encrypted)),
),
(
"encrypted DASH",
edit_manifest(HIRES, |m| {
m.replace(
"<Role ",
r#"<ContentProtection schemeIdUri="urn:mpeg:dash:mp4protection:2011" value="cenc"/><Role "#,
)
}),
Err(unsupported(Unsupported::Encrypted)),
),
(
"unknown manifest type",
edit(
HIGH,
&[("manifestMimeType", "application/vnd.tidal.emu".into())],
),
Err(unsupported(Unsupported::Manifest(
"application/vnd.tidal.emu".into(),
))),
),
];
for (name, body, expected) in rows {
let got = parse_playback_info(&body).map(|r| r.plan);
assert_eq!(got, expected, "{name}");
}
for (name, body) in [
("no URL", bts_with("urls", serde_json::json!([]))),
("not base64", edit(HIGH, &[("manifest", "%%%".into())])),
("not JSON", edit_manifest(HIGH, |_| "nope".into())),
] {
let got = parse_playback_info(&body);
assert!(
matches!(got, Err(StreamError::Malformed(_))),
"{name}: {got:?}"
);
}
}
fn segments(plan: &StreamPlan) -> (&str, &[Segment], Codec) {
match plan {
StreamPlan::Segmented {
init_url,
segments,
codec,
} => (init_url, segments, *codec),
other => panic!("not segmented: {other:?}"),
}
}
fn assert_timeline(segments: &[Segment], first_number: u64, total_ticks: u64) {
let mut next_start = segments.first().map_or(0, |s| s.start);
for (i, segment) in segments.iter().enumerate() {
assert_eq!(segment.number, first_number + i as u64, "number of #{i}");
assert_eq!(segment.start, next_start, "start of #{i}");
next_start += segment.duration;
}
let total: u64 = segments.iter().map(|s| s.duration).sum();
assert_eq!(total, total_ticks);
}
#[test]
fn ac3_dash_manifest() {
let plan = parse_mpd(MPD_HIRES).unwrap();
let (init_url, segs, codec) = segments(&plan);
assert_eq!(segs.len(), 74);
assert_eq!(codec, Codec::Flac);
assert_eq!(
init_url,
"https://sp-ad-fa.audio.tidal.com/mediatracks/FAKE/0.mp4?token=FAKE"
);
assert_timeline(segs, 1, 27_936_774);
let url =
|n| format!("https://sp-ad-fa.audio.tidal.com/mediatracks/FAKE/{n}.mp4?token=FAKE");
assert_eq!(segs[0].url, url(1));
assert_eq!(segs[1].url, url(2));
assert_eq!(segs[73].url, url(74));
assert_eq!(segs[0].start, 0);
assert_eq!(segs[0].start_time(), Duration::ZERO);
assert_eq!(segs[1].start_time(), Duration::from_millis(3968));
assert_eq!(segs[72].duration, 380_928);
assert_eq!(segs[73].start, 73 * 380_928);
assert_eq!(segs[73].duration, 129_030);
assert!(segs.iter().all(|s| s.timescale == 96_000));
assert_eq!(segs[73].end_time().as_millis(), 291_008);
let plan = parse_mpd(MPD_LOSSLESS).unwrap();
let (init_url, segs, _) = segments(&plan);
let query = "Policy=FAKE-POLICY&Signature=FAKE-SIG&Key-Pair-Id=FAKE-KEY";
assert_eq!(
init_url,
format!("https://sp-ad-cf.audio.tidal.com/mediatracks/FAKE/0.mp4?{query}")
);
assert_eq!(segs.len(), 57);
assert_timeline(segs, 1, 56 * 176_128 + 52_930);
assert_eq!(
segs[56].url,
format!("https://sp-ad-cf.audio.tidal.com/mediatracks/FAKE/57.mp4?{query}")
);
assert_eq!(segs[56].end_time().as_millis(), 224_854);
let mpd = r#"<MPD><Period><AdaptationSet><Representation codecs="flac">
<SegmentTemplate timescale="10" initialization="i.mp4" media="s$Number$.m4s" startNumber="5">
<SegmentTimeline><S t="100" d="20" r="2"/><S d="7"/><S d="3" r="1"/></SegmentTimeline>
</SegmentTemplate></Representation></AdaptationSet></Period></MPD>"#;
let plan = parse_mpd(mpd).unwrap();
let (init_url, segs, _) = segments(&plan);
assert_eq!(init_url, "i.mp4");
let got: Vec<(u64, &str, u64, u64)> = segs
.iter()
.map(|s| (s.number, s.url.as_str(), s.start, s.duration))
.collect();
assert_eq!(
got,
[
(5, "s5.m4s", 100, 20),
(6, "s6.m4s", 120, 20),
(7, "s7.m4s", 140, 20),
(8, "s8.m4s", 160, 7),
(9, "s9.m4s", 167, 3),
(10, "s10.m4s", 170, 3),
]
);
assert_eq!(segs[0].start_time(), Duration::from_secs(10));
}
#[test]
fn ac4_validate_playbackinfo() {
let errors: Vec<(&str, Vec<u8>, StreamError)> = vec![
(
"preview",
edit(HIRES, &[("assetPresentation", "PREVIEW".into())]),
StreamError::PreviewOnly,
),
(
"Dolby Atmos",
edit(HIRES, &[("audioMode", "DOLBY_ATMOS".into())]),
unsupported(Unsupported::AudioMode("DOLBY_ATMOS".into())),
),
(
"HE-AAC (LOW)",
bts_with("codecs", "mp4a.40.5".into()),
unsupported(Unsupported::Codec("mp4a.40.5".into())),
),
(
"HE-AAC v2",
bts_with("codecs", "mp4a.40.29".into()),
unsupported(Unsupported::Codec("mp4a.40.29".into())),
),
(
"HE-AAC in DASH",
edit_manifest(HIRES, |m| {
m.replace(r#"codecs="flac""#, r#"codecs="mp4a.40.5""#)
}),
unsupported(Unsupported::Codec("mp4a.40.5".into())),
),
(
"unknown quality",
edit(HIRES, &[("audioQuality", "HI_RES".into())]),
unsupported(Unsupported::Quality("HI_RES".into())),
),
];
for (name, body, expected) in errors {
assert_eq!(parse_playback_info(&body), Err(expected), "{name}");
}
let grants = [
(
"hi-res",
HIRES,
1001,
AudioQuality::HiResLossless,
Some(24),
Some(96_000),
Codec::Flac,
),
(
"lossless",
LOSSLESS,
1002,
AudioQuality::Lossless,
Some(16),
Some(44_100),
Codec::Flac,
),
(
"high",
HIGH,
1002,
AudioQuality::High,
None,
None,
Codec::AacLc,
),
];
for (name, body, track_id, quality, bits, rate, codec) in grants {
let got = parse_playback_info(body.as_bytes());
let got = got.unwrap_or_else(|e| panic!("{name}: {e:?}"));
assert_eq!(got.track_id, track_id, "{name}");
assert_eq!(got.quality, quality, "{name}");
assert_eq!(got.bit_depth, bits, "{name}");
assert_eq!(got.sample_rate, rate, "{name}");
assert_eq!(got.plan.codec(), codec, "{name}");
}
}
}