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