Skip to main content

dash_mpd/
fetch.rs

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