diff --git a/unshackle/core/manifests/dash.py b/unshackle/core/manifests/dash.py index f41302a3..9610b9c9 100644 --- a/unshackle/core/manifests/dash.py +++ b/unshackle/core/manifests/dash.py @@ -349,20 +349,16 @@ class DASH: if period_count > 1: log.debug(f"Multi-period manifest detected with {period_count} content periods") + stored_period: Optional[Element] = track.data["dash"].get("period") + for period_idx, content_period in enumerate(content_periods): - # Find the matching representation in this period - matched_rep = None - matched_as = None - for as_ in content_period.findall("AdaptationSet"): - if DASH.is_trick_mode(as_): - continue - for rep in as_.findall("Representation"): - if rep.get("id") == rep_id: - matched_rep = rep - matched_as = as_ - break - if matched_rep is not None: - break + if content_period is stored_period: + matched_as, matched_rep = adaptation_set, representation + else: + matched_as, matched_rep = DASH.resolve_representation(content_period, rep_id, adaptation_set) or ( + None, + None, + ) if matched_rep is None or matched_as is None: period_id = content_period.get("id", period_idx) @@ -934,6 +930,54 @@ class DASH: for prop in essential_props + supplemental_props ) + @staticmethod + def adaptation_set_key(adaptation_set: Element) -> tuple[Optional[str], ...]: + """Identity of an AdaptationSet, used to re-find its counterpart in another Period.""" + return tuple( + adaptation_set.get(attr) + for attr in ("contentType", "mimeType", "lang", "audioTrackId", "audioTrackSubtype", "id") + ) + + @staticmethod + def resolve_representation( + period: Element, rep_id: Optional[str], adaptation_set: Element + ) -> Optional[tuple[Element, Element]]: + """ + Find the (AdaptationSet, Representation) in another Period matching rep_id. + + Representation ids are only unique within an AdaptationSet, so the set matching + `adaptation_set`'s identity wins over an earlier sibling that reuses the same id + for different content (e.g. audio description vs dialog). + """ + as_key = DASH.adaptation_set_key(adaptation_set) + + def candidates(within: Element) -> list[tuple[Element, Element]]: + found = [] + for as_ in within.findall("AdaptationSet"): + if DASH.is_trick_mode(as_) or DASH.adaptation_set_key(as_) != as_key: + continue + for rep in as_.findall("Representation"): + if rep.get("id") == rep_id: + found.append((as_, rep)) + break + return found + + matches = candidates(period) + if matches: + parent = adaptation_set.getparent() + siblings = [as_ for as_, _ in candidates(parent)] if parent is not None else [] + ordinal = siblings.index(adaptation_set) if adaptation_set in siblings else 0 + return matches[min(ordinal, len(matches) - 1)] + + for as_ in period.findall("AdaptationSet"): + if DASH.is_trick_mode(as_): + continue + for rep in as_.findall("Representation"): + if rep.get("id") == rep_id: + return as_, rep + + return None + @staticmethod def is_descriptive(adaptation_set: Element) -> bool: """Check if contents of Adaptation Set is Descriptive.""" diff --git a/unshackle/core/utils/bitrate.py b/unshackle/core/utils/bitrate.py index 63ff44cb..17f5b441 100644 --- a/unshackle/core/utils/bitrate.py +++ b/unshackle/core/utils/bitrate.py @@ -278,24 +278,22 @@ def extract_dash(track: "Track", session: Union[Session, RnetSession]) -> list[S data = track.data["dash"] manifest = data["manifest"] - rep_id = data.get("representation_id") or data["representation"].get("id") + adaptation_set = data["adaptation_set"] + representation = data["representation"] + rep_id = data.get("representation_id") or representation.get("id") filtered_period_ids = data.get("filtered_period_ids", []) + stored_period = data.get("period") track_url = track.url if isinstance(track.url, str) else track.url[0] content_periods = [p for p in manifest.findall("Period") if DASH._is_content_period(p, filtered_period_ids)] raw_segments: list[tuple[str, Optional[str]]] = [] for period in content_periods: - matched_rep = matched_as = None - for as_ in period.findall("AdaptationSet"): - if DASH.is_trick_mode(as_): - continue - for rep in as_.findall("Representation"): - if rep.get("id") == rep_id: - matched_rep, matched_as = rep, as_ - break - if matched_rep is not None: - break + # rep ids are only unique within an AdaptationSet, so resolve like DASH.download_track + if period is stored_period: + matched_as, matched_rep = adaptation_set, representation + else: + matched_as, matched_rep = DASH.resolve_representation(period, rep_id, adaptation_set) or (None, None) if matched_rep is None or matched_as is None: continue