Skip to main content

dash_mpd/
fetch.rs

1//! Support for downloading content from DASH MPD media streams.
2
3use std::env;
4use tokio::io;
5use tokio::fs;
6use tokio::fs::File;
7use tokio::io::{BufReader, BufWriter, AsyncWriteExt, AsyncSeekExt, AsyncReadExt};
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::time::Duration;
11use tokio::time::Instant;
12use chrono::Utc;
13use std::sync::Arc;
14use std::collections::HashMap;
15use std::cmp::min;
16use std::ffi::OsStr;
17use std::num::NonZeroU32;
18use std::sync::LazyLock;
19use futures_util::TryFutureExt;
20use tracing::{trace, info, warn, error};
21use regex::Regex;
22use url::Url;
23use bytes::Bytes;
24use data_url::DataUrl;
25use reqwest::header::{RANGE, CONTENT_TYPE};
26use backon::{ExponentialBuilder, Retryable};
27use governor::{Quota, RateLimiter};
28use xot::{xmlname, Xot};
29use edit_distance::edit_distance;
30use crate::{MPD, Period, Representation, AdaptationSet, SegmentBase, DashMpdError};
31use crate::{parse, mux_audio_video, copy_video_to_container, copy_audio_to_container};
32use crate::{is_audio_adaptation, is_video_adaptation, is_subtitle_adaptation};
33use crate::{subtitle_type, content_protection_type, SubtitleType};
34use crate::check_conformity;
35#[cfg(not(feature = "libav"))]
36use crate::ffmpeg::concat_output_files;
37use crate::media::{temporary_outpath, AudioTrack};
38use crate::subtitles::{wvtt_extract, convert_ttml_srt, convert_vtt_srt};
39use crate::decryption::{
40    decrypt_mp4decrypt,
41    decrypt_shaka,
42    decrypt_shaka_container,
43    decrypt_mp4box,
44    decrypt_mp4box_container
45};
46#[allow(unused_imports)]
47use crate::media::video_containers_concatable;
48
49#[cfg(all(feature = "sandbox", target_os = "linux"))]
50use crate::sandbox::{restrict_thread};
51
52
53/// A `Client` from the `reqwest` crate, that we use to download content over HTTP.
54pub type HttpClient = reqwest::Client;
55type DirectRateLimiter = RateLimiter<governor::state::direct::NotKeyed,
56                                     governor::state::InMemoryState,
57                                     governor::clock::DefaultClock,
58                                     governor::middleware::NoOpMiddleware>;
59
60
61// When reading stdout or stderr from an external commandline application to display for the user,
62// this is the maximum number of octets read.
63#[must_use]
64pub fn partial_process_output(output: &[u8]) -> String {
65    let cleaned = strip_ansi_escapes::strip(output);
66    let len = min(cleaned.len(), 4096);
67    #[allow(clippy::indexing_slicing)]
68    String::from_utf8_lossy(&cleaned[0..len]).to_string()
69}
70
71
72// This doesn't work correctly on modern Android, where there is no global location for temporary
73// files (fix needed in the tempfile crate)
74pub fn tmp_file_path(prefix: &str, extension: &OsStr) -> Result<PathBuf, DashMpdError> {
75    if let Some(ext) = extension.to_str() {
76        // suffix should include the "." separator
77        let fmt = format!(".{}", extension.to_string_lossy());
78        let suffix = if ext.starts_with('.') {
79            extension
80        } else {
81            OsStr::new(&fmt)
82        };
83        let file = tempfile::Builder::new()
84            .prefix(prefix)
85            .suffix(suffix)
86            .rand_bytes(7)
87            .disable_cleanup(env::var("DASHMPD_PERSIST_FILES").is_ok())
88            .tempfile()
89            .map_err(|e| DashMpdError::Io(e, String::from("creating temporary file")))?;
90        Ok(file.path().to_path_buf())
91    } else {
92        Err(DashMpdError::Other(String::from("converting filename extension")))
93    }
94}
95
96
97// This version avoids calling set_readonly(false), which results in a world-writable file on Unix
98// platforms.
99// https://rust-lang.github.io/rust-clippy/master/index.html#permissions_set_readonly_false
100#[cfg(unix)]
101async fn ensure_permissions_readable(path: &Path) -> Result<(), DashMpdError> {
102    use std::fs::Permissions;
103    use std::os::unix::fs::PermissionsExt;
104
105    let perms = Permissions::from_mode(0o644);
106    fs::set_permissions(path, perms)
107        .map_err(|e| DashMpdError::Io(e, String::from("setting file permissions"))).await?;
108    Ok(())
109}
110
111#[cfg(not(unix))]
112async fn ensure_permissions_readable(path: &Path) -> Result<(), DashMpdError> {
113    let mut perms = fs::metadata(path).await
114        .map_err(|e| DashMpdError::Io(e, String::from("reading file permissions")))?
115        .permissions();
116    perms.set_readonly(false);
117    fs::set_permissions(path, perms)
118        .map_err(|e| DashMpdError::Io(e, String::from("setting file permissions"))).await?;
119    Ok(())
120}
121
122
123/// Receives updates concerning the progression of the download, and can display this information to
124/// the user, for example using a progress bar. Bandwidth is reported in units of octets per second.
125pub trait ProgressObserver: Send + Sync {
126    fn update(&self, percent: u32, bandwidth: u64, message: &str);
127}
128
129
130/// Preference for retrieving media representation with highest quality (and highest file size) or
131/// lowest quality (and lowest file size).
132#[derive(PartialEq, Eq, Clone, Copy, Default)]
133pub enum QualityPreference { #[default] Lowest, Intermediate, Highest }
134
135
136/// The `DashDownloader` allows the download of streaming media content from a DASH MPD manifest.
137///
138/// This involves:
139///    - fetching the manifest file
140///    - parsing its XML contents
141///    - identifying the different Periods, potentially filtering out Periods that contain undesired
142///      content such as advertising
143///    - selecting for each Period the desired audio and video representations, according to user
144///      preferences concerning the audio language, video dimensions and quality settings, and other
145///      attributes such as label and role
146///    - downloading all the audio and video segments for each Representation
147///    - concatenating the audio segments and video segments into a stream
148///    - potentially decrypting the audio and video content, if DRM is present
149///    - muxing the audio and video streams to produce a single video file including audio
150///    - concatenating the streams from each Period into a single media container.
151///
152/// This should work with both MPEG-DASH MPD manifests (where the media segments are typically
153/// placed in fragmented MP4 or MPEG-2 TS containers) and for
154/// [WebM-DASH](http://wiki.webmproject.org/adaptive-streaming/webm-dash-specification).
155pub struct DashDownloader {
156    pub mpd_url: String,
157    pub redirected_url: Url,
158    base_url: Option<String>,
159    referer: Option<String>,
160    auth_username: Option<String>,
161    auth_password: Option<String>,
162    auth_bearer_token: Option<String>,
163    pub output_path: Option<PathBuf>,
164    http_client: Option<HttpClient>,
165    quality_preference: QualityPreference,
166    language_preference_audio: Option<String>,
167    language_preference_subtitles: Option<String>,
168    role_preference: Vec<String>,
169    video_width_preference: Option<u64>,
170    video_height_preference: Option<u64>,
171    video_codec_preference: Vec<String>,
172    video_id_wanted: Option<String>,
173    fetch_video: bool,
174    fetch_audio: bool,
175    fetch_subtitles: bool,
176    keep_video: Option<PathBuf>,
177    // FIXME this should be a Vec<PathBuf> to handle streams with multiple audio tracks
178    keep_audio: Option<PathBuf>,
179    concatenate_periods: bool,
180    fragment_path: Option<PathBuf>,
181    pub decryption_keys: HashMap<String, String>,
182    xslt_stylesheets: Vec<PathBuf>,
183    minimum_period_duration: Option<Duration>,
184    content_type_checks: bool,
185    conformity_checks: bool,
186    use_index_range: bool,
187    fragment_retry_count: u32,
188    max_error_count: u32,
189    progress_observers: Vec<Arc<dyn ProgressObserver>>,
190    sleep_between_requests: u8,
191    allow_live_streams: bool,
192    force_duration: Option<f64>,
193    rate_limit: u64,
194    bw_limiter: Option<DirectRateLimiter>,
195    bw_estimator_started: Instant,
196    bw_estimator_bytes: usize,
197    pub sandbox: bool,
198    pub verbosity: u8,
199    record_metainformation: bool,
200    pub muxer_preference: HashMap<String, String>,
201    pub concat_preference: HashMap<String, String>,
202    pub decryptor_preference: String,
203    pub ffmpeg_location: String,
204    pub vlc_location: String,
205    pub mkvmerge_location: String,
206    pub mp4box_location: String,
207    pub mp4decrypt_location: String,
208    pub shaka_packager_location: String,
209}
210
211
212// We don't want to test this code example on the CI infrastructure as it's too expensive
213// and requires network access.
214#[cfg(not(doctest))]
215/// The DashDownloader follows the builder pattern to allow various optional arguments concerning
216/// the download of DASH media content (preferences concerning bitrate/quality, specifying an HTTP
217/// proxy, etc.).
218///
219/// # Example
220///
221/// ```rust
222/// use dash_mpd::fetch::DashDownloader;
223///
224/// let url = "https://storage.googleapis.com/shaka-demo-assets/heliocentrism/heliocentrism.mpd";
225/// match DashDownloader::new(url)
226///        .worst_quality()
227///        .download().await
228/// {
229///    Ok(path) => println!("Downloaded to {path:?}"),
230///    Err(e) => eprintln!("Download failed: {e}"),
231/// }
232/// ```
233impl DashDownloader {
234    /// Create a `DashDownloader` for the specified DASH manifest URL `mpd_url`.
235    ///
236    /// # Panics
237    ///
238    /// Will panic if `mpd_url` cannot be parsed as an URL.
239    #[must_use]
240    pub fn new(mpd_url: &str) -> DashDownloader {
241        DashDownloader {
242            mpd_url: String::from(mpd_url),
243            redirected_url: Url::parse(mpd_url).unwrap(),
244            base_url: None,
245            referer: None,
246            auth_username: None,
247            auth_password: None,
248            auth_bearer_token: None,
249            output_path: None,
250            http_client: None,
251            quality_preference: QualityPreference::Lowest,
252            language_preference_audio: None,
253            language_preference_subtitles: None,
254            role_preference: vec!["main".to_string(), "alternate".to_string()],
255            video_width_preference: None,
256            video_height_preference: None,
257            video_codec_preference: Vec::new(),
258            video_id_wanted: None,
259            fetch_video: true,
260            fetch_audio: true,
261            fetch_subtitles: false,
262            keep_video: None,
263            keep_audio: None,
264            concatenate_periods: true,
265            fragment_path: None,
266            decryption_keys: HashMap::new(),
267            xslt_stylesheets: Vec::new(),
268            minimum_period_duration: None,
269            content_type_checks: true,
270            conformity_checks: true,
271            use_index_range: true,
272            fragment_retry_count: 10,
273            max_error_count: 30,
274            progress_observers: Vec::new(),
275            sleep_between_requests: 0,
276            allow_live_streams: false,
277            force_duration: None,
278            rate_limit: 0,
279            bw_limiter: None,
280            bw_estimator_started: Instant::now(),
281            bw_estimator_bytes: 0,
282            sandbox: false,
283            verbosity: 0,
284            record_metainformation: true,
285            muxer_preference: HashMap::new(),
286            concat_preference: HashMap::new(),
287            decryptor_preference: String::from("mp4decrypt"),
288            ffmpeg_location: String::from("ffmpeg"),
289	    vlc_location: if cfg!(target_os = "windows") {
290                // The official VideoLan Windows installer doesn't seem to place its installation
291                // directory in the PATH, so we try with the default full path.
292                String::from("c:/Program Files/VideoLAN/VLC/vlc.exe")
293            } else {
294                String::from("vlc")
295            },
296	    mkvmerge_location: String::from("mkvmerge"),
297	    mp4box_location: if cfg!(target_os = "windows") {
298                String::from("MP4Box.exe")
299            } else if cfg!(target_os = "linux") || cfg!(target_os = "macos") {
300                String::from("MP4Box")
301            } else {
302                String::from("mp4box")
303            },
304            mp4decrypt_location: String::from("mp4decrypt"),
305            shaka_packager_location: String::from("shaka-packager"),
306        }
307    }
308
309    /// Specify the base URL to use when downloading content from the manifest. This may be useful
310    /// when downloading from a file:// URL.
311    #[must_use]
312    pub fn with_base_url(mut self, base_url: String) -> DashDownloader {
313        self.base_url = Some(base_url);
314        self
315    }
316
317
318    /// Specify the reqwest Client to be used for HTTP requests that download the DASH streaming
319    /// media content. Allows you to specify a proxy, the user agent, custom request headers,
320    /// request timeouts, additional root certificates to trust, client identity certificates, etc.
321    ///
322    /// # Example
323    ///
324    /// ```rust
325    /// use dash_mpd::fetch::DashDownloader;
326    ///
327    /// let client = reqwest::Client::builder()
328    ///      .user_agent("Mozilla/5.0")
329    ///      .timeout(Duration::new(30, 0))
330    ///      .build()
331    ///      .expect("creating HTTP client");
332    ///  let url = "https://cloudflarestream.com/31c9291ab41fac05471db4e73aa11717/manifest/video.mpd";
333    ///  let out = PathBuf::from(env::temp_dir()).join("cloudflarestream.mp4");
334    ///  DashDownloader::new(url)
335    ///      .with_http_client(client)
336    ///      .download_to(out)
337    ///       .await
338    /// ```
339    #[must_use]
340    pub fn with_http_client(mut self, client: HttpClient) -> DashDownloader {
341        self.http_client = Some(client);
342        self
343    }
344
345    /// Specify the value for the Referer HTTP header used in network requests. This value is used
346    /// when retrieving the MPD manifest, when retrieving video and audio media segments, and when
347    /// retrieving subtitle data.
348    #[must_use]
349    pub fn with_referer(mut self, referer: String) -> DashDownloader {
350        self.referer = Some(referer);
351        self
352    }
353
354    /// Specify the username and password to use to authenticate network requests for the manifest
355    /// and media segments.
356    #[must_use]
357    pub fn with_authentication(mut self, username: &str, password: &str) -> DashDownloader {
358        self.auth_username = Some(username.to_string());
359        self.auth_password = Some(password.to_string());
360        self
361    }
362
363    /// Specify the Bearer token to use to authenticate network requests for the manifest and media
364    /// segments.
365    #[must_use]
366    pub fn with_auth_bearer(mut self, token: &str) -> DashDownloader {
367        self.auth_bearer_token = Some(token.to_string());
368        self
369    }
370
371    /// Add an observer implementing the `ProgressObserver` trait, that will receive updates concerning
372    /// the progression of the download (allows implementation of a progress bar, for example).
373    #[must_use]
374    pub fn add_progress_observer(mut self, observer: Arc<dyn ProgressObserver>) -> DashDownloader {
375        self.progress_observers.push(observer);
376        self
377    }
378
379    /// If the DASH manifest specifies several Adaptations with different bitrates (levels of
380    /// quality), prefer the Adaptation with the highest bitrate (largest output file).
381    #[must_use]
382    pub fn best_quality(mut self) -> DashDownloader {
383        self.quality_preference = QualityPreference::Highest;
384        self
385    }
386
387    /// If the DASH manifest specifies several Adaptations with different bitrates (levels of
388    /// quality), prefer the Adaptation with an intermediate bitrate (closest to the median value).
389    #[must_use]
390    pub fn intermediate_quality(mut self) -> DashDownloader {
391        self.quality_preference = QualityPreference::Intermediate;
392        self
393    }
394
395    /// If the DASH manifest specifies several Adaptations with different bitrates (levels of
396    /// quality), prefer the Adaptation with the lowest bitrate (smallest output file).
397    #[must_use]
398    pub fn worst_quality(mut self) -> DashDownloader {
399        self.quality_preference = QualityPreference::Lowest;
400        self
401    }
402
403    /// Specify the preferred language for audio streams and subtitle streams, when multiple audio
404    /// streams or subtitle tracks with different languages are available. Must be in RFC 5646
405    /// format (e.g. "fr" or "en-AU"). If a preference is not specified and multiple streams are
406    /// present, the first one listed in the DASH manifest will be downloaded.
407    //
408    // TODO: this could be modified to allow a comma-separated list, or the special value "all"
409    #[must_use]
410    pub fn prefer_language(mut self, lang: String) -> DashDownloader {
411        self.language_preference_audio = Some(lang.clone());
412        self.language_preference_subtitles = Some(lang);
413        self
414    }
415
416    /// Specify the preferred language for audio, when multiple audio streams with different
417    /// languages are available. Must be in RFC 5646 format (e.g. "fr" or "en-AU"). If a preference
418    /// is not specified and multiple audio streams are present, the first one listed in the DASH
419    /// manifest will be downloaded.
420    #[must_use]
421    pub fn prefer_audio_language(mut self, lang: String) -> DashDownloader {
422        self.language_preference_audio = Some(lang);
423        self
424    }
425
426    /// Specify the preferred language for subtitles, when multiple subtitle tracks with different
427    /// languages are available. Must be in RFC 5646 format (e.g. "fr" or "en-AU"). If a preference
428    /// is not specified and multiple subtitle tracks are available, the first one listed in the
429    /// DASH manifest will be downloaded.
430    #[must_use]
431    pub fn prefer_subtitle_language(mut self, lang: String) -> DashDownloader {
432        self.language_preference_subtitles = Some(lang);
433        self
434    }
435
436
437    /// Specify the preference ordering for Role annotations on AdaptationSet elements. Some DASH
438    /// streams include multiple AdaptationSets, one annotated "main" and another "alternate", for
439    /// example. If `role_preference` is ["main", "alternate"] and one of the AdaptationSets is
440    /// annotated "main", then we will only download that AdaptationSet. If no role annotations are
441    /// specified, this preference is ignored. This preference selection is applied before the
442    /// preferences related to stream quality and video height/width: for example an AdaptationSet
443    /// with role=alternate will be ignored when a role=main AdaptationSet is present, even if we
444    /// also specify a quality preference for highest and the role=alternate stream has a higher
445    /// quality.
446    #[must_use]
447    pub fn prefer_roles(mut self, role_preference: Vec<String>) -> DashDownloader {
448        if role_preference.len() < u8::MAX as usize {
449            self.role_preference = role_preference;
450        } else {
451            warn!("Ignoring role_preference ordering due to excessive length");
452        }
453        self
454    }
455
456    /// If the DASH manifest specifies several video AdaptationSets with different resolutions, prefer
457    /// the AdaptationSet and child Representations whose width is closest to the specified `width`.
458    #[must_use]
459    pub fn prefer_video_width(mut self, width: u64) -> DashDownloader {
460        self.video_width_preference = Some(width);
461        self
462    }
463
464    /// If the DASH manifest specifies several video AdaptationSets with different resolutions, prefer
465    /// the AdaptationSet and child Representations whose height is closest to the specified `height`.
466    #[must_use]
467    pub fn prefer_video_height(mut self, height: u64) -> DashDownloader {
468        self.video_height_preference = Some(height);
469        self
470    }
471
472    /// Specify a preference ordering for codecs used for video streams. The argument
473    /// `codec_preference` is a vector of Strings of the form "h264", "vp09" and "av1". The matching
474    /// of codecs is based on substring prefix, so for example a preference of "hev1" will match a
475    /// codec whose full name as specified in the manifest is "hev1.1.6.L60.90".
476    #[must_use]
477    pub fn prefer_video_codecs(mut self, codec_preference: Vec<String>) -> DashDownloader {
478        if codec_preference.len() < u8::MAX as usize {
479            self.video_codec_preference = codec_preference;
480        } else {
481            warn!("Ignoring video codec_preference due to excessive length");
482        }
483        self
484    }
485
486    /// Specify a substring to use as a filter on video Representation @id attributes. When a
487    /// manifest provides multiple video streams in different Representation elements, this makes it
488    /// possible to select a specific video stream by providing its full id. If only a substring of
489    /// the id is specified, this preference will be combined with other preferences such as the
490    /// quality level and codec preference to select a single preferred video stream.
491    #[must_use]
492    pub fn want_video_id_substring(mut self, substring: String) -> DashDownloader {
493        self.video_id_wanted = Some(substring);
494        self
495    }
496
497    /// If the media stream has separate audio and video streams, only download the video stream.
498    #[must_use]
499    pub fn video_only(mut self) -> DashDownloader {
500        self.fetch_audio = false;
501        self.fetch_video = true;
502        self
503    }
504
505    /// If the media stream has separate audio and video streams, only download the audio stream.
506    #[must_use]
507    pub fn audio_only(mut self) -> DashDownloader {
508        self.fetch_audio = true;
509        self.fetch_video = false;
510        self
511    }
512
513    /// Keep the file containing video at the specified path. If the path already exists, file
514    /// contents will be overwritten.
515    #[must_use]
516    pub fn keep_video_as<P: Into<PathBuf>>(mut self, video_path: P) -> DashDownloader {
517        self.keep_video = Some(video_path.into());
518        self
519    }
520
521    /// Keep the file containing audio at the specified path. If the path already exists, file
522    /// contents will be overwritten.
523    #[must_use]
524    pub fn keep_audio_as<P: Into<PathBuf>>(mut self, audio_path: P) -> DashDownloader {
525        self.keep_audio = Some(audio_path.into());
526        self
527    }
528
529    /// Save media fragments to the directory `fragment_path`. The directory will be created if it
530    /// does not exist.
531    #[must_use]
532    pub fn save_fragments_to<P: Into<PathBuf>>(mut self, fragment_path: P) -> DashDownloader {
533        self.fragment_path = Some(fragment_path.into());
534        self
535    }
536
537    /// Add a key to be used to decrypt MPEG media streams that use Common Encryption (cenc). This
538    /// function may be called several times to specify multiple kid/key pairs. Decryption uses the
539    /// external commandline application specified by `with_decryptor_preference`, run as a
540    /// subprocess.
541    ///
542    /// # Arguments
543    ///
544    /// * `id` - a track ID in decimal or a 128-bit KID in hexadecimal format (32 hex characters).
545    ///   Examples: "1" or "eb676abbcb345e96bbcf616630f1a3da".
546    ///
547    /// * `key` - a 128-bit key in hexadecimal format.
548    #[must_use]
549    pub fn add_decryption_key(mut self, id: String, key: String) -> DashDownloader {
550        self.decryption_keys.insert(id, key);
551        self
552    }
553
554    /// Register an XSLT stylesheet that will be applied to the MPD manifest after XLink processing
555    /// and before deserialization into Rust structs. The stylesheet will be applied to the manifest
556    /// using the xsltproc commandline tool, which supports XSLT 1.0. If multiple stylesheets are
557    /// registered, they will be called in sequence in the same order as their registration. If the
558    /// application of a stylesheet fails, the download will be aborted.
559    ///
560    /// This is an experimental API which may change in future versions of the library.
561    ///
562    /// # Arguments
563    ///
564    /// * `stylesheet`: the path to an XSLT stylesheet.
565    #[must_use]
566    pub fn with_xslt_stylesheet<P: Into<PathBuf>>(mut self, stylesheet: P) -> DashDownloader {
567        self.xslt_stylesheets.push(stylesheet.into());
568        self
569    }
570
571    /// Don't download (skip) Periods in the manifest whose duration is less than the specified
572    /// value.
573    #[must_use]
574    pub fn minimum_period_duration(mut self, value: Duration) -> DashDownloader {
575        self.minimum_period_duration = Some(value);
576        self
577    }
578
579    /// Parameter `value` determines whether audio content is downloaded. If disabled, the output
580    /// media file will either contain only a video track (if `fetch_video` is true and the manifest
581    /// includes a video stream), or will be empty.
582    #[must_use]
583    pub fn fetch_audio(mut self, value: bool) -> DashDownloader {
584        self.fetch_audio = value;
585        self
586    }
587
588    /// Parameter `value` determines whether video content is downloaded. If disabled, the output
589    /// media file will either contain only an audio track (if `fetch_audio` is true and the manifest
590    /// includes an audio stream which is separate from the video stream), or will be empty.
591    #[must_use]
592    pub fn fetch_video(mut self, value: bool) -> DashDownloader {
593        self.fetch_video = value;
594        self
595    }
596
597    /// Specify whether subtitles should be fetched, if they are available. If subtitles are
598    /// requested and available, they will be downloaded to a file named with the same name as the
599    /// media output and an appropriate extension (".vtt", ".ttml", ".srt", etc.).
600    ///
601    /// # Arguments
602    ///
603    /// * `value`: enable or disable the retrieval of subtitles.
604    #[must_use]
605    pub fn fetch_subtitles(mut self, value: bool) -> DashDownloader {
606        self.fetch_subtitles = value;
607        self
608    }
609
610    /// For multi-Period manifests, parameter `value` determines whether the content of multiple
611    /// Periods is concatenated into a single output file where their resolutions, frame rate and
612    /// aspect ratios are compatible, or kept in individual files.
613    #[must_use]
614    pub fn concatenate_periods(mut self, value: bool) -> DashDownloader {
615        self.concatenate_periods = value;
616        self
617    }
618
619    /// Don't check that the content-type of downloaded segments corresponds to audio or video
620    /// content (may be necessary with poorly configured HTTP servers).
621    #[must_use]
622    pub fn without_content_type_checks(mut self) -> DashDownloader {
623        self.content_type_checks = false;
624        self
625    }
626
627    /// Specify whether to check that the content-type of downloaded segments corresponds to audio
628    /// or video content (this may need to be set to false with poorly configured HTTP servers).
629    #[must_use]
630    pub fn content_type_checks(mut self, value: bool) -> DashDownloader {
631        self.content_type_checks = value;
632        self
633    }
634
635    /// Specify whether to run various conformity checks on the content of the DASH manifest before
636    /// downloading media segments.
637    #[must_use]
638    pub fn conformity_checks(mut self, value: bool) -> DashDownloader {
639        self.conformity_checks = value;
640        self
641    }
642
643    /// Specify whether the use the sidx/Cue index for SegmentBase@indexRange addressing.
644    ///
645    /// If set to true (the default value), downloads of media whose manifest uses
646    /// SegmentBase@indexRange addressing will retrieve the index information (currently only sidx
647    /// information used in ISOBMFF/MP4 containers; Cue information for WebM containers is currently
648    /// not supported) with a byte range request, then retrieve and concatenate the different bytes
649    /// ranges indicated in the index. This is the download method used by most DASH players
650    /// (set-top box and browser-based). It avoids downloading the content identified by the
651    /// BaseURL as a very large chunk, which can fill up RAM and may be banned by certain content
652    /// servers.
653    ///
654    /// If set to false, the BaseURL content will be downloaded as a single large chunk. This may be
655    /// more robust on certain content streams that have been encoded in a manner which is not
656    /// suitable for byte range retrieval.
657    #[must_use]
658    pub fn use_index_range(mut self, value: bool) -> DashDownloader {
659        self.use_index_range = value;
660        self
661    }
662
663    /// The upper limit on the number of times to attempt to fetch a media segment, even in the
664    /// presence of network errors. Transient network errors (such as timeouts) do not count towards
665    /// this limit.
666    #[must_use]
667    pub fn fragment_retry_count(mut self, count: u32) -> DashDownloader {
668        self.fragment_retry_count = count;
669        self
670    }
671
672    /// The upper limit on the number of non-transient network errors encountered for this download
673    /// before we abort the download.
674    ///
675    /// Transient network errors such as an HTTP 408 “request timeout” are retried automatically
676    /// with an exponential backoff mechanism, and do not count towards this upper limit. The
677    /// default is to fail after 30 non-transient network errors over the whole download.
678    #[must_use]
679    pub fn max_error_count(mut self, count: u32) -> DashDownloader {
680        self.max_error_count = count;
681        self
682    }
683
684    /// Specify a number of seconds to sleep between network requests (default 0).
685    #[must_use]
686    pub fn sleep_between_requests(mut self, seconds: u8) -> DashDownloader {
687        self.sleep_between_requests = seconds;
688        self
689    }
690
691    /// Specify whether to attempt to download from a “live” stream, or dynamic DASH manifest.
692    /// Default is false.
693    ///
694    /// Downloading from a genuinely live stream won’t work well, because this library doesn’t
695    /// implement the clock-related throttling needed to only download media segments when they
696    /// become available. However, some media sources publish pseudo-live streams where all media
697    /// segments are in fact available, which we will be able to download. You might also have some
698    /// success in combination with the `sleep_between_requests()` method.
699    ///
700    /// You may also need to force a duration for the live stream using method
701    /// `force_duration()`, because live streams often don’t specify a duration.
702    #[must_use]
703    pub fn allow_live_streams(mut self, value: bool) -> DashDownloader {
704        self.allow_live_streams = value;
705        self
706    }
707
708    /// Specify the number of seconds to capture from the media stream, overriding the duration
709    /// specified in the DASH manifest.
710    ///
711    /// This is mostly useful for live streams, for which the duration is often not specified. It
712    /// can also be used to capture only the first part of a normal (static/on-demand) media stream.
713    #[must_use]
714    pub fn force_duration(mut self, seconds: f64) -> DashDownloader {
715        if seconds < 0.0 {
716            warn!("Ignoring negative value for force_duration()");
717        } else {
718            self.force_duration = Some(seconds);
719            if self.verbosity > 1 {
720                info!("Setting forced duration to {seconds:.1} seconds");
721            }
722        }
723        self
724    }
725
726    /// A maximal limit on the network bandwidth consumed to download media segments, expressed in
727    /// octets (bytes) per second. No limit on bandwidth if set to zero (the default value).
728    ///
729    /// Limiting bandwidth below 50kB/s is not recommended, as the downloader may fail to respect
730    /// this limit.
731    #[must_use]
732    pub fn with_rate_limit(mut self, bps: u64) -> DashDownloader {
733        if bps < 10 * 1024 {
734            warn!("Limiting bandwidth below 10kB/s is unlikely to be stable");
735        }
736        if self.verbosity > 1 {
737            info!("Limiting bandwidth to {} kB/s", bps/1024);
738        }
739        self.rate_limit = bps;
740        // Our rate_limit is in bytes/second, but the governor::RateLimiter can only handle an u32 rate.
741        // We express our cells in the RateLimiter in kB/s instead of bytes/second, to allow for numbing
742        // future bandwidth capacities. We need to be careful to allow a quota burst size which
743        // corresponds to the size (in kB) of the largest media segments we are going to be retrieving,
744        // because that's the number of bucket cells that will be consumed for each downloaded segment.
745        let mut kps = 1 + bps / 1024;
746        if kps > u64::from(u32::MAX) {
747            warn!("Throttling bandwidth limit");
748            kps = u32::MAX.into();
749        }
750        if let Some(bw_limit) = NonZeroU32::new(kps as u32) {
751            if let Some(burst) = NonZeroU32::new(10 * 1024) {
752                let bw_quota = Quota::per_second(bw_limit)
753                    .allow_burst(burst);
754                self.bw_limiter = Some(RateLimiter::direct(bw_quota));
755            }
756        }
757        self
758    }
759
760    /// Set the verbosity level of the download process.
761    ///
762    /// # Arguments
763    ///
764    /// * Level - an integer specifying the verbosity level.
765    /// - 0: no information is printed
766    /// - 1: basic information on the number of Periods and bandwidth of selected representations
767    /// - 2: information above + segment addressing mode
768    /// - 3 or larger: information above + size of each downloaded segment
769    #[must_use]
770    pub fn verbosity(mut self, level: u8) -> DashDownloader {
771        self.verbosity = level;
772        self
773    }
774
775    /// Enable or disable the security sandboxing support.
776    ///
777    /// Security sandboxing is experimental. It is only available on Linux, when the crate is
778    /// compiled with the `sandbox` feature enabled. It uses features of the Landlock LSM.
779    ///
780    /// # Arguments
781    ///
782    /// * enable - a boolean specifying whether to enable the sandboxing support. If enabling is
783    ///   requested but support is not available, a warning message will be printed.
784    #[must_use]
785    pub fn sandbox(mut self, enable: bool) -> DashDownloader {
786        #[cfg(not(all(feature = "sandbox", target_os = "linux")))]
787        if enable {
788            warn!("Sandboxing only available on Linux with crate feature sandbox enabled");
789        }
790        if self.verbosity > 1 && enable {
791            info!("Enabling sandboxing support");
792        }
793        self.sandbox = enable;
794        self
795    }
796
797    /// Specify whether to record metainformation concerning the media content (origin URL, title,
798    /// source and copyright metainformation) as extended attributes in the output file, assuming
799    /// this information is present in the DASH manifest.
800    #[must_use]
801    pub fn record_metainformation(mut self, record: bool) -> DashDownloader {
802        self.record_metainformation = record;
803        self
804    }
805
806    /// When muxing audio and video streams to a container of type `container`, try muxing
807    /// applications following the order given by `ordering`.
808    ///
809    /// This function may be called multiple times to specify the ordering for different container
810    /// types. If called more than once for the same container type, the ordering specified in the
811    /// last call is retained.
812    ///
813    /// # Arguments
814    ///
815    /// * `container`: the container type (e.g. "mp4", "mkv", "avi")
816    /// * `ordering`: the comma-separated order of preference for trying muxing applications (e.g.
817    ///   "ffmpeg,vlc,mp4box")
818    ///
819    /// # Example
820    ///
821    /// ```rust
822    /// let out = DashDownloader::new(url)
823    ///      .with_muxer_preference("mkv", "ffmpeg")
824    ///      .download_to("wonderful.mkv")
825    ///      .await?;
826    /// ```
827    #[must_use]
828    pub fn with_muxer_preference(mut self, container: &str, ordering: &str) -> DashDownloader {
829        self.muxer_preference.insert(container.to_string(), ordering.to_string());
830        self
831    }
832
833    /// When concatenating streams from a multi-period manifest to a container of type `container`,
834    /// try concat helper applications following the order given by `ordering`.
835    ///
836    /// This function may be called multiple times to specify the ordering for different container
837    /// types. If called more than once for the same container type, the ordering specified in the
838    /// last call is retained.
839    ///
840    /// # Arguments
841    ///
842    /// * `container`: the container type (e.g. "mp4", "mkv", "avi")
843    /// * `ordering`: the comma-separated order of preference for trying concat helper applications.
844    ///   Valid possibilities are "ffmpeg" (the ffmpeg concat filter, slow), "ffmpegdemuxer" (the
845    ///   ffmpeg concat demuxer, fast but less robust), "mkvmerge" (fast but not robust), and "mp4box".
846    ///
847    /// # Example
848    ///
849    /// ```rust
850    /// let out = DashDownloader::new(url)
851    ///      .with_concat_preference("mkv", "ffmpeg,mkvmerge")
852    ///      .download_to("wonderful.mkv")
853    ///      .await?;
854    /// ```
855    #[must_use]
856    pub fn with_concat_preference(mut self, container: &str, ordering: &str) -> DashDownloader {
857        self.concat_preference.insert(container.to_string(), ordering.to_string());
858        self
859    }
860
861    /// Specify the commandline application to be used to decrypt media which has been enriched with
862    /// ContentProtection (DRM).
863    ///
864    /// # Arguments
865    ///
866    /// * `decryption_tool`: one of "mp4decrypt", "shaka", "mp4box", "shaka-container",
867    ///   "mp4box-container". The options with `-container` in the name are run via a Docker/Podman
868    ///   container.
869    #[must_use]
870    pub fn with_decryptor_preference(mut self, decryption_tool: &str) -> DashDownloader {
871        self.decryptor_preference = decryption_tool.to_string();
872        self
873    }
874
875    /// Specify the location of the `ffmpeg` application, if not located in PATH.
876    ///
877    /// # Arguments
878    ///
879    /// * `ffmpeg_path`: the path to the ffmpeg application. If it does not specify an absolute
880    ///   path, the `PATH` environment variable will be searched in a platform-specific way
881    ///   (implemented in `std::process::Command`).
882    ///
883    /// # Example
884    ///
885    /// ```rust
886    /// #[cfg(target_os = "unix")]
887    /// let ddl = ddl.with_ffmpeg("/opt/ffmpeg-next/bin/ffmpeg");
888    /// ```
889    #[must_use]
890    pub fn with_ffmpeg(mut self, ffmpeg_path: &str) -> DashDownloader {
891        self.ffmpeg_location = ffmpeg_path.to_string();
892        self
893    }
894
895    /// Specify the location of the VLC application, if not located in PATH.
896    ///
897    /// # Arguments
898    ///
899    /// * `vlc_path`: the path to the VLC application. If it does not specify an absolute
900    ///   path, the `PATH` environment variable will be searched in a platform-specific way
901    ///   (implemented in `std::process::Command`).
902    ///
903    /// # Example
904    ///
905    /// ```rust
906    /// #[cfg(target_os = "windows")]
907    /// let ddl = ddl.with_vlc("C:/Program Files/VideoLAN/VLC/vlc.exe");
908    /// ```
909    #[must_use]
910    pub fn with_vlc(mut self, vlc_path: &str) -> DashDownloader {
911        self.vlc_location = vlc_path.to_string();
912        self
913    }
914
915    /// Specify the location of the mkvmerge application, if not located in PATH.
916    ///
917    /// # Arguments
918    ///
919    /// * `path`: the path to the mkvmerge application. If it does not specify an absolute
920    ///   path, the `PATH` environment variable will be searched in a platform-specific way
921    ///   (implemented in `std::process::Command`).
922    #[must_use]
923    pub fn with_mkvmerge(mut self, path: &str) -> DashDownloader {
924        self.mkvmerge_location = path.to_string();
925        self
926    }
927
928    /// Specify the location of the MP4Box application, if not located in PATH.
929    ///
930    /// # Arguments
931    ///
932    /// * `path`: the path to the MP4Box application. If it does not specify an absolute
933    ///   path, the `PATH` environment variable will be searched in a platform-specific way
934    ///   (implemented in `std::process::Command`).
935    #[must_use]
936    pub fn with_mp4box(mut self, path: &str) -> DashDownloader {
937        self.mp4box_location = path.to_string();
938        self
939    }
940
941    /// Specify the location of the Bento4 mp4decrypt application, if not located in PATH.
942    ///
943    /// # Arguments
944    ///
945    /// * `path`: the path to the mp4decrypt application. If it does not specify an absolute
946    ///   path, the `PATH` environment variable will be searched in a platform-specific way
947    ///   (implemented in `std::process::Command`).
948    #[must_use]
949    pub fn with_mp4decrypt(mut self, path: &str) -> DashDownloader {
950        self.mp4decrypt_location = path.to_string();
951        self
952    }
953
954    /// Specify the location of the shaka-packager application, if not located in PATH.
955    ///
956    /// # Arguments
957    ///
958    /// * `path`: the path to the shaka-packager application. If it does not specify an absolute
959    ///   path, the `PATH` environment variable will be searched in a platform-specific way
960    ///   (implemented in `std::process::Command`).
961    #[must_use]
962    pub fn with_shaka_packager(mut self, path: &str) -> DashDownloader {
963        self.shaka_packager_location = path.to_string();
964        self
965    }
966
967    /// Download DASH streaming media content to the file named by `out`. If the output file `out`
968    /// already exists, its content will be overwritten.
969    ///
970    /// Note that the media container format used when muxing audio and video streams depends on the
971    /// filename extension of the path `out`. If the filename extension is `.mp4`, an MPEG-4
972    /// container will be used; if it is `.mkv` a Matroska container will be used, for `.webm` a
973    /// WebM container (specific type of Matroska) will be used, and otherwise the heuristics
974    /// implemented by the selected muxer (by default ffmpeg) will apply (e.g. an `.avi` extension
975    /// will generate an AVI container).
976    pub async fn download_to<P: Into<PathBuf>>(mut self, out: P) -> Result<PathBuf, DashMpdError> {
977        self.output_path = Some(out.into());
978        if self.http_client.is_none() {
979            let client = reqwest::Client::builder()
980                .timeout(Duration::new(30, 0))
981                .cookie_store(true)
982                .build()
983                .map_err(|_| DashMpdError::Network(String::from("building HTTP client")))?;
984            self.http_client = Some(client);
985        }
986        fetch_mpd(&mut self).await
987    }
988
989    /// Download DASH streaming media content to a file in the current working directory and return
990    /// the corresponding `PathBuf`.
991    ///
992    /// The name of the output file is derived from the manifest URL. The output file will be
993    /// overwritten if it already exists. The downloaded media will be placed in an MPEG-4
994    /// container. To select another media container, see the `download_to` function.
995    pub async fn download(mut self) -> Result<PathBuf, DashMpdError> {
996        let cwd = env::current_dir()
997            .map_err(|e| DashMpdError::Io(e, String::from("obtaining current directory")))?;
998        let filename = generate_filename_from_url(&self.mpd_url);
999        let outpath = cwd.join(filename);
1000        self.output_path = Some(outpath);
1001        if self.http_client.is_none() {
1002            let client = reqwest::Client::builder()
1003                .timeout(Duration::new(30, 0))
1004                .cookie_store(true)
1005                .build()
1006                .map_err(|_| DashMpdError::Network(String::from("building HTTP client")))?;
1007            self.http_client = Some(client);
1008        }
1009        fetch_mpd(&mut self).await
1010    }
1011}
1012
1013
1014fn mpd_is_dynamic(mpd: &MPD) -> bool {
1015    if let Some(mpdtype) = mpd.mpdtype.as_ref() {
1016        return mpdtype.eq("dynamic");
1017    }
1018    false
1019}
1020
1021// Parse a range specifier, such as Initialization@range or SegmentBase@indexRange attributes, of
1022// the form "45-67"
1023fn parse_range(range: &str) -> Result<(u64, u64), DashMpdError> {
1024    let v: Vec<&str> = range.split_terminator('-').collect();
1025    if v.len() != 2 {
1026        return Err(DashMpdError::Parsing(format!("invalid range specifier: {range}")));
1027    }
1028    #[allow(clippy::indexing_slicing)]
1029    let start: u64 = v[0].parse()
1030        .map_err(|_| DashMpdError::Parsing(String::from("invalid start for range specifier")))?;
1031    #[allow(clippy::indexing_slicing)]
1032    let end: u64 = v[1].parse()
1033        .map_err(|_| DashMpdError::Parsing(String::from("invalid end for range specifier")))?;
1034    Ok((start, end))
1035}
1036
1037#[derive(Debug)]
1038struct MediaFragment {
1039    period: u8,
1040    url: Url,
1041    start_byte: Option<u64>,
1042    end_byte: Option<u64>,
1043    is_init: bool,
1044    timeout: Option<Duration>,
1045}
1046
1047#[derive(Debug)]
1048struct MediaFragmentBuilder {
1049    period: u8,
1050    url: Url,
1051    start_byte: Option<u64>,
1052    end_byte: Option<u64>,
1053    is_init: bool,
1054    timeout: Option<Duration>,
1055}
1056
1057impl MediaFragmentBuilder {
1058    pub fn new(period: u8, url: Url) -> MediaFragmentBuilder {
1059        MediaFragmentBuilder {
1060            period, url, start_byte: None, end_byte: None, is_init: false, timeout: None
1061        }
1062    }
1063
1064    pub fn with_range(mut self, start_byte: Option<u64>, end_byte: Option<u64>) -> MediaFragmentBuilder {
1065        self.start_byte = start_byte;
1066        self.end_byte = end_byte;
1067        self
1068    }
1069
1070    pub fn with_timeout(mut self, timeout: Duration) -> MediaFragmentBuilder {
1071        self.timeout = Some(timeout);
1072        self
1073    }
1074
1075    pub fn set_init(mut self) -> MediaFragmentBuilder {
1076        self.is_init = true;
1077        self
1078    }
1079
1080    pub fn build(self) -> MediaFragment {
1081        MediaFragment {
1082            period: self.period,
1083            url: self.url,
1084            start_byte: self.start_byte,
1085            end_byte: self.end_byte,
1086            is_init: self.is_init,
1087            timeout: self.timeout
1088        }
1089    }
1090}
1091
1092// This struct is used to share information concerning the media fragments identified while parsing
1093// a Period as being wanted for download, alongside any diagnostics information that we collected
1094// while parsing the Period (in particular, any ContentProtection details).
1095#[derive(Debug, Default)]
1096struct PeriodOutputs {
1097    fragments: Vec<MediaFragment>,
1098    diagnostics: Vec<String>,
1099    subtitle_formats: Vec<SubtitleType>,
1100    selected_audio_language: String,
1101    selected_subtitle_language: String,
1102}
1103
1104#[derive(Debug, Default)]
1105struct PeriodDownloads {
1106    audio_fragments: Vec<MediaFragment>,
1107    video_fragments: Vec<MediaFragment>,
1108    subtitle_fragments: Vec<MediaFragment>,
1109    subtitle_formats: Vec<SubtitleType>,
1110    period_counter: u8,
1111    id: Option<String>,
1112    selected_audio_language: String,
1113    selected_subtitle_language: String,
1114}
1115
1116fn period_fragment_count(pd: &PeriodDownloads) -> usize {
1117    pd.audio_fragments.len() +
1118        pd.video_fragments.len() +
1119        pd.subtitle_fragments.len()
1120}
1121
1122
1123
1124async fn throttle_download_rate(downloader: &DashDownloader, size: u32) -> Result<(), DashMpdError> {
1125    if downloader.rate_limit > 0 {
1126        if let Some(cells) = NonZeroU32::new(size) {
1127            if let Some(limiter) = downloader.bw_limiter.as_ref() {
1128                #[allow(clippy::redundant_pattern_matching)]
1129                if let Err(_) = limiter.until_n_ready(cells).await {
1130                    return Err(DashMpdError::Other(
1131                        "Bandwidth limit is too low".to_string()));
1132                }
1133            }
1134        }
1135    }
1136    Ok(())
1137}
1138
1139
1140fn generate_filename_from_url(url: &str) -> PathBuf {
1141    use sanitise_file_name::{sanitise_with_options, Options};
1142
1143    let mut path = url;
1144    if let Some(p) = path.strip_prefix("http://") {
1145        path = p;
1146    } else if let Some(p) = path.strip_prefix("https://") {
1147        path = p;
1148    } else if let Some(p) = path.strip_prefix("file://") {
1149        path = p;
1150    }
1151    if let Some(p) = path.strip_prefix("www.") {
1152        path = p;
1153    }
1154    if let Some(p) = path.strip_prefix("ftp.") {
1155        path = p;
1156    }
1157    if let Some(p) = path.strip_suffix(".mpd") {
1158        path = p;
1159    }
1160    let mut sanitize_opts = Options::DEFAULT;
1161    sanitize_opts.length_limit = 150;
1162    // We could also enable sanitize_opts.url_safe here.
1163
1164    // We currently default to an MP4 container (could default to Matroska which is more flexible,
1165    // and less patent-encumbered, but perhaps less commonly supported).
1166    PathBuf::from(sanitise_with_options(path, &sanitize_opts) + ".mp4")
1167}
1168
1169// A manifest containing a single Period will be saved to the output name requested by calling
1170// download_to("outputname.mp4") or to a name determined by generate_filename_from_url() above from
1171// the MPD URL.
1172//
1173// A manifest containing multiple Periods will be saved (in the general case where each period has a
1174// different resolution) to files whose name is built from the outputname, including the period name
1175// as a stem suffix (e.g. "outputname-p3.mp4" for the third period). The content of the first Period
1176// will be saved to a file with the requested outputname ("outputname.mp4" in this example).
1177//
1178// In the special case where each period has the same resolution (meaning that it is possible to
1179// concatenate the Periods into a single media container, re-encoding if the codecs used in each
1180// period differ), the content will be saved to a single file named as for a single Period.
1181//
1182// Illustration for a three-Period manifest with differing resolutions:
1183//
1184//    download_to("foo.mkv") => foo.mkv (Period 1), foo-p2.mkv (Period 2), foo-p3.mkv (Period 3)
1185fn output_path_for_period(base: &Path, period: u8) -> PathBuf {
1186    assert!(period > 0);
1187    if period == 1 {
1188        base.to_path_buf()
1189    } else {
1190        if let Some(stem) = base.file_stem() {
1191            if let Some(ext) = base.extension() {
1192                let fname = format!("{}-p{period}.{}", stem.to_string_lossy(), ext.to_string_lossy());
1193                return base.with_file_name(fname);
1194            }
1195        }
1196        let p = format!("dashmpd-p{period}");
1197        tmp_file_path(&p, base.extension().unwrap_or(OsStr::new("mp4")))
1198            .unwrap_or_else(|_| p.into())
1199    }
1200}
1201
1202fn is_absolute_url(s: &str) -> bool {
1203    s.starts_with("http://") ||
1204        s.starts_with("https://") ||
1205        s.starts_with("file://") ||
1206        s.starts_with("ftp://")
1207}
1208
1209fn merge_baseurls(current: &Url, new: &str) -> Result<Url, DashMpdError> {
1210    if is_absolute_url(new) {
1211        Url::parse(new)
1212            .map_err(|e| parse_error("parsing BaseURL", e))
1213    } else {
1214        // We are careful to merge the query portion of the current URL (which is either the
1215        // original manifest URL, or the URL that it redirected to, or the value of a BaseURL
1216        // element in the manifest) with the new URL. But if the new URL already has a query string,
1217        // it takes precedence.
1218        //
1219        // Examples
1220        //
1221        // merge_baseurls(https://example.com/manifest.mpd?auth=secret, /video42.mp4) =>
1222        //   https://example.com/video42.mp4?auth=secret
1223        //
1224        // merge_baseurls(https://example.com/manifest.mpd?auth=old, /video42.mp4?auth=new) =>
1225        //   https://example.com/video42.mp4?auth=new
1226        let mut merged = current.join(new)
1227            .map_err(|e| parse_error("joining base with BaseURL", e))?;
1228        if merged.query().is_none() {
1229            merged.set_query(current.query());
1230        }
1231        Ok(merged)
1232    }
1233}
1234
1235// Return true if the response includes a content-type header corresponding to audio. We need to
1236// allow "video/" MIME types because some servers return "video/mp4" content-type for audio segments
1237// in an MP4 container, and we accept application/octet-stream headers because some servers are
1238// poorly configured.
1239fn content_type_audio_p(response: &reqwest::Response) -> bool {
1240    match response.headers().get("content-type") {
1241        Some(ct) => {
1242            let ctb = ct.as_bytes();
1243            ctb.starts_with(b"audio/") ||
1244                ctb.starts_with(b"video/") ||
1245                ctb.starts_with(b"application/octet-stream")
1246        },
1247        None => false,
1248    }
1249}
1250
1251// Return true if the response includes a content-type header corresponding to video.
1252fn content_type_video_p(response: &reqwest::Response) -> bool {
1253    match response.headers().get("content-type") {
1254        Some(ct) => {
1255            let ctb = ct.as_bytes();
1256            ctb.starts_with(b"video/") ||
1257                ctb.starts_with(b"application/octet-stream")
1258        },
1259        None => false,
1260    }
1261}
1262
1263
1264// Return a measure of the distance between this AdaptationSet's lang attribute and the language
1265// code specified by language_preference. If the AdaptationSet node has no lang attribute, return an
1266// arbitrary large distance.
1267fn adaptation_lang_distance(a: &AdaptationSet, language_preference: &str) -> u8 {
1268    if let Some(lang) = &a.lang {
1269        if lang.eq(language_preference) {
1270            return 0;
1271        }
1272        // The Levenshtein similarity measure for strings
1273        edit_distance(lang, language_preference)
1274            .try_into()
1275            .unwrap_or(u8::MAX)
1276    } else {
1277        100
1278    }
1279}
1280
1281// We can have a <Role value="foobles"> element directly within the AdaptationSet element, or within
1282// a ContentComponent element in the AdaptationSet.
1283fn adaptation_roles(a: &AdaptationSet) -> Vec<String> {
1284    let mut roles = Vec::new();
1285    for r in &a.Role {
1286        if let Some(rv) = &r.value {
1287            roles.push(String::from(rv));
1288        }
1289    }
1290    for cc in &a.ContentComponent {
1291        for r in &cc.Role {
1292            if let Some(rv) = &r.value {
1293                roles.push(String::from(rv));
1294            }
1295        }
1296    }
1297    roles
1298}
1299
1300// Best possible "score" is zero. 
1301fn adaptation_role_distance(a: &AdaptationSet, role_preference: &[String]) -> u8 {
1302    adaptation_roles(a).iter()
1303        .map(|r| role_preference.binary_search(r).unwrap_or(u8::MAX.into()))
1304        .map(|u| u8::try_from(u).unwrap_or(u8::MAX))
1305        .min()
1306        .unwrap_or(u8::MAX)
1307}
1308
1309
1310// We select the AdaptationSets that correspond to our language preference, and if there are several
1311// with our language preference, that with the role according to role_preference, and if no
1312// role_preference, return all adaptations.
1313//
1314// Start by getting a Vec of adaptation_lang_distance
1315// Take the min and collect all Adaptations where dist = min_distance
1316// then apply role_preference
1317fn select_preferred_adaptations<'a>(
1318    adaptations: Vec<&'a AdaptationSet>,
1319    downloader: &DashDownloader) -> Vec<&'a AdaptationSet>
1320{
1321    let mut preferred: Vec<&'a AdaptationSet>;
1322    // TODO: modify this algorithm to allow for multiple preferred languages
1323    if let Some(ref lang) = downloader.language_preference_audio {
1324        preferred = Vec::new();
1325        let distance: Vec<u8> = adaptations.iter()
1326            .map(|a| adaptation_lang_distance(a, lang))
1327            .collect();
1328        let min_distance = distance.iter().min().unwrap_or(&0);
1329        for (i, a) in adaptations.iter().enumerate() {
1330            if let Some(di) = distance.get(i) {
1331                if di == min_distance {
1332                    preferred.push(a);
1333                }
1334            }
1335        }
1336    } else {
1337        preferred = adaptations;
1338    }
1339    // Apply the role_preference. For example, a role_preference of ["main", "alternate",
1340    // "supplementary", "commentary"] means we should prefer an AdaptationSet with role=main, and
1341    // return only that AdaptationSet. If there are no role annotations on the AdaptationSets, or
1342    // the specified roles don't match anything in our role_preference ordering, then all
1343    // AdaptationSets will receive the maximum distance and they will all be returned.
1344    let role_distance: Vec<u8> = preferred.iter()
1345        .map(|a| adaptation_role_distance(a, &downloader.role_preference))
1346        .collect();
1347    let role_distance_min = role_distance.iter().min().unwrap_or(&0);
1348    let mut best = Vec::new();
1349    for (i, a) in preferred.into_iter().enumerate() {
1350        if let Some(rdi) = role_distance.get(i) {
1351            if rdi == role_distance_min {
1352                best.push(a);
1353            }
1354        }
1355    }
1356    best
1357}
1358
1359
1360// Filter Representations according to their @id by filtering out those that do not have the
1361// user-specified video_id_wanted substring in the id attribute.
1362fn representation_filter_video_id<'a>(
1363    representations: Vec<&'a Representation>,
1364    downloader: &DashDownloader) -> Vec<&'a Representation>
1365{
1366    if let Some(wantid) = &downloader.video_id_wanted {
1367        representations.iter()
1368            .filter(|r| r.id.as_ref().is_some_and(|i| i.contains(wantid)))
1369            .copied()
1370            .collect()
1371    } else {
1372        representations
1373    }
1374}
1375
1376// Filter Representations according to their video width, retaining those that have a video width
1377// which is closest to the preference specified by the user. If several Representations have the
1378// same width, for example with different video codecs, we return all the Representations with that
1379// width.
1380fn representation_filter_video_width<'a>(
1381    representations: Vec<&'a Representation>,
1382    downloader: &DashDownloader) -> Vec<&'a Representation>
1383{
1384    if let Some(want) = downloader.video_width_preference {
1385        let best = representations.iter()
1386            .min_by_key(|x| if let Some(w) = x.width { want.abs_diff(w) } else { u64::MAX });
1387        match best {
1388            Some(b) => representations.iter()
1389                .filter(|r| r.width == b.width)
1390                .copied()
1391                .collect::<Vec<&Representation>>(),
1392            None => representations,
1393        }
1394    } else {
1395        representations
1396    }
1397}
1398
1399// Filter Representations according to their video height, retaining those that have a video height
1400// which is closest to the preference specified by the user. If several Representations have the
1401// same height, for example with different video codecs, we return all the Representations with that
1402// height.
1403fn representation_filter_video_height<'a>(
1404    representations: Vec<&'a Representation>,
1405    downloader: &DashDownloader) -> Vec<&'a Representation>
1406{
1407    if let Some(want) = downloader.video_height_preference {
1408        let best = representations.iter()
1409            .min_by_key(|x| if let Some(h) = x.height { want.abs_diff(h) } else { u64::MAX });
1410        match best {
1411            Some(b) => representations.iter()
1412                .filter(|r| r.height == b.height)
1413                .copied()
1414                .collect::<Vec<&Representation>>(),
1415            None => representations,
1416        }
1417    } else {
1418        representations
1419    }
1420}
1421
1422// Filter Representations according to the video codec, following the user-specified preference
1423// ordering in video_codec_preference. If the preference is not specified (the
1424// video_codec_preference is empty), then do not filter out any Representations.
1425//
1426// FIXME Here we assume that the codec is specified on the Representation element, but it could also
1427// be specified on the parent AdaptationSet.
1428fn representation_filter_video_codec<'a>(
1429    representations: Vec<&'a Representation>,
1430    downloader: &DashDownloader) -> Vec<&'a Representation>
1431{
1432    if downloader.video_codec_preference.is_empty() {
1433        representations
1434    } else {
1435        let best = representations.iter()
1436            .min_by_key(|r|
1437                        if let Some(codec) = &r.codecs {
1438                            downloader.video_codec_preference.iter()
1439                                .position(|prefc| codec.starts_with(prefc))
1440                                .unwrap_or(usize::MAX)
1441                        } else {
1442                           usize::MAX
1443                        });
1444        match best {
1445            Some(b) => if let Some(bcodec) = &b.codecs {
1446                // It's not uncommon for the Representations in an AdaptationSet to have different
1447                // codec subfamilies, which are specified in the manifest (eg "avc1.64000d",
1448                // "avc1.640015", "avc1.640016" and so on). We only want to filter on the codec
1449                // family (avc1 in this example), rather than on the specific subfamily.
1450                let bcodec_start = match bcodec.find('.') {
1451                    Some(idx) => &bcodec[..idx],
1452                    None => bcodec,
1453                };
1454                representations.iter()
1455                    .filter(|r| r.codecs.as_ref()
1456                            .is_some_and(|rc| rc.starts_with(bcodec_start)))
1457                    .copied()
1458                    .collect()
1459            } else {
1460                representations
1461            },
1462            None => representations,
1463        }
1464    }
1465}
1466
1467// Filter Representations according to the user-specified quality_preference. Rank following the
1468// @qualityRanking attribute if it is present, and otherwise by the @bandwidth attribute. Note that
1469// quality ranking may be different from bandwidth ranking when different codecs are used. Note that
1470// there is always a quality_preference, which defaults to the lowest quality and smallest file
1471// size.
1472fn representation_filter_video_quality<'a>(
1473    representations: Vec<&'a Representation>,
1474    downloader: &DashDownloader) -> Vec<&'a Representation>
1475{
1476    if representations.iter().all(|x| x.qualityRanking.is_some()) {
1477        // rank according to the @qualityRanking attribute (lower values represent
1478        // higher quality content)
1479        match downloader.quality_preference {
1480            QualityPreference::Lowest => {
1481                let best = representations.iter()
1482                    .max_by_key(|r| r.qualityRanking.unwrap_or(u8::MAX));
1483                match best {
1484                    Some(b) => representations.iter()
1485                        .filter(|r| r.qualityRanking.unwrap_or(u8::MAX) ==
1486                                b.qualityRanking.unwrap_or(u8::MAX))
1487                        .copied()
1488                        .collect(),
1489                    None => representations,
1490                }
1491            },
1492            QualityPreference::Highest => {
1493                let best = representations.iter()
1494                    .min_by_key(|r| r.qualityRanking.unwrap_or(0));
1495                match best {
1496                    Some(b) => representations.iter()
1497                        .filter(|r| r.qualityRanking.unwrap_or(0) ==
1498                                b.qualityRanking.unwrap_or(0))
1499                        .copied()
1500                        .collect(),
1501                    None => representations,
1502                }
1503            },
1504            QualityPreference::Intermediate => {
1505                let count = representations.len();
1506                match count {
1507                    0 | 1 => representations,
1508                    _ => {
1509                        let mut ranking: Vec<u8> = representations.iter()
1510                            .map(|r| r.qualityRanking.unwrap_or(u8::MAX))
1511                            .collect();
1512                        ranking.sort_unstable();
1513                        if let Some(want_ranking) = ranking.get(count / 2) {
1514                            representations.iter()
1515                                .filter(|r| r.qualityRanking.unwrap_or(u8::MAX) == *want_ranking)
1516                                .copied()
1517                                .collect()
1518                        } else {
1519                            representations
1520                        }
1521                    },
1522                }
1523            },
1524        }
1525    } else {
1526        // rank according to the bandwidth attribute (lower values imply lower quality)
1527        let bw_large = 1_000_000_000;
1528        match downloader.quality_preference {
1529            QualityPreference::Lowest => {
1530                let best = representations.iter()
1531                    .min_by_key(|r| r.bandwidth.unwrap_or(bw_large));
1532                match best {
1533                    Some(b) => representations.iter()
1534                        .filter(|r| r.bandwidth.unwrap_or(bw_large) ==
1535                                b.bandwidth.unwrap_or(bw_large))
1536                        .copied()
1537                        .collect(),
1538                    None => representations,
1539                }
1540            },
1541            QualityPreference::Highest => {
1542                let best = representations.iter()
1543                    .max_by_key(|r| r.bandwidth.unwrap_or(0));
1544                match best {
1545                    Some(b) => representations.iter()
1546                        .filter(|r| r.bandwidth.unwrap_or(0) ==
1547                            b.bandwidth.unwrap_or(0))
1548                        .copied()
1549                        .collect(),
1550                    None => representations,
1551                }
1552            }
1553            QualityPreference::Intermediate => {
1554                let count = representations.len();
1555                match count {
1556                    0 | 1 => representations,
1557                    _ => {
1558                        let mut ranking: Vec<u64> = representations.iter()
1559                            .map(|r| r.bandwidth.unwrap_or(bw_large))
1560                            .collect();
1561                        ranking.sort_unstable();
1562                        if let Some(want_ranking) = ranking.get(count / 2) {
1563                            representations.iter()
1564                                .filter(|r| r.bandwidth.unwrap_or(bw_large) == *want_ranking)
1565                                .copied()
1566                                .collect()
1567                        } else {
1568                            representations
1569                        }
1570                    },
1571                }
1572            },
1573        }
1574    }
1575}
1576
1577
1578// A manifest often contains multiple video Representations with different codecs, bandwidths and
1579// video resolutions. We select the Representation to download by ranking following the user's
1580// specified codec preference, or their quality preference. We first rank following the
1581// @qualityRanking attribute if it is present, and otherwise by the bandwidth specified. Note that
1582// quality ranking may be different from bandwidth ranking when different codecs are used.
1583fn select_preferred_representation<'a>(
1584    representations: &[&'a Representation],
1585    downloader: &DashDownloader) -> Option<&'a Representation>
1586{
1587    if representations.iter().all(|x| x.qualityRanking.is_some()) {
1588        // rank according to the @qualityRanking attribute (lower values represent
1589        // higher quality content)
1590        match downloader.quality_preference {
1591            QualityPreference::Lowest =>
1592                representations.iter()
1593                .max_by_key(|r| r.qualityRanking.unwrap_or(u8::MAX))
1594                .copied(),
1595            QualityPreference::Highest =>
1596                representations.iter().min_by_key(|r| r.qualityRanking.unwrap_or(0))
1597                    .copied(),
1598            QualityPreference::Intermediate => {
1599                let count = representations.len();
1600                match count {
1601                    0 => None,
1602                    1 => Some(representations[0]),
1603                    _ => {
1604                        let mut ranking: Vec<u8> = representations.iter()
1605                            .map(|r| r.qualityRanking.unwrap_or(u8::MAX))
1606                            .collect();
1607                        ranking.sort_unstable();
1608                        if let Some(want_ranking) = ranking.get(count / 2) {
1609                            representations.iter()
1610                                .find(|r| r.qualityRanking.unwrap_or(u8::MAX) == *want_ranking)
1611                                .copied()
1612                        } else {
1613                            representations.first().copied()
1614                        }
1615                    },
1616                }
1617            },
1618        }
1619    } else {
1620        // rank according to the bandwidth attribute (lower values imply lower quality)
1621        match downloader.quality_preference {
1622            QualityPreference::Lowest => representations.iter()
1623                .min_by_key(|r| r.bandwidth.unwrap_or(1_000_000_000))
1624                .copied(),
1625            QualityPreference::Highest => representations.iter()
1626                .max_by_key(|r| r.bandwidth.unwrap_or(0))
1627                .copied(),
1628            QualityPreference::Intermediate => {
1629                let count = representations.len();
1630                match count {
1631                    0 => None,
1632                    1 => Some(representations[0]),
1633                    _ => {
1634                        let mut ranking: Vec<u64> = representations.iter()
1635                            .map(|r| r.bandwidth.unwrap_or(100_000_000))
1636                            .collect();
1637                        ranking.sort_unstable();
1638                        if let Some(want_ranking) = ranking.get(count / 2) {
1639                            representations.iter()
1640                                .find(|r| r.bandwidth.unwrap_or(100_000_000) == *want_ranking)
1641                                .copied()
1642                        } else {
1643                            representations.first().copied()
1644                        }
1645                    },
1646                }
1647            },
1648        }
1649    }
1650}
1651
1652
1653// The AdaptationSet a is the parent of the Representation r.
1654fn print_available_subtitles_representation(r: &Representation, a: &AdaptationSet) {
1655    let unspecified = "<unspecified>".to_string();
1656    let empty = "".to_string();
1657    let lang = r.lang.as_ref().unwrap_or(a.lang.as_ref().unwrap_or(&unspecified));
1658    let codecs = r.codecs.as_ref().unwrap_or(a.codecs.as_ref().unwrap_or(&empty));
1659    let typ = subtitle_type(&a);
1660    let stype = if !codecs.is_empty() {
1661        format!("{typ:?}/{codecs}")
1662    } else {
1663        format!("{typ:?}")
1664    };
1665    let role = a.Role.first()
1666        .map_or_else(|| String::from(""),
1667                     |r| r.value.as_ref().map_or_else(|| String::from(""), |v| format!(" role={v}")));
1668    let label = a.Label.first()
1669        .map_or_else(|| String::from(""), |l| format!(" label={}", l.clone().content));
1670    info!("  subs {stype:>18} | {lang:>10} |{role}{label}");
1671}
1672
1673fn print_available_subtitles_adaptation(a: &AdaptationSet) {
1674    a.representations.iter()
1675        .for_each(|r| print_available_subtitles_representation(r, a));
1676}
1677
1678// The AdaptationSet a is the parent of the Representation r.
1679fn print_available_streams_representation(r: &Representation, a: &AdaptationSet, typ: &str) {
1680    // for now, we ignore the Vec representation.SubRepresentation which could contain width, height, bw etc.
1681    let unspecified = "<unspecified>".to_string();
1682    let w = r.width.unwrap_or(a.width.unwrap_or(0));
1683    let h = r.height.unwrap_or(a.height.unwrap_or(0));
1684    let codec = r.codecs.as_ref().unwrap_or(a.codecs.as_ref().unwrap_or(&unspecified));
1685    let bw = r.bandwidth.unwrap_or(a.maxBandwidth.unwrap_or(0));
1686    let fmt = if typ.eq("audio") {
1687        let unknown = String::from("?");
1688        format!("lang={}", r.lang.as_ref().unwrap_or(a.lang.as_ref().unwrap_or(&unknown)))
1689    } else if w == 0 || h == 0 {
1690        // Some MPDs do not specify width and height, such as
1691        // https://dash.akamaized.net/fokus/adinsertion-samples/scte/dash.mpd
1692        String::from("")
1693    } else {
1694        format!("{w}x{h}")
1695    };
1696    let role = a.Role.first()
1697        .map_or_else(|| String::from(""),
1698                     |r| r.value.as_ref().map_or_else(|| String::from(""), |v| format!(" role={v}")));
1699    let label = a.Label.first()
1700        .map_or_else(|| String::from(""), |l| format!(" label={}", l.clone().content));
1701    let maybe_id = if let Some(rid) = &r.id {
1702        format!(" (id={rid})")
1703    } else {
1704        String::from("")
1705    };
1706    info!("  {typ} {codec:17} | {:5} Kbps | {fmt:>9}{role}{label}{maybe_id}", bw / 1024);
1707}
1708
1709fn print_available_streams_adaptation(a: &AdaptationSet, typ: &str) {
1710    a.representations.iter()
1711        .for_each(|r| print_available_streams_representation(r, a, typ));
1712}
1713
1714fn print_available_streams_period(p: &Period) {
1715    p.adaptations.iter()
1716        .filter(is_audio_adaptation)
1717        .for_each(|a| print_available_streams_adaptation(a, "audio"));
1718    p.adaptations.iter()
1719        .filter(is_video_adaptation)
1720        .for_each(|a| print_available_streams_adaptation(a, "video"));
1721    p.adaptations.iter()
1722        .filter(is_subtitle_adaptation)
1723        .for_each(print_available_subtitles_adaptation);
1724}
1725
1726#[tracing::instrument(level="trace", skip_all)]
1727fn print_available_streams(mpd: &MPD) {
1728    use humantime::format_duration;
1729
1730    let mut counter = 0;
1731    for p in &mpd.periods {
1732        let mut period_duration_secs: f64 = -1.0;
1733        if let Some(d) = mpd.mediaPresentationDuration {
1734            period_duration_secs = d.as_secs_f64();
1735        }
1736        if let Some(d) = &p.duration {
1737            period_duration_secs = d.as_secs_f64();
1738        }
1739        counter += 1;
1740        let duration = if period_duration_secs > 0.0 {
1741            format_duration(Duration::from_secs_f64(period_duration_secs)).to_string()
1742        } else {
1743            String::from("unknown")
1744        };
1745        if let Some(id) = p.id.as_ref() {
1746            info!("Streams in period {id} (#{counter}), duration {duration}:");
1747        } else {
1748            info!("Streams in period #{counter}, duration {duration}:");
1749        }
1750        print_available_streams_period(p);
1751    }
1752}
1753
1754async fn extract_init_pssh(downloader: &DashDownloader, init_url: Url) -> Option<Vec<u8>> {
1755    use bstr::ByteSlice;
1756    use hex_literal::hex;
1757
1758    if let Some(client) = downloader.http_client.as_ref() {
1759        let mut req = client.get(init_url);
1760        if let Some(referer) = &downloader.referer {
1761            req = req.header("Referer", referer);
1762        }
1763        if let Some(username) = &downloader.auth_username {
1764            if let Some(password) = &downloader.auth_password {
1765                req = req.basic_auth(username, Some(password));
1766            }
1767        }
1768        if let Some(token) = &downloader.auth_bearer_token {
1769            req = req.bearer_auth(token);
1770        }
1771        if let Ok(mut resp) = req.send().await {
1772            // We only download the first bytes of the init segment, because it may be very large in the
1773            // case of indexRange adressing, and we don't want to fill up RAM.
1774            let mut chunk_counter = 0;
1775            let mut segment_first_bytes = Vec::<u8>::new();
1776            while let Ok(Some(chunk)) = resp.chunk().await {
1777                let size = min((chunk.len()/1024+1) as u32, u32::MAX);
1778                #[allow(clippy::redundant_pattern_matching)]
1779                if let Err(_) = throttle_download_rate(downloader, size).await {
1780                    return None;
1781                }
1782                segment_first_bytes.append(&mut chunk.to_vec());
1783                chunk_counter += 1;
1784                if chunk_counter > 20 {
1785                    break;
1786                }
1787            }
1788            let needle = b"pssh";
1789            for offset in segment_first_bytes.find_iter(needle) {
1790                #[allow(clippy::needless_range_loop)]
1791                for i in offset-4..offset+2 {
1792                    if let Some(b) = segment_first_bytes.get(i) {
1793                        if *b != 0 {
1794                            continue;
1795                        }
1796                    }
1797                }
1798                #[allow(clippy::needless_range_loop)]
1799                for i in offset+4..offset+8 {
1800                    if let Some(b) = segment_first_bytes.get(i) {
1801                        if *b != 0 {
1802                            continue;
1803                        }
1804                    }
1805                }
1806                if offset+24 > segment_first_bytes.len() {
1807                    continue;
1808                }
1809                // const PLAYREADY_SYSID: [u8; 16] = hex!("9a04f07998404286ab92e65be0885f95");
1810                const WIDEVINE_SYSID: [u8; 16] = hex!("edef8ba979d64acea3c827dcd51d21ed");
1811                if let Some(sysid) = segment_first_bytes.get((offset+8)..(offset+24)) {
1812                    if !sysid.eq(&WIDEVINE_SYSID) {
1813                        continue;
1814                    }
1815                }
1816                if let Some(length) = segment_first_bytes.get(offset-1) {
1817                    let start = offset - 4;
1818                    let end = start + *length as usize;
1819                    if let Some(pssh) = &segment_first_bytes.get(start..end) {
1820                        return Some(pssh.to_vec());
1821                    }
1822                }
1823            }
1824        }
1825        None
1826    } else {
1827        None
1828    }
1829}
1830
1831
1832// From https://dashif.org/docs/DASH-IF-IOP-v4.3.pdf:
1833// "For the avoidance of doubt, only %0[width]d is permitted and no other identifiers. The reason
1834// is that such a string replacement can be easily implemented without requiring a specific library."
1835//
1836// Instead of pulling in C printf() or a reimplementation such as the printf_compat crate, we reimplement
1837// this functionality directly.
1838//
1839// Example template: "$RepresentationID$/$Number%06d$.m4s"
1840static URL_TEMPLATE_IDS: LazyLock<Vec<(&'static str, String, Regex)>> =
1841    LazyLock::new(|| vec!["RepresentationID", "Number", "Time", "Bandwidth"].into_iter()
1842                  .map(|k| (k, format!("${k}$"), Regex::new(&format!("\\${k}%0([\\d])d\\$")).unwrap()))
1843                  .collect());
1844
1845
1846fn resolve_url_template(template: &str, params: &HashMap<&str, String>) -> String {
1847    let mut result = template.to_string();
1848    for (k, ident, rx) in URL_TEMPLATE_IDS.iter() {
1849        // first check for simple cases such as $Number$
1850        if result.contains(ident) {
1851            if let Some(value) = params.get(k as &str) {
1852                result = result.replace(ident, value);
1853            }
1854        }
1855        // now check for complex cases such as $Number%06d$
1856        if let Some(cap) = rx.captures(&result) {
1857            if let Some(value) = params.get(k as &str) {
1858                if let Ok(width) = cap[1].parse::<usize>() {
1859                    if let Some(m) = rx.find(&result) {
1860                        let count = format!("{value:0>width$}");
1861                        result = result[..m.start()].to_owned() + &count + &result[m.end()..];
1862                    }
1863                }
1864            }
1865        }
1866    }
1867    result
1868}
1869
1870
1871fn reqwest_error_transient_p(e: &reqwest::Error) -> bool {
1872    if e.is_timeout() {
1873        return true;
1874    }
1875    if let Some(s) = e.status() {
1876        if s == reqwest::StatusCode::REQUEST_TIMEOUT ||
1877            s == reqwest::StatusCode::TOO_MANY_REQUESTS ||
1878            s == reqwest::StatusCode::SERVICE_UNAVAILABLE ||
1879            s == reqwest::StatusCode::GATEWAY_TIMEOUT {
1880                return true;
1881            }
1882    }
1883    false
1884}
1885
1886fn notify_transient<E: std::fmt::Debug>(err: &E, dur: Duration) {
1887    warn!("Transient error after {dur:?}: {err:?}");
1888}
1889
1890fn network_error(why: &str, e: &reqwest::Error) -> DashMpdError {
1891    if e.is_timeout() {
1892        DashMpdError::NetworkTimeout(format!("{why}: {e:?}"))
1893    } else if e.is_connect() {
1894        DashMpdError::NetworkConnect(format!("{why}: {e:?}"))
1895    } else {
1896        DashMpdError::Network(format!("{why}: {e:?}"))
1897    }
1898}
1899
1900fn parse_error(why: &str, e: impl std::error::Error) -> DashMpdError {
1901    DashMpdError::Parsing(format!("{why}: {e:#?}"))
1902}
1903
1904
1905// This would be easier with middleware such as https://lib.rs/crates/tower-reqwest or
1906// https://lib.rs/crates/reqwest-retry or https://docs.rs/again/latest/again/
1907// or https://github.com/naomijub/tokio-retry
1908async fn reqwest_bytes_with_retries(
1909    client: &reqwest::Client,
1910    req: reqwest::Request,
1911    retry_count: u32) -> Result<Bytes, reqwest::Error>
1912{
1913    let mut last_error = None;
1914    for _ in 0..retry_count {
1915        if let Some(rqw) = req.try_clone() {
1916            match client.execute(rqw).await {
1917                Ok(response) => {
1918                    match response.error_for_status() {
1919                        Ok(resp) => {
1920                            match resp.bytes().await {
1921                                Ok(bytes) => return Ok(bytes),
1922                                Err(e) => {
1923                                    info!("Retrying after HTTP error {e:?}");
1924                                    last_error = Some(e);
1925                                },
1926                            }
1927                        },
1928                        Err(e) => {
1929                            info!("Retrying after HTTP error {e:?}");
1930                            last_error = Some(e);
1931                        },
1932                    }
1933                },
1934                Err(e) => {
1935                    info!("Retrying after HTTP error {e:?}");
1936                    last_error = Some(e);
1937                },
1938            }
1939        }
1940    }
1941    Err(last_error.unwrap())
1942}
1943
1944// As per https://www.freedesktop.org/wiki/CommonExtendedAttributes/, set extended filesystem
1945// attributes indicating metadata such as the origin URL, title, source and copyright, if
1946// specified in the MPD manifest. This functionality is only active on platforms where the xattr
1947// crate supports extended attributes (currently Android, Linux, MacOS, FreeBSD, and NetBSD); on
1948// unsupported Unix platforms it's a no-op. On other non-Unix platforms the crate doesn't build.
1949//
1950// TODO: on Windows, could use NTFS Alternate Data Streams
1951// https://en.wikipedia.org/wiki/NTFS#Alternate_data_stream_(ADS)
1952//
1953// We could also include a certain amount of metainformation (title, copyright) in the video
1954// container metadata, though this would have to be implemented separately by each muxing helper and
1955// each concat helper application in the ffmpeg module.
1956#[allow(unused_variables)]
1957fn maybe_record_metainformation(path: &Path, downloader: &DashDownloader, mpd: &MPD) {
1958    #[cfg(target_family = "unix")]
1959    if downloader.record_metainformation && (downloader.fetch_audio || downloader.fetch_video) {
1960        if let Ok(origin_url) = Url::parse(&downloader.mpd_url) {
1961            // Don't record the origin URL if it contains sensitive information such as passwords
1962            #[allow(clippy::collapsible_if)]
1963            if origin_url.username().is_empty() && origin_url.password().is_none() {
1964                #[cfg(target_family = "unix")]
1965                if xattr::set(path, "user.xdg.origin.url", downloader.mpd_url.as_bytes()).is_err() {
1966                    info!("Failed to set user.xdg.origin.url xattr on output file");
1967                }
1968            }
1969            for pi in &mpd.ProgramInformation {
1970                if let Some(t) = &pi.Title {
1971                    if let Some(tc) = &t.content {
1972                        if xattr::set(path, "user.dublincore.title", tc.as_bytes()).is_err() {
1973                            info!("Failed to set user.dublincore.title xattr on output file");
1974                        }
1975                    }
1976                }
1977                if let Some(source) = &pi.Source {
1978                    if let Some(sc) = &source.content {
1979                        if xattr::set(path, "user.dublincore.source", sc.as_bytes()).is_err() {
1980                            info!("Failed to set user.dublincore.source xattr on output file");
1981                        }
1982                    }
1983                }
1984                if let Some(copyright) = &pi.Copyright {
1985                    if let Some(cc) = &copyright.content {
1986                        if xattr::set(path, "user.dublincore.rights", cc.as_bytes()).is_err() {
1987                            info!("Failed to set user.dublincore.rights xattr on output file");
1988                        }
1989                    }
1990                }
1991            }
1992        }
1993    }
1994}
1995
1996// From the DASH-IF-IOP-v4.0 specification, "If the value of the @xlink:href attribute is
1997// urn:mpeg:dash:resolve-to-zero:2013, HTTP GET request is not issued, and the in-MPD element shall
1998// be removed from the MPD."
1999fn fetchable_xlink_href(href: &str) -> bool {
2000    (!href.is_empty()) && href.ne("urn:mpeg:dash:resolve-to-zero:2013")
2001}
2002
2003fn element_resolves_to_zero(xot: &mut Xot, element: xot::Node) -> bool {
2004    let xlink_ns = xmlname::CreateNamespace::new(xot, "xlink", "http://www.w3.org/1999/xlink");
2005    let xlink_href_name = xmlname::CreateName::namespaced(xot, "href", &xlink_ns);
2006    if let Some(href) = xot.get_attribute(element, xlink_href_name.into()) {
2007        return href.eq("urn:mpeg:dash:resolve-to-zero:2013");
2008    }
2009    false
2010}
2011
2012fn skip_xml_preamble(input: &str) -> &str {
2013    if input.starts_with("<?xml") {
2014        if let Some(end_pos) = input.find("?>") {
2015            // Return the part of the string after the XML declaration
2016            return &input[end_pos + 2..]; // Skip past "?>"
2017        }
2018    }
2019    // If no XML preamble, return the original string
2020    input
2021}
2022
2023async fn apply_xslt_stylesheets(
2024    downloader: &DashDownloader,
2025    xot: &mut Xot,
2026    doc: xot::Node) -> Result<String, DashMpdError> {
2027    #[cfg(feature = "xee-xslt")]
2028    return apply_xslt_stylesheets_xee(downloader, xot, doc).await;
2029    #[cfg(not(feature = "xee-xslt"))]
2030    return apply_xslt_stylesheets_xsltproc(downloader, xot, doc).await;
2031}
2032
2033// Run user-specified XSLT stylesheets on the manifest, using xsltproc (a component of libxslt)
2034// as a commandline filter application. Existing XSLT implementations in Rust are incomplete
2035// (but improving; hopefully we will one day be able to use the xrust crate).
2036#[allow(dead_code)]
2037async fn apply_xslt_stylesheets_xsltproc(
2038    downloader: &DashDownloader,
2039    xot: &mut Xot,
2040    doc: xot::Node) -> Result<String, DashMpdError> {
2041    let mut buf = Vec::new();
2042    xot.write(doc, &mut buf)
2043        .map_err(|e| parse_error("serializing rewritten manifest", e))?;
2044    for ss in &downloader.xslt_stylesheets {
2045        if downloader.verbosity > 0 {
2046            info!("Applying XSLT stylesheet {} with xsltproc", ss.display());
2047        }
2048        let tmpmpd = tmp_file_path("dashxslt", OsStr::new("xslt"))?;
2049        fs::write(&tmpmpd, &buf).await
2050            .map_err(|e| DashMpdError::Io(e, String::from("writing MPD")))?;
2051        let xsltproc = Command::new("xsltproc")
2052            .args([ss, &tmpmpd])
2053            .output()
2054            .map_err(|e| DashMpdError::Io(e, String::from("spawning xsltproc")))?;
2055        if !xsltproc.status.success() {
2056            let msg = format!("xsltproc returned {}", xsltproc.status);
2057            let out = partial_process_output(&xsltproc.stderr).clone();
2058            return Err(DashMpdError::Io(std::io::Error::other(msg), out));
2059        }
2060        if env::var("DASHMPD_PERSIST_FILES").is_err() {
2061            if let Err(e) = fs::remove_file(&tmpmpd).await {
2062                warn!("Error removing temporary MPD after XSLT processing: {e:?}");
2063            }
2064        }
2065        buf.clone_from(&xsltproc.stdout);
2066        if downloader.verbosity > 2 {
2067            println!("Rewritten XSLT: {}", String::from_utf8_lossy(&buf));
2068        }
2069    }
2070    String::from_utf8(buf)
2071        .map_err(|e| parse_error("parsing UTF-8", e))
2072}
2073
2074// Try to use the xee crate functionality for XSLT processing. We need an alternative utility
2075// function to evaluate that accepts a full XSLT stylehseet, rather than only the XML for a
2076// transform.
2077#[allow(dead_code)]
2078#[cfg(feature = "xee-xslt")]
2079async fn apply_xslt_stylesheets_xee(
2080    downloader: &DashDownloader,
2081    xot: &mut Xot,
2082    doc: xot::Node) -> Result<String, DashMpdError>
2083{
2084    use xee_xslt_compiler::evaluate;
2085    use std::fmt::Write;
2086
2087    let mut xml = xot.to_string(doc)
2088        .map_err(|e| parse_error("serializing rewritten manifest", e))?;
2089    for ss in &downloader.xslt_stylesheets {
2090        if downloader.verbosity > 0 {
2091            info!("  Applying XSLT stylesheet {} with xee", ss.display());
2092        }
2093        let xslt = fs::read_to_string(ss).await
2094            .map_err(|_| DashMpdError::Other(String::from("reading XSLT stylesheet")))?;
2095        let seq = evaluate(xot, &xml, &xslt)
2096            .map_err(|e| DashMpdError::Other(format!("applying XSLT: {e:?}")))?;
2097        let mut f = String::new();
2098        for item in seq.iter() {
2099            match item.to_node() {
2100                Ok(n) => f.write_str(&xot.to_string(n).expect("writing to string"))
2101                    .expect("writing to string"),
2102                Err(e) => error!("xee non-node item {item:?}: {e:?}"),
2103            }
2104        }
2105        xml = f;
2106    }
2107    Ok(xml)
2108}
2109
2110// Walk all descendents of the root node, looking for target nodes with an xlink:href and collect
2111// into a Vec. For each of these, retrieve the remote content, insert_after() the target node, then
2112// delete the target node.
2113async fn resolve_xlink_references(
2114    downloader: &DashDownloader,
2115    xot: &mut Xot,
2116    node: xot::Node) -> Result<(), DashMpdError>
2117{
2118    let xlink_ns = xmlname::CreateNamespace::new(xot, "xlink", "http://www.w3.org/1999/xlink");
2119    let xlink_href_name = xmlname::CreateName::namespaced(xot, "href", &xlink_ns);
2120    let xlinked = xot.descendants(node)
2121        .filter(|d| xot.get_attribute(*d, xlink_href_name.into()).is_some())
2122        .collect::<Vec<_>>();
2123    for xl in xlinked {
2124        if element_resolves_to_zero(xot, xl) {
2125            trace!("Removing node with resolve-to-zero xlink:href {xl:?}");
2126            if let Err(e) = xot.remove(xl) {
2127                return Err(parse_error("Failed to remove resolve-to-zero XML node", e));
2128            }
2129        } else if let Some(href) = xot.get_attribute(xl, xlink_href_name.into()) {
2130            if fetchable_xlink_href(href) {
2131                let xlink_url = if is_absolute_url(href) {
2132                    Url::parse(href)
2133                        .map_err(|e|
2134                            if let Ok(ns) = xot.to_string(node) {
2135                                parse_error(&format!("parsing XLink on {ns}"), e)
2136                            } else {
2137                                parse_error("parsing XLink", e)
2138                            }
2139                        )?
2140                } else {
2141                    // Note that we are joining against the original/redirected URL for the MPD, and
2142                    // not against the currently scoped BaseURL
2143                    let mut merged = downloader.redirected_url.join(href)
2144                        .map_err(|e|
2145                            if let Ok(ns) = xot.to_string(node) {
2146                                parse_error(&format!("parsing XLink on {ns}"), e)
2147                            } else {
2148                                parse_error("parsing XLink", e)
2149                            }
2150                        )?;
2151                    merged.set_query(downloader.redirected_url.query());
2152                    merged
2153                };
2154                let client = downloader.http_client.as_ref().unwrap();
2155                trace!("Fetching XLinked element {}", xlink_url.clone());
2156                let mut req = client.get(xlink_url.clone())
2157                    .header("Accept", "application/dash+xml,video/vnd.mpeg.dash.mpd")
2158                    .header("Accept-Language", "en-US,en")
2159                    .header("Sec-Fetch-Mode", "navigate");
2160                if let Some(referer) = &downloader.referer {
2161                    req = req.header("Referer", referer);
2162                } else {
2163                    req = req.header("Referer", downloader.redirected_url.to_string());
2164                }
2165                if let Some(username) = &downloader.auth_username {
2166                    if let Some(password) = &downloader.auth_password {
2167                        req = req.basic_auth(username, Some(password));
2168                    }
2169                }
2170                if let Some(token) = &downloader.auth_bearer_token {
2171                    req = req.bearer_auth(token);
2172                }
2173                let xml = req.send().await
2174                    .map_err(|e|
2175                             if let Ok(ns) = xot.to_string(node) {
2176                                 network_error(&format!("fetching XLink for {ns}"), &e)
2177                             } else {
2178                                 network_error("fetching XLink", &e)
2179                             }
2180                        )?
2181                    .error_for_status()
2182                    .map_err(|e|
2183                             if let Ok(ns) = xot.to_string(node) {
2184                                 network_error(&format!("fetching XLink for {ns}"), &e)
2185                             } else {
2186                                 network_error("fetching XLink", &e)
2187                             }
2188                        )?
2189                    .text().await
2190                    .map_err(|e|
2191                             if let Ok(ns) = xot.to_string(node) {
2192                                 network_error(&format!("resolving XLink for {ns}"), &e)
2193                             } else {
2194                                 network_error("resolving XLink", &e)
2195                             }
2196                        )?;
2197                if downloader.verbosity > 2 {
2198                    if let Ok(ns) = xot.to_string(node) {
2199                        info!("  Resolved onLoad XLink {xlink_url} on {ns} -> {} octets", xml.len());
2200                    } else {
2201                        info!("  Resolved onLoad XLink {xlink_url} -> {} octets", xml.len());
2202                    }
2203                }
2204                // The difficulty here is that the XML fragment received may contain multiple elements,
2205                // for example a Period with xlink resolves to two Period elements. For a single
2206                // resolved element we can simply replace the original element by its resolved
2207                // counterpart. When the xlink resolves to multiple elements, we can't insert them back
2208                // into the parent node directly, but need to return them to the caller for later insertion.
2209                let wrapped_xml = r#"<?xml version="1.0" encoding="utf-8"?>"#.to_owned() +
2210                    r#"<wrapper xmlns="urn:mpeg:dash:schema:mpd:2011" "# +
2211                    r#"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" "# +
2212                    r#"xmlns:cenc="urn:mpeg:cenc:2013" "# +
2213                    r#"xmlns:mspr="urn:microsoft:playready" "# +
2214                    r#"xmlns:xlink="http://www.w3.org/1999/xlink">"# +
2215                    skip_xml_preamble(&xml) +
2216                    r"</wrapper>";
2217                let wrapper_doc = xot.parse(&wrapped_xml)
2218                    .map_err(|e| parse_error("parsing xlinked content", e))?;
2219                let wrapper_doc_el = xot.document_element(wrapper_doc)
2220                    .map_err(|e| parse_error("extracting XML document element", e))?;
2221                for needs_insertion in xot.children(wrapper_doc_el).collect::<Vec<_>>() {
2222                    // FIXME we are inserting nodes that serialize to nothing (namespace nodes?)
2223                    xot.insert_after(xl, needs_insertion)
2224                        .map_err(|e| parse_error("inserting XLinked content", e))?;
2225                }
2226                xot.remove(xl)
2227                    .map_err(|e| parse_error("removing XLink node", e))?;
2228            }
2229        }
2230    }
2231    Ok(())
2232}
2233
2234#[tracing::instrument(level="trace", skip_all)]
2235pub async fn parse_resolving_xlinks(
2236    downloader: &DashDownloader,
2237    xml: &[u8]) -> Result<MPD, DashMpdError>
2238{
2239    use xot::xmlname::NameStrInfo;
2240
2241    let mut xot = Xot::new();
2242    let doc = xot.parse_bytes(xml)
2243        .map_err(|e| parse_error("XML parsing", e))?;
2244    let doc_el = xot.document_element(doc)
2245        .map_err(|e| parse_error("extracting XML document element", e))?;
2246    let doc_name = match xot.node_name(doc_el) {
2247        Some(n) => n,
2248        None => return Err(DashMpdError::Parsing(String::from("missing root node name"))),
2249    };
2250    let root_name = xot.name_ref(doc_name, doc_el)
2251        .map_err(|e| parse_error("extracting root node name", e))?;
2252    let root_local_name = root_name.local_name();
2253    if !root_local_name.eq("MPD") {
2254        return Err(DashMpdError::Parsing(format!("root element is {root_local_name}, expecting <MPD>")));
2255    }
2256    // The remote XLink fragments may contain further XLink references. However, we only repeat the
2257    // resolution 5 times to avoid potential infloop DoS attacks.
2258    for _ in 1..5 {
2259        resolve_xlink_references(downloader, &mut xot, doc).await?;
2260    }
2261    let rewritten = apply_xslt_stylesheets(downloader, &mut xot, doc).await?;
2262    // Here using the quick-xml serde support to deserialize into Rust structs.
2263    let mpd = parse(&rewritten)?;
2264    if downloader.conformity_checks {
2265        for emsg in check_conformity(&mpd) {
2266            warn!("DASH conformity error in manifest: {emsg}");
2267        }
2268    }
2269    Ok(mpd)
2270}
2271
2272async fn do_segmentbase_indexrange(
2273    downloader: &DashDownloader,
2274    period_counter: u8,
2275    base_url: Url,
2276    sb: &SegmentBase,
2277    dict: &HashMap<&str, String>
2278) -> Result<Vec<MediaFragment>, DashMpdError>
2279{
2280    // Something like the following
2281    //
2282    // <SegmentBase indexRange="839-3534" timescale="12288">
2283    //   <Initialization range="0-838"/>
2284    // </SegmentBase>
2285    //
2286    // The SegmentBase@indexRange attribute points to a byte range in the media file
2287    // that contains index information (an sidx box for MPEG files, or a Cues entry for
2288    // a DASH-WebM stream). There are two possible strategies to implement when downloading this content:
2289    //
2290    //   - Simply download the full content specified by the BaseURL element for this
2291    //     segment (ignoring the indexRange attribute).
2292    //
2293    //   - Download the sidx box using a Range request, parse the segment references it
2294    //     contains, and download each one using a different Range request, and
2295    //     concatenate the full contents.
2296    //
2297    // The first option is what a browser-based player does. It avoids making a huge
2298    // segment download that will fill up our RAM if chunked download is not offered by
2299    // the server. It works with web servers that prevent direct access to the full
2300    // MP4/WebM file by blocking requests without a limited byte range. Its more
2301    // correct, because in theory the content at BaseURL might contain lots of
2302    // irrelevant information which is not pointed to by any of the sidx byte ranges.
2303    // However, it is a little more fragile because some MP4 elements that are necessary
2304    // to create a valid MP4 file (e.g. trex, trun, tfhd boxes) might not be included in
2305    // the sidx-referenced byte ranges.
2306    //
2307    // In practice, it seems that the indexRange information is mostly provided by DASH
2308    // encoders to allow clients to rewind and fast-forward a stream, and both
2309    // strategies work. We default to using the indexRange information, but include the
2310    // option parse_index_range to allow fallback to the simpler "download-it-all"
2311    // strategy.
2312    let mut fragments = Vec::new();
2313    let mut start_byte: Option<u64> = None;
2314    let mut end_byte: Option<u64> = None;
2315    let mut indexable_segments = false;
2316    if downloader.use_index_range {
2317        if let Some(ir) = &sb.indexRange {
2318            // Fetch the octet slice corresponding to the (sidx) index.
2319            let (s, e) = parse_range(ir)?;
2320            trace!("Fetching sidx for {}", base_url.clone());
2321            let mut req = downloader.http_client.as_ref()
2322                .unwrap()
2323                .get(base_url.clone())
2324                .header(RANGE, format!("bytes={s}-{e}"))
2325                .header("Referer", downloader.redirected_url.to_string())
2326                .header("Sec-Fetch-Mode", "navigate");
2327            if let Some(username) = &downloader.auth_username {
2328                if let Some(password) = &downloader.auth_password {
2329                    req = req.basic_auth(username, Some(password));
2330                }
2331            }
2332            if let Some(token) = &downloader.auth_bearer_token {
2333                req = req.bearer_auth(token);
2334            }
2335            let mut resp = req.send().await
2336                .map_err(|e| network_error("fetching index data", &e))?
2337                .error_for_status()
2338                .map_err(|e| network_error("fetching index data", &e))?;
2339            let headers = std::mem::take(resp.headers_mut());
2340            if let Some(content_type) = headers.get(CONTENT_TYPE) {
2341                let idx = resp.bytes().await
2342                    .map_err(|e| network_error("fetching index data", &e))?;
2343                if idx.len() as u64 != e - s + 1 {
2344                    warn!("  HTTP server does not support Range requests; can't use indexRange addressing");
2345                } else {
2346                    #[allow(clippy::collapsible_else_if)]
2347                    if content_type.eq("video/mp4") ||
2348                        content_type.eq("audio/mp4") {
2349                            // Handle as ISOBMFF. First prepare to save the index data itself
2350                            // and any leading bytes (from byte positions 0 to s) to the output
2351                            // container, because it may contain other types of MP4 boxes than
2352                            // only sidx boxes (eg. trex, trun tfhd boxes), which are necessary
2353                            // to play the media content. Then prepare to save each referenced
2354                            // segment chunk to the output container.
2355                            let mf = MediaFragmentBuilder::new(period_counter, base_url.clone())
2356                                .with_range(Some(0), Some(e))
2357                                .build();
2358                            fragments.push(mf);
2359                            let mut max_chunk_pos = 0;
2360                            if let Ok(segment_chunks) = crate::sidx::from_isobmff_sidx(&idx, e+1) {
2361                                trace!("Have {} segment chunks in sidx data", segment_chunks.len());
2362                                for chunk in segment_chunks {
2363                                    let mf = MediaFragmentBuilder::new(period_counter, base_url.clone())
2364                                        .with_range(Some(chunk.start), Some(chunk.end))
2365                                        .build();
2366                                    fragments.push(mf);
2367                                    if chunk.end > max_chunk_pos {
2368                                        max_chunk_pos = chunk.end;
2369                                    }
2370                                }
2371                                indexable_segments = true;
2372                            }
2373                        }
2374                    // In theory we should also be able to handle Cue data in a WebM media
2375                    // stream similarly to chunks specified by an sidx box in an ISOBMFF/MP4
2376                    // container. However, simply appending the content pointed to by the
2377                    // different Cue elements in the WebM file leads to an invalid media
2378                    // file. We need to implement more complicated logic to reconstruct a
2379                    // valid WebM file from chunks of content.
2380                }
2381            }
2382        }
2383    }
2384    if indexable_segments {
2385        if let Some(init) = &sb.Initialization {
2386            if let Some(range) = &init.range {
2387                let (s, e) = parse_range(range)?;
2388                start_byte = Some(s);
2389                end_byte = Some(e);
2390            }
2391            if let Some(su) = &init.sourceURL {
2392                let path = resolve_url_template(su, dict);
2393                let u = merge_baseurls(&base_url, &path)?;
2394                let mf = MediaFragmentBuilder::new(period_counter, u)
2395                    .with_range(start_byte, end_byte)
2396                    .set_init()
2397                    .build();
2398                fragments.push(mf);
2399            } else {
2400                // Use the current BaseURL
2401                let mf = MediaFragmentBuilder::new(period_counter, base_url.clone())
2402                    .with_range(start_byte, end_byte)
2403                    .set_init()
2404                    .build();
2405                fragments.push(mf);
2406            }
2407        }
2408    } else {
2409        // If anything prevented us from handling this SegmentBase@indexRange element using
2410        // HTTP Range requests, just download the whole segment as a single chunk. This is
2411        // likely to be a large HTTP request (for instance, the full video content as a
2412        // single MP4 file), so we increase our network request timeout.
2413        trace!("Falling back to retrieving full SegmentBase for {}", base_url.clone());
2414        let mf = MediaFragmentBuilder::new(period_counter, base_url.clone())
2415            .with_timeout(Duration::new(10_000, 0))
2416            .build();
2417        fragments.push(mf);
2418    }
2419    Ok(fragments)
2420}
2421
2422
2423#[tracing::instrument(level="trace", skip_all)]
2424async fn do_period_audio(
2425    downloader: &DashDownloader,
2426    mpd: &MPD,
2427    period: &Period,
2428    period_counter: u8,
2429    base_url: Url
2430) -> Result<PeriodOutputs, DashMpdError>
2431{
2432    let mut fragments = Vec::new();
2433    let mut diagnostics = Vec::new();
2434    let mut opt_init: Option<String> = None;
2435    let mut opt_media: Option<String> = None;
2436    let mut opt_duration: Option<f64> = None;
2437    let mut timescale = 1;
2438    let mut start_number = 1;
2439    // The period_duration is specified either by the <Period> duration attribute, or by the
2440    // mediaPresentationDuration of the top-level MPD node.
2441    let mut period_duration_secs: f64 = -1.0;
2442    if let Some(d) = mpd.mediaPresentationDuration {
2443        period_duration_secs = d.as_secs_f64();
2444    }
2445    if let Some(d) = period.duration {
2446        period_duration_secs = d.as_secs_f64();
2447    }
2448    if let Some(s) = downloader.force_duration {
2449        period_duration_secs = s;
2450    }
2451    // SegmentTemplate as a direct child of a Period element. This can specify some common attribute
2452    // values (media, timescale, duration, startNumber) for child SegmentTemplate nodes in an
2453    // enclosed AdaptationSet or Representation node.
2454    if let Some(st) = &period.SegmentTemplate {
2455        if let Some(i) = &st.initialization {
2456            opt_init = Some(i.clone());
2457        }
2458        if let Some(m) = &st.media {
2459            opt_media = Some(m.clone());
2460        }
2461        if let Some(d) = st.duration {
2462            opt_duration = Some(d);
2463        }
2464        if let Some(ts) = st.timescale {
2465            timescale = ts;
2466        }
2467        if let Some(s) = st.startNumber {
2468            start_number = s;
2469        }
2470    }
2471    let mut selected_audio_language = "unk";
2472    // Handle the AdaptationSet with audio content. Note that some streams don't separate out
2473    // audio and video streams, so this might be None.
2474    let audio_adaptations: Vec<&AdaptationSet> = period.adaptations.iter()
2475        .filter(is_audio_adaptation)
2476        .collect();
2477    let representations: Vec<&Representation> = select_preferred_adaptations(audio_adaptations, downloader)
2478        .iter()
2479        .flat_map(|a| a.representations.iter())
2480        .collect();
2481    if let Some(audio_repr) = select_preferred_representation(&representations, downloader) {
2482        // Find the AdaptationSet that is the parent of the selected Representation. This may be
2483        // needed for certain Representation attributes whose value can be located higher in the XML
2484        // tree.
2485        let audio_adaptation = period.adaptations.iter()
2486            .find(|a| a.representations.iter().any(|r| r.eq(audio_repr)))
2487            .unwrap();
2488        if let Some(lang) = audio_repr.lang.as_ref().or(audio_adaptation.lang.as_ref()) {
2489            selected_audio_language = lang;
2490        }
2491        // The AdaptationSet may have a BaseURL (e.g. the test BBC streams). We use a local variable
2492        // to make sure we don't "corrupt" the base_url for the video segments.
2493        let mut base_url = base_url.clone();
2494        if let Some(bu) = &audio_adaptation.BaseURL.first() {
2495            base_url = merge_baseurls(&base_url, &bu.base)?;
2496        }
2497        if let Some(bu) = audio_repr.BaseURL.first() {
2498            base_url = merge_baseurls(&base_url, &bu.base)?;
2499        }
2500        if downloader.verbosity > 0 {
2501            let bw = if let Some(bw) = audio_repr.bandwidth {
2502                format!("bw={} Kbps ", bw / 1024)
2503            } else {
2504                String::from("")
2505            };
2506            let unknown = String::from("?");
2507            let lang = audio_repr.lang.as_ref()
2508                .unwrap_or(audio_adaptation.lang.as_ref()
2509                           .unwrap_or(&unknown));
2510            let codec = audio_repr.codecs.as_ref()
2511                .unwrap_or(audio_adaptation.codecs.as_ref()
2512                           .unwrap_or(&unknown));
2513            let maybe_id = if let Some(rid) = &audio_repr.id {
2514                format!(" (id={rid})")
2515            } else {
2516                String::from("")
2517            };
2518            diagnostics.push(format!("  Audio stream selected: {bw}lang={lang} codec={codec}{maybe_id}"));
2519            // Check for ContentProtection on the selected Representation/Adaptation
2520            for cp in audio_repr.ContentProtection.iter()
2521                .chain(audio_adaptation.ContentProtection.iter())
2522            {
2523                diagnostics.push(format!("  ContentProtection: {}", content_protection_type(cp)));
2524                if let Some(kid) = &cp.default_KID {
2525                    diagnostics.push(format!("    KID: {}", kid.replace('-', "")));
2526                }
2527                for pssh_element in &cp.cenc_pssh {
2528                    if let Some(pssh_b64) = &pssh_element.content {
2529                        diagnostics.push(format!("    PSSH (from manifest): {pssh_b64}"));
2530                        if let Ok(pssh) = pssh_box::from_base64(pssh_b64) {
2531                            diagnostics.push(format!("    {pssh}"));
2532                        }
2533                    }
2534                }
2535            }
2536        }
2537        // SegmentTemplate as a direct child of an Adaptation node. This can specify some common
2538        // attribute values (media, timescale, duration, startNumber) for child SegmentTemplate
2539        // nodes in an enclosed Representation node. Don't download media segments here, only
2540        // download for SegmentTemplate nodes that are children of a Representation node.
2541        if let Some(st) = &audio_adaptation.SegmentTemplate {
2542            if let Some(i) = &st.initialization {
2543                opt_init = Some(i.clone());
2544            }
2545            if let Some(m) = &st.media {
2546                opt_media = Some(m.clone());
2547            }
2548            if let Some(d) = st.duration {
2549                opt_duration = Some(d);
2550            }
2551            if let Some(ts) = st.timescale {
2552                timescale = ts;
2553            }
2554            if let Some(s) = st.startNumber {
2555                start_number = s;
2556            }
2557        }
2558        let mut dict = HashMap::new();
2559        if let Some(rid) = &audio_repr.id {
2560            dict.insert("RepresentationID", rid.clone());
2561        }
2562        if let Some(b) = &audio_repr.bandwidth {
2563            dict.insert("Bandwidth", b.to_string());
2564        }
2565        // Now the 6 possible addressing modes: (1) SegmentList,
2566        // (2) SegmentTemplate+SegmentTimeline, (3) SegmentTemplate@duration,
2567        // (4) SegmentTemplate@index, (5) SegmentBase@indexRange, (6) plain BaseURL
2568        
2569        // Though SegmentBase and SegmentList addressing modes are supposed to be
2570        // mutually exclusive, some manifests in the wild use both. So we try to work
2571        // around the brokenness.
2572        // Example: http://ftp.itec.aau.at/datasets/mmsys12/ElephantsDream/MPDs/ElephantsDreamNonSeg_6s_isoffmain_DIS_23009_1_v_2_1c2_2011_08_30.mpd
2573        if let Some(sl) = &audio_adaptation.SegmentList {
2574            // (1) AdaptationSet>SegmentList addressing mode (can be used in conjunction
2575            // with Representation>SegmentList addressing mode)
2576            if downloader.verbosity > 1 {
2577                info!("  Using AdaptationSet>SegmentList addressing mode for audio representation");
2578            }
2579            let mut start_byte: Option<u64> = None;
2580            let mut end_byte: Option<u64> = None;
2581            if let Some(init) = &sl.Initialization {
2582                if let Some(range) = &init.range {
2583                    let (s, e) = parse_range(range)?;
2584                    start_byte = Some(s);
2585                    end_byte = Some(e);
2586                }
2587                if let Some(su) = &init.sourceURL {
2588                    let path = resolve_url_template(su, &dict);
2589                    let init_url = merge_baseurls(&base_url, &path)?;
2590                    let mf = MediaFragmentBuilder::new(period_counter, init_url)
2591                        .with_range(start_byte, end_byte)
2592                        .set_init()
2593                        .build();
2594                    fragments.push(mf);
2595                } else {
2596                    let mf = MediaFragmentBuilder::new(period_counter, base_url.clone())
2597                        .with_range(start_byte, end_byte)
2598                        .set_init()
2599                        .build();
2600                    fragments.push(mf);
2601                }
2602            }
2603            for su in &sl.segment_urls {
2604                start_byte = None;
2605                end_byte = None;
2606                // we are ignoring SegmentURL@indexRange
2607                if let Some(range) = &su.mediaRange {
2608                    let (s, e) = parse_range(range)?;
2609                    start_byte = Some(s);
2610                    end_byte = Some(e);
2611                }
2612                if let Some(m) = &su.media {
2613                    let u = merge_baseurls(&base_url, m)?;
2614                    let mf = MediaFragmentBuilder::new(period_counter, u)
2615                        .with_range(start_byte, end_byte)
2616                        .build();
2617                    fragments.push(mf);
2618                } else if let Some(bu) = audio_adaptation.BaseURL.first() {
2619                    let u = merge_baseurls(&base_url, &bu.base)?;
2620                    let mf = MediaFragmentBuilder::new(period_counter, u)
2621                        .with_range(start_byte, end_byte)
2622                        .build();
2623                    fragments.push(mf);
2624                }
2625            }
2626        }
2627        if let Some(sl) = &audio_repr.SegmentList {
2628            // (1) Representation>SegmentList addressing mode
2629            if downloader.verbosity > 1 {
2630                info!("  Using Representation>SegmentList addressing mode for audio representation");
2631            }
2632            let mut start_byte: Option<u64> = None;
2633            let mut end_byte: Option<u64> = None;
2634            if let Some(init) = &sl.Initialization {
2635                if let Some(range) = &init.range {
2636                    let (s, e) = parse_range(range)?;
2637                    start_byte = Some(s);
2638                    end_byte = Some(e);
2639                }
2640                if let Some(su) = &init.sourceURL {
2641                    let path = resolve_url_template(su, &dict);
2642                    let init_url = merge_baseurls(&base_url, &path)?;
2643                    let mf = MediaFragmentBuilder::new(period_counter, init_url)
2644                        .with_range(start_byte, end_byte)
2645                        .set_init()
2646                        .build();
2647                    fragments.push(mf);
2648                } else {
2649                    let mf = MediaFragmentBuilder::new(period_counter, base_url.clone())
2650                        .with_range(start_byte, end_byte)
2651                        .set_init()
2652                        .build();
2653                    fragments.push(mf);
2654                }
2655            }
2656            for su in &sl.segment_urls {
2657                start_byte = None;
2658                end_byte = None;
2659                // we are ignoring SegmentURL@indexRange
2660                if let Some(range) = &su.mediaRange {
2661                    let (s, e) = parse_range(range)?;
2662                    start_byte = Some(s);
2663                    end_byte = Some(e);
2664                }
2665                if let Some(m) = &su.media {
2666                    let u = merge_baseurls(&base_url, m)?;
2667                    let mf = MediaFragmentBuilder::new(period_counter, u)
2668                        .with_range(start_byte, end_byte)
2669                        .build();
2670                    fragments.push(mf);
2671                } else if let Some(bu) = audio_repr.BaseURL.first() {
2672                    let u = merge_baseurls(&base_url, &bu.base)?;
2673                    let mf = MediaFragmentBuilder::new(period_counter, u)
2674                        .with_range(start_byte, end_byte)
2675                        .build();
2676                    fragments.push(mf);
2677                }
2678            }
2679        } else if audio_repr.SegmentTemplate.is_some() ||
2680            audio_adaptation.SegmentTemplate.is_some()
2681        {
2682            // Here we are either looking at a Representation.SegmentTemplate, or a
2683            // higher-level AdaptationSet.SegmentTemplate
2684            let st;
2685            if let Some(it) = &audio_repr.SegmentTemplate {
2686                st = it;
2687            } else if let Some(it) = &audio_adaptation.SegmentTemplate {
2688                st = it;
2689            } else {
2690                panic!("unreachable");
2691            }
2692            if let Some(i) = &st.initialization {
2693                opt_init = Some(i.clone());
2694            }
2695            if let Some(m) = &st.media {
2696                opt_media = Some(m.clone());
2697            }
2698            if let Some(ts) = st.timescale {
2699                timescale = ts;
2700            }
2701            if let Some(sn) = st.startNumber {
2702                start_number = sn;
2703            }
2704            if let Some(stl) = &audio_repr.SegmentTemplate.as_ref().and_then(|st| st.SegmentTimeline.clone())
2705                .or(audio_adaptation.SegmentTemplate.as_ref().and_then(|st| st.SegmentTimeline.clone()))
2706            {
2707                // (2) SegmentTemplate with SegmentTimeline addressing mode (also called
2708                // "explicit addressing" in certain DASH-IF documents)
2709                if downloader.verbosity > 1 {
2710                    info!("  Using SegmentTemplate+SegmentTimeline addressing mode for audio representation");
2711                }
2712                if let Some(init) = opt_init {
2713                    let path = resolve_url_template(&init, &dict);
2714                    let u = merge_baseurls(&base_url, &path)?;
2715                    let mf = MediaFragmentBuilder::new(period_counter, u)
2716                        .set_init()
2717                        .build();
2718                    fragments.push(mf);
2719                }
2720                let mut elapsed_seconds = 0.0;
2721                if let Some(media) = opt_media {
2722                    let audio_path = resolve_url_template(&media, &dict);
2723                    let mut segment_time = 0;
2724                    let mut segment_duration;
2725                    let mut number = start_number;
2726                    let mut target_duration = period_duration_secs;
2727                    if let Some(target) = downloader.force_duration {
2728                        if target > period_duration_secs {
2729                            warn!("  Requested forced duration exceeds available content");
2730                        } else {
2731                            target_duration = target;
2732                        }
2733                    }
2734                    'segment_loop: for s in &stl.segments {
2735                        if let Some(t) = s.t {
2736                            segment_time = t;
2737                        }
2738                        segment_duration = s.d;
2739                        // the URLTemplate may be based on $Time$, or on $Number$
2740                        let dict = HashMap::from([("Time", segment_time.to_string()),
2741                                                  ("Number", number.to_string())]);
2742                        let path = resolve_url_template(&audio_path, &dict);
2743                        let u = merge_baseurls(&base_url, &path)?;
2744                        fragments.push(MediaFragmentBuilder::new(period_counter, u).build());
2745                        number += 1;
2746                        elapsed_seconds += segment_duration as f64 / timescale as f64;
2747                        if downloader.force_duration.is_some() &&
2748                            target_duration > 0.0 &&
2749                            elapsed_seconds > target_duration {
2750                            break 'segment_loop;
2751                        }
2752                        if let Some(r) = s.r {
2753                            let mut count = 0i64;
2754                            loop {
2755                                count += 1;
2756                                // Exit from the loop after @r iterations (if @r is positive). A
2757                                // negative value of the @r attribute indicates that the duration
2758                                // indicated in @d attribute repeats until the start of the next S
2759                                // element, the end of the Period or until the next MPD update.
2760                                if r >= 0 && count > r {
2761                                    break;
2762                                }
2763                                if downloader.force_duration.is_some() &&
2764                                    target_duration > 0.0 &&
2765                                    elapsed_seconds > target_duration {
2766                                    break 'segment_loop;
2767                                }
2768                                if let Some(end_number) = st.endNumber {
2769                                    if count as u64 > end_number {
2770                                        break;
2771                                    }
2772                                }
2773                                segment_time += segment_duration;
2774                                elapsed_seconds += segment_duration as f64 / timescale as f64;
2775                                let dict = HashMap::from([("Time", segment_time.to_string()),
2776                                                          ("Number", number.to_string())]);
2777                                let path = resolve_url_template(&audio_path, &dict);
2778                                let u = merge_baseurls(&base_url, &path)?;
2779                                fragments.push(MediaFragmentBuilder::new(period_counter, u).build());
2780                                number += 1;
2781                            }
2782                        }
2783                        segment_time += segment_duration;
2784                    }
2785                } else {
2786                    return Err(DashMpdError::UnhandledMediaStream(
2787                        "SegmentTimeline without a media attribute".to_string()));
2788                }
2789            } else { // no SegmentTimeline element
2790                // (3) SegmentTemplate@duration addressing mode or (4) SegmentTemplate@index
2791                // addressing mode (also called "simple addressing" in certain DASH-IF
2792                // documents)
2793                if downloader.verbosity > 1 {
2794                    info!("  Using SegmentTemplate addressing mode for audio representation");
2795                }
2796                let mut total_number = 0i64;
2797                if let Some(init) = opt_init {
2798                    let path = resolve_url_template(&init, &dict);
2799                    let u = merge_baseurls(&base_url, &path)?;
2800                    let mf = MediaFragmentBuilder::new(period_counter, u)
2801                        .set_init()
2802                        .build();
2803                    fragments.push(mf);
2804                }
2805                if let Some(media) = opt_media {
2806                    let audio_path = resolve_url_template(&media, &dict);
2807                    let timescale = st.timescale.unwrap_or(timescale);
2808                    let mut segment_duration: f64 = -1.0;
2809                    if let Some(d) = opt_duration {
2810                        // it was set on the Period.SegmentTemplate node
2811                        segment_duration = d;
2812                    }
2813                    if let Some(std) = st.duration {
2814                        if timescale == 0 {
2815                            return Err(DashMpdError::UnhandledMediaStream(
2816                                "SegmentTemplate@duration attribute cannot be zero".to_string()));
2817                        }
2818                        segment_duration = std / timescale as f64;
2819                    }
2820                    if segment_duration < 0.0 {
2821                        return Err(DashMpdError::UnhandledMediaStream(
2822                            "Audio representation is missing SegmentTemplate@duration attribute".to_string()));
2823                    }
2824                    total_number += (period_duration_secs / segment_duration).round() as i64;
2825                    let mut number = start_number;
2826                    // For dynamic MPDs the latest available segment is numbered
2827                    //    LSN = floor((now - (availabilityStartTime+PST))/segmentDuration + startNumber - 1)
2828                    if mpd_is_dynamic(mpd) {
2829                        if let Some(start_time) = mpd.availabilityStartTime {
2830                            let elapsed = Utc::now().signed_duration_since(start_time).as_seconds_f64() / segment_duration;
2831                            number = (elapsed + number as f64 - 1f64).floor() as u64;
2832                        } else {
2833                            return Err(DashMpdError::UnhandledMediaStream(
2834                                "dynamic manifest is missing @availabilityStartTime".to_string()));
2835                        }
2836                    }
2837                    if let Some(end_number) = st.endNumber {
2838                        total_number = end_number as i64;
2839                    }
2840                    for _ in 1..=total_number {
2841                        let dict = HashMap::from([("Number", number.to_string())]);
2842                        let path = resolve_url_template(&audio_path, &dict);
2843                        let u = merge_baseurls(&base_url, &path)?;
2844                        fragments.push(MediaFragmentBuilder::new(period_counter, u).build());
2845                        number += 1;
2846                    }
2847                }
2848            }
2849        } else if let Some(sb) = &audio_repr.SegmentBase {
2850            // (5) SegmentBase@indexRange addressing mode
2851            if downloader.verbosity > 1 {
2852                info!("  Using SegmentBase@indexRange addressing mode for audio representation");
2853            }
2854            let mf = do_segmentbase_indexrange(downloader, period_counter, base_url, sb, &dict).await?;
2855            fragments.extend(mf);
2856        } else if fragments.is_empty() {
2857            if let Some(bu) = audio_repr.BaseURL.first() {
2858                // (6) plain BaseURL addressing mode
2859                if downloader.verbosity > 1 {
2860                    info!("  Using BaseURL addressing mode for audio representation");
2861                }
2862                let u = merge_baseurls(&base_url, &bu.base)?;
2863                fragments.push(MediaFragmentBuilder::new(period_counter, u).build());
2864            }
2865        }
2866        if fragments.is_empty() {
2867            return Err(DashMpdError::UnhandledMediaStream(
2868                "no usable addressing mode identified for audio representation".to_string()));
2869        }
2870    }
2871    Ok(PeriodOutputs {
2872        fragments,
2873        diagnostics,
2874        subtitle_formats: Vec::new(),
2875        selected_audio_language: String::from(selected_audio_language),
2876        selected_subtitle_language: String::from("")
2877            
2878    })
2879}
2880
2881
2882#[tracing::instrument(level="trace", skip_all)]
2883async fn do_period_video(
2884    downloader: &DashDownloader,
2885    mpd: &MPD,
2886    period: &Period,
2887    period_counter: u8,
2888    base_url: Url
2889    ) -> Result<PeriodOutputs, DashMpdError>
2890{
2891    let mut fragments = Vec::new();
2892    let mut diagnostics = Vec::new();
2893    let mut period_duration_secs: f64 = 0.0;
2894    let mut opt_init: Option<String> = None;
2895    let mut opt_media: Option<String> = None;
2896    let mut opt_duration: Option<f64> = None;
2897    let mut timescale = 1;
2898    let mut start_number = 1;
2899    if let Some(d) = mpd.mediaPresentationDuration {
2900        period_duration_secs = d.as_secs_f64();
2901    }
2902    if let Some(d) = period.duration {
2903        period_duration_secs = d.as_secs_f64();
2904    }
2905    if let Some(s) = downloader.force_duration {
2906        period_duration_secs = s;
2907    }
2908    // SegmentTemplate as a direct child of a Period element. This can specify some common attribute
2909    // values (media, timescale, duration, startNumber) for child SegmentTemplate nodes in an
2910    // enclosed AdaptationSet or Representation node.
2911    if let Some(st) = &period.SegmentTemplate {
2912        if let Some(i) = &st.initialization {
2913            opt_init = Some(i.clone());
2914        }
2915        if let Some(m) = &st.media {
2916            opt_media = Some(m.clone());
2917        }
2918        if let Some(d) = st.duration {
2919            opt_duration = Some(d);
2920        }
2921        if let Some(ts) = st.timescale {
2922            timescale = ts;
2923        }
2924        if let Some(s) = st.startNumber {
2925            start_number = s;
2926        }
2927    }
2928    // A manifest may contain multiple AdaptationSets with video content (in particular, when
2929    // different codecs are offered). Each AdaptationSet often contains multiple video
2930    // Representations with different bandwidths, video resolutions and codecs. We select the
2931    // Representation to download by ranking them according to the following user-specified
2932    // preferences:
2933    //
2934    //   - a substring of the video @id attribute
2935    //   - the preferred width
2936    //   - the preferred height
2937    //   - the video codec preference ordering
2938    //   - the quality preference (defaulting to the lowest quality available)
2939    //
2940    // The preferences are applied in the order shown in the list above.
2941    //
2942    // If these preferences have not been specified, they have no filtering effect, except for the
2943    // quality preference which defaults to preferring the lowest quality and smallest file size.
2944    let video_adaptations: Vec<&AdaptationSet> = period.adaptations.iter()
2945        .filter(is_video_adaptation)
2946        .collect();
2947    let representations: Vec<&Representation> = select_preferred_adaptations(video_adaptations, downloader)
2948        .iter()
2949        .flat_map(|a| a.representations.iter())
2950        .collect();
2951    trace!("Before filtering we have {} Representations", representations.len());
2952    let representations = representation_filter_video_id(representations, downloader);
2953    trace!("After video_id filter we have {} Representations", representations.len());
2954    let representations = representation_filter_video_width(representations, downloader);
2955    trace!("After width filter we have {} Representations", representations.len());
2956    let representations = representation_filter_video_height(representations, downloader);
2957    trace!("After height filter we have {} Representations", representations.len());
2958    let representations = representation_filter_video_codec(representations, downloader);
2959    trace!("After video codec filter we have {} Representations", representations.len());
2960    let representations = representation_filter_video_quality(representations, downloader);
2961    trace!("After quality filter we have {} Representations", representations.len());
2962    if let Some(video_repr) = representations.first() {
2963        // Find the AdaptationSet that is the parent of the selected Representation. This may be
2964        // needed for certain Representation attributes whose value can be located higher in the XML
2965        // tree.
2966        let video_adaptation = period.adaptations.iter()
2967            .find(|a| a.representations.iter().any(|r| r.eq(video_repr)))
2968            .unwrap();
2969        // The AdaptationSet may have a BaseURL. We use a local variable to make sure we
2970        // don't "corrupt" the base_url for the subtitle segments.
2971        let mut base_url = base_url.clone();
2972        if let Some(bu) = &video_adaptation.BaseURL.first() {
2973            base_url = merge_baseurls(&base_url, &bu.base)?;
2974        }
2975        if let Some(bu) = &video_repr.BaseURL.first() {
2976            base_url = merge_baseurls(&base_url, &bu.base)?;
2977        }
2978        if downloader.verbosity > 0 {
2979            let bw = if let Some(bw) = video_repr.bandwidth.or(video_adaptation.maxBandwidth) {
2980                format!("bw={} Kbps ", bw / 1024)
2981            } else {
2982                String::from("")
2983            };
2984            let unknown = String::from("?");
2985            let w = video_repr.width.unwrap_or(video_adaptation.width.unwrap_or(0));
2986            let h = video_repr.height.unwrap_or(video_adaptation.height.unwrap_or(0));
2987            let fmt = if w == 0 || h == 0 {
2988                String::from("")
2989            } else {
2990                format!("resolution={w}x{h} ")
2991            };
2992            let codec = video_repr.codecs.as_ref()
2993                .unwrap_or(video_adaptation.codecs.as_ref().unwrap_or(&unknown));
2994            let maybe_id = if let Some(rid) = &video_repr.id {
2995                format!(" (id={rid})")
2996            } else {
2997                String::from("")
2998            };
2999            diagnostics.push(format!("  Video stream selected: {bw}{fmt}codec={codec}{maybe_id}"));
3000            // Check for ContentProtection on the selected Representation/Adaptation
3001            for cp in video_repr.ContentProtection.iter()
3002                .chain(video_adaptation.ContentProtection.iter())
3003            {
3004                diagnostics.push(format!("  ContentProtection: {}", content_protection_type(cp)));
3005                if let Some(kid) = &cp.default_KID {
3006                    diagnostics.push(format!("    KID: {}", kid.replace('-', "")));
3007                }
3008                for pssh_element in &cp.cenc_pssh {
3009                    if let Some(pssh_b64) = &pssh_element.content {
3010                        diagnostics.push(format!("    PSSH (from manifest): {pssh_b64}"));
3011                        if let Ok(pssh) = pssh_box::from_base64(pssh_b64) {
3012                            diagnostics.push(format!("    {pssh}"));
3013                        }
3014                    }
3015                }
3016            }
3017        }
3018        let mut dict = HashMap::new();
3019        if let Some(rid) = &video_repr.id {
3020            dict.insert("RepresentationID", rid.clone());
3021        }
3022        if let Some(b) = &video_repr.bandwidth {
3023            dict.insert("Bandwidth", b.to_string());
3024        }
3025        // SegmentTemplate as a direct child of an Adaptation node. This can specify some common
3026        // attribute values (media, timescale, duration, startNumber) for child SegmentTemplate
3027        // nodes in an enclosed Representation node. Don't download media segments here, only
3028        // download for SegmentTemplate nodes that are children of a Representation node.
3029        if let Some(st) = &video_adaptation.SegmentTemplate {
3030            if let Some(i) = &st.initialization {
3031                opt_init = Some(i.clone());
3032            }
3033            if let Some(m) = &st.media {
3034                opt_media = Some(m.clone());
3035            }
3036            if let Some(d) = st.duration {
3037                opt_duration = Some(d);
3038            }
3039            if let Some(ts) = st.timescale {
3040                timescale = ts;
3041            }
3042            if let Some(s) = st.startNumber {
3043                start_number = s;
3044            }
3045        }
3046        // Now the 6 possible addressing modes: (1) SegmentList,
3047        // (2) SegmentTemplate+SegmentTimeline, (3) SegmentTemplate@duration,
3048        // (4) SegmentTemplate@index, (5) SegmentBase@indexRange, (6) plain BaseURL
3049        if let Some(sl) = &video_adaptation.SegmentList {
3050            // (1) AdaptationSet>SegmentList addressing mode
3051            if downloader.verbosity > 1 {
3052                info!("  Using AdaptationSet>SegmentList addressing mode for video representation");
3053            }
3054            let mut start_byte: Option<u64> = None;
3055            let mut end_byte: Option<u64> = None;
3056            if let Some(init) = &sl.Initialization {
3057                if let Some(range) = &init.range {
3058                    let (s, e) = parse_range(range)?;
3059                    start_byte = Some(s);
3060                    end_byte = Some(e);
3061                }
3062                if let Some(su) = &init.sourceURL {
3063                    let path = resolve_url_template(su, &dict);
3064                    let u = merge_baseurls(&base_url, &path)?;
3065                    let mf = MediaFragmentBuilder::new(period_counter, u)
3066                        .with_range(start_byte, end_byte)
3067                        .set_init()
3068                        .build();
3069                    fragments.push(mf);
3070                }
3071            } else {
3072                let mf = MediaFragmentBuilder::new(period_counter, base_url.clone())
3073                    .with_range(start_byte, end_byte)
3074                    .set_init()
3075                    .build();
3076                fragments.push(mf);
3077            }
3078            for su in &sl.segment_urls {
3079                start_byte = None;
3080                end_byte = None;
3081                // we are ignoring @indexRange
3082                if let Some(range) = &su.mediaRange {
3083                    let (s, e) = parse_range(range)?;
3084                    start_byte = Some(s);
3085                    end_byte = Some(e);
3086                }
3087                if let Some(m) = &su.media {
3088                    let u = merge_baseurls(&base_url, m)?;
3089                    let mf = MediaFragmentBuilder::new(period_counter, u)
3090                        .with_range(start_byte, end_byte)
3091                        .build();
3092                    fragments.push(mf);
3093                } else if let Some(bu) = video_adaptation.BaseURL.first() {
3094                    let u = merge_baseurls(&base_url, &bu.base)?;
3095                    let mf = MediaFragmentBuilder::new(period_counter, u)
3096                        .with_range(start_byte, end_byte)
3097                        .build();
3098                    fragments.push(mf);
3099                }
3100            }
3101        }
3102        if let Some(sl) = &video_repr.SegmentList {
3103            // (1) Representation>SegmentList addressing mode
3104            if downloader.verbosity > 1 {
3105                info!("  Using Representation>SegmentList addressing mode for video representation");
3106            }
3107            let mut start_byte: Option<u64> = None;
3108            let mut end_byte: Option<u64> = None;
3109            if let Some(init) = &sl.Initialization {
3110                if let Some(range) = &init.range {
3111                    let (s, e) = parse_range(range)?;
3112                    start_byte = Some(s);
3113                    end_byte = Some(e);
3114                }
3115                if let Some(su) = &init.sourceURL {
3116                    let path = resolve_url_template(su, &dict);
3117                    let u = merge_baseurls(&base_url, &path)?;
3118                    let mf = MediaFragmentBuilder::new(period_counter, u)
3119                        .with_range(start_byte, end_byte)
3120                        .set_init()
3121                        .build();
3122                    fragments.push(mf);
3123                } else {
3124                    let mf = MediaFragmentBuilder::new(period_counter, base_url.clone())
3125                        .with_range(start_byte, end_byte)
3126                        .set_init()
3127                        .build();
3128                    fragments.push(mf);
3129                }
3130            }
3131            for su in &sl.segment_urls {
3132                start_byte = None;
3133                end_byte = None;
3134                // we are ignoring @indexRange
3135                if let Some(range) = &su.mediaRange {
3136                    let (s, e) = parse_range(range)?;
3137                    start_byte = Some(s);
3138                    end_byte = Some(e);
3139                }
3140                if let Some(m) = &su.media {
3141                    let u = merge_baseurls(&base_url, m)?;
3142                    let mf = MediaFragmentBuilder::new(period_counter, u)
3143                        .with_range(start_byte, end_byte)
3144                        .build();
3145                    fragments.push(mf);
3146                } else if let Some(bu) = video_repr.BaseURL.first() {
3147                    let u = merge_baseurls(&base_url, &bu.base)?;
3148                    let mf = MediaFragmentBuilder::new(period_counter, u)
3149                        .with_range(start_byte, end_byte)
3150                        .build();
3151                    fragments.push(mf);
3152                }
3153            }
3154        } else if video_repr.SegmentTemplate.is_some() ||
3155            video_adaptation.SegmentTemplate.is_some() {
3156                // Here we are either looking at a Representation.SegmentTemplate, or a
3157                // higher-level AdaptationSet.SegmentTemplate
3158                let st;
3159                if let Some(it) = &video_repr.SegmentTemplate {
3160                    st = it;
3161                } else if let Some(it) = &video_adaptation.SegmentTemplate {
3162                    st = it;
3163                } else {
3164                    panic!("impossible");
3165                }
3166                if let Some(i) = &st.initialization {
3167                    opt_init = Some(i.clone());
3168                }
3169                if let Some(m) = &st.media {
3170                    opt_media = Some(m.clone());
3171                }
3172                if let Some(ts) = st.timescale {
3173                    timescale = ts;
3174                }
3175                if let Some(sn) = st.startNumber {
3176                    start_number = sn;
3177                }
3178                if let Some(stl) = &video_repr.SegmentTemplate.as_ref().and_then(|st| st.SegmentTimeline.clone())
3179                    .or(video_adaptation.SegmentTemplate.as_ref().and_then(|st| st.SegmentTimeline.clone()))
3180                {
3181                    // (2) SegmentTemplate with SegmentTimeline addressing mode
3182                    if downloader.verbosity > 1 {
3183                        info!("  Using SegmentTemplate+SegmentTimeline addressing mode for video representation");
3184                    }
3185                    if let Some(init) = opt_init {
3186                        let path = resolve_url_template(&init, &dict);
3187                        let u = merge_baseurls(&base_url, &path)?;
3188                        let mf = MediaFragmentBuilder::new(period_counter, u)
3189                            .set_init()
3190                            .build();
3191                        fragments.push(mf);
3192                    }
3193                    let mut elapsed_seconds = 0.0;
3194                    if let Some(media) = opt_media {
3195                        let video_path = resolve_url_template(&media, &dict);
3196                        let mut segment_time = 0;
3197                        let mut segment_duration;
3198                        let mut number = start_number;
3199                        let mut target_duration = period_duration_secs;
3200                        if let Some(target) = downloader.force_duration {
3201                            if target > period_duration_secs {
3202                                warn!("  Requested forced duration exceeds available content");
3203                            } else {
3204                                target_duration = target;
3205                            }
3206                        }
3207                        'segment_loop: for s in &stl.segments {
3208                            if let Some(t) = s.t {
3209                                segment_time = t;
3210                            }
3211                            segment_duration = s.d;
3212                            // the URLTemplate may be based on $Time$, or on $Number$
3213                            let dict = HashMap::from([("Time", segment_time.to_string()),
3214                                                      ("Number", number.to_string())]);
3215                            let path = resolve_url_template(&video_path, &dict);
3216                            let u = merge_baseurls(&base_url, &path)?;
3217                            let mf = MediaFragmentBuilder::new(period_counter, u).build();
3218                            fragments.push(mf);
3219                            number += 1;
3220                            elapsed_seconds += segment_duration as f64 / timescale as f64;
3221                            if downloader.force_duration.is_some() &&
3222                                target_duration > 0.0 &&
3223                                elapsed_seconds > target_duration
3224                            {
3225                                break 'segment_loop;
3226                            }
3227                            if let Some(r) = s.r {
3228                                let mut count = 0i64;
3229                                loop {
3230                                    count += 1;
3231                                    // Exit from the loop after @r iterations (if @r is
3232                                    // positive). A negative value of the @r attribute indicates
3233                                    // that the duration indicated in @d attribute repeats until
3234                                    // the start of the next S element, the end of the Period or
3235                                    // until the next MPD update.
3236                                    if r >= 0 && count > r {
3237                                        break;
3238                                    }
3239                                    if downloader.force_duration.is_some() &&
3240                                        target_duration > 0.0 &&
3241                                        elapsed_seconds > target_duration
3242                                    {
3243                                        break 'segment_loop;
3244                                    }
3245                                    if let Some(end_number) = st.endNumber {
3246                                        if count as u64 > end_number {
3247                                            break;
3248                                        }
3249                                    }
3250                                    segment_time += segment_duration;
3251                                    elapsed_seconds += segment_duration as f64 / timescale as f64;
3252                                    let dict = HashMap::from([("Time", segment_time.to_string()),
3253                                                              ("Number", number.to_string())]);
3254                                    let path = resolve_url_template(&video_path, &dict);
3255                                    let u = merge_baseurls(&base_url, &path)?;
3256                                    let mf = MediaFragmentBuilder::new(period_counter, u).build();
3257                                    fragments.push(mf);
3258                                    number += 1;
3259                                }
3260                            }
3261                            segment_time += segment_duration;
3262                        }
3263                    } else {
3264                        return Err(DashMpdError::UnhandledMediaStream(
3265                            "SegmentTimeline without a media attribute".to_string()));
3266                    }
3267                } else { // no SegmentTimeline element
3268                    // (3) SegmentTemplate@duration addressing mode or (4) SegmentTemplate@index addressing mode
3269                    if downloader.verbosity > 1 {
3270                        info!("  Using SegmentTemplate addressing mode for video representation");
3271                    }
3272                    let mut total_number = 0i64;
3273                    if let Some(init) = opt_init {
3274                        let path = resolve_url_template(&init, &dict);
3275                        let u = merge_baseurls(&base_url, &path)?;
3276                        let mf = MediaFragmentBuilder::new(period_counter, u)
3277                            .set_init()
3278                            .build();
3279                        fragments.push(mf);
3280                    }
3281                    if let Some(media) = opt_media {
3282                        let video_path = resolve_url_template(&media, &dict);
3283                        let timescale = st.timescale.unwrap_or(timescale);
3284                        let mut segment_duration: f64 = -1.0;
3285                        if let Some(d) = opt_duration {
3286                            // it was set on the Period.SegmentTemplate node
3287                            segment_duration = d;
3288                        }
3289                        if let Some(std) = st.duration {
3290                            if timescale == 0 {
3291                                return Err(DashMpdError::UnhandledMediaStream(
3292                                    "SegmentTemplate@duration attribute cannot be zero".to_string()));
3293                            }
3294                            segment_duration = std / timescale as f64;
3295                        }
3296                        if segment_duration < 0.0 {
3297                            return Err(DashMpdError::UnhandledMediaStream(
3298                                "Video representation is missing SegmentTemplate@duration attribute".to_string()));
3299                        }
3300                        total_number += (period_duration_secs / segment_duration).round() as i64;
3301                        let mut number = start_number;
3302                        // For a live manifest (dynamic MPD), we look at the time elapsed since now
3303                        // and the mpd.availabilityStartTime to determine the correct value for
3304                        // startNumber, based on duration and timescale. The latest available
3305                        // segment is numbered
3306                        //
3307                        //    LSN = floor((now - (availabilityStartTime+PST))/segmentDuration + startNumber - 1)
3308
3309                        // https://dashif.org/Guidelines-TimingModel/Timing-Model.pdf
3310                        // To be more precise, any LeapSecondInformation should be added to the availabilityStartTime.
3311                        if mpd_is_dynamic(mpd) {
3312                            if let Some(start_time) = mpd.availabilityStartTime {
3313                                let elapsed = Utc::now().signed_duration_since(start_time).as_seconds_f64() / segment_duration;
3314                                number = (elapsed + number as f64 - 1f64).floor() as u64;
3315                            } else {
3316                                return Err(DashMpdError::UnhandledMediaStream(
3317                                    "dynamic manifest is missing @availabilityStartTime".to_string()));
3318                            }
3319                        }
3320                        if let Some(end_number) = st.endNumber {
3321                            total_number = end_number as i64;
3322                        }
3323                        for _ in 1..=total_number {
3324                            let dict = HashMap::from([("Number", number.to_string())]);
3325                            let path = resolve_url_template(&video_path, &dict);
3326                            let u = merge_baseurls(&base_url, &path)?;
3327                            let mf = MediaFragmentBuilder::new(period_counter, u).build();
3328                            fragments.push(mf);
3329                            number += 1;
3330                        }
3331                    }
3332                }
3333            } else if let Some(sb) = &video_repr.SegmentBase {
3334                // (5) SegmentBase@indexRange addressing mode
3335                if downloader.verbosity > 1 {
3336                    info!("  Using SegmentBase@indexRange addressing mode for video representation");
3337                }
3338                let mf = do_segmentbase_indexrange(downloader, period_counter, base_url, sb, &dict).await?;
3339                fragments.extend(mf);
3340            } else if fragments.is_empty()  {
3341                if let Some(bu) = video_repr.BaseURL.first() {
3342                    // (6) BaseURL addressing mode
3343                    if downloader.verbosity > 1 {
3344                        info!("  Using BaseURL addressing mode for video representation");
3345                    }
3346                    let u = merge_baseurls(&base_url, &bu.base)?;
3347                    let mf = MediaFragmentBuilder::new(period_counter, u)
3348                        .with_timeout(Duration::new(10000, 0))
3349                        .build();
3350                    fragments.push(mf);
3351                }
3352            }
3353        if fragments.is_empty() {
3354            return Err(DashMpdError::UnhandledMediaStream(
3355                "no usable addressing mode identified for video representation".to_string()));
3356        }
3357    }
3358    // FIXME we aren't correctly handling manifests without a Representation node
3359    // eg https://raw.githubusercontent.com/zencoder/go-dash/master/mpd/fixtures/newperiod.mpd
3360    Ok(PeriodOutputs {
3361        fragments,
3362        diagnostics,
3363        subtitle_formats: Vec::new(),
3364        selected_audio_language: String::from("unk"),
3365        selected_subtitle_language: String::from(""),
3366    })
3367}
3368
3369#[tracing::instrument(level="trace", skip_all)]
3370async fn do_period_subtitles(
3371    downloader: &DashDownloader,
3372    mpd: &MPD,
3373    period: &Period,
3374    period_counter: u8,
3375    base_url: Url
3376    ) -> Result<PeriodOutputs, DashMpdError>
3377{
3378    let client = downloader.http_client.as_ref()
3379        .ok_or_else(|| DashMpdError::Other(String::from("no HTTP client specified")))?;
3380    let output_path = &downloader.output_path.as_ref()
3381        .ok_or_else(|| DashMpdError::Other(String::from("no output_path set")))?
3382        .clone();
3383    let period_output_path = output_path_for_period(output_path, period_counter);
3384    let mut fragments = Vec::new();
3385    let mut subtitle_formats = Vec::new();
3386    let mut period_duration_secs: f64 = 0.0;
3387    if let Some(d) = mpd.mediaPresentationDuration {
3388        period_duration_secs = d.as_secs_f64();
3389    }
3390    if let Some(d) = period.duration {
3391        period_duration_secs = d.as_secs_f64();
3392    }
3393    let maybe_subtitle_adaptation = if let Some(ref lang) = downloader.language_preference_subtitles {
3394        period.adaptations.iter().filter(is_subtitle_adaptation)
3395            .min_by_key(|a| adaptation_lang_distance(a, lang))
3396    } else {
3397        // returns the first subtitle adaptation found
3398        period.adaptations.iter().find(is_subtitle_adaptation)
3399    };
3400    let mut subtitle_lang: Option<String> = None;
3401    if downloader.fetch_subtitles {
3402        if let Some(subtitle_adaptation) = maybe_subtitle_adaptation {
3403            if let Some(lang) = subtitle_adaptation.lang.as_ref() {
3404                subtitle_lang = Some(lang.clone());
3405            }
3406            let subtitle_format = subtitle_type(&subtitle_adaptation);
3407            subtitle_formats.push(subtitle_format);
3408            if downloader.verbosity > 1 && downloader.fetch_subtitles {
3409                info!("  Retrieving subtitles in format {subtitle_format:?}");
3410            }
3411            // The AdaptationSet may have a BaseURL. We use a local variable to make sure we
3412            // don't "corrupt" the base_url for the subtitle segments.
3413            let mut base_url = base_url.clone();
3414            if let Some(bu) = &subtitle_adaptation.BaseURL.first() {
3415                base_url = merge_baseurls(&base_url, &bu.base)?;
3416            }
3417            // We don't do any ranking on subtitle Representations, because there is probably only a
3418            // single one for our selected Adaptation.
3419            if let Some(rep) = subtitle_adaptation.representations.first() {
3420                if subtitle_lang.is_none() {
3421                    if let Some(lang) = rep.lang.as_ref() {
3422                        subtitle_lang = Some(lang.clone());
3423                    }
3424                }
3425                if !rep.BaseURL.is_empty() {
3426                    for st_bu in &rep.BaseURL {
3427                        let st_url = merge_baseurls(&base_url, &st_bu.base)?;
3428                        let mut req = client.get(st_url.clone());
3429                        if let Some(referer) = &downloader.referer {
3430                            req = req.header("Referer", referer);
3431                        } else {
3432                            req = req.header("Referer", base_url.to_string());
3433                        }
3434                        let rqw = req.build()
3435                            .map_err(|e| network_error("building request", &e))?;
3436                        let subs = reqwest_bytes_with_retries(client, rqw, 5).await
3437                            .map_err(|e| network_error("fetching subtitles", &e))?;
3438                        let mut subs_path = period_output_path.clone();
3439                        let subtitle_format = subtitle_type(&subtitle_adaptation);
3440                        match subtitle_format {
3441                            SubtitleType::Vtt => subs_path.set_extension("vtt"),
3442                            SubtitleType::Srt => subs_path.set_extension("srt"),
3443                            SubtitleType::Sami => subs_path.set_extension("sami"),
3444                            SubtitleType::Wvtt => subs_path.set_extension("wvtt"),
3445                            SubtitleType::Ttml | SubtitleType::Stpp => subs_path.set_extension("ttml"),
3446                            _ => subs_path.set_extension("sub"),
3447                        };
3448                        subtitle_formats.push(subtitle_format);
3449                        let mut subs_file = File::create(&subs_path).await
3450                            .map_err(|e| DashMpdError::Io(e, String::from("creating subtitle file")))?;
3451                        if downloader.verbosity > 2 {
3452                            info!("  Subtitle {st_url} -> {} octets", subs.len());
3453                        }
3454                        match subs_file.write_all(&subs).await {
3455                            Ok(()) => {
3456                                if downloader.verbosity > 0 {
3457                                    info!("  Downloaded subtitles ({subtitle_format:?}) to {}",
3458                                             subs_path.display());
3459                                }
3460                            },
3461                            Err(e) => {
3462                                error!("Unable to write subtitle file: {e:?}");
3463                                return Err(DashMpdError::Io(e, String::from("writing subtitle data")));
3464                            },
3465                        }
3466                        if subtitle_formats.contains(&SubtitleType::Ttml) {
3467                            if let Err(e) = convert_ttml_srt(downloader, &subs_path).await {
3468                                warn!("Error while converting TTML subtitles to SubRip: {e}");
3469                            }
3470                        }
3471                        if subtitle_formats.contains(&SubtitleType::Vtt) {
3472                            if let Err(e) = convert_vtt_srt(downloader, &subs_path).await {
3473                                warn!("Error while converting VTT subtitles to SubRip: {e}");
3474                            }
3475                        }
3476                        if subtitle_formats.contains(&SubtitleType::Wvtt) ||
3477                            subtitle_formats.contains(&SubtitleType::Ttxt)
3478                        {
3479                            if let Err(e) = wvtt_extract(downloader, &subs_path).await {
3480                                warn!("Error while extracting wvtt subtitles: {e}");
3481                            }
3482                        }
3483                    }
3484                } else if rep.SegmentTemplate.is_some() || subtitle_adaptation.SegmentTemplate.is_some() {
3485                    let mut opt_init: Option<String> = None;
3486                    let mut opt_media: Option<String> = None;
3487                    let mut opt_duration: Option<f64> = None;
3488                    let mut timescale = 1;
3489                    let mut start_number = 1;
3490                    // SegmentTemplate as a direct child of an Adaptation node. This can specify some common
3491                    // attribute values (media, timescale, duration, startNumber) for child SegmentTemplate
3492                    // nodes in an enclosed Representation node. Don't download media segments here, only
3493                    // download for SegmentTemplate nodes that are children of a Representation node.
3494                    if let Some(st) = &rep.SegmentTemplate {
3495                        if let Some(i) = &st.initialization {
3496                            opt_init = Some(i.clone());
3497                        }
3498                        if let Some(m) = &st.media {
3499                            opt_media = Some(m.clone());
3500                        }
3501                        if let Some(d) = st.duration {
3502                            opt_duration = Some(d);
3503                        }
3504                        if let Some(ts) = st.timescale {
3505                            timescale = ts;
3506                        }
3507                        if let Some(s) = st.startNumber {
3508                            start_number = s;
3509                        }
3510                    }
3511                    let rid = match &rep.id {
3512                        Some(id) => id,
3513                        None => return Err(
3514                            DashMpdError::UnhandledMediaStream(
3515                                "Missing @id on Representation node".to_string())),
3516                    };
3517                    let mut dict = HashMap::from([("RepresentationID", rid.clone())]);
3518                    if let Some(b) = &rep.bandwidth {
3519                        dict.insert("Bandwidth", b.to_string());
3520                    }
3521                    // Now the 6 possible addressing modes: (1) SegmentList,
3522                    // (2) SegmentTemplate+SegmentTimeline, (3) SegmentTemplate@duration,
3523                    // (4) SegmentTemplate@index, (5) SegmentBase@indexRange, (6) plain BaseURL
3524                    if let Some(sl) = &rep.SegmentList {
3525                        // (1) AdaptationSet>SegmentList addressing mode (can be used in conjunction
3526                        // with Representation>SegmentList addressing mode)
3527                        if downloader.verbosity > 1 {
3528                            info!("  Using AdaptationSet>SegmentList addressing mode for subtitle representation");
3529                        }
3530                        let mut start_byte: Option<u64> = None;
3531                        let mut end_byte: Option<u64> = None;
3532                        if let Some(init) = &sl.Initialization {
3533                            if let Some(range) = &init.range {
3534                                let (s, e) = parse_range(range)?;
3535                                start_byte = Some(s);
3536                                end_byte = Some(e);
3537                            }
3538                            if let Some(su) = &init.sourceURL {
3539                                let path = resolve_url_template(su, &dict);
3540                                let u = merge_baseurls(&base_url, &path)?;
3541                                let mf = MediaFragmentBuilder::new(period_counter, u)
3542                                    .with_range(start_byte, end_byte)
3543                                    .set_init()
3544                                    .build();
3545                                fragments.push(mf);
3546                            } else {
3547                                let mf = MediaFragmentBuilder::new(period_counter, base_url.clone())
3548                                    .with_range(start_byte, end_byte)
3549                                    .set_init()
3550                                    .build();
3551                                fragments.push(mf);
3552                            }
3553                        }
3554                        for su in &sl.segment_urls {
3555                            start_byte = None;
3556                            end_byte = None;
3557                            // we are ignoring SegmentURL@indexRange
3558                            if let Some(range) = &su.mediaRange {
3559                                let (s, e) = parse_range(range)?;
3560                                start_byte = Some(s);
3561                                end_byte = Some(e);
3562                            }
3563                            if let Some(m) = &su.media {
3564                                let u = merge_baseurls(&base_url, m)?;
3565                                let mf = MediaFragmentBuilder::new(period_counter, u)
3566                                    .with_range(start_byte, end_byte)
3567                                    .build();
3568                                fragments.push(mf);
3569                            } else if let Some(bu) = subtitle_adaptation.BaseURL.first() {
3570                                let u = merge_baseurls(&base_url, &bu.base)?;
3571                                let mf = MediaFragmentBuilder::new(period_counter, u)
3572                                    .with_range(start_byte, end_byte)
3573                                    .build();
3574                                fragments.push(mf);
3575                            }
3576                        }
3577                    }
3578                    if let Some(sl) = &rep.SegmentList {
3579                        // (1) Representation>SegmentList addressing mode
3580                        if downloader.verbosity > 1 {
3581                            info!("  Using Representation>SegmentList addressing mode for subtitle representation");
3582                        }
3583                        let mut start_byte: Option<u64> = None;
3584                        let mut end_byte: Option<u64> = None;
3585                        if let Some(init) = &sl.Initialization {
3586                            if let Some(range) = &init.range {
3587                                let (s, e) = parse_range(range)?;
3588                                start_byte = Some(s);
3589                                end_byte = Some(e);
3590                            }
3591                            if let Some(su) = &init.sourceURL {
3592                                let path = resolve_url_template(su, &dict);
3593                                let u = merge_baseurls(&base_url, &path)?;
3594                                let mf = MediaFragmentBuilder::new(period_counter, u)
3595                                    .with_range(start_byte, end_byte)
3596                                    .set_init()
3597                                    .build();
3598                                fragments.push(mf);
3599                            } else {
3600                                let mf = MediaFragmentBuilder::new(period_counter, base_url.clone())
3601                                    .with_range(start_byte, end_byte)
3602                                    .set_init()
3603                                    .build();
3604                                fragments.push(mf);
3605                            }
3606                        }
3607                        for su in &sl.segment_urls {
3608                            start_byte = None;
3609                            end_byte = None;
3610                            // we are ignoring SegmentURL@indexRange
3611                            if let Some(range) = &su.mediaRange {
3612                                let (s, e) = parse_range(range)?;
3613                                start_byte = Some(s);
3614                                end_byte = Some(e);
3615                            }
3616                            if let Some(m) = &su.media {
3617                                let u = merge_baseurls(&base_url, m)?;
3618                                let mf = MediaFragmentBuilder::new(period_counter, u)
3619                                    .with_range(start_byte, end_byte)
3620                                    .build();
3621                                fragments.push(mf);
3622                            } else if let Some(bu) = &rep.BaseURL.first() {
3623                                let u = merge_baseurls(&base_url, &bu.base)?;
3624                                let mf = MediaFragmentBuilder::new(period_counter, u)
3625                                    .with_range(start_byte, end_byte)
3626                                    .build();
3627                                fragments.push(mf);
3628                            }
3629                        }
3630                    } else if rep.SegmentTemplate.is_some() ||
3631                        subtitle_adaptation.SegmentTemplate.is_some()
3632                    {
3633                        // Here we are either looking at a Representation.SegmentTemplate, or a
3634                        // higher-level AdaptationSet.SegmentTemplate
3635                        let st;
3636                        if let Some(it) = &rep.SegmentTemplate {
3637                            st = it;
3638                        } else if let Some(it) = &subtitle_adaptation.SegmentTemplate {
3639                            st = it;
3640                        } else {
3641                            panic!("unreachable");
3642                        }
3643                        if let Some(i) = &st.initialization {
3644                            opt_init = Some(i.clone());
3645                        }
3646                        if let Some(m) = &st.media {
3647                            opt_media = Some(m.clone());
3648                        }
3649                        if let Some(ts) = st.timescale {
3650                            timescale = ts;
3651                        }
3652                        if let Some(sn) = st.startNumber {
3653                            start_number = sn;
3654                        }
3655                        if let Some(stl) = &rep.SegmentTemplate.as_ref()
3656                            .and_then(|st| st.SegmentTimeline.clone())
3657                            .or(subtitle_adaptation.SegmentTemplate.as_ref().and_then(|st| st.SegmentTimeline.clone()))
3658                        {
3659                            // (2) SegmentTemplate with SegmentTimeline addressing mode (also called
3660                            // "explicit addressing" in certain DASH-IF documents)
3661                            if downloader.verbosity > 1 {
3662                                info!("  Using SegmentTemplate+SegmentTimeline addressing mode for subtitle representation");
3663                            }
3664                            if let Some(init) = opt_init {
3665                                let path = resolve_url_template(&init, &dict);
3666                                let u = merge_baseurls(&base_url, &path)?;
3667                                let mf = MediaFragmentBuilder::new(period_counter, u)
3668                                    .set_init()
3669                                    .build();
3670                                fragments.push(mf);
3671                            }
3672                            if let Some(media) = opt_media {
3673                                let sub_path = resolve_url_template(&media, &dict);
3674                                let mut segment_time = 0;
3675                                let mut segment_duration;
3676                                let mut number = start_number;
3677                                for s in &stl.segments {
3678                                    if let Some(t) = s.t {
3679                                        segment_time = t;
3680                                    }
3681                                    segment_duration = s.d;
3682                                    // the URLTemplate may be based on $Time$, or on $Number$
3683                                    let dict = HashMap::from([("Time", segment_time.to_string()),
3684                                                              ("Number", number.to_string())]);
3685                                    let path = resolve_url_template(&sub_path, &dict);
3686                                    let u = merge_baseurls(&base_url, &path)?;
3687                                    let mf = MediaFragmentBuilder::new(period_counter, u).build();
3688                                    fragments.push(mf);
3689                                    number += 1;
3690                                    if let Some(r) = s.r {
3691                                        let mut count = 0i64;
3692                                        // FIXME perhaps we also need to account for startTime?
3693                                        let end_time = period_duration_secs * timescale as f64;
3694                                        loop {
3695                                            count += 1;
3696                                            // Exit from the loop after @r iterations (if @r is
3697                                            // positive). A negative value of the @r attribute indicates
3698                                            // that the duration indicated in @d attribute repeats until
3699                                            // the start of the next S element, the end of the Period or
3700                                            // until the next MPD update.
3701                                            if r >= 0 {
3702                                                if count > r {
3703                                                    break;
3704                                                }
3705                                                if downloader.force_duration.is_some() &&
3706                                                    segment_time as f64 > end_time
3707                                                {
3708                                                    break;
3709                                                }
3710                                            } else if segment_time as f64 > end_time {
3711                                                break;
3712                                            }
3713                                            if let Some(end_number) = st.endNumber {
3714                                                if count as u64 > end_number {
3715                                                    break;
3716                                                }
3717                                            }
3718                                            segment_time += segment_duration;
3719                                            let dict = HashMap::from([("Time", segment_time.to_string()),
3720                                                                      ("Number", number.to_string())]);
3721                                            let path = resolve_url_template(&sub_path, &dict);
3722                                            let u = merge_baseurls(&base_url, &path)?;
3723                                            let mf = MediaFragmentBuilder::new(period_counter, u).build();
3724                                            fragments.push(mf);
3725                                            number += 1;
3726                                        }
3727                                    }
3728                                    segment_time += segment_duration;
3729                                }
3730                            } else {
3731                                return Err(DashMpdError::UnhandledMediaStream(
3732                                    "SegmentTimeline without a media attribute".to_string()));
3733                            }
3734                        } else { // no SegmentTimeline element
3735                            // (3) SegmentTemplate@duration addressing mode or (4) SegmentTemplate@index
3736                            // addressing mode (also called "simple addressing" in certain DASH-IF
3737                            // documents)
3738                            if downloader.verbosity > 0 {
3739                                info!("  Using SegmentTemplate addressing mode for stpp subtitles");
3740                            }
3741                            if let Some(i) = &st.initialization {
3742                                opt_init = Some(i.clone());
3743                            }
3744                            if let Some(m) = &st.media {
3745                                opt_media = Some(m.clone());
3746                            }
3747                            if let Some(d) = st.duration {
3748                                opt_duration = Some(d);
3749                            }
3750                            if let Some(ts) = st.timescale {
3751                                timescale = ts;
3752                            }
3753                            if let Some(s) = st.startNumber {
3754                                start_number = s;
3755                            }
3756                            let rid = match &rep.id {
3757                                Some(id) => id,
3758                                None => return Err(
3759                                    DashMpdError::UnhandledMediaStream(
3760                                        "Missing @id on Representation node".to_string())),
3761                            };
3762                            let mut dict = HashMap::from([("RepresentationID", rid.clone())]);
3763                            if let Some(b) = &rep.bandwidth {
3764                                dict.insert("Bandwidth", b.to_string());
3765                            }
3766                            let mut total_number = 0i64;
3767                            if let Some(init) = opt_init {
3768                                let path = resolve_url_template(&init, &dict);
3769                                let u = merge_baseurls(&base_url, &path)?;
3770                                let mf = MediaFragmentBuilder::new(period_counter, u)
3771                                    .set_init()
3772                                    .build();
3773                                fragments.push(mf);
3774                            }
3775                            if let Some(media) = opt_media {
3776                                let sub_path = resolve_url_template(&media, &dict);
3777                                let mut segment_duration: f64 = -1.0;
3778                                if let Some(d) = opt_duration {
3779                                    // it was set on the Period.SegmentTemplate node
3780                                    segment_duration = d;
3781                                }
3782                                if let Some(std) = st.duration {
3783                                    if timescale == 0 {
3784                                        return Err(DashMpdError::UnhandledMediaStream(
3785                                            "SegmentTemplate@duration attribute cannot be zero".to_string()));
3786                                    }
3787                                    segment_duration = std / timescale as f64;
3788                                }
3789                                if segment_duration < 0.0 {
3790                                    return Err(DashMpdError::UnhandledMediaStream(
3791                                        "Subtitle representation is missing SegmentTemplate@duration".to_string()));
3792                                }
3793                                total_number += (period_duration_secs / segment_duration).ceil() as i64;
3794                                if let Some(end_number) = st.endNumber {
3795                                    total_number = end_number as i64;
3796                                }
3797                                let mut number = start_number;
3798                                #[allow(clippy::explicit_counter_loop)]
3799                                for _ in 1..=total_number {
3800                                    let dict = HashMap::from([("Number", number.to_string())]);
3801                                    let path = resolve_url_template(&sub_path, &dict);
3802                                    let u = merge_baseurls(&base_url, &path)?;
3803                                    let mf = MediaFragmentBuilder::new(period_counter, u).build();
3804                                    fragments.push(mf);
3805                                    number += 1;
3806                                }
3807                            }
3808                        }
3809                    } else if let Some(sb) = &rep.SegmentBase {
3810                        // SegmentBase@indexRange addressing mode
3811                        info!("  Using SegmentBase@indexRange for subs");
3812                        if downloader.verbosity > 1 {
3813                            info!("  Using SegmentBase@indexRange addressing mode for subtitle representation");
3814                        }
3815                        let mut start_byte: Option<u64> = None;
3816                        let mut end_byte: Option<u64> = None;
3817                        if let Some(init) = &sb.Initialization {
3818                            if let Some(range) = &init.range {
3819                                let (s, e) = parse_range(range)?;
3820                                start_byte = Some(s);
3821                                end_byte = Some(e);
3822                            }
3823                            if let Some(su) = &init.sourceURL {
3824                                let path = resolve_url_template(su, &dict);
3825                                let u = merge_baseurls(&base_url, &path)?;
3826                                let mf = MediaFragmentBuilder::new(period_counter, u)
3827                                    .with_range(start_byte, end_byte)
3828                                    .set_init()
3829                                    .build();
3830                                fragments.push(mf);
3831                            }
3832                        }
3833                        let mf = MediaFragmentBuilder::new(period_counter, base_url.clone())
3834                            .set_init()
3835                            .build();
3836                        fragments.push(mf);
3837                        // TODO also implement SegmentBase addressing mode for subtitles
3838                        // (sample MPD: https://usp-cmaf-test.s3.eu-central-1.amazonaws.com/tears-of-steel-ttml.mpd)
3839                    }
3840                }
3841            }
3842        }
3843    }
3844    Ok(PeriodOutputs {
3845        fragments,
3846        diagnostics: Vec::new(),
3847        subtitle_formats,
3848        selected_audio_language: String::from("unk"),
3849        selected_subtitle_language: subtitle_lang.unwrap_or_else(|| String::from("unk")),
3850    })
3851}
3852
3853
3854// This is a complement to the DashDownloader struct, intended to contain the mutable state
3855// associated with a download. We have chosen an API where the DashDownloader is not mutable.
3856struct DownloadState {
3857    period_counter: u8,
3858    segment_count: usize,
3859    segment_counter: usize,
3860    download_errors: u32
3861}
3862
3863// Fetch a media fragment at URL frag.url, using the reqwest client in downloader.http_client.
3864// Network bandwidth is throttled according to downloader.rate_limit. Transient network failures are
3865// retried.
3866//
3867// Note: We return a File instead of a Bytes buffer, because some streams using SegmentBase indexing
3868// have huge segments that can fill up RAM.
3869#[tracing::instrument(level="trace", skip_all)]
3870async fn fetch_fragment(
3871    downloader: &mut DashDownloader,
3872    frag: &MediaFragment,
3873    fragment_type: &str,
3874    progress_percent: u32) -> Result<File, DashMpdError>
3875{
3876    let send_request = || async {
3877        trace!("send_request {}", frag.url.clone());
3878        // Don't use only "audio/*" or "video/*" in Accept header because some web servers (eg.
3879        // media.axprod.net) are misconfigured and reject requests for valid audio content (eg .m4s)
3880        let mut req = downloader.http_client.as_ref().unwrap()
3881            .get(frag.url.clone())
3882            .header("Accept", format!("{fragment_type}/*;q=0.9,*/*;q=0.5"))
3883            .header("Sec-Fetch-Mode", "navigate");
3884        if let Some(sb) = &frag.start_byte {
3885            if let Some(eb) = &frag.end_byte {
3886                req = req.header(RANGE, format!("bytes={sb}-{eb}"));
3887            }
3888        }
3889        if let Some(ts) = &frag.timeout {
3890            req = req.timeout(*ts);
3891        }
3892        if let Some(referer) = &downloader.referer {
3893            req = req.header("Referer", referer);
3894        } else {
3895            req = req.header("Referer", downloader.redirected_url.to_string());
3896        }
3897        if let Some(username) = &downloader.auth_username {
3898            if let Some(password) = &downloader.auth_password {
3899                req = req.basic_auth(username, Some(password));
3900            }
3901        }
3902        if let Some(token) = &downloader.auth_bearer_token {
3903            req = req.bearer_auth(token);
3904        }
3905        req.send().await?
3906            .error_for_status()
3907    };
3908    match send_request
3909        .retry(ExponentialBuilder::default())
3910        .when(reqwest_error_transient_p)
3911        .notify(notify_transient)
3912        .await
3913    {
3914        Ok(response) => {
3915            match response.error_for_status() {
3916                Ok(mut resp) => {
3917                    let tmp_out_std = tempfile::tempfile()
3918                        .map_err(|e| DashMpdError::Io(e, String::from("creating tmpfile for fragment")))?;
3919                    let mut tmp_out = tokio::fs::File::from_std(tmp_out_std);
3920                      let content_type_checker = if fragment_type.eq("audio") {
3921                        content_type_audio_p
3922                    } else if fragment_type.eq("video") {
3923                        content_type_video_p
3924                    } else {
3925                        panic!("fragment_type not audio or video");
3926                    };
3927                    if !downloader.content_type_checks || content_type_checker(&resp) {
3928                        let mut fragment_out: Option<File> = None;
3929                        if let Some(ref fragment_path) = downloader.fragment_path {
3930                            if let Some(path) = frag.url.path_segments()
3931                                .unwrap_or_else(|| "".split(' '))
3932                                .next_back()
3933                            {
3934                                let vf_file = fragment_path.clone().join(fragment_type).join(path);
3935                                if let Ok(f) = File::create(vf_file).await {
3936                                    fragment_out = Some(f);
3937                                }
3938                            }
3939                        }
3940                        let mut segment_size = 0;
3941                        // Download in chunked format instead of using reqwest's .bytes() API, in
3942                        // order to avoid saturating RAM with a large media segment. This is
3943                        // important for DASH manifests that use indexRange addressing, which we
3944                        // don't download using byte range requests as a normal DASH client would
3945                        // do, but rather download using a single network request.
3946                        while let Some(chunk) = resp.chunk().await
3947                            .map_err(|e| network_error(&format!("fetching DASH {fragment_type} segment"), &e))?
3948                        {
3949                            segment_size += chunk.len();
3950                            downloader.bw_estimator_bytes += chunk.len();
3951                            let size = min((chunk.len()/1024+1) as u32, u32::MAX);
3952                            throttle_download_rate(downloader, size).await?;
3953                            if let Err(e) = tmp_out.write_all(&chunk).await {
3954                                return Err(DashMpdError::Io(e, format!("writing DASH {fragment_type} data")));
3955                            }
3956                            if let Some(ref mut fout) = fragment_out {
3957                                fout.write_all(&chunk)
3958                                    .map_err(|e| DashMpdError::Io(e, format!("writing {fragment_type} fragment")))
3959                                    .await?;
3960                            }
3961                            let elapsed = downloader.bw_estimator_started.elapsed().as_secs_f64();
3962                            if (elapsed > 0.5) || (downloader.bw_estimator_bytes > 50_000) {
3963                                let bw = downloader.bw_estimator_bytes as f64 / elapsed;
3964                                for observer in &downloader.progress_observers {
3965                                    observer.update(progress_percent, bw as u64, &format!("Fetching {fragment_type} segments"));
3966                                }
3967                                downloader.bw_estimator_started = Instant::now();
3968                                downloader.bw_estimator_bytes = 0;
3969                            }
3970                        }
3971                        if downloader.verbosity > 2 {
3972                            if let Some(sb) = &frag.start_byte {
3973                                if let Some(eb) = &frag.end_byte {
3974                                    info!("  {fragment_type} segment {} range {sb}-{eb} -> {} octets",
3975                                          frag.url, segment_size);
3976                                }
3977                            } else {
3978                                info!("  {fragment_type} segment {} -> {segment_size} octets", &frag.url);
3979                            }
3980                        }
3981                    } else {
3982                        warn!("Ignoring segment {} with non-{fragment_type} content-type", frag.url);
3983                    }
3984                    tmp_out.sync_all().await
3985                        .map_err(|e| DashMpdError::Io(e, format!("syncing {fragment_type} fragment")))?;
3986                    Ok(tmp_out)
3987                },
3988                Err(e) => Err(network_error("HTTP error", &e)),
3989            }
3990        },
3991        Err(e) => Err(network_error(&format!("{e:?}"), &e)),
3992    }
3993}
3994
3995
3996// Retrieve the audio segments for period `period_counter` and concatenate them to a file at tmppath.
3997#[tracing::instrument(level="trace", skip_all)]
3998async fn fetch_period_audio(
3999    downloader: &mut DashDownloader,
4000    tmppath: &Path,
4001    audio_fragments: &[MediaFragment],
4002    ds: &mut DownloadState) -> Result<bool, DashMpdError>
4003{
4004    let start_download = Instant::now();
4005    let mut have_audio = false;
4006    {
4007        // We need a local scope for our temporary File, so that the file is closed when we later
4008        // optionally call the decryption application (which requires exclusive access to its input
4009        // file on Windows).
4010        let tmpfile_audio = File::create(tmppath).await
4011            .map_err(|e| DashMpdError::Io(e, String::from("creating audio tmpfile")))?;
4012        ensure_permissions_readable(tmppath).await?;
4013        let mut tmpfile_audio = BufWriter::new(tmpfile_audio);
4014        // Optionally create the directory to which we will save the audio fragments.
4015        if let Some(ref fragment_path) = downloader.fragment_path {
4016            let audio_fragment_dir = fragment_path.join("audio");
4017            if !audio_fragment_dir.exists() {
4018                fs::create_dir_all(audio_fragment_dir).await
4019                    .map_err(|e| DashMpdError::Io(e, String::from("creating audio fragment dir")))?;
4020            }
4021        }
4022        // TODO: in DASH, the init segment contains headers that are necessary to generate a valid MP4
4023        // file, so we should always abort if the first segment cannot be fetched. However, we could
4024        // tolerate loss of subsequent segments.
4025        for frag in audio_fragments.iter().filter(|f| f.period == ds.period_counter) {
4026            ds.segment_counter += 1;
4027            // We don't want the progress_percent to exceed 98 here, because we reserve 99% for
4028            // muxing and 100% for the "Done" message.
4029            let progress_percent = min(98, (100.0 * ds.segment_counter as f32 / (2.0 + ds.segment_count as f32)).ceil() as u32);
4030            let url = &frag.url;
4031            // A manifest may use a data URL (RFC 2397) to embed media content such as the
4032            // initialization segment directly in the manifest (recommended by YouTube for live
4033            // streaming, but uncommon in practice).
4034            if url.scheme() == "data" {
4035                let us = &url.to_string();
4036                let du = DataUrl::process(us)
4037                    .map_err(|_| DashMpdError::Parsing(String::from("parsing data URL")))?;
4038                if du.mime_type().type_ != "audio" {
4039                    return Err(DashMpdError::UnhandledMediaStream(
4040                        String::from("expecting audio content in data URL")));
4041                }
4042                let (body, _fragment) = du.decode_to_vec()
4043                    .map_err(|_| DashMpdError::Parsing(String::from("decoding data URL")))?;
4044                if downloader.verbosity > 2 {
4045                    info!("  Audio segment data URL -> {} octets", body.len());
4046                }
4047                tmpfile_audio.write_all(&body)
4048                    .map_err(|e| DashMpdError::Io(e, String::from("writing DASH audio data")))
4049                    .await?;
4050                have_audio = true;
4051            } else {
4052                // We could download these segments in parallel, but that might upset some servers.
4053                'done: for _ in 0..downloader.fragment_retry_count {
4054                    match fetch_fragment(downloader, frag, "audio", progress_percent).await {
4055                        Ok(mut frag_file) => {
4056                            frag_file.rewind().await
4057                                .map_err(|e| DashMpdError::Io(e, String::from("rewinding fragment tempfile")))?;
4058                            let mut buf = Vec::new();
4059                            frag_file.read_to_end(&mut buf).await
4060                                .map_err(|e| DashMpdError::Io(e, String::from("reading fragment tempfile")))?;
4061                            tmpfile_audio.write_all(&buf)
4062                                .map_err(|e| DashMpdError::Io(e, String::from("writing DASH audio data")))
4063                                .await?;
4064                            have_audio = true;
4065                            break 'done;
4066                        },
4067                        Err(e) => {
4068                            if downloader.verbosity > 0 {
4069                                error!("Error fetching audio segment {url}: {e:?}");
4070                            }
4071                            ds.download_errors += 1;
4072                            if ds.download_errors > downloader.max_error_count {
4073                                error!("max_error_count network errors encountered");
4074                                return Err(DashMpdError::Network(
4075                                    String::from("more than max_error_count network errors")));
4076                            }
4077                        },
4078                    }
4079                    info!("  Retrying audio segment {url}");
4080                    if downloader.sleep_between_requests > 0 {
4081                        tokio::time::sleep(Duration::new(downloader.sleep_between_requests.into(), 0)).await;
4082                    }
4083                }
4084            }
4085        }
4086        tmpfile_audio.flush().map_err(|e| {
4087            error!("Couldn't flush DASH audio file: {e}");
4088            DashMpdError::Io(e, String::from("flushing DASH audio file"))
4089        }).await?;
4090    } // end local scope for the FileHandle
4091    if !downloader.decryption_keys.is_empty() {
4092        if downloader.verbosity > 0 {
4093            let metadata = fs::metadata(tmppath).await
4094                .map_err(|e| DashMpdError::Io(e, String::from("reading encrypted audio metadata")))?;
4095            info!("  Attempting to decrypt audio stream ({} kB) with {}",
4096                  metadata.len() / 1024,
4097                  downloader.decryptor_preference);
4098        }
4099        let out_ext = downloader.output_path.as_ref()
4100            .ok_or_else(|| DashMpdError::Other(String::from("no output_path set")))?
4101            .extension()
4102            .unwrap_or(OsStr::new("mp4"));
4103        let decrypted = tmp_file_path("dashmpd-decrypted-audio", out_ext)?;
4104        if downloader.decryptor_preference.eq("mp4decrypt") {
4105            decrypt_mp4decrypt(downloader, tmppath, &decrypted, "audio").await?;
4106        } else if downloader.decryptor_preference.eq("shaka") {
4107            decrypt_shaka(downloader, tmppath, &decrypted, "audio").await?;
4108        } else if downloader.decryptor_preference.eq("shaka-container") {
4109            decrypt_shaka_container(downloader, tmppath, &decrypted, "audio").await?;
4110        } else if downloader.decryptor_preference.eq("mp4box") {
4111            decrypt_mp4box(downloader, tmppath, &decrypted, "audio").await?;
4112        } else if downloader.decryptor_preference.eq("mp4box-container") {
4113            decrypt_mp4box_container(downloader, tmppath, &decrypted, "audio").await?;
4114        } else {
4115            return Err(DashMpdError::Decrypting(String::from("unknown decryption application")));
4116        }
4117        if let Err(e) = fs::metadata(&decrypted).await {
4118            return Err(DashMpdError::Decrypting(format!("missing decrypted audio file: {e:?}")));
4119        }
4120        fs::remove_file(&tmppath).await
4121            .map_err(|e| DashMpdError::Io(e, String::from("deleting encrypted audio tmpfile")))?;
4122        fs::rename(&decrypted, &tmppath).await
4123            .map_err(|e| {
4124                let dbg = Command::new("bash")
4125                    .args(["-c", &format!("id;ls -l {}", decrypted.display())])
4126                    .output()
4127                    .unwrap();
4128                warn!("debugging ls: {}", String::from_utf8_lossy(&dbg.stdout));
4129                DashMpdError::Io(e, format!("renaming decrypted audio {}->{}", decrypted.display(), tmppath.display()))
4130            })?;
4131    }
4132    if let Ok(metadata) = fs::metadata(&tmppath).await {
4133        if downloader.verbosity > 1 {
4134            let mbytes = metadata.len() as f64 / (1024.0 * 1024.0);
4135            let elapsed = start_download.elapsed();
4136            info!("  Wrote {mbytes:.1}MB to DASH audio file ({:.1} MB/s)",
4137                     mbytes / elapsed.as_secs_f64());
4138        }
4139    }
4140    Ok(have_audio)
4141}
4142
4143
4144// Retrieve the video segments for period `period_counter` and concatenate them to a file at tmppath.
4145#[tracing::instrument(level="trace", skip_all)]
4146async fn fetch_period_video(
4147    downloader: &mut DashDownloader,
4148    tmppath: &Path,
4149    video_fragments: &[MediaFragment],
4150    ds: &mut DownloadState) -> Result<bool, DashMpdError>
4151{
4152    let start_download = Instant::now();
4153    let mut have_video = false;
4154    {
4155        // We need a local scope for our tmpfile_video File, so that the file is closed when we
4156        // later call the decryption helper application. Certain helper configurations like
4157        // mp4decrypt on Windows require exclusive access to its input file.
4158        let tmpfile_video = File::create(tmppath).await
4159            .map_err(|e| DashMpdError::Io(e, String::from("creating video tmpfile")))?;
4160        ensure_permissions_readable(tmppath).await?;
4161        let mut tmpfile_video = BufWriter::new(tmpfile_video);
4162        // Optionally create the directory to which we will save the video fragments.
4163        if let Some(ref fragment_path) = downloader.fragment_path {
4164            let video_fragment_dir = fragment_path.join("video");
4165            if !video_fragment_dir.exists() {
4166                fs::create_dir_all(video_fragment_dir).await
4167                    .map_err(|e| DashMpdError::Io(e, String::from("creating video fragment dir")))?;
4168            }
4169        }
4170        for frag in video_fragments.iter().filter(|f| f.period == ds.period_counter) {
4171            ds.segment_counter += 1;
4172            // We don't want the progress_percent to exceed 98 here, because we reserve 99% for
4173            // muxing and 100% for the "Done" message.
4174            let progress_percent = min(98, (100.0 * ds.segment_counter as f32 / ds.segment_count as f32).ceil() as u32);
4175            if frag.url.scheme() == "data" {
4176                let us = &frag.url.to_string();
4177                let du = DataUrl::process(us)
4178                    .map_err(|_| DashMpdError::Parsing(String::from("parsing data URL")))?;
4179                if du.mime_type().type_ != "video" {
4180                    return Err(DashMpdError::UnhandledMediaStream(
4181                        String::from("expecting video content in data URL")));
4182                }
4183                let (body, _fragment) = du.decode_to_vec()
4184                    .map_err(|_| DashMpdError::Parsing(String::from("decoding data URL")))?;
4185                if downloader.verbosity > 2 {
4186                    info!("  Video segment data URL -> {} octets", body.len());
4187                }
4188                tmpfile_video.write_all(&body)
4189                    .map_err(|e| DashMpdError::Io(e, String::from("writing DASH video data")))
4190                    .await?;
4191                have_video = true;
4192            } else {
4193                'done: for _ in 0..downloader.fragment_retry_count {
4194                    match fetch_fragment(downloader, frag, "video", progress_percent).await {
4195                        Ok(mut frag_file) => {
4196                            frag_file.rewind().await
4197                                .map_err(|e| DashMpdError::Io(e, String::from("rewinding fragment tempfile")))?;
4198                            let mut buf = Vec::new();
4199                            frag_file.read_to_end(&mut buf).await
4200                                .map_err(|e| DashMpdError::Io(e, String::from("reading fragment tempfile")))?;
4201                            tmpfile_video.write_all(&buf)
4202                                .map_err(|e| DashMpdError::Io(e, String::from("writing DASH video data")))
4203                                .await?;
4204                            have_video = true;
4205                            break 'done;
4206                        },
4207                        Err(e) => {
4208                            if downloader.verbosity > 0 {
4209                                error!("  Error fetching video segment {}: {e:?}", frag.url);
4210                            }
4211                            ds.download_errors += 1;
4212                            if ds.download_errors > downloader.max_error_count {
4213                                return Err(DashMpdError::Network(
4214                                    String::from("more than max_error_count network errors")));
4215                            }
4216                        },
4217                    }
4218                    info!("  Retrying video segment {}", frag.url);
4219                    if downloader.sleep_between_requests > 0 {
4220                        tokio::time::sleep(Duration::new(downloader.sleep_between_requests.into(), 0)).await;
4221                    }
4222                }
4223            }
4224        }
4225        tmpfile_video.flush().map_err(|e| {
4226            error!("  Couldn't flush video file: {e}");
4227            DashMpdError::Io(e, String::from("flushing video file"))
4228        }).await?;
4229    } // end local scope for tmpfile_video File
4230    if !downloader.decryption_keys.is_empty() {
4231        if downloader.verbosity > 0 {
4232            let metadata = fs::metadata(tmppath).await
4233                .map_err(|e| DashMpdError::Io(e, String::from("reading encrypted video metadata")))?;
4234            info!("  Attempting to decrypt video stream ({} kB) with {}",
4235                   metadata.len() / 1024,
4236                   downloader.decryptor_preference);
4237        }
4238        let out_ext = downloader.output_path.as_ref()
4239            .ok_or_else(|| DashMpdError::Other(String::from("no output_path set")))?
4240            .extension()
4241            .unwrap_or(OsStr::new("mp4"));
4242        let decrypted = tmp_file_path("dashmpd-decrypted-video", out_ext)?;
4243        if downloader.decryptor_preference.eq("mp4decrypt") {
4244            decrypt_mp4decrypt(downloader, tmppath, &decrypted, "video").await?;
4245        } else if downloader.decryptor_preference.eq("shaka") {
4246            decrypt_shaka(downloader, tmppath, &decrypted, "video").await?;
4247        } else if downloader.decryptor_preference.eq("shaka-container") {
4248            decrypt_shaka_container(downloader, tmppath, &decrypted, "video").await?;
4249        } else if downloader.decryptor_preference.eq("mp4box") {
4250            decrypt_mp4box(downloader, tmppath, &decrypted, "video").await?;
4251        } else if downloader.decryptor_preference.eq("mp4box-container") {
4252            decrypt_mp4box_container(downloader, tmppath, &decrypted, "video").await?;
4253        } else {
4254            return Err(DashMpdError::Decrypting(String::from("unknown decryption application")));
4255        }
4256        if let Err(e) = fs::metadata(&decrypted).await {
4257            return Err(DashMpdError::Decrypting(format!("missing decrypted video file: {e:?}")));
4258        }
4259        fs::remove_file(&tmppath).await
4260            .map_err(|e| DashMpdError::Io(e, String::from("deleting encrypted video tmpfile")))?;
4261        fs::rename(&decrypted, &tmppath).await
4262            .map_err(|e| DashMpdError::Io(e, String::from("renaming decrypted video")))?;
4263    }
4264    if let Ok(metadata) = fs::metadata(&tmppath).await {
4265        if downloader.verbosity > 1 {
4266            let mbytes = metadata.len() as f64 / (1024.0 * 1024.0);
4267            let elapsed = start_download.elapsed();
4268            info!("  Wrote {mbytes:.1}MB to DASH video file ({:.1} MB/s)",
4269                     mbytes / elapsed.as_secs_f64());
4270        }
4271    }
4272    Ok(have_video)
4273}
4274
4275
4276// Retrieve the video segments for period `ds.period_counter` and concatenate them to a file at `tmppath`.
4277#[tracing::instrument(level="trace", skip_all)]
4278async fn fetch_period_subtitles(
4279    downloader: &DashDownloader,
4280    tmppath: &Path,
4281    subtitle_fragments: &[MediaFragment],
4282    subtitle_formats: &[SubtitleType],
4283    ds: &mut DownloadState) -> Result<bool, DashMpdError>
4284{
4285    use crate::stpp::StppDocument;
4286    use crate::vtt::VttDocument;
4287    
4288    let client = downloader.http_client.clone().unwrap();
4289    let start_download = Instant::now();
4290    let mut have_subtitles = false;
4291    {
4292        let tmpfile_subs = File::create(tmppath).await
4293            .map_err(|e| DashMpdError::Io(e, String::from("creating subs tmpfile")))?;
4294        // Only used if subtitle_formats contains SubtitleFormat::Stpp
4295        let mut stpp_document = StppDocument::new();
4296        // Only used if subtitle_formats contains SubtitleFormat::Vtt
4297        let mut vtt_document = VttDocument::new();
4298        ensure_permissions_readable(tmppath).await?;
4299        let mut tmpfile_subs = BufWriter::new(tmpfile_subs);
4300        for frag in subtitle_fragments {
4301            // Update any ProgressObservers
4302            ds.segment_counter += 1;
4303            let progress_percent = min(98, (100.0 * ds.segment_counter as f32 / ds.segment_count as f32).ceil() as u32);
4304            for observer in &downloader.progress_observers {
4305                observer.update(progress_percent, 1, "Fetching subtitle segments");
4306            }
4307            if frag.url.scheme() == "data" {
4308                let us = &frag.url.to_string();
4309                let du = DataUrl::process(us)
4310                    .map_err(|_| DashMpdError::Parsing(String::from("parsing data URL")))?;
4311                if du.mime_type().type_ != "video" {
4312                    return Err(DashMpdError::UnhandledMediaStream(
4313                        String::from("expecting video content in data URL")));
4314                }
4315                let (body, _fragment) = du.decode_to_vec()
4316                    .map_err(|_| DashMpdError::Parsing(String::from("decoding data URL")))?;
4317                if downloader.verbosity > 2 {
4318                    info!("  Subtitle segment data URL -> {} octets", body.len());
4319                }
4320                tmpfile_subs.write_all(&body)
4321                    .map_err(|e| DashMpdError::Io(e, String::from("writing DASH subtitle data")))
4322                    .await?;
4323                have_subtitles = true;
4324            } else {
4325                let fetch = || async {
4326                    let mut req = client.get(frag.url.clone())
4327                        .header("Sec-Fetch-Mode", "navigate");
4328                    if let Some(sb) = &frag.start_byte {
4329                        if let Some(eb) = &frag.end_byte {
4330                            req = req.header(RANGE, format!("bytes={sb}-{eb}"));
4331                        }
4332                    }
4333                    if let Some(referer) = &downloader.referer {
4334                        req = req.header("Referer", referer);
4335                    } else {
4336                        req = req.header("Referer", downloader.redirected_url.to_string());
4337                    }
4338                    if let Some(username) = &downloader.auth_username {
4339                        if let Some(password) = &downloader.auth_password {
4340                            req = req.basic_auth(username, Some(password));
4341                        }
4342                    }
4343                    if let Some(token) = &downloader.auth_bearer_token {
4344                        req = req.bearer_auth(token);
4345                    }
4346                    req.send().await?
4347                        .error_for_status()
4348                };
4349                let mut failure = None;
4350                match fetch
4351                    .retry(ExponentialBuilder::default())
4352                    .when(reqwest_error_transient_p)
4353                    .notify(notify_transient)
4354                    .await
4355                {
4356                    Ok(response) => {
4357                        if response.status().is_success() {
4358                            let content_bytes = response.bytes().await
4359                                .map_err(|e| network_error("fetching DASH subtitle segment", &e))?;
4360                            if downloader.verbosity > 2 {
4361                                if let Some(sb) = &frag.start_byte {
4362                                    if let Some(eb) = &frag.end_byte {
4363                                        info!("  Subtitle segment {} range {sb}-{eb} -> {} octets",
4364                                                 &frag.url, content_bytes.len());
4365                                    }
4366                                } else {
4367                                    info!("  Subtitle segment {} -> {} octets", &frag.url, content_bytes.len());
4368                                }
4369                            }
4370                            let size = min((content_bytes.len()/1024 + 1) as u32, u32::MAX);
4371                            throttle_download_rate(downloader, size).await?;
4372                            if subtitle_formats.contains(&SubtitleType::Stpp) {
4373                                stpp_document.add_from_mp4(&content_bytes)?;
4374                                // TODO: likewise handle fMP4 segments that contain WebVTT
4375                                // (codec=wvtt), using vttc boxes for text cues and vtte boxes for
4376                                // empty samples.
4377                            } else if subtitle_formats.contains(&SubtitleType::Vtt) {
4378                                vtt_document.add_bytes(&content_bytes)?;
4379                            } else {
4380                                tmpfile_subs.write_all(&content_bytes)
4381                                    .map_err(|e| DashMpdError::Io(e, String::from("writing DASH subtitle data")))
4382                                    .await?;
4383                            }
4384                            have_subtitles = true;
4385                        } else {
4386                            failure = Some(format!("HTTP error {}", response.status().as_str()));
4387                        }
4388                    },
4389                    Err(e) => failure = Some(format!("{e}")),
4390                }
4391                if let Some(f) = failure {
4392                    if downloader.verbosity > 0 {
4393                        error!("{f} fetching subtitle segment {}", &frag.url);
4394                    }
4395                    ds.download_errors += 1;
4396                    if ds.download_errors > downloader.max_error_count {
4397                        return Err(DashMpdError::Network(
4398                            String::from("more than max_error_count network errors")));
4399                    }
4400                }
4401            }
4402            if downloader.sleep_between_requests > 0 {
4403                tokio::time::sleep(Duration::new(downloader.sleep_between_requests.into(), 0)).await;
4404            }
4405        }
4406        if subtitle_formats.contains(&SubtitleType::Stpp) {
4407            if downloader.verbosity > 1 {
4408                info!("  Writing TTML subtitles to {tmppath:?}");
4409            }
4410            tmpfile_subs.write_all(stpp_document.to_string().as_bytes())
4411                .map_err(|e| DashMpdError::Io(e, String::from("writing DASH TTML subtitle data")))
4412                .await?;
4413            tmpfile_subs.flush().map_err(|e| {
4414                error!("Couldn't flush subs file: {e}");
4415                DashMpdError::Io(e, String::from("flushing subtitle file"))
4416            }).await?;
4417        }
4418        if subtitle_formats.contains(&SubtitleType::Vtt) {
4419            if downloader.verbosity > 1 {
4420                info!("  Writing VTT subtitles to {tmppath:?}");
4421            }
4422            tmpfile_subs.write_all(vtt_document.to_string().as_bytes())
4423                .map_err(|e| DashMpdError::Io(e, String::from("writing DASH VTT subtitle data")))
4424                .await?;
4425            tmpfile_subs.flush().map_err(|e| {
4426                error!("Couldn't flush subs file: {e}");
4427                DashMpdError::Io(e, String::from("flushing subtitle file"))
4428            }).await?;
4429        }
4430    } // end local scope for tmpfile_subs File
4431    if have_subtitles {
4432        if let Ok(metadata) = fs::metadata(tmppath).await {
4433            if downloader.verbosity > 1 {
4434                let mbytes = metadata.len() as f64 / (1024.0 * 1024.0);
4435                let elapsed = start_download.elapsed();
4436                info!("  Wrote {mbytes:.1}MB to DASH subtitle file ({:.1} MB/s)",
4437                      mbytes / elapsed.as_secs_f64());
4438            }
4439        }
4440        // TODO: for subtitle_formats sub and srt we could also try to embed them in the output
4441        // file, for example using MP4Box or mkvmerge
4442        if subtitle_formats.contains(&SubtitleType::Wvtt) ||
4443           subtitle_formats.contains(&SubtitleType::Ttxt)
4444        {
4445            if let Err(e) = wvtt_extract(downloader, tmppath).await {
4446                warn!("Error while extracting wvtt subtitles: {e}");
4447            }
4448        }
4449        if subtitle_formats.contains(&SubtitleType::Stpp) {
4450            // Copy from the temporary filename for the subtitle file to a .ttml file with the same
4451            // basename as the requested media output file. Copy rather than rename in case we are
4452            // crossing filesystems.
4453            let tmpfile_in = File::open(tmppath).await
4454                .map_err(|e| DashMpdError::Io(
4455                    e, String::from("opening tmp subtitle output")))?;
4456            let ttml_path = downloader.output_path.as_ref()
4457                .ok_or_else(|| DashMpdError::Other(String::from("no output_path set")))?
4458                .with_extension("ttml");
4459            let ttml_file = File::create(&ttml_path).await
4460                .map_err(|e| DashMpdError::Io(
4461                    e, String::from("opening TTML output file")))?;
4462            io::copy(&mut BufReader::new(tmpfile_in), &mut BufWriter::new(ttml_file)).await
4463                .map_err(|e| DashMpdError::Io(
4464                    e, String::from("copying TTML subtitles")))?;
4465            info!("  Copied TTML subtitles to {}", ttml_path.display());
4466            if let Err(e) = convert_ttml_srt(downloader, &ttml_path).await {
4467                warn!("Error while converting TTML subtitles to SubRip: {e}");
4468            }
4469        }
4470        if subtitle_formats.contains(&SubtitleType::Vtt) {
4471            // Copy from the temporary filename for the subtitle file to a .vtt file with the same
4472            // basename as the requested media output file. Copy rather than rename in case we a
4473            // crossing filesystems.
4474            let tmpfile_in = File::open(tmppath).await
4475                .map_err(|e| DashMpdError::Io(
4476                    e, String::from("opening tmp subtitle output")))?;
4477            let vtt_path = downloader.output_path.as_ref()
4478                .ok_or_else(|| DashMpdError::Other(String::from("no output_path set")))?
4479                .with_extension("vtt");
4480            let vtt_file = File::create(&vtt_path).await
4481                .map_err(|e| DashMpdError::Io(
4482                    e, String::from("opening VTT output file")))?;
4483            io::copy(&mut BufReader::new(tmpfile_in), &mut BufWriter::new(vtt_file)).await
4484                .map_err(|e| DashMpdError::Io(
4485                    e, String::from("copying VTT subtitles")))?;
4486            info!("  Copied WebVTT subtitles to {}", vtt_path.display());
4487            if let Err(e) = convert_vtt_srt(downloader, &vtt_path).await {
4488                warn!("Error while converting WebVTT subtitles to SubRip: {e}");
4489            }
4490        }
4491    }
4492    Ok(have_subtitles)
4493}
4494
4495
4496// Fetch XML content of manifest from an HTTP/HTTPS URL
4497async fn fetch_mpd_http(downloader: &mut DashDownloader) -> Result<Bytes, DashMpdError> {
4498    let client = &downloader.http_client.clone().unwrap();
4499    let send_request = || async {
4500        let mut req = client.get(&downloader.mpd_url)
4501            .header("Accept", "application/dash+xml,video/vnd.mpeg.dash.mpd")
4502            .header("Accept-Language", "en-US,en")
4503            .header("Upgrade-Insecure-Requests", "1")
4504            .header("Sec-Fetch-Mode", "navigate");
4505        if let Some(referer) = &downloader.referer {
4506            req = req.header("Referer", referer);
4507        }
4508        if let Some(username) = &downloader.auth_username {
4509            if let Some(password) = &downloader.auth_password {
4510                req = req.basic_auth(username, Some(password));
4511            }
4512        }
4513        if let Some(token) = &downloader.auth_bearer_token {
4514            req = req.bearer_auth(token);
4515        }
4516        req.send().await?
4517            .error_for_status()
4518    };
4519    for observer in &downloader.progress_observers {
4520        observer.update(1, 1, "Fetching DASH manifest");
4521    }
4522    if downloader.verbosity > 0 {
4523        if !downloader.fetch_audio && !downloader.fetch_video && !downloader.fetch_subtitles {
4524            info!("Only simulating media downloads");
4525        }
4526        info!("Fetching the DASH manifest");
4527    }
4528    let response = send_request
4529        .retry(ExponentialBuilder::default())
4530        .when(reqwest_error_transient_p)
4531        .notify(notify_transient)
4532        .await
4533        .map_err(|e| network_error("requesting DASH manifest", &e))?;
4534    if !response.status().is_success() {
4535        let msg = format!("fetching DASH manifest (HTTP {})", response.status().as_str());
4536        return Err(DashMpdError::Network(msg));
4537    }
4538    downloader.redirected_url = response.url().clone();
4539    response.bytes().await
4540        .map_err(|e| network_error("fetching DASH manifest", &e))
4541}
4542
4543// Fetch XML content of manifest from a file:// URL. The reqwest library is not able to download
4544// from this URL type.
4545async fn fetch_mpd_file_url(downloader: &mut DashDownloader) -> Result<Bytes, DashMpdError> {
4546    if ! &downloader.mpd_url.starts_with("file://") {
4547        return Err(DashMpdError::Other(String::from("expecting file:// URL scheme")));
4548    }
4549    let url = Url::parse(&downloader.mpd_url)
4550        .map_err(|_| DashMpdError::Other(String::from("parsing MPD URL")))?;
4551    let path = url.to_file_path()
4552        .map_err(|_| DashMpdError::Other(String::from("extracting path from file:// URL")))?;
4553    let octets = fs::read(path).await
4554               .map_err(|_| DashMpdError::Other(String::from("reading from file:// URL")))?;
4555    Ok(Bytes::from(octets))
4556}
4557
4558
4559// Decode and return a manifest encoded as a data URL.
4560async fn fetch_mpd_data_url(downloader: &mut DashDownloader) -> Result<Bytes, DashMpdError> {
4561    if !&downloader.mpd_url.starts_with("data:") {
4562        return Err(DashMpdError::Other(String::from("expecting data: URL scheme")));
4563    }
4564    let du = DataUrl::process(&downloader.mpd_url)
4565        .map_err(|_| DashMpdError::Parsing(String::from("parsing mpd data URL")))?;
4566    let (body, _fragment) = du.decode_to_vec()
4567        .map_err(|_| DashMpdError::Parsing(String::from("decoding mpd data URL")))?;
4568    Ok(Bytes::from(body))
4569}
4570
4571
4572#[tracing::instrument(level="trace", skip_all)]
4573async fn fetch_mpd(downloader: &mut DashDownloader) -> Result<PathBuf, DashMpdError> {
4574    #[cfg(all(feature = "sandbox", target_os = "linux"))]
4575    if downloader.sandbox {
4576        if let Err(e) = restrict_thread(downloader) {
4577            warn!("Sandboxing failed: {e:?}");
4578        }
4579    }
4580    let xml = if downloader.mpd_url.starts_with("file://") {
4581        fetch_mpd_file_url(downloader).await?
4582    } else if downloader.mpd_url.starts_with("data:") {
4583        fetch_mpd_data_url(downloader).await?
4584    } else {
4585        fetch_mpd_http(downloader).await?
4586    };
4587    let mut mpd: MPD = parse_resolving_xlinks(downloader, &xml).await
4588        .map_err(|e| parse_error("parsing DASH XML", e))?;
4589    // From the DASH specification: "If at least one MPD.Location element is present, the value of
4590    // any MPD.Location element is used as the MPD request". We make a new request to the URI and reparse.
4591    let client = &downloader.http_client.clone().unwrap();
4592    if let Some(new_location) = &mpd.locations.first() {
4593        let new_url = &new_location.url;
4594        if downloader.verbosity > 0 {
4595            info!("Redirecting to new manifest <Location> {new_url}");
4596        }
4597        let send_request = || async {
4598            let mut req = client.get(new_url)
4599                .header("Accept", "application/dash+xml,video/vnd.mpeg.dash.mpd")
4600                .header("Accept-Language", "en-US,en")
4601                .header("Sec-Fetch-Mode", "navigate");
4602            if let Some(referer) = &downloader.referer {
4603                req = req.header("Referer", referer);
4604            } else {
4605                req = req.header("Referer", downloader.redirected_url.to_string());
4606            }
4607            if let Some(username) = &downloader.auth_username {
4608                if let Some(password) = &downloader.auth_password {
4609                    req = req.basic_auth(username, Some(password));
4610                }
4611            }
4612            if let Some(token) = &downloader.auth_bearer_token {
4613                req = req.bearer_auth(token);
4614            }
4615            req.send().await?
4616                .error_for_status()
4617        };
4618        let response = send_request
4619            .retry(ExponentialBuilder::default())
4620            .when(reqwest_error_transient_p)
4621            .notify(notify_transient)
4622            .await
4623            .map_err(|e| network_error("requesting relocated DASH manifest", &e))?;
4624        if !response.status().is_success() {
4625            let msg = format!("fetching DASH manifest (HTTP {})", response.status().as_str());
4626            return Err(DashMpdError::Network(msg));
4627        }
4628        downloader.redirected_url = response.url().clone();
4629        let xml = response.bytes().await
4630            .map_err(|e| network_error("fetching relocated DASH manifest", &e))?;
4631        mpd = parse_resolving_xlinks(downloader, &xml).await
4632            .map_err(|e| parse_error("parsing relocated DASH XML", e))?;
4633    }
4634    if mpd_is_dynamic(&mpd) {
4635        // TODO: look at algorithm used in function segment_numbers at
4636        // https://github.com/streamlink/streamlink/blob/master/src/streamlink/stream/dash_manifest.py
4637        if downloader.allow_live_streams {
4638            if downloader.verbosity > 0 {
4639                warn!("Attempting to download from live stream (this may not work).");
4640            }
4641        } else {
4642            return Err(DashMpdError::UnhandledMediaStream("Don't know how to download dynamic MPD".to_string()));
4643        }
4644    }
4645    let mut toplevel_base_url = downloader.redirected_url.clone();
4646    // There may be several BaseURL tags in the MPD, but we don't currently implement failover
4647    if let Some(bu) = &mpd.base_url.first() {
4648        toplevel_base_url = merge_baseurls(&downloader.redirected_url, &bu.base)?;
4649    }
4650    // A BaseURL specified explicitly when instantiating the DashDownloader overrides the BaseURL
4651    // specified in the manifest.
4652    if let Some(base) = &downloader.base_url {
4653        toplevel_base_url = merge_baseurls(&downloader.redirected_url, base)?;
4654    }
4655    if downloader.verbosity > 0 {
4656        let pcount = mpd.periods.len();
4657        info!("DASH manifest has {pcount} period{}", if pcount > 1 { "s" }  else { "" });
4658        print_available_streams(&mpd);
4659    }
4660    // Analyse the content of each Period in the manifest. We need to ensure that we associate media
4661    // segments with the correct period, because segments in each Period may use different codecs,
4662    // so they can't be concatenated together directly without reencoding. The main purpose for this
4663    // iteration of Periods (which is then followed by an iteration over Periods where we retrieve
4664    // the media segments and concatenate them) is to obtain a count of the total number of media
4665    // fragments that we are going to retrieve, so that the ProgressBar shows information relevant
4666    // to the total download (we don't want a per-Period ProgressBar).
4667    let mut pds: Vec<PeriodDownloads> = Vec::new();
4668    let mut period_counter = 0;
4669    for mpd_period in &mpd.periods {
4670        let period = mpd_period.clone();
4671        period_counter += 1;
4672        if let Some(min) = downloader.minimum_period_duration {
4673            if let Some(duration) = period.duration {
4674                if duration < min {
4675                    if let Some(id) = period.id.as_ref() {
4676                        info!("Skipping period {id} (#{period_counter}): duration is less than requested minimum");
4677                    } else {
4678                        info!("Skipping period #{period_counter}: duration is less than requested minimum");
4679                    }
4680                    continue;
4681                }
4682            }
4683        }
4684        let mut pd = PeriodDownloads { period_counter, ..Default::default() };
4685        if let Some(id) = period.id.as_ref() {
4686            pd.id = Some(id.clone());
4687        }
4688        if downloader.verbosity > 0 && !downloader.fetch_audio && !downloader.fetch_video && !downloader.fetch_subtitles {
4689            if let Some(id) = period.id.as_ref() {
4690                info!("Preparing download for period {id} (#{period_counter})");
4691            } else {
4692                info!("Preparing download for period #{period_counter}");
4693            }
4694        }
4695        let mut base_url = toplevel_base_url.clone();
4696        // A BaseURL could be specified for each Period
4697        if let Some(bu) = period.BaseURL.first() {
4698            base_url = merge_baseurls(&base_url, &bu.base)?;
4699        }
4700        let mut audio_outputs = PeriodOutputs::default();
4701        if downloader.fetch_audio {
4702            audio_outputs = do_period_audio(downloader, &mpd, &period, period_counter, base_url.clone()).await?;
4703            for f in audio_outputs.fragments {
4704                pd.audio_fragments.push(f);
4705            }
4706            pd.selected_audio_language = audio_outputs.selected_audio_language;
4707        }
4708        let mut video_outputs = PeriodOutputs::default();
4709        if downloader.fetch_video {
4710            video_outputs = do_period_video(downloader, &mpd, &period, period_counter, base_url.clone()).await?;
4711            for f in video_outputs.fragments {
4712                pd.video_fragments.push(f);
4713            }
4714        }
4715        match do_period_subtitles(downloader, &mpd, &period, period_counter, base_url.clone()).await {
4716            Ok(subtitle_outputs) => {
4717                for f in subtitle_outputs.fragments {
4718                    pd.subtitle_fragments.push(f);
4719                }
4720                for f in subtitle_outputs.subtitle_formats {
4721                    pd.subtitle_formats.push(f);
4722                }
4723                pd.selected_subtitle_language = subtitle_outputs.selected_subtitle_language;
4724            },
4725            Err(e) => warn!("  Ignoring error triggered while processing subtitles: {e}"),
4726        }
4727        // Print some diagnostics information on the selected streams
4728        if downloader.verbosity > 0 {
4729            use base64::prelude::{Engine as _, BASE64_STANDARD};
4730
4731            audio_outputs.diagnostics.iter().for_each(|msg| info!("{}", msg));
4732            for f in pd.audio_fragments.iter().filter(|f| f.is_init) {
4733                if let Some(pssh_bytes) = extract_init_pssh(downloader, f.url.clone()).await {
4734                    info!("    PSSH (from init segment): {}", BASE64_STANDARD.encode(&pssh_bytes));
4735                    if let Ok(pssh) = pssh_box::from_bytes(&pssh_bytes) {
4736                        info!("    {}", pssh.to_string());
4737                    }
4738                }
4739            }
4740            video_outputs.diagnostics.iter().for_each(|msg| info!("{}", msg));
4741            for f in pd.video_fragments.iter().filter(|f| f.is_init) {
4742                if let Some(pssh_bytes) = extract_init_pssh(downloader, f.url.clone()).await {
4743                    info!("    PSSH (from init segment): {}", BASE64_STANDARD.encode(&pssh_bytes));
4744                    if let Ok(pssh) = pssh_box::from_bytes(&pssh_bytes) {
4745                        info!("    {}", pssh.to_string());
4746                    }
4747                }
4748            }
4749        }
4750        pds.push(pd);
4751    } // loop over Periods
4752
4753    // To collect the muxed audio and video segments for each Period in the MPD, before their
4754    // final concatenation-with-reencoding.
4755    let output_path = &downloader.output_path.as_ref()
4756        .ok_or_else(|| DashMpdError::Other(String::from("no output_path set")))?
4757        .clone();
4758    let mut period_output_pathbufs: Vec<PathBuf> = Vec::new();
4759    let mut ds = DownloadState {
4760        period_counter: 0,
4761        // The additional +2 is for our initial .mpd fetch action and final muxing action
4762        segment_count: pds.iter().map(period_fragment_count).sum(),
4763        segment_counter: 0,
4764        download_errors: 0
4765    };
4766    for pd in pds {
4767        let mut have_audio = false;
4768        let mut have_video = false;
4769        let mut have_subtitles = false;
4770        ds.period_counter = pd.period_counter;
4771        let period_output_path = output_path_for_period(output_path, pd.period_counter);
4772        #[allow(clippy::collapsible_if)]
4773        if downloader.verbosity > 0 {
4774            if downloader.fetch_audio || downloader.fetch_video || downloader.fetch_subtitles {
4775                let idnum = if let Some(id) = pd.id {
4776                    format!("id={} (#{})", id, pd.period_counter)
4777                } else {
4778                    format!("#{}", pd.period_counter)
4779                };
4780                info!("Period {idnum}: fetching {} audio, {} video and {} subtitle segments",
4781                      pd.audio_fragments.len(),
4782                      pd.video_fragments.len(),
4783                      pd.subtitle_fragments.len());
4784            }
4785        }
4786        let output_ext = downloader.output_path.as_ref()
4787            .ok_or_else(|| DashMpdError::Other(String::from("no output_path set")))?
4788            .extension()
4789            .unwrap_or(OsStr::new("mp4"));
4790        let tmppath_audio = if let Some(ref path) = downloader.keep_audio {
4791            path.clone()
4792        } else {
4793            tmp_file_path("dashmpd-audio", output_ext)?
4794        };
4795        let tmppath_video = if let Some(ref path) = downloader.keep_video {
4796            path.clone()
4797        } else {
4798            tmp_file_path("dashmpd-video", output_ext)?
4799        };
4800        let tmppath_subs = tmp_file_path("dashmpd-subs", OsStr::new("sub"))?;
4801        if downloader.fetch_audio && !pd.audio_fragments.is_empty() {
4802            // TODO: to allow the download of multiple audio tracks (with multiple languages), we
4803            // need to call fetch_period_audio multiple times with a different file path each time,
4804            // and with the audio_fragments only relevant for that language.
4805            have_audio = fetch_period_audio(downloader,
4806                                            &tmppath_audio, &pd.audio_fragments,
4807                                            &mut ds).await?;
4808        }
4809        if downloader.fetch_video && !pd.video_fragments.is_empty() {
4810            have_video = fetch_period_video(downloader,
4811                                            &tmppath_video, &pd.video_fragments,
4812                                            &mut ds).await?;
4813        }
4814        // Here we handle subtitles that are distributed in fragmented MP4 segments, rather than as a
4815        // single .srt or .vtt file file. This is the case for WVTT (WebVTT) and STPP (which should be
4816        // formatted as EBU-TT for DASH media) formats.
4817        if downloader.fetch_subtitles && !pd.subtitle_fragments.is_empty() {
4818            have_subtitles = fetch_period_subtitles(downloader,
4819                                                    &tmppath_subs,
4820                                                    &pd.subtitle_fragments,
4821                                                    &pd.subtitle_formats,
4822                                                    &mut ds).await?;
4823        }
4824
4825        // The output file for this Period is either a mux of the audio and video streams, if both
4826        // are present, or just the audio stream, or just the video stream.
4827        if have_audio && have_video {
4828            for observer in &downloader.progress_observers {
4829                observer.update(99, 1, "Muxing audio and video");
4830            }
4831            if downloader.verbosity > 1 {
4832                info!("  Muxing audio and video streams");
4833            }
4834            let audio_tracks = vec![
4835                AudioTrack {
4836                    language: pd.selected_audio_language,
4837                    path: tmppath_audio.clone()
4838                }];
4839            mux_audio_video(downloader, &period_output_path, &audio_tracks, &tmppath_video).await?;
4840            if pd.subtitle_formats.contains(&SubtitleType::Stpp) {
4841                let container = match &period_output_path.extension() {
4842                    Some(ext) => ext.to_str().unwrap_or("mp4"),
4843                    None => "mp4",
4844                };
4845                if container.eq("mp4") {
4846                    if downloader.verbosity > 1 {
4847                        if let Some(fmt) = &pd.subtitle_formats.first() {
4848                            info!("  Downloaded media contains subtitles in {fmt:?} format");
4849                        }
4850                        info!("  Running MP4Box to merge subtitles with output MP4 container");
4851                    }
4852                    // We can try to add the subtitles to the MP4 container, using MP4Box. Only
4853                    // works with MP4 containers.
4854                    let tmp_str = tmppath_subs.to_string_lossy();
4855                    let period_output_str = period_output_path.to_string_lossy();
4856                    let subtitle_lang = format!("3={}", pd.selected_subtitle_language);
4857                    let verbosity = match downloader.verbosity {
4858                        0 => "all@error",
4859                        1 => "all@warning",
4860                        2 => "all@info",
4861                        _ => "all@debug",
4862                    };
4863                    let args = vec![
4864                        "-logs", verbosity,
4865                        "-noprog",
4866                        "-lang", &subtitle_lang,
4867                        "-add", &tmp_str,
4868                        &period_output_str];
4869                    if downloader.verbosity > 0 {
4870                        info!("  Running MP4Box {}", args.join(" "));
4871                    }
4872                    if let Ok(mp4box) = Command::new(downloader.mp4box_location.clone())
4873                        .args(args)
4874                        .output()
4875                    {
4876                        let msg = partial_process_output(&mp4box.stdout);
4877                        if !msg.is_empty() {
4878                            info!("  MP4Box stdout: {msg}");
4879                        }
4880                        let msg = partial_process_output(&mp4box.stderr);
4881                        if !msg.is_empty() {
4882                            info!("  MP4Box stderr: {msg}");
4883                        }
4884                        if mp4box.status.success() {
4885                            info!("  Merged subtitles with MP4 container");
4886                        } else {
4887                            warn!("  Error running MP4Box to merge subtitles");
4888                        }
4889                    } else {
4890                        warn!("  Failed to spawn MP4Box to merge subtitles");
4891                    }
4892                } else if container.eq("mkv") || container.eq("webm") {
4893                    // Try using mkvmerge to add a subtitle track. mkvmerge does not seem to be able
4894                    // to merge STPP subtitles, but can merge SRT if we have managed to convert
4895                    // them.
4896                    //
4897                    // We mkvmerge to a temporary output file, and if the command succeeds we copy
4898                    // that to the original output path. Note that mkvmerge on Windows is compiled
4899                    // using MinGW and isn't able to handle native pathnames (for instance files
4900                    // created with tempfile::Builder), so we use temporary_outpath() which will create a
4901                    // temporary file in the current directory on Windows.
4902                    //
4903                    //    mkvmerge -o output.mkv input.mkv subs.srt
4904                    let srt = period_output_path.with_extension("srt");
4905                    if srt.exists() {
4906                        if downloader.verbosity > 0 {
4907                            info!("  Running mkvmerge to merge subtitles with output Matroska container");
4908                        }
4909                        let tmppath = temporary_outpath(".mkv")?;
4910                        let pop_arg = &period_output_path.to_string_lossy();
4911                        let srt_arg = &srt.to_string_lossy();
4912                        let mkvmerge_args = vec!["-o", &tmppath, pop_arg, srt_arg];
4913                        if downloader.verbosity > 0 {
4914                            info!("  Running mkvmerge {}", mkvmerge_args.join(" "));
4915                        }
4916                        if let Ok(mkvmerge) = Command::new(downloader.mkvmerge_location.clone())
4917                            .args(mkvmerge_args)
4918                            .output()
4919                        {
4920                            let msg = partial_process_output(&mkvmerge.stdout);
4921                            if !msg.is_empty() {
4922                                info!("  mkvmerge stdout: {msg}");
4923                            }
4924                            let msg = partial_process_output(&mkvmerge.stderr);
4925                            if !msg.is_empty() {
4926                                info!("  mkvmerge stderr: {msg}");
4927                            }
4928                            if mkvmerge.status.success() {
4929                                info!("  Merged subtitles with Matroska container");
4930                                // Copy the output file from mkvmerge to the period_output_path
4931                                // local scope so that tmppath is not busy on Windows and can be deleted
4932                                {
4933                                    let tmpfile = File::open(tmppath.clone()).await
4934                                        .map_err(|e| DashMpdError::Io(
4935                                            e, String::from("opening mkvmerge output")))?;
4936                                    let mut merged = BufReader::new(tmpfile);
4937                                    // This will truncate the period_output_path
4938                                    let outfile = File::create(period_output_path.clone()).await
4939                                        .map_err(|e| DashMpdError::Io(
4940                                            e, String::from("creating output file")))?;
4941                                    let mut sink = BufWriter::new(outfile);
4942                                    io::copy(&mut merged, &mut sink).await
4943                                        .map_err(|e| DashMpdError::Io(
4944                                            e, String::from("copying mkvmerge output to output file")))?;
4945                                }
4946                                if env::var("DASHMPD_PERSIST_FILES").is_err() {
4947	                            if let Err(e) = fs::remove_file(tmppath).await {
4948                                        warn!("  Error deleting temporary mkvmerge output: {e}");
4949                                    }
4950                                }
4951                            } else {
4952                                warn!("  Error running mkvmerge to merge subtitles");
4953                            }
4954                        }
4955                    }
4956                }
4957            }
4958        } else if have_audio {
4959            copy_audio_to_container(downloader, &period_output_path, &tmppath_audio).await?;
4960        } else if have_video {
4961            copy_video_to_container(downloader, &period_output_path, &tmppath_video).await?;
4962        } else if downloader.fetch_video && downloader.fetch_audio {
4963            return Err(DashMpdError::UnhandledMediaStream("no audio or video streams found".to_string()));
4964        } else if downloader.fetch_video {
4965            return Err(DashMpdError::UnhandledMediaStream("no video streams found".to_string()));
4966        } else if downloader.fetch_audio {
4967            return Err(DashMpdError::UnhandledMediaStream("no audio streams found".to_string()));
4968        }
4969        #[allow(clippy::collapsible_if)]
4970        if downloader.keep_audio.is_none() && downloader.fetch_audio {
4971            if env::var("DASHMPD_PERSIST_FILES").is_err() {
4972                if tmppath_audio.exists() && fs::remove_file(tmppath_audio).await.is_err() {
4973                    info!("  Failed to delete temporary file for audio stream");
4974                }
4975            }
4976        }
4977        #[allow(clippy::collapsible_if)]
4978        if downloader.keep_video.is_none() && downloader.fetch_video {
4979            if env::var("DASHMPD_PERSIST_FILES").is_err() {
4980                if tmppath_video.exists() && fs::remove_file(tmppath_video).await.is_err() {
4981                    info!("  Failed to delete temporary file for video stream");
4982                }
4983            }
4984        }
4985        #[allow(clippy::collapsible_if)]
4986        if env::var("DASHMPD_PERSIST_FILES").is_err() {
4987            if downloader.fetch_subtitles && tmppath_subs.exists() &&
4988                fs::remove_file(tmppath_subs).await.is_err() {
4989                info!("  Failed to delete temporary file for subtitles");
4990            }
4991        }
4992        if downloader.verbosity > 1 && (downloader.fetch_audio || downloader.fetch_video || have_subtitles) {
4993            if let Ok(metadata) = fs::metadata(&period_output_path).await {
4994                info!("  Wrote {:.1}MB to media file", metadata.len() as f64 / (1024.0 * 1024.0));
4995            }
4996        }
4997        if have_audio || have_video {
4998            period_output_pathbufs.push(period_output_path);
4999        }
5000    } // Period iterator
5001    let period_output_paths: Vec<&Path> = period_output_pathbufs
5002        .iter()
5003        .map(PathBuf::as_path)
5004        .collect();
5005    #[allow(clippy::comparison_chain)]
5006    if period_output_paths.len() == 1 {
5007        // We already arranged to write directly to the requested output_path.
5008        maybe_record_metainformation(output_path, downloader, &mpd);
5009    } else if period_output_paths.len() > 1 {
5010        // If the streams for the different periods are all of the same resolution, we can
5011        // concatenate them (with reencoding) into a single media file. Otherwise, we can't
5012        // concatenate without rescaling and loss of quality, so we leave them in separate files.
5013        // This feature isn't implemented using libav instead of ffmpeg as a subprocess.
5014        #[allow(unused_mut)]
5015        let mut concatenated = false;
5016        #[cfg(not(feature = "libav"))]
5017        // if downloader.concatenate_periods && video_containers_concatable(downloader, &period_output_paths) {
5018        if downloader.concatenate_periods && video_containers_concatable(downloader, &period_output_paths) {
5019            info!("Preparing to concatenate multiple Periods into one output file");
5020            concat_output_files(downloader, &period_output_paths).await?;
5021            for p in &period_output_paths[1..] {
5022                if fs::remove_file(p).await.is_err() {
5023                    warn!("  Failed to delete temporary file {}", p.display());
5024                }
5025            }
5026            concatenated = true;
5027            if let Some(pop) = period_output_paths.first() {
5028                maybe_record_metainformation(pop, downloader, &mpd);
5029            }
5030        }
5031        if !concatenated {
5032            info!("Media content has been saved in a separate file for each period:");
5033            // FIXME this is not the original period number if we have dropped periods
5034            period_counter = 0;
5035            for p in period_output_paths {
5036                period_counter += 1;
5037                info!("  Period #{period_counter}: {}", p.display());
5038                maybe_record_metainformation(p, downloader, &mpd);
5039            }
5040        }
5041    }
5042    let have_content_protection = mpd.periods.iter().any(
5043        |p| p.adaptations.iter().any(
5044            |a| (!a.ContentProtection.is_empty()) ||
5045                a.representations.iter().any(
5046                    |r| !r.ContentProtection.is_empty())));
5047    if have_content_protection && downloader.decryption_keys.is_empty() {
5048        warn!("Manifest seems to use ContentProtection (DRM), but you didn't provide decryption keys.");
5049    }
5050    for observer in &downloader.progress_observers {
5051        observer.update(100, 1, "Done");
5052    }
5053    Ok(PathBuf::from(output_path))
5054}
5055
5056
5057#[cfg(test)]
5058mod tests {
5059    #[test]
5060    fn test_resolve_url_template() {
5061        use std::collections::HashMap;
5062        use super::resolve_url_template;
5063
5064        assert_eq!(resolve_url_template("AA$Time$BB", &HashMap::from([("Time", "ZZZ".to_string())])),
5065                   "AAZZZBB");
5066        assert_eq!(resolve_url_template("AA$Number%06d$BB", &HashMap::from([("Number", "42".to_string())])),
5067                   "AA000042BB");
5068        let dict = HashMap::from([("RepresentationID", "640x480".to_string()),
5069                                  ("Number", "42".to_string()),
5070                                  ("Time", "ZZZ".to_string())]);
5071        assert_eq!(resolve_url_template("AA/$RepresentationID$/segment-$Number%05d$.mp4", &dict),
5072                   "AA/640x480/segment-00042.mp4");
5073    }
5074}