Files
unshackle-SeFree/unshackle/core/utils/template_formatter.py
Andy 6ce7b6c4d3 feat(templates)!: add customizable output filename templates (#12)
BREAKING CHANGE: The 'scene_naming' config option has been removed.
Users must configure 'output_template' in unshackle.yaml with movies, series, and songs templates. See unshackle-example.yaml for examples.
2026-02-26 18:23:18 -07:00

163 lines
6.3 KiB
Python

from __future__ import annotations
import logging
import re
from typing import Any
from unshackle.core.utilities import sanitize_filename
log = logging.getLogger(__name__)
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
"""
is_valid, missing_vars = self.validate(context)
if not is_valid:
error_msg = f"Missing required template variables: {', '.join(missing_vars)}"
log.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:
var_name = variable[:-1]
value = context.get(var_name, "")
if value:
safe_value = str(value).strip()
result = result.replace(placeholder, safe_value)
else:
# Remove the placeholder and consume the adjacent separator on one side
# e.g. "{disc?}-{track}" → "{track}" when disc is empty
# e.g. "{title}.{edition?}.{quality}" → "{title}.{quality}" when edition is empty
def _remove_conditional(m: re.Match) -> str:
s = m.group(0)
has_left = s[0] in ".- "
has_right = s[-1] in ".- "
if has_left and has_right:
return s[0] # keep left separator
return ""
result = re.sub(
rf"[\.\s\-]?{re.escape(placeholder)}[\.\s\-]?",
_remove_conditional,
result,
count=1,
)
else:
value = context.get(variable, "")
if value is None:
log.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"-{2,}", "-", result) # Multiple dashes -> single dash
result = re.sub(r"^[\.\s\-]+|[\.\s\-]+$", "", result) # Remove leading/trailing dots, spaces, dashes
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
result = re.sub(r"\(\s*\)", "", result) # Remove empty parentheses (empty conditional)
# Determine the appropriate separator based on template style
# Count separator characters between variables (between } and {)
between_vars = re.findall(r"\}([^{]*)\{", self.template)
separator_text = "".join(between_vars)
dot_count = separator_text.count(".")
space_count = separator_text.count(" ")
if space_count > dot_count:
result = sanitize_filename(result, spacer=" ")
else:
result = sanitize_filename(result, spacer=".")
if not result or result.isspace():
log.warning("Template formatting resulted in empty filename, using fallback")
return "untitled"
log.debug(f"Template formatted successfully: '{self.template}' -> '{result}'")
return result
except (KeyError, ValueError, re.error) as e:
log.error(f"Error formatting template '{self.template}': {e}")
fallback = f"error_formatting_{hash(self.template) % 10000}"
log.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
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