From 0440f2ba54a04bf61fd5dab24f9e87cefa48f039 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Tue, 27 Oct 2026 11:38:47 -0600 Subject: [PATCH] fix(manifests): keep service drm, rebase carried vtt cues, ship single-track mpds DASH no longer drops service-set DRM when the manifest has none, and the WebVTT rebase allows a cue that starts one segment early. --remote also sends single-track manifests matched by track id. --- tests/core/test_dash_clearkey_licensing.py | 31 ++++++++++++---- .../unit/test_track_manifest_fragments.py | 36 +++++++++++++++++++ tests/tracks/test_webvtt_merge.py | 12 +++++++ unshackle/core/api/handlers.py | 9 ++--- unshackle/core/manifests/dash.py | 4 +-- unshackle/core/tracks/subtitle.py | 8 +++-- unshackle/core/utils/webvtt.py | 7 ++-- 7 files changed, 90 insertions(+), 17 deletions(-) diff --git a/tests/core/test_dash_clearkey_licensing.py b/tests/core/test_dash_clearkey_licensing.py index 03a3bdb3..6f85790b 100644 --- a/tests/core/test_dash_clearkey_licensing.py +++ b/tests/core/test_dash_clearkey_licensing.py @@ -56,11 +56,8 @@ class RecordingLicence: drm.content_keys[kid] = KEY.hex() -def test_download_track_licenses_clearkey_cenc(tmp_path, monkeypatch) -> None: - track = DASH.from_text(MPD, MANIFEST_URL).to_tracks().videos[0] - # KID normally comes from the init segment; short-circuit the probe (offline). - monkeypatch.setattr(track, "get_key_id", lambda *args, **kwargs: KID) - +def licence_only(track: Any, tmp_path: Any) -> RecordingLicence: + """Run download_track as far as licensing and return the licence callback it used.""" licence = RecordingLicence() ctx = DownloadContext( save_path=tmp_path / "video.mp4", @@ -69,12 +66,20 @@ def test_download_track_licenses_clearkey_cenc(tmp_path, monkeypatch) -> None: license_widevine=licence, cdm=None, ) - DOWNLOAD_LICENCE_ONLY.set() try: DASH.download_track(track=track, ctx=ctx) finally: DOWNLOAD_LICENCE_ONLY.clear() + return licence + + +def test_download_track_licenses_clearkey_cenc(tmp_path, monkeypatch) -> None: + track = DASH.from_text(MPD, MANIFEST_URL).to_tracks().videos[0] + # KID normally comes from the init segment; short-circuit the probe (offline). + monkeypatch.setattr(track, "get_key_id", lambda *args, **kwargs: KID) + + licence = licence_only(track, tmp_path) assert len(licence.calls) == 1, "ClearKeyCENC track was never licensed" drm, track_kid = licence.calls[0] @@ -82,3 +87,17 @@ def test_download_track_licenses_clearkey_cenc(tmp_path, monkeypatch) -> None: assert track_kid == KID # with content keys populated, decrypt has what it needs assert drm.content_keys == {KID: KEY.hex()} + + +def test_download_track_keeps_service_drm_when_manifest_has_none(tmp_path, monkeypatch) -> None: + """A service-set non-Widevine DRM survives a manifest with no ContentProtection, and is licensed.""" + service_drm = [ClearKeyCENC([KID], LAURL)] + bare_mpd = MPD.split("")[1] + track = DASH.from_text(bare_mpd, MANIFEST_URL).to_tracks().videos[0] + track.drm = service_drm + monkeypatch.setattr(track, "get_key_id", lambda *args, **kwargs: KID) + + licence = licence_only(track, tmp_path) + + assert track.drm == service_drm + assert len(licence.calls) == 1 diff --git a/tests/remote/unit/test_track_manifest_fragments.py b/tests/remote/unit/test_track_manifest_fragments.py index dfa37ad5..d479b381 100644 --- a/tests/remote/unit/test_track_manifest_fragments.py +++ b/tests/remote/unit/test_track_manifest_fragments.py @@ -72,10 +72,46 @@ def test_detached_adaptation_set_round_trips() -> None: assert client_track.descriptor == Subtitle.Descriptor.DASH +def test_single_track_manifest_round_trips_by_id() -> None: + """A manifest that holds only its own track ships as a fragment, so the client needs no attribute match.""" + 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"), + sdh=True, + descriptor=Subtitle.Descriptor.DASH, + data={ + "dash": { + "manifest": manifest, + "period": period, + "adaptation_set": adaptation_set, + "representation": adaptation_set.find("Representation"), + } + }, + ) + client_track = Subtitle( + id_="abc123", + url=track.url, + codec=Subtitle.Codec.WebVTT, + language=Language.get("en"), + sdh=True, + descriptor=Subtitle.Descriptor.URL, + ) + resolve_track_manifests(Tracks([client_track]), extract_track_manifests(Tracks([track]))) + + assert client_track.descriptor == Subtitle.Descriptor.DASH + assert client_track.data["dash"]["representation_id"] == "sub-0" + + def test_in_tree_adaptation_set_is_not_shipped() -> None: manifest = etree.fromstring(MPD.encode()) period = manifest.find("Period") adaptation_set = period.find("AdaptationSet") + adaptation_set.append(etree.fromstring(b'')) track = Subtitle( id_="abc123", url="https://cdn.example/manifest.mpd", diff --git a/tests/tracks/test_webvtt_merge.py b/tests/tracks/test_webvtt_merge.py index 915c88a5..f7fe2cf1 100644 --- a/tests/tracks/test_webvtt_merge.py +++ b/tests/tracks/test_webvtt_merge.py @@ -126,6 +126,18 @@ def test_rebase_moves_live_clock_cues_to_zero(): assert "00:05:29.600 --> 00:05:30.129 line:0.01%\nText" in out +def test_rebase_moves_cues_when_the_first_is_carried_over_from_before_the_offset(): + vtt = "WEBVTT\n\n03:59:43.500 --> 03:59:45.000\nCarried\n\n04:03:32.598 --> 04:03:32.698\nNext\n" + out = rebase_webvtt(vtt, timestamp_ms("03:59:44.000"), 2002) + assert "00:00:00.000 --> 00:00:01.000\nCarried" in out + assert "00:03:48.598 --> 00:03:48.698\nNext" in out + + +def test_rebase_leaves_a_later_period_with_a_small_offset_alone(): + vtt = "WEBVTT\n\n00:00:05.000 --> 00:00:12.000\nText\n" + assert rebase_webvtt(vtt, 10_000, 2000) == vtt + + def test_rebase_leaves_presentation_time_cues_alone(): # a second pass, or a VOD period with a non-zero offset, must not shift again vtt = "WEBVTT\n\n00:05:29.600 --> 00:05:30.129\nText\n" diff --git a/unshackle/core/api/handlers.py b/unshackle/core/api/handlers.py index c327623e..a98b1e67 100644 --- a/unshackle/core/api/handlers.py +++ b/unshackle/core/api/handlers.py @@ -1560,11 +1560,12 @@ def extract_manifests(tracks) -> List[Dict[str, Any]]: 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. + """Build a one-AdaptationSet MPD for each track the client cannot find again in the served manifest. 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. + copied nodes, that never joins the manifest tree the server serialises. A service can also + build a manifest that holds only its own track, which the client's attribute match cannot + tell apart from others. Each of those tracks gets its own MPD, matched by track id. """ import base64 import zlib @@ -1583,7 +1584,7 @@ def extract_track_manifests(tracks) -> List[Dict[str, Any]]: adaptation_set = dash.get("adaptation_set") if manifest is None or adaptation_set is None: continue - if adaptation_set.getroottree().getroot() is manifest: + if adaptation_set.getroottree().getroot() is manifest and len(manifest.findall(".//Representation")) > 1: continue root = etree.Element("MPD", dict(manifest.attrib)) diff --git a/unshackle/core/manifests/dash.py b/unshackle/core/manifests/dash.py index 4073afd1..b904e449 100644 --- a/unshackle/core/manifests/dash.py +++ b/unshackle/core/manifests/dash.py @@ -401,7 +401,7 @@ class DASH: # Only override existing DRM if: # 1. No existing DRM was set, OR # 2. Existing DRM contains same type as manifest DRM, OR - # 3. Existing DRM is not Widevine (preserve Widevine when service explicitly set it) + # 3. Existing DRM is not Widevine and the manifest has DRM to replace it with should_override_drm = ( not existing_drm or ( @@ -409,7 +409,7 @@ class DASH: and manifest_drm and any(isinstance(existing, type(manifest)) for existing in existing_drm for manifest in manifest_drm) ) - or (existing_drm and not any(isinstance(drm, Widevine) for drm in existing_drm)) + or (existing_drm and manifest_drm and not any(isinstance(drm, Widevine) for drm in existing_drm)) ) pre_existing_keys = {} diff --git a/unshackle/core/tracks/subtitle.py b/unshackle/core/tracks/subtitle.py index 8208b220..a4562fa9 100644 --- a/unshackle/core/tracks/subtitle.py +++ b/unshackle/core/tracks/subtitle.py @@ -286,8 +286,12 @@ class Subtitle(Track): self.data["dash"]["adaptation_set"], self.data["dash"]["representation"] ) if template is not None: - offset = template.get("presentationTimeOffset", "0") - text = rebase_webvtt(text, int(offset) * 1000 // int(template.get("timescale", "1"))) + timescale = int(template.get("timescale", "1")) + text = rebase_webvtt( + text, + int(template.get("presentationTimeOffset", "0")) * 1000 // timescale, + int(template.get("duration", "0")) * 1000 // timescale, + ) if len(self.data["dash"]["segment_durations"]) > 1: text = merge_segmented_webvtt( text, diff --git a/unshackle/core/utils/webvtt.py b/unshackle/core/utils/webvtt.py index d7d060e3..ec2c0eb8 100644 --- a/unshackle/core/utils/webvtt.py +++ b/unshackle/core/utils/webvtt.py @@ -79,14 +79,15 @@ def merge_segmented_webvtt(vtt_raw: str, segment_durations: Optional[list[int]] return "WEBVTT\n\n" + "\n\n".join(blocks) + "\n" -def rebase_webvtt(text: str, offset_ms: int) -> str: +def rebase_webvtt(text: str, offset_ms: int, segment_ms: int = 0) -> str: """ Subtract offset_ms from every cue time when the cues sit on the media timeline of a DASH period whose presentationTimeOffset is offset_ms, as a live replay's do. Cues that start - below the offset are already presentation times, so the text stays as it is. + more than one segment below the offset are already presentation times, so the text stays + as it is. A cue carried into the first segment can start up to one segment early. """ first = CUE_TIMES.search(text) - if offset_ms <= 0 or not first or timestamp_ms(first[2]) < offset_ms: + if offset_ms <= 0 or not first or timestamp_ms(first[2]) < offset_ms - segment_ms: return text def shift(ts: str) -> str: