mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-12 12:05:07 +00:00
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:
@@ -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.
|
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`"
|
=== "Response `200`"
|
||||||
|
|
||||||
```json
|
```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" } ],
|
"chapters": [ { "timestamp": "00:00:00.000", "name": "Intro" } ],
|
||||||
"attachments": [ { "url": "https://...", "name": "font.ttf", "mime_type": "font/ttf", "description": "" } ],
|
"attachments": [ { "url": "https://...", "name": "font.ttf", "mime_type": "font/ttf", "description": "" } ],
|
||||||
"manifests": [ { "type": "dash", "url": "https://...", "data": "<base64 zlib xml>" } ],
|
"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_headers": { "User-Agent": "..." },
|
||||||
"session_cookies": { "sid": "..." },
|
"session_cookies": { "sid": "..." },
|
||||||
"server_cdm_type": "widevine"
|
"server_cdm_type": "widevine"
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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
|
downloading. A service can build an AdaptationSet of its own that the served
|
||||||
merged into the client's local HTTP session.
|
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
|
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
|
`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"]
|
||||||
@@ -743,6 +743,66 @@ def extract_manifests(tracks) -> List[Dict[str, Any]]:
|
|||||||
return manifests
|
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]]]:
|
def serialize_drm(drm_list) -> Optional[List[Dict[str, Any]]]:
|
||||||
"""Serialise DRM objects to JSON-serializable list."""
|
"""Serialise DRM objects to JSON-serializable list."""
|
||||||
if not drm_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)
|
audio_tracks = sorted(tracks.audio, key=lambda t: t.bitrate or 0, reverse=True)
|
||||||
|
|
||||||
manifests = extract_manifests(tracks)
|
manifests = extract_manifests(tracks)
|
||||||
|
track_manifests = extract_track_manifests(tracks)
|
||||||
|
|
||||||
svc_session = session.service_instance.session
|
svc_session = session.service_instance.session
|
||||||
session_headers = dict(svc_session.headers) if hasattr(svc_session, "headers") else {}
|
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],
|
"attachments": [a for a in map(serialize_attachment, tracks.attachments) if a],
|
||||||
"manifests": manifests,
|
"manifests": manifests,
|
||||||
|
"track_manifests": track_manifests,
|
||||||
"session_headers": session_headers,
|
"session_headers": session_headers,
|
||||||
"session_cookies": session_cookies,
|
"session_cookies": session_cookies,
|
||||||
"server_cdm": server_cdm_allowed(request, service_tag),
|
"server_cdm": server_cdm_allowed(request, service_tag),
|
||||||
|
|||||||
@@ -304,6 +304,51 @@ def apply_service_track_data(tracks: Tracks, data: Dict[str, Any]) -> None:
|
|||||||
track.data.update(extra)
|
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:
|
def resolve_manifest_data(tracks: Tracks, manifests: list) -> None:
|
||||||
"""Re-parse serialized manifests and fill track.data for downloading.
|
"""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():
|
for k, v in result.get("session_cookies", {}).items():
|
||||||
self._session.cookies.set(k, v)
|
self._session.cookies.set(k, v)
|
||||||
|
|
||||||
|
resolve_track_manifests(tracks, result.get("track_manifests", []))
|
||||||
resolve_manifest_data(tracks, result.get("manifests", []))
|
resolve_manifest_data(tracks, result.get("manifests", []))
|
||||||
apply_service_track_data(tracks, result)
|
apply_service_track_data(tracks, result)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user