dash_mpd/
fetch.rs

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