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/commands/dl.py b/unshackle/commands/dl.py index c2d8002..d99c58f 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,22 +801,26 @@ 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) 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 @@ -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...") @@ -1093,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...") 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/__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/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 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. 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" },