2 Commits

Author SHA1 Message Date
0a820e6552 fix(dl): normalize non-scene episode folder/file naming
Ensure episode downloads use a consistent non-scene layout when
`scene_naming` is disabled by:

- adding a sanitized series-title directory before season/episode folders
  for sidecars, sample-based paths, and muxed outputs
- updating `Episode.get_filename()` to return `Season XX` for folder names
  in non-scene mode
- generating non-scene episode file names as
  `Title SXXEXX - Episode Name`
- adding token append helpers to avoid duplicate/empty naming tokens

This keeps output paths predictable across download stages and prevents
naming inconsistencies or duplicated suffixes.fix(dl): normalize non-scene episode folder/file naming

Ensure episode downloads use a consistent non-scene layout when
`scene_naming` is disabled by:

- adding a sanitized series-title directory before season/episode folders
  for sidecars, sample-based paths, and muxed outputs
- updating `Episode.get_filename()` to return `Season XX` for folder names
  in non-scene mode
- generating non-scene episode file names as
  `Title SXXEXX - Episode Name`
- adding token append helpers to avoid duplicate/empty naming tokens

This keeps output paths predictable across download stages and prevents
naming inconsistencies or duplicated suffixes.
2026-03-02 22:35:48 +07:00
8748ce8a11 feat(movie): improve non-scene filename sanitization
Add dedicated non-scene filename sanitization for movies to produce cleaner, filesystem-safe names when `scene_naming` is disabled. The new logic:
- optionally transliterates unicode based on `unicode_filenames`
- removes combining marks/diacritics and disallowed punctuation
- normalizes separators and extra whitespace

Also update movie name construction to explicitly format `Name (Year)` and append a trailing ` -` for non-scene naming before metadata tokens, improving readability and consistency.

Update default config values in `unshackle.yaml` to match non-scene/local usage:
- disable `scene_naming` by default
- comment out default `tag`
- change downloads directory to `Downloads`feat(movie): improve non-scene filename sanitization

Add dedicated non-scene filename sanitization for movies to produce cleaner, filesystem-safe names when `scene_naming` is disabled. The new logic:
- optionally transliterates unicode based on `unicode_filenames`
- removes combining marks/diacritics and disallowed punctuation
- normalizes separators and extra whitespace

Also update movie name construction to explicitly format `Name (Year)` and append a trailing ` -` for non-scene naming before metadata tokens, improving readability and consistency.

Update default config values in `unshackle.yaml` to match non-scene/local usage:
- disable `scene_naming` by default
- comment out default `tag`
- change downloads directory to `Downloads`
2026-03-02 19:42:53 +07:00
4 changed files with 140 additions and 55 deletions

View File

@@ -60,7 +60,7 @@ from unshackle.core.tracks import Audio, Subtitle, Tracks, Video
from unshackle.core.tracks.attachment import Attachment from unshackle.core.tracks.attachment import Attachment
from unshackle.core.tracks.hybrid import Hybrid from unshackle.core.tracks.hybrid import Hybrid
from unshackle.core.utilities import (find_font_with_fallbacks, get_debug_logger, get_system_fonts, init_debug_logger, from unshackle.core.utilities import (find_font_with_fallbacks, get_debug_logger, get_system_fonts, init_debug_logger,
is_close_match, suggest_font_packages, time_elapsed_since) is_close_match, sanitize_filename, suggest_font_packages, time_elapsed_since)
from unshackle.core.utils import tags from unshackle.core.utils import tags
from unshackle.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE, from unshackle.core.utils.click_types import (AUDIO_CODEC_LIST, LANGUAGE_RANGE, QUALITY_LIST, SEASON_RANGE,
ContextData, MultipleChoice, SubtitleCodecChoice, VideoCodecChoice) ContextData, MultipleChoice, SubtitleCodecChoice, VideoCodecChoice)
@@ -2015,6 +2015,8 @@ class dl:
sidecar_dir = config.directories.downloads sidecar_dir = config.directories.downloads
if not no_folder and isinstance(title, (Episode, Song)) and media_info: if not no_folder and isinstance(title, (Episode, Song)) and media_info:
if isinstance(title, Episode) and not config.scene_naming:
sidecar_dir /= sanitize_filename(title.title.replace("$", "S"), " ")
sidecar_dir /= title.get_filename(media_info, show_service=not no_source, folder=True) sidecar_dir /= title.get_filename(media_info, show_service=not no_source, folder=True)
sidecar_dir.mkdir(parents=True, exist_ok=True) sidecar_dir.mkdir(parents=True, exist_ok=True)
@@ -2080,6 +2082,8 @@ class dl:
) )
if sample_track and sample_track.path: if sample_track and sample_track.path:
media_info = MediaInfo.parse(sample_track.path) media_info = MediaInfo.parse(sample_track.path)
if isinstance(title, Episode) and not config.scene_naming:
final_dir /= sanitize_filename(title.title.replace("$", "S"), " ")
final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True) final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True)
final_dir.mkdir(parents=True, exist_ok=True) final_dir.mkdir(parents=True, exist_ok=True)
@@ -2122,6 +2126,8 @@ class dl:
audio_codec_suffix = muxed_audio_codecs.get(muxed_path) audio_codec_suffix = muxed_audio_codecs.get(muxed_path)
if not no_folder and isinstance(title, (Episode, Song)): if not no_folder and isinstance(title, (Episode, Song)):
if isinstance(title, Episode) and not config.scene_naming:
final_dir /= sanitize_filename(title.title.replace("$", "S"), " ")
final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True) final_dir /= title.get_filename(media_info, show_service=not no_source, folder=True)
final_dir.mkdir(parents=True, exist_ok=True) final_dir.mkdir(parents=True, exist_ok=True)

View File

@@ -123,14 +123,36 @@ class Episode(Title):
scan_suffix = "i" scan_suffix = "i"
return f"{resolution}{scan_suffix}" return f"{resolution}{scan_suffix}"
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}"
def _append_unique_token(tokens: list[str], token: Optional[str]) -> None:
token = (token or "").strip()
if token and token not in tokens:
tokens.append(token)
if folder and not config.scene_naming:
return sanitize_filename(f"Season {self.season:02}", " ")
non_scene_episode_file = not config.scene_naming and not folder
extra_tokens: list[str] = []
# Title [Year] SXXEXX Name (or Title [Year] SXX if folder) # Title [Year] SXXEXX Name (or Title [Year] SXX if folder)
if folder: if folder:
name = f"{self.title}" name = f"{self.title}"
if self.year and config.series_year: if self.year and config.series_year:
name += f" {self.year}" name += f" {self.year}"
name += f" S{self.season:02}" name += f" S{self.season:02}"
else: elif non_scene_episode_file:
if config.dash_naming: episode_label = self.name or f"Episode {self.number:02}"
name = f"{self.title.replace('$', 'S')} S{self.season:02}E{self.number:02} - {episode_label}"
elif config.dash_naming:
# Format: Title - SXXEXX - Episode Name # Format: Title - SXXEXX - Episode Name
name = self.title.replace("$", "S") # e.g., Arli$$ name = self.title.replace("$", "S") # e.g., Arli$$
@@ -159,10 +181,13 @@ class Episode(Title):
if primary_video_track: if primary_video_track:
resolution_token = _get_resolution_token(primary_video_track) resolution_token = _get_resolution_token(primary_video_track)
if resolution_token: if resolution_token:
if non_scene_episode_file:
_append_unique_token(extra_tokens, resolution_token)
else:
name += f" {resolution_token}" name += f" {resolution_token}"
# Service (use track source if available) # Service (use track source if available)
if show_service: if show_service and config.scene_naming:
source_name = None source_name = None
if self.tracks: if self.tracks:
first_track = next(iter(self.tracks), None) first_track = next(iter(self.tracks), None)
@@ -171,14 +196,21 @@ class Episode(Title):
name += f" {source_name or self.service.__name__}" name += f" {source_name or self.service.__name__}"
# 'WEB-DL' # 'WEB-DL'
if config.scene_naming:
name += " WEB-DL" name += " WEB-DL"
# DUAL # DUAL
if unique_audio_languages == 2: if unique_audio_languages == 2:
if non_scene_episode_file:
_append_unique_token(extra_tokens, "DUAL")
else:
name += " DUAL" name += " DUAL"
# MULTi # MULTi
if unique_audio_languages > 2: if unique_audio_languages > 2:
if non_scene_episode_file:
_append_unique_token(extra_tokens, "MULTi")
else:
name += " MULTi" name += " MULTi"
# Audio Codec + Channels (+ feature) # Audio Codec + Channels (+ feature)
@@ -194,8 +226,15 @@ class Episode(Title):
channels = float(channel_count) channels = float(channel_count)
features = primary_audio_track.format_additionalfeatures or "" features = primary_audio_track.format_additionalfeatures or ""
name += f" {AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}" audio_token = f"{AUDIO_CODEC_MAP.get(codec, codec)}{channels:.1f}"
if non_scene_episode_file:
_append_unique_token(extra_tokens, audio_token)
else:
name += f" {audio_token}"
if "JOC" in features or primary_audio_track.joc: if "JOC" in features or primary_audio_track.joc:
if non_scene_episode_file:
_append_unique_token(extra_tokens, "Atmos")
else:
name += " Atmos" name += " Atmos"
# Video (dynamic range + hfr +) Codec # Video (dynamic range + hfr +) Codec
@@ -210,36 +249,55 @@ class Episode(Title):
) )
frame_rate = float(primary_video_track.frame_rate) 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 # Primary HDR format detection
if hdr_format: if hdr_format:
if hdr_format_full.startswith("Dolby Vision"): if hdr_format_full.startswith("Dolby Vision"):
if non_scene_episode_file:
_append_unique_token(extra_tokens, "DV")
else:
name = _append_token(name, "DV") name = _append_token(name, "DV")
if any( if any(
indicator in (hdr_format_full + " " + hdr_format) indicator in (hdr_format_full + " " + hdr_format)
for indicator in ["HDR10", "SMPTE ST 2086"] for indicator in ["HDR10", "SMPTE ST 2086"]
): ):
if non_scene_episode_file:
_append_unique_token(extra_tokens, "HDR")
else:
name = _append_token(name, "HDR") name = _append_token(name, "HDR")
elif "HDR Vivid" in hdr_format: elif "HDR Vivid" in hdr_format:
if non_scene_episode_file:
_append_unique_token(extra_tokens, "HDR")
else:
name = _append_token(name, "HDR") name = _append_token(name, "HDR")
else: else:
dynamic_range = DYNAMIC_RANGE_MAP.get(hdr_format) or hdr_format or "" dynamic_range = DYNAMIC_RANGE_MAP.get(hdr_format) or hdr_format or ""
if non_scene_episode_file:
_append_unique_token(extra_tokens, dynamic_range)
else:
name = _append_token(name, dynamic_range) 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(): elif "HLG" in trc or "Hybrid Log-Gamma" in trc or "ARIB STD-B67" in trc or "arib-std-b67" in trc.lower():
if non_scene_episode_file:
_append_unique_token(extra_tokens, "HLG")
else:
name += " HLG" 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(): 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():
if non_scene_episode_file:
_append_unique_token(extra_tokens, "HDR")
else:
name += " HDR" name += " HDR"
if frame_rate > 30: if frame_rate > 30:
if non_scene_episode_file:
_append_unique_token(extra_tokens, "HFR")
else:
name += " HFR" name += " HFR"
name += f" {VIDEO_CODEC_MAP.get(codec, codec)}" video_codec_token = VIDEO_CODEC_MAP.get(codec, codec)
if non_scene_episode_file:
_append_unique_token(extra_tokens, video_codec_token)
else:
name += f" {video_codec_token}"
if non_scene_episode_file and extra_tokens:
name += f" - {' '.join(extra_tokens)}"
if config.tag: if config.tag:
name += f"-{config.tag}" name += f"-{config.tag}"

View File

@@ -1,3 +1,5 @@
import re
import unicodedata
from abc import ABC from abc import ABC
from typing import Any, Iterable, Optional, Union from typing import Any, Iterable, Optional, Union
@@ -5,6 +7,7 @@ from langcodes import Language
from pymediainfo import MediaInfo from pymediainfo import MediaInfo
from rich.tree import Tree from rich.tree import Tree
from sortedcontainers import SortedKeyList from sortedcontainers import SortedKeyList
from unidecode import unidecode
from unshackle.core.config import config from unshackle.core.config import config
from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP from unshackle.core.constants import AUDIO_CODEC_MAP, DYNAMIC_RANGE_MAP, VIDEO_CODEC_MAP
@@ -52,6 +55,18 @@ class Movie(Title):
return f"{self.name} ({self.year})" return f"{self.name} ({self.year})"
return self.name return self.name
@staticmethod
def _sanitize_non_scene_filename(filename: str) -> str:
if not config.unicode_filenames:
filename = unidecode(filename)
filename = "".join(c for c in filename if unicodedata.category(c) != "Mn")
filename = filename.replace("/", " & ").replace(";", " & ")
filename = re.sub(r"[\\:*!?¿,'\"<>|$#~]", "", filename)
filename = re.sub(r"\s{2,}", " ", filename)
return filename.strip()
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:
primary_video_track = next(iter(media_info.video_tracks), None) primary_video_track = next(iter(media_info.video_tracks), None)
primary_audio_track = None primary_audio_track = None
@@ -89,7 +104,11 @@ class Movie(Title):
return f"{resolution}{scan_suffix}" return f"{resolution}{scan_suffix}"
# Name (Year) # Name (Year)
name = str(self).replace("$", "S") # e.g., Arli$$ name = self.name.replace("$", "S") # e.g., Arli$$
if self.year:
name = f"{name} ({self.year})"
if not config.scene_naming:
name += " -"
if primary_video_track: if primary_video_track:
resolution_token = _get_resolution_token(primary_video_track) resolution_token = _get_resolution_token(primary_video_track)
@@ -179,7 +198,9 @@ class Movie(Title):
if config.tag: if config.tag:
name += f"-{config.tag}" name += f"-{config.tag}"
return sanitize_filename(name, "." if config.scene_naming else " ") if config.scene_naming:
return sanitize_filename(name, ".")
return self._sanitize_non_scene_filename(name)
class Movies(SortedKeyList, ABC): class Movies(SortedKeyList, ABC):

View File

@@ -1,5 +1,5 @@
# Group or Username to postfix to the end of all download filenames following a dash # Group or Username to postfix to the end of all download filenames following a dash
tag: Kenzuya # tag: Kenzuya
# Enable/disable tagging with group name (default: true) # Enable/disable tagging with group name (default: true)
tag_group_name: true tag_group_name: true
@@ -13,7 +13,7 @@ set_terminal_bg: false
# Set file naming convention # Set file naming convention
# true for style - Prime.Suspect.S07E01.The.Final.Act.Part.One.1080p.ITV.WEB-DL.AAC2.0.H.264 # 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 # false for style - Prime Suspect S07E01 The Final Act - Part One
scene_naming: true scene_naming: false
# Whether to include the year in series names for episodes and folders (default: true) # Whether to include the year in series names for episodes and folders (default: true)
# true for style - Show Name (2023) S01E01 Episode Name # true for style - Show Name (2023) S01E01 Episode Name
@@ -64,7 +64,7 @@ directories:
cache: Cache cache: Cache
# cookies: Cookies # cookies: Cookies
dcsl: DCSL # Device Certificate Status List dcsl: DCSL # Device Certificate Status List
downloads: /home/kenzuya/Mounts/ketuakenzuya/Downloads downloads: Downloads
logs: Logs logs: Logs
temp: Temp temp: Temp
# wvds: WVDs # wvds: WVDs