Last active
December 28, 2025 23:05
-
-
Save rubenhortas/d0ccd46750dd009d0f3fbedec5a4ecc9 to your computer and use it in GitHub Desktop.
Utility to normalize TV show filenames
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| TV Show Renamer: A utility to normalize TV show filenames. | |
| This script automates the tedious process of renaming video files from messy | |
| tracker formats (e.g., 'Show.Name.S01E01.720p.x264-Group.mkv') into a clean, | |
| standardized structure (e.g., 'Show Name S01E01.mkv'). | |
| KEY FEATURES: | |
| * Format Normalization: Converts multiple episode formats (S01E01, 1x01, | |
| [Cap.101]) into a single consistent output style (English or Spanish). | |
| * Intelligent Cleanup: Automatically removes over 100+ common metadata | |
| tags, including video quality (4K, 1080p), codecs (x264, HEVC), | |
| platforms (NF, AMZN), and release group names. | |
| * Recursive Processing: Can scan entire directory trees to find and | |
| process video files (.mp4, .mkv, .avi). | |
| * Safety First: Includes a 'Test Mode' (-t) to preview changes without | |
| modifying any files, and prevents overwriting existing files. | |
| * Clean Typography: Corrects capitalization and removes redundant | |
| periods, underscores, and dashes from titles. | |
| USAGE: | |
| usage: tv_show_renamer.py [-h] [-t] [-of {en,es}] [-sf {en,es}] [-ts TITLE_SEPARATOR] [paths ...] | |
| TV Show Renamer (Normalize 'S01E01' and '1x01' formats) | |
| positional arguments: | |
| paths Directories to process | |
| options: | |
| -h, --help show this help message and exit | |
| -t, --test Test mode (no changes) | |
| -of, --output-format {en,es} | |
| Format: 'en' (S01E01) or 'es' (01x01). Default: 'en' | |
| -sf, --skip-format {en,es} | |
| Skip files that already have this format | |
| -ts, --title-separator TITLE_SEPARATOR | |
| Separating character between the episode and the title, e.g.: '-' | |
| """ | |
| import argparse | |
| import re | |
| import signal | |
| import sys | |
| from pathlib import Path | |
| # Keywords to strip (Case insensitive) | |
| # Don't add '[]' | |
| _ACRONYMS = [ | |
| # Video qualities | |
| 'CAM', | |
| 'CAMRIP', | |
| 'HDCAM', | |
| 'BDSCR', | |
| 'DDC', | |
| 'DVDSCR', | |
| 'DVDSCREENER', | |
| 'SCR', | |
| 'SCREENER', | |
| 'WEBSCREENER', | |
| 'HDTC', | |
| 'TC', | |
| 'TELECINE', | |
| 'HDTS', | |
| 'TELESYNC', | |
| 'TS', | |
| 'TVRIP', | |
| 'VHSRIP', | |
| 'WP', | |
| 'WORKPRINT', | |
| 'BD5', | |
| 'BD9', | |
| 'BD25', | |
| 'BD50', | |
| 'BD66', | |
| 'BD100', | |
| 'BDISO', | |
| 'BDMV', | |
| 'BDR', | |
| 'BDRIP', | |
| 'DISC', | |
| 'BLU RAY', | |
| 'BLU RAY RIP', | |
| 'BLURAY', | |
| 'BRIP', | |
| 'BRRIP', | |
| 'COMPLETE.BLURAY', | |
| 'REMUX', | |
| 'PDTV', | |
| 'DVDMUX', | |
| 'DVDR', | |
| 'DVDRIP', | |
| 'DVD-5', | |
| 'DVD-9', | |
| 'DVD-FULL', | |
| 'FULL-RIP', | |
| 'ISO RIP', | |
| 'ISOLESS RIP', | |
| 'PREDVDRIP', | |
| 'UNTOUCHED RIP', | |
| 'HD', | |
| 'HDRIP', | |
| 'HDTV', | |
| 'HDTVRIP', | |
| 'PPV', | |
| 'PPVRIP', | |
| 'WEB', | |
| 'WEB DL', | |
| 'WEB RIP', | |
| 'WEB (SCENE)', | |
| 'WEBDL', | |
| 'WEBRIP', | |
| 'WEB-DL', | |
| 'WEB-DLRIP', | |
| 'WEB-RIP', | |
| # 4K | |
| 'DS4k', | |
| 'RM4K', | |
| # Video resolutions | |
| 'm-720p', | |
| 'mini 720p', | |
| '720p', | |
| 'm-1080p', | |
| 'mini 1080p', | |
| '1080', | |
| '1080p', | |
| 'm1080p', | |
| 'mHD', | |
| 'mini HD', | |
| 'μHD', | |
| 'micro HD', | |
| # Video Codecs | |
| 'XVid', | |
| 'AV1', | |
| 'x264', | |
| 'AVC', | |
| 'h264', | |
| 'H.264', | |
| 'H-264', | |
| 'H 264', | |
| 'x265', | |
| 'h265', | |
| 'H.265', | |
| 'H 265', | |
| 'HEVC', | |
| '10bit', | |
| # Video bit rates | |
| 'CBR', | |
| 'VBR', | |
| # Video dynamic range | |
| 'SDR', | |
| 'HDR', | |
| # Audio languages | |
| 'Dual', | |
| 'MULTI', | |
| 'AA3', | |
| 'AAc', | |
| 'AAC2 0', | |
| 'AC3', | |
| # Audio qualities | |
| '2.0', | |
| '2Ch', | |
| '5.1', | |
| '7.1', | |
| '2 0', | |
| '5 1', | |
| '7 1', | |
| # Audio Codecs | |
| 'AA3', | |
| 'AC3', | |
| 'DDP', | |
| 'DDP2.0', | |
| 'DDP2 0', | |
| 'DDP5.1', | |
| 'DDP5 1', | |
| # Corrected versions | |
| '(Proper)', | |
| '(Repack)', | |
| 'PROPER', | |
| 'REPACK', | |
| # Subtitles | |
| '+Subs', | |
| 'HC', | |
| 'MULTISUBS', | |
| 'Subs', | |
| 'SUBTITLES', | |
| # Platforfms | |
| 'A3P', | |
| 'ABC', | |
| 'ALL4', | |
| 'AMZ', | |
| 'AMZN', | |
| 'ARD', | |
| 'ATVP', | |
| 'APTV', | |
| 'BBC', | |
| 'CBC', | |
| 'CBS', | |
| 'CR', | |
| 'DSNP', | |
| 'DISNEY', | |
| 'HBO', | |
| 'HBOM', | |
| 'HMax', | |
| 'HULU', | |
| 'NBC', | |
| 'NF', | |
| 'NFLX', | |
| 'PARA', | |
| 'PMNT', | |
| 'PCOK', | |
| 'PCKL', | |
| 'SHOW', | |
| 'STZ', | |
| # Groups and sites | |
| 'AFG', | |
| 'EZTV.to', | |
| 'EZTVx.to', | |
| 'eztv.re', | |
| 'www.AtomixHQ.com', | |
| 'www.AtomixHQ.link', | |
| 'www.AtomoHD.BEER', | |
| 'www.AtomoHD.CASA', | |
| 'www.AtomoHD.cloud', | |
| 'www.AtomoHD.FUTBOL', | |
| 'www.AtomoHD.LOVE', | |
| 'www.AtomoHD.ninja', | |
| 'www.AtomoHD.PLUS', | |
| 'www.descargas2020.com', | |
| 'www.descargas2020.org', | |
| 'www.maxitorrent.com', | |
| 'www.PCTmix.com', | |
| 'www.pctnew.com', | |
| 'www.newpct1.com', | |
| '(wolfmax4k.com)', | |
| 'BONE', | |
| 'CBFM', | |
| 'DiRT', | |
| 'EDITH', | |
| 'ETHEL', | |
| 'FENiX', | |
| 'FLUX', | |
| 'FREQUENCY', | |
| 'GalaxyTV', | |
| 'glhf', | |
| 'ggwp', | |
| 'GRACE', | |
| 'HiggsBoson', | |
| 'ION10', | |
| 'JFF', | |
| 'lazycunts', | |
| 'MeGusta', | |
| 'MGHW', | |
| 'NTb', | |
| 'RAWR', | |
| 'STAN', | |
| 'STC', | |
| 'successfulcrab', | |
| 'SYNCOPY', | |
| 'TORRENTGALAXY', | |
| 'TRUFFLE', | |
| # Languages | |
| 'Cast', | |
| 'Castellano', | |
| ] | |
| _VIDEO_EXTENSIONS = {'.mp4', '.avi', '.mkv'} | |
| _RE_EPISODE = re.compile(r""" | |
| (?P<show_name>.*?) | |
| (?: | |
| s(?P<s_en>\d{1,2})e(?P<e_en>\d{2}) | # s01e01 | |
| (?P<s_es>\d{1,2})x(?P<e_es>\d{2}) | # 1x01 | |
| \[Cap.(?P<se_es>\d+)\] # [Cap.101] | |
| ) | |
| [\s\.\-]*(?P<title>.*)$ | |
| """, re.IGNORECASE | re.VERBOSE) | |
| _RE_CLEANUP_TAGS = re.compile(r'\s*\[.*?\]\s*') | |
| _RE_ACRONYMS_CLEANUP = re.compile( | |
| r'(?:[\s\.\-]+|^)(' + '|'.join(map(re.escape, sorted(_ACRONYMS, key=len, reverse=True))) + r')(?=[\s\.\-]|$)', | |
| re.IGNORECASE | |
| ) | |
| _RE_SPACES_CLEANUP = re.compile(r'[\s\.]+') | |
| # ANSI colors | |
| RED = "\033[91m" | |
| GREEN = "\033[92m" | |
| RESET = "\033[0m" | |
| class Video: | |
| __slots__ = ['original_name', 'new_name', '_path', '_output_format', '_skip_format', | |
| '_title_separator', '_extension', '_language'] | |
| def __init__(self, file_path: Path, output_format: str, skip_format: str | None, title_separator: str): | |
| self.original_name = file_path.name | |
| self.new_name = None | |
| self._path = file_path | |
| self._output_format = output_format | |
| self._skip_format = skip_format | |
| self._title_separator = title_separator | |
| self._extension = file_path.suffix | |
| self._language = None | |
| self._parse() | |
| def _parse(self) -> None: | |
| match = _RE_EPISODE.search(self._path.stem) | |
| if not match: | |
| return | |
| s_en, e_en = match.group('s_en'), match.group('e_en') | |
| s_es, e_es = match.group('s_es'), match.group('e_es') | |
| se_es = match.group('se_es') | |
| if s_en and e_en: | |
| season, episode, self._language = s_en, e_en, 'en' | |
| elif s_es and e_es: | |
| season, episode, self._language = s_es, e_es, 'es' | |
| elif se_es: | |
| season = se_es[:-2] if len(se_es) > 2 else "01" | |
| episode = se_es[-2:].zfill(2) | |
| self._language = 'es' | |
| else: | |
| return | |
| if self._skip_format == self._language: | |
| return | |
| show_name = self._clean_text(match.group('show_name')) | |
| show_name = ' '.join(word.capitalize() for word in show_name.split()) | |
| ep_title = self._clean_text(match.group('title')) | |
| season_int = int(season) | |
| if self._output_format == 'en': | |
| formatted_id = f"S{season_int:02d}E{episode}" | |
| else: | |
| formatted_id = f"{season_int:02d}x{episode}" | |
| parts = [show_name, formatted_id] | |
| if ep_title: | |
| if self._title_separator.strip(): | |
| parts.append(self._title_separator) | |
| parts.append(ep_title) | |
| self.new_name = ' '.join(parts) + self._extension | |
| def _clean_text(self, text: str) -> str: | |
| if not text: | |
| return '' | |
| clean_text = _RE_CLEANUP_TAGS.sub('', text) | |
| clean_text = _RE_ACRONYMS_CLEANUP.sub(' ', clean_text) | |
| clean_text = _RE_SPACES_CLEANUP.sub(' ' , clean_text).strip(' .-_') | |
| return clean_text | |
| def _rename_videos(directory: Path, output_format: str, skip_format: str, title_separator: str, testing: bool) -> None: | |
| videos = 0 | |
| renamed = 0 | |
| print(f"\n{GREEN + '(TEST) ' + RESET if testing else ''}Processing: {GREEN}{directory}{RESET}") | |
| video_files = ( | |
| f for f in directory.rglob('*') | |
| if f.suffix.lower() in _VIDEO_EXTENSIONS and f.is_file() | |
| ) | |
| for video_file in sorted(video_files, key=lambda x: x.name): | |
| videos += 1 | |
| video = Video(video_file, output_format, skip_format, title_separator) | |
| if video.new_name and video.new_name != video.original_name: | |
| target = video_file.with_name(video.new_name) | |
| print(f"{video.original_name:<100} -> {GREEN}{video.new_name:<100}{RESET}") | |
| if not testing: | |
| if target.exists(): | |
| print(f"{RED}[!] Skip: '{target}' already exists{RESET}") | |
| continue | |
| try: | |
| video_file.rename(target) | |
| renamed += 1 | |
| except Exception as e: | |
| print(f"{RED}[!] Error: {e}{RESET}") | |
| print(f"\nRenamed {GREEN}{renamed}{RESET}/{videos} videos") | |
| def main(argv: list[str] | None = None) -> int: | |
| parser = argparse.ArgumentParser(description="TV Show Renamer (Normalize 'S01E01' and '1x01' formats)") | |
| parser.add_argument('paths', nargs='*', help='Directories to process') | |
| parser.add_argument('-t', '--test', action='store_true', help='Test mode (no changes)') | |
| parser.add_argument('-of', '--output-format', choices=['en', 'es'], default='en', help="Format: 'en' (S01E01) or 'es' (01x01). Default: 'en'") | |
| parser.add_argument('-sf', '--skip-format', choices=['en', 'es'], help='Skip files that already have this format') | |
| parser.add_argument('-ts', '--title-separator', default='', help="Separating character between the episode and the title, e.g.: '-'") | |
| args = parser.parse_args(argv) | |
| if not args.paths: | |
| parser.print_help() | |
| return 0 | |
| try: | |
| title_separator = args.title_separator | |
| if title_separator: | |
| if len(title_separator) > 1 and Path(title_separator).exists(): | |
| print(f"Warning: The separator '{title_separator}' looks like a filename.") | |
| print("If you wanted to use \"*\", please use quotes: -ts \"*\"") | |
| for p in args.paths: | |
| path_obj = Path(p).resolve() | |
| if path_obj.is_dir(): | |
| _rename_videos( | |
| directory=path_obj, | |
| output_format=args.output_format, | |
| skip_format=args.skip_format, | |
| title_separator=title_separator, | |
| testing=args.test | |
| ) | |
| else: | |
| print(f"{RED}[!] Error: '{p}' is not a directory{RESET}") | |
| return 0 | |
| except Exception as e: | |
| print(f"{RED}[!] Error: {e}{RESET}") | |
| return 1 | |
| if __name__ == '__main__': | |
| signal.signal(signal.SIGINT, lambda sig, frame: sys.exit(0)) | |
| sys.exit(main(sys.argv[1:])) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment