dash_mpd/
fetch.rs

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