From 3f6a7e1f6895054853211b90172260dc7ab6cd17 Mon Sep 17 00:00:00 2001 From: Andy Date: Wed, 8 Oct 2025 01:54:30 +0000 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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" }, From 45902bba13d75cad9a714f771ad42ea1dd708a08 Mon Sep 17 00:00:00 2001 From: Andy Date: Mon, 13 Oct 2025 16:43:31 +0000 Subject: [PATCH 4/5] fix: use keyword arguments for Attachment constructor in font attachment Fixes #24 When attaching fonts for ASS/SSA subtitles, the Attachment class was being called with positional arguments instead of keyword arguments. This caused the font Path object to be incorrectly interpreted, leading to an error: "Invalid URL 'Arial (arial)': No scheme supplied." Changed Attachment(font, name) to Attachment(path=font, name=name) to explicitly pass arguments by keyword, ensuring proper parameter handling. --- unshackle/commands/dl.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 8b37032..4e4ad8d 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -1106,11 +1106,11 @@ class dl: if family_dir.exists(): fonts = family_dir.glob("*.*tf") for font in fonts: - title.tracks.add(Attachment(font, f"{font_name} ({font.stem})")) + title.tracks.add(Attachment(path=font, name=f"{font_name} ({font.stem})")) font_count += 1 elif fonts_from_system: for font in fonts_from_system: - title.tracks.add(Attachment(font, f"{font_name} ({font.stem})")) + title.tracks.add(Attachment(path=font, name=f"{font_name} ({font.stem})")) font_count += 1 else: self.log.warning(f"Subtitle uses font [text2]{font_name}[/] but it could not be found...") From a7bde29401d7dd38b81d5a17b298013fb9b9258b Mon Sep 17 00:00:00 2001 From: Andy Date: Wed, 15 Oct 2025 22:39:44 +0000 Subject: [PATCH 5/5] fix: only exclude forced subs when --forced-subs flag is not set Previously, forced subtitles were incorrectly included when they matched languages in the lang configuration, even without the --forced-subs flag. This caused forced subs to appear when using language configs or the -l parameter, which violated the expected behavior. --- unshackle/commands/dl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 4e4ad8d..d99c58f 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -820,7 +820,7 @@ class dl: sys.exit(1) if not forced_subs: - title.tracks.select_subtitles(lambda x: not x.forced or is_close_match(x.language, lang)) + title.tracks.select_subtitles(lambda x: not x.forced) # filter audio tracks # might have no audio tracks if part of the video, e.g. transport stream hls