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