From e323f6f3b3e34478c54150426a07a5459b53844b Mon Sep 17 00:00:00 2001 From: Andy Date: Wed, 25 Mar 2026 21:42:47 -0600 Subject: [PATCH] feat(template): add configurable folder naming via output_template.folder (#94) Adds an optional `folder` key under `output_template` to customize output folder names using the same template variables as file naming. --- docs/OUTPUT_CONFIG.md | 26 ++++++++++++++++++++++++++ unshackle/core/config.py | 7 ++++++- unshackle/core/titles/episode.py | 30 +++++++++++++++++++++--------- unshackle/core/titles/movie.py | 8 ++++++++ unshackle/core/titles/song.py | 8 ++++++++ unshackle/unshackle-example.yaml | 11 +++++++++++ 6 files changed, 80 insertions(+), 10 deletions(-) diff --git a/docs/OUTPUT_CONFIG.md b/docs/OUTPUT_CONFIG.md index c53af37..ec0b7ee 100644 --- a/docs/OUTPUT_CONFIG.md +++ b/docs/OUTPUT_CONFIG.md @@ -61,6 +61,32 @@ Example outputs: - Scene series: `Example.Show.2024.S01E01.Pilot.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG` - Plex movies: `Example Movie (2024) 1080p` +### folder (optional) + +Controls the folder name for downloaded content. Uses the same template variables as the file templates above. + +If not configured, the default folder naming is used: +- Movies: `Title (Year)` +- Series: Derived from the `series` template with episode-specific variables removed +- Songs: `Artist - Album (Year)` + +```yaml +output_template: + 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}' + + # Scene-style folder + folder: '{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}' + + # Plex-friendly folder + # folder: '{title} ({year?})' +``` + +Example outputs: +- Scene folder: `Example.Show.2024.S01.1080p.EXAMPLE.WEB-DL.DDP5.1.H.264-TAG/` +- Plex folder: `Example Show (2024)/` + --- --- diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 686043e..2b36185 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -99,6 +99,7 @@ class Config: self.language_tags: dict = kwargs.get("language_tags") or {} self.output_template: dict = kwargs.get("output_template") or {} + self.folder_template: str = self.output_template.pop("folder", "") or "" if kwargs.get("scene_naming") is not None: raise SystemExit( @@ -161,7 +162,11 @@ class Config: unsafe_chars = r'[<>:"/\\|?*]' - for template_type, template_str in self.output_template.items(): + all_templates = dict(self.output_template) + if self.folder_template: + all_templates["folder"] = self.folder_template + + for template_type, template_str in all_templates.items(): if not isinstance(template_str, str): warnings.warn(f"Template '{template_type}' must be a string, got {type(template_str).__name__}") continue diff --git a/unshackle/core/titles/episode.py b/unshackle/core/titles/episode.py index c93404e..23cd601 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -100,19 +100,31 @@ class Episode(Title): def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str: if folder: + if config.folder_template: + formatter = TemplateFormatter(config.folder_template) + context = self._build_template_context(media_info, show_service) + context['season'] = f"S{self.season:02}" + + folder_name = formatter.format(context) + + if '.' in config.folder_template and ' ' not in config.folder_template: + return sanitize_filename(folder_name, ".") + else: + return sanitize_filename(folder_name, " ") + 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) + derived_template = series_template + derived_template = re.sub(r'\{episode\}', '', derived_template) + derived_template = re.sub(r'\{episode_name\?\}', '', derived_template) + derived_template = re.sub(r'\{episode_name\}', '', derived_template) + derived_template = re.sub(r'\{season_episode\}', '{season}', derived_template) - 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) + derived_template = re.sub(r'\.{2,}', '.', derived_template) + derived_template = re.sub(r'\s{2,}', ' ', derived_template) + derived_template = re.sub(r'^[\.\s]+|[\.\s]+$', '', derived_template) - formatter = TemplateFormatter(folder_template) + formatter = TemplateFormatter(derived_template) context = self._build_template_context(media_info, show_service) context['season'] = f"S{self.season:02}" diff --git a/unshackle/core/titles/movie.py b/unshackle/core/titles/movie.py index 70088a1..8cf8540 100644 --- a/unshackle/core/titles/movie.py +++ b/unshackle/core/titles/movie.py @@ -59,6 +59,14 @@ class Movie(Title): def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str: if folder: + if config.folder_template: + formatter = TemplateFormatter(config.folder_template) + context = self._build_template_context(media_info, show_service) + folder_name = formatter.format(context) + if '.' in config.folder_template and ' ' not in config.folder_template: + return sanitize_filename(folder_name, ".") + else: + return sanitize_filename(folder_name, " ") name = f"{self.name}" if self.year: name += f" ({self.year})" diff --git a/unshackle/core/titles/song.py b/unshackle/core/titles/song.py index e481e1b..82ae62d 100644 --- a/unshackle/core/titles/song.py +++ b/unshackle/core/titles/song.py @@ -94,6 +94,14 @@ class Song(Title): def get_filename(self, media_info: MediaInfo, folder: bool = False, show_service: bool = True) -> str: if folder: + if config.folder_template: + formatter = TemplateFormatter(config.folder_template) + context = self._build_template_context(media_info, show_service) + folder_name = formatter.format(context) + if '.' in config.folder_template and ' ' not in config.folder_template: + return sanitize_filename(folder_name, ".") + else: + return sanitize_filename(folder_name, " ") name = f"{self.artist} - {self.album}" if self.year: name += f" ({self.year})" diff --git a/unshackle/unshackle-example.yaml b/unshackle/unshackle-example.yaml index e33aba4..5e65121 100644 --- a/unshackle/unshackle-example.yaml +++ b/unshackle/unshackle-example.yaml @@ -52,6 +52,17 @@ output_template: # 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}' + # + # Folder naming (optional). Controls the folder name for downloaded content. + # If not configured, series folders are derived from the series template (minus episode info), + # movie folders use "{title} ({year})", and song folders use "{artist} - {album} ({year})". + # Uses the same template variables as the file templates above. + # + # Scene-style folder: + # folder: '{title}.{year?}.{repack?}.{edition?}.{lang_tag?}.{quality}.{source}.WEB-DL.{dual?}.{multi?}.{audio_full}.{atmos?}.{hdr?}.{hfr?}.{video}-{tag}' + # + # Plex-friendly folder: + # folder: '{title} ({year?})' # Language-based tagging for output filenames # Automatically adds language identifiers (e.g., DANiSH, NORDiC, DKsubs) based on