Skip to main content

dash_runtime/
selection.rs

1//! Pure selection of AdaptationSets and Representations
2//! (`docs/dash-runtime/DESIGN.md` 5.1), by role, language, codec and
3//! bandwidth, into the variant model a host speaks.
4//!
5//! 1. Candidate sets: audio and video ISOBMFF sets that survive the drops of
6//!    DESIGN D10 (text, MPEG-2 TS and WebM segments, trick modes, protected
7//!    content, an `EssentialProperty` this core does not understand, a
8//!    Representation that depends on another, unresolved xlink, invalid
9//!    segment information).
10//! 2. Video: the first remaining set with Role `main` (else document order);
11//!    the Representation is the highest `@bandwidth` the policy allows, else
12//!    the lowest. `variants` lists every Representation of every selectable
13//!    video set in MPD order.
14//! 3. Audio: the first set whose `@lang` matches the preferred language by
15//!    BCP 47 prefix and whose Role is not `commentary`/`description`; else
16//!    Role `main`; else the first. DASH has no DEFAULT flag, Role `main`
17//!    plays that part (5.8.5.5 Table 34).
18
19use broadcast_common::impl_spec_display;
20
21use crate::address::template;
22use crate::mpd::descriptors::{self, Role, essential_understood};
23use crate::mpd::inherit::{SegmentInfo, effective_common, resolve_segment_info};
24use crate::mpd::model::{AdaptationSet, Mpd, Period, Representation};
25
26const CODEC_SEPARATOR: char = ',';
27const LANG_SUBTAG_SEPARATOR: char = '-';
28const MIME_VIDEO: &str = "video/";
29const MIME_AUDIO: &str = "audio/";
30const MIME_TEXT: &str = "text/";
31const MIME_APPLICATION: &str = "application/";
32const MIME_MP2T_SUFFIX: &str = "/mp2t";
33const MIME_WEBM_SUFFIX: &str = "/webm";
34const CONTENT_VIDEO: &str = "video";
35const CONTENT_AUDIO: &str = "audio";
36const CONTENT_TEXT: &str = "text";
37const CODEC_STPP: &str = "stpp";
38const CODEC_WVTT: &str = "wvtt";
39
40/// What a set carries.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
42#[non_exhaustive]
43pub enum SetClass {
44    /// Video.
45    Video,
46    /// Audio.
47    Audio,
48    /// Subtitles or captions.
49    Text,
50    /// Anything else.
51    Other,
52}
53
54impl SetClass {
55    /// Stable snake_case label.
56    #[must_use]
57    pub fn name(&self) -> &'static str {
58        match self {
59            Self::Video => "video",
60            Self::Audio => "audio",
61            Self::Text => "text",
62            Self::Other => "other",
63        }
64    }
65}
66impl_spec_display!(SetClass);
67
68/// Why a set or Representation was not selectable.
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70#[non_exhaustive]
71pub enum DropReason {
72    /// Subtitles/captions (out of scope).
73    Text,
74    /// Neither audio nor video.
75    NotMedia,
76    /// MPEG-2 TS segments.
77    Mp2tSegments,
78    /// WebM segments.
79    WebM,
80    /// A trick-mode set.
81    TrickMode,
82    /// Protected, and an unprotected alternative exists in the same group.
83    ProtectedAlternativeExists,
84    /// Protected with no unprotected alternative.
85    UnsupportedEncryption,
86    /// An `EssentialProperty` this core does not understand (5.8.4.8).
87    UnknownEssentialProperty,
88    /// The Representation needs another one to be decoded.
89    DependencyRepresentation,
90    /// An unresolved xlink.
91    UnresolvedXlink,
92    /// Segment information violating 5.3.9.1 / 5.3.9.4.4 or out of scope.
93    InvalidSegmentInfo,
94}
95
96impl DropReason {
97    /// Stable snake_case label.
98    #[must_use]
99    pub fn name(&self) -> &'static str {
100        match self {
101            Self::Text => "text",
102            Self::NotMedia => "not_media",
103            Self::Mp2tSegments => "mp2t_segments",
104            Self::WebM => "webm",
105            Self::TrickMode => "trick_mode",
106            Self::ProtectedAlternativeExists => "protected_alternative_exists",
107            Self::UnsupportedEncryption => "unsupported_encryption",
108            Self::UnknownEssentialProperty => "unknown_essential_property",
109            Self::DependencyRepresentation => "dependency_representation",
110            Self::UnresolvedXlink => "unresolved_xlink",
111            Self::InvalidSegmentInfo => "invalid_segment_info",
112        }
113    }
114}
115impl_spec_display!(DropReason);
116
117/// A Representation's position in the MPD.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
119#[non_exhaustive]
120pub struct RepRef {
121    /// Period index.
122    pub period: usize,
123    /// AdaptationSet index in the Period.
124    pub set: usize,
125    /// Representation index in the set.
126    pub rep: usize,
127}
128
129impl RepRef {
130    /// A reference to Period `period`, AdaptationSet `set`, Representation
131    /// `rep`.
132    #[must_use]
133    pub fn at(period: usize, set: usize, rep: usize) -> Self {
134        Self { period, set, rep }
135    }
136
137    /// The three elements this reference names.
138    #[must_use]
139    pub fn resolve<'a>(
140        &self,
141        mpd: &'a Mpd,
142    ) -> Option<(&'a Period, &'a AdaptationSet, &'a Representation)> {
143        let period = mpd.periods.get(self.period)?;
144        let set = period.adaptation_sets.get(self.set)?;
145        Some((period, set, set.representations.get(self.rep)?))
146    }
147}
148
149/// One drop, with its reason.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151#[non_exhaustive]
152pub struct Dropped {
153    /// AdaptationSet index.
154    pub set: usize,
155    /// Representation index when only one Representation was dropped.
156    pub rep: Option<usize>,
157    /// Why.
158    pub reason: DropReason,
159}
160
161/// Selection policy; the same three limits as `hls_runtime::VariantPolicy`
162/// plus the audio language.
163#[derive(Debug, Clone, PartialEq, Eq, Default)]
164#[non_exhaustive]
165pub struct Policy {
166    /// Skip Representations above this bandwidth.
167    pub max_bandwidth: Option<u64>,
168    /// Skip Representations above this height.
169    pub max_height: Option<u32>,
170    /// When non-empty, a Representation must carry a codec starting with one
171    /// of these prefixes.
172    pub codec_prefixes: Vec<String>,
173    /// Preferred audio language (BCP 47).
174    pub preferred_audio_language: Option<String>,
175}
176
177/// An audio AdaptationSet and its Representations.
178#[derive(Debug, Clone, PartialEq, Eq)]
179#[non_exhaustive]
180pub struct AudioSet {
181    /// AdaptationSet index.
182    pub set: usize,
183    /// Selectable Representations.
184    pub reps: Vec<RepRef>,
185    /// Role `main` present.
186    pub is_main: bool,
187}
188
189/// The result of [`select`] for one Period.
190#[derive(Debug, Clone, PartialEq, Eq, Default)]
191#[non_exhaustive]
192pub struct Selection {
193    /// Every selectable video Representation, MPD order.
194    pub variants: Vec<RepRef>,
195    /// Index into `variants` of the chosen one.
196    pub chosen_variant: Option<usize>,
197    /// Selectable audio sets.
198    pub audio_sets: Vec<AudioSet>,
199    /// The chosen audio Representation.
200    pub chosen_audio: Option<RepRef>,
201    /// Everything dropped, with reasons.
202    pub dropped: Vec<Dropped>,
203}
204
205fn contains_ci(haystack: &str, needle: &str) -> bool {
206    haystack
207        .to_ascii_lowercase()
208        .contains(&needle.to_ascii_lowercase())
209}
210
211/// Classify a set from `@contentType`, then the MIME types, then codecs.
212#[must_use]
213pub fn classify(set: &AdaptationSet) -> SetClass {
214    let mime = set
215        .common
216        .mime_type
217        .as_deref()
218        .or_else(|| {
219            set.representations
220                .iter()
221                .find_map(|r| r.common.mime_type.as_deref())
222        })
223        .unwrap_or_default();
224    let codecs = set
225        .common
226        .codecs
227        .as_deref()
228        .or_else(|| {
229            set.representations
230                .iter()
231                .find_map(|r| r.common.codecs.as_deref())
232        })
233        .unwrap_or_default();
234    match set.content_type.as_deref() {
235        Some(CONTENT_VIDEO) => return SetClass::Video,
236        Some(CONTENT_AUDIO) => return SetClass::Audio,
237        Some(CONTENT_TEXT) => return SetClass::Text,
238        _ => {}
239    }
240    if mime.starts_with(MIME_VIDEO) {
241        SetClass::Video
242    } else if mime.starts_with(MIME_AUDIO) {
243        SetClass::Audio
244    } else if mime.starts_with(MIME_TEXT)
245        || contains_ci(codecs, CODEC_STPP)
246        || contains_ci(codecs, CODEC_WVTT)
247        || (mime.starts_with(MIME_APPLICATION) && contains_ci(mime, "ttml"))
248    {
249        SetClass::Text
250    } else {
251        SetClass::Other
252    }
253}
254
255fn container_drop(set: &AdaptationSet) -> Option<DropReason> {
256    let mimes = std::iter::once(set.common.mime_type.as_deref())
257        .chain(
258            set.representations
259                .iter()
260                .map(|r| r.common.mime_type.as_deref()),
261        )
262        .flatten();
263    for mime in mimes {
264        if mime.ends_with(MIME_MP2T_SUFFIX) {
265            return Some(DropReason::Mp2tSegments);
266        }
267        if mime.ends_with(MIME_WEBM_SUFFIX) {
268            return Some(DropReason::WebM);
269        }
270    }
271    None
272}
273
274fn has_trick_mode(set: &AdaptationSet) -> bool {
275    set.essential_properties
276        .iter()
277        .chain(&set.supplemental_properties)
278        .any(|d| d.scheme_id_uri == transmux::TRICKMODE_SCHEME)
279}
280
281fn unknown_essential(descs: &[crate::mpd::model::Descriptor]) -> bool {
282    descs.iter().any(|d| !essential_understood(d))
283}
284
285fn segment_info_ok(period: &Period, set: &AdaptationSet, rep: &Representation) -> bool {
286    match resolve_segment_info(period, set, rep) {
287        Ok(SegmentInfo::Template(t)) => {
288            let media_ok = t
289                .media
290                .as_deref()
291                .is_some_and(|m| template::scan_media(m).is_ok_and(|u| !u.sub_number));
292            let init_ok = t
293                .initialization
294                .as_deref()
295                .is_none_or(|i| template::scan_initialization(i).is_ok_and(|u| !u.sub_number));
296            let timeline_ok = t
297                .multi
298                .timeline
299                .as_ref()
300                .is_none_or(|tl| tl.unsupported.is_none());
301            media_ok && init_ok && timeline_ok
302        }
303        Ok(SegmentInfo::List(l)) => l
304            .multi
305            .timeline
306            .as_ref()
307            .is_none_or(|tl| tl.unsupported.is_none()),
308        Ok(SegmentInfo::Base(_)) => true,
309        Err(_) => false,
310    }
311}
312
313/// Whether `preferred` matches `lang` by BCP 47 prefix, ASCII case-insensitive.
314#[must_use]
315pub fn lang_matches(preferred: &str, lang: &str) -> bool {
316    let (p, l) = (preferred.to_ascii_lowercase(), lang.to_ascii_lowercase());
317    l == p
318        || l.strip_prefix(&p)
319            .is_some_and(|rest| rest.starts_with(LANG_SUBTAG_SEPARATOR))
320}
321
322fn codec_allowed(codecs: Option<&str>, prefixes: &[String]) -> bool {
323    if prefixes.is_empty() {
324        return true;
325    }
326    codecs.is_some_and(|c| {
327        c.split(CODEC_SEPARATOR)
328            .map(str::trim)
329            .any(|tok| prefixes.iter().any(|p| tok.starts_with(p.as_str())))
330    })
331}
332
333fn is_protected(set: &AdaptationSet) -> bool {
334    !set.content_protections.is_empty()
335        || (!set.representations.is_empty()
336            && set
337                .representations
338                .iter()
339                .all(|r| !r.content_protections.is_empty()))
340}
341
342fn is_main(set: &AdaptationSet) -> bool {
343    descriptors::roles(&set.roles).any(|r| r == Role::Main)
344}
345
346fn rep_allowed(set: &AdaptationSet, rep: &Representation, policy: &Policy) -> bool {
347    let c = effective_common(set, rep);
348    policy.max_bandwidth.is_none_or(|m| rep.bandwidth <= m)
349        && policy
350            .max_height
351            .is_none_or(|h| c.height.is_none_or(|rh| rh <= h))
352        && codec_allowed(c.codecs.as_deref(), &policy.codec_prefixes)
353}
354
355/// Select for `period_idx` of `mpd`.
356#[must_use]
357pub fn select(mpd: &Mpd, period_idx: usize, policy: &Policy) -> Selection {
358    let mut out = Selection::default();
359    let Some(period) = mpd.periods.get(period_idx) else {
360        return out;
361    };
362    let classes: Vec<SetClass> = period.adaptation_sets.iter().map(classify).collect();
363    let mut survivors: Vec<usize> = Vec::new();
364    for (si, set) in period.adaptation_sets.iter().enumerate() {
365        let class = classes[si];
366        let set_drop = if set.xlink.is_some() {
367            Some(DropReason::UnresolvedXlink)
368        } else if class == SetClass::Text {
369            Some(DropReason::Text)
370        } else if class == SetClass::Other {
371            Some(DropReason::NotMedia)
372        } else if let Some(r) = container_drop(set) {
373            Some(r)
374        } else if has_trick_mode(set) {
375            Some(DropReason::TrickMode)
376        } else if unknown_essential(&set.essential_properties) {
377            Some(DropReason::UnknownEssentialProperty)
378        } else if is_protected(set) {
379            let alternative = period
380                .adaptation_sets
381                .iter()
382                .enumerate()
383                .any(|(oi, other)| {
384                    oi != si
385                        && set.group.is_some()
386                        && other.group == set.group
387                        && classes[oi] == class
388                        && !is_protected(other)
389                });
390            Some(if alternative {
391                DropReason::ProtectedAlternativeExists
392            } else {
393                DropReason::UnsupportedEncryption
394            })
395        } else {
396            None
397        };
398        match set_drop {
399            Some(reason) => out.dropped.push(Dropped {
400                set: si,
401                rep: None,
402                reason,
403            }),
404            None => survivors.push(si),
405        }
406    }
407
408    let mut usable: Vec<(usize, Vec<usize>)> = Vec::new();
409    for &si in &survivors {
410        let set = &period.adaptation_sets[si];
411        let mut reps = Vec::new();
412        for (ri, rep) in set.representations.iter().enumerate() {
413            let reason = if !rep.dependency_ids.is_empty() {
414                Some(DropReason::DependencyRepresentation)
415            } else if unknown_essential(&rep.essential_properties) {
416                Some(DropReason::UnknownEssentialProperty)
417            } else if !rep.content_protections.is_empty() {
418                Some(DropReason::UnsupportedEncryption)
419            } else if !segment_info_ok(period, set, rep) {
420                Some(DropReason::InvalidSegmentInfo)
421            } else {
422                None
423            };
424            match reason {
425                Some(reason) => out.dropped.push(Dropped {
426                    set: si,
427                    rep: Some(ri),
428                    reason,
429                }),
430                None => reps.push(ri),
431            }
432        }
433        if !reps.is_empty() {
434            usable.push((si, reps));
435        }
436    }
437
438    let video_sets: Vec<&(usize, Vec<usize>)> = usable
439        .iter()
440        .filter(|(si, _)| classes[*si] == SetClass::Video)
441        .collect();
442    for (si, reps) in &video_sets {
443        for ri in reps {
444            out.variants.push(RepRef {
445                period: period_idx,
446                set: *si,
447                rep: *ri,
448            });
449        }
450    }
451    let set_has_allowed = |(si, reps): &(usize, Vec<usize>)| -> bool {
452        let set = &period.adaptation_sets[*si];
453        reps.iter()
454            .any(|ri| rep_allowed(set, &set.representations[*ri], policy))
455    };
456    let main_video = video_sets
457        .iter()
458        .find(|(si, _)| is_main(&period.adaptation_sets[*si]));
459    let chosen_video_set = video_sets
460        .iter()
461        .copied()
462        .filter(|x| set_has_allowed(x))
463        .find(|(si, _)| is_main(&period.adaptation_sets[*si]))
464        .or_else(|| video_sets.iter().copied().find(|x| set_has_allowed(x)))
465        .or_else(|| main_video.copied())
466        .or_else(|| video_sets.first().copied());
467    if let Some((si, reps)) = chosen_video_set {
468        let set = &period.adaptation_sets[*si];
469        let pick = reps
470            .iter()
471            .filter(|ri| rep_allowed(set, &set.representations[**ri], policy))
472            .max_by_key(|ri| set.representations[**ri].bandwidth)
473            .or_else(|| {
474                reps.iter()
475                    .min_by_key(|ri| set.representations[**ri].bandwidth)
476            });
477        if let Some(ri) = pick {
478            out.chosen_variant = out
479                .variants
480                .iter()
481                .position(|v| v.set == *si && v.rep == *ri);
482        }
483    }
484
485    for (si, reps) in usable
486        .iter()
487        .filter(|(si, _)| classes[*si] == SetClass::Audio)
488    {
489        let set = &period.adaptation_sets[*si];
490        out.audio_sets.push(AudioSet {
491            set: *si,
492            reps: reps
493                .iter()
494                .map(|ri| RepRef {
495                    period: period_idx,
496                    set: *si,
497                    rep: *ri,
498                })
499                .collect(),
500            is_main: is_main(set),
501        });
502    }
503    let secondary = |set: &AdaptationSet| {
504        descriptors::roles(&set.roles).any(|r| matches!(r, Role::Commentary | Role::Description))
505    };
506    let audio_set_has_allowed = |a: &AudioSet| -> bool {
507        let set = &period.adaptation_sets[a.set];
508        a.reps
509            .iter()
510            .any(|r| rep_allowed(set, &set.representations[r.rep], policy))
511    };
512    let audio_candidates: Vec<&AudioSet> = if out.audio_sets.iter().any(audio_set_has_allowed) {
513        out.audio_sets
514            .iter()
515            .filter(|a| audio_set_has_allowed(a))
516            .collect()
517    } else {
518        out.audio_sets.iter().collect()
519    };
520    let chosen_set = policy
521        .preferred_audio_language
522        .as_deref()
523        .and_then(|pref| {
524            audio_candidates.iter().copied().find(|a| {
525                let s = &period.adaptation_sets[a.set];
526                s.lang.as_deref().is_some_and(|l| lang_matches(pref, l)) && !secondary(s)
527            })
528        })
529        .or_else(|| audio_candidates.iter().copied().find(|a| a.is_main))
530        .or_else(|| audio_candidates.first().copied());
531    if let Some(a) = chosen_set {
532        let set = &period.adaptation_sets[a.set];
533        let best = a
534            .reps
535            .iter()
536            .filter(|r| {
537                let rep = &set.representations[r.rep];
538                codec_allowed(
539                    effective_common(set, rep).codecs.as_deref(),
540                    &policy.codec_prefixes,
541                )
542            })
543            .max_by_key(|r| set.representations[r.rep].bandwidth)
544            .or_else(|| a.reps.first());
545        out.chosen_audio = best.copied();
546    }
547    out
548}