9 Commits

Author SHA1 Message Date
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
Andy
29a697a8e7 fix(tracks): close temp session and improve path type error 2026-02-08 20:04:22 -07:00
Andy
c5b063391c fix(serve): default PlayReady access to none
Remove unreachable fallback to all devices; if a user has no explicit playready_devices configured, the PlayReady subapp receives an empty list (secure-by-default).
2026-02-08 20:00:39 -07:00
Andy
5fa0b33664 revert(monalisa): pass key via argv again
Reverts the env/stdin key passing change introduced in 6c83790, since ML-Worker builds in use expect the key as argv[1].
2026-02-08 19:51:22 -07:00
Andy
5650c2b591 fix(hls): remove no-op encryption_data reassignment 2026-02-08 10:43:49 -07:00
Andy
5f49663ea8 fix(monalisa): harden wasm calls and license handling
- Validate _monalisa_context_alloc return and cleanup on init failure
- Derive deterministic KID when DCID missing to avoid collisions
- Ensure stackRestore always runs via try/finally in _ccall
- Log base64 decode failures without leaking license contents
- Add bounds/alignment checks for i32 memory writes
2026-02-08 10:39:23 -07:00
11 changed files with 128 additions and 45 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). This changelog is automatically generated using [git-cliff](https://git-cliff.org).
## [Unreleased] ## [3.0.0] - 2026-02-15
### Features ### 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 - *drm*: Add MonaLisa DRM support to core infrastructure
- *audio*: Codec lists and split muxing - *audio*: Codec lists and split muxing
- *proxy*: Add specific server selection for WindscribeVPN - *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 ### 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 - *dl*: Always clean up hybrid temp hevc outputs
- *hls*: Finalize n_m3u8dl_re outputs - *hls*: Finalize n_m3u8dl_re outputs
- *downloader*: Restore requests progress for single-url downloads - *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 ### Documentation
- Add configuration documentation WIP - Add configuration documentation WIP
- *changelog*: Add 2.4.0 release notes - *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 ### 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 - Reorganize Planned Features section in README for clarity
- Improve track selection logic in dl.py - 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.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.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 [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] [project]
name = "unshackle" name = "unshackle"
version = "2.4.0" version = "3.0.0"
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"

View File

@@ -211,7 +211,7 @@ def serve(
"devices": prd_devices, "devices": prd_devices,
"users": { "users": {
user_key: { user_key: {
"devices": user_cfg.get("playready_devices", prd_device_names), "devices": user_cfg.get("playready_devices", []),
"username": user_cfg.get("username", "user"), "username": user_cfg.get("username", "user"),
} }
for user_key, user_cfg in serve_config["users"].items() for user_key, user_cfg in serve_config["users"].items()

View File

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

View File

@@ -1,5 +1,6 @@
import atexit import atexit
import logging import logging
from datetime import datetime
import click import click
import urllib3 import urllib3
@@ -58,7 +59,7 @@ def main(version: bool, debug: bool) -> None:
r" ▀▀▀ ▀▀ █▪ ▀▀▀▀ ▀▀▀ · ▀ ▀ ·▀▀▀ ·▀ ▀.▀▀▀ ▀▀▀ ", r" ▀▀▀ ▀▀ █▪ ▀▀▀▀ ▀▀▀ · ▀ ▀ ·▀▀▀ ·▀ ▀.▀▀▀ ▀▀▀ ",
style="ascii.art", 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), (1, 11, 1, 10),
expand=True, expand=True,

View File

@@ -7,8 +7,11 @@ a WebAssembly module that runs locally via wasmtime.
import base64 import base64
import ctypes import ctypes
import hashlib
import json import json
import logging
import re import re
import sys
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Dict, Optional, Union from typing import Dict, Optional, Union
@@ -17,6 +20,8 @@ import wasmtime
from unshackle.core import binaries from unshackle.core import binaries
logger = logging.getLogger(__name__)
class MonaLisaCDM: class MonaLisaCDM:
""" """
@@ -128,10 +133,27 @@ class MonaLisaCDM:
} }
self.exports["___wasm_call_ctors"](self.store) self.exports["___wasm_call_ctors"](self.store)
self.ctx = self.exports["_monalisa_context_alloc"](self.store) ctx = self.exports["_monalisa_context_alloc"](self.store)
self.ctx = ctx
# _monalisa_context_alloc is expected to return a positive pointer/handle.
# Treat 0/negative/non-int-like values as allocation failure.
try:
ctx_int = int(ctx)
except Exception:
ctx_int = None
if ctx_int is None or ctx_int <= 0:
# Ensure we don't leave a partially-initialized instance around.
self.close()
raise RuntimeError(f"Failed to allocate MonaLisa context (ctx={ctx!r})")
return 1 return 1
except Exception as e: except Exception as e:
raise RuntimeError(f"Failed to initialize session: {e}") # Clean up partial state (e.g., store/memory/instance) before propagating failure.
self.close()
if isinstance(e, RuntimeError):
raise
raise RuntimeError(f"Failed to initialize session: {e}") from e
def close(self, session_id: int = 1) -> None: def close(self, session_id: int = 1) -> None:
""" """
@@ -188,7 +210,9 @@ class MonaLisaCDM:
# Extract DCID from license to generate KID # Extract DCID from license to generate KID
try: try:
decoded = base64.b64decode(license_b64).decode("ascii", errors="ignore") decoded = base64.b64decode(license_b64).decode("ascii", errors="ignore")
except Exception: except Exception as e:
# Avoid logging raw license content; log only safe metadata.
logger.exception("Failed to base64-decode MonaLisa license (len=%s): %s", len(license_b64), e)
decoded = "" decoded = ""
m = re.search( m = re.search(
@@ -198,7 +222,14 @@ class MonaLisaCDM:
if m: if m:
kid_bytes = uuid.uuid5(uuid.NAMESPACE_DNS, m.group()).bytes kid_bytes = uuid.uuid5(uuid.NAMESPACE_DNS, m.group()).bytes
else: else:
kid_bytes = uuid.UUID(int=0).bytes # No DCID in the license: derive a deterministic per-license KID to avoid collisions.
try:
license_raw = base64.b64decode(license_b64)
except Exception:
license_raw = license_b64.encode("utf-8", errors="replace")
license_hash = hashlib.sha256(license_raw).hexdigest()
kid_bytes = uuid.uuid5(uuid.NAMESPACE_DNS, f"monalisa:license:{license_hash}").bytes
return {"kid": kid_bytes.hex(), "key": key_bytes.hex(), "type": "CONTENT"} return {"kid": kid_bytes.hex(), "key": key_bytes.hex(), "type": "CONTENT"}
@@ -221,21 +252,29 @@ class MonaLisaCDM:
stack = 0 stack = 0
converted_args = [] converted_args = []
for arg in args: try:
if isinstance(arg, str): for arg in args:
if stack == 0: if isinstance(arg, str):
stack = self.exports["stackSave"](self.store) if stack == 0:
max_length = (len(arg) << 2) + 1 stack = self.exports["stackSave"](self.store)
ptr = self.exports["stackAlloc"](self.store, max_length) max_length = (len(arg) << 2) + 1
self._string_to_utf8(arg, ptr, max_length) ptr = self.exports["stackAlloc"](self.store, max_length)
converted_args.append(ptr) self._string_to_utf8(arg, ptr, max_length)
else: converted_args.append(ptr)
converted_args.append(arg) else:
converted_args.append(arg)
result = self.exports[func_name](self.store, *converted_args) result = self.exports[func_name](self.store, *converted_args)
finally:
if stack != 0: # stackAlloc pointers live on the WASM stack; always restore even if the call throws.
self.exports["stackRestore"](self.store, stack) if stack != 0:
exc = sys.exc_info()[1]
try:
self.exports["stackRestore"](self.store, stack)
except Exception:
# If we're already failing, don't mask the original exception.
if exc is None:
raise
if return_type is bool: if return_type is bool:
return bool(result) return bool(result)
@@ -243,6 +282,13 @@ class MonaLisaCDM:
def _write_i32(self, addr: int, value: int) -> None: def _write_i32(self, addr: int, value: int) -> None:
"""Write a 32-bit integer to WASM memory.""" """Write a 32-bit integer to WASM memory."""
if addr % 4 != 0:
raise ValueError(f"Unaligned i32 write: addr={addr} (must be 4-byte aligned)")
data_len = self.memory.data_len(self.store)
if addr < 0 or addr + 4 > data_len:
raise IndexError(f"i32 write out of bounds: addr={addr}, mem_len={data_len}")
data = self.memory.data_ptr(self.store) data = self.memory.data_ptr(self.store)
mem_ptr = ctypes.cast(data, ctypes.POINTER(ctypes.c_int32)) mem_ptr = ctypes.cast(data, ctypes.POINTER(ctypes.c_int32))
mem_ptr[addr >> 2] = value mem_ptr[addr >> 2] = value

View File

@@ -234,11 +234,7 @@ class MonaLisa:
raise MonaLisa.Exceptions.DecryptionFailed(f"Segment file does not exist: {segment_path}") raise MonaLisa.Exceptions.DecryptionFailed(f"Segment file does not exist: {segment_path}")
# Stage 1: ML-Worker decryption # Stage 1: ML-Worker decryption
# Do not pass secrets via argv (visible in process listings/logs). cmd = [str(worker_path), str(self._key), str(bbts_path), str(ents_path)]
# ML-Worker supports receiving the key out-of-band; we provide it via env + stdin.
cmd = [str(worker_path), "-", str(bbts_path), str(ents_path)]
worker_env = os.environ.copy()
worker_env["WORKER_KEY"] = self._key
startupinfo = None startupinfo = None
if sys.platform == "win32": if sys.platform == "win32":
@@ -251,8 +247,6 @@ class MonaLisa:
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, text=True,
input=self._key,
env=worker_env,
startupinfo=startupinfo, startupinfo=startupinfo,
timeout=worker_timeout_s, timeout=worker_timeout_s,
) )

View File

@@ -116,9 +116,14 @@ class HLS:
for playlist in self.manifest.playlists: for playlist in self.manifest.playlists:
audio_group = playlist.stream_info.audio audio_group = playlist.stream_info.audio
if audio_group: audio_codec: Optional[Audio.Codec] = None
audio_codec = Audio.Codec.from_codecs(playlist.stream_info.codecs) if audio_group and playlist.stream_info.codecs:
audio_codecs_by_group_id[audio_group] = audio_codec 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: try:
# TODO: Any better way to figure out the primary track type? # TODO: Any better way to figure out the primary track type?
@@ -611,8 +616,6 @@ class HLS:
discon_i += 1 discon_i += 1
range_offset = 0 # TODO: Should this be reset or not? range_offset = 0 # TODO: Should this be reset or not?
map_data = None map_data = None
if encryption_data:
encryption_data = (encryption_data[0], encryption_data[1])
if segment.init_section and (not map_data or segment.init_section != map_data[0]): if segment.init_section and (not map_data or segment.init_section != map_data[0]):
if segment.init_section.byterange: if segment.init_section.byterange:

View File

@@ -65,9 +65,13 @@ class Attachment:
path = None path = None
else: else:
try: try:
session = session or requests.Session() if session is None:
response = session.get(url, stream=True) with requests.Session() as session:
response.raise_for_status() response = session.get(url, stream=True)
response.raise_for_status()
else:
response = session.get(url, stream=True)
response.raise_for_status()
config.directories.temp.mkdir(parents=True, exist_ok=True) config.directories.temp.mkdir(parents=True, exist_ok=True)
download_path.parent.mkdir(parents=True, exist_ok=True) download_path.parent.mkdir(parents=True, exist_ok=True)
@@ -80,7 +84,9 @@ class Attachment:
raise ValueError(f"Failed to download attachment from URL: {e}") raise ValueError(f"Failed to download attachment from URL: {e}")
if path is not None and not isinstance(path, (str, Path)): if path is not None and not isinstance(path, (str, Path)):
raise ValueError("The attachment path must be provided.") raise ValueError(
f"Invalid attachment path type: expected str or Path, got {type(path).__name__}."
)
if path is not None: if path is not None:
path = Path(path) path = Path(path)

View File

@@ -221,13 +221,15 @@ class Tracks:
self.videos.sort(key=lambda x: not is_close_match(language, [x.language])) 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: 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: if not self.audio:
return return
# descriptive # bitrate (highest first)
self.audio.sort(key=lambda x: x.descriptive)
# bitrate (within each descriptive group)
self.audio.sort(key=lambda x: float(x.bitrate or 0.0), reverse=True) 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 # language
for language in reversed(by_language or []): for language in reversed(by_language or []):
if str(language) in ("all", "best"): if str(language) in ("all", "best"):

2
uv.lock generated
View File

@@ -1627,7 +1627,7 @@ wheels = [
[[package]] [[package]]
name = "unshackle" name = "unshackle"
version = "2.4.0" version = "3.0.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },