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