5 Commits

Author SHA1 Message Date
Andy
cee7d9a75f fix(n_m3u8dl_re): pass all content keys for DualKey DRM decryption 2026-02-15 13:37:49 -07:00
Andy
bf9087a1ce chore(release): bump version to 3.0.0
BREAKING CHANGE: PlayReady users without explicit playready_devices no longer get access to all devices by default.

Key changes:
- feat(drm): add MonaLisa DRM support to core infrastructure
- feat(cdm): add remote PlayReady CDM support via pyplayready RemoteCdm
- feat(serve): add PlayReady CDM support alongside Widevine
- feat(gluetun): Gluetun VPN integration and Windscribe support
- feat(audio): codec lists and split muxing
- feat(tracks): prioritize Atmos audio tracks over higher bitrate non-Atmos
- feat(video): detect interlaced scan type from MPD manifests
- feat(cdm): normalize CDM detection for local and remote implementations
- fix(serve)!: make PlayReady users config consistently a mapping
- 50+ additional bug fixes across HLS/DASH, proxies, subtitles, and more
2026-02-15 13:04:42 -07:00
Andy
23cc351f77 feat(tracks): prioritize Atmos audio tracks over higher bitrate non-Atmos 2026-02-15 12:08:27 -07:00
Andy
132d3549f9 fix(main): update copyright year dynamically in version display 2026-02-11 16:01:33 -07:00
Andy
3ee554401a feat(HLS): improve audio codec handling with error handling for codec extraction 2026-02-10 08:34:54 -07:00
13 changed files with 106 additions and 95 deletions

View File

@@ -6,7 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
This changelog is automatically generated using [git-cliff](https://git-cliff.org).
## [Unreleased]
## [3.0.0] - 2026-02-15
### Features
@@ -21,6 +21,9 @@ This changelog is automatically generated using [git-cliff](https://git-cliff.or
- *drm*: Add MonaLisa DRM support to core infrastructure
- *audio*: Codec lists and split muxing
- *proxy*: Add specific server selection for WindscribeVPN
- *cdm*: Normalize CDM detection for local and remote implementations
- *HLS*: Improve audio codec handling with error handling for codec extraction
- *tracks*: Prioritize Atmos audio tracks over higher bitrate non-Atmos
### Bug Fixes
@@ -53,11 +56,39 @@ This changelog is automatically generated using [git-cliff](https://git-cliff.or
- *dl*: Always clean up hybrid temp hevc outputs
- *hls*: Finalize n_m3u8dl_re outputs
- *downloader*: Restore requests progress for single-url downloads
- *dl*: Invert audio codec suffixing when splitting
- *dl*: Support snake_case keys for RemoteCdm
- *aria2c*: Warn on config mismatch and wait for RPC ready
- *serve*: [**breaking**] Make PlayReady users config consistently a mapping
- *dl*: Preserve proxy_query selector (not resolved URI)
- *gluetun*: Stop leaking proxy/vpn secrets to process list
- *monalisa*: Avoid leaking secrets and add worker safety
- *dl*: Avoid selecting all variants when multiple audio codecs requested
- *hls*: Keep range offset numeric and align MonaLisa licensing
- *titles*: Remove trailing space from HDR dynamic range label
- *config*: Normalize playready_remote remote_cdm keys
- *titles*: Avoid None/double spaces in HDR tokens
- *naming*: Keep technical tokens with scene_naming off
- *api*: Log PSSH extraction failures
- *proxies*: Harden surfshark and windscribe selection
- *service*: Redact proxy credentials in logs
- *monalisa*: Harden wasm calls and license handling
- *hls*: Remove no-op encryption_data reassignment
- *serve*: Default PlayReady access to none
- *tracks*: Close temp session and improve path type error
- *main*: Update copyright year dynamically in version display
### Reverts
- *monalisa*: Pass key via argv again
### Documentation
- Add configuration documentation WIP
- *changelog*: Add 2.4.0 release notes
- *changelog*: Update cliff config and regenerate changelog
- *changelog*: Complete 2.4.0 notes
- *config*: Clarify sdh_method uses subtitle-filter
### Performance Improvements
@@ -451,7 +482,7 @@ This changelog is automatically generated using [git-cliff](https://git-cliff.or
- Reorganize Planned Features section in README for clarity
- Improve track selection logic in dl.py
[unreleased]: https://github.com/unshackle-dl/unshackle/compare/2.3.0..HEAD
[3.0.0]: https://github.com/unshackle-dl/unshackle/compare/2.3.0..3.0.0
[2.3.0]: https://github.com/unshackle-dl/unshackle/compare/2.2.0..2.3.0
[2.2.0]: https://github.com/unshackle-dl/unshackle/compare/2.1.0..2.2.0
[2.1.0]: https://github.com/unshackle-dl/unshackle/compare/2.0.0..2.1.0

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "unshackle"
version = "2.4.0"
version = "3.0.0"
description = "Modular Movie, TV, and Music Archival Software."
authors = [{ name = "unshackle team" }]
requires-python = ">=3.10,<3.13"
@@ -57,7 +57,7 @@ dependencies = [
"chardet>=5.2.0,<6",
"curl-cffi>=0.7.0b4,<0.14",
"pyplayready>=0.6.3,<0.7",
"httpx[http2]>=0.28.1,<0.29",
"httpx>=0.28.1,<0.29",
"cryptography>=45.0.0,<47",
"subby",
"aiohttp>=3.13.3,<4",

View File

@@ -1 +1 @@
__version__ = "2.4.0"
__version__ = "3.0.0"

View File

@@ -1,5 +1,6 @@
import atexit
import logging
from datetime import datetime
import click
import urllib3
@@ -58,7 +59,7 @@ def main(version: bool, debug: bool) -> None:
r" ▀▀▀ ▀▀ █▪ ▀▀▀▀ ▀▀▀ · ▀ ▀ ·▀▀▀ ·▀ ▀.▀▀▀ ▀▀▀ ",
style="ascii.art",
),
f"v [repr.number]{__version__}[/] - © 2025 - github.com/unshackle-dl/unshackle",
f"v [repr.number]{__version__}[/] - © 2025-{datetime.now().year} - github.com/unshackle-dl/unshackle",
),
(1, 11, 1, 10),
expand=True,

View File

@@ -192,8 +192,10 @@ def build_download_args(
if ad_keyword:
args["--ad-keyword"] = ad_keyword
key_args = []
if content_keys:
args["--key"] = next((f"{kid.hex}:{key.lower()}" for kid, key in content_keys.items()), None)
for kid, key in content_keys.items():
key_args.extend(["--key", f"{kid.hex}:{key.lower()}"])
decryption_config = config.decryption.lower()
engine_name = DECRYPTION_ENGINE.get(decryption_config) or "SHAKA_PACKAGER"
@@ -221,6 +223,9 @@ def build_download_args(
elif value is not False and value is not None:
command.extend([flag, str(value)])
# Append all content keys (multiple --key flags supported by N_m3u8DL-RE)
command.extend(key_args)
if headers:
for key, value in headers.items():
if key.lower() not in ("accept-encoding", "cookie"):

View File

@@ -11,7 +11,6 @@ from Cryptodome.Util.Padding import unpad
from curl_cffi.requests import Session as CurlSession
from m3u8.model import Key
from requests import Session
import httpx
class ClearKey:
@@ -71,8 +70,8 @@ class ClearKey:
"""
if not isinstance(m3u_key, Key):
raise ValueError(f"Provided M3U Key is in an unexpected type {m3u_key!r}")
if not isinstance(session, (Session, CurlSession, httpx.Client, type(None))):
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not a {type(session)}")
if not isinstance(session, (Session, CurlSession, type(None))):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not a {type(session)}")
if not m3u_key.method.startswith("AES"):
raise ValueError(f"Provided M3U Key is not an AES Clear Key, {m3u_key.method}")

View File

@@ -15,7 +15,6 @@ from urllib.parse import urljoin, urlparse
from uuid import UUID
from zlib import crc32
import httpx
import requests
from curl_cffi.requests import Session as CurlSession
from langcodes import Language, tag_is_valid
@@ -51,7 +50,7 @@ class DASH:
self.url = url
@classmethod
def from_url(cls, url: str, session: Optional[Union[Session, CurlSession, httpx.Client]] = None, **args: Any) -> DASH:
def from_url(cls, url: str, session: Optional[Union[Session, CurlSession]] = None, **args: Any) -> DASH:
if not url:
raise requests.URLRequired("DASH manifest URL must be provided for relative path computations.")
if not isinstance(url, str):
@@ -59,15 +58,16 @@ class DASH:
if not session:
session = Session()
elif not isinstance(session, (Session, CurlSession, httpx.Client)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}")
try:
res = session.get(url, **args)
res.raise_for_status()
except Exception as e:
raise RuntimeError("Failed to request the MPD document.") from e
elif not isinstance(session, (Session, CurlSession)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
res = session.get(url, **args)
if res.url != url:
url = res.url
if not res.ok:
raise requests.ConnectionError("Failed to request the MPD document.", response=res)
return DASH.from_text(res.text, url)
@classmethod
@@ -261,8 +261,8 @@ class DASH:
):
if not session:
session = Session()
elif not isinstance(session, (Session, CurlSession, httpx.Client)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, 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})

View File

@@ -15,7 +15,6 @@ from urllib.parse import urljoin
from uuid import UUID
from zlib import crc32
import httpx
import m3u8
import requests
from curl_cffi.requests import Response as CurlResponse
@@ -39,7 +38,7 @@ from unshackle.core.utilities import get_debug_logger, get_extension, is_close_m
class HLS:
def __init__(self, manifest: M3U8, session: Optional[Union[Session, CurlSession, httpx.Client]] = None):
def __init__(self, manifest: M3U8, session: Optional[Union[Session, CurlSession]] = None):
if not manifest:
raise ValueError("HLS manifest must be provided.")
if not isinstance(manifest, M3U8):
@@ -51,7 +50,7 @@ class HLS:
self.session = session or Session()
@classmethod
def from_url(cls, url: str, session: Optional[Union[Session, CurlSession, httpx.Client]] = None, **args: Any) -> HLS:
def from_url(cls, url: str, session: Optional[Union[Session, CurlSession]] = None, **args: Any) -> HLS:
if not url:
raise requests.URLRequired("HLS manifest URL must be provided.")
if not isinstance(url, str):
@@ -59,15 +58,23 @@ class HLS:
if not session:
session = Session()
elif not isinstance(session, (Session, CurlSession, httpx.Client)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}")
elif not isinstance(session, (Session, CurlSession)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
res = session.get(url, **args)
# Handle requests and curl_cffi response objects
if isinstance(res, requests.Response):
if not res.ok:
raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res)
content = res.text
elif isinstance(res, CurlResponse):
if not res.ok:
raise requests.ConnectionError("Failed to request the M3U(8) document.", response=res)
content = res.text
else:
raise TypeError(f"Expected response to be a requests.Response or curl_cffi.Response, not {type(res)}")
try:
res = session.get(url, **args)
res.raise_for_status()
except Exception as e:
raise RuntimeError("Failed to request the M3U(8) document.") from e
content = res.text
master = m3u8.loads(content, uri=url)
return cls(master, session)
@@ -109,9 +116,14 @@ class HLS:
for playlist in self.manifest.playlists:
audio_group = playlist.stream_info.audio
if audio_group:
audio_codec = Audio.Codec.from_codecs(playlist.stream_info.codecs)
audio_codecs_by_group_id[audio_group] = audio_codec
audio_codec: Optional[Audio.Codec] = None
if audio_group and playlist.stream_info.codecs:
try:
audio_codec = Audio.Codec.from_codecs(playlist.stream_info.codecs)
except ValueError:
audio_codec = None
if audio_codec:
audio_codecs_by_group_id[audio_group] = audio_codec
try:
# TODO: Any better way to figure out the primary track type?
@@ -258,7 +270,7 @@ class HLS:
save_path: Path,
save_dir: Path,
progress: partial,
session: Optional[Union[Session, CurlSession, httpx.Client]] = None,
session: Optional[Union[Session, CurlSession]] = None,
proxy: Optional[str] = None,
max_workers: Optional[int] = None,
license_widevine: Optional[Callable] = None,
@@ -267,8 +279,8 @@ class HLS:
) -> None:
if not session:
session = Session()
elif not isinstance(session, (Session, CurlSession, httpx.Client)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, 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:
# Handle proxies differently based on session type
@@ -841,7 +853,7 @@ class HLS:
@staticmethod
def parse_session_data_keys(
manifest: M3U8, session: Optional[Union[Session, CurlSession, httpx.Client]] = None
manifest: M3U8, session: Optional[Union[Session, CurlSession]] = None
) -> list[m3u8.model.Key]:
"""Parse `com.apple.hls.keys` session data and return Key objects."""
keys: list[m3u8.model.Key] = []
@@ -916,7 +928,7 @@ class HLS:
def get_track_kid_from_init(
master: M3U8,
track: AnyTrack,
session: Union[Session, CurlSession, httpx.Client],
session: Union[Session, CurlSession],
) -> Optional[UUID]:
"""
Extract the track's Key ID from its init segment (EXT-X-MAP).
@@ -983,7 +995,7 @@ class HLS:
@staticmethod
def get_drm(
key: Union[m3u8.model.SessionKey, m3u8.model.Key],
session: Optional[Union[Session, CurlSession, httpx.Client]] = None,
session: Optional[Union[Session, CurlSession]] = None,
) -> DRM_T:
"""
Convert HLS EXT-X-KEY data to an initialized DRM object.
@@ -995,8 +1007,8 @@ class HLS:
Raises a NotImplementedError if the key system is not supported.
"""
if not isinstance(session, (Session, CurlSession, httpx.Client, type(None))):
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {type(session)}")
if not isinstance(session, (Session, CurlSession, type(None))):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {type(session)}")
if not session:
session = Session()

View File

@@ -9,7 +9,6 @@ from functools import partial
from pathlib import Path
from typing import Any, Callable, Optional, Union
import httpx
import requests
from curl_cffi.requests import Session as CurlSession
from langcodes import Language, tag_is_valid
@@ -36,13 +35,13 @@ class ISM:
self.url = url
@classmethod
def from_url(cls, url: str, session: Optional[Union[Session, CurlSession, httpx.Client]] = None, **kwargs: Any) -> "ISM":
def from_url(cls, url: str, session: Optional[Union[Session, CurlSession]] = None, **kwargs: Any) -> "ISM":
if not url:
raise requests.URLRequired("ISM manifest URL must be provided")
if not session:
session = Session()
elif not isinstance(session, (Session, CurlSession, httpx.Client)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {session!r}")
elif not isinstance(session, (Session, CurlSession)):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {session!r}")
res = session.get(url, **kwargs)
if res.url != url:
url = res.url

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
from typing import Optional, Union
import httpx
import m3u8
from curl_cffi.requests import Session as CurlSession
from requests import Session
@@ -17,7 +16,7 @@ def parse(
master: m3u8.M3U8,
language: str,
*,
session: Optional[Union[Session, CurlSession, httpx.Client]] = None,
session: Optional[Union[Session, CurlSession]] = None,
) -> Tracks:
"""Parse a variant playlist to ``Tracks`` with basic information, defer DRM loading."""
tracks = HLS(master, session=session).to_tracks(language)

View File

@@ -13,7 +13,6 @@ from typing import Any, Callable, Iterable, Optional, Union
from uuid import UUID
from zlib import crc32
import httpx
from curl_cffi.requests import Session as CurlSession
from langcodes import Language
from requests import Session
@@ -614,8 +613,8 @@ class Track:
raise TypeError(f"Expected url to be a {str}, not {type(url)}")
if not isinstance(byte_range, (str, type(None))):
raise TypeError(f"Expected byte_range to be a {str}, not {type(byte_range)}")
if not isinstance(session, (Session, CurlSession, httpx.Client, type(None))):
raise TypeError(f"Expected session to be a {Session} or {CurlSession} or {httpx.Client}, not {type(session)}")
if not isinstance(session, (Session, CurlSession, type(None))):
raise TypeError(f"Expected session to be a {Session} or {CurlSession}, not {type(session)}")
if not url:
if self.descriptor != self.Descriptor.URL:

View File

@@ -221,13 +221,15 @@ class Tracks:
self.videos.sort(key=lambda x: not is_close_match(language, [x.language]))
def sort_audio(self, by_language: Optional[Sequence[Union[str, Language]]] = None) -> None:
"""Sort audio tracks by bitrate, descriptive, and optionally language."""
"""Sort audio tracks by bitrate, Atmos, descriptive, and optionally language."""
if not self.audio:
return
# descriptive
self.audio.sort(key=lambda x: x.descriptive)
# bitrate (within each descriptive group)
# bitrate (highest first)
self.audio.sort(key=lambda x: float(x.bitrate or 0.0), reverse=True)
# Atmos tracks first (prioritize over higher bitrate non-Atmos)
self.audio.sort(key=lambda x: not x.atmos)
# descriptive tracks last
self.audio.sort(key=lambda x: x.descriptive)
# language
for language in reversed(by_language or []):
if str(language) in ("all", "best"):

42
uv.lock generated
View File

@@ -608,28 +608,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "h2"
version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
]
[[package]]
name = "hpack"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
@@ -658,20 +636,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[package.optional-dependencies]
http2 = [
{ name = "h2" },
]
[[package]]
name = "hyperframe"
version = "6.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
]
[[package]]
name = "identify"
version = "2.6.16"
@@ -1663,7 +1627,7 @@ wheels = [
[[package]]
name = "unshackle"
version = "2.4.0"
version = "3.0.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1678,7 +1642,7 @@ dependencies = [
{ name = "curl-cffi" },
{ name = "filelock" },
{ name = "fonttools" },
{ name = "httpx", extra = ["http2"] },
{ name = "httpx" },
{ name = "jsonpickle" },
{ name = "langcodes" },
{ name = "language-data" },
@@ -1736,7 +1700,7 @@ requires-dist = [
{ name = "curl-cffi", specifier = ">=0.7.0b4,<0.14" },
{ name = "filelock", specifier = ">=3.20.3,<4" },
{ name = "fonttools", specifier = ">=4.60.2,<5" },
{ name = "httpx", extras = ["http2"], specifier = ">=0.28.1,<0.29" },
{ name = "httpx", specifier = ">=0.28.1,<0.29" },
{ name = "jsonpickle", specifier = ">=3.0.4,<5" },
{ name = "langcodes", specifier = ">=3.4.0,<4" },
{ name = "language-data", specifier = ">=1.4.0" },