Merge branch 'main' into dev

This commit is contained in:
Andy
2026-01-22 14:47:25 -07:00
10 changed files with 1062 additions and 896 deletions

View File

@@ -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/),
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
### Added

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "unshackle"
version = "2.3.0"
version = "2.3.1"
description = "Modular Movie, TV, and Music Archival Software."
authors = [{ name = "unshackle team" }]
requires-python = ">=3.10,<3.13"
@@ -31,7 +31,8 @@ dependencies = [
"click>=8.1.8,<9",
"construct>=2.8.8,<3",
"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",
"langcodes>=3.4.0,<4",
"lxml>=5.2.1,<7",
@@ -52,13 +53,14 @@ dependencies = [
"sortedcontainers>=2.4.0,<3",
"subtitle-filter>=1.4.9,<2",
"Unidecode>=1.3.8,<2",
"urllib3>=2.2.1,<3",
"urllib3>=2.6.3,<3",
"chardet>=5.2.0,<6",
"curl-cffi>=0.7.0b4,<0.14",
"pyplayready>=0.6.3,<0.7",
"httpx>=0.28.1,<0.29",
"cryptography>=45.0.0,<47",
"subby",
"aiohttp>=3.13.3,<4",
"aiohttp-swagger3>=0.9.0,<1",
"pysubs2>=1.7.0,<2",
"PyExecJS>=1.5.1,<2",
@@ -78,6 +80,7 @@ unshackle = "unshackle.core.__main__:main"
[dependency-groups]
dev = [
"pre-commit>=3.7.0,<5",
"virtualenv>=20.36.1,<22",
"mypy>=1.9.0,<2",
"mypy-protobuf>=3.6.0,<4",
"types-protobuf>=4.24.0.20240408,<7",
@@ -118,4 +121,4 @@ no_implicit_optional = true
[tool.uv.sources]
unshackle = { workspace = true }
subby = { git = "https://github.com/vevv/subby.git", rev = "5a925c367ffb3f5e53fd114ae222d3be1fdff35d" }
subby = { git = "https://github.com/vevv/subby.git", rev = "1ea6a52028c5bea8177c8abc91716d74e4d097e1" }

View File

@@ -135,7 +135,7 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug
app["config"] = {"users": []}
else:
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
setup_routes(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])
# Setup config - add API secret to users for authentication
serve_config = dict(config.serve)
if not serve_config.get("users"):
serve_config["users"] = []
if not serve_config.get("users") or not isinstance(serve_config["users"], dict):
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.on_startup.append(pywidevine_serve._startup)

View File

@@ -1 +1 @@
__version__ = "2.3.0"
__version__ = "2.3.1"

View File

@@ -449,8 +449,8 @@ class DecryptLabsRemoteCDM:
error_msg = data.get("message", "Unknown error")
if "details" in data:
error_msg += f" - Details: {data['details']}"
if "error" in data:
error_msg += f" - Error: {data['error']}"
if "Error" in data:
error_msg += f" - Error: {data['Error']}"
if "service_certificate is required" in str(data) and not session["service_certificate"]:
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}"
if "details" in data:
error_msg += f" - Details: {data['details']}"
if "error" in data:
error_msg += f" - Error: {data['error']}"
if "Error" in data:
error_msg += f" - Error: {data['Error']}"
if already_tried_cache and data.get("message") == "success":
return b""
@@ -612,8 +612,8 @@ class DecryptLabsRemoteCDM:
if data.get("message") != "success":
error_msg = data.get("message", "Unknown error")
if "error" in data:
error_msg += f" - Error: {data['error']}"
if "Error" in data:
error_msg += f" - Error: {data['Error']}"
if "details" in data:
error_msg += f" - Details: {data['details']}"
raise requests.RequestException(f"License decrypt error: {error_msg}")

View File

@@ -154,7 +154,9 @@ class PlayReady:
pssh_boxes.extend(
Box.parse(base64.b64decode(x.uri.split(",")[-1]))
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)

View File

@@ -12,6 +12,7 @@ from functools import partial
from pathlib import Path
from typing import Any, Callable, Optional, Union
from urllib.parse import urljoin
from uuid import UUID
from zlib import crc32
import m3u8
@@ -260,7 +261,9 @@ class HLS:
sys.exit(1)
playlist_text = response.text
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)
@@ -268,23 +271,51 @@ class HLS:
log.error("Track's HLS playlist has no segments, expecting an invariant M3U8 playlist.")
sys.exit(1)
# Get session DRM as fallback but prefer media playlist keys for accurate KID matching
if track.drm:
session_drm = track.get_drm_for_cdm(cdm)
if isinstance(session_drm, (Widevine, PlayReady)):
# license and grab content keys
try:
if not license_widevine:
raise ValueError("license_widevine func must be supplied to use DRM")
progress(downloaded="LICENSING")
license_widevine(session_drm)
progress(downloaded="[yellow]LICENSED")
except Exception: # noqa
DOWNLOAD_CANCELLED.set() # skip pending track downloads
progress(downloaded="[red]FAILED")
raise
else:
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:
if not license_widevine:
raise ValueError("license_widevine func must be supplied to use DRM")
progress(downloaded="LICENSING")
license_widevine(session_drm)
progress(downloaded="[yellow]LICENSED")
except Exception: # noqa
DOWNLOAD_CANCELLED.set() # skip pending track downloads
progress(downloaded="[red]FAILED")
raise
if DOWNLOAD_LICENCE_ONLY.is_set():
progress(downloaded="[yellow]SKIPPED")
return
@@ -341,12 +372,15 @@ class HLS:
if downloader.__name__ == "n_m3u8dl_re":
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(
{
"output_dir": save_dir,
"filename": track.id,
"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
map_data: Optional[tuple[m3u8.model.InitializationSection, bytes]] = None
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:
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])
else:
track_kid = None
if not track_kid:
track_kid = drm.kid
progress(downloaded="LICENSING")
license_widevine(drm, track_kid=track_kid)
progress(downloaded="[yellow]LICENSED")
@@ -770,6 +806,60 @@ class HLS:
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
def get_supported_key(keys: list[Union[m3u8.model.SessionKey, m3u8.model.Key]]) -> Optional[m3u8.Key]:
"""
@@ -798,9 +888,9 @@ class HLS:
return key
elif key.keyformat and key.keyformat.lower() == WidevineCdm.urn:
return key
elif key.keyformat and (
key.keyformat.lower() == PlayReadyCdm or "com.microsoft.playready" in key.keyformat.lower()
):
elif key.keyformat and key.keyformat.lower() in {
f"urn:uuid:{PR_PSSH.SYSTEM_ID}", "com.microsoft.playready"
}:
return key
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]),
**key._extra_params, # noqa
)
elif key.keyformat and (
key.keyformat.lower() == PlayReadyCdm or "com.microsoft.playready" in key.keyformat.lower()
):
elif key.keyformat and key.keyformat.lower() in {
f"urn:uuid:{PR_PSSH.SYSTEM_ID}", "com.microsoft.playready"
}:
drm = PlayReady(
pssh=PR_PSSH(key.uri.split(",")[-1]),
pssh_b64=key.uri.split(",")[-1],

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import logging
import re
import subprocess
from collections import defaultdict
@@ -16,7 +17,7 @@ from construct import Container
from pycaption import Caption, CaptionList, CaptionNode, WebVTTReader
from pycaption.geometry import Layout
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 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.utils.webvtt import merge_segmented_webvtt
# silence srt library INFO logging
logging.getLogger("srt").setLevel(logging.ERROR)
class Subtitle(Track):
class Codec(str, Enum):
@@ -595,10 +599,13 @@ class Subtitle(Track):
if self.codec == Subtitle.Codec.WebVTT:
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:
converter = SAMIConverter()
srt_subtitles = converter.from_file(str(self.path))
srt_subtitles = converter.from_file(self.path)
if srt_subtitles is not None:
# Apply common fixes
@@ -607,11 +614,11 @@ class Subtitle(Track):
# If target is SRT, we're done
if codec == Subtitle.Codec.SubRip:
output_path.write_text(str(fixed_srt), encoding="utf8")
fixed_srt.save(output_path, encoding="utf8")
else:
# Convert from SRT to target format using existing pycaption logic
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
caption_set = self.parse(temp_srt_path.read_bytes(), Subtitle.Codec.SubRip)
@@ -724,7 +731,7 @@ class Subtitle(Track):
elif conversion_method == "pysubs2":
return self.convert_with_pysubs2(codec)
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)
else:
return self._convert_standard(codec)
@@ -1177,9 +1184,12 @@ class Subtitle(Track):
if sdh_method == "subby" and self.codec == Subtitle.Codec.SubRip:
# Use subby's SDHStripper directly on the file
fixer = CommonIssuesFixer()
stripper = SDHStripper()
stripped_srt, _ = stripper.from_file(str(self.path))
self.path.write_text(str(stripped_srt), encoding="utf8")
srt, _ = fixer.from_file(self.path)
stripped, status = stripper.from_srt(srt)
if status is True:
stripped.save(self.path)
return
elif sdh_method == "subtitleedit" and binaries.SubtitleEdit:
# Force use of SubtitleEdit
@@ -1205,9 +1215,12 @@ class Subtitle(Track):
# Try subby first for SRT files, then fall back
if self.codec == Subtitle.Codec.SubRip:
try:
fixer = CommonIssuesFixer()
stripper = SDHStripper()
stripped_srt, _ = stripper.from_file(str(self.path))
self.path.write_text(str(stripped_srt), encoding="utf8")
srt, _ = fixer.from_file(self.path)
stripped, status = stripper.from_srt(srt)
if status is True:
stripped.save(self.path)
return
except Exception:
pass # Fall through to other methods

View File

@@ -215,7 +215,8 @@ class Track:
# or when the subtitle has a direct file extension
if self.downloader.__name__ == "n_m3u8dl_re" and (
self.descriptor == self.Descriptor.URL
or get_extension(self.url) in {
or get_extension(self.url)
in {
".srt",
".vtt",
".ttml",
@@ -303,7 +304,9 @@ class Track:
try:
self.drm = [Widevine.from_track(self, session)]
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:
try:
self.drm = [Widevine.from_track(self, session)]
@@ -311,7 +314,9 @@ class Track:
try:
self.drm = [PlayReady.from_track(self, session)]
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:
track_kid = self.get_key_id(session=session)
@@ -548,7 +553,6 @@ class Track:
try:
import m3u8
from pyplayready.cdm import Cdm as PlayReadyCdm
from pyplayready.system.pssh import PSSH as PR_PSSH
from pywidevine.cdm import Cdm as WidevineCdm
from pywidevine.pssh import PSSH as WV_PSSH
@@ -569,7 +573,7 @@ class Track:
pssh_b64 = key.uri.split(",")[-1]
drm = Widevine(pssh=WV_PSSH(pssh_b64))
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]
drm = PlayReady(pssh=PR_PSSH(pssh_b64), pssh_b64=pssh_b64)
drm_list.append(drm)

1715
uv.lock generated

File diff suppressed because it is too large Load Diff