feat: Implement custom output templates for flexible filename generation and backward compatibility

This commit is contained in:
Andy
2025-09-03 00:18:21 +00:00
parent d9763184bd
commit 4564be6204
8 changed files with 278 additions and 80 deletions

View File

@@ -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/), 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 ## [1.4.4] - 2025-09-02
### Added ### Added
@@ -26,7 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- **Matroska Tag Compliance**: Enhanced media container compatibility - **Matroska Tag Compliance**: Enhanced media container compatibility
- Fixed Matroska tag compliance with official specification - Fixed Matroska tag compliance with official specification
- **Application Branding**: Cleaned up version display - **Application Branding**: Cleaned up version display
- Removed old devine version reference from banner to avoid developer confusion - Removed old devine version reference from banner to avoid developer confusion

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,12 +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)
# 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 {} 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

@@ -179,17 +179,49 @@ class Episode(Title):
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:
if folder: if folder:
# For folders, use simple naming: "Title Year S01" # For folders, use the series template but exclude episode-specific variables
name = f"{self.title}" series_template = config.output_template.get("series")
if self.year: if series_template:
name += f" {self.year}" # Create a folder-friendly version by removing episode-specific variables
name += f" S{self.season:02}" folder_template = series_template
return sanitize_filename(name, " ") # 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 = ( template = (
config.output_template.get("series") 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) formatter = TemplateFormatter(template)

View File

@@ -136,7 +136,8 @@ class Movie(Title):
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:
# 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 = ( template = (
config.output_template.get("movies") config.output_template.get("movies")
or "{title}.{year}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}" or "{title}.{year}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}"

View File

@@ -129,10 +129,10 @@ class Song(Title):
name += f" ({self.year})" name += f" ({self.year})"
return sanitize_filename(name, " ") 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 = ( template = (
config.output_template.get("songs") config.output_template.get("songs") or "{track_number}.{title}.{source?}.WEB-DL.{audio_full}.{atmos?}-{tag}"
or "{track_number}.{title}.{source?}.WEB-DL.{audio_full}.{atmos?}-{tag}"
) )
formatter = TemplateFormatter(template) formatter = TemplateFormatter(template)

View File

@@ -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']}" standard_tags["TVDB2"] = f"series/{show_ids['tvdb']}"
if show_ids.get("tmdbtv"): if show_ids.get("tmdbtv"):
standard_tags["TMDB"] = f"tv/{show_ids['tmdbtv']}" standard_tags["TMDB"] = f"tv/{show_ids['tmdbtv']}"
# Handle movie data from Simkl # Handle movie data from Simkl
elif simkl_data.get("type") == "movie" and "movie" in simkl_data: elif simkl_data.get("type") == "movie" and "movie" in simkl_data:
movie_ids = simkl_data.get("movie", {}).get("ids", {}) movie_ids = simkl_data.get("movie", {}).get("ids", {})

View File

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

View File

@@ -12,6 +12,11 @@ set_terminal_bg: false
# File naming is now controlled via output_template (see below) # File naming is now controlled via output_template (see below)
# Default behavior provides scene-style naming similar to the old scene_naming: true # 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 # Custom output templates for filenames
# When not defined, defaults to scene-style naming equivalent to the old scene_naming: true # When not defined, defaults to scene-style naming equivalent to the old scene_naming: true
@@ -131,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
@@ -158,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
@@ -167,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