mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-03-17 16:47:29 +00:00
Compare commits
9 Commits
6b8a8ba8a8
...
3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf9087a1ce | ||
|
|
23cc351f77 | ||
|
|
132d3549f9 | ||
|
|
3ee554401a | ||
|
|
29a697a8e7 | ||
|
|
c5b063391c | ||
|
|
5fa0b33664 | ||
|
|
5650c2b591 | ||
|
|
5f49663ea8 |
35
CHANGELOG.md
35
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "2.4.0"
|
__version__ = "3.0.0"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
Reference in New Issue
Block a user