diff --git a/CONFIG.md b/CONFIG.md index 5a5aae8..15eef05 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -659,11 +659,12 @@ Control subtitle conversion and SDH (hearing-impaired) stripping behavior. - `subby`: Always use subby with CommonIssuesFixer. - `subtitleedit`: Prefer SubtitleEdit when available; otherwise fallback to standard conversion. - `pycaption`: Use only the pycaption library (no SubtitleEdit, no subby). + - `pysubs2`: Use pysubs2 library (supports SRT, SSA, ASS, WebVTT, TTML, SAMI, MicroDVD, MPL2, TMP formats). - `sdh_method`: How to strip SDH cues. Default: `auto`. - `auto`: Try subby for SRT first, then SubtitleEdit, then filter-subs. - - `subby`: Use subby’s SDHStripper (SRT only). - - `subtitleedit`: Use SubtitleEdit’s RemoveTextForHI when available. + - `subby`: Use subby's SDHStripper (SRT only). + - `subtitleedit`: Use SubtitleEdit's RemoveTextForHI when available. - `filter-subs`: Use the subtitle-filter library. Example: diff --git a/pyproject.toml b/pyproject.toml index d9bd604..d1d43e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,11 +54,12 @@ dependencies = [ "urllib3>=2.2.1,<3", "chardet>=5.2.0,<6", "curl-cffi>=0.7.0b4,<0.8", - "pyplayready>=0.6.0,<0.7", + "pyplayready>=0.6.3,<0.7", "httpx>=0.28.1,<0.29", "cryptography>=45.0.0", "subby", "aiohttp-swagger3>=0.9.0,<1", + "pysubs2>=1.7.0,<2", ] [project.urls] @@ -114,4 +115,4 @@ no_implicit_optional = true [tool.uv.sources] unshackle = { workspace = true } -subby = { git = "https://github.com/vevv/subby.git" } +subby = { git = "https://github.com/vevv/subby.git", rev = "5a925c367ffb3f5e53fd114ae222d3be1fdff35d" } diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 9a99bfc..559cfd9 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -213,7 +213,7 @@ class dl: @click.option( "--sub-format", type=SubtitleCodecChoice(Subtitle.Codec), - default="srt", + default=None, help="Set Output Subtitle Format, only converting if necessary.", ) @click.option("-V", "--video-only", is_flag=True, default=False, help="Only download video tracks.") @@ -1701,10 +1701,14 @@ class dl: # All DecryptLabs CDMs use DecryptLabsRemoteCDM return DecryptLabsRemoteCDM(service_name=service, vaults=self.vaults, **cdm_api) else: - del cdm_api["name"] - if "type" in cdm_api: - del cdm_api["type"] - return RemoteCdm(**cdm_api) + return RemoteCdm( + device_type=cdm_api['Device Type'], + system_id=cdm_api['System ID'], + security_level=cdm_api['Security Level'], + host=cdm_api['Host'], + secret=cdm_api['Secret'], + device_name=cdm_api['Device Name'], + ) prd_path = config.directories.prds / f"{cdm_name}.prd" if not prd_path.is_file(): diff --git a/unshackle/commands/prd.py b/unshackle/commands/prd.py index 443efec..e8dcf1a 100644 --- a/unshackle/commands/prd.py +++ b/unshackle/commands/prd.py @@ -5,10 +5,10 @@ from typing import Optional import click import requests from Crypto.Random import get_random_bytes +from pyplayready import InvalidCertificateChain, OutdatedDevice from pyplayready.cdm import Cdm from pyplayready.crypto.ecc_key import ECCKey from pyplayready.device import Device -from pyplayready.misc.exceptions import InvalidCertificateChain, OutdatedDevice from pyplayready.system.bcert import Certificate, CertificateChain from pyplayready.system.pssh import PSSH diff --git a/unshackle/core/__main__.py b/unshackle/core/__main__.py index e4717fa..7aac73e 100644 --- a/unshackle/core/__main__.py +++ b/unshackle/core/__main__.py @@ -1,3 +1,9 @@ +import warnings + +# Suppress SyntaxWarning from unmaintained tinycss package (dependency of subby) +# Must be set before any imports that might trigger tinycss loading +warnings.filterwarnings("ignore", category=SyntaxWarning, module="tinycss") + import atexit import logging from pathlib import Path diff --git a/unshackle/core/manifests/dash.py b/unshackle/core/manifests/dash.py index ec19e25..56fec08 100644 --- a/unshackle/core/manifests/dash.py +++ b/unshackle/core/manifests/dash.py @@ -253,8 +253,8 @@ class DASH: ): if not session: session = Session() - elif not isinstance(session, Session): - raise TypeError(f"Expected session to be a {Session}, not {session!r}") + elif not isinstance(session, (Session, CurlSession)): + raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}") if proxy: session.proxies.update({"all": proxy}) diff --git a/unshackle/core/tracks/subtitle.py b/unshackle/core/tracks/subtitle.py index d2a5cad..e336345 100644 --- a/unshackle/core/tracks/subtitle.py +++ b/unshackle/core/tracks/subtitle.py @@ -10,6 +10,7 @@ from pathlib import Path from typing import Any, Callable, Iterable, Optional, Union import pycaption +import pysubs2 import requests from construct import Container from pycaption import Caption, CaptionList, CaptionNode, WebVTTReader @@ -33,6 +34,9 @@ class Subtitle(Track): TimedTextMarkupLang = "TTML" # https://wikipedia.org/wiki/Timed_Text_Markup_Language WebVTT = "VTT" # https://wikipedia.org/wiki/WebVTT SAMI = "SMI" # https://wikipedia.org/wiki/SAMI + MicroDVD = "SUB" # https://wikipedia.org/wiki/MicroDVD + MPL2 = "MPL2" # MPL2 subtitle format + TMP = "TMP" # TMP subtitle format # MPEG-DASH box-encapsulated subtitle formats fTTML = "STPP" # https://www.w3.org/TR/2018/REC-ttml-imsc1.0.1-20180424 fVTT = "WVTT" # https://www.w3.org/TR/webvtt1 @@ -56,6 +60,12 @@ class Subtitle(Track): return Subtitle.Codec.WebVTT elif mime in ("smi", "sami"): return Subtitle.Codec.SAMI + elif mime in ("sub", "microdvd"): + return Subtitle.Codec.MicroDVD + elif mime == "mpl2": + return Subtitle.Codec.MPL2 + elif mime == "tmp": + return Subtitle.Codec.TMP elif mime == "stpp": return Subtitle.Codec.fTTML elif mime == "wvtt": @@ -391,6 +401,57 @@ class Subtitle(Track): # Fall back to existing conversion method on any error return self._convert_standard(codec) + def convert_with_pysubs2(self, codec: Subtitle.Codec) -> Path: + """ + Convert subtitle using pysubs2 library for broad format support. + + pysubs2 is a pure-Python library supporting SubRip (SRT), SubStation Alpha + (SSA/ASS), WebVTT, TTML, SAMI, MicroDVD, MPL2, and TMP formats. + """ + if not self.path or not self.path.exists(): + raise ValueError("You must download the subtitle track first.") + + if self.codec == codec: + return self.path + + output_path = self.path.with_suffix(f".{codec.value.lower()}") + original_path = self.path + + codec_to_pysubs2_format = { + Subtitle.Codec.SubRip: "srt", + Subtitle.Codec.SubStationAlpha: "ssa", + Subtitle.Codec.SubStationAlphav4: "ass", + Subtitle.Codec.WebVTT: "vtt", + Subtitle.Codec.TimedTextMarkupLang: "ttml", + Subtitle.Codec.SAMI: "sami", + Subtitle.Codec.MicroDVD: "microdvd", + Subtitle.Codec.MPL2: "mpl2", + Subtitle.Codec.TMP: "tmp", + } + + pysubs2_output_format = codec_to_pysubs2_format.get(codec) + if pysubs2_output_format is None: + return self._convert_standard(codec) + + try: + subs = pysubs2.load(str(self.path), encoding="utf-8") + + subs.save(str(output_path), format_=pysubs2_output_format, encoding="utf-8") + + if original_path.exists() and original_path != output_path: + original_path.unlink() + + self.path = output_path + self.codec = codec + + if callable(self.OnConverted): + self.OnConverted(codec) + + return output_path + + except Exception: + return self._convert_standard(codec) + def convert(self, codec: Subtitle.Codec) -> Path: """ Convert this Subtitle to another Format. @@ -400,6 +461,7 @@ class Subtitle(Track): - 'subby': Always uses subby with CommonIssuesFixer - 'subtitleedit': Uses SubtitleEdit when available, falls back to pycaption - 'pycaption': Uses only pycaption library + - 'pysubs2': Uses pysubs2 library """ # Check configuration for conversion method conversion_method = config.subtitle.get("conversion_method", "auto") @@ -407,11 +469,12 @@ class Subtitle(Track): if conversion_method == "subby": return self.convert_with_subby(codec) elif conversion_method == "subtitleedit": - return self._convert_standard(codec) # SubtitleEdit is used in standard conversion + return self._convert_standard(codec) elif conversion_method == "pycaption": return self._convert_pycaption_only(codec) + elif conversion_method == "pysubs2": + return self.convert_with_pysubs2(codec) elif conversion_method == "auto": - # Use subby for formats it handles better if self.codec in (Subtitle.Codec.WebVTT, Subtitle.Codec.SAMI): return self.convert_with_subby(codec) else: diff --git a/unshackle/unshackle-example.yaml b/unshackle/unshackle-example.yaml index ca4f031..3b00f48 100644 --- a/unshackle/unshackle-example.yaml +++ b/unshackle/unshackle-example.yaml @@ -253,6 +253,7 @@ tmdb_api_key: "" # - subby: Always use subby with advanced processing # - pycaption: Use only pycaption library (no SubtitleEdit, no subby) # - subtitleedit: Prefer SubtitleEdit when available, fall back to pycaption +# - pysubs2: Use pysubs2 library (supports SRT/SSA/ASS/WebVTT/TTML/SAMI/MicroDVD/MPL2/TMP) subtitle: conversion_method: auto sdh_method: auto diff --git a/uv.lock b/uv.lock index 9ae2600..93ac1c5 100644 --- a/uv.lock +++ b/uv.lock @@ -1610,7 +1610,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1,<0.29" }, { name = "jsonpickle", specifier = ">=3.0.4,<4" }, { name = "langcodes", specifier = ">=3.4.0,<4" }, - { name = "lxml", specifier = ">=5.2.1,<6" }, + { name = "lxml", specifier = ">=5.2.1,<7" }, { name = "pproxy", specifier = ">=2.7.9,<3" }, { name = "protobuf", specifier = ">=4.25.3,<5" }, { name = "pycaption", specifier = ">=2.2.6,<3" },