Skip to content

Instantly share code, notes, and snippets.

@rubenhortas
Last active December 28, 2025 23:05
Show Gist options
  • Select an option

  • Save rubenhortas/d0ccd46750dd009d0f3fbedec5a4ecc9 to your computer and use it in GitHub Desktop.

Select an option

Save rubenhortas/d0ccd46750dd009d0f3fbedec5a4ecc9 to your computer and use it in GitHub Desktop.
Utility to normalize TV show filenames
#!/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