Skip to main content

tidal_player_api/
stream.rs

1//! Stream resolution (spec 0003, AC1–AC5): turns a track ID into a
2//! [`StreamPlan`] the player can fetch, through
3//! `GET /tracks/{id}/playbackinfopostpaywall`.
4//!
5//! One request per resolve, at the highest quality asked for: Tidal grants
6//! less by itself when the track or the account has less, and the granted
7//! quality is reported in [`ResolvedStream::quality`]. Nothing is downloaded
8//! here; the plan only says where the bytes are.
9
10use std::fmt;
11use std::sync::Arc;
12use std::time::Duration;
13
14use base64::Engine;
15use base64::engine::general_purpose::STANDARD as BASE64;
16use quick_xml::XmlVersion;
17use quick_xml::events::{BytesStart, Event};
18use quick_xml::reader::Reader;
19use serde::Deserialize;
20use tidal_player_core::AudioQuality;
21
22use crate::SUB_STATUS_NOT_AVAILABLE;
23use crate::auth::{AuthError, Authenticator};
24
25/// How long the `playbackinfopostpaywall` request may take before it fails
26/// with [`AuthError::Transport`].
27pub const RESOLVE_TIMEOUT: Duration = Duration::from_secs(10);
28
29/// An audio codec the player can decode.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum Codec {
32    /// FLAC, as a raw FLAC file or in (fragmented) MP4.
33    Flac,
34    /// AAC-LC (`mp4a.40.2`) in MP4.
35    AacLc,
36}
37
38/// One media segment of a [`StreamPlan::Segmented`] stream.
39///
40/// Times are in ticks of `timescale` per second, as in the MPD, so they add
41/// up exactly; [`Segment::start_time`] and [`Segment::duration_time`] convert.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Segment {
44    /// The DASH segment number (`$Number$`).
45    pub number: u64,
46    /// The segment's URL.
47    pub url: String,
48    /// Start, in ticks from the start of the track.
49    pub start: u64,
50    /// Length, in ticks.
51    pub duration: u64,
52    /// Ticks per second.
53    pub timescale: u32,
54}
55
56impl Segment {
57    /// Start from the start of the track (rounded down to the nanosecond).
58    pub fn start_time(&self) -> Duration {
59        ticks_to_duration(self.start, self.timescale)
60    }
61
62    /// Length (rounded down to the nanosecond).
63    pub fn duration_time(&self) -> Duration {
64        ticks_to_duration(self.duration, self.timescale)
65    }
66
67    /// End from the start of the track (rounded down to the nanosecond).
68    pub fn end_time(&self) -> Duration {
69        ticks_to_duration(self.start + self.duration, self.timescale)
70    }
71}
72
73fn ticks_to_duration(ticks: u64, timescale: u32) -> Duration {
74    if timescale == 0 {
75        return Duration::ZERO;
76    }
77    let nanos = u128::from(ticks) * 1_000_000_000 / u128::from(timescale);
78    Duration::from_nanos(u64::try_from(nanos).unwrap_or(u64::MAX))
79}
80
81/// Where a track's bytes are.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum StreamPlan {
84    /// One file (BTS manifest), fetched with HTTP `Range` requests.
85    Single { url: String, codec: Codec },
86    /// MPEG-DASH: the init segment, then `segments` in order, joined.
87    Segmented {
88        init_url: String,
89        segments: Vec<Segment>,
90        codec: Codec,
91    },
92}
93
94impl StreamPlan {
95    /// The codec of the stream.
96    pub fn codec(&self) -> Codec {
97        match self {
98            Self::Single { codec, .. } | Self::Segmented { codec, .. } => *codec,
99        }
100    }
101}
102
103/// A resolved track: what Tidal granted, and where to fetch it.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct ResolvedStream {
106    /// The track ID Tidal answered for.
107    pub track_id: u64,
108    /// The quality Tidal **granted** (may be lower than asked).
109    pub quality: AudioQuality,
110    /// Bits per sample, when the response says (lossless grants).
111    pub bit_depth: Option<u8>,
112    /// Sample rate in Hz, when the response says (lossless grants).
113    pub sample_rate: Option<u32>,
114    pub plan: StreamPlan,
115}
116
117/// What made a stream unplayable.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum Unsupported {
120    /// An `encryptionType` other than `NONE`, or DASH `ContentProtection`.
121    Encrypted,
122    /// A `manifestMimeType` other than BTS or DASH.
123    Manifest(String),
124    /// An `audioMode` other than `STEREO` (Dolby Atmos, Sony 360).
125    AudioMode(String),
126    /// A codec other than FLAC or AAC-LC (e.g. HE-AAC).
127    Codec(String),
128    /// An `audioQuality` this player does not know.
129    Quality(String),
130}
131
132impl fmt::Display for Unsupported {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        match self {
135            Self::Encrypted => f.write_str("the stream is encrypted"),
136            Self::Manifest(mime) => write!(f, "unknown manifest type {mime:?}"),
137            Self::AudioMode(mode) => write!(f, "audio mode {mode} is not supported"),
138            Self::Codec(codec) => write!(f, "codec {codec} is not supported"),
139            Self::Quality(quality) => write!(f, "unknown audio quality {quality:?}"),
140        }
141    }
142}
143
144/// Why a track could not be resolved.
145#[derive(Debug, Clone, PartialEq, thiserror::Error)]
146pub enum StreamError {
147    /// `401` with `subStatus` `4005`: not playable for this account or
148    /// country (or at this quality).
149    #[error("the track is not available")]
150    NotAvailable,
151    /// `500` with `subStatus` `999`: unknown track ID, or not streamable in
152    /// the account's country.
153    #[error("the track was not found, or cannot be streamed in this country")]
154    NotFound,
155    /// The response is for a preview, not the full track.
156    #[error("the track is only available as a preview")]
157    PreviewOnly,
158    /// Another `500`.
159    #[error("Tidal server error (HTTP {0})")]
160    Server(u16),
161    /// The stream exists but cannot be played.
162    #[error("not playable: {0}")]
163    Unsupported(Unsupported),
164    /// A `200` whose body or manifest could not be read; says what was
165    /// wrong, never quotes the body (its URLs carry tokens).
166    #[error("malformed stream response: {0}")]
167    Malformed(&'static str),
168    /// From the [`Authenticator`], unchanged: transport errors and
169    /// timeouts, other statuses (`5xx`, `429`, …), `LoginRequired`.
170    #[error(transparent)]
171    Auth(#[from] AuthError),
172}
173
174/// Resolves tracks to streams through the [`Authenticator`].
175#[derive(Debug, Clone)]
176pub struct StreamResolver {
177    auth: Arc<Authenticator>,
178    timeout: Duration,
179}
180
181impl StreamResolver {
182    /// A resolver whose requests time out after [`RESOLVE_TIMEOUT`].
183    pub fn new(auth: Arc<Authenticator>) -> Self {
184        Self {
185            auth,
186            timeout: RESOLVE_TIMEOUT,
187        }
188    }
189
190    /// Replaces the request timeout.
191    pub fn with_timeout(mut self, timeout: Duration) -> Self {
192        self.timeout = timeout;
193        self
194    }
195
196    /// Resolves `track_id`, asking for `max_quality` (AC1, AC5).
197    ///
198    /// Sends exactly one `playbackinfopostpaywall` request, whatever the
199    /// outcome (no client-side quality ladder): Tidal downgrades by itself,
200    /// and [`ResolvedStream::quality`] says what it granted. Errors map as
201    /// in spec 0003 "Quality"; anything not listed there comes back as
202    /// [`StreamError::Auth`], unchanged.
203    pub async fn resolve_stream(
204        &self,
205        track_id: u64,
206        max_quality: AudioQuality,
207    ) -> Result<ResolvedStream, StreamError> {
208        let (_, country) = self.auth.account().await;
209        let path = format!("tracks/{track_id}/playbackinfopostpaywall");
210        let query = [
211            ("audioquality", max_quality.tidal_name()),
212            ("playbackmode", "STREAM"),
213            ("assetpresentation", "FULL"),
214            ("countryCode", country.as_str()),
215        ];
216        let response = self.auth.get(&path, &query, Some(self.timeout)).await?;
217        if response.status().is_success() {
218            return parse_playback_info(response.body());
219        }
220        Err(map_error(response.status().as_u16(), response.sub_status()))
221    }
222}
223
224/// Tidal's `subStatus` on a `500` for an unknown track ID, or one that
225/// cannot be streamed in the account's country.
226const SUB_STATUS_NOT_FOUND: u64 = 999;
227
228/// Maps a non-2xx `playbackinfopostpaywall` answer (AC5).
229fn map_error(status: u16, sub_status: Option<u64>) -> StreamError {
230    match (status, sub_status) {
231        (401, Some(SUB_STATUS_NOT_AVAILABLE)) => StreamError::NotAvailable,
232        (500, Some(SUB_STATUS_NOT_FOUND)) => StreamError::NotFound,
233        (500, _) => StreamError::Server(500),
234        (status, _) => StreamError::Auth(AuthError::Http { status, code: None }),
235    }
236}
237
238const MIME_BTS: &str = "application/vnd.tidal.bts";
239const MIME_DASH: &str = "application/dash+xml";
240
241/// The fields of a `playbackinfopostpaywall` body that resolution uses.
242#[derive(Deserialize)]
243#[serde(rename_all = "camelCase")]
244struct PlaybackInfo {
245    track_id: u64,
246    asset_presentation: String,
247    audio_mode: String,
248    audio_quality: String,
249    manifest_mime_type: String,
250    manifest: String,
251    bit_depth: Option<u8>,
252    sample_rate: Option<u32>,
253}
254
255/// Parses and validates a `playbackinfopostpaywall` body (AC2–AC4).
256///
257/// Error messages never quote the body: its URLs carry tokens.
258pub fn parse_playback_info(body: &[u8]) -> Result<ResolvedStream, StreamError> {
259    let info: PlaybackInfo = serde_json::from_slice(body)
260        .map_err(|_| StreamError::Malformed("playback info is not the expected JSON"))?;
261    if info.asset_presentation != "FULL" {
262        return Err(StreamError::PreviewOnly);
263    }
264    if info.audio_mode != "STEREO" {
265        return Err(StreamError::Unsupported(Unsupported::AudioMode(
266            info.audio_mode,
267        )));
268    }
269    let quality = serde_json::from_value(serde_json::Value::String(info.audio_quality.clone()))
270        .map_err(|_| StreamError::Unsupported(Unsupported::Quality(info.audio_quality)))?;
271    let plan = match info.manifest_mime_type.as_str() {
272        MIME_BTS => parse_bts(&decode_manifest(&info.manifest)?)?,
273        MIME_DASH => {
274            let xml = String::from_utf8(decode_manifest(&info.manifest)?)
275                .map_err(|_| StreamError::Malformed("MPD is not UTF-8"))?;
276            parse_mpd(&xml)?
277        }
278        _ => {
279            return Err(StreamError::Unsupported(Unsupported::Manifest(
280                info.manifest_mime_type,
281            )));
282        }
283    };
284    Ok(ResolvedStream {
285        track_id: info.track_id,
286        quality,
287        bit_depth: info.bit_depth,
288        sample_rate: info.sample_rate,
289        plan,
290    })
291}
292
293fn decode_manifest(manifest: &str) -> Result<Vec<u8>, StreamError> {
294    BASE64
295        .decode(manifest)
296        .map_err(|_| StreamError::Malformed("manifest is not base64"))
297}
298
299/// Maps a manifest's codec string; HE-AAC and anything else is unsupported.
300fn codec(codecs: &str) -> Result<Codec, StreamError> {
301    match codecs.trim().to_ascii_lowercase().as_str() {
302        "flac" => Ok(Codec::Flac),
303        "mp4a.40.2" => Ok(Codec::AacLc),
304        _ => Err(StreamError::Unsupported(Unsupported::Codec(
305            codecs.to_owned(),
306        ))),
307    }
308}
309
310/// A BTS manifest.
311#[derive(Deserialize)]
312#[serde(rename_all = "camelCase")]
313struct Bts {
314    codecs: String,
315    encryption_type: String,
316    urls: Vec<String>,
317}
318
319/// Parses a BTS manifest (AC2): one file, `urls[0]`.
320fn parse_bts(manifest: &[u8]) -> Result<StreamPlan, StreamError> {
321    let bts: Bts = serde_json::from_slice(manifest)
322        .map_err(|_| StreamError::Malformed("BTS manifest is not the expected JSON"))?;
323    if bts.encryption_type != "NONE" {
324        return Err(StreamError::Unsupported(Unsupported::Encrypted));
325    }
326    let codec = codec(&bts.codecs)?;
327    let url = bts
328        .urls
329        .into_iter()
330        .next()
331        .ok_or(StreamError::Malformed("BTS manifest has no URL"))?;
332    Ok(StreamPlan::Single { url, codec })
333}
334
335/// Upper bound on the segments of one track (about 4 days of 4 s segments),
336/// so a hostile `r` cannot exhaust memory.
337const MAX_SEGMENTS: u64 = 100_000;
338
339/// One `<S>` entry of a `SegmentTimeline`.
340struct TimelineEntry {
341    t: Option<u64>,
342    d: u64,
343    r: u64,
344}
345
346/// What the MPD parser collects.
347#[derive(Default)]
348struct Mpd {
349    encrypted: bool,
350    representations: usize,
351    adaptation_codecs: Option<String>,
352    representation_codecs: Option<String>,
353    timescale: Option<u32>,
354    initialization: Option<String>,
355    media: Option<String>,
356    start_number: Option<u64>,
357    timeline: Vec<TimelineEntry>,
358}
359
360/// Parses a DASH MPD (AC3): one audio `Representation` whose
361/// `SegmentTemplate` has `initialization`, `media` with `$Number$`,
362/// `startNumber`, `timescale` and a `SegmentTimeline`. URLs must be
363/// absolute (Tidal's are; `BaseURL` is not supported).
364fn parse_mpd(xml: &str) -> Result<StreamPlan, StreamError> {
365    let mpd = read_mpd(xml)?;
366    if mpd.encrypted {
367        return Err(StreamError::Unsupported(Unsupported::Encrypted));
368    }
369    if mpd.representations != 1 {
370        return Err(StreamError::Malformed(
371            "MPD must have exactly one Representation",
372        ));
373    }
374    let codecs = mpd
375        .representation_codecs
376        .or(mpd.adaptation_codecs)
377        .ok_or(StreamError::Malformed("MPD has no codecs"))?;
378    let codec = codec(&codecs)?;
379    let init_url = mpd
380        .initialization
381        .ok_or(StreamError::Malformed("MPD has no initialization segment"))?;
382    let media = mpd
383        .media
384        .filter(|m| m.contains("$Number$"))
385        .ok_or(StreamError::Malformed(
386            "MPD has no media template with $Number$",
387        ))?;
388    let timescale = mpd.timescale.unwrap_or(1);
389    if timescale == 0 {
390        return Err(StreamError::Malformed("MPD timescale is 0"));
391    }
392    if mpd.timeline.is_empty() {
393        return Err(StreamError::Malformed("MPD has no SegmentTimeline"));
394    }
395    let total: u64 = mpd
396        .timeline
397        .iter()
398        .map(|s| s.r.saturating_add(1))
399        .fold(0, u64::saturating_add);
400    if total > MAX_SEGMENTS {
401        return Err(StreamError::Malformed("MPD has too many segments"));
402    }
403
404    let mut segments = Vec::with_capacity(total as usize);
405    let mut time = 0u64;
406    let mut number = mpd.start_number.unwrap_or(1);
407    for entry in &mpd.timeline {
408        if let Some(t) = entry.t {
409            time = t;
410        }
411        for _ in 0..=entry.r {
412            segments.push(Segment {
413                number,
414                url: media.replace("$Number$", &number.to_string()),
415                start: time,
416                duration: entry.d,
417                timescale,
418            });
419            time = time
420                .checked_add(entry.d)
421                .ok_or(StreamError::Malformed("MPD timeline overflows"))?;
422            number += 1;
423        }
424    }
425    Ok(StreamPlan::Segmented {
426        init_url,
427        segments,
428        codec,
429    })
430}
431
432/// Collects the parts of the MPD that [`parse_mpd`] uses.
433fn read_mpd(xml: &str) -> Result<Mpd, StreamError> {
434    const BAD_XML: StreamError = StreamError::Malformed("MPD is not well-formed XML");
435    let mut reader = Reader::from_str(xml);
436    let mut mpd = Mpd::default();
437    loop {
438        let element = match reader.read_event().map_err(|_| BAD_XML)? {
439            Event::Start(e) | Event::Empty(e) => e,
440            Event::Eof => break,
441            _ => continue,
442        };
443        let attrs = attributes(&element)?;
444        let get = |name: &str| {
445            attrs
446                .iter()
447                .find(|(k, _)| k == name)
448                .map(|(_, v)| v.clone())
449        };
450        match element.local_name().as_ref() {
451            "ContentProtection" => mpd.encrypted = true,
452            "AdaptationSet" => mpd.adaptation_codecs = get("codecs"),
453            "Representation" => {
454                mpd.representations += 1;
455                mpd.representation_codecs = get("codecs");
456            }
457            "SegmentTemplate" => {
458                mpd.timescale = get("timescale").map(|v| number(&v)).transpose()?;
459                mpd.initialization = get("initialization");
460                mpd.media = get("media");
461                mpd.start_number = get("startNumber").map(|v| number(&v)).transpose()?;
462            }
463            "S" => {
464                let d = get("d").ok_or(StreamError::Malformed("MPD segment has no duration"))?;
465                let d: u64 = number(&d)?;
466                if d == 0 {
467                    return Err(StreamError::Malformed("MPD segment has no duration"));
468                }
469                mpd.timeline.push(TimelineEntry {
470                    t: get("t").map(|v| number(&v)).transpose()?,
471                    d,
472                    // A negative `r` (open-ended) does not parse as u64.
473                    r: get("r").map(|v| number(&v)).transpose()?.unwrap_or(0),
474                });
475            }
476            _ => {}
477        }
478    }
479    Ok(mpd)
480}
481
482/// The element's attributes as `(local name, unescaped value)`.
483fn attributes(element: &BytesStart<'_>) -> Result<Vec<(String, String)>, StreamError> {
484    const BAD_ATTR: StreamError = StreamError::Malformed("MPD has a malformed attribute");
485    element
486        .attributes()
487        .map(|attr| {
488            let attr = attr.map_err(|_| BAD_ATTR)?;
489            let key = attr.key.local_name().as_ref().to_owned();
490            let value = attr
491                .normalized_value(XmlVersion::Implicit1_0)
492                .map_err(|_| BAD_ATTR)?
493                .into_owned();
494            Ok((key, value))
495        })
496        .collect()
497}
498
499fn number<T: std::str::FromStr>(value: &str) -> Result<T, StreamError> {
500    value
501        .trim()
502        .parse()
503        .map_err(|_| StreamError::Malformed("MPD has a malformed number"))
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use base64::Engine;
510    use base64::engine::general_purpose::STANDARD;
511    use serde_json::Value;
512
513    const HIRES: &str = include_str!("../tests/fixtures/stream/playbackinfo_hires_dash.json");
514    const LOSSLESS: &str = include_str!("../tests/fixtures/stream/playbackinfo_lossless_dash.json");
515    const HIGH: &str = include_str!("../tests/fixtures/stream/playbackinfo_high_bts.json");
516    const BTS_FLAC: &str =
517        include_str!("../tests/fixtures/stream/playbackinfo_lossless_bts_flac.json");
518    const MPD_HIRES: &str = include_str!("../tests/fixtures/stream/manifest_hires_24_96.mpd");
519    const MPD_LOSSLESS: &str = include_str!("../tests/fixtures/stream/manifest_lossless_16_44.mpd");
520
521    /// `fixture` with top-level fields replaced.
522    fn edit(fixture: &str, fields: &[(&str, Value)]) -> Vec<u8> {
523        let mut info: Value = serde_json::from_str(fixture).unwrap();
524        for (key, value) in fields {
525            info[*key] = value.clone();
526        }
527        serde_json::to_vec(&info).unwrap()
528    }
529
530    /// The decoded manifest of `fixture`.
531    fn manifest(fixture: &str) -> String {
532        let info: Value = serde_json::from_str(fixture).unwrap();
533        let bytes = STANDARD.decode(info["manifest"].as_str().unwrap()).unwrap();
534        String::from_utf8(bytes).unwrap()
535    }
536
537    /// `fixture` with its manifest passed through `change`.
538    fn edit_manifest(fixture: &str, change: impl Fn(String) -> String) -> Vec<u8> {
539        let encoded = STANDARD.encode(change(manifest(fixture)));
540        edit(fixture, &[("manifest", Value::String(encoded))])
541    }
542
543    /// The `HIGH` fixture with one field of its BTS manifest replaced.
544    fn bts_with(field: &str, value: Value) -> Vec<u8> {
545        edit_manifest(HIGH, |m| {
546            let mut bts: Value = serde_json::from_str(&m).unwrap();
547            bts[field] = value.clone();
548            bts.to_string()
549        })
550    }
551
552    fn unsupported(what: Unsupported) -> StreamError {
553        StreamError::Unsupported(what)
554    }
555
556    #[test]
557    fn ac2_bts_manifest() {
558        let rows: Vec<(&str, Vec<u8>, Result<StreamPlan, StreamError>)> = vec![
559            (
560                "FLAC",
561                BTS_FLAC.as_bytes().to_vec(),
562                Ok(StreamPlan::Single {
563                    url: "https://amz-pr-fa.audio.tidal.com/FAKE.flac?token=FAKE".into(),
564                    codec: Codec::Flac,
565                }),
566            ),
567            (
568                "AAC-LC",
569                HIGH.as_bytes().to_vec(),
570                Ok(StreamPlan::Single {
571                    url: "https://amz-pr-fa.audio.tidal.com/FAKE.mp4?token=FAKE".into(),
572                    codec: Codec::AacLc,
573                }),
574            ),
575            (
576                "encrypted BTS",
577                bts_with("encryptionType", "OLD_AES".into()),
578                Err(unsupported(Unsupported::Encrypted)),
579            ),
580            (
581                "encrypted DASH",
582                edit_manifest(HIRES, |m| {
583                    m.replace(
584                        "<Role ",
585                        r#"<ContentProtection schemeIdUri="urn:mpeg:dash:mp4protection:2011" value="cenc"/><Role "#,
586                    )
587                }),
588                Err(unsupported(Unsupported::Encrypted)),
589            ),
590            (
591                "unknown manifest type",
592                edit(
593                    HIGH,
594                    &[("manifestMimeType", "application/vnd.tidal.emu".into())],
595                ),
596                Err(unsupported(Unsupported::Manifest(
597                    "application/vnd.tidal.emu".into(),
598                ))),
599            ),
600        ];
601        for (name, body, expected) in rows {
602            let got = parse_playback_info(&body).map(|r| r.plan);
603            assert_eq!(got, expected, "{name}");
604        }
605
606        for (name, body) in [
607            ("no URL", bts_with("urls", serde_json::json!([]))),
608            ("not base64", edit(HIGH, &[("manifest", "%%%".into())])),
609            ("not JSON", edit_manifest(HIGH, |_| "nope".into())),
610        ] {
611            let got = parse_playback_info(&body);
612            assert!(
613                matches!(got, Err(StreamError::Malformed(_))),
614                "{name}: {got:?}"
615            );
616        }
617    }
618
619    fn segments(plan: &StreamPlan) -> (&str, &[Segment], Codec) {
620        match plan {
621            StreamPlan::Segmented {
622                init_url,
623                segments,
624                codec,
625            } => (init_url, segments, *codec),
626            other => panic!("not segmented: {other:?}"),
627        }
628    }
629
630    /// Checks numbering, contiguity and the total of `segments`.
631    fn assert_timeline(segments: &[Segment], first_number: u64, total_ticks: u64) {
632        let mut next_start = segments.first().map_or(0, |s| s.start);
633        for (i, segment) in segments.iter().enumerate() {
634            assert_eq!(segment.number, first_number + i as u64, "number of #{i}");
635            assert_eq!(segment.start, next_start, "start of #{i}");
636            next_start += segment.duration;
637        }
638        let total: u64 = segments.iter().map(|s| s.duration).sum();
639        assert_eq!(total, total_ticks);
640    }
641
642    #[test]
643    fn ac3_dash_manifest() {
644        // The recorded hi-res MPD: 73 × 380928 + 129030 ticks at 96 kHz.
645        let plan = parse_mpd(MPD_HIRES).unwrap();
646        let (init_url, segs, codec) = segments(&plan);
647        assert_eq!(segs.len(), 74);
648        assert_eq!(codec, Codec::Flac);
649        assert_eq!(
650            init_url,
651            "https://sp-ad-fa.audio.tidal.com/mediatracks/FAKE/0.mp4?token=FAKE"
652        );
653        assert_timeline(segs, 1, 27_936_774);
654        let url =
655            |n| format!("https://sp-ad-fa.audio.tidal.com/mediatracks/FAKE/{n}.mp4?token=FAKE");
656        assert_eq!(segs[0].url, url(1));
657        assert_eq!(segs[1].url, url(2));
658        assert_eq!(segs[73].url, url(74));
659        assert_eq!(segs[0].start, 0);
660        assert_eq!(segs[0].start_time(), Duration::ZERO);
661        assert_eq!(segs[1].start_time(), Duration::from_millis(3968));
662        assert_eq!(segs[72].duration, 380_928);
663        assert_eq!(segs[73].start, 73 * 380_928);
664        assert_eq!(segs[73].duration, 129_030);
665        assert!(segs.iter().all(|s| s.timescale == 96_000));
666        // = mediaPresentationDuration PT4M51.008S, to the millisecond.
667        assert_eq!(segs[73].end_time().as_millis(), 291_008);
668
669        // The recorded 16/44.1 MPD: `&amp;` in the signed URLs is unescaped.
670        let plan = parse_mpd(MPD_LOSSLESS).unwrap();
671        let (init_url, segs, _) = segments(&plan);
672        let query = "Policy=FAKE-POLICY&Signature=FAKE-SIG&Key-Pair-Id=FAKE-KEY";
673        assert_eq!(
674            init_url,
675            format!("https://sp-ad-cf.audio.tidal.com/mediatracks/FAKE/0.mp4?{query}")
676        );
677        assert_eq!(segs.len(), 57);
678        assert_timeline(segs, 1, 56 * 176_128 + 52_930);
679        assert_eq!(
680            segs[56].url,
681            format!("https://sp-ad-cf.audio.tidal.com/mediatracks/FAKE/57.mp4?{query}")
682        );
683        // = mediaPresentationDuration PT3M44.854S, to the millisecond.
684        assert_eq!(segs[56].end_time().as_millis(), 224_854);
685
686        // Hand-written: `t` on the first entry, repeats, startNumber 5.
687        let mpd = r#"<MPD><Period><AdaptationSet><Representation codecs="flac">
688            <SegmentTemplate timescale="10" initialization="i.mp4" media="s$Number$.m4s" startNumber="5">
689              <SegmentTimeline><S t="100" d="20" r="2"/><S d="7"/><S d="3" r="1"/></SegmentTimeline>
690            </SegmentTemplate></Representation></AdaptationSet></Period></MPD>"#;
691        let plan = parse_mpd(mpd).unwrap();
692        let (init_url, segs, _) = segments(&plan);
693        assert_eq!(init_url, "i.mp4");
694        let got: Vec<(u64, &str, u64, u64)> = segs
695            .iter()
696            .map(|s| (s.number, s.url.as_str(), s.start, s.duration))
697            .collect();
698        assert_eq!(
699            got,
700            [
701                (5, "s5.m4s", 100, 20),
702                (6, "s6.m4s", 120, 20),
703                (7, "s7.m4s", 140, 20),
704                (8, "s8.m4s", 160, 7),
705                (9, "s9.m4s", 167, 3),
706                (10, "s10.m4s", 170, 3),
707            ]
708        );
709        assert_eq!(segs[0].start_time(), Duration::from_secs(10));
710    }
711
712    #[test]
713    fn ac4_validate_playbackinfo() {
714        let errors: Vec<(&str, Vec<u8>, StreamError)> = vec![
715            (
716                "preview",
717                edit(HIRES, &[("assetPresentation", "PREVIEW".into())]),
718                StreamError::PreviewOnly,
719            ),
720            (
721                "Dolby Atmos",
722                edit(HIRES, &[("audioMode", "DOLBY_ATMOS".into())]),
723                unsupported(Unsupported::AudioMode("DOLBY_ATMOS".into())),
724            ),
725            (
726                "HE-AAC (LOW)",
727                bts_with("codecs", "mp4a.40.5".into()),
728                unsupported(Unsupported::Codec("mp4a.40.5".into())),
729            ),
730            (
731                "HE-AAC v2",
732                bts_with("codecs", "mp4a.40.29".into()),
733                unsupported(Unsupported::Codec("mp4a.40.29".into())),
734            ),
735            (
736                "HE-AAC in DASH",
737                edit_manifest(HIRES, |m| {
738                    m.replace(r#"codecs="flac""#, r#"codecs="mp4a.40.5""#)
739                }),
740                unsupported(Unsupported::Codec("mp4a.40.5".into())),
741            ),
742            (
743                "unknown quality",
744                edit(HIRES, &[("audioQuality", "HI_RES".into())]),
745                unsupported(Unsupported::Quality("HI_RES".into())),
746            ),
747        ];
748        for (name, body, expected) in errors {
749            assert_eq!(parse_playback_info(&body), Err(expected), "{name}");
750        }
751
752        let grants = [
753            (
754                "hi-res",
755                HIRES,
756                1001,
757                AudioQuality::HiResLossless,
758                Some(24),
759                Some(96_000),
760                Codec::Flac,
761            ),
762            (
763                "lossless",
764                LOSSLESS,
765                1002,
766                AudioQuality::Lossless,
767                Some(16),
768                Some(44_100),
769                Codec::Flac,
770            ),
771            (
772                "high",
773                HIGH,
774                1002,
775                AudioQuality::High,
776                None,
777                None,
778                Codec::AacLc,
779            ),
780        ];
781        for (name, body, track_id, quality, bits, rate, codec) in grants {
782            let got = parse_playback_info(body.as_bytes());
783            let got = got.unwrap_or_else(|e| panic!("{name}: {e:?}"));
784            assert_eq!(got.track_id, track_id, "{name}");
785            assert_eq!(got.quality, quality, "{name}");
786            assert_eq!(got.bit_depth, bits, "{name}");
787            assert_eq!(got.sample_rate, rate, "{name}");
788            assert_eq!(got.plan.codec(), codec, "{name}");
789        }
790    }
791}