Add discovery

This commit is contained in:
Nirvana
2026-03-05 15:59:09 +01:00
parent f7f90cae22
commit 27e43b26d9
3 changed files with 198 additions and 31 deletions
@@ -68,7 +68,9 @@ class ManifestParser:
Returns:
Full URL to an initialization segment, or None if not found
"""
# Build effective base URL from manifest URL and BaseURL elements
# Build effective base URL from manifest URL and MPD/Period-level BaseURL
# elements only (Representation-level BaseURLs are relative media paths,
# not base URLs, and are intentionally excluded by extract_base_urls).
base_urls = ManifestUtils.extract_base_urls(manifest_content)
effective_base = URLResolver.build_effective_base_url(manifest_url, base_urls)
@@ -82,43 +84,75 @@ class ManifestParser:
target_sets = video_sets + audio_sets
for ad_set_info in target_sets:
# Extract SegmentTemplate initialization attribute
# ------------------------------------------------------------------
# Branch A: SegmentTemplate-based manifest (most live/VOD streams)
# The init segment URL is expressed as a template attribute.
# ------------------------------------------------------------------
init_template = ManifestUtils.extract_segment_template_initialization(
ad_set_info.content
)
if not init_template:
continue
if init_template:
logger.debug(f"Found init template: {init_template}")
logger.debug(f"Found init template: {init_template}")
# Get first Representation ID from this AdaptationSet
rep_id = ManifestUtils.extract_first_representation_id(ad_set_info.content)
# Get first Representation ID from this AdaptationSet
rep_id = ManifestUtils.extract_first_representation_id(ad_set_info.content)
if not rep_id:
logger.debug("No Representation ID found in AdaptationSet")
continue
if not rep_id:
logger.debug("No Representation ID found in AdaptationSet")
continue
logger.debug(f"Using Representation ID: {rep_id}")
logger.debug(f"Using Representation ID: {rep_id}")
# Substitute template variables with defaults
init_url = URLResolver.substitute_template_variables(
init_template,
representation_id=rep_id,
bandwidth="0",
time="0",
number="1"
)
# Substitute template variables with defaults
init_url = URLResolver.substitute_template_variables(
init_template,
representation_id=rep_id,
bandwidth="0",
time="0",
number="1"
)
# Construct full URL
full_url = URLResolver.construct_full_url(
effective_base,
init_url,
url_encode_filename=True
)
# Construct full URL
full_url = URLResolver.construct_full_url(
effective_base,
init_url,
url_encode_filename=True
)
logger.info(f"Constructed init segment URL (SegmentTemplate): {full_url}")
return full_url
logger.info(f"Constructed init segment URL: {full_url}")
return full_url
# ------------------------------------------------------------------
# Branch B: SegmentBase manifest (on-demand, single-file MP4)
# Each Representation has a <BaseURL> pointing to the full MP4 file.
# The init segment is the same file, accessed via HTTP Range using
# the range from <Initialization range="start-end"/>.
# We return the bare MP4 URL; the caller is responsible for issuing
# an appropriate Range request if it wants only the init bytes.
# ------------------------------------------------------------------
segment_base_url = ManifestUtils.extract_segment_base_url(ad_set_info.content)
if segment_base_url:
init_range = ManifestUtils.extract_segment_base_init_range(ad_set_info.content)
full_url = URLResolver.construct_full_url(
effective_base,
segment_base_url,
url_encode_filename=False # path is already a clean relative URL
)
if init_range:
logger.info(
f"Constructed init segment URL (SegmentBase): {full_url} "
f"[Range: bytes={init_range}]"
)
else:
logger.info(
f"Constructed init segment URL (SegmentBase, no range): {full_url}"
)
return full_url
logger.warning("Could not find init segment URL in manifest")
return None
@@ -131,12 +131,61 @@ class ManifestUtils:
@staticmethod
def extract_base_urls(manifest_content: str) -> List[str]:
"""
Extract all BaseURL elements from manifest.
Extract only MPD-level and Period-level BaseURL elements from manifest.
Representation-level BaseURL values are relative media paths, not base URLs,
and must NOT be included here or they corrupt the effective base URL calculation.
Args:
manifest_content: Full manifest XML content
Returns:
List of BaseURL text contents
List of BaseURL text contents at MPD or Period scope only
"""
return re.findall(r"<BaseURL[^>]*>([^<]+)</BaseURL>", manifest_content)
base_urls = []
# Strip everything from the first AdaptationSet onward so we only
# see MPD-level and Period-level BaseURL elements.
first_as = re.search(r"<AdaptationSet[\s>]", manifest_content)
header_content = manifest_content[:first_as.start()] if first_as else manifest_content
for match in re.finditer(r"<BaseURL[^>]*>([^<]+)</BaseURL>", header_content):
base_urls.append(match.group(1))
return base_urls
@staticmethod
def extract_segment_base_url(ad_set_content: str) -> Optional[str]:
"""
For SegmentBase manifests, extract the first Representation-level BaseURL
within an AdaptationSet. This is the complete (relative or absolute) URL
for a self-contained MP4 file; the init segment lives inside it at the byte
range given by the <Initialization range="…"/> element.
Args:
ad_set_content: AdaptationSet XML content
Returns:
BaseURL string from the first Representation, or None if not present
"""
match = re.search(r"<BaseURL[^>]*>([^<]+)</BaseURL>", ad_set_content)
return match.group(1).strip() if match else None
@staticmethod
def extract_segment_base_init_range(ad_set_content: str) -> Optional[str]:
"""
Extract the byte-range for the init segment from a SegmentBase manifest.
Returns the value of the 'range' attribute on the <Initialization> element,
e.g. "0-686".
Args:
ad_set_content: AdaptationSet XML content
Returns:
Range string ("start-end") or None if not present
"""
match = re.search(
r"<Initialization[^>]*\brange=\"([^\"]+)\"",
ad_set_content,
re.IGNORECASE,
)
return match.group(1).strip() if match else None
@@ -410,6 +410,35 @@ class MPDRewriter:
period.remove(adaptation_set)
logger.info(f"Removed {removal_count} video representation(s), kept highest quality only")
# Clean up SupplementalProperty elements that reference removed AdaptationSet IDs.
# These are used for codec-switching hints (e.g. AVC↔HEVC) and become dangling
# after we discard all but one video AdaptationSet.
CODEC_SWITCH_SCHEME = "urn:mpeg:dash:adaptation-set-switching:2016"
surviving_as_ids: Set[str] = set()
for period in root.findall(".//mpd:Period", self.MPD_NAMESPACE):
for adaptation_set in period.findall("mpd:AdaptationSet", self.MPD_NAMESPACE):
as_id = adaptation_set.get("id")
if as_id:
surviving_as_ids.add(as_id)
for period in root.findall(".//mpd:Period", self.MPD_NAMESPACE):
for adaptation_set in period.findall("mpd:AdaptationSet", self.MPD_NAMESPACE):
for sp in list(adaptation_set.findall("mpd:SupplementalProperty", self.MPD_NAMESPACE)):
if sp.get("schemeIdUri") == CODEC_SWITCH_SCHEME:
raw_value = sp.get("value", "")
# Keep only IDs that still exist in the tree
kept = [
v.strip()
for v in raw_value.split(",")
if v.strip() in surviving_as_ids
]
if not kept:
# No surviving peers — remove the property entirely
adaptation_set.remove(sp)
else:
sp.set("value", ",".join(kept))
return best_video
def _is_video_adaptation_set(self, adaptation_set: ET.Element) -> bool:
@@ -797,7 +826,62 @@ class MPDRewriter:
if element.tag.endswith("Representation"):
current_rep_id = element.get("id", "")
# Rewrite URL attributes
# ------------------------------------------------------------------
# SegmentBase manifest support (Bug 2 fix):
# Representation-level <BaseURL> elements carry the full media URL.
# Rewrite their text content through the proxy so all media requests
# are routed correctly. We also resolve <SegmentBase> / <Initialization>
# range-request URLs here (Bug 3 fix) so the init segment is fetched
# via the proxy with proper decryption parameters.
# ------------------------------------------------------------------
if element.tag.endswith("BaseURL") and element.text:
parent_rep_id = current_rep_id # captured when we entered Representation
raw_url = element.text.strip()
if raw_url:
resolved = urljoin(base_url, raw_url)
# Rewrite the BaseURL text through the proxy.
# We don't know the segment type yet (the SegmentBase child will
# handle Initialization specifically), so pass None for seg_type
# so the proxy receives both kid+key when in decrypt mode.
element.text = self.build_proxy_url(
resolved, None, None, current_encrypted, current_kid,
representation_id=parent_rep_id,
)
# Update local base_url so that any sibling/child elements that
# use urljoin against it resolve correctly against the proxied URL.
# We intentionally do NOT update the outer base_url variable here
# — it is passed by value to children via the recursion call below.
base_url = resolved # use original resolved URL for child resolution
# SegmentBase <Initialization range="…"/> — rewrite as a proxy URL that
# carries the Range header information so the proxy can pass it through.
# The actual media bytes live at the parent Representation's <BaseURL>,
# which has already been rewritten above. We rebuild the init URL from
# the (original, pre-proxy) base_url so we don't double-encode it.
if element.tag.endswith("Initialization") and "range" in element.attrib:
byte_range = element.attrib["range"]
# base_url at this point is the resolved (original CDN) Representation
# URL set just above when we processed the sibling <BaseURL> element.
# If for some reason it wasn't set (no <BaseURL> sibling), fall back
# to the inherited base_url which is the AdaptationSet/Period base.
init_proxy_url = self.build_proxy_url(
base_url, None, "initialization", current_encrypted, current_kid,
representation_id=current_rep_id,
)
# Replace the range attribute with a marker the proxy understands,
# and store the proxied URL as a sourceURL attribute so the player
# uses it for the range request rather than the BaseURL text.
# DASH players that understand SegmentBase will issue:
# GET <sourceURL> Range: bytes=<range>
# Proxies that receive this will forward the Range header to the CDN.
element.set("sourceURL", init_proxy_url)
# Leave the range attribute intact so the player still knows which
# bytes to request.
# ------------------------------------------------------------------
# Standard SegmentTemplate / SegmentList attribute rewriting
# (unchanged from original — handles media/initialization/sourceURL attrs)
# ------------------------------------------------------------------
attr_map = {
"media": "media",
"initialization": "initialization",