Merge branch 'main' into feature/add-rest-api

This commit is contained in:
Andy
2025-10-08 21:42:34 +00:00
10 changed files with 220 additions and 36 deletions

View File

@@ -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/), 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). 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 ## [1.4.7] - 2025-09-25
### Added ### Added

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "unshackle" name = "unshackle"
version = "1.4.6" version = "1.4.8"
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

@@ -180,6 +180,12 @@ class dl:
help="Required subtitle languages. Downloads all subtitles only if these languages exist. Cannot be used with --s-lang.", 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("-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( @click.option(
"--proxy", "--proxy",
type=str, type=str,
@@ -258,6 +264,7 @@ class dl:
@click.option( @click.option(
"--no-source", is_flag=True, default=False, help="Disable the source tag from the output file name and path." "--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( @click.option(
"--workers", "--workers",
type=int, type=int,
@@ -467,6 +474,7 @@ class dl:
s_lang: list[str], s_lang: list[str],
require_subs: list[str], require_subs: list[str],
forced_subs: bool, forced_subs: bool,
exact_lang: bool,
sub_format: Optional[Subtitle.Codec], sub_format: Optional[Subtitle.Codec],
video_only: bool, video_only: bool,
audio_only: bool, audio_only: bool,
@@ -484,6 +492,7 @@ class dl:
no_proxy: bool, no_proxy: bool,
no_folder: bool, no_folder: bool,
no_source: bool, no_source: bool,
no_mux: bool,
workers: Optional[int], workers: Optional[int],
downloads: int, downloads: int,
best_available: bool, best_available: bool,
@@ -707,7 +716,9 @@ class dl:
else: else:
if language not in processed_video_lang: if language not in processed_video_lang:
processed_video_lang.append(language) 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: if not title.tracks.videos:
self.log.error(f"There's no {processed_video_lang} Video Track...") self.log.error(f"There's no {processed_video_lang} Video Track...")
sys.exit(1) sys.exit(1)
@@ -790,16 +801,20 @@ class dl:
f"Required languages found ({', '.join(require_subs)}), downloading all available subtitles" f"Required languages found ({', '.join(require_subs)}), downloading all available subtitles"
) )
elif s_lang and "all" not in s_lang: 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 = [ missing_langs = [
lang_ lang_
for lang_ in s_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: if missing_langs:
self.log.error(", ".join(missing_langs) + " not found in tracks") self.log.error(", ".join(missing_langs) + " not found in tracks")
sys.exit(1) 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: if not title.tracks.subtitles:
self.log.error(f"There's no {s_lang} Subtitle Track...") self.log.error(f"There's no {s_lang} Subtitle Track...")
sys.exit(1) sys.exit(1)
@@ -863,7 +878,7 @@ class dl:
elif "all" not in processed_lang: elif "all" not in processed_lang:
per_language = 1 per_language = 1
title.tracks.audio = title.tracks.by_language( 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: if not title.tracks.audio:
self.log.error(f"There's no {processed_lang} Audio Track, cannot continue...") self.log.error(f"There's no {processed_lang} Audio Track, cannot continue...")
@@ -1139,7 +1154,12 @@ class dl:
muxed_paths = [] 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( progress = Progress(
TextColumn("[progress.description]{task.description}"), TextColumn("[progress.description]{task.description}"),
SpinnerColumn(finished_text=""), SpinnerColumn(finished_text=""),
@@ -1258,6 +1278,52 @@ class dl:
# dont mux # dont mux
muxed_paths.append(title.tracks.audio[0].path) muxed_paths.append(title.tracks.audio[0].path)
if no_mux:
# Handle individual track files without muxing
final_dir = config.directories.downloads
if not no_folder and isinstance(title, (Episode, Song)):
# 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)
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: for muxed_path in muxed_paths:
media_info = MediaInfo.parse(muxed_path) media_info = MediaInfo.parse(muxed_path)
final_dir = config.directories.downloads final_dir = config.directories.downloads

View File

@@ -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 atexit
import logging import logging
from pathlib import Path from pathlib import Path

View File

@@ -6,6 +6,7 @@ DOWNLOAD_LICENCE_ONLY = Event()
DRM_SORT_MAP = ["ClearKey", "Widevine"] DRM_SORT_MAP = ["ClearKey", "Widevine"]
LANGUAGE_MAX_DISTANCE = 5 # this is max to be considered "same", e.g., en, en-US, en-AU 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"} 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"} 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"} AUDIO_CODEC_MAP = {"E-AC-3": "DDP", "AC-3": "DD"}

View File

@@ -14,7 +14,7 @@ from rich.tree import Tree
from unshackle.core import binaries from unshackle.core import binaries
from unshackle.core.config import config from unshackle.core.config import config
from unshackle.core.console import console 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.events import events
from unshackle.core.tracks.attachment import Attachment from unshackle.core.tracks.attachment import Attachment
from unshackle.core.tracks.audio import Audio from unshackle.core.tracks.audio import Audio
@@ -294,11 +294,14 @@ class Tracks:
self.videos = selected self.videos = selected
@staticmethod @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 = [] selected = []
for language in languages: for language in languages:
selected.extend( 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 : per_language or None
] ]
) )

View File

@@ -24,7 +24,7 @@ from unidecode import unidecode
from unshackle.core.cacher import Cacher from unshackle.core.cacher import Cacher
from unshackle.core.config import config 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: 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 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: 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. Scan a byte array for a wanted MP4/ISOBMFF box, then parse and yield each find.

View File

@@ -294,7 +294,7 @@ def _apply_tags(path: Path, tags: dict[str, str]) -> None:
for name, value in tags.items(): for name, value in tags.items():
xml_lines.append(f" <Simple><Name>{escape(name)}</Name><String>{escape(value)}</String></Simple>") xml_lines.append(f" <Simple><Name>{escape(name)}</Name><String>{escape(value)}</String></Simple>")
xml_lines.extend([" </Tag>", "</Tags>"]) xml_lines.extend([" </Tag>", "</Tags>"])
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)) f.write("\n".join(xml_lines))
tmp_path = Path(f.name) tmp_path = Path(f.name)
try: try:

View File

@@ -16,13 +16,21 @@ class InsertResult(Enum):
class HTTP(Vault): 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__( def __init__(
self, self,
name: str, name: str,
host: str, host: str,
password: str, password: Optional[str] = None,
api_key: Optional[str] = None,
username: Optional[str] = None, username: Optional[str] = None,
api_mode: str = "query", api_mode: str = "query",
no_push: bool = False, no_push: bool = False,
@@ -34,13 +42,17 @@ class HTTP(Vault):
name: Vault name name: Vault name
host: Host URL host: Host URL
password: Password for query mode or API token for json mode password: Password for query mode or API token for json mode
username: Username (required for query mode, ignored for json mode) api_key: API key (alternative to password, used for decrypt_labs mode)
api_mode: "query" for query parameters or "json" for JSON API 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 no_push: If True, this vault will not receive pushed keys
""" """
super().__init__(name, no_push) super().__init__(name, no_push)
self.url = host 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.username = username
self.api_mode = api_mode.lower() self.api_mode = api_mode.lower()
self.current_title = None self.current_title = None
@@ -48,11 +60,15 @@ class HTTP(Vault):
self.session.headers.update({"User-Agent": f"unshackle v{__version__}"}) self.session.headers.update({"User-Agent": f"unshackle v{__version__}"})
self.api_session_id = None 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 # Validate configuration based on mode
if self.api_mode == "query" and not self.username: if self.api_mode == "query" and not self.username:
raise ValueError("Username is required for query mode") raise ValueError("Username is required for query mode")
elif self.api_mode not in ["query", "json"]: elif self.api_mode not in ["query", "json", "decrypt_labs"]:
raise ValueError("api_mode must be either 'query' or 'json'") raise ValueError("api_mode must be either 'query', 'json', or 'decrypt_labs'")
def request(self, method: str, params: dict = None) -> dict: def request(self, method: str, params: dict = None) -> dict:
"""Make a request to the JSON API vault.""" """Make a request to the JSON API vault."""
@@ -95,7 +111,51 @@ class HTTP(Vault):
if isinstance(kid, UUID): if isinstance(kid, UUID):
kid = kid.hex 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: try:
params = { params = {
"kid": kid, "kid": kid,
@@ -132,7 +192,9 @@ class HTTP(Vault):
return data["keys"][0]["key"] return data["keys"][0]["key"]
def get_keys(self, service: str) -> Iterator[tuple[str, str]]: 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 # 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 # This will cause the copy command to rely on the API's internal duplicate handling
return iter([]) return iter([])
@@ -153,6 +215,9 @@ class HTTP(Vault):
if not key or key.count("0") == len(key): if not key or key.count("0") == len(key):
raise ValueError("You cannot add a NULL Content Key to a Vault.") raise ValueError("You cannot add a NULL Content Key to a Vault.")
if self.api_mode == "decrypt_labs":
return False
if isinstance(kid, UUID): if isinstance(kid, UUID):
kid = kid.hex kid = kid.hex
@@ -192,6 +257,9 @@ class HTTP(Vault):
return data.get("status_code") == 200 return data.get("status_code") == 200
def add_keys(self, service: str, kid_keys: dict[Union[UUID, str], str]) -> int: 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(): for kid, key in kid_keys.items():
if not key or key.count("0") == len(key): if not key or key.count("0") == len(key):
raise ValueError("You cannot add a NULL Content Key to a Vault.") raise ValueError("You cannot add a NULL Content Key to a Vault.")
@@ -243,7 +311,9 @@ class HTTP(Vault):
return inserted_count return inserted_count
def get_services(self) -> Iterator[str]: 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: try:
response = self.request("GetServices") response = self.request("GetServices")
services = response.get("services", []) services = response.get("services", [])
@@ -283,6 +353,9 @@ class HTTP(Vault):
if not key or key.count("0") == len(key): if not key or key.count("0") == len(key):
raise ValueError("You cannot add a NULL Content Key to a Vault.") 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): if isinstance(kid, UUID):
kid = kid.hex kid = kid.hex

2
uv.lock generated
View File

@@ -1545,7 +1545,7 @@ wheels = [
[[package]] [[package]]
name = "unshackle" name = "unshackle"
version = "1.4.6" version = "1.4.8"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp-swagger3" }, { name = "aiohttp-swagger3" },