1use 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
25pub const RESOLVE_TIMEOUT: Duration = Duration::from_secs(10);
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31pub enum Codec {
32 Flac,
34 AacLc,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct Segment {
44 pub number: u64,
46 pub url: String,
48 pub start: u64,
50 pub duration: u64,
52 pub timescale: u32,
54}
55
56impl Segment {
57 pub fn start_time(&self) -> Duration {
59 ticks_to_duration(self.start, self.timescale)
60 }
61
62 pub fn duration_time(&self) -> Duration {
64 ticks_to_duration(self.duration, self.timescale)
65 }
66
67 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#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum StreamPlan {
84 Single { url: String, codec: Codec },
86 Segmented {
88 init_url: String,
89 segments: Vec<Segment>,
90 codec: Codec,
91 },
92}
93
94impl StreamPlan {
95 pub fn codec(&self) -> Codec {
97 match self {
98 Self::Single { codec, .. } | Self::Segmented { codec, .. } => *codec,
99 }
100 }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct ResolvedStream {
106 pub track_id: u64,
108 pub quality: AudioQuality,
110 pub bit_depth: Option<u8>,
112 pub sample_rate: Option<u32>,
114 pub plan: StreamPlan,
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum Unsupported {
120 Encrypted,
122 Manifest(String),
124 AudioMode(String),
126 Codec(String),
128 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#[derive(Debug, Clone, PartialEq, thiserror::Error)]
146pub enum StreamError {
147 #[error("the track is not available")]
150 NotAvailable,
151 #[error("the track was not found, or cannot be streamed in this country")]
154 NotFound,
155 #[error("the track is only available as a preview")]
157 PreviewOnly,
158 #[error("Tidal server error (HTTP {0})")]
160 Server(u16),
161 #[error("not playable: {0}")]
163 Unsupported(Unsupported),
164 #[error("malformed stream response: {0}")]
167 Malformed(&'static str),
168 #[error(transparent)]
171 Auth(#[from] AuthError),
172}
173
174#[derive(Debug, Clone)]
176pub struct StreamResolver {
177 auth: Arc<Authenticator>,
178 timeout: Duration,
179}
180
181impl StreamResolver {
182 pub fn new(auth: Arc<Authenticator>) -> Self {
184 Self {
185 auth,
186 timeout: RESOLVE_TIMEOUT,
187 }
188 }
189
190 pub fn with_timeout(mut self, timeout: Duration) -> Self {
192 self.timeout = timeout;
193 self
194 }
195
196 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
224const SUB_STATUS_NOT_FOUND: u64 = 999;
227
228fn 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#[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
255pub 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
299fn 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#[derive(Deserialize)]
312#[serde(rename_all = "camelCase")]
313struct Bts {
314 codecs: String,
315 encryption_type: String,
316 urls: Vec<String>,
317}
318
319fn 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
335const MAX_SEGMENTS: u64 = 100_000;
338
339struct TimelineEntry {
341 t: Option<u64>,
342 d: u64,
343 r: u64,
344}
345
346#[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
360fn 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
432fn 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 r: get("r").map(|v| number(&v)).transpose()?.unwrap_or(0),
474 });
475 }
476 _ => {}
477 }
478 }
479 Ok(mpd)
480}
481
482fn 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 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 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 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 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 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 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 assert_eq!(segs[73].end_time().as_millis(), 291_008);
668
669 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 assert_eq!(segs[56].end_time().as_millis(), 224_854);
685
686 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}