From 6ce7b6c4d3afece21e0d1dfa1f3a40b0f7ece7dc Mon Sep 17 00:00:00 2001 From: Andy Date: Thu, 26 Feb 2026 18:23:18 -0700 Subject: [PATCH] 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. --- docs/OUTPUT_CONFIG.md | 39 ++-- pyproject.toml | 2 +- unshackle/commands/dl.py | 5 +- unshackle/core/__init__.py | 2 +- unshackle/core/config.py | 96 +++++++++- unshackle/core/titles/episode.py | 200 +++++---------------- unshackle/core/titles/movie.py | 152 ++-------------- unshackle/core/titles/song.py | 67 +++---- unshackle/core/titles/title.py | 112 ++++++++++++ unshackle/core/utilities.py | 2 +- unshackle/core/utils/template_formatter.py | 162 +++++++++++++++++ unshackle/unshackle-example.yaml | 44 +++-- 12 files changed, 508 insertions(+), 375 deletions(-) create mode 100644 unshackle/core/utils/template_formatter.py diff --git a/docs/OUTPUT_CONFIG.md b/docs/OUTPUT_CONFIG.md index c09123e..868925d 100644 --- a/docs/OUTPUT_CONFIG.md +++ b/docs/OUTPUT_CONFIG.md @@ -30,16 +30,35 @@ filenames: --- -## scene_naming (bool) +## output_template (dict) -Set scene-style naming for titles. When `true` uses scene naming patterns (e.g., `Prime.Suspect.S07E01...`), when -`false` uses a more human-readable style (e.g., `Prime Suspect S07E01 ...`). Default: `true`. +Configure custom output filename templates for movies, series, and songs. +This is **required** in your `unshackle.yaml` — a warning is shown if not configured. ---- +Available variables: `{title}`, `{year}`, `{season}`, `{episode}`, `{season_episode}`, `{episode_name}`, +`{quality}`, `{resolution}`, `{source}`, `{audio}`, `{audio_channels}`, `{audio_full}`, +`{video}`, `{hdr}`, `{hfr}`, `{atmos}`, `{dual}`, `{multi}`, `{tag}`, `{edition}`, `{repack}` -## dash_naming (bool) +Add `?` suffix to make a variable conditional (omitted when empty): `{year?}`, `{hdr?}`, `{repack?}` -Use dash-separated naming convention for output files. Default: `false`. +```yaml +output_template: + # Scene-style (dot-separated) + movies: '{title}.{year}.{repack?}.{edition?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}' + series: '{title}.{year?}.{season_episode}.{episode_name?}.{repack?}.{edition?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}' + songs: '{track_number}.{title}.{repack?}.{edition?}.{source?}.WEB-DL.{audio_full}.{atmos?}-{tag}' + + # Plex-friendly (space-separated) + # movies: '{title} ({year}) {quality}' + # series: '{title} {season_episode} {episode_name?}' + # songs: '{track_number}. {title}' +``` + +Example outputs: +- Scene movies: `The.Matrix.1999.1080p.NF.WEB-DL.DDP5.1.H.264-EXAMPLE` +- Scene movies (REPACK): `Dune.2021.REPACK.2160p.HBO.WEB-DL.DDP5.1.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` --- @@ -50,16 +69,10 @@ to ASCII equivalents. Default: `false`. --- -## series_year (bool) - -Whether to include the series year in series names for episodes and folders. Default: `true`. - ---- - ## tag (str) Group or Username to postfix to the end of download filenames following a dash. -Only applies when `scene_naming` is enabled. +Use `{tag}` in your output template to include it. For example, `tag: "J0HN"` will have `-J0HN` at the end of all download filenames. --- diff --git a/pyproject.toml b/pyproject.toml index 806e201..efb360e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "unshackle" -version = "3.1.0" +version = "4.0.0" description = "Modular Movie, TV, and Music Archival Software." authors = [{ name = "unshackle team" }] requires-python = ">=3.10,<3.13" diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index b072572..3e8ee97 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -2399,13 +2399,14 @@ class dl: final_dir.mkdir(parents=True, exist_ok=True) final_path = final_dir / f"{final_filename}{muxed_path.suffix}" + template_type = "series" if isinstance(title, Episode) else "songs" if isinstance(title, Song) else "movies" + sep = config.get_template_separator(template_type) + if final_path.exists() and audio_codec_suffix and append_audio_codec_suffix: - sep = "." if config.scene_naming else " " final_filename = f"{final_filename.rstrip()}{sep}{audio_codec_suffix.name}" final_path = final_dir / f"{final_filename}{muxed_path.suffix}" if final_path in used_final_paths: - sep = "." if config.scene_naming else " " i = 2 while final_path in used_final_paths: final_path = final_dir / f"{final_filename.rstrip()}{sep}{i}{muxed_path.suffix}" diff --git a/unshackle/core/__init__.py b/unshackle/core/__init__.py index f5f41e5..ce1305b 100644 --- a/unshackle/core/__init__.py +++ b/unshackle/core/__init__.py @@ -1 +1 @@ -__version__ = "3.1.0" +__version__ = "4.0.0" diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 957b3f2..fcc9c55 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 @@ -93,11 +95,26 @@ class Config: self.decrypt_labs_api_key: str = kwargs.get("decrypt_labs_api_key") or "" self.update_checks: bool = kwargs.get("update_checks", True) self.update_check_interval: int = kwargs.get("update_check_interval", 24) - self.scene_naming: bool = kwargs.get("scene_naming", True) - self.dash_naming: bool = kwargs.get("dash_naming", False) - self.series_year: bool = kwargs.get("series_year", True) + + self.output_template: dict = kwargs.get("output_template") or {} + + if kwargs.get("scene_naming") is not None: + raise SystemExit( + "ERROR: The 'scene_naming' option has been removed.\n" + "Please configure 'output_template' in your unshackle.yaml instead.\n" + "See unshackle-example.yaml for examples." + ) + + if not self.output_template: + raise SystemExit( + "ERROR: No 'output_template' configured in your unshackle.yaml.\n" + "Please add an 'output_template' section with movies, series, and songs templates.\n" + "See unshackle-example.yaml for examples." + ) + + self._validate_output_templates() + self.unicode_filenames: bool = kwargs.get("unicode_filenames", False) - self.insert_episodename_into_filenames: bool = kwargs.get("insert_episodename_into_filenames", True) 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 @@ -106,6 +123,77 @@ class Config: self.debug: bool = kwargs.get("debug", False) self.debug_keys: bool = kwargs.get("debug_keys", False) + def _validate_output_templates(self) -> None: + """Validate output template configurations and warn about potential issues.""" + if not self.output_template: + return + + valid_variables = { + "title", + "year", + "season", + "episode", + "season_episode", + "episode_name", + "quality", + "resolution", + "source", + "tag", + "track_number", + "artist", + "album", + "disc", + "audio", + "audio_channels", + "audio_full", + "atmos", + "dual", + "multi", + "video", + "hdr", + "hfr", + "edition", + "repack", + } + + 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 + + variables = re.findall(r"\{([^}]+)\}", template_str) + + for var in variables: + var_clean = var.rstrip("?") + if var_clean not in valid_variables: + warnings.warn(f"Unknown template variable '{var}' in {template_type} template") + + 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") + + if not template_str.strip(): + warnings.warn(f"Template '{template_type}' is empty") + + def get_template_separator(self, template_type: str = "movies") -> str: + """Get the filename separator for the given template type. + + Analyzes the active template to determine whether it uses dots or spaces + between variables. Falls back to dot separator (scene-style) by default. + + Args: + template_type: One of "movies", "series", or "songs". + """ + template = self.output_template[template_type] + between_vars = re.findall(r"\}([^{]*)\{", template) + separator_text = "".join(between_vars) + dot_count = separator_text.count(".") + space_count = separator_text.count(" ") + + return " " if space_count > dot_count else "." + @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 4c0e07e..c93404e 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -9,9 +9,9 @@ from rich.tree import Tree from sortedcontainers import SortedKeyList from unshackle.core.config import config -from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP from unshackle.core.titles.title import Title from unshackle.core.utilities import sanitize_filename +from unshackle.core.utils.template_formatter import TemplateFormatter class Episode(Title): @@ -78,180 +78,60 @@ class Episode(Title): self.year = year self.description = description + def _build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: + """Build template context dictionary from MediaInfo.""" + context = self._build_base_template_context(media_info, show_service) + context["title"] = self.title.replace("$", "S") + context["year"] = self.year or "" + context["season"] = f"S{self.season:02}" + context["episode"] = f"E{self.number:02}" + context["season_episode"] = f"S{self.season:02}E{self.number:02}" + context["episode_name"] = self.name or "" + return context + def __str__(self) -> str: return "{title}{year} S{season:02}E{number:02} {name}".format( 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, number=self.number, name=self.name or "", ).strip() 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 = None - if media_info.audio_tracks: - sorted_audio = sorted( - media_info.audio_tracks, - key=lambda x: ( - float(x.bit_rate) if x.bit_rate else 0, - bool(x.format_additionalfeatures and "JOC" in x.format_additionalfeatures), - ), - reverse=True, - ) - primary_audio_track = sorted_audio[0] - unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language}) - - def _get_resolution_token(track: Any) -> str: - if not track or not getattr(track, "height", None): - return "" - width = getattr(track, "width", track.height) - resolution = min(width, track.height) - try: - dar = getattr(track, "other_display_aspect_ratio", None) or [] - if dar and dar[0]: - aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")] - if len(aspect_ratio) == 1: - aspect_ratio.append(1) - ratio = aspect_ratio[0] / aspect_ratio[1] - if ratio not in (16 / 9, 4 / 3, 9 / 16, 3 / 4): - resolution = int(max(width, track.height) * (9 / 16)) - except Exception: - pass - - scan_suffix = "i" if str(getattr(track, "scan_type", "")).lower() == "interlaced" else "p" - return f"{resolution}{scan_suffix}" - - # Title [Year] SXXEXX Name (or Title [Year] SXX if folder) if folder: - name = f"{self.title}" - if self.year and config.series_year: - name += f" {self.year}" - name += f" S{self.season:02}" - else: - if config.dash_naming: - # Format: Title - SXXEXX - Episode Name - name = self.title.replace("$", "S") # e.g., Arli$$ + series_template = config.output_template.get("series") + if series_template: + folder_template = series_template + 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) - # Add year if configured - if self.year and config.series_year: - name += f" {self.year}" + 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) - # Add season and episode - name += f" - S{self.season:02}E{self.number:02}" + formatter = TemplateFormatter(folder_template) + context = self._build_template_context(media_info, show_service) + context['season'] = f"S{self.season:02}" - # Add episode name with dash separator - if self.name and config.insert_episodename_into_filenames: - name += f" - {self.name}" + folder_name = formatter.format(context) - name = name.strip() - else: - # Standard format without extra dashes - name = "{title}{year} S{season:02}E{number:02} {name}".format( - title=self.title.replace("$", "S"), # e.g., Arli$$ - year=f" {self.year}" if self.year and config.series_year else "", - season=self.season, - number=self.number, - name=self.name if self.name and config.insert_episodename_into_filenames else "", - ).strip() - - if getattr(config, "repack", False): - name += " REPACK" - - if self.tracks: - first_track = next(iter(self.tracks), None) - if first_track and first_track.edition: - name += " " + " ".join(first_track.edition) - - if primary_video_track: - resolution_token = _get_resolution_token(primary_video_track) - if resolution_token: - name += f" {resolution_token}" - - # Service (use track source if available) - if show_service: - source_name = None - if self.tracks: - first_track = next(iter(self.tracks), None) - if first_track and hasattr(first_track, "source") and first_track.source: - source_name = first_track.source - name += f" {source_name or 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 - hdr_format_full = primary_video_track.hdr_format or "" - trc = ( - primary_video_track.transfer_characteristics - or primary_video_track.transfer_characteristics_original - or "" - ) - frame_rate = float(primary_video_track.frame_rate) - - def _append_token(current: str, token: Optional[str]) -> str: - token = (token or "").strip() - current = current.rstrip() - if not token: - return current - if current.endswith(f" {token}"): - return current - return f"{current} {token}" - - # Primary HDR format detection - if hdr_format: - if hdr_format_full.startswith("Dolby Vision"): - name = _append_token(name, "DV") - if any( - indicator in (hdr_format_full + " " + hdr_format) - for indicator in ["HDR10", "SMPTE ST 2086"] - ): - name = _append_token(name, "HDR") - elif "HDR Vivid" in hdr_format: - name = _append_token(name, "HDR") + if '.' in series_template and ' ' not in series_template: + return sanitize_filename(folder_name, ".") else: - dynamic_range = DYNAMIC_RANGE_MAP.get(hdr_format) or hdr_format or "" - name = _append_token(name, dynamic_range) - elif "HLG" in trc or "Hybrid Log-Gamma" in trc or "ARIB STD-B67" in trc or "arib-std-b67" in trc.lower(): - name += " HLG" - elif any(indicator in trc for indicator in ["PQ", "SMPTE ST 2084", "BT.2100"]) or "smpte2084" in trc.lower() or "bt.2020-10" in trc.lower(): - name += " HDR" - if frame_rate > 30: - name += " HFR" - name += f" {VIDEO_CODEC_MAP.get(codec, codec)}" + return sanitize_filename(folder_name, " ") + else: + name = f"{self.title}" + if self.year: + name += f" {self.year}" + name += f" S{self.season:02}" + return sanitize_filename(name, " ") - if config.tag: - name += f"-{config.tag}" - - return sanitize_filename(name, "." if config.scene_naming else " ") + formatter = TemplateFormatter(config.output_template["series"]) + context = self._build_template_context(media_info, show_service) + return formatter.format(context) class Series(SortedKeyList, ABC): @@ -261,7 +141,7 @@ class Series(SortedKeyList, ABC): def __str__(self) -> str: if not self: 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: seasons = Counter(x.season for x in self) diff --git a/unshackle/core/titles/movie.py b/unshackle/core/titles/movie.py index f647d36..70088a1 100644 --- a/unshackle/core/titles/movie.py +++ b/unshackle/core/titles/movie.py @@ -7,9 +7,9 @@ from rich.tree import Tree from sortedcontainers import SortedKeyList from unshackle.core.config import config -from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP 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): @@ -45,148 +45,28 @@ class Movie(Title): self.year = year self.description = description + def _build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: + """Build template context dictionary from MediaInfo.""" + context = self._build_base_template_context(media_info, show_service) + context["title"] = self.name.replace("$", "S") + context["year"] = self.year or "" + return context + def __str__(self) -> str: if self.year: - if config.dash_naming: - return f"{self.name} - {self.year}" return f"{self.name} ({self.year})" return self.name 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 = None - if media_info.audio_tracks: - sorted_audio = sorted( - media_info.audio_tracks, - key=lambda x: ( - float(x.bit_rate) if x.bit_rate else 0, - bool(x.format_additionalfeatures and "JOC" in x.format_additionalfeatures), - ), - reverse=True, - ) - primary_audio_track = sorted_audio[0] - unique_audio_languages = len({x.language.split("-")[0] for x in media_info.audio_tracks if x.language}) + if folder: + name = f"{self.name}" + if self.year: + name += f" ({self.year})" + return sanitize_filename(name, " ") - def _get_resolution_token(track: Any) -> str: - if not track or not getattr(track, "height", None): - return "" - width = getattr(track, "width", track.height) - resolution = min(width, track.height) - try: - dar = getattr(track, "other_display_aspect_ratio", None) or [] - if dar and dar[0]: - aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")] - if len(aspect_ratio) == 1: - aspect_ratio.append(1) - ratio = aspect_ratio[0] / aspect_ratio[1] - if ratio not in (16 / 9, 4 / 3, 9 / 16, 3 / 4): - resolution = int(max(width, track.height) * (9 / 16)) - except Exception: - pass - - scan_suffix = "i" if str(getattr(track, "scan_type", "")).lower() == "interlaced" else "p" - return f"{resolution}{scan_suffix}" - - # Name (Year) - name = str(self).replace("$", "S") # e.g., Arli$$ - - if getattr(config, "repack", False): - name += " REPACK" - - if self.tracks: - first_track = next(iter(self.tracks), None) - if first_track and first_track.edition: - name += " " + " ".join(first_track.edition) - - if primary_video_track: - resolution_token = _get_resolution_token(primary_video_track) - if resolution_token: - name += f" {resolution_token}" - - # Service (use track source if available) - if show_service: - source_name = None - if self.tracks: - first_track = next(iter(self.tracks), None) - if first_track and hasattr(first_track, "source") and first_track.source: - source_name = first_track.source - name += f" {source_name or 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 - hdr_format_full = primary_video_track.hdr_format or "" - trc = ( - primary_video_track.transfer_characteristics - or primary_video_track.transfer_characteristics_original - or "" - ) - frame_rate = float(primary_video_track.frame_rate) - - def _append_token(current: str, token: Optional[str]) -> str: - token = (token or "").strip() - current = current.rstrip() - if not token: - return current - if current.endswith(f" {token}"): - return current - return f"{current} {token}" - - # Primary HDR format detection - if hdr_format: - if hdr_format_full.startswith("Dolby Vision"): - name = _append_token(name, "DV") - if any( - indicator in (hdr_format_full + " " + hdr_format) - for indicator in ["HDR10", "SMPTE ST 2086"] - ): - name = _append_token(name, "HDR") - elif "HDR Vivid" in hdr_format: - name = _append_token(name, "HDR") - else: - dynamic_range = DYNAMIC_RANGE_MAP.get(hdr_format) or hdr_format or "" - name = _append_token(name, dynamic_range) - elif "HLG" in trc or "Hybrid Log-Gamma" in trc or "ARIB STD-B67" in trc or "arib-std-b67" in trc.lower(): - name += " HLG" - elif any(indicator in trc for indicator in ["PQ", "SMPTE ST 2084", "BT.2100"]) or "smpte2084" in trc.lower() or "bt.2020-10" in trc.lower(): - name += " HDR" - 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, "." if config.scene_naming else " ") + formatter = TemplateFormatter(config.output_template["movies"]) + context = self._build_template_context(media_info, show_service) + return formatter.format(context) class Movies(SortedKeyList, ABC): diff --git a/unshackle/core/titles/song.py b/unshackle/core/titles/song.py index ad63478..e481e1b 100644 --- a/unshackle/core/titles/song.py +++ b/unshackle/core/titles/song.py @@ -7,9 +7,9 @@ from rich.tree import Tree from sortedcontainers import SortedKeyList from unshackle.core.config import config -from unshackle.core.constants import AUDIO_CODEC_MAP from unshackle.core.titles.title import Title from unshackle.core.utilities import sanitize_filename +from unshackle.core.utils.template_formatter import TemplateFormatter class Song(Title): @@ -41,7 +41,7 @@ class Song(Title): if not album: raise ValueError("Song album must be provided") if not isinstance(album, str): - raise TypeError(f"Expected album to be a str, not {name!r}") + raise TypeError(f"Expected album to be a str, not {album!r}") if not track: raise ValueError("Song track must be provided") @@ -81,54 +81,27 @@ class Song(Title): artist=self.artist, album=self.album, year=self.year, track=self.track, name=self.name ).strip() + def _build_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: + """Build template context dictionary from MediaInfo.""" + context = self._build_base_template_context(media_info, show_service) + context["title"] = self.name.replace("$", "S") + context["year"] = self.year or "" + context["track_number"] = f"{self.track:02}" + context["artist"] = self.artist.replace("$", "S") + context["album"] = self.album.replace("$", "S") + context["disc"] = f"{self.disc:02}" if self.disc > 1 else "" + return context + 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: - # Artist - Album (Year) - name = str(self).split(" / ")[0] - else: - # NN. Song Name - name = str(self).split(" / ")[1] + name = f"{self.artist} - {self.album}" + if self.year: + name += f" ({self.year})" + return sanitize_filename(name, " ") - if getattr(config, "repack", False): - name += " REPACK" - - if self.tracks: - first_track = next(iter(self.tracks), None) - if first_track and first_track.edition: - name += " " + " ".join(first_track.edition) - - # Service (use track source if available) - if show_service: - source_name = None - if self.tracks: - first_track = next(iter(self.tracks), None) - if first_track and hasattr(first_track, "source") and first_track.source: - source_name = first_track.source - name += f" {source_name or 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, " ") + formatter = TemplateFormatter(config.output_template["songs"]) + context = self._build_template_context(media_info, show_service) + return formatter.format(context) class Album(SortedKeyList, ABC): diff --git a/unshackle/core/titles/title.py b/unshackle/core/titles/title.py index 7873b87..713ebb6 100644 --- a/unshackle/core/titles/title.py +++ b/unshackle/core/titles/title.py @@ -6,6 +6,8 @@ from typing import Any, Optional, Union from langcodes import Language from pymediainfo import MediaInfo +from unshackle.core.config import config +from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP from unshackle.core.tracks import Tracks @@ -51,6 +53,116 @@ class Title: def __eq__(self, other: Title) -> bool: return self.id == other.id + def _build_base_template_context(self, media_info: MediaInfo, show_service: bool = True) -> dict: + """Build base template context dictionary from MediaInfo. + + Extracts video, audio, HDR, HFR, and multi-language information shared + across all title types. Subclasses should call this and extend the + returned dict with their specific fields (e.g., season/episode). + """ + 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: dict[str, Any] = { + "source": self.service.__name__ if show_service else "", + "tag": config.tag or "", + "repack": "REPACK" if getattr(config, "repack", False) else "", + "quality": "", + "resolution": "", + "audio": "", + "audio_channels": "", + "audio_full": "", + "atmos": "", + "dual": "", + "multi": "", + "video": "", + "hdr": "", + "hfr": "", + "edition": "", + } + + if self.tracks: + first_track = next(iter(self.tracks), None) + if first_track and first_track.edition: + context["edition"] = " ".join(first_track.edition) + + if primary_video_track: + width = getattr(primary_video_track, "width", primary_video_track.height) + resolution = min(width, primary_video_track.height) + try: + dar = getattr(primary_video_track, "other_display_aspect_ratio", None) or [] + if dar and dar[0]: + aspect_ratio = [int(float(plane)) for plane in str(dar[0]).split(":")] + if len(aspect_ratio) == 1: + aspect_ratio.append(1) + ratio = aspect_ratio[0] / aspect_ratio[1] + if ratio not in (16 / 9, 4 / 3, 9 / 16, 3 / 4): + resolution = int(max(width, primary_video_track.height) * (9 / 16)) + except Exception: + pass + + scan_suffix = "i" if str(getattr(primary_video_track, "scan_type", "")).lower() == "interlaced" else "p" + + context.update( + { + "quality": f"{resolution}{scan_suffix}", + "resolution": str(resolution), + "video": VIDEO_CODEC_MAP.get(primary_video_track.format, 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 + 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"] = "" + + frame_rate = float(primary_video_track.frame_rate) if primary_video_track.frame_rate else 0.0 + context["hfr"] = "HFR" if frame_rate > 30 else "" + + 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 "", + } + ) + + 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 + @abstractmethod def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str: """ diff --git a/unshackle/core/utilities.py b/unshackle/core/utilities.py index c015459..b57ca14 100644 --- a/unshackle/core/utilities.py +++ b/unshackle/core/utilities.py @@ -136,7 +136,7 @@ def sanitize_filename(filename: str, spacer: str = ".") -> str: if spacer == ".": filename = re.sub(r" - ", spacer, filename) # title separators to spacer (avoids .-. pattern) filename = re.sub(r"[:; ]", spacer, filename) # structural chars to (spacer) - filename = re.sub(r"[\\*!?¿,'\"" "()<>|$#~]", "", filename) # not filename safe chars + filename = re.sub(r"[\\*!?¿,'\"" "<>|$#~]", "", filename) # not filename safe chars filename = re.sub(rf"[{spacer}]{{2,}}", spacer, filename) # remove extra neighbouring (spacer)s return filename diff --git a/unshackle/core/utils/template_formatter.py b/unshackle/core/utils/template_formatter.py new file mode 100644 index 0000000..0f67367 --- /dev/null +++ b/unshackle/core/utils/template_formatter.py @@ -0,0 +1,162 @@ +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 diff --git a/unshackle/unshackle-example.yaml b/unshackle/unshackle-example.yaml index 2a248c2..56945cd 100644 --- a/unshackle/unshackle-example.yaml +++ b/unshackle/unshackle-example.yaml @@ -17,15 +17,40 @@ tag_imdb_tmdb: true # Set terminal background color (custom option not in CONFIG.md) set_terminal_bg: false -# Set file naming convention -# true for style - Prime.Suspect.S07E01.The.Final.Act.Part.One.1080p.ITV.WEB-DL.AAC2.0.H.264 -# false for style - Prime Suspect S07E01 The Final Act - Part One -scene_naming: true - -# Whether to include the year in series names for episodes and folders (default: true) -# true for style - Show Name (2023) S01E01 Episode Name -# false for style - Show Name S01E01 Episode Name -series_year: true +# Custom output templates for filenames +# Configure output_template in your unshackle.yaml to control filename format. +# If not configured, default scene-style templates are used and a warning is shown. +# Available variables: {title}, {year}, {season}, {episode}, {season_episode}, {episode_name}, +# {quality}, {resolution}, {source}, {audio}, {audio_channels}, {audio_full}, +# {video}, {hdr}, {hfr}, {atmos}, {dual}, {multi}, {tag}, {edition}, {repack} +# Conditional variables (included only if present): Add ? suffix like {year?}, {episode_name?}, {hdr?} +# Customize the templates below: +# +# Example outputs: +# Scene movies: 'The.Matrix.1999.1080p.SERVICE.WEB-DL.DDP5.1.H.264-EXAMPLE' +# Scene movies (HDR): 'Dune.2021.2160p.SERVICE.WEB-DL.DDP5.1.HDR10.H.265-EXAMPLE' +# Scene movies (REPACK): 'Dune.2021.REPACK.2160p.SERVICE.WEB-DL.DDP5.1.H.265-EXAMPLE' +# Scene series: 'Breaking.Bad.2008.S01E01.Pilot.1080p.SERVICE.WEB-DL.DDP5.1.H.264-EXAMPLE' +# Plex movies: 'The Matrix (1999) 1080p' +# Plex series: 'Breaking Bad S01E01 Pilot' +output_template: + # Scene-style naming (dot-separated) + movies: '{title}.{year}.{repack?}.{edition?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}' + series: '{title}.{year?}.{season_episode}.{episode_name?}.{repack?}.{edition?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}' + songs: '{track_number}.{title}.{repack?}.{edition?}.{source?}.WEB-DL.{audio_full}.{atmos?}-{tag}' + # + # Plex-friendly naming (space-separated, clean format) + # movies: '{title} ({year}) {quality}' + # series: '{title} {season_episode} {episode_name?}' + # songs: '{track_number}. {title}' + # + # 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}' # Check for updates from GitHub repository on startup (default: true) update_checks: true @@ -41,7 +66,6 @@ title_cache_max_retention: 86400 # Maximum cache retention for fallback when API # Filename Configuration unicode_filenames: false # optionally replace non-ASCII characters with ASCII equivalents -insert_episodename_into_filenames: true # optionally determines whether the specific name of an episode is automatically included within the filename for series content. # Debug logging configuration # Comprehensive JSON-based debug logging for troubleshooting and service development