Skip to main content

dash_mpd/
fetch.rs

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