14 Commits

Author SHA1 Message Date
Andy
4564be6204 feat: Implement custom output templates for flexible filename generation and backward compatibility 2025-09-03 00:18:21 +00:00
Andy
d9763184bd Merge branch 'main' into feature/custom-output-templates 2025-09-02 23:08:29 +00:00
Andy
86bb162868 feat(tags): Enhance tag handling for TV shows and movies from Simkl data
Fixes #15
2025-09-02 22:01:44 +00:00
Andy
501cfd68e8 fix(cdm): Add error message for missing service certificate in CDM session 2025-09-02 19:16:34 +00:00
Andy
76fb2eea95 feat: implement intelligent caching system for CDM license requests 2025-09-02 18:48:34 +00:00
Andy
ea5ec40bcd Merge branch 'main' of https://github.com/unshackle-dl/unshackle 2025-09-02 17:34:12 +00:00
Andy
329850b043 feat(cdm): Enhance key retrieval logic and improve cached keys handling 2025-09-02 17:33:31 +00:00
Andy
73595f3b50 feat(cdm): Enhance key retrieval logic and improve cached keys handling 2025-09-02 17:23:02 +00:00
Andy
1e82283133 fix(tags): Fix import order. 2025-09-02 04:13:43 +00:00
Andy
ab13dde9d2 feat(changelog): Update changelog for version 1.4.4 with enhanced CDM support, configuration options, and various improvements 2025-09-02 04:10:28 +00:00
Andy
9fd0895128 feat(cdm): Refactor DecryptLabsRemoteCDM full support for Widevine/Playready and ChromeCDM 2025-09-02 04:02:52 +00:00
Andy
ed744205ad fix(tags): 🐛 Fix Matroska tag compliance with official specification
- Update IMDB tags to use ID only (tt123456) instead of URLs
  - Update TMDB tags to use prefix/id format (movie/123456, tv/123456)
  - Update TVDB tags to use numeric ID only
  - Add XML escaping for tag values
  - Fix XML declaration to use double quotes

Fixes #15
2025-09-01 21:02:08 +00:00
Andy
fbada7ac4d feat(song): Enhance filename generation with custom template support and audio context 2025-08-22 05:32:22 +00:00
Andy
e30a3c71c7 feat(template): Implement custom filename template formatter with variable substitution 2025-08-15 23:37:12 +00:00
16 changed files with 1282 additions and 872 deletions

View File

@@ -5,6 +5,64 @@ 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).
## [Unreleased]
### Added
- **Custom Output Templates**: Flexible filename customization system
- New `output_template` configuration in unshackle.yaml for movies, series, and songs
- Support for conditional variables using `?` suffix (e.g., `{year?}`, `{hdr?}`)
- Comprehensive template variables for title, quality, audio, video, and metadata
- Multiple naming styles: Scene-style (dot-separated), Plex-friendly (space-separated), minimal, custom
- Automatic template validation and enhanced error handling
- **Full backward compatibility**: Old `scene_naming` option still works and automatically converts to equivalent templates
- Folder naming now follows series template patterns (excluding episode-specific variables)
- Deprecation warnings guide users to migrate from `scene_naming` to `output_template`
### Changed
- **Filename Generation**: Updated all title classes (Movie, Episode, Song) to use new template system
- Enhanced context building for template variable substitution
- Improved separator handling based on template style detection
- Better handling of conditional content like HDR, Atmos, and multi-language audio
## [1.4.4] - 2025-09-02
### Added
- **Enhanced DecryptLabs CDM Support**: Comprehensive remote CDM functionality
- Full support for Widevine, PlayReady, and ChromeCDM through DecryptLabsRemoteCDM
- Enhanced session management and caching support for remote WV/PR operations
- Support for cached keys and improved license handling
- New CDM configurations for Chrome and PlayReady devices with updated User-Agent and service certificate
- **Advanced Configuration Options**: New device and language preferences
- Added configuration options for device certificate status list
- Enhanced language preference settings
### Changed
- **DRM Decryption Enhancements**: Streamlined decryption process
- Simplified decrypt method by removing unused parameter and streamlined logic
- Improved DecryptLabs CDM configurations with better device support
### Fixed
- **Matroska Tag Compliance**: Enhanced media container compatibility
- Fixed Matroska tag compliance with official specification
- **Application Branding**: Cleaned up version display
- Removed old devine version reference from banner to avoid developer confusion
- Updated branding while maintaining original GNU license compliance
- **IP Information Handling**: Improved geolocation services
- Enhanced get_ip_info functionality with better failover handling
- Added support for 429 error handling and multiple API provider fallback
- Implemented cached IP info retrieval with fallback tester to avoid rate limiting
- **Dependencies**: Streamlined package requirements
- Removed unnecessary data extra requirement from langcodes
### Removed
- Deprecated version references in application banner for clarity
## [1.4.3] - 2025-08-20 ## [1.4.3] - 2025-08-20
### Added ### Added

View File

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

@@ -345,7 +345,10 @@ class dl:
sys.exit(1) sys.exit(1)
if self.cdm: if self.cdm:
if hasattr(self.cdm, "device_type") and self.cdm.device_type.name in ["ANDROID", "CHROME"]: if isinstance(self.cdm, DecryptLabsRemoteCDM):
drm_type = "PlayReady" if self.cdm.is_playready else "Widevine"
self.log.info(f"Loaded {drm_type} Remote CDM: DecryptLabs (L{self.cdm.security_level})")
elif hasattr(self.cdm, "device_type") and self.cdm.device_type.name in ["ANDROID", "CHROME"]:
self.log.info(f"Loaded Widevine CDM: {self.cdm.system_id} (L{self.cdm.security_level})") self.log.info(f"Loaded Widevine CDM: {self.cdm.system_id} (L{self.cdm.security_level})")
else: else:
self.log.info( self.log.info(
@@ -874,7 +877,12 @@ class dl:
), ),
licence=partial( licence=partial(
service.get_playready_license service.get_playready_license
if isinstance(self.cdm, PlayReadyCdm) if (
isinstance(self.cdm, PlayReadyCdm)
or (
isinstance(self.cdm, DecryptLabsRemoteCDM) and self.cdm.is_playready
)
)
and hasattr(service, "get_playready_license") and hasattr(service, "get_playready_license")
else service.get_widevine_license, else service.get_widevine_license,
title=title, title=title,
@@ -1201,10 +1209,22 @@ class dl:
if not drm: if not drm:
return return
if isinstance(drm, Widevine) and not isinstance(self.cdm, WidevineCdm): if isinstance(drm, Widevine):
self.cdm = self.get_cdm(self.service, self.profile, drm="widevine") if not isinstance(self.cdm, (WidevineCdm, DecryptLabsRemoteCDM)) or (
elif isinstance(drm, PlayReady) and not isinstance(self.cdm, PlayReadyCdm): isinstance(self.cdm, DecryptLabsRemoteCDM) and self.cdm.is_playready
self.cdm = self.get_cdm(self.service, self.profile, drm="playready") ):
widevine_cdm = self.get_cdm(self.service, self.profile, drm="widevine")
if widevine_cdm:
self.log.info("Switching to Widevine CDM for Widevine content")
self.cdm = widevine_cdm
elif isinstance(drm, PlayReady):
if not isinstance(self.cdm, (PlayReadyCdm, DecryptLabsRemoteCDM)) or (
isinstance(self.cdm, DecryptLabsRemoteCDM) and not self.cdm.is_playready
):
playready_cdm = self.get_cdm(self.service, self.profile, drm="playready")
if playready_cdm:
self.log.info("Switching to PlayReady CDM for PlayReady content")
self.cdm = playready_cdm
if isinstance(drm, Widevine): if isinstance(drm, Widevine):
with self.DRM_TABLE_LOCK: with self.DRM_TABLE_LOCK:
@@ -1477,26 +1497,17 @@ class dl:
cdm_api = next(iter(x for x in config.remote_cdm if x["name"] == cdm_name), None) cdm_api = next(iter(x for x in config.remote_cdm if x["name"] == cdm_name), None)
if cdm_api: if cdm_api:
is_decrypt_lab = True if cdm_api["type"] == "decrypt_labs" else False is_decrypt_lab = True if cdm_api.get("type") == "decrypt_labs" else False
if is_decrypt_lab: if is_decrypt_lab:
device_type = cdm_api.get("device_type")
del cdm_api["name"] del cdm_api["name"]
del cdm_api["type"] del cdm_api["type"]
# Use the appropriate DecryptLabs CDM class based on device type # All DecryptLabs CDMs use DecryptLabsRemoteCDM
if device_type == "PLAYREADY" or cdm_api.get("device_name") in ["SL2", "SL3"]: return DecryptLabsRemoteCDM(service_name=service, vaults=self.vaults, **cdm_api)
from unshackle.core.cdm.decrypt_labs_remote_cdm import DecryptLabsRemotePlayReadyCDM
# Remove unused parameters for PlayReady CDM
cdm_params = cdm_api.copy()
cdm_params.pop("device_type", None)
cdm_params.pop("system_id", None)
return DecryptLabsRemotePlayReadyCDM(service_name=service, vaults=self.vaults, **cdm_params)
else:
return DecryptLabsRemoteCDM(service_name=service, vaults=self.vaults, **cdm_api)
else: else:
del cdm_api["name"] del cdm_api["name"]
del cdm_api["type"] if "type" in cdm_api:
del cdm_api["type"]
return RemoteCdm(**cdm_api) return RemoteCdm(**cdm_api)
prd_path = config.directories.prds / f"{cdm_name}.prd" prd_path = config.directories.prds / f"{cdm_name}.prd"

View File

@@ -1 +1 @@
__version__ = "1.4.3" __version__ = "1.4.4"

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import re
import warnings
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@@ -90,13 +92,116 @@ class Config:
self.tmdb_api_key: str = kwargs.get("tmdb_api_key") or "" self.tmdb_api_key: str = kwargs.get("tmdb_api_key") or ""
self.update_checks: bool = kwargs.get("update_checks", True) self.update_checks: bool = kwargs.get("update_checks", True)
self.update_check_interval: int = kwargs.get("update_check_interval", 24) self.update_check_interval: int = kwargs.get("update_check_interval", 24)
self.scene_naming: bool = kwargs.get("scene_naming", True)
self.series_year: bool = kwargs.get("series_year", True) # Handle backward compatibility for scene_naming option
self.scene_naming: Optional[bool] = kwargs.get("scene_naming")
self.output_template: dict = kwargs.get("output_template") or {}
# Apply scene_naming compatibility if no output_template is defined
self._apply_scene_naming_compatibility()
# Validate output templates
self._validate_output_templates()
self.title_cache_time: int = kwargs.get("title_cache_time", 1800) # 30 minutes default self.title_cache_time: int = kwargs.get("title_cache_time", 1800) # 30 minutes default
self.title_cache_max_retention: int = kwargs.get("title_cache_max_retention", 86400) # 24 hours default self.title_cache_max_retention: int = kwargs.get("title_cache_max_retention", 86400) # 24 hours default
self.title_cache_enabled: bool = kwargs.get("title_cache_enabled", True) self.title_cache_enabled: bool = kwargs.get("title_cache_enabled", True)
def _apply_scene_naming_compatibility(self) -> None:
"""Apply backward compatibility for the old scene_naming option."""
if self.scene_naming is not None:
# Only apply if no output_template is already defined
if not self.output_template.get("movies") and not self.output_template.get("series"):
if self.scene_naming:
# scene_naming: true = scene-style templates
self.output_template.update(
{
"movies": "{title}.{year}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}",
"series": "{title}.{year?}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}",
"songs": "{track_number}.{title}.{source?}.WEB-DL.{audio_full}.{atmos?}-{tag}",
}
)
else:
# scene_naming: false = Plex-friendly templates
self.output_template.update(
{
"movies": "{title} ({year}) {quality}",
"series": "{title} {season_episode} {episode_name?}",
"songs": "{track_number}. {title}",
}
)
# Warn about deprecated option
warnings.warn(
"The 'scene_naming' option is deprecated. Please use 'output_template' instead. "
"Your current setting has been converted to equivalent templates.",
DeprecationWarning,
stacklevel=2,
)
def _validate_output_templates(self) -> None:
"""Validate output template configurations and warn about potential issues."""
if not self.output_template:
return
# Known template variables for validation
valid_variables = {
# Basic variables
"title",
"year",
"season",
"episode",
"season_episode",
"episode_name",
"quality",
"resolution",
"source",
"tag",
"track_number",
"artist",
"album",
"disc",
# Audio variables
"audio",
"audio_channels",
"audio_full",
"atmos",
"dual",
"multi",
# Video variables
"video",
"hdr",
"hfr",
}
# Filesystem-unsafe characters that could cause issues
unsafe_chars = r'[<>:"/\\|?*]'
for template_type, template_str in self.output_template.items():
if not isinstance(template_str, str):
warnings.warn(f"Template '{template_type}' must be a string, got {type(template_str).__name__}")
continue
# Extract variables from template
variables = re.findall(r"\{([^}]+)\}", template_str)
# Check for unknown variables
for var in variables:
# Remove conditional suffix if present
var_clean = var.rstrip("?")
if var_clean not in valid_variables:
warnings.warn(f"Unknown template variable '{var}' in {template_type} template")
# Check for filesystem-unsafe characters outside of variables
# Replace variables with safe placeholders for testing
test_template = re.sub(r"\{[^}]+\}", "TEST", template_str)
if re.search(unsafe_chars, test_template):
warnings.warn(f"Template '{template_type}' may contain filesystem-unsafe characters")
# Check for empty template
if not template_str.strip():
warnings.warn(f"Template '{template_type}' is empty")
@classmethod @classmethod
def from_yaml(cls, path: Path) -> Config: def from_yaml(cls, path: Path) -> Config:
if not path.exists(): if not path.exists():

View File

@@ -256,44 +256,38 @@ class PlayReady:
return keys return keys
def get_content_keys(self, cdm: PlayReadyCdm, certificate: Callable, licence: Callable) -> None: def get_content_keys(self, cdm: PlayReadyCdm, certificate: Callable, licence: Callable) -> None:
for kid in self.kids: session_id = cdm.open()
if kid in self.content_keys: try:
continue if hasattr(cdm, "set_pssh_b64") and self.pssh_b64:
cdm.set_pssh_b64(self.pssh_b64)
session_id = cdm.open() if hasattr(cdm, "set_required_kids"):
try: cdm.set_required_kids(self.kids)
if hasattr(cdm, "set_pssh_b64") and self.pssh_b64:
cdm.set_pssh_b64(self.pssh_b64)
challenge = cdm.get_license_challenge(session_id, self.pssh.wrm_headers[0]) challenge = cdm.get_license_challenge(session_id, self.pssh.wrm_headers[0])
if challenge:
try: try:
license_res = licence(challenge=challenge) license_res = licence(challenge=challenge)
except Exception: if isinstance(license_res, bytes):
if hasattr(cdm, "use_cached_keys_as_fallback"): license_str = license_res.decode(errors="ignore")
if cdm.use_cached_keys_as_fallback(session_id): else:
keys = self._extract_keys_from_cdm(cdm, session_id) license_str = str(license_res)
self.content_keys.update(keys)
continue
if "<License>" not in license_str:
try:
license_str = base64.b64decode(license_str + "===").decode()
except Exception:
pass
cdm.parse_license(session_id, license_str)
except Exception:
raise raise
if isinstance(license_res, bytes): keys = self._extract_keys_from_cdm(cdm, session_id)
license_str = license_res.decode(errors="ignore") self.content_keys.update(keys)
else: finally:
license_str = str(license_res) cdm.close(session_id)
if "<License>" not in license_str:
try:
license_str = base64.b64decode(license_str + "===").decode()
except Exception:
pass
cdm.parse_license(session_id, license_str)
keys = self._extract_keys_from_cdm(cdm, session_id)
self.content_keys.update(keys)
finally:
cdm.close(session_id)
if not self.content_keys: if not self.content_keys:
raise PlayReady.Exceptions.EmptyLicense("No Content Keys were within the License") raise PlayReady.Exceptions.EmptyLicense("No Content Keys were within the License")

View File

@@ -185,6 +185,9 @@ class Widevine:
if cert and hasattr(cdm, "set_service_certificate"): if cert and hasattr(cdm, "set_service_certificate"):
cdm.set_service_certificate(session_id, cert) cdm.set_service_certificate(session_id, cert)
if hasattr(cdm, "set_required_kids"):
cdm.set_required_kids(self.kids)
challenge = cdm.get_license_challenge(session_id, self.pssh) challenge = cdm.get_license_challenge(session_id, self.pssh)
if hasattr(cdm, "has_cached_keys") and cdm.has_cached_keys(session_id): if hasattr(cdm, "has_cached_keys") and cdm.has_cached_keys(session_id):
@@ -218,6 +221,9 @@ class Widevine:
if cert and hasattr(cdm, "set_service_certificate"): if cert and hasattr(cdm, "set_service_certificate"):
cdm.set_service_certificate(session_id, cert) cdm.set_service_certificate(session_id, cert)
if hasattr(cdm, "set_required_kids"):
cdm.set_required_kids(self.kids)
challenge = cdm.get_license_challenge(session_id, self.pssh) challenge = cdm.get_license_challenge(session_id, self.pssh)
if hasattr(cdm, "has_cached_keys") and cdm.has_cached_keys(session_id): if hasattr(cdm, "has_cached_keys") and cdm.has_cached_keys(session_id):

View File

@@ -12,6 +12,7 @@ from unshackle.core.config import config
from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP
from unshackle.core.titles.title import Title from unshackle.core.titles.title import Title
from unshackle.core.utilities import sanitize_filename from unshackle.core.utilities import sanitize_filename
from unshackle.core.utils.template_formatter import TemplateFormatter
class Episode(Title): class Episode(Title):
@@ -78,116 +79,154 @@ class Episode(Title):
self.year = year self.year = year
self.description = description self.description = description
def _build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict:
"""Build template context dictionary from MediaInfo."""
primary_video_track = next(iter(media_info.video_tracks), None)
primary_audio_track = next(iter(media_info.audio_tracks), None)
unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language})
context = {
"title": self.title.replace("$", "S"),
"year": self.year or "",
"season": f"S{self.season:02}",
"episode": f"E{self.number:02}",
"season_episode": f"S{self.season:02}E{self.number:02}",
"episode_name": self.name or "",
"tag": config.tag or "",
"source": self.service.__name__ if show_service else "",
}
# Video information
if primary_video_track:
resolution = primary_video_track.height
aspect_ratio = [int(float(plane)) for plane in primary_video_track.other_display_aspect_ratio[0].split(":")]
if len(aspect_ratio) == 1:
aspect_ratio.append(1)
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
resolution = int(primary_video_track.width * (9 / 16))
context.update(
{
"quality": f"{resolution}p",
"resolution": str(resolution),
"video": VIDEO_CODEC_MAP.get(primary_video_track.format, primary_video_track.format),
}
)
# HDR information
hdr_format = primary_video_track.hdr_format_commercial
trc = primary_video_track.transfer_characteristics or primary_video_track.transfer_characteristics_original
if hdr_format:
if (primary_video_track.hdr_format or "").startswith("Dolby Vision"):
context["hdr"] = "DV"
base_layer = DYNAMIC_RANGE_MAP.get(hdr_format)
if base_layer and base_layer != "DV":
context["hdr"] += f".{base_layer}"
else:
context["hdr"] = DYNAMIC_RANGE_MAP.get(hdr_format, "")
elif trc and "HLG" in trc:
context["hdr"] = "HLG"
else:
context["hdr"] = ""
# High frame rate
frame_rate = float(primary_video_track.frame_rate)
context["hfr"] = "HFR" if frame_rate > 30 else ""
# Audio information
if primary_audio_track:
codec = primary_audio_track.format
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
if channel_layout:
channels = float(sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" ")))
else:
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0
channels = float(channel_count)
features = primary_audio_track.format_additionalfeatures or ""
context.update(
{
"audio": AUDIO_CODEC_MAP.get(codec, codec),
"audio_channels": f"{channels:.1f}",
"audio_full": f"{AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}",
"atmos": "Atmos" if ("JOC" in features or primary_audio_track.joc) else "",
}
)
# Multi-language audio
if unique_audio_languages == 2:
context["dual"] = "DUAL"
context["multi"] = ""
elif unique_audio_languages > 2:
context["dual"] = ""
context["multi"] = "MULTi"
else:
context["dual"] = ""
context["multi"] = ""
return context
def __str__(self) -> str: def __str__(self) -> str:
return "{title}{year} S{season:02}E{number:02} {name}".format( return "{title}{year} S{season:02}E{number:02} {name}".format(
title=self.title, title=self.title,
year=f" {self.year}" if self.year and config.series_year else "", year=f" {self.year}" if self.year else "",
season=self.season, season=self.season,
number=self.number, number=self.number,
name=self.name or "", name=self.name or "",
).strip() ).strip()
def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str: def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str:
primary_video_track = next(iter(media_info.video_tracks), None)
primary_audio_track = next(iter(media_info.audio_tracks), None)
unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language})
# Title [Year] SXXEXX Name (or Title [Year] SXX if folder)
if folder: if folder:
name = f"{self.title}" # For folders, use the series template but exclude episode-specific variables
if self.year and config.series_year: series_template = config.output_template.get("series")
name += f" {self.year}" if series_template:
name += f" S{self.season:02}" # Create a folder-friendly version by removing episode-specific variables
else: folder_template = series_template
name = "{title}{year} S{season:02}E{number:02} {name}".format( # Remove episode number and episode name from template for folders
title=self.title.replace("$", "S"), # e.g., Arli$$ folder_template = re.sub(r'\{episode\}', '', folder_template)
year=f" {self.year}" if self.year and config.series_year else "", folder_template = re.sub(r'\{episode_name\?\}', '', folder_template)
season=self.season, folder_template = re.sub(r'\{episode_name\}', '', folder_template)
number=self.number, folder_template = re.sub(r'\{season_episode\}', '{season}', folder_template)
name=self.name or "",
).strip()
if config.scene_naming: # Clean up any double separators that might result
# Resolution folder_template = re.sub(r'\.{2,}', '.', folder_template)
if primary_video_track: folder_template = re.sub(r'\s{2,}', ' ', folder_template)
resolution = primary_video_track.height folder_template = re.sub(r'^[\.\s]+|[\.\s]+$', '', folder_template)
aspect_ratio = [
int(float(plane)) for plane in primary_video_track.other_display_aspect_ratio[0].split(":")
]
if len(aspect_ratio) == 1:
# e.g., aspect ratio of 2 (2.00:1) would end up as `(2.0,)`, add 1
aspect_ratio.append(1)
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
# We want the resolution represented in a 4:3 or 16:9 canvas.
# If it's not 4:3 or 16:9, calculate as if it's inside a 16:9 canvas,
# otherwise the track's height value is fine.
# We are assuming this title is some weird aspect ratio so most
# likely a movie or HD source, so it's most likely widescreen so
# 16:9 canvas makes the most sense.
resolution = int(primary_video_track.width * (9 / 16))
name += f" {resolution}p"
# Service formatter = TemplateFormatter(folder_template)
if show_service: context = self._build_template_context(media_info, show_service)
name += f" {self.service.__name__}" # Override season_episode with just season for folders
context['season'] = f"S{self.season:02}"
# 'WEB-DL' folder_name = formatter.format(context)
name += " WEB-DL"
# DUAL # Keep the same separator style as the series template
if unique_audio_languages == 2: if '.' in series_template and ' ' not in series_template:
name += " DUAL" # Dot-based template - use dot separator for folders too
return sanitize_filename(folder_name, ".")
# MULTi
if unique_audio_languages > 2:
name += " MULTi"
# Audio Codec + Channels (+ feature)
if primary_audio_track:
codec = primary_audio_track.format
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
if channel_layout:
channels = float(
sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" "))
)
else: else:
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0 # Space-based template - use space separator
channels = float(channel_count) return sanitize_filename(folder_name, " ")
else:
# Fallback to simple naming if no template defined
name = f"{self.title}"
if self.year:
name += f" {self.year}"
name += f" S{self.season:02}"
return sanitize_filename(name, " ")
features = primary_audio_track.format_additionalfeatures or "" # Use template from output_template (which includes scene_naming compatibility)
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}" # or fallback to default scene-style template
if "JOC" in features or primary_audio_track.joc: template = (
name += " Atmos" config.output_template.get("series")
or "{title}.{year?}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hfr?}.{video}-{tag}"
)
# Video (dynamic range + hfr +) Codec formatter = TemplateFormatter(template)
if primary_video_track: context = self._build_template_context(media_info, show_service)
codec = primary_video_track.format return formatter.format(context)
hdr_format = primary_video_track.hdr_format_commercial
trc = (
primary_video_track.transfer_characteristics
or primary_video_track.transfer_characteristics_original
)
frame_rate = float(primary_video_track.frame_rate)
if hdr_format:
if (primary_video_track.hdr_format or "").startswith("Dolby Vision"):
name += " DV"
if DYNAMIC_RANGE_MAP.get(hdr_format) and DYNAMIC_RANGE_MAP.get(hdr_format) != "DV":
name += " HDR"
else:
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
elif trc and "HLG" in trc:
name += " HLG"
if frame_rate > 30:
name += " HFR"
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}"
if config.tag:
name += f"-{config.tag}"
return sanitize_filename(name)
else:
# Simple naming style without technical details - use spaces instead of dots
return sanitize_filename(name, " ")
class Series(SortedKeyList, ABC): class Series(SortedKeyList, ABC):
@@ -197,7 +236,7 @@ class Series(SortedKeyList, ABC):
def __str__(self) -> str: def __str__(self) -> str:
if not self: if not self:
return super().__str__() return super().__str__()
return self[0].title + (f" ({self[0].year})" if self[0].year and config.series_year else "") return self[0].title + (f" ({self[0].year})" if self[0].year else "")
def tree(self, verbose: bool = False) -> Tree: def tree(self, verbose: bool = False) -> Tree:
seasons = Counter(x.season for x in self) seasons = Counter(x.season for x in self)

View File

@@ -9,7 +9,7 @@ from sortedcontainers import SortedKeyList
from unshackle.core.config import config from unshackle.core.config import config
from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP
from unshackle.core.titles.title import Title from unshackle.core.titles.title import Title
from unshackle.core.utilities import sanitize_filename from unshackle.core.utils.template_formatter import TemplateFormatter
class Movie(Title): class Movie(Title):
@@ -45,100 +45,107 @@ class Movie(Title):
self.year = year self.year = year
self.description = description self.description = description
def _build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict:
"""Build template context dictionary from MediaInfo."""
primary_video_track = next(iter(media_info.video_tracks), None)
primary_audio_track = next(iter(media_info.audio_tracks), None)
unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language})
context = {
"title": self.name.replace("$", "S"),
"year": self.year or "",
"tag": config.tag or "",
"source": self.service.__name__ if show_service else "",
}
# Video information
if primary_video_track:
resolution = primary_video_track.height
aspect_ratio = [int(float(plane)) for plane in primary_video_track.other_display_aspect_ratio[0].split(":")]
if len(aspect_ratio) == 1:
aspect_ratio.append(1)
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
resolution = int(primary_video_track.width * (9 / 16))
context.update(
{
"quality": f"{resolution}p",
"resolution": str(resolution),
"video": VIDEO_CODEC_MAP.get(primary_video_track.format, primary_video_track.format),
}
)
# HDR information
hdr_format = primary_video_track.hdr_format_commercial
trc = primary_video_track.transfer_characteristics or primary_video_track.transfer_characteristics_original
if hdr_format:
if (primary_video_track.hdr_format or "").startswith("Dolby Vision"):
context["hdr"] = "DV"
base_layer = DYNAMIC_RANGE_MAP.get(hdr_format)
if base_layer and base_layer != "DV":
context["hdr"] += f".{base_layer}"
else:
context["hdr"] = DYNAMIC_RANGE_MAP.get(hdr_format, "")
elif trc and "HLG" in trc:
context["hdr"] = "HLG"
else:
context["hdr"] = ""
# High frame rate
frame_rate = float(primary_video_track.frame_rate)
context["hfr"] = "HFR" if frame_rate > 30 else ""
# Audio information
if primary_audio_track:
codec = primary_audio_track.format
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
if channel_layout:
channels = float(sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" ")))
else:
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0
channels = float(channel_count)
features = primary_audio_track.format_additionalfeatures or ""
context.update(
{
"audio": AUDIO_CODEC_MAP.get(codec, codec),
"audio_channels": f"{channels:.1f}",
"audio_full": f"{AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}",
"atmos": "Atmos" if ("JOC" in features or primary_audio_track.joc) else "",
}
)
# Multi-language audio
if unique_audio_languages == 2:
context["dual"] = "DUAL"
context["multi"] = ""
elif unique_audio_languages > 2:
context["dual"] = ""
context["multi"] = "MULTi"
else:
context["dual"] = ""
context["multi"] = ""
return context
def __str__(self) -> str: def __str__(self) -> str:
if self.year: if self.year:
return f"{self.name} ({self.year})" return f"{self.name} ({self.year})"
return self.name return self.name
def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str: def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str:
primary_video_track = next(iter(media_info.video_tracks), None) # Use template from output_template (which includes scene_naming compatibility)
primary_audio_track = next(iter(media_info.audio_tracks), None) # or fallback to default scene-style template
unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language}) template = (
config.output_template.get("movies")
or "{title}.{year}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}"
)
# Name (Year) formatter = TemplateFormatter(template)
name = str(self).replace("$", "S") # e.g., Arli$$ context = self._build_template_context(media_info, show_service)
return formatter.format(context)
if config.scene_naming:
# Resolution
if primary_video_track:
resolution = primary_video_track.height
aspect_ratio = [
int(float(plane)) for plane in primary_video_track.other_display_aspect_ratio[0].split(":")
]
if len(aspect_ratio) == 1:
# e.g., aspect ratio of 2 (2.00:1) would end up as `(2.0,)`, add 1
aspect_ratio.append(1)
if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3):
# We want the resolution represented in a 4:3 or 16:9 canvas.
# If it's not 4:3 or 16:9, calculate as if it's inside a 16:9 canvas,
# otherwise the track's height value is fine.
# We are assuming this title is some weird aspect ratio so most
# likely a movie or HD source, so it's most likely widescreen so
# 16:9 canvas makes the most sense.
resolution = int(primary_video_track.width * (9 / 16))
name += f" {resolution}p"
# Service
if show_service:
name += f" {self.service.__name__}"
# 'WEB-DL'
name += " WEB-DL"
# DUAL
if unique_audio_languages == 2:
name += " DUAL"
# MULTi
if unique_audio_languages > 2:
name += " MULTi"
# Audio Codec + Channels (+ feature)
if primary_audio_track:
codec = primary_audio_track.format
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
if channel_layout:
channels = float(
sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" "))
)
else:
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0
channels = float(channel_count)
features = primary_audio_track.format_additionalfeatures or ""
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
if "JOC" in features or primary_audio_track.joc:
name += " Atmos"
# Video (dynamic range + hfr +) Codec
if primary_video_track:
codec = primary_video_track.format
hdr_format = primary_video_track.hdr_format_commercial
trc = (
primary_video_track.transfer_characteristics
or primary_video_track.transfer_characteristics_original
)
frame_rate = float(primary_video_track.frame_rate)
if hdr_format:
if (primary_video_track.hdr_format or "").startswith("Dolby Vision"):
name += " DV"
if DYNAMIC_RANGE_MAP.get(hdr_format) and DYNAMIC_RANGE_MAP.get(hdr_format) != "DV":
name += " HDR"
else:
name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} "
elif trc and "HLG" in trc:
name += " HLG"
if frame_rate > 30:
name += " HFR"
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}"
if config.tag:
name += f"-{config.tag}"
return sanitize_filename(name)
else:
# Simple naming style without technical details - use spaces instead of dots
return sanitize_filename(name, " ")
class Movies(SortedKeyList, ABC): class Movies(SortedKeyList, ABC):

View File

@@ -10,6 +10,7 @@ from unshackle.core.config import config
from unshackle.core.constants import AUDIO_CODEC_MAP from unshackle.core.constants import AUDIO_CODEC_MAP
from unshackle.core.titles.title import Title from unshackle.core.titles.title import Title
from unshackle.core.utilities import sanitize_filename from unshackle.core.utilities import sanitize_filename
from unshackle.core.utils.template_formatter import TemplateFormatter
class Song(Title): class Song(Title):
@@ -81,46 +82,63 @@ class Song(Title):
artist=self.artist, album=self.album, year=self.year, track=self.track, name=self.name artist=self.artist, album=self.album, year=self.year, track=self.track, name=self.name
).strip() ).strip()
def _build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict:
"""Build template context dictionary from MediaInfo."""
primary_audio_track = next(iter(media_info.audio_tracks), None)
context = {
"artist": self.artist.replace("$", "S"),
"album": self.album.replace("$", "S"),
"title": self.name.replace("$", "S"),
"track_number": f"{self.track:02}",
"disc": f"{self.disc:02}" if self.disc > 1 else "",
"year": self.year or "",
"tag": config.tag or "",
"source": self.service.__name__ if show_service else "",
}
# Audio information
if primary_audio_track:
codec = primary_audio_track.format
channel_layout = primary_audio_track.channel_layout or primary_audio_track.channellayout_original
if channel_layout:
channels = float(sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" ")))
else:
channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0
channels = float(channel_count)
features = primary_audio_track.format_additionalfeatures or ""
context.update(
{
"audio": AUDIO_CODEC_MAP.get(codec, codec),
"audio_channels": f"{channels:.1f}",
"audio_full": f"{AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}",
"atmos": "Atmos" if ("JOC" in features or primary_audio_track.joc) else "",
}
)
return context
def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str: def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str:
audio_track = next(iter(media_info.audio_tracks), None)
codec = audio_track.format
channel_layout = audio_track.channel_layout or audio_track.channellayout_original
if channel_layout:
channels = float(sum({"LFE": 0.1}.get(position.upper(), 1) for position in channel_layout.split(" ")))
else:
channel_count = audio_track.channel_s or audio_track.channels or 0
channels = float(channel_count)
features = audio_track.format_additionalfeatures or ""
if folder: if folder:
# Artist - Album (Year) # For folders, use simple naming: "Artist - Album (Year)"
name = str(self).split(" / ")[0] name = f"{self.artist} - {self.album}"
else: if self.year:
# NN. Song Name name += f" ({self.year})"
name = str(self).split(" / ")[1]
if config.scene_naming:
# Service
if show_service:
name += f" {self.service.__name__}"
# 'WEB-DL'
name += " WEB-DL"
# Audio Codec + Channels (+ feature)
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
if "JOC" in features or audio_track.joc:
name += " Atmos"
if config.tag:
name += f"-{config.tag}"
return sanitize_filename(name, " ")
else:
# Simple naming style without technical details
return sanitize_filename(name, " ") return sanitize_filename(name, " ")
# Use template from output_template (which includes scene_naming compatibility)
# or fallback to default scene-style template
template = (
config.output_template.get("songs") or "{track_number}.{title}.{source?}.WEB-DL.{audio_full}.{atmos?}-{tag}"
)
formatter = TemplateFormatter(template)
context = self._build_template_context(media_info, show_service)
return formatter.format(context)
class Album(SortedKeyList, ABC): class Album(SortedKeyList, ABC):
def __init__(self, iterable: Optional[Iterable] = None): def __init__(self, iterable: Optional[Iterable] = None):

View File

@@ -420,6 +420,15 @@ class Track:
for drm in self.drm: for drm in self.drm:
if isinstance(drm, PlayReady): if isinstance(drm, PlayReady):
return drm return drm
elif hasattr(cdm, 'is_playready'):
if cdm.is_playready:
for drm in self.drm:
if isinstance(drm, PlayReady):
return drm
else:
for drm in self.drm:
if isinstance(drm, Widevine):
return drm
return self.drm[0] return self.drm[0]

View File

@@ -8,6 +8,7 @@ import tempfile
from difflib import SequenceMatcher from difflib import SequenceMatcher
from pathlib import Path from pathlib import Path
from typing import Optional, Tuple from typing import Optional, Tuple
from xml.sax.saxutils import escape
import requests import requests
from requests.adapters import HTTPAdapter, Retry from requests.adapters import HTTPAdapter, Retry
@@ -289,9 +290,9 @@ def _apply_tags(path: Path, tags: dict[str, str]) -> None:
log.debug("mkvpropedit not found on PATH; skipping tags") log.debug("mkvpropedit not found on PATH; skipping tags")
return return
log.debug("Applying tags to %s: %s", path, tags) log.debug("Applying tags to %s: %s", path, tags)
xml_lines = ["<?xml version='1.0' encoding='UTF-8'?>", "<Tags>", " <Tag>", " <Targets/>"] xml_lines = ['<?xml version="1.0" encoding="UTF-8"?>', "<Tags>", " <Tag>", " <Targets/>"]
for name, value in tags.items(): for name, value in tags.items():
xml_lines.append(f" <Simple><Name>{name}</Name><String>{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) as f:
f.write("\n".join(xml_lines)) f.write("\n".join(xml_lines))
@@ -349,13 +350,25 @@ def tag_file(path: Path, title: Title, tmdb_id: Optional[int] | None = None) ->
if simkl_tmdb_id: if simkl_tmdb_id:
tmdb_id = simkl_tmdb_id tmdb_id = simkl_tmdb_id
show_ids = simkl_data.get("show", {}).get("ids", {}) # Handle TV show data from Simkl
if show_ids.get("imdb"): if simkl_data.get("type") == "episode" and "show" in simkl_data:
standard_tags["IMDB"] = f"https://www.imdb.com/title/{show_ids['imdb']}" show_ids = simkl_data.get("show", {}).get("ids", {})
if show_ids.get("tvdb"): if show_ids.get("imdb"):
standard_tags["TVDB"] = f"https://thetvdb.com/dereferrer/series/{show_ids['tvdb']}" standard_tags["IMDB"] = show_ids["imdb"]
if show_ids.get("tmdbtv"): if show_ids.get("tvdb"):
standard_tags["TMDB"] = f"https://www.themoviedb.org/tv/{show_ids['tmdbtv']}" standard_tags["TVDB2"] = f"series/{show_ids['tvdb']}"
if show_ids.get("tmdbtv"):
standard_tags["TMDB"] = f"tv/{show_ids['tmdbtv']}"
# Handle movie data from Simkl
elif simkl_data.get("type") == "movie" and "movie" in simkl_data:
movie_ids = simkl_data.get("movie", {}).get("ids", {})
if movie_ids.get("imdb"):
standard_tags["IMDB"] = movie_ids["imdb"]
if movie_ids.get("tvdb"):
standard_tags["TVDB2"] = f"movies/{movie_ids['tvdb']}"
if movie_ids.get("tmdb"):
standard_tags["TMDB"] = f"movie/{movie_ids['tmdb']}"
# Use TMDB API for additional metadata (either from provided ID or Simkl lookup) # Use TMDB API for additional metadata (either from provided ID or Simkl lookup)
api_key = _api_key() api_key = _api_key()
@@ -373,8 +386,8 @@ def tag_file(path: Path, title: Title, tmdb_id: Optional[int] | None = None) ->
_apply_tags(path, custom_tags) _apply_tags(path, custom_tags)
return return
tmdb_url = f"https://www.themoviedb.org/{'movie' if kind == 'movie' else 'tv'}/{tmdb_id}" prefix = "movie" if kind == "movie" else "tv"
standard_tags["TMDB"] = tmdb_url standard_tags["TMDB"] = f"{prefix}/{tmdb_id}"
try: try:
ids = external_ids(tmdb_id, kind) ids = external_ids(tmdb_id, kind)
except requests.RequestException as exc: except requests.RequestException as exc:
@@ -385,11 +398,13 @@ def tag_file(path: Path, title: Title, tmdb_id: Optional[int] | None = None) ->
imdb_id = ids.get("imdb_id") imdb_id = ids.get("imdb_id")
if imdb_id: if imdb_id:
standard_tags["IMDB"] = f"https://www.imdb.com/title/{imdb_id}" standard_tags["IMDB"] = imdb_id
tvdb_id = ids.get("tvdb_id") tvdb_id = ids.get("tvdb_id")
if tvdb_id: if tvdb_id:
tvdb_prefix = "movies" if kind == "movie" else "series" if kind == "movie":
standard_tags["TVDB"] = f"https://thetvdb.com/dereferrer/{tvdb_prefix}/{tvdb_id}" standard_tags["TVDB2"] = f"movies/{tvdb_id}"
else:
standard_tags["TVDB2"] = f"series/{tvdb_id}"
merged_tags = { merged_tags = {
**custom_tags, **custom_tags,

View File

@@ -0,0 +1,147 @@
import logging
import re
from typing import Any, Dict, List
from unshackle.core.utilities import sanitize_filename
class TemplateFormatter:
"""
Template formatter for custom filename patterns.
Supports variable substitution and conditional variables.
Example: '{title}.{year}.{quality?}.{source}-{tag}'
"""
def __init__(self, template: str):
"""Initialize the template formatter.
Args:
template: Template string with variables in {variable} format
"""
self.template = template
self.variables = self._extract_variables()
def _extract_variables(self) -> List[str]:
"""Extract all variables from the template."""
pattern = r"\{([^}]+)\}"
matches = re.findall(pattern, self.template)
return [match.strip() for match in matches]
def format(self, context: Dict[str, Any]) -> str:
"""Format the template with the provided context.
Args:
context: Dictionary containing variable values
Returns:
Formatted filename string
Raises:
ValueError: If required template variables are missing from context
"""
logger = logging.getLogger(__name__)
# Validate that all required variables are present
is_valid, missing_vars = self.validate(context)
if not is_valid:
error_msg = f"Missing required template variables: {', '.join(missing_vars)}"
logger.error(error_msg)
raise ValueError(error_msg)
try:
result = self.template
for variable in self.variables:
placeholder = "{" + variable + "}"
is_conditional = variable.endswith("?")
if is_conditional:
# Remove the ? for conditional variables
var_name = variable[:-1]
value = context.get(var_name, "")
if value:
# Replace with actual value, ensuring it's string and safe
safe_value = str(value).strip()
result = result.replace(placeholder, safe_value)
else:
# Remove the placeholder entirely for empty conditional variables
result = result.replace(placeholder, "")
else:
# Regular variable
value = context.get(variable, "")
if value is None:
logger.warning(f"Template variable '{variable}' is None, using empty string")
value = ""
safe_value = str(value).strip()
result = result.replace(placeholder, safe_value)
# Clean up multiple consecutive dots/separators and other artifacts
result = re.sub(r"\.{2,}", ".", result) # Multiple dots -> single dot
result = re.sub(r"\s{2,}", " ", result) # Multiple spaces -> single space
result = re.sub(r"^[\.\s]+|[\.\s]+$", "", result) # Remove leading/trailing dots and spaces
result = re.sub(r"\.-", "-", result) # Remove dots before dashes (for dot-based templates)
result = re.sub(r"[\.\s]+\)", ")", result) # Remove dots/spaces before closing parentheses
# Determine the appropriate separator based on template style
# If the template contains spaces (like Plex-friendly), preserve them
if " " in self.template and "." not in self.template:
# Space-based template (Plex-friendly) - use space separator
result = sanitize_filename(result, spacer=" ")
else:
# Dot-based template (scene-style) - use dot separator
result = sanitize_filename(result, spacer=".")
# Final validation - ensure we have a non-empty result
if not result or result.isspace():
logger.warning("Template formatting resulted in empty filename, using fallback")
return "untitled"
logger.debug(f"Template formatted successfully: '{self.template}' -> '{result}'")
return result
except Exception as e:
logger.error(f"Error formatting template '{self.template}': {e}")
# Return a safe fallback filename
fallback = f"error_formatting_{hash(self.template) % 10000}"
logger.warning(f"Using fallback filename: {fallback}")
return fallback
def validate(self, context: Dict[str, Any]) -> tuple[bool, List[str]]:
"""Validate that all required variables are present in context.
Args:
context: Dictionary containing variable values
Returns:
Tuple of (is_valid, missing_variables)
"""
missing = []
for variable in self.variables:
is_conditional = variable.endswith("?")
var_name = variable[:-1] if is_conditional else variable
# Only check non-conditional variables
if not is_conditional and var_name not in context:
missing.append(var_name)
return len(missing) == 0, missing
def get_required_variables(self) -> List[str]:
"""Get list of required (non-conditional) variables."""
required = []
for variable in self.variables:
if not variable.endswith("?"):
required.append(variable)
return required
def get_optional_variables(self) -> List[str]:
"""Get list of optional (conditional) variables."""
optional = []
for variable in self.variables:
if variable.endswith("?"):
optional.append(variable[:-1]) # Remove the ?
return optional

View File

@@ -10,15 +10,45 @@ tag_imdb_tmdb: true
# Set terminal background color (custom option not in CONFIG.md) # Set terminal background color (custom option not in CONFIG.md)
set_terminal_bg: false set_terminal_bg: false
# Set file naming convention # File naming is now controlled via output_template (see below)
# true for style - Prime.Suspect.S07E01.The.Final.Act.Part.One.1080p.ITV.WEB-DL.AAC2.0.H.264 # Default behavior provides scene-style naming similar to the old scene_naming: true
# false for style - Prime Suspect S07E01 The Final Act - Part One #
scene_naming: true # BACKWARD COMPATIBILITY: The old scene_naming option is still supported:
# scene_naming: true -> Equivalent to scene-style templates (dot-separated)
# scene_naming: false -> Equivalent to Plex-friendly templates (space-separated)
# Note: output_template takes precedence over scene_naming if both are defined
# Whether to include the year in series names for episodes and folders (default: true) # Custom output templates for filenames
# true for style - Show Name (2023) S01E01 Episode Name # When not defined, defaults to scene-style naming equivalent to the old scene_naming: true
# false for style - Show Name S01E01 Episode Name # Available variables: {title}, {year}, {season}, {episode}, {season_episode}, {episode_name},
series_year: true # {quality}, {resolution}, {source}, {audio}, {audio_channels}, {audio_full},
# {video}, {hdr}, {hfr}, {atmos}, {dual}, {multi}, {tag}
# Conditional variables (included only if present): Add ? suffix like {year?}, {episode_name?}, {hdr?}
# Uncomment and customize the templates below:
#
# output_template:
# # Scene-style naming (dot-separated) - Default behavior when no template is defined
# movies: '{title}.{year}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}'
# series: '{title}.{year?}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}'
#
# # Plex-friendly naming (space-separated, clean format)
# # movies: '{title} ({year}) {quality}'
# # series: '{title} {season_episode} {episode_name?}'
#
# # Minimal naming (basic info only)
# # movies: '{title}.{year}.{quality}'
# # series: '{title}.{season_episode}.{episode_name?}'
#
# # Custom scene-style with specific elements
# # movies: '{title}.{year}.{quality}.{hdr?}.{source}.WEB-DL.{audio_full}.{video}-{tag}'
# # series: '{title}.{year?}.{season_episode}.{episode_name?}.{quality}.{hdr?}.{source}.WEB-DL.{audio_full}.{atmos?}.{video}-{tag}'
#
# Example outputs:
# Scene movies: 'The.Matrix.1999.1080p.NF.WEB-DL.DDP5.1.H.264-EXAMPLE'
# Scene movies (HDR): 'Dune.2021.2160p.HBO.WEB-DL.DDP5.1.HDR10.H.265-EXAMPLE'
# Scene series: 'Breaking.Bad.2008.S01E01.Pilot.1080p.NF.WEB-DL.DDP5.1.H.264-EXAMPLE'
# Plex movies: 'The Matrix (1999) 1080p'
# Plex series: 'Breaking Bad S01E01 Pilot'
# Check for updates from GitHub repository on startup (default: true) # Check for updates from GitHub repository on startup (default: true)
update_checks: true update_checks: true
@@ -106,25 +136,25 @@ remote_cdm:
secret: secret_key secret: secret_key
- name: "decrypt_labs_chrome" - name: "decrypt_labs_chrome"
type: "decrypt_labs" # Required to identify as DecryptLabs CDM type: "decrypt_labs" # Required to identify as DecryptLabs CDM
device_name: "ChromeCDM" # Scheme identifier - must match exactly device_name: "ChromeCDM" # Scheme identifier - must match exactly
device_type: CHROME device_type: CHROME
system_id: 4464 # Doesn't matter system_id: 4464 # Doesn't matter
security_level: 3 security_level: 3
host: "https://keyxtractor.decryptlabs.com" host: "https://keyxtractor.decryptlabs.com"
secret: "your_decrypt_labs_api_key_here" # Replace with your API key secret: "your_decrypt_labs_api_key_here" # Replace with your API key
- name: "decrypt_labs_l1" - name: "decrypt_labs_l1"
type: "decrypt_labs" type: "decrypt_labs"
device_name: "L1" # Scheme identifier - must match exactly device_name: "L1" # Scheme identifier - must match exactly
device_type: ANDROID device_type: ANDROID
system_id: 4464 system_id: 4464
security_level: 1 security_level: 1
host: "https://keyxtractor.decryptlabs.com" host: "https://keyxtractor.decryptlabs.com"
secret: "your_decrypt_labs_api_key_here" secret: "your_decrypt_labs_api_key_here"
- name: "decrypt_labs_l2" - name: "decrypt_labs_l2"
type: "decrypt_labs" type: "decrypt_labs"
device_name: "L2" # Scheme identifier - must match exactly device_name: "L2" # Scheme identifier - must match exactly
device_type: ANDROID device_type: ANDROID
system_id: 4464 system_id: 4464
security_level: 2 security_level: 2
@@ -133,7 +163,7 @@ remote_cdm:
- name: "decrypt_labs_playready_sl2" - name: "decrypt_labs_playready_sl2"
type: "decrypt_labs" type: "decrypt_labs"
device_name: "SL2" # Scheme identifier - must match exactly device_name: "SL2" # Scheme identifier - must match exactly
device_type: PLAYREADY device_type: PLAYREADY
system_id: 0 system_id: 0
security_level: 2000 security_level: 2000
@@ -142,7 +172,7 @@ remote_cdm:
- name: "decrypt_labs_playready_sl3" - name: "decrypt_labs_playready_sl3"
type: "decrypt_labs" type: "decrypt_labs"
device_name: "SL3" # Scheme identifier - must match exactly device_name: "SL3" # Scheme identifier - must match exactly
device_type: PLAYREADY device_type: PLAYREADY
system_id: 0 system_id: 0
security_level: 3000 security_level: 3000

2
uv.lock generated
View File

@@ -1499,7 +1499,7 @@ wheels = [
[[package]] [[package]]
name = "unshackle" name = "unshackle"
version = "1.4.3" version = "1.4.4"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "appdirs" }, { name = "appdirs" },