From 4564be6204c192967e21f648e20b82a56e1156fe Mon Sep 17 00:00:00 2001 From: Andy Date: Wed, 3 Sep 2025 00:18:21 +0000 Subject: [PATCH] feat: Implement custom output templates for flexible filename generation and backward compatibility --- CHANGELOG.md | 23 +++- unshackle/core/config.py | 106 +++++++++++++++ unshackle/core/titles/episode.py | 48 +++++-- unshackle/core/titles/movie.py | 3 +- unshackle/core/titles/song.py | 6 +- unshackle/core/utils/tags.py | 2 +- unshackle/core/utils/template_formatter.py | 149 +++++++++++++-------- unshackle/unshackle-example.yaml | 21 +-- 8 files changed, 278 insertions(+), 80 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6886af..b9f0216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ 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). +## [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 @@ -26,7 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **Matroska Tag Compliance**: Enhanced media container compatibility +- **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 diff --git a/unshackle/core/config.py b/unshackle/core/config.py index d102471..edb5821 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -1,5 +1,7 @@ from __future__ import annotations +import re +import warnings from pathlib import Path from typing import Any, Optional @@ -90,12 +92,116 @@ class Config: self.tmdb_api_key: str = kwargs.get("tmdb_api_key") or "" self.update_checks: bool = kwargs.get("update_checks", True) self.update_check_interval: int = kwargs.get("update_check_interval", 24) + + # 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_max_retention: int = kwargs.get("title_cache_max_retention", 86400) # 24 hours default 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 def from_yaml(cls, path: Path) -> Config: if not path.exists(): diff --git a/unshackle/core/titles/episode.py b/unshackle/core/titles/episode.py index 619066b..90f6dec 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -179,17 +179,49 @@ class Episode(Title): def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str: if folder: - # For folders, use simple naming: "Title Year S01" - name = f"{self.title}" - if self.year: - name += f" {self.year}" - name += f" S{self.season:02}" - return sanitize_filename(name, " ") + # For folders, use the series template but exclude episode-specific variables + series_template = config.output_template.get("series") + if series_template: + # Create a folder-friendly version by removing episode-specific variables + folder_template = series_template + # Remove episode number and episode name from template for folders + folder_template = re.sub(r'\{episode\}', '', folder_template) + folder_template = re.sub(r'\{episode_name\?\}', '', folder_template) + folder_template = re.sub(r'\{episode_name\}', '', folder_template) + folder_template = re.sub(r'\{season_episode\}', '{season}', folder_template) - # Use custom template if defined, otherwise use default scene-style template + # Clean up any double separators that might result + folder_template = re.sub(r'\.{2,}', '.', folder_template) + folder_template = re.sub(r'\s{2,}', ' ', folder_template) + folder_template = re.sub(r'^[\.\s]+|[\.\s]+$', '', folder_template) + + formatter = TemplateFormatter(folder_template) + context = self._build_template_context(media_info, show_service) + # Override season_episode with just season for folders + context['season'] = f"S{self.season:02}" + + folder_name = formatter.format(context) + + # Keep the same separator style as the series template + if '.' in series_template and ' ' not in series_template: + # Dot-based template - use dot separator for folders too + return sanitize_filename(folder_name, ".") + else: + # Space-based template - use space separator + 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, " ") + + # Use template from output_template (which includes scene_naming compatibility) + # or fallback to default scene-style template template = ( config.output_template.get("series") - or "{title}.{year?}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}" + or "{title}.{year?}.{season_episode}.{episode_name?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hfr?}.{video}-{tag}" ) formatter = TemplateFormatter(template) diff --git a/unshackle/core/titles/movie.py b/unshackle/core/titles/movie.py index 6024eda..ab4ca78 100644 --- a/unshackle/core/titles/movie.py +++ b/unshackle/core/titles/movie.py @@ -136,7 +136,8 @@ class Movie(Title): return self.name def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str: - # Use custom template if defined, otherwise use default scene-style template + # Use template from output_template (which includes scene_naming compatibility) + # or fallback to default scene-style template template = ( config.output_template.get("movies") or "{title}.{year}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}" diff --git a/unshackle/core/titles/song.py b/unshackle/core/titles/song.py index 8846f4d..1c96634 100644 --- a/unshackle/core/titles/song.py +++ b/unshackle/core/titles/song.py @@ -129,10 +129,10 @@ class Song(Title): name += f" ({self.year})" return sanitize_filename(name, " ") - # Use custom template if defined, otherwise use default scene-style template + # 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}" + config.output_template.get("songs") or "{track_number}.{title}.{source?}.WEB-DL.{audio_full}.{atmos?}-{tag}" ) formatter = TemplateFormatter(template) diff --git a/unshackle/core/utils/tags.py b/unshackle/core/utils/tags.py index 56d25f0..728c03d 100644 --- a/unshackle/core/utils/tags.py +++ b/unshackle/core/utils/tags.py @@ -359,7 +359,7 @@ def tag_file(path: Path, title: Title, tmdb_id: Optional[int] | None = None) -> 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", {}) diff --git a/unshackle/core/utils/template_formatter.py b/unshackle/core/utils/template_formatter.py index 2232548..24acdfa 100644 --- a/unshackle/core/utils/template_formatter.py +++ b/unshackle/core/utils/template_formatter.py @@ -1,5 +1,6 @@ +import logging import re -from typing import Dict, Any, List, Optional +from typing import Any, Dict, List from unshackle.core.utilities import sanitize_filename @@ -7,108 +8,140 @@ 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'\{([^}]+)\}' + 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 """ - 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 - result = result.replace(placeholder, str(value)) + 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: - # Remove the placeholder entirely for empty conditional variables - result = result.replace(placeholder, '') + # 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: - # Regular variable - value = context.get(variable, '') - result = result.replace(placeholder, str(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='.') - - return result - + # 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('?') + 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('?'): + 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('?'): + if variable.endswith("?"): optional.append(variable[:-1]) # Remove the ? - return optional \ No newline at end of file + return optional diff --git a/unshackle/unshackle-example.yaml b/unshackle/unshackle-example.yaml index 2347080..82a1a59 100644 --- a/unshackle/unshackle-example.yaml +++ b/unshackle/unshackle-example.yaml @@ -12,6 +12,11 @@ set_terminal_bg: false # File naming is now controlled via output_template (see below) # Default behavior provides scene-style naming similar to the old 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 # Custom output templates for filenames # When not defined, defaults to scene-style naming equivalent to the old scene_naming: true @@ -131,25 +136,25 @@ remote_cdm: secret: secret_key - name: "decrypt_labs_chrome" - type: "decrypt_labs" # Required to identify as DecryptLabs CDM - device_name: "ChromeCDM" # Scheme identifier - must match exactly + type: "decrypt_labs" # Required to identify as DecryptLabs CDM + device_name: "ChromeCDM" # Scheme identifier - must match exactly device_type: CHROME system_id: 4464 # Doesn't matter security_level: 3 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" type: "decrypt_labs" - device_name: "L1" # Scheme identifier - must match exactly + device_name: "L1" # Scheme identifier - must match exactly device_type: ANDROID - system_id: 4464 + system_id: 4464 security_level: 1 host: "https://keyxtractor.decryptlabs.com" secret: "your_decrypt_labs_api_key_here" - name: "decrypt_labs_l2" type: "decrypt_labs" - device_name: "L2" # Scheme identifier - must match exactly + device_name: "L2" # Scheme identifier - must match exactly device_type: ANDROID system_id: 4464 security_level: 2 @@ -158,7 +163,7 @@ remote_cdm: - name: "decrypt_labs_playready_sl2" type: "decrypt_labs" - device_name: "SL2" # Scheme identifier - must match exactly + device_name: "SL2" # Scheme identifier - must match exactly device_type: PLAYREADY system_id: 0 security_level: 2000 @@ -167,7 +172,7 @@ remote_cdm: - name: "decrypt_labs_playready_sl3" type: "decrypt_labs" - device_name: "SL3" # Scheme identifier - must match exactly + device_name: "SL3" # Scheme identifier - must match exactly device_type: PLAYREADY system_id: 0 security_level: 3000