From d03d54b0bbd5d516b02836e464c4e819aa15f32b Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Thu, 29 Jan 2026 02:37:34 +0900 Subject: [PATCH 1/7] Add filename configuration options Added filename configuration options for handling Unicode and episode names. --- unshackle/unshackle-example.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/unshackle/unshackle-example.yaml b/unshackle/unshackle-example.yaml index 36e7c2c..41d95e5 100644 --- a/unshackle/unshackle-example.yaml +++ b/unshackle/unshackle-example.yaml @@ -39,6 +39,10 @@ title_cache_enabled: true # Enable/disable title caching globally (default: true title_cache_time: 1800 # Cache duration in seconds (default: 1800 = 30 minutes) title_cache_max_retention: 86400 # Maximum cache retention for fallback when API fails (default: 86400 = 24 hours) +# 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 debug: From 4988d37fe955669c0d156aa933dc1adc5b010089 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Thu, 29 Jan 2026 02:38:31 +0900 Subject: [PATCH 2/7] Update episode name handling in filename --- unshackle/core/titles/episode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unshackle/core/titles/episode.py b/unshackle/core/titles/episode.py index b260ce9..1b34228 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -114,7 +114,7 @@ class Episode(Title): year=f" {self.year}" if self.year and config.series_year else "", season=self.season, number=self.number, - name=self.name or "", + name=self.name if self.name and config.insert_episodename_into_filenames else "", ).strip() if config.scene_naming: From 77d55153e68826b4be64a2d4743383a65ce69823 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Thu, 29 Jan 2026 02:38:50 +0900 Subject: [PATCH 3/7] Add insert_episodename_into_filenames config option --- unshackle/core/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 1c50d62..80eeeaa 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -96,6 +96,7 @@ class Config: self.scene_naming: bool = kwargs.get("scene_naming", True) self.series_year: bool = kwargs.get("series_year", True) 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 From ca3a6cc3ea61d4af613f4bb1911c777dff383718 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Tue, 3 Feb 2026 23:00:53 +0900 Subject: [PATCH 4/7] Update --- unshackle/core/titles/episode.py | 48 +++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 10 deletions(-) diff --git a/unshackle/core/titles/episode.py b/unshackle/core/titles/episode.py index 1b34228..87b7de1 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -109,13 +109,31 @@ class Episode(Title): name += f" {self.year}" name += f" S{self.season:02}" else: - 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 config.dash_naming: + # Format: Title - SXXEXX - Episode Name + name = self.title.replace("$", "S") # e.g., Arli$$ + + # Add year if configured + if self.year and config.series_year: + name += f" {self.year}" + + # Add season and episode + name += f" - S{self.season:02}E{self.number:02}" + + # Add episode name with dash separator + if self.name: + name += f" - {self.name}" + + 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 config.scene_naming: # Resolution @@ -135,11 +153,21 @@ class Episode(Title): # likely a movie or HD source, so it's most likely widescreen so # 16:9 canvas makes the most sense. resolution = int(primary_video_track.width * (9 / 16)) - name += f" {resolution}p" + # Determine scan type suffix - default to "p", use "i" only if explicitly interlaced + scan_suffix = "p" + scan_type = getattr(primary_video_track, 'scan_type', None) + if scan_type and str(scan_type).lower() == "interlaced": + scan_suffix = "i" + name += f" {resolution}{scan_suffix}" - # Service + # Service (use track source if available) if show_service: - name += f" {self.service.__name__}" + 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" From a4e1c6bb751e4f91f72257d21e6f2059ad2615f2 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Thu, 26 Feb 2026 02:01:45 +0900 Subject: [PATCH 5/7] Fix --- unshackle/core/titles/episode.py | 194 ++++++++++++++++--------------- 1 file changed, 102 insertions(+), 92 deletions(-) diff --git a/unshackle/core/titles/episode.py b/unshackle/core/titles/episode.py index 87b7de1..d9e4196 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -102,6 +102,27 @@ class Episode(Title): 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}" @@ -121,7 +142,7 @@ class Episode(Title): name += f" - S{self.season:02}E{self.number:02}" # Add episode name with dash separator - if self.name: + if self.name and config.insert_episodename_into_filenames: name += f" - {self.name}" name = name.strip() @@ -135,106 +156,95 @@ class Episode(Title): name=self.name if self.name and config.insert_episodename_into_filenames else "", ).strip() - if config.scene_naming: - # Resolution - if primary_video_track: - resolution = primary_video_track.height - aspect_ratio = [ - int(float(plane)) for plane in primary_video_track.other_display_aspect_ratio[0].split(":") - ] - if len(aspect_ratio) == 1: - # e.g., aspect ratio of 2 (2.00:1) would end up as `(2.0,)`, add 1 - aspect_ratio.append(1) - if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3): - # We want the resolution represented in a 4:3 or 16:9 canvas. - # If it's not 4:3 or 16:9, calculate as if it's inside a 16:9 canvas, - # otherwise the track's height value is fine. - # We are assuming this title is some weird aspect ratio so most - # likely a movie or HD source, so it's most likely widescreen so - # 16:9 canvas makes the most sense. - resolution = int(primary_video_track.width * (9 / 16)) - # Determine scan type suffix - default to "p", use "i" only if explicitly interlaced - scan_suffix = "p" - scan_type = getattr(primary_video_track, 'scan_type', None) - if scan_type and str(scan_type).lower() == "interlaced": - scan_suffix = "i" - name += f" {resolution}{scan_suffix}" + 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__}" + # 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" + # 'WEB-DL' + name += " WEB-DL" - # DUAL - if unique_audio_languages == 2: - name += " DUAL" + # DUAL + if unique_audio_languages == 2: + name += " DUAL" - # MULTi - if unique_audio_languages > 2: - name += " MULTi" + # 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 "" + # 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(" ")) ) - frame_rate = float(primary_video_track.frame_rate) + else: + channel_count = primary_audio_track.channel_s or primary_audio_track.channels or 0 + channels = float(channel_count) - # Primary HDR format detection - if hdr_format: - if hdr_format_full.startswith("Dolby Vision"): - name += " DV" - if any( - indicator in (hdr_format_full + " " + hdr_format) - for indicator in ["HDR10", "SMPTE ST 2086"] - ): - name += " HDR" - else: - name += f" {DYNAMIC_RANGE_MAP.get(hdr_format)} " - 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)}" + 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" - if config.tag: - name += f"-{config.tag}" + # 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) - return sanitize_filename(name) - else: - # Simple naming style without technical details - use spaces instead of dots - return sanitize_filename(name, " ") + 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 " ") class Series(SortedKeyList, ABC): From bde1945f67f137a28b1995aca467f254278af981 Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Thu, 26 Feb 2026 02:05:40 +0900 Subject: [PATCH 6/7] Fix --- unshackle/core/titles/episode.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/unshackle/core/titles/episode.py b/unshackle/core/titles/episode.py index d9e4196..be6cc15 100644 --- a/unshackle/core/titles/episode.py +++ b/unshackle/core/titles/episode.py @@ -107,7 +107,6 @@ class Episode(Title): 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]: @@ -156,6 +155,9 @@ class Episode(Title): name=self.name if self.name and config.insert_episodename_into_filenames else "", ).strip() + if getattr(config, "repack", False): + name += " REPACK" + if primary_video_track: resolution_token = _get_resolution_token(primary_video_track) if resolution_token: From 30269b6c17db9134246be1848d8e0816462ace4c Mon Sep 17 00:00:00 2001 From: CodeName393 Date: Thu, 26 Feb 2026 02:07:06 +0900 Subject: [PATCH 7/7] Fix --- unshackle/core/titles/movie.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/unshackle/core/titles/movie.py b/unshackle/core/titles/movie.py index d14df3e..3eb0b50 100644 --- a/unshackle/core/titles/movie.py +++ b/unshackle/core/titles/movie.py @@ -70,22 +70,21 @@ class Movie(Title): def _get_resolution_token(track: Any) -> str: if not track or not getattr(track, "height", None): return "" - resolution = track.height + 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) - if aspect_ratio[0] / aspect_ratio[1] not in (16 / 9, 4 / 3): - resolution = int(track.width * (9 / 16)) + 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 = "p" - scan_type = getattr(track, "scan_type", None) - if scan_type and str(scan_type).lower() == "interlaced": - scan_suffix = "i" + scan_suffix = "i" if str(getattr(track, "scan_type", "")).lower() == "interlaced" else "p" return f"{resolution}{scan_suffix}" # Name (Year)