dash_mpd/
fetch.rs

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