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