mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-11 19:45:06 +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.
|
||||
|
||||
`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"
|
||||
|
||||
@@ -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"]
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user