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