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