forked from kenzuya/unshackle
Merge branch 'main' into dev
This commit is contained in:
23
CHANGELOG.md
23
CHANGELOG.md
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [2.3.1] - 2026-01-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Vulnerable Dependencies**: Upgraded dependencies to address security alerts
|
||||||
|
- urllib3: 2.5.0 → 2.6.3 (CVE-2025-66418, CVE-2025-66471, CVE-2026-21441)
|
||||||
|
- aiohttp: 3.13.2 → 3.13.3 (8 CVEs including CVE-2025-69223, CVE-2025-69227)
|
||||||
|
- fonttools: 4.60.1 → 4.61.1 (CVE-2025-66034)
|
||||||
|
- filelock: 3.19.1 → 3.20.3 (CVE-2025-68146, CVE-2026-22701)
|
||||||
|
- virtualenv: 20.34.0 → 20.36.1 (CVE-2026-22702)
|
||||||
|
- **HLS Key Selection**: Prefer media playlist keys over session keys for accurate KID matching
|
||||||
|
- Session keys from master playlists often contain PSSHs with multiple KIDs covering all tracks
|
||||||
|
- Unified DRM licensing logic for all downloaders
|
||||||
|
- Added `filter_keys_for_cdm()` to select keys matching configured CDM type
|
||||||
|
- Added `get_track_kid_from_init()` to extract KID from init segment with fallback
|
||||||
|
- Fixed PlayReady keyformat matching using strict `PR_PSSH.SYSTEM_ID` URN
|
||||||
|
- Fixes download failures where track KID was null or mismatched
|
||||||
|
- **DASH Audio Track Selection**: Include language in N_m3u8DL-RE track selection
|
||||||
|
- Fixes duplicate audio downloads when DASH manifests have multiple adaptation sets with same representation IDs
|
||||||
|
- **SubtitleEdit Compatibility**: Update CLI syntax for SubtitleEdit 4.x
|
||||||
|
- Use lowercase format names (subrip, webvtt, advancedsubstationalpha)
|
||||||
|
- Respect `conversion_method` config setting when stripping SDH
|
||||||
|
|
||||||
## [2.3.0] - 2026-01-18
|
## [2.3.0] - 2026-01-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "unshackle"
|
name = "unshackle"
|
||||||
version = "2.3.0"
|
version = "2.3.1"
|
||||||
description = "Modular Movie, TV, and Music Archival Software."
|
description = "Modular Movie, TV, and Music Archival Software."
|
||||||
authors = [{ name = "unshackle team" }]
|
authors = [{ name = "unshackle team" }]
|
||||||
requires-python = ">=3.10,<3.13"
|
requires-python = ">=3.10,<3.13"
|
||||||
@@ -31,7 +31,8 @@ dependencies = [
|
|||||||
"click>=8.1.8,<9",
|
"click>=8.1.8,<9",
|
||||||
"construct>=2.8.8,<3",
|
"construct>=2.8.8,<3",
|
||||||
"crccheck>=1.3.0,<2",
|
"crccheck>=1.3.0,<2",
|
||||||
"fonttools>=4.0.0,<5",
|
"filelock>=3.20.3,<4",
|
||||||
|
"fonttools>=4.60.2,<5",
|
||||||
"jsonpickle>=3.0.4,<5",
|
"jsonpickle>=3.0.4,<5",
|
||||||
"langcodes>=3.4.0,<4",
|
"langcodes>=3.4.0,<4",
|
||||||
"lxml>=5.2.1,<7",
|
"lxml>=5.2.1,<7",
|
||||||
@@ -52,13 +53,14 @@ dependencies = [
|
|||||||
"sortedcontainers>=2.4.0,<3",
|
"sortedcontainers>=2.4.0,<3",
|
||||||
"subtitle-filter>=1.4.9,<2",
|
"subtitle-filter>=1.4.9,<2",
|
||||||
"Unidecode>=1.3.8,<2",
|
"Unidecode>=1.3.8,<2",
|
||||||
"urllib3>=2.2.1,<3",
|
"urllib3>=2.6.3,<3",
|
||||||
"chardet>=5.2.0,<6",
|
"chardet>=5.2.0,<6",
|
||||||
"curl-cffi>=0.7.0b4,<0.14",
|
"curl-cffi>=0.7.0b4,<0.14",
|
||||||
"pyplayready>=0.6.3,<0.7",
|
"pyplayready>=0.6.3,<0.7",
|
||||||
"httpx>=0.28.1,<0.29",
|
"httpx>=0.28.1,<0.29",
|
||||||
"cryptography>=45.0.0,<47",
|
"cryptography>=45.0.0,<47",
|
||||||
"subby",
|
"subby",
|
||||||
|
"aiohttp>=3.13.3,<4",
|
||||||
"aiohttp-swagger3>=0.9.0,<1",
|
"aiohttp-swagger3>=0.9.0,<1",
|
||||||
"pysubs2>=1.7.0,<2",
|
"pysubs2>=1.7.0,<2",
|
||||||
"PyExecJS>=1.5.1,<2",
|
"PyExecJS>=1.5.1,<2",
|
||||||
@@ -78,6 +80,7 @@ unshackle = "unshackle.core.__main__:main"
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pre-commit>=3.7.0,<5",
|
"pre-commit>=3.7.0,<5",
|
||||||
|
"virtualenv>=20.36.1,<22",
|
||||||
"mypy>=1.9.0,<2",
|
"mypy>=1.9.0,<2",
|
||||||
"mypy-protobuf>=3.6.0,<4",
|
"mypy-protobuf>=3.6.0,<4",
|
||||||
"types-protobuf>=4.24.0.20240408,<7",
|
"types-protobuf>=4.24.0.20240408,<7",
|
||||||
@@ -118,4 +121,4 @@ no_implicit_optional = true
|
|||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
unshackle = { workspace = true }
|
unshackle = { workspace = true }
|
||||||
subby = { git = "https://github.com/vevv/subby.git", rev = "5a925c367ffb3f5e53fd114ae222d3be1fdff35d" }
|
subby = { git = "https://github.com/vevv/subby.git", rev = "1ea6a52028c5bea8177c8abc91716d74e4d097e1" }
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug
|
|||||||
app["config"] = {"users": []}
|
app["config"] = {"users": []}
|
||||||
else:
|
else:
|
||||||
app = web.Application(middlewares=[cors_middleware, pywidevine_serve.authentication])
|
app = web.Application(middlewares=[cors_middleware, pywidevine_serve.authentication])
|
||||||
app["config"] = {"users": [api_secret]}
|
app["config"] = {"users": {api_secret: {"devices": [], "username": "api_user"}}}
|
||||||
app["debug_api"] = debug_api
|
app["debug_api"] = debug_api
|
||||||
setup_routes(app)
|
setup_routes(app)
|
||||||
setup_swagger(app)
|
setup_swagger(app)
|
||||||
@@ -156,10 +156,14 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug
|
|||||||
app = web.Application(middlewares=[cors_middleware, pywidevine_serve.authentication])
|
app = web.Application(middlewares=[cors_middleware, pywidevine_serve.authentication])
|
||||||
# Setup config - add API secret to users for authentication
|
# Setup config - add API secret to users for authentication
|
||||||
serve_config = dict(config.serve)
|
serve_config = dict(config.serve)
|
||||||
if not serve_config.get("users"):
|
if not serve_config.get("users") or not isinstance(serve_config["users"], dict):
|
||||||
serve_config["users"] = []
|
serve_config["users"] = {}
|
||||||
if api_secret not in serve_config["users"]:
|
if api_secret not in serve_config["users"]:
|
||||||
serve_config["users"].append(api_secret)
|
device_names = [d.stem if hasattr(d, "stem") else str(d) for d in serve_config.get("devices", [])]
|
||||||
|
serve_config["users"][api_secret] = {
|
||||||
|
"devices": device_names,
|
||||||
|
"username": "api_user"
|
||||||
|
}
|
||||||
app["config"] = serve_config
|
app["config"] = serve_config
|
||||||
|
|
||||||
app.on_startup.append(pywidevine_serve._startup)
|
app.on_startup.append(pywidevine_serve._startup)
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "2.3.0"
|
__version__ = "2.3.1"
|
||||||
|
|||||||
@@ -449,8 +449,8 @@ class DecryptLabsRemoteCDM:
|
|||||||
error_msg = data.get("message", "Unknown error")
|
error_msg = data.get("message", "Unknown error")
|
||||||
if "details" in data:
|
if "details" in data:
|
||||||
error_msg += f" - Details: {data['details']}"
|
error_msg += f" - Details: {data['details']}"
|
||||||
if "error" in data:
|
if "Error" in data:
|
||||||
error_msg += f" - Error: {data['error']}"
|
error_msg += f" - Error: {data['Error']}"
|
||||||
|
|
||||||
if "service_certificate is required" in str(data) and not session["service_certificate"]:
|
if "service_certificate is required" in str(data) and not session["service_certificate"]:
|
||||||
error_msg += " (No service certificate was provided to the CDM session)"
|
error_msg += " (No service certificate was provided to the CDM session)"
|
||||||
@@ -537,8 +537,8 @@ class DecryptLabsRemoteCDM:
|
|||||||
error_msg = f"API response: {data['message']} - {error_msg}"
|
error_msg = f"API response: {data['message']} - {error_msg}"
|
||||||
if "details" in data:
|
if "details" in data:
|
||||||
error_msg += f" - Details: {data['details']}"
|
error_msg += f" - Details: {data['details']}"
|
||||||
if "error" in data:
|
if "Error" in data:
|
||||||
error_msg += f" - Error: {data['error']}"
|
error_msg += f" - Error: {data['Error']}"
|
||||||
|
|
||||||
if already_tried_cache and data.get("message") == "success":
|
if already_tried_cache and data.get("message") == "success":
|
||||||
return b""
|
return b""
|
||||||
@@ -612,8 +612,8 @@ class DecryptLabsRemoteCDM:
|
|||||||
|
|
||||||
if data.get("message") != "success":
|
if data.get("message") != "success":
|
||||||
error_msg = data.get("message", "Unknown error")
|
error_msg = data.get("message", "Unknown error")
|
||||||
if "error" in data:
|
if "Error" in data:
|
||||||
error_msg += f" - Error: {data['error']}"
|
error_msg += f" - Error: {data['Error']}"
|
||||||
if "details" in data:
|
if "details" in data:
|
||||||
error_msg += f" - Details: {data['details']}"
|
error_msg += f" - Details: {data['details']}"
|
||||||
raise requests.RequestException(f"License decrypt error: {error_msg}")
|
raise requests.RequestException(f"License decrypt error: {error_msg}")
|
||||||
|
|||||||
@@ -154,7 +154,9 @@ class PlayReady:
|
|||||||
pssh_boxes.extend(
|
pssh_boxes.extend(
|
||||||
Box.parse(base64.b64decode(x.uri.split(",")[-1]))
|
Box.parse(base64.b64decode(x.uri.split(",")[-1]))
|
||||||
for x in (master.session_keys or master.keys)
|
for x in (master.session_keys or master.keys)
|
||||||
if x and x.keyformat and "playready" in x.keyformat.lower()
|
if x and x.keyformat and x.keyformat.lower() in {
|
||||||
|
f"urn:uuid:{PSSH.SYSTEM_ID}", "com.microsoft.playready"
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
init_data = track.get_init_segment(session=session)
|
init_data = track.get_init_segment(session=session)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from functools import partial
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Optional, Union
|
from typing import Any, Callable, Optional, Union
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
from uuid import UUID
|
||||||
from zlib import crc32
|
from zlib import crc32
|
||||||
|
|
||||||
import m3u8
|
import m3u8
|
||||||
@@ -260,7 +261,9 @@ class HLS:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
playlist_text = response.text
|
playlist_text = response.text
|
||||||
else:
|
else:
|
||||||
raise TypeError(f"Expected response to be a requests.Response or curl_cffi.Response, not {type(response)}")
|
raise TypeError(
|
||||||
|
f"Expected response to be a requests.Response or curl_cffi.Response, not {type(response)}"
|
||||||
|
)
|
||||||
|
|
||||||
master = m3u8.loads(playlist_text, uri=track.url)
|
master = m3u8.loads(playlist_text, uri=track.url)
|
||||||
|
|
||||||
@@ -268,10 +271,40 @@ class HLS:
|
|||||||
log.error("Track's HLS playlist has no segments, expecting an invariant M3U8 playlist.")
|
log.error("Track's HLS playlist has no segments, expecting an invariant M3U8 playlist.")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Get session DRM as fallback but prefer media playlist keys for accurate KID matching
|
||||||
if track.drm:
|
if track.drm:
|
||||||
session_drm = track.get_drm_for_cdm(cdm)
|
session_drm = track.get_drm_for_cdm(cdm)
|
||||||
if isinstance(session_drm, (Widevine, PlayReady)):
|
else:
|
||||||
# license and grab content keys
|
session_drm = None
|
||||||
|
|
||||||
|
initial_drm_licensed = False
|
||||||
|
initial_drm_key = None # Track the EXT-X-KEY used for initial licensing
|
||||||
|
media_keys = [k for k in (master.keys or []) if k is not None]
|
||||||
|
if media_keys:
|
||||||
|
cdm_media_keys = HLS.filter_keys_for_cdm(media_keys, cdm)
|
||||||
|
media_playlist_key = HLS.get_supported_key(cdm_media_keys) if cdm_media_keys else None
|
||||||
|
|
||||||
|
if media_playlist_key:
|
||||||
|
media_drm = HLS.get_drm(media_playlist_key, session)
|
||||||
|
if isinstance(media_drm, (Widevine, PlayReady)):
|
||||||
|
track_kid = HLS.get_track_kid_from_init(master, track, session) or media_drm.kid
|
||||||
|
try:
|
||||||
|
if not license_widevine:
|
||||||
|
raise ValueError("license_widevine func must be supplied to use DRM")
|
||||||
|
progress(downloaded="LICENSING")
|
||||||
|
license_widevine(media_drm, track_kid=track_kid)
|
||||||
|
progress(downloaded="[yellow]LICENSED")
|
||||||
|
initial_drm_licensed = True
|
||||||
|
initial_drm_key = media_playlist_key
|
||||||
|
track.drm = [media_drm]
|
||||||
|
session_drm = media_drm
|
||||||
|
except Exception: # noqa
|
||||||
|
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||||
|
progress(downloaded="[red]FAILED")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Fall back to session DRM if media playlist has no matching keys
|
||||||
|
if not initial_drm_licensed and session_drm and isinstance(session_drm, (Widevine, PlayReady)):
|
||||||
try:
|
try:
|
||||||
if not license_widevine:
|
if not license_widevine:
|
||||||
raise ValueError("license_widevine func must be supplied to use DRM")
|
raise ValueError("license_widevine func must be supplied to use DRM")
|
||||||
@@ -282,8 +315,6 @@ class HLS:
|
|||||||
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
DOWNLOAD_CANCELLED.set() # skip pending track downloads
|
||||||
progress(downloaded="[red]FAILED")
|
progress(downloaded="[red]FAILED")
|
||||||
raise
|
raise
|
||||||
else:
|
|
||||||
session_drm = None
|
|
||||||
|
|
||||||
if DOWNLOAD_LICENCE_ONLY.is_set():
|
if DOWNLOAD_LICENCE_ONLY.is_set():
|
||||||
progress(downloaded="[yellow]SKIPPED")
|
progress(downloaded="[yellow]SKIPPED")
|
||||||
@@ -341,12 +372,15 @@ class HLS:
|
|||||||
|
|
||||||
if downloader.__name__ == "n_m3u8dl_re":
|
if downloader.__name__ == "n_m3u8dl_re":
|
||||||
skip_merge = True
|
skip_merge = True
|
||||||
|
# session_drm already has correct content_keys from initial licensing above
|
||||||
|
n_m3u8dl_content_keys = session_drm.content_keys if session_drm else None
|
||||||
|
|
||||||
downloader_args.update(
|
downloader_args.update(
|
||||||
{
|
{
|
||||||
"output_dir": save_dir,
|
"output_dir": save_dir,
|
||||||
"filename": track.id,
|
"filename": track.id,
|
||||||
"track": track,
|
"track": track,
|
||||||
"content_keys": session_drm.content_keys if session_drm else None,
|
"content_keys": n_m3u8dl_content_keys,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -390,7 +424,7 @@ class HLS:
|
|||||||
range_offset = 0
|
range_offset = 0
|
||||||
map_data: Optional[tuple[m3u8.model.InitializationSection, bytes]] = None
|
map_data: Optional[tuple[m3u8.model.InitializationSection, bytes]] = None
|
||||||
if session_drm:
|
if session_drm:
|
||||||
encryption_data: Optional[tuple[Optional[m3u8.Key], DRM_T]] = (None, session_drm)
|
encryption_data: Optional[tuple[Optional[m3u8.Key], DRM_T]] = (initial_drm_key, session_drm)
|
||||||
else:
|
else:
|
||||||
encryption_data: Optional[tuple[Optional[m3u8.Key], DRM_T]] = None
|
encryption_data: Optional[tuple[Optional[m3u8.Key], DRM_T]] = None
|
||||||
|
|
||||||
@@ -571,6 +605,8 @@ class HLS:
|
|||||||
track_kid = track.get_key_id(map_data[1])
|
track_kid = track.get_key_id(map_data[1])
|
||||||
else:
|
else:
|
||||||
track_kid = None
|
track_kid = None
|
||||||
|
if not track_kid:
|
||||||
|
track_kid = drm.kid
|
||||||
progress(downloaded="LICENSING")
|
progress(downloaded="LICENSING")
|
||||||
license_widevine(drm, track_kid=track_kid)
|
license_widevine(drm, track_kid=track_kid)
|
||||||
progress(downloaded="[yellow]LICENSED")
|
progress(downloaded="[yellow]LICENSED")
|
||||||
@@ -770,6 +806,60 @@ class HLS:
|
|||||||
|
|
||||||
return keys
|
return keys
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def filter_keys_for_cdm(
|
||||||
|
keys: list[Union[m3u8.model.SessionKey, m3u8.model.Key]],
|
||||||
|
cdm: object,
|
||||||
|
) -> list[Union[m3u8.model.SessionKey, m3u8.model.Key]]:
|
||||||
|
"""
|
||||||
|
Filter EXT-X-KEY entries to only include those matching the CDM type.
|
||||||
|
|
||||||
|
This ensures we select the correct DRM system (Widevine vs PlayReady)
|
||||||
|
based on what CDM is configured, avoiding license request failures.
|
||||||
|
"""
|
||||||
|
playready_urn = f"urn:uuid:{PR_PSSH.SYSTEM_ID}"
|
||||||
|
playready_keyformats = {playready_urn, "com.microsoft.playready"}
|
||||||
|
if isinstance(cdm, WidevineCdm):
|
||||||
|
return [k for k in keys if k.keyformat and k.keyformat.lower() == WidevineCdm.urn]
|
||||||
|
elif isinstance(cdm, PlayReadyCdm):
|
||||||
|
return [k for k in keys if k.keyformat and k.keyformat.lower() in playready_keyformats]
|
||||||
|
elif hasattr(cdm, "is_playready"):
|
||||||
|
if cdm.is_playready:
|
||||||
|
return [k for k in keys if k.keyformat and k.keyformat.lower() in playready_keyformats]
|
||||||
|
else:
|
||||||
|
return [k for k in keys if k.keyformat and k.keyformat.lower() == WidevineCdm.urn]
|
||||||
|
return keys
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_track_kid_from_init(
|
||||||
|
master: M3U8,
|
||||||
|
track: AnyTrack,
|
||||||
|
session: Union[Session, CurlSession],
|
||||||
|
) -> Optional[UUID]:
|
||||||
|
"""
|
||||||
|
Extract the track's Key ID from its init segment (EXT-X-MAP).
|
||||||
|
|
||||||
|
Returns None if no init segment exists or KID extraction fails.
|
||||||
|
The caller should fall back to drm.kid from the PSSH if this returns None.
|
||||||
|
"""
|
||||||
|
map_section = next((seg.init_section for seg in master.segments if seg.init_section), None)
|
||||||
|
if not map_section:
|
||||||
|
return None
|
||||||
|
|
||||||
|
map_uri = urljoin(map_section.base_uri or master.base_uri or "", map_section.uri)
|
||||||
|
try:
|
||||||
|
if map_section.byterange:
|
||||||
|
byte_range = HLS.calculate_byte_range(map_section.byterange, 0)
|
||||||
|
headers = {"Range": f"bytes={byte_range}"}
|
||||||
|
else:
|
||||||
|
headers = {}
|
||||||
|
map_res = session.get(url=map_uri, headers=headers)
|
||||||
|
if map_res.ok:
|
||||||
|
return track.get_key_id(map_res.content)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_supported_key(keys: list[Union[m3u8.model.SessionKey, m3u8.model.Key]]) -> Optional[m3u8.Key]:
|
def get_supported_key(keys: list[Union[m3u8.model.SessionKey, m3u8.model.Key]]) -> Optional[m3u8.Key]:
|
||||||
"""
|
"""
|
||||||
@@ -798,9 +888,9 @@ class HLS:
|
|||||||
return key
|
return key
|
||||||
elif key.keyformat and key.keyformat.lower() == WidevineCdm.urn:
|
elif key.keyformat and key.keyformat.lower() == WidevineCdm.urn:
|
||||||
return key
|
return key
|
||||||
elif key.keyformat and (
|
elif key.keyformat and key.keyformat.lower() in {
|
||||||
key.keyformat.lower() == PlayReadyCdm or "com.microsoft.playready" in key.keyformat.lower()
|
f"urn:uuid:{PR_PSSH.SYSTEM_ID}", "com.microsoft.playready"
|
||||||
):
|
}:
|
||||||
return key
|
return key
|
||||||
else:
|
else:
|
||||||
unsupported_systems.append(key.method + (f" ({key.keyformat})" if key.keyformat else ""))
|
unsupported_systems.append(key.method + (f" ({key.keyformat})" if key.keyformat else ""))
|
||||||
@@ -837,9 +927,9 @@ class HLS:
|
|||||||
pssh=WV_PSSH(key.uri.split(",")[-1]),
|
pssh=WV_PSSH(key.uri.split(",")[-1]),
|
||||||
**key._extra_params, # noqa
|
**key._extra_params, # noqa
|
||||||
)
|
)
|
||||||
elif key.keyformat and (
|
elif key.keyformat and key.keyformat.lower() in {
|
||||||
key.keyformat.lower() == PlayReadyCdm or "com.microsoft.playready" in key.keyformat.lower()
|
f"urn:uuid:{PR_PSSH.SYSTEM_ID}", "com.microsoft.playready"
|
||||||
):
|
}:
|
||||||
drm = PlayReady(
|
drm = PlayReady(
|
||||||
pssh=PR_PSSH(key.uri.split(",")[-1]),
|
pssh=PR_PSSH(key.uri.split(",")[-1]),
|
||||||
pssh_b64=key.uri.split(",")[-1],
|
pssh_b64=key.uri.split(",")[-1],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
@@ -16,7 +17,7 @@ from construct import Container
|
|||||||
from pycaption import Caption, CaptionList, CaptionNode, WebVTTReader
|
from pycaption import Caption, CaptionList, CaptionNode, WebVTTReader
|
||||||
from pycaption.geometry import Layout
|
from pycaption.geometry import Layout
|
||||||
from pymp4.parser import MP4
|
from pymp4.parser import MP4
|
||||||
from subby import CommonIssuesFixer, SAMIConverter, SDHStripper, WebVTTConverter
|
from subby import CommonIssuesFixer, SAMIConverter, SDHStripper, WebVTTConverter, WVTTConverter
|
||||||
from subtitle_filter import Subtitles
|
from subtitle_filter import Subtitles
|
||||||
|
|
||||||
from unshackle.core import binaries
|
from unshackle.core import binaries
|
||||||
@@ -25,6 +26,9 @@ from unshackle.core.tracks.track import Track
|
|||||||
from unshackle.core.utilities import try_ensure_utf8
|
from unshackle.core.utilities import try_ensure_utf8
|
||||||
from unshackle.core.utils.webvtt import merge_segmented_webvtt
|
from unshackle.core.utils.webvtt import merge_segmented_webvtt
|
||||||
|
|
||||||
|
# silence srt library INFO logging
|
||||||
|
logging.getLogger("srt").setLevel(logging.ERROR)
|
||||||
|
|
||||||
|
|
||||||
class Subtitle(Track):
|
class Subtitle(Track):
|
||||||
class Codec(str, Enum):
|
class Codec(str, Enum):
|
||||||
@@ -595,10 +599,13 @@ class Subtitle(Track):
|
|||||||
|
|
||||||
if self.codec == Subtitle.Codec.WebVTT:
|
if self.codec == Subtitle.Codec.WebVTT:
|
||||||
converter = WebVTTConverter()
|
converter = WebVTTConverter()
|
||||||
srt_subtitles = converter.from_file(str(self.path))
|
srt_subtitles = converter.from_file(self.path)
|
||||||
|
if self.codec == Subtitle.Codec.fVTT:
|
||||||
|
converter = WVTTConverter()
|
||||||
|
srt_subtitles = converter.from_file(self.path)
|
||||||
elif self.codec == Subtitle.Codec.SAMI:
|
elif self.codec == Subtitle.Codec.SAMI:
|
||||||
converter = SAMIConverter()
|
converter = SAMIConverter()
|
||||||
srt_subtitles = converter.from_file(str(self.path))
|
srt_subtitles = converter.from_file(self.path)
|
||||||
|
|
||||||
if srt_subtitles is not None:
|
if srt_subtitles is not None:
|
||||||
# Apply common fixes
|
# Apply common fixes
|
||||||
@@ -607,11 +614,11 @@ class Subtitle(Track):
|
|||||||
|
|
||||||
# If target is SRT, we're done
|
# If target is SRT, we're done
|
||||||
if codec == Subtitle.Codec.SubRip:
|
if codec == Subtitle.Codec.SubRip:
|
||||||
output_path.write_text(str(fixed_srt), encoding="utf8")
|
fixed_srt.save(output_path, encoding="utf8")
|
||||||
else:
|
else:
|
||||||
# Convert from SRT to target format using existing pycaption logic
|
# Convert from SRT to target format using existing pycaption logic
|
||||||
temp_srt_path = self.path.with_suffix(".temp.srt")
|
temp_srt_path = self.path.with_suffix(".temp.srt")
|
||||||
temp_srt_path.write_text(str(fixed_srt), encoding="utf8")
|
fixed_srt.save(temp_srt_path, encoding="utf8")
|
||||||
|
|
||||||
# Parse the SRT and convert to target format
|
# Parse the SRT and convert to target format
|
||||||
caption_set = self.parse(temp_srt_path.read_bytes(), Subtitle.Codec.SubRip)
|
caption_set = self.parse(temp_srt_path.read_bytes(), Subtitle.Codec.SubRip)
|
||||||
@@ -724,7 +731,7 @@ class Subtitle(Track):
|
|||||||
elif conversion_method == "pysubs2":
|
elif conversion_method == "pysubs2":
|
||||||
return self.convert_with_pysubs2(codec)
|
return self.convert_with_pysubs2(codec)
|
||||||
elif conversion_method == "auto":
|
elif conversion_method == "auto":
|
||||||
if self.codec in (Subtitle.Codec.WebVTT, Subtitle.Codec.SAMI):
|
if self.codec in (Subtitle.Codec.WebVTT, Subtitle.Codec.fVTT, Subtitle.Codec.SAMI):
|
||||||
return self.convert_with_subby(codec)
|
return self.convert_with_subby(codec)
|
||||||
else:
|
else:
|
||||||
return self._convert_standard(codec)
|
return self._convert_standard(codec)
|
||||||
@@ -1177,9 +1184,12 @@ class Subtitle(Track):
|
|||||||
|
|
||||||
if sdh_method == "subby" and self.codec == Subtitle.Codec.SubRip:
|
if sdh_method == "subby" and self.codec == Subtitle.Codec.SubRip:
|
||||||
# Use subby's SDHStripper directly on the file
|
# Use subby's SDHStripper directly on the file
|
||||||
|
fixer = CommonIssuesFixer()
|
||||||
stripper = SDHStripper()
|
stripper = SDHStripper()
|
||||||
stripped_srt, _ = stripper.from_file(str(self.path))
|
srt, _ = fixer.from_file(self.path)
|
||||||
self.path.write_text(str(stripped_srt), encoding="utf8")
|
stripped, status = stripper.from_srt(srt)
|
||||||
|
if status is True:
|
||||||
|
stripped.save(self.path)
|
||||||
return
|
return
|
||||||
elif sdh_method == "subtitleedit" and binaries.SubtitleEdit:
|
elif sdh_method == "subtitleedit" and binaries.SubtitleEdit:
|
||||||
# Force use of SubtitleEdit
|
# Force use of SubtitleEdit
|
||||||
@@ -1205,9 +1215,12 @@ class Subtitle(Track):
|
|||||||
# Try subby first for SRT files, then fall back
|
# Try subby first for SRT files, then fall back
|
||||||
if self.codec == Subtitle.Codec.SubRip:
|
if self.codec == Subtitle.Codec.SubRip:
|
||||||
try:
|
try:
|
||||||
|
fixer = CommonIssuesFixer()
|
||||||
stripper = SDHStripper()
|
stripper = SDHStripper()
|
||||||
stripped_srt, _ = stripper.from_file(str(self.path))
|
srt, _ = fixer.from_file(self.path)
|
||||||
self.path.write_text(str(stripped_srt), encoding="utf8")
|
stripped, status = stripper.from_srt(srt)
|
||||||
|
if status is True:
|
||||||
|
stripped.save(self.path)
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # Fall through to other methods
|
pass # Fall through to other methods
|
||||||
|
|||||||
@@ -215,7 +215,8 @@ class Track:
|
|||||||
# or when the subtitle has a direct file extension
|
# or when the subtitle has a direct file extension
|
||||||
if self.downloader.__name__ == "n_m3u8dl_re" and (
|
if self.downloader.__name__ == "n_m3u8dl_re" and (
|
||||||
self.descriptor == self.Descriptor.URL
|
self.descriptor == self.Descriptor.URL
|
||||||
or get_extension(self.url) in {
|
or get_extension(self.url)
|
||||||
|
in {
|
||||||
".srt",
|
".srt",
|
||||||
".vtt",
|
".vtt",
|
||||||
".ttml",
|
".ttml",
|
||||||
@@ -303,7 +304,9 @@ class Track:
|
|||||||
try:
|
try:
|
||||||
self.drm = [Widevine.from_track(self, session)]
|
self.drm = [Widevine.from_track(self, session)]
|
||||||
except Widevine.Exceptions.PSSHNotFound:
|
except Widevine.Exceptions.PSSHNotFound:
|
||||||
log.warning("No PlayReady or Widevine PSSH was found for this track, is it DRM free?")
|
log.warning(
|
||||||
|
"No PlayReady or Widevine PSSH was found for this track, is it DRM free?"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
self.drm = [Widevine.from_track(self, session)]
|
self.drm = [Widevine.from_track(self, session)]
|
||||||
@@ -311,7 +314,9 @@ class Track:
|
|||||||
try:
|
try:
|
||||||
self.drm = [PlayReady.from_track(self, session)]
|
self.drm = [PlayReady.from_track(self, session)]
|
||||||
except PlayReady.Exceptions.PSSHNotFound:
|
except PlayReady.Exceptions.PSSHNotFound:
|
||||||
log.warning("No Widevine or PlayReady PSSH was found for this track, is it DRM free?")
|
log.warning(
|
||||||
|
"No Widevine or PlayReady PSSH was found for this track, is it DRM free?"
|
||||||
|
)
|
||||||
|
|
||||||
if self.drm:
|
if self.drm:
|
||||||
track_kid = self.get_key_id(session=session)
|
track_kid = self.get_key_id(session=session)
|
||||||
@@ -548,7 +553,6 @@ class Track:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
import m3u8
|
import m3u8
|
||||||
from pyplayready.cdm import Cdm as PlayReadyCdm
|
|
||||||
from pyplayready.system.pssh import PSSH as PR_PSSH
|
from pyplayready.system.pssh import PSSH as PR_PSSH
|
||||||
from pywidevine.cdm import Cdm as WidevineCdm
|
from pywidevine.cdm import Cdm as WidevineCdm
|
||||||
from pywidevine.pssh import PSSH as WV_PSSH
|
from pywidevine.pssh import PSSH as WV_PSSH
|
||||||
@@ -569,7 +573,7 @@ class Track:
|
|||||||
pssh_b64 = key.uri.split(",")[-1]
|
pssh_b64 = key.uri.split(",")[-1]
|
||||||
drm = Widevine(pssh=WV_PSSH(pssh_b64))
|
drm = Widevine(pssh=WV_PSSH(pssh_b64))
|
||||||
drm_list.append(drm)
|
drm_list.append(drm)
|
||||||
elif fmt == PlayReadyCdm or "com.microsoft.playready" in fmt:
|
elif fmt in {f"urn:uuid:{PR_PSSH.SYSTEM_ID}", "com.microsoft.playready"}:
|
||||||
pssh_b64 = key.uri.split(",")[-1]
|
pssh_b64 = key.uri.split(",")[-1]
|
||||||
drm = PlayReady(pssh=PR_PSSH(pssh_b64), pssh_b64=pssh_b64)
|
drm = PlayReady(pssh=PR_PSSH(pssh_b64), pssh_b64=pssh_b64)
|
||||||
drm_list.append(drm)
|
drm_list.append(drm)
|
||||||
|
|||||||
Reference in New Issue
Block a user