Merge branch 'dev' into feat/downloader-speed

# Conflicts:
#	unshackle/core/downloaders/requests.py
#	unshackle/core/manifests/dash.py
This commit is contained in:
imSp4rky
2026-07-16 12:47:59 -06:00
103 changed files with 3270 additions and 1706 deletions
+208
View File
@@ -0,0 +1,208 @@
"""CDM detection and load-time DRM stamping for local and remote CDMs.
Covers the predicates in ``unshackle.core.cdm.detect`` (playready/widevine, local/remote,
and the remote combinations) and the loader stamping that sets a remote CDM's DRM type at
load time instead of guessing it on each call. Services such as AMZN route to a device
profile off that type, so a wrong classification here is what produces a Downgrade.Hd
license denial.
"""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
import pytest
from unshackle.core.cdm import loader as loader_mod
from unshackle.core.cdm.detect import (
cdm_location,
is_local_cdm,
is_playready_cdm,
is_remote_cdm,
is_remote_playready_cdm,
is_remote_widevine_cdm,
is_widevine_cdm,
)
from unshackle.core.cdm.loader import _stamp_remote
pytestmark = pytest.mark.unit
REPO = Path(__file__).resolve().parents[2]
PRD_DIR = REPO / "unshackle" / "PRDs"
def test_stamped_remote_playready():
cdm = SimpleNamespace(drm="playready", is_remote_cdm=True)
assert is_playready_cdm(cdm)
assert not is_widevine_cdm(cdm)
assert is_remote_cdm(cdm)
assert is_remote_playready_cdm(cdm)
assert not is_remote_widevine_cdm(cdm)
assert cdm_location(cdm) == "remote"
def test_stamped_remote_widevine():
cdm = SimpleNamespace(drm="widevine", is_remote_cdm=True)
assert is_widevine_cdm(cdm)
assert not is_playready_cdm(cdm)
assert is_remote_widevine_cdm(cdm)
assert not is_remote_playready_cdm(cdm)
assert cdm_location(cdm) == "remote"
def test_drm_stamp_beats_conflicting_is_playready_attr():
# The stamped drm takes priority over the wrapper's own is_playready guess.
cdm = SimpleNamespace(drm="playready", is_playready=False, is_remote_cdm=True)
assert is_playready_cdm(cdm)
assert not is_widevine_cdm(cdm)
def test_drm_stamp_case_insensitive():
cdm = SimpleNamespace(drm="PlayReady", is_remote_cdm=True)
assert is_playready_cdm(cdm)
def test_none_classifies_as_nothing():
for fn in (
is_playready_cdm,
is_widevine_cdm,
is_remote_cdm,
is_local_cdm,
is_remote_playready_cdm,
is_remote_widevine_cdm,
):
assert fn(None) is False
assert cdm_location(None) == "unknown"
@pytest.mark.parametrize("device_name", ["SL2", "SL3", "SL2000", "SL3000", "sl3000"])
def test_decryptlabs_sl_names_are_playready(device_name):
from unshackle.core.cdm.decrypt_labs_remote_cdm import DecryptLabsRemoteCDM
cdm = DecryptLabsRemoteCDM(secret="x", device_name=device_name)
assert cdm.is_playready
assert is_playready_cdm(cdm)
assert is_remote_cdm(cdm)
assert is_remote_playready_cdm(cdm)
assert not is_local_cdm(cdm)
@pytest.mark.parametrize("device_name", ["ChromeCDM", "L1", "L2"])
def test_decryptlabs_widevine_names(device_name):
from unshackle.core.cdm.decrypt_labs_remote_cdm import DecryptLabsRemoteCDM
cdm = DecryptLabsRemoteCDM(secret="x", device_name=device_name)
assert not cdm.is_playready
assert is_widevine_cdm(cdm)
assert is_remote_widevine_cdm(cdm)
def test_decryptlabs_device_type_forces_playready():
from unshackle.core.cdm.decrypt_labs_remote_cdm import DecryptLabsRemoteCDM
cdm = DecryptLabsRemoteCDM(secret="x", device_name="ChromeCDM", device_type="PLAYREADY")
assert cdm.is_playready
@pytest.mark.parametrize(
("device", "expect_pr"),
[
({"name": "SL3000"}, True),
({"name": "SL2"}, True),
({"name": "ChromeCDM"}, False),
({"type": "PLAYREADY", "name": "ChromeCDM"}, True),
],
)
def test_custom_remote_cdm_classification(device, expect_pr):
from unshackle.core.cdm.custom_remote_cdm import CustomRemoteCDM
cdm = CustomRemoteCDM(host="http://example.invalid", device=device)
assert cdm.is_playready is expect_pr
assert is_playready_cdm(cdm) is expect_pr
assert is_remote_cdm(cdm)
def test_stamp_remote_sets_attrs_and_returns_same_object():
obj = SimpleNamespace()
out = _stamp_remote(obj, "playready")
assert out is obj
assert obj.drm == "playready"
assert obj.is_remote_cdm is True
def test_stamp_remote_is_best_effort_on_unsettable_object():
class Slotted:
__slots__ = ()
obj = Slotted()
out = _stamp_remote(obj, "widevine") # must not raise
assert out is obj
assert not hasattr(obj, "drm")
def test_load_remote_decryptlabs_playready_is_stamped():
cdm_api = {"name": "dl-pr", "type": "decrypt_labs", "secret": "x", "device_name": "SL3000"}
cdm = loader_mod._load_remote_cdm(dict(cdm_api), "dl-pr", "AMZN", None)
assert cdm.drm == "playready"
assert cdm.is_remote_cdm is True
assert is_remote_playready_cdm(cdm)
def test_load_remote_decryptlabs_widevine_is_stamped():
cdm_api = {"name": "dl-wv", "type": "decrypt_labs", "secret": "x", "device_name": "ChromeCDM"}
cdm = loader_mod._load_remote_cdm(dict(cdm_api), "dl-wv", "AMZN", None)
assert cdm.drm == "widevine"
assert is_remote_widevine_cdm(cdm)
def test_load_remote_native_playready_is_stamped(monkeypatch):
import pyplayready.remote.remotecdm as prmod
class FakePlayReadyRemote:
def __init__(self, **kwargs):
self.kwargs = kwargs
monkeypatch.setattr(prmod, "RemoteCdm", FakePlayReadyRemote)
cdm_api = {"name": "pr", "Device Type": "PLAYREADY", "host": "h", "secret": "s", "device_name": "d"}
cdm = loader_mod._load_remote_cdm(dict(cdm_api), "pr", "AMZN", None)
assert isinstance(cdm, FakePlayReadyRemote)
assert cdm.drm == "playready"
assert cdm.is_remote_cdm is True
assert is_remote_playready_cdm(cdm)
def test_load_remote_native_widevine_is_stamped(monkeypatch):
import pywidevine.remotecdm as wvmod
class FakeWidevineRemote:
def __init__(self, **kwargs):
self.kwargs = kwargs
monkeypatch.setattr(wvmod, "RemoteCdm", FakeWidevineRemote)
cdm_api = {"name": "wv", "Device Type": "ANDROID", "host": "h", "secret": "s", "device_name": "d"}
cdm = loader_mod._load_remote_cdm(dict(cdm_api), "wv", "AMZN", None)
assert isinstance(cdm, FakeWidevineRemote)
assert cdm.drm == "widevine"
assert is_remote_widevine_cdm(cdm)
@pytest.fixture
def local_playready_cdm():
prds = sorted(PRD_DIR.glob("*.prd"))
if not prds:
pytest.skip(f"no local .prd present in {PRD_DIR}")
from pyplayready.cdm import Cdm
from pyplayready.device import Device
return Cdm.from_device(Device.load(str(prds[0])))
def test_local_playready_is_playready_and_local(local_playready_cdm):
cdm = local_playready_cdm
assert is_playready_cdm(cdm)
assert not is_widevine_cdm(cdm)
assert is_local_cdm(cdm)
assert not is_remote_cdm(cdm)
assert not is_remote_playready_cdm(cdm)
assert cdm_location(cdm) == "local"
+1
View File
@@ -16,6 +16,7 @@ from typing import Any, Optional
from uuid import UUID
import pytest
# lxml.etree: XML parser used to build ContentProtection fixtures for DASH.get_drm
from lxml import etree
+7 -2
View File
@@ -9,8 +9,13 @@ from pathlib import Path
import pytest
from unshackle.core.utilities import (close_debug_logger, get_debug_logger, init_debug_logger, log_event,
timed_operation)
from unshackle.core.utilities import (
close_debug_logger,
get_debug_logger,
init_debug_logger,
log_event,
timed_operation,
)
from unshackle.core.utils.redact import redact_all, redact_path, redact_text, redact_url, safe_display_url
from unshackle.core.utils.subprocess import log_tool_run
+27 -5
View File
@@ -12,11 +12,24 @@ import struct
import pytest
from unshackle.core.manifests.ism_init import (NAL_START_CODE, PIFF_SENC_UUID, box, build_avcc, build_dec3,
build_hvcc, build_init_segment, full_box,
parse_codec_private_data_colour, parse_hevc_sps_format,
read_per_sample_iv_size, read_track_id, remove_emulation_prevention,
split_nal_units, synthesize_aac_codec_private_data)
from unshackle.core.manifests.ism_init import (
NAL_START_CODE,
PIFF_SENC_UUID,
box,
build_avcc,
build_dec3,
build_hvcc,
build_init_segment,
full_box,
parse_codec_private_data_colour,
parse_codec_private_data_vui,
parse_hevc_sps_format,
read_per_sample_iv_size,
read_track_id,
remove_emulation_prevention,
split_nal_units,
synthesize_aac_codec_private_data,
)
# Real CodecPrivateData taken from a Smooth Streaming manifest.
VIDEO_HEVC_CPD = (
@@ -472,6 +485,15 @@ def test_parse_colour_absent_or_unknown_returns_none():
assert parse_codec_private_data_colour("HVC1", b"\x00\x00\x00\x01\x42") is None
def test_parse_vui_fps():
assert parse_codec_private_data_vui("HVC1", bytes.fromhex(VIDEO_HEVC_CPD))[1] == pytest.approx(24000 / 1001)
assert parse_codec_private_data_vui("HVC1", bytes.fromhex(VIDEO_HEVC_PQ_CPD))[1] == 25.0
# VUI timing can be present even when colour info is absent.
assert parse_codec_private_data_vui("HVC1", bytes.fromhex(VIDEO_HEVC10_CPD)) == (None, 25.0)
# AVC is deliberately untrusted for fps (field-based VUI timing).
assert parse_codec_private_data_vui("H264", bytes.fromhex(VIDEO_AVC_CPD)) == (None, None)
def test_hvcc_profile_tier_level_is_nonzero():
# De-emulated PTL must yield real profile/level, not the off-by-one garbage.
hvcc = build_hvcc(bytes.fromhex(VIDEO_HEVC_CPD))
+43
View File
@@ -0,0 +1,43 @@
import pytest
from unshackle.core.manifests.ism import ISM
from unshackle.core.tracks import Audio
MANIFEST = """<?xml version="1.0"?>
<SmoothStreamingMedia MajorVersion="2" MinorVersion="0" Duration="60000000" TimeScale="10000000">
<StreamIndex Type="audio" Name="audio" Language="en"
Url="QualityLevels({Bitrate})/Fragments(audio={start_time})">
<QualityLevel Index="0" Bitrate="128000" AudioTag="255" SamplingRate="48000"
Channels="2" BitsPerSample="16" CodecPrivateData="1190"/>
<c t="0" d="20000000" r="2"/>
<c/>
</StreamIndex>
</SmoothStreamingMedia>
"""
def test_placeholder_spellings_repeat_and_audiotag_fallback() -> None:
tracks = ISM.from_text(MANIFEST, "https://cdn.example/x.ism/manifest").to_tracks()
(audio,) = tracks.audio
segments = audio.data["ism"]["segments"]
assert segments == [
"https://cdn.example/x.ism/QualityLevels(128000)/Fragments(audio=0)",
"https://cdn.example/x.ism/QualityLevels(128000)/Fragments(audio=20000000)",
"https://cdn.example/x.ism/QualityLevels(128000)/Fragments(audio=40000000)",
]
assert audio.codec == Audio.Codec.AAC # AudioTag 255 means AACL
def test_missing_language_without_fallback_raises() -> None:
# MS-SSTR Language is optional; video StreamIndexes commonly omit it.
no_lang = MANIFEST.replace(' Language="en"', "")
with pytest.raises(ValueError, match="fallback language"):
ISM.from_text(no_lang, "https://cdn.example/x.ism/manifest").to_tracks()
tracks = ISM.from_text(no_lang, "https://cdn.example/x.ism/manifest").to_tracks(language="en")
assert str(tracks.audio[0].language) == "en"
def test_live_manifest_rejected() -> None:
live = MANIFEST.replace('Duration="60000000"', 'Duration="60000000" IsLive="TRUE"', 1)
with pytest.raises(ValueError, match="Live"):
ISM.from_text(live, "https://cdn.example/x.ism/manifest").to_tracks()
+2 -2
View File
@@ -4,11 +4,11 @@ undecidable from the manifest; the post-mux bitstream probe names it."""
from __future__ import annotations
from tests.core.test_ism_init import VIDEO_HEVC10_CPD, VIDEO_HEVC_DV_CPD, VIDEO_HEVC_PQ_CPD, VIDEO_HEVC_SDR_CPD
from unshackle.core.manifests import ISM
from unshackle.core.tracks import Video
from tests.core.test_ism_init import (VIDEO_HEVC10_CPD, VIDEO_HEVC_DV_CPD, VIDEO_HEVC_HLG_CPD, # isort: skip
VIDEO_HEVC_PQ_CPD, VIDEO_HEVC_SDR_CPD)
from tests.core.test_ism_init import VIDEO_HEVC_HLG_CPD # isort: skip
def manifest_xml(cpd: str, fourcc: str = "HVC1") -> str:
+25 -5
View File
@@ -12,8 +12,14 @@ from typing import Any, Optional
from rich.console import Console, RenderableType
from rich.panel import Panel
from unshackle.core.music.display import (MusicHeaderInfo, TrackRow, format_track_detail_quality, render_album_header,
render_artwork_preview, render_track_panel)
from unshackle.core.music.display import (
MusicHeaderInfo,
TrackRow,
format_track_detail_quality,
render_album_header,
render_artwork_preview,
render_track_panel,
)
from unshackle.core.titles.music import Song
@@ -97,8 +103,20 @@ def test_format_track_detail_quality_passthrough() -> None:
def test_render_track_panel_returns_panel() -> None:
rows = [
TrackRow(song=make_song(track=1, name="First"), quality_label="FLAC 16-bit/44.1kHz", layout="Stereo", duration_str="3:00", cd=True),
TrackRow(song=make_song(track=2, name="Second"), quality_label="OGG 320", layout="Stereo", duration_str="4:00", hires=True),
TrackRow(
song=make_song(track=1, name="First"),
quality_label="FLAC 16-bit/44.1kHz",
layout="Stereo",
duration_str="3:00",
cd=True,
),
TrackRow(
song=make_song(track=2, name="Second"),
quality_label="OGG 320",
layout="Stereo",
duration_str="4:00",
hires=True,
),
]
panel = render_track_panel(rows, total=len(rows))
assert isinstance(panel, Panel)
@@ -112,7 +130,9 @@ def test_render_track_panel_returns_panel() -> None:
def test_render_track_panel_singular_label() -> None:
rows = [TrackRow(song=make_song(name="Only"), quality_label="FLAC 16-bit/44.1kHz", layout="Stereo", duration_str="3:00")]
rows = [
TrackRow(song=make_song(name="Only"), quality_label="FLAC 16-bit/44.1kHz", layout="Stereo", duration_str="3:00")
]
text = render_to_text(render_track_panel(rows, total=1))
# The count node reads "1 Track" (singular); the panel title is always
# "Available Tracks", so we only check the count node phrasing here.
+11 -3
View File
@@ -6,9 +6,17 @@ from typing import Any, Optional
import pytest
from unshackle.core.music.extract import (build_music_from_songs, classify_release_kind, dedupe_track_options,
duration_seconds, first_number, first_text, format_duration, format_names,
year_from_value)
from unshackle.core.music.extract import (
build_music_from_songs,
classify_release_kind,
dedupe_track_options,
duration_seconds,
first_number,
first_text,
format_duration,
format_names,
year_from_value,
)
from unshackle.core.music.models import MusicTrackOption
from unshackle.core.titles.music import Music, Song
+1 -3
View File
@@ -115,9 +115,7 @@ def hand_built_ctx(body: dict[str, Any]) -> click.Context:
ctx = click.Context(parsed.command)
ctx.params = {"repack": body.get("repack", False)}
for name in ctx.params:
ctx.set_parameter_source(
name, ParameterSource.COMMANDLINE if name in body else ParameterSource.DEFAULT
)
ctx.set_parameter_source(name, ParameterSource.COMMANDLINE if name in body else ParameterSource.DEFAULT)
return ctx
+1 -3
View File
@@ -240,9 +240,7 @@ def test_refresh_reports_discarded_local_edits(monkeypatch, tmp_path):
def test_changed_services_up_to_date(monkeypatch):
monkeypatch.setattr(service_repo.binaries, "Git", "git")
# same HEAD before/after → no diff computed at all
monkeypatch.setattr(
service_repo.subprocess, "run", lambda *a, **k: pytest.fail("no git diff when HEAD unchanged")
)
monkeypatch.setattr(service_repo.subprocess, "run", lambda *a, **k: pytest.fail("no git diff when HEAD unchanged"))
assert service_repo._changed_services(Path("/x"), "same", "same") == []
@@ -0,0 +1,179 @@
"""Regression: exact-lang must distinguish a base language from its regional variant.
Reproduced with `--exact-lang --lang ar,ar-EG` against a streaming manifest that carries a
distinct `ar` ("Arabic") and `ar-EG` ("Arabic (Egypt)") audio track. CLDR tag_distance measures
intelligibility, not tag identity: it rates a base language and its "paradigm" regional variant
as the same language at distance 0 (ar/ar-EG, en/en-US, pt/pt-BR). Plain distance matching then
resolved both requested tags to the same track, dropping one variant and appending the other
twice, which crashed Tracks() reconstruction.
In exact mode by_language follows RFC 4647 Lookup: it prefers the most specific (string-equal)
tag, so each tag routes to its own track. It still falls back to the fuzzy match for pure aliases
(zh matches cmn) and the lenient base-to-regional case (en finds en-US when only en-US exists).
Distances above 0 (es/es-419, fr/fr-CA) were never collapsed and stay strict.
"""
from __future__ import annotations
import pytest
from unshackle.core.tracks import Audio
from unshackle.core.tracks.tracks import Tracks
from unshackle.core.utilities import is_exact_match
# Base language vs regional variant that CLDR rates at distance 0 (paradigm locales). These are
# the pairs that collapse under naive exact matching.
COLLAPSING_PAIRS = [
("ar", "ar-EG"),
("en", "en-US"),
("pt", "pt-BR"),
("fr", "fr-FR"),
("es", "es-ES"),
("de", "de-DE"),
("it", "it-IT"),
]
# Pairs CLDR rates at distance >0, which exact matching already separates. Never collapsed.
# Includes two-regional splits (no bare base) as services more commonly ship them.
DISTINCT_PAIRS = [
("es", "es-419"),
("fr", "fr-CA"),
("fr-FR", "fr-CA"),
("pt-PT", "pt-BR"),
("es-ES", "es-419"),
("en-US", "en-GB"),
("de-DE", "de-AT"),
("it-IT", "it-CH"),
("es-419", "es-AR"),
("no", "nn"),
]
# A full real-world audio language set for one title, the kind of mix a streaming manifest ships,
# which must resolve 1:1 with no collision or loss. Tags are drawn from published streaming
# language references (Warner Bros. Discovery / Netflix) and include regional and script variants.
REAL_WORLD_AUDIO_LANGS = [
"ar",
"ar-EG",
"ar-SA",
"bn-IN",
"cmn-TW",
"cs",
"da",
"de",
"de-AT",
"de-CH",
"el",
"en",
"en-GB",
"en-AU",
"es-419",
"es-AR",
"es-ES",
"fi",
"fr-CA",
"fr-FR",
"he",
"hi",
"hu",
"id",
"it",
"it-CH",
"ja",
"ko",
"nl",
"no",
"pl",
"pt-BR",
"pt-PT",
"ro",
"ru",
"sk",
"sr",
"sr-Latn",
"sv",
"ta",
"th",
"tr",
"uk",
"vi",
"yue",
"zh-Hans",
"zh-Hant",
]
def _mk(*tags):
return [Audio("https://x", language=t, id_=t) for t in tags]
def _select(tags, pool, exact=True):
return [(t.id, str(t.language)) for t in Tracks.by_language(pool, tags, per_language=1, exact_match=exact)]
@pytest.mark.parametrize("base,reg", COLLAPSING_PAIRS)
def test_collapsing_pairs_are_distance_zero(base, reg):
# Root cause: the regional variant exact-matches the base request (CLDR paradigm data).
assert is_exact_match(base, [reg]) is True
@pytest.mark.parametrize("base,reg", DISTINCT_PAIRS)
def test_distinct_pairs_are_not_distance_zero(base, reg):
# Contrast: these never collapse, which is why the bug was language-specific.
assert is_exact_match(base, [reg]) is False
@pytest.mark.parametrize("base,reg", COLLAPSING_PAIRS)
def test_exact_lang_selects_distinct_collapsing_variants(base, reg):
# Both tracks present + both tags requested: each tag must route to its own track.
assert _select([base, reg], _mk(base, reg)) == [(base, base), (reg, reg)]
@pytest.mark.parametrize("base,reg", DISTINCT_PAIRS)
def test_exact_lang_distinct_regionals_still_work(base, reg):
assert _select([base, reg], _mk(base, reg)) == [(base, base), (reg, reg)]
def test_exact_lang_lenient_base_to_regional_fallback():
# Only the regional present; a base request must still find it when CLDR rates them equal.
assert _select(["en"], _mk("en-US")) == [("en-US", "en-US")]
# Alias with no string-equal track must still resolve (zh -> cmn).
assert _select(["zh"], _mk("cmn")) == [("cmn", "cmn")]
def test_exact_lang_stays_strict_for_nonzero_distance():
# `es` does NOT accept `es-419` in exact mode (distance 5), but fuzzy mode does.
assert _select(["es"], _mk("es-419")) == []
assert _select(["es"], _mk("es-419"), exact=False) == [("es-419", "es-419")]
@pytest.mark.parametrize("base,reg", COLLAPSING_PAIRS)
def test_overlapping_tags_never_duplicate_in_fuzzy_mode(base, reg):
# Non-exact still collapses base/regional, but must dedupe rather than return a track twice.
selected = Tracks.by_language(_mk(base, reg), [base, reg], per_language=1, exact_match=False)
assert len({t.id for t in selected}) == len(selected)
def test_exact_lang_three_way_base_and_two_regionals():
# fr (base, dist 0 to fr-FR) + fr-FR + fr-CA (dist 4): all three must resolve distinctly.
assert _select(["fr", "fr-FR", "fr-CA"], _mk("fr", "fr-FR", "fr-CA")) == [
("fr", "fr"),
("fr-FR", "fr-FR"),
("fr-CA", "fr-CA"),
]
# Base request with no bare-tag track falls back to its paradigm regional, without stealing
# the sibling regional (fr -> fr-FR, not fr-CA).
assert _select(["fr", "fr-CA"], _mk("fr-FR", "fr-CA")) == [("fr-FR", "fr-FR"), ("fr-CA", "fr-CA")]
@pytest.mark.parametrize("tag", REAL_WORLD_AUDIO_LANGS)
def test_full_language_set_each_tag_resolves_to_itself(tag):
# Against a full real-world language pool, every exact request must return its own track, so
# that no tag captures another's: ar-EG must resolve to ar-EG, never to ar.
pool = _mk(*REAL_WORLD_AUDIO_LANGS)
assert _select([tag], pool) == [(tag, tag)]
def test_full_language_set_selects_every_track_once():
pool = _mk(*REAL_WORLD_AUDIO_LANGS)
selected = Tracks.by_language(pool, REAL_WORLD_AUDIO_LANGS, per_language=1, exact_match=True)
assert len(selected) == len(REAL_WORLD_AUDIO_LANGS)
assert {t.id for t in selected} == set(REAL_WORLD_AUDIO_LANGS)
+8
View File
@@ -86,6 +86,14 @@ def _wait_for_health(url: str, timeout: float = 60.0) -> bool:
return False
@pytest.fixture(autouse=True)
def _isolated_job_history(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Redirect job history writes to tmp_path so tests don't append to the server's api_history.jsonl."""
from unshackle.core.api import download_manager
monkeypatch.setattr(download_manager, "_history_path", lambda: tmp_path / "api_history.jsonl")
@pytest.fixture(scope="session")
def secret_key(request: pytest.FixtureRequest) -> str:
return str(request.config.getoption("--secret-key"))
@@ -9,8 +9,13 @@ import pytest
from aiohttp import web
from unshackle.core.api import handlers
from unshackle.core.api.download_manager import (DownloadJob, JobStatus, _redact_parameters, _redact_text,
_secret_values)
from unshackle.core.api.download_manager import (
DownloadJob,
JobStatus,
_redact_parameters,
_redact_text,
_secret_values,
)
from unshackle.core.api.errors import APIError, APIErrorCode
pytestmark = pytest.mark.unit
@@ -116,9 +121,7 @@ async def test_cdm_override_allowed_when_enabled(stub_handler):
async def test_cdm_override_allowlist_permits_only_named_device(stub_handler):
stub_handler.setattr(handlers.config, "serve", {"cdm_overrides": ["good"]})
assert isinstance(
await handlers.download_handler({"service": "ATV", "title_id": "t", "cdm": "good"}), web.Response
)
assert isinstance(await handlers.download_handler({"service": "ATV", "title_id": "t", "cdm": "good"}), web.Response)
with pytest.raises(APIError) as ei:
await handlers.download_handler({"service": "ATV", "title_id": "t", "cdm": "other"})
assert ei.value.error_code == APIErrorCode.FORBIDDEN
+7 -2
View File
@@ -6,8 +6,13 @@ import json
import pytest
from unshackle.core.api.errors import (APIError, APIErrorCode, build_error_response, categorize_exception,
handle_api_exception)
from unshackle.core.api.errors import (
APIError,
APIErrorCode,
build_error_response,
categorize_exception,
handle_api_exception,
)
pytestmark = pytest.mark.unit
+10 -3
View File
@@ -5,9 +5,16 @@ from __future__ import annotations
import pytest
from langcodes import Language
from unshackle.core.api.handlers import (sanitize_log, serialize_audio_track, serialize_drm, serialize_subtitle_track,
serialize_title, serialize_video_track, validate_download_parameters,
validate_service)
from unshackle.core.api.handlers import (
sanitize_log,
serialize_audio_track,
serialize_drm,
serialize_subtitle_track,
serialize_title,
serialize_video_track,
validate_download_parameters,
validate_service,
)
from unshackle.core.titles.episode import Episode
from unshackle.core.titles.movie import Movie
from unshackle.core.tracks import Audio, Subtitle, Video
+7 -5
View File
@@ -9,8 +9,12 @@ from __future__ import annotations
import pytest
from unshackle.core.api.progress import (DOWNLOAD_PROGRESS_CEILING, build_job_progress_callables,
track_progress_label, track_progress_weight)
from unshackle.core.api.progress import (
DOWNLOAD_PROGRESS_CEILING,
build_job_progress_callables,
track_progress_label,
track_progress_weight,
)
pytestmark = pytest.mark.unit
@@ -76,9 +80,7 @@ def test_weighting_makes_video_dominate_progress():
def test_active_tracks_labels_reported_and_cleared_on_done():
updates: list[dict] = []
cbs = build_job_progress_callables(
[Video(2160, "DV"), Audio("en-US", "2.0")], [_noop, _noop], updates.append
)
cbs = build_job_progress_callables([Video(2160, "DV"), Audio("en-US", "2.0")], [_noop, _noop], updates.append)
cbs[0](total=100, completed=10) # video downloading
assert updates[-1]["active_tracks"] == ["video 2160p DV"]
@@ -6,8 +6,16 @@ from enum import Enum
import pytest
from unshackle.core.remote_service import (_build_title, _build_tracks, _deserialize_audio, _deserialize_subtitle,
_deserialize_video, _enum_get, _match_track, _reconstruct_drm)
from unshackle.core.remote_service import (
_build_title,
_build_tracks,
_deserialize_audio,
_deserialize_subtitle,
_deserialize_video,
_enum_get,
_match_track,
_reconstruct_drm,
)
from unshackle.core.titles.episode import Episode
from unshackle.core.titles.movie import Movie
from unshackle.core.tracks import Audio, Subtitle, Video
+12
View File
@@ -87,6 +87,18 @@ def test_subtitleedit_ranks_first_when_available(monkeypatch):
assert chain[0] == "subtitleedit"
def test_styled_ass_to_ttml_vtt_prefers_pysubs2_over_subtitleedit(monkeypatch):
# Measured: SE flattens ASS inline styling to TTML/VTT; pysubs2 keeps i/b/u.
monkeypatch.setattr(binaries, "SubtitleEdit", "/usr/bin/seconv")
for target in (Codec.TimedTextMarkupLang, Codec.WebVTT):
chain = [b.name for b in sc.resolve_backends(Codec.SubStationAlphav4, target)]
assert chain[0] == "pysubs2", f"expected pysubs2 first for ASS->{target.name}, got {chain}"
assert "subtitleedit" in chain # still a fallback
# ASS->SRT keeps SE first (only backend that keeps colour there).
chain = [b.name for b in sc.resolve_backends(Codec.SubStationAlphav4, Codec.SubRip)]
assert chain[0] == "subtitleedit"
def test_pin_then_fallback_orders_pin_first():
chain = [b.name for b in sc.resolve_backends(Codec.WebVTT, Codec.SubRip, pin="pysubs2")]
assert chain[0] == "pysubs2"
+35 -14
View File
@@ -90,8 +90,11 @@ def test_failed_subtitle_does_not_truncate_video_or_audio():
h = Harness(fail_ids={"s-he"})
download_tracks_in_passes(
[video, audio, sub], 4, h.run_one,
skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped,
[video, audio, sub],
4,
h.run_one,
skip_subtitle_errors=True,
on_subtitle_skipped=h.on_subtitle_skipped,
)
assert set(h.completed) == {"v", "a"} # both fatal tracks fully downloaded
@@ -105,8 +108,11 @@ def test_good_subtitle_kept_bad_subtitle_skipped():
h = Harness(fail_ids={"s-fr"})
download_tracks_in_passes(
[video, good, bad], 4, h.run_one,
skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped,
[video, good, bad],
4,
h.run_one,
skip_subtitle_errors=True,
on_subtitle_skipped=h.on_subtitle_skipped,
)
assert "s-en" in h.completed # the available subtitle still downloaded
@@ -121,8 +127,11 @@ def test_subtitle_failure_stays_fatal_without_flag():
with pytest.raises(RuntimeError):
download_tracks_in_passes(
[video, sub], 4, h.run_one,
skip_subtitle_errors=False, on_subtitle_skipped=h.on_subtitle_skipped,
[video, sub],
4,
h.run_one,
skip_subtitle_errors=False,
on_subtitle_skipped=h.on_subtitle_skipped,
)
assert not DOWNLOAD_CANCELLED.is_set() # the finally clears the event even on the fatal path
@@ -135,8 +144,11 @@ def test_cancel_event_is_reset_between_titles():
h = Harness(fail_ids=set())
download_tracks_in_passes(
[video, audio], 4, h.run_one,
skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped,
[video, audio],
4,
h.run_one,
skip_subtitle_errors=True,
on_subtitle_skipped=h.on_subtitle_skipped,
)
assert set(h.completed) == {"v", "a"}
@@ -150,8 +162,11 @@ def test_cancel_event_cleared_after_failed_final_subtitle():
h = Harness(fail_ids={"s-he"})
download_tracks_in_passes(
[video, sub], 4, h.run_one,
skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped,
[video, sub],
4,
h.run_one,
skip_subtitle_errors=True,
on_subtitle_skipped=h.on_subtitle_skipped,
)
assert not DOWNLOAD_CANCELLED.is_set() # the helper clears it on exit for any later code
@@ -164,8 +179,11 @@ def test_all_subtitles_skipped_video_audio_kept():
h = Harness(fail_ids={"s-en", "s-he"})
download_tracks_in_passes(
[video, audio, s1, s2], 4, h.run_one,
skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped,
[video, audio, s1, s2],
4,
h.run_one,
skip_subtitle_errors=True,
on_subtitle_skipped=h.on_subtitle_skipped,
)
assert set(h.completed) == {"v", "a"} # both fatal tracks survived
@@ -179,8 +197,11 @@ def test_duplicate_language_subtitles_distinguished_by_id():
h = Harness(fail_ids={"en-forced", "en-sdh"})
download_tracks_in_passes(
[make_video(), forced, sdh], 4, h.run_one,
skip_subtitle_errors=True, on_subtitle_skipped=h.on_subtitle_skipped,
[make_video(), forced, sdh],
4,
h.run_one,
skip_subtitle_errors=True,
on_subtitle_skipped=h.on_subtitle_skipped,
)
assert [t.id for t in h.skipped] == ["en-forced", "en-sdh"] # same language, distinct ids
+68
View File
@@ -0,0 +1,68 @@
import pytest
from unshackle.core.tracks import Subtitle, Tracks
def sub(lang: str, forced: bool = False, sdh: bool = False, cc: bool = False) -> Subtitle:
return Subtitle(
url=f"https://example.test/{lang}-{forced}-{sdh}-{cc}",
language=lang,
codec=Subtitle.Codec.SubRip,
forced=forced,
sdh=sdh,
cc=cc,
)
def shape(tracks: Tracks) -> list[tuple[str, str]]:
return [
(str(s.language), "forced" if s.forced else "sdh" if (s.sdh or s.cc) else "normal") for s in tracks.subtitles
]
@pytest.fixture
def tracks() -> Tracks:
t = Tracks()
t.add([sub("en", sdh=True), sub("en", forced=True), sub("en"), sub("it", forced=True), sub("it")])
return t
def test_default_order_is_forced_normal_sdh(tracks: Tracks) -> None:
tracks.sort_subtitles(by_language=["en"])
assert shape(tracks) == [
("en", "forced"),
("en", "normal"),
("en", "sdh"),
("it", "forced"),
("it", "normal"),
]
def test_type_priority_overrides_group_order(tracks: Tracks) -> None:
tracks.sort_subtitles(by_language=["en"], type_priority=["normal", "sdh", "forced"])
assert shape(tracks) == [
("en", "normal"),
("en", "sdh"),
("en", "forced"),
("it", "normal"),
("it", "forced"),
]
def test_type_priority_unlisted_types_fall_to_end(tracks: Tracks) -> None:
tracks.sort_subtitles(by_language=["en"], type_priority=["normal"])
# unlisted types keep their pre-sort relative order (stable sort)
assert shape(tracks) == [
("en", "normal"),
("en", "sdh"),
("en", "forced"),
("it", "normal"),
("it", "forced"),
]
def test_cc_counts_as_sdh() -> None:
t = Tracks()
t.add([sub("en", cc=True), sub("en")])
t.sort_subtitles(by_language=["en"], type_priority=["normal", "sdh"])
assert shape(t) == [("en", "normal"), ("en", "sdh")]
+138
View File
@@ -0,0 +1,138 @@
"""Unit tests for the audio/video track-selection primitives.
Cover the pure ``Tracks`` helpers behind the ``dl`` selection layer:
- ``Tracks.by_resolutions``: the 16:9 canvas fallback must tolerate tracks with
no known width instead of crashing on ``int(None * 9 / 16)``.
- ``Tracks.sort_videos``: resolution outranks bitrate, and an unknown-bitrate
track can still win on resolution.
- ``Tracks.sort_audio``: descriptive-last, Atmos, codec_priority then bitrate;
the first track after sorting is the "best" pick the dl layer selects.
"""
from __future__ import annotations
from unshackle.core.tracks import Audio, Tracks, Video
def make_video(
track_id: str,
*,
height: int,
bitrate: int | None,
width: int | None = None,
codec: Video.Codec = Video.Codec.HEVC,
) -> Video:
return Video(
id_=track_id,
url=f"https://example.test/{track_id}.m3u8",
language="en",
codec=codec,
range_=Video.Range.SDR,
width=width,
height=height,
bitrate=bitrate,
)
def make_audio(
track_id: str,
*,
codec: Audio.Codec,
bitrate: int,
joc: int | None = None,
descriptive: bool = False,
language: str = "en",
) -> Audio:
return Audio(
id_=track_id,
url=f"https://example.test/{track_id}.m4a",
language=language,
codec=codec,
bitrate=bitrate,
joc=joc,
descriptive=descriptive,
)
# ---------------------------------------------------------------------------
# by_resolutions: width=None must not crash the canvas fallback
# ---------------------------------------------------------------------------
def test_by_resolutions_tolerates_width_none_track() -> None:
tracks = Tracks()
tracks.videos = [
make_video("h1080", height=1080, width=1920, bitrate=8_000_000), # no exact/canvas 720 match
make_video("no-width", height=480, width=None, bitrate=1_000_000), # width-less, must be skipped
make_video("canvas-720", height=406, width=1280, bitrate=3_000_000), # 1280*9/16 == 720
]
tracks.by_resolutions([720])
assert [t.id for t in tracks.videos] == ["canvas-720"]
# ---------------------------------------------------------------------------
# sort_videos: resolution before bitrate
# ---------------------------------------------------------------------------
def test_sort_videos_resolution_outranks_bitrate() -> None:
tracks = Tracks()
tracks.videos = [
make_video("hi-bitrate-720", height=720, bitrate=10_000_000, width=1280),
make_video("lo-bitrate-2160", height=2160, bitrate=5_000_000, width=3840),
]
tracks.sort_videos()
assert [t.id for t in tracks.videos] == ["lo-bitrate-2160", "hi-bitrate-720"]
def test_sort_videos_unknown_bitrate_still_wins_on_resolution() -> None:
tracks = Tracks()
tracks.videos = [
make_video("known-720", height=720, bitrate=10_000_000, width=1280),
make_video("unknown-1080", height=1080, bitrate=None, width=1920),
]
tracks.sort_videos()
assert [t.id for t in tracks.videos] == ["unknown-1080", "known-720"]
# ---------------------------------------------------------------------------
# sort_audio: first track after sort is the "best" the dl layer picks
# ---------------------------------------------------------------------------
def test_sort_audio_atmos_ranks_first() -> None:
tracks = Tracks()
tracks.audio = [
make_audio("ec3-320", codec=Audio.Codec.EC3, bitrate=320_000),
make_audio("ac3-640", codec=Audio.Codec.AC3, bitrate=640_000),
make_audio("ec3-atmos", codec=Audio.Codec.EC3, bitrate=768_000, joc=16),
make_audio("ec3-desc", codec=Audio.Codec.EC3, bitrate=768_000, descriptive=True),
]
tracks.sort_audio(codec_priority=["EC3", "AC3"])
best = Tracks.by_language(tracks.audio, ["en"])[0]
assert best.id == "ec3-atmos"
# descriptive always sorts last
assert tracks.audio[-1].id == "ec3-desc"
def test_sort_audio_codec_priority_beats_bitrate() -> None:
tracks = Tracks()
tracks.audio = [
make_audio("ac3-640", codec=Audio.Codec.AC3, bitrate=640_000),
make_audio("ec3-320", codec=Audio.Codec.EC3, bitrate=320_000),
]
tracks.sort_audio(codec_priority=["EC3", "AC3"])
best = Tracks.by_language(tracks.audio, ["en"])[0]
# EC3 is prioritized even though the AC3 track has the higher bitrate
assert best.id == "ec3-320"
+109
View File
@@ -0,0 +1,109 @@
"""Regression tests for segmented-WebVTT merging (``core/utils/webvtt.py``).
The merge used to round-trip cues through pycaption's reader/writer (and a pysubs2
pre-normalization step), both of which silently dropped inline formatting despite
``preserve_formatting=True``. It is now a pure string-level splice: cue
timing lines, settings, and payload are kept verbatim, so span tags
(``<i>``/``<u>``/``<b>``), ASS-style overrides (``{\\an8}``), and entities all
survive, while duplicate headers collapse and boundary-duplicate cues dedupe.
"""
from __future__ import annotations
from unshackle.core.utils.webvtt import merge_segmented_webvtt
# Two segments, each a full WebVTT document with its own X-TIMESTAMP-MAP header,
# as they arrive concatenated from a DASH/HLS subtitle stream. The first cue carries
# span tags, an {\an8} override, a cue setting, and a literal &lt; entity.
SEGMENTED_VTT = (
"WEBVTT\n"
"X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000\n\n"
"00:00:01.000 --> 00:00:03.000 line:10% align:center\n"
"<i>Hello</i> {\\an8}world <u>und</u> <b>bold</b> 5 &lt; 6\n\n"
"WEBVTT\n"
"X-TIMESTAMP-MAP=MPEGTS:1800000,LOCAL:00:00:00.000\n\n"
"00:00:04.000 --> 00:00:06.000\n"
"<i>Second</i> cue\n"
)
def test_merge_preserves_inline_formatting():
out = merge_segmented_webvtt(SEGMENTED_VTT, segment_durations=[900000, 900000], timescale=1)
for tag in ("<i>", "</i>", "<u>", "</u>", "<b>", "</b>", "{\\an8}"):
assert tag in out, f"{tag!r} was stripped from merged output:\n{out}"
def test_merge_preserves_cue_settings_and_entities():
out = merge_segmented_webvtt(SEGMENTED_VTT, segment_durations=[900000, 900000], timescale=1)
assert "line:10% align:center" in out # cue positioning survives
assert "&lt;" in out # a literal entity is not turned into a stray tag
def test_merge_collapses_headers_into_single_document():
out = merge_segmented_webvtt(SEGMENTED_VTT, segment_durations=[900000, 900000], timescale=1)
assert out.count("WEBVTT") == 1
assert "X-TIMESTAMP-MAP" not in out
def test_merge_dedupes_boundary_duplicate_cues():
# A cue that spans a segment boundary is emitted in both segments; the merge
# should collapse the adjacent identical pair into one.
dup = SEGMENTED_VTT + (
"\nWEBVTT\n"
"X-TIMESTAMP-MAP=MPEGTS:2700000,LOCAL:00:00:00.000\n\n"
"00:00:04.000 --> 00:00:06.000\n"
"<i>Second</i> cue\n"
)
out = merge_segmented_webvtt(dup, segment_durations=[900000, 900000, 900000], timescale=1)
assert out.count("<i>Second</i> cue") == 1, f"boundary duplicate not deduped:\n{out}"
def test_merge_keeps_timing_lines_verbatim():
# String-level merge must not re-render timestamps (pycaption shortened
# 00:00:01.000 -> 00:01.000) and must keep the settings tail untouched.
out = merge_segmented_webvtt(SEGMENTED_VTT, segment_durations=[900000, 900000], timescale=1)
assert "00:00:01.000 --> 00:00:03.000 line:10% align:center" in out
assert "00:00:04.000 --> 00:00:06.000" in out
def test_merge_splices_triple_duplicate_into_one():
# A cue spanning two boundaries appears in three consecutive segments; the
# kept cue must be extended each time (latent bug: extending the dropped one).
vtt = (
"WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nSame cue\n\n"
"WEBVTT\n\n00:00:02.000 --> 00:00:03.000\nSame cue\n\n"
"WEBVTT\n\n00:00:03.000 --> 00:00:04.000\nSame cue\n"
)
out = merge_segmented_webvtt(vtt)
assert out.count("Same cue") == 1
assert "00:00:01.000 --> 00:00:04.000" in out
def test_merge_drops_cue_ids_and_notes_keeps_style():
vtt = (
"WEBVTT\n\n"
"STYLE\n::cue { color: red }\n\n"
"NOTE a comment\n\n"
"some-cue-id-42\n00:00:01.000 --> 00:00:02.000\nHello\n"
)
out = merge_segmented_webvtt(vtt)
assert "Hello" in out
assert "some-cue-id-42" not in out
assert "NOTE" not in out
assert "STYLE\n::cue { color: red }" in out # kept; players without STYLE support ignore it
def test_merge_dedupes_repeated_style_blocks_before_cues():
# Every segment repeats the same STYLE block; keep exactly one, before the first cue.
style = "WEBVTT\n\nSTYLE\n::cue { color: red }\n\n"
vtt = style + "00:00:01.000 --> 00:00:02.000\nOne\n\n" + style + "00:00:03.000 --> 00:00:04.000\nTwo\n"
out = merge_segmented_webvtt(vtt)
assert out.count("STYLE") == 1
assert out.index("STYLE") < out.index("-->")
def test_merge_skips_malformed_timing_line():
vtt = "WEBVTT\n\ngarbage --> not-a-time\nBroken\n\n00:00:01.000 --> 00:00:02.000\nGood\n"
out = merge_segmented_webvtt(vtt)
assert "Good" in out
assert "Broken" not in out