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