mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 13:05:09 +00:00
Merge branch 'dev' into feat/downloader-speed
# Conflicts: # unshackle/core/downloaders/requests.py # unshackle/core/manifests/dash.py
This commit is contained in:
@@ -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"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
@@ -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"
|
||||
@@ -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 < 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 < 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 "<" 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
|
||||
Reference in New Issue
Block a user