From d6f8e42f52f2fb1822db26530e9c3643d6a0d41b Mon Sep 17 00:00:00 2001 From: Andy Date: Wed, 1 Oct 2025 18:11:40 +0000 Subject: [PATCH 1/6] feat: add --no-mux flag to skip muxing tracks into container files Add --no-mux command-line option to allow downloading individual track files without muxing them into a container file (.mkv/.mka/.mks). This addresses use cases where users want to download tracks separately, such as: - Downloading only subtitles as individual .srt/.vtt files - Keeping audio/video/subtitle tracks as separate files - Converting subtitle formats without creating container files When --no-mux is used: - Tracks are saved as individual files with descriptive suffixes - Video tracks: filename.{codec}.ext - Audio tracks: filename.{language}.{codec}.ext - Subtitle tracks: filename.{language}.forced.sdh.ext (as applicable) - Folder structure respects --no-folder flag Resolves #21 --- unshackle/commands/dl.py | 71 +++++++++++++++++++++++++++++++++++----- 1 file changed, 62 insertions(+), 9 deletions(-) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 559cfd9..c2d8002 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -258,6 +258,7 @@ class dl: @click.option( "--no-source", is_flag=True, default=False, help="Disable the source tag from the output file name and path." ) + @click.option("--no-mux", is_flag=True, default=False, help="Do not mux tracks into a container file.") @click.option( "--workers", type=int, @@ -484,6 +485,7 @@ class dl: no_proxy: bool, no_folder: bool, no_source: bool, + no_mux: bool, workers: Optional[int], downloads: int, best_available: bool, @@ -1139,7 +1141,12 @@ class dl: muxed_paths = [] - if isinstance(title, (Movie, Episode)): + if no_mux: + # Skip muxing, handle individual track files + for track in title.tracks: + if track.path and track.path.exists(): + muxed_paths.append(track.path) + elif isinstance(title, (Movie, Episode)): progress = Progress( TextColumn("[progress.description]{task.description}"), SpinnerColumn(finished_text=""), @@ -1258,19 +1265,65 @@ class dl: # dont mux muxed_paths.append(title.tracks.audio[0].path) - for muxed_path in muxed_paths: - media_info = MediaInfo.parse(muxed_path) + if no_mux: + # Handle individual track files without muxing final_dir = config.directories.downloads - final_filename = title.get_filename(media_info, show_service=not no_source) - if not no_folder and isinstance(title, (Episode, Song)): - final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True) + # Create folder based on title + # Use first available track for filename generation + sample_track = title.tracks.videos[0] if title.tracks.videos else ( + title.tracks.audio[0] if title.tracks.audio else ( + title.tracks.subtitles[0] if title.tracks.subtitles else None + ) + ) + if sample_track and sample_track.path: + media_info = MediaInfo.parse(sample_track.path) + final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True) final_dir.mkdir(parents=True, exist_ok=True) - final_path = final_dir / f"{final_filename}{muxed_path.suffix}" - shutil.move(muxed_path, final_path) - tags.tag_file(final_path, title, self.tmdb_id) + for track_path in muxed_paths: + # Generate appropriate filename for each track + media_info = MediaInfo.parse(track_path) + base_filename = title.get_filename(media_info, show_service=not no_source) + + # Add track type suffix to filename + track = next((t for t in title.tracks if t.path == track_path), None) + if track: + if isinstance(track, Video): + track_suffix = f".{track.codec.name if hasattr(track.codec, 'name') else 'video'}" + elif isinstance(track, Audio): + lang_suffix = f".{track.language}" if track.language else "" + track_suffix = f"{lang_suffix}.{track.codec.name if hasattr(track.codec, 'name') else 'audio'}" + elif isinstance(track, Subtitle): + lang_suffix = f".{track.language}" if track.language else "" + forced_suffix = ".forced" if track.forced else "" + sdh_suffix = ".sdh" if track.sdh else "" + track_suffix = f"{lang_suffix}{forced_suffix}{sdh_suffix}" + else: + track_suffix = "" + + final_path = final_dir / f"{base_filename}{track_suffix}{track_path.suffix}" + else: + final_path = final_dir / f"{base_filename}{track_path.suffix}" + + shutil.move(track_path, final_path) + self.log.debug(f"Saved: {final_path.name}") + else: + # Handle muxed files + for muxed_path in muxed_paths: + media_info = MediaInfo.parse(muxed_path) + final_dir = config.directories.downloads + final_filename = title.get_filename(media_info, show_service=not no_source) + + if not no_folder and isinstance(title, (Episode, Song)): + final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True) + + final_dir.mkdir(parents=True, exist_ok=True) + final_path = final_dir / f"{final_filename}{muxed_path.suffix}" + + shutil.move(muxed_path, final_path) + tags.tag_file(final_path, title, self.tmdb_id) title_dl_time = time_elapsed_since(dl_start_time) console.print( From 4cec942613934e59ea2c2ccd4c49561628f2f79f Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 4 Oct 2025 21:58:30 +0000 Subject: [PATCH 2/6] feat(vaults): add DecryptLabs API support to HTTP vault --- unshackle/vaults/HTTP.py | 93 +++++++++++++++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 10 deletions(-) diff --git a/unshackle/vaults/HTTP.py b/unshackle/vaults/HTTP.py index b3db62f..65a72f7 100644 --- a/unshackle/vaults/HTTP.py +++ b/unshackle/vaults/HTTP.py @@ -16,13 +16,21 @@ class InsertResult(Enum): class HTTP(Vault): - """Key Vault using HTTP API with support for both query parameters and JSON payloads.""" + """ + Key Vault using HTTP API with support for multiple API modes. + + Supported modes: + - query: Uses GET requests with query parameters + - json: Uses POST requests with JSON payloads + - decrypt_labs: Uses DecryptLabs API format (read-only) + """ def __init__( self, name: str, host: str, - password: str, + password: Optional[str] = None, + api_key: Optional[str] = None, username: Optional[str] = None, api_mode: str = "query", no_push: bool = False, @@ -34,13 +42,17 @@ class HTTP(Vault): name: Vault name host: Host URL password: Password for query mode or API token for json mode - username: Username (required for query mode, ignored for json mode) - api_mode: "query" for query parameters or "json" for JSON API + api_key: API key (alternative to password, used for decrypt_labs mode) + username: Username (required for query mode, ignored for json/decrypt_labs mode) + api_mode: "query" for query parameters, "json" for JSON API, or "decrypt_labs" for DecryptLabs API no_push: If True, this vault will not receive pushed keys """ super().__init__(name, no_push) self.url = host - self.password = password + self.password = api_key or password + if not self.password: + raise ValueError("Either password or api_key is required") + self.username = username self.api_mode = api_mode.lower() self.current_title = None @@ -48,11 +60,15 @@ class HTTP(Vault): self.session.headers.update({"User-Agent": f"unshackle v{__version__}"}) self.api_session_id = None + if self.api_mode == "decrypt_labs": + self.session.headers.update({"decrypt-labs-api-key": self.password}) + self.no_push = True + # Validate configuration based on mode if self.api_mode == "query" and not self.username: raise ValueError("Username is required for query mode") - elif self.api_mode not in ["query", "json"]: - raise ValueError("api_mode must be either 'query' or 'json'") + elif self.api_mode not in ["query", "json", "decrypt_labs"]: + raise ValueError("api_mode must be either 'query', 'json', or 'decrypt_labs'") def request(self, method: str, params: dict = None) -> dict: """Make a request to the JSON API vault.""" @@ -95,7 +111,51 @@ class HTTP(Vault): if isinstance(kid, UUID): kid = kid.hex - if self.api_mode == "json": + if self.api_mode == "decrypt_labs": + try: + request_payload = {"service": service.lower(), "kid": kid} + + response = self.session.post(self.url, json=request_payload) + + if not response.ok: + return None + + data = response.json() + + if data.get("message") != "success": + return None + + cached_keys = data.get("cached_keys") + if not cached_keys: + return None + + if isinstance(cached_keys, str): + try: + cached_keys = json.loads(cached_keys) + except json.JSONDecodeError: + return cached_keys + + if isinstance(cached_keys, dict): + if cached_keys.get("kid") == kid: + return cached_keys.get("key") + if kid in cached_keys: + return cached_keys[kid] + elif isinstance(cached_keys, list): + for entry in cached_keys: + if isinstance(entry, dict): + if entry.get("kid") == kid: + return entry.get("key") + elif isinstance(entry, str) and ":" in entry: + entry_kid, entry_key = entry.split(":", 1) + if entry_kid == kid: + return entry_key + + except Exception as e: + print(f"Failed to get key from DecryptLabs ({e.__class__.__name__}: {e})") + return None + return None + + elif self.api_mode == "json": try: params = { "kid": kid, @@ -132,7 +192,9 @@ class HTTP(Vault): return data["keys"][0]["key"] def get_keys(self, service: str) -> Iterator[tuple[str, str]]: - if self.api_mode == "json": + if self.api_mode == "decrypt_labs": + return iter([]) + elif self.api_mode == "json": # JSON API doesn't support getting all keys, so return empty iterator # This will cause the copy command to rely on the API's internal duplicate handling return iter([]) @@ -153,6 +215,9 @@ class HTTP(Vault): if not key or key.count("0") == len(key): raise ValueError("You cannot add a NULL Content Key to a Vault.") + if self.api_mode == "decrypt_labs": + return False + if isinstance(kid, UUID): kid = kid.hex @@ -192,6 +257,9 @@ class HTTP(Vault): return data.get("status_code") == 200 def add_keys(self, service: str, kid_keys: dict[Union[UUID, str], str]) -> int: + if self.api_mode == "decrypt_labs": + return 0 + for kid, key in kid_keys.items(): if not key or key.count("0") == len(key): raise ValueError("You cannot add a NULL Content Key to a Vault.") @@ -243,7 +311,9 @@ class HTTP(Vault): return inserted_count def get_services(self) -> Iterator[str]: - if self.api_mode == "json": + if self.api_mode == "decrypt_labs": + return iter([]) + elif self.api_mode == "json": try: response = self.request("GetServices") services = response.get("services", []) @@ -283,6 +353,9 @@ class HTTP(Vault): if not key or key.count("0") == len(key): raise ValueError("You cannot add a NULL Content Key to a Vault.") + if self.api_mode == "decrypt_labs": + return InsertResult.FAILURE + if isinstance(kid, UUID): kid = kid.hex From e9ba78cec30966f8a0abcbacd4ce181ce83ce477 Mon Sep 17 00:00:00 2001 From: Posi+ive <74866617+Posiplusive@users.noreply.github.com> Date: Mon, 6 Oct 2025 06:42:21 +0800 Subject: [PATCH 3/6] Specify 'utf-8' encoding when opening temp file. (#22) --- unshackle/core/utils/tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unshackle/core/utils/tags.py b/unshackle/core/utils/tags.py index 728c03d..5a5e616 100644 --- a/unshackle/core/utils/tags.py +++ b/unshackle/core/utils/tags.py @@ -294,7 +294,7 @@ def _apply_tags(path: Path, tags: dict[str, str]) -> None: for name, value in tags.items(): xml_lines.append(f" {escape(name)}{escape(value)}") xml_lines.extend([" ", ""]) - with tempfile.NamedTemporaryFile("w", suffix=".xml", delete=False) as f: + with tempfile.NamedTemporaryFile("w", suffix=".xml", delete=False, encoding="utf-8") as f: f.write("\n".join(xml_lines)) tmp_path = Path(f.name) try: From 3f6a7e1f6895054853211b90172260dc7ab6cd17 Mon Sep 17 00:00:00 2001 From: Andy Date: Wed, 8 Oct 2025 01:54:30 +0000 Subject: [PATCH 4/6] feat: add --exact-lang flag for precise language matching New --exact-lang CLI flag that enables exact language code matching instead of fuzzy matching. This allows users to get specific regional variants without matching all related variants. Examples: - `-l es-419` normally matches all Spanish (es-ES, es-419, es-MX) - `-l es-419 --exact-lang` matches ONLY es-419 (Latin American Spanish) Fixes language detection issue where specific variants like es-419 (Latin American Spanish) would match all Spanish variants instead of just close regional variants. --- unshackle/commands/dl.py | 21 +++++++++++++++++---- unshackle/core/constants.py | 1 + unshackle/core/tracks/tracks.py | 9 ++++++--- unshackle/core/utilities.py | 10 +++++++++- 4 files changed, 33 insertions(+), 8 deletions(-) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index c2d8002..8b37032 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -180,6 +180,12 @@ class dl: help="Required subtitle languages. Downloads all subtitles only if these languages exist. Cannot be used with --s-lang.", ) @click.option("-fs", "--forced-subs", is_flag=True, default=False, help="Include forced subtitle tracks.") + @click.option( + "--exact-lang", + is_flag=True, + default=False, + help="Use exact language matching (no variants). With this flag, -l es-419 matches ONLY es-419, not es-ES or other variants.", + ) @click.option( "--proxy", type=str, @@ -468,6 +474,7 @@ class dl: s_lang: list[str], require_subs: list[str], forced_subs: bool, + exact_lang: bool, sub_format: Optional[Subtitle.Codec], video_only: bool, audio_only: bool, @@ -709,7 +716,9 @@ class dl: else: if language not in processed_video_lang: processed_video_lang.append(language) - title.tracks.videos = title.tracks.by_language(title.tracks.videos, processed_video_lang) + title.tracks.videos = title.tracks.by_language( + title.tracks.videos, processed_video_lang, exact_match=exact_lang + ) if not title.tracks.videos: self.log.error(f"There's no {processed_video_lang} Video Track...") sys.exit(1) @@ -792,16 +801,20 @@ class dl: f"Required languages found ({', '.join(require_subs)}), downloading all available subtitles" ) elif s_lang and "all" not in s_lang: + from unshackle.core.utilities import is_exact_match + + match_func = is_exact_match if exact_lang else is_close_match + missing_langs = [ lang_ for lang_ in s_lang - if not any(is_close_match(lang_, [sub.language]) for sub in title.tracks.subtitles) + if not any(match_func(lang_, [sub.language]) for sub in title.tracks.subtitles) ] if missing_langs: self.log.error(", ".join(missing_langs) + " not found in tracks") sys.exit(1) - title.tracks.select_subtitles(lambda x: is_close_match(x.language, s_lang)) + title.tracks.select_subtitles(lambda x: match_func(x.language, s_lang)) if not title.tracks.subtitles: self.log.error(f"There's no {s_lang} Subtitle Track...") sys.exit(1) @@ -865,7 +878,7 @@ class dl: elif "all" not in processed_lang: per_language = 1 title.tracks.audio = title.tracks.by_language( - title.tracks.audio, processed_lang, per_language=per_language + title.tracks.audio, processed_lang, per_language=per_language, exact_match=exact_lang ) if not title.tracks.audio: self.log.error(f"There's no {processed_lang} Audio Track, cannot continue...") diff --git a/unshackle/core/constants.py b/unshackle/core/constants.py index 609fcc3..6a14f7d 100644 --- a/unshackle/core/constants.py +++ b/unshackle/core/constants.py @@ -6,6 +6,7 @@ DOWNLOAD_LICENCE_ONLY = Event() DRM_SORT_MAP = ["ClearKey", "Widevine"] LANGUAGE_MAX_DISTANCE = 5 # this is max to be considered "same", e.g., en, en-US, en-AU +LANGUAGE_EXACT_DISTANCE = 0 # exact match only, no variants VIDEO_CODEC_MAP = {"AVC": "H.264", "HEVC": "H.265"} DYNAMIC_RANGE_MAP = {"HDR10": "HDR", "HDR10+": "HDR10P", "Dolby Vision": "DV", "HDR10 / HDR10+": "HDR10P", "HDR10 / HDR10": "HDR"} AUDIO_CODEC_MAP = {"E-AC-3": "DDP", "AC-3": "DD"} diff --git a/unshackle/core/tracks/tracks.py b/unshackle/core/tracks/tracks.py index cf691b7..eeacd47 100644 --- a/unshackle/core/tracks/tracks.py +++ b/unshackle/core/tracks/tracks.py @@ -14,7 +14,7 @@ from rich.tree import Tree from unshackle.core import binaries from unshackle.core.config import config from unshackle.core.console import console -from unshackle.core.constants import LANGUAGE_MAX_DISTANCE, AnyTrack, TrackT +from unshackle.core.constants import LANGUAGE_EXACT_DISTANCE, LANGUAGE_MAX_DISTANCE, AnyTrack, TrackT from unshackle.core.events import events from unshackle.core.tracks.attachment import Attachment from unshackle.core.tracks.audio import Audio @@ -294,11 +294,14 @@ class Tracks: self.videos = selected @staticmethod - def by_language(tracks: list[TrackT], languages: list[str], per_language: int = 0) -> list[TrackT]: + def by_language( + tracks: list[TrackT], languages: list[str], per_language: int = 0, exact_match: bool = False + ) -> list[TrackT]: + distance = LANGUAGE_EXACT_DISTANCE if exact_match else LANGUAGE_MAX_DISTANCE selected = [] for language in languages: selected.extend( - [x for x in tracks if closest_supported_match(x.language, [language], LANGUAGE_MAX_DISTANCE)][ + [x for x in tracks if closest_supported_match(str(x.language), [language], distance)][ : per_language or None ] ) diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index 784c037..9302e0d 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -24,7 +24,7 @@ from unidecode import unidecode from unshackle.core.cacher import Cacher from unshackle.core.config import config -from unshackle.core.constants import LANGUAGE_MAX_DISTANCE +from unshackle.core.constants import LANGUAGE_EXACT_DISTANCE, LANGUAGE_MAX_DISTANCE def rotate_log_file(log_path: Path, keep: int = 20) -> Path: @@ -114,6 +114,14 @@ def is_close_match(language: Union[str, Language], languages: Sequence[Union[str return closest_match(language, list(map(str, languages)))[1] <= LANGUAGE_MAX_DISTANCE +def is_exact_match(language: Union[str, Language], languages: Sequence[Union[str, Language, None]]) -> bool: + """Check if a language is an exact match to any of the provided languages.""" + languages = [x for x in languages if x] + if not languages: + return False + return closest_match(language, list(map(str, languages)))[1] <= LANGUAGE_EXACT_DISTANCE + + def get_boxes(data: bytes, box_type: bytes, as_bytes: bool = False) -> Box: """ Scan a byte array for a wanted MP4/ISOBMFF box, then parse and yield each find. From 283736c57baa7c05e74863cb50deaced7ab80502 Mon Sep 17 00:00:00 2001 From: Andy Date: Wed, 8 Oct 2025 21:26:26 +0000 Subject: [PATCH 5/6] revert: remove tinycss SyntaxWarning suppression and fix isort Revert the warnings filter added in 2d5e807 as it didn't work as expected to suppress the tinycss SyntaxWarning. Also fix isort order in prd.py for pyplayready imports. --- unshackle/commands/prd.py | 2 +- unshackle/core/__main__.py | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/unshackle/commands/prd.py b/unshackle/commands/prd.py index 3b8c037..e8dcf1a 100644 --- a/unshackle/commands/prd.py +++ b/unshackle/commands/prd.py @@ -5,10 +5,10 @@ from typing import Optional import click import requests from Crypto.Random import get_random_bytes +from pyplayready import InvalidCertificateChain, OutdatedDevice from pyplayready.cdm import Cdm from pyplayready.crypto.ecc_key import ECCKey from pyplayready.device import Device -from pyplayready import InvalidCertificateChain, OutdatedDevice from pyplayready.system.bcert import Certificate, CertificateChain from pyplayready.system.pssh import PSSH diff --git a/unshackle/core/__main__.py b/unshackle/core/__main__.py index 7aac73e..e4717fa 100644 --- a/unshackle/core/__main__.py +++ b/unshackle/core/__main__.py @@ -1,9 +1,3 @@ -import warnings - -# Suppress SyntaxWarning from unmaintained tinycss package (dependency of subby) -# Must be set before any imports that might trigger tinycss loading -warnings.filterwarnings("ignore", category=SyntaxWarning, module="tinycss") - import atexit import logging from pathlib import Path From 170a427af0e2162c19039c1c2a18570944b0e6e6 Mon Sep 17 00:00:00 2001 From: Andy Date: Wed, 8 Oct 2025 21:30:01 +0000 Subject: [PATCH 6/6] chore: bump version to 1.4.8 --- CHANGELOG.md | 39 ++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- unshackle/core/__init__.py | 2 +- uv.lock | 2 +- 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68cd024..abe5c9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,45 @@ 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). +## [1.4.8] - 2025-10-08 + +### Added + +- **Exact Language Matching**: New `--exact-lang` flag for precise language matching + - Enables strict language code matching without fallbacks +- **No-Mux Flag**: New `--no-mux` flag to skip muxing tracks into container files + - Useful for keeping individual track files separate +- **DecryptLabs API Integration for HTTP Vault**: Enhanced vault support + - Added DecryptLabs API support to HTTP vault for improved key retrieval +- **AC4 Audio Codec Support**: Enhanced audio format handling + - Added AC4 codec support in Audio class with updated mime/profile handling +- **pysubs2 Subtitle Conversion**: Extended subtitle format support + - Added pysubs2 subtitle conversion with extended format support + - Configurable conversion method in configuration + +### Changed + +- **Audio Track Sorting**: Optimized audio track selection logic + - Improved audio track sorting by grouping descriptive tracks and sorting by bitrate + - Better identification of ATMOS and DD+ as highest quality for filenaming +- **pyplayready Update**: Upgraded to version 0.6.3 + - Updated import paths to resolve compatibility issues + - Fixed lxml constraints for better dependency management +- **pysubs2 Conversion Method**: Moved from auto to manual configuration + - pysubs2 no longer auto-selected during testing phase + +### Fixed + +- **Remote CDM**: Fixed curl_cffi compatibility + - Added curl_cffi to instance checks in RemoteCDM +- **Temporary File Handling**: Improved encoding handling + - Specified UTF-8 encoding when opening temporary files + +### Reverted + +- **tinycss SyntaxWarning Suppression**: Removed ineffective warning filter + - Reverted warnings filter that didn't work as expected for suppressing tinycss warnings + ## [1.4.7] - 2025-09-25 ### Added diff --git a/pyproject.toml b/pyproject.toml index 58fb236..906dfd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "unshackle" -version = "1.4.6" +version = "1.4.8" description = "Modular Movie, TV, and Music Archival Software." authors = [{ name = "unshackle team" }] requires-python = ">=3.10,<3.13" diff --git a/unshackle/core/__init__.py b/unshackle/core/__init__.py index ac329c9..4963389 100644 --- a/unshackle/core/__init__.py +++ b/unshackle/core/__init__.py @@ -1 +1 @@ -__version__ = "1.4.7" +__version__ = "1.4.8" diff --git a/uv.lock b/uv.lock index dfc9259..7cf248f 100644 --- a/uv.lock +++ b/uv.lock @@ -1514,7 +1514,7 @@ wheels = [ [[package]] name = "unshackle" -version = "1.4.6" +version = "1.4.8" source = { editable = "." } dependencies = [ { name = "appdirs" },