fix(remote): send per-track MPD fragments for service-built AdaptationSets

A service can build an AdaptationSet the served manifest does not contain, so the client's re-parse could never find it and the track downloaded with no manifest data. Those tracks now get a one-AdaptationSet MPD of their own.
This commit is contained in:
imSp4rky
2026-09-02 15:13:06 -06:00
parent 80167630af
commit ac8f71b969
5 changed files with 240 additions and 2 deletions
+3
View File
@@ -972,6 +972,8 @@ Requires an authenticated remote session. Otherwise it returns `404 SESSION_NOT_
Get tracks and chapters for a specific title within the remote session. Body: `{ "title_id": "..." }` (required). Unlike `list-tracks`, track objects here **include** download URLs, along with session headers/cookies and manifest data the client needs to download directly.
`manifests` holds each served DASH/ISM manifest once. A service can build an AdaptationSet of its own that the served manifest does not contain. `track_manifests` holds a one-AdaptationSet MPD for each such track, and the client re-parses that in place of the whole manifest.
=== "Response `200`"
```json
@@ -983,6 +985,7 @@ Get tracks and chapters for a specific title within the remote session. Body: `{
"chapters": [ { "timestamp": "00:00:00.000", "name": "Intro" } ],
"attachments": [ { "url": "https://...", "name": "font.ttf", "mime_type": "font/ttf", "description": "" } ],
"manifests": [ { "type": "dash", "url": "https://...", "data": "<base64 zlib xml>" } ],
"track_manifests": [ { "track_id": "s-1", "type": "dash", "url": "https://...", "data": "<base64 zlib xml>" } ],
"session_headers": { "User-Agent": "..." },
"session_cookies": { "sid": "..." },
"server_cdm_type": "widevine"
+5 -2
View File
@@ -173,8 +173,11 @@ hood the client walks a remote session through its lifecycle.
Once authenticated, the client fetches the title list, then the tracks for the
chosen title. The tracks come back with playback URLs, and the server ships
DASH/ISM manifests as compressed XML so the client can re-parse them locally for
downloading. Any `session_headers` / `session_cookies` the server used are
merged into the client's local HTTP session.
downloading. A service can build an AdaptationSet of its own that the served
manifest does not contain. Each such track gets a one-AdaptationSet MPD in
`track_manifests`, and the client re-parses that instead. Any
`session_headers` / `session_cookies` the server used are merged into the
client's local HTTP session.
The tracks response repeats the title. A service can rewrite title fields inside
`get_tracks`, so the client takes this copy in place of the one from the title
@@ -0,0 +1,124 @@
"""An AdaptationSet a service built itself survives the server-to-client round trip."""
from __future__ import annotations
import pytest
from langcodes import Language
from lxml import etree
from unshackle.core.api.handlers import extract_track_manifests
from unshackle.core.remote_service import resolve_track_manifests
from unshackle.core.tracks import Subtitle, Tracks
pytestmark = pytest.mark.unit
MPD = """<?xml version="1.0"?>
<MPD type="static" mediaPresentationDuration="PT10S">
<BaseURL>https://cdn.example/base/</BaseURL>
<Period id="0">
<AdaptationSet contentType="text" lang="en">
<Representation id="sub-0" bandwidth="1000">
<SegmentTemplate media="$Number$.vtt" startNumber="1">
<SegmentTimeline><S t="0" d="4"/></SegmentTimeline>
</SegmentTemplate>
</Representation>
</AdaptationSet>
</Period>
</MPD>"""
def test_detached_adaptation_set_round_trips() -> None:
manifest = etree.fromstring(MPD.encode())
period = manifest.find("Period")
# a service that merges segment timelines ends up with a copy detached from the tree
combined = etree.fromstring(etree.tostring(period.find("AdaptationSet")))
combined.find("Representation").find("SegmentTemplate").find("SegmentTimeline").append(
etree.fromstring(b'<S t="4" d="4"/>')
)
track = Subtitle(
id_="abc123",
url="https://cdn.example/manifest.mpd",
codec=Subtitle.Codec.WebVTT,
language=Language.get("en"),
descriptor=Subtitle.Descriptor.DASH,
data={
"dash": {
"manifest": manifest,
"period": period,
"adaptation_set": combined,
"representation": combined.find("Representation"),
}
},
)
tracks = Tracks([track])
fragments = extract_track_manifests(tracks)
assert len(fragments) == 1
client_track = Subtitle(
id_="abc123",
url=track.url,
codec=Subtitle.Codec.WebVTT,
language=Language.get("en"),
descriptor=Subtitle.Descriptor.URL,
)
resolve_track_manifests(Tracks([client_track]), fragments)
dash = client_track.data["dash"]
assert dash["manifest"].find("BaseURL").text == "https://cdn.example/base/"
assert dash["representation_id"] == "sub-0"
assert len(dash["representation"].find("SegmentTemplate").find("SegmentTimeline").findall("S")) == 2
assert client_track.descriptor == Subtitle.Descriptor.DASH
def test_in_tree_adaptation_set_is_not_shipped() -> None:
manifest = etree.fromstring(MPD.encode())
period = manifest.find("Period")
adaptation_set = period.find("AdaptationSet")
track = Subtitle(
id_="abc123",
url="https://cdn.example/manifest.mpd",
codec=Subtitle.Codec.WebVTT,
language=Language.get("en"),
descriptor=Subtitle.Descriptor.DASH,
data={
"dash": {
"manifest": manifest,
"period": period,
"adaptation_set": adaptation_set,
"representation": adaptation_set.find("Representation"),
}
},
)
assert extract_track_manifests(Tracks([track])) == []
def test_fragment_keeps_only_the_track_representation() -> None:
manifest = etree.fromstring(MPD.encode())
period = manifest.find("Period")
combined = etree.fromstring(etree.tostring(period.find("AdaptationSet")))
wanted = etree.fromstring(b'<Representation id="sub-1" bandwidth="2000"/>')
combined.append(wanted)
track = Subtitle(
id_="abc123",
url="https://cdn.example/manifest.mpd",
codec=Subtitle.Codec.WebVTT,
language=Language.get("en"),
descriptor=Subtitle.Descriptor.DASH,
data={"dash": {"manifest": manifest, "period": period, "adaptation_set": combined, "representation": wanted}},
)
client_track = Subtitle(
id_="abc123",
url=track.url,
codec=Subtitle.Codec.WebVTT,
language=Language.get("en"),
descriptor=Subtitle.Descriptor.URL,
)
resolve_track_manifests(Tracks([client_track]), extract_track_manifests(Tracks([track])))
dash = client_track.data["dash"]
assert dash["representation_id"] == "sub-1"
assert [r.get("id") for r in dash["adaptation_set"].findall("Representation")] == ["sub-1"]
+62
View File
@@ -743,6 +743,66 @@ def extract_manifests(tracks) -> List[Dict[str, Any]]:
return manifests
def extract_track_manifests(tracks) -> List[Dict[str, Any]]:
"""Build a one-AdaptationSet MPD for each track whose AdaptationSet the served manifest does not contain.
A service can build an AdaptationSet of its own, out of merged segment timelines or
copied nodes, that never joins the manifest tree the server serialises. The client's
re-parse of that manifest cannot find it, so each of those tracks gets its own MPD.
"""
import base64
import zlib
from copy import deepcopy
from lxml import etree
from unshackle.core.config import config as app_config
compression_level = app_config.serve.get("compression_level", 1)
fragments: List[Dict[str, Any]] = []
for track in list(tracks.videos) + list(tracks.audio) + list(tracks.subtitles):
dash = (getattr(track, "data", None) or {}).get("dash") or {}
manifest = dash.get("manifest")
adaptation_set = dash.get("adaptation_set")
if manifest is None or adaptation_set is None:
continue
if adaptation_set.getroottree().getroot() is manifest:
continue
root = etree.Element("MPD", dict(manifest.attrib))
for child in manifest:
if child.tag != "Period":
root.append(deepcopy(child))
period = dash.get("period")
new_period = etree.SubElement(root, "Period", dict(period.attrib) if period is not None else {})
for child in period if period is not None else []:
if child.tag != "AdaptationSet":
new_period.append(deepcopy(child))
new_adaptation_set = deepcopy(adaptation_set)
new_period.append(new_adaptation_set)
representation = dash.get("representation")
if representation is not None:
for sibling in new_adaptation_set.findall("Representation"):
new_adaptation_set.remove(sibling)
new_adaptation_set.append(deepcopy(representation))
xml_bytes = etree.tostring(root, xml_declaration=True, encoding="UTF-8")
compressed = zlib.compress(xml_bytes, compression_level) if compression_level else xml_bytes
fragments.append(
{
"track_id": str(track.id),
"type": "dash",
"url": str(track.url) if track.url else None,
"data": base64.b64encode(compressed).decode("ascii"),
}
)
return fragments
def serialize_drm(drm_list) -> Optional[List[Dict[str, Any]]]:
"""Serialise DRM objects to JSON-serializable list."""
if not drm_list:
@@ -2588,6 +2648,7 @@ async def session_tracks_handler(
audio_tracks = sorted(tracks.audio, key=lambda t: t.bitrate or 0, reverse=True)
manifests = extract_manifests(tracks)
track_manifests = extract_track_manifests(tracks)
svc_session = session.service_instance.session
session_headers = dict(svc_session.headers) if hasattr(svc_session, "headers") else {}
@@ -2644,6 +2705,7 @@ async def session_tracks_handler(
],
"attachments": [a for a in map(serialize_attachment, tracks.attachments) if a],
"manifests": manifests,
"track_manifests": track_manifests,
"session_headers": session_headers,
"session_cookies": session_cookies,
"server_cdm": server_cdm_allowed(request, service_tag),
+46
View File
@@ -304,6 +304,51 @@ def apply_service_track_data(tracks: Tracks, data: Dict[str, Any]) -> None:
track.data.update(extra)
def resolve_track_manifests(tracks: Tracks, track_manifests: list) -> None:
"""Fill track.data from the per-track MPD fragments the server sent.
A service can build an AdaptationSet the served manifest does not contain, so the
client's re-parse of that manifest cannot find it. The server sends a one-AdaptationSet
MPD for each of those tracks instead. This runs first, and the whole-manifest re-parse
then skips every track whose ``data`` already holds a ``dash`` entry.
"""
import base64 as b64
from lxml import etree
from unshackle.core.api.compression import safe_inflate
if not track_manifests:
return
log_m = logging.getLogger("remote_service")
by_id = {str(t.id): t for t in list(tracks.videos) + list(tracks.audio) + list(tracks.subtitles)}
for entry in track_manifests:
track = by_id.get(str(entry.get("track_id")))
if track is None or entry.get("type") != "dash" or not entry.get("data"):
continue
try:
manifest = etree.fromstring(safe_inflate(b64.b64decode(entry["data"])))
period = manifest.find("Period")
adaptation_set = period.find("AdaptationSet")
representation = adaptation_set.find("Representation")
if representation is None:
raise ValueError("fragment has no Representation")
except Exception as e:
log_m.warning("Failed to re-parse the track fragment for track %s: %s", track.id, e)
continue
track.data["dash"] = {
"manifest": manifest,
"period": period,
"adaptation_set": adaptation_set,
"representation": representation,
"representation_id": representation.get("id"),
}
track.descriptor = Track.Descriptor.DASH
def resolve_manifest_data(tracks: Tracks, manifests: list) -> None:
"""Re-parse serialized manifests and fill track.data for downloading.
@@ -974,6 +1019,7 @@ class RemoteService:
for k, v in result.get("session_cookies", {}).items():
self._session.cookies.set(k, v)
resolve_track_manifests(tracks, result.get("track_manifests", []))
resolve_manifest_data(tracks, result.get("manifests", []))
apply_service_track_data(tracks, result)