diff --git a/docs/dev/rest-api/endpoints.md b/docs/dev/rest-api/endpoints.md index e8a7688d..87152715 100644 --- a/docs/dev/rest-api/endpoints.md +++ b/docs/dev/rest-api/endpoints.md @@ -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": "" } ], + "track_manifests": [ { "track_id": "s-1", "type": "dash", "url": "https://...", "data": "" } ], "session_headers": { "User-Agent": "..." }, "session_cookies": { "sid": "..." }, "server_cdm_type": "widevine" diff --git a/docs/dev/rest-api/remote-sessions.md b/docs/dev/rest-api/remote-sessions.md index 9c0fb31c..9a8b235e 100644 --- a/docs/dev/rest-api/remote-sessions.md +++ b/docs/dev/rest-api/remote-sessions.md @@ -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 diff --git a/tests/remote/unit/test_track_manifest_fragments.py b/tests/remote/unit/test_track_manifest_fragments.py new file mode 100644 index 00000000..dfa37ad5 --- /dev/null +++ b/tests/remote/unit/test_track_manifest_fragments.py @@ -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 = """ + + https://cdn.example/base/ + + + + + + + + + +""" + + +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'') + ) + + 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'') + 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"] diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index a77e5caf..d2019825 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -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), diff --git a/unshackle/core/remote_service.py b/unshackle/core/remote_service.py index 2ed724aa..51218933 100644 --- a/unshackle/core/remote_service.py +++ b/unshackle/core/remote_service.py @@ -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)