rename-subtitles
A GNOME/Nautilus script that renames subtitle files so they match their video files, letting players (GNOME Videos, VLC) auto-load them.
What it does
- Pairs subtitles to videos by episode number (
S01E02,1x02,Season 1 Episode 2, or a loneE02). - Falls back to 1-to-1 matching when a folder has a single video and a single subtitle with no episode markers.
- Preserves language codes:
sub.en.srtmatched toMovie.mkv→Movie.en.srt. - Never overwrites an existing file — such cases are reported as skipped.
- Shows a Zenity confirmation dialog listing every planned rename before touching anything.
Video extensions: mkv mp4 avi m4v mov wmv flv webm mpg mpeg ts m2ts Subtitle extensions: srt sub ssa ass vtt idx sup
Install (Nautilus right-click)
cp rename-subtitles ~/.local/share/nautilus/scripts/"Rename subtitles to match videos"
chmod +x ~/.local/share/nautilus/scripts/"Rename subtitles to match videos"
nautilus -q # reload the Scripts menu (quits open Files windows)
Then in Files: select the folder (or the video + subtitle files), right-click → Scripts → Rename subtitles to match videos.
Dependency
The confirmation dialog needs zenity:
sudo dnf install zenity # Fedora
Without zenity the script still works from a terminal (it prompts on the console), but the right-click dialog will not appear.
Command-line usage
./rename-subtitles [PATH ...] # files and/or folders; defaults to cwd
| 1 | #!/usr/bin/env python3 |
| 2 | """Rename subtitle files to match their corresponding video files. |
| 3 | |
| 4 | Designed as a GNOME/Nautilus right-click script, but also runnable from a |
| 5 | terminal. Subtitles are paired to videos by episode number (S01E02, 1x02, ...) |
| 6 | with a fallback to 1-to-1 matching when a folder holds a single video + single |
| 7 | subtitle. Trailing language codes (e.g. `.en`, `.eng`) are preserved and |
| 8 | existing files are never overwritten. A Zenity dialog lists the planned renames |
| 9 | for confirmation before anything happens. |
| 10 | |
| 11 | Invocation: |
| 12 | * From Nautilus: reads NAUTILUS_SCRIPT_SELECTED_FILE_PATHS. |
| 13 | * From a terminal: `./rename-subtitles [PATH ...]` (files and/or folders). |
| 14 | With no arguments it operates on the current directory. |
| 15 | """ |
| 16 | |
| 17 | import os |
| 18 | import re |
| 19 | import shutil |
| 20 | import subprocess |
| 21 | import sys |
| 22 | |
| 23 | VIDEO_EXTS = { |
| 24 | ".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv", |
| 25 | ".flv", ".webm", ".mpg", ".mpeg", ".ts", ".m2ts", |
| 26 | } |
| 27 | SUBTITLE_EXTS = { |
| 28 | ".srt", ".sub", ".ssa", ".ass", ".vtt", ".idx", ".sup", |
| 29 | } |
| 30 | |
| 31 | # Language codes we recognise as a trailing token and preserve. |
| 32 | LANG_CODES = { |
| 33 | # ISO 639-1 (2-letter) — common subset |
| 34 | "en", "fr", "es", "de", "it", "nl", "pt", "sv", "da", "no", "fi", |
| 35 | "pl", "ru", "cs", "hu", "ro", "tr", "el", "he", "ar", "ja", "ko", |
| 36 | "zh", "hi", "th", "vi", "id", "uk", "bg", "hr", "sr", "sk", "sl", |
| 37 | "et", "lv", "lt", "fa", |
| 38 | # ISO 639-2 (3-letter) and common English names |
| 39 | "eng", "english", "fre", "fra", "french", "spa", "esp", "spanish", |
| 40 | "ger", "deu", "german", "ita", "italian", "dut", "nld", "dutch", |
| 41 | "por", "portuguese", "swe", "swedish", "dan", "danish", "nor", |
| 42 | "norwegian", "fin", "finnish", "pol", "polish", "rus", "russian", |
| 43 | "cze", "ces", "czech", "hun", "hungarian", "rum", "ron", "romanian", |
| 44 | "tur", "turkish", "gre", "ell", "greek", "heb", "hebrew", "ara", |
| 45 | "arabic", "jpn", "japanese", "kor", "korean", "chi", "zho", "chinese", |
| 46 | "hin", "hindi", "tha", "thai", "vie", "vietnamese", "ind", "indonesian", |
| 47 | "ukr", "ukrainian", |
| 48 | } |
| 49 | |
| 50 | # Episode-key regexes, tried in order. Each must yield (season, episode). |
| 51 | _EP_PATTERNS = [ |
| 52 | re.compile(r"[Ss](\d{1,2})[\._ -]?[Ee](\d{1,3})"), # S01E02, s1e2, S01.E02 |
| 53 | re.compile(r"(?<!\d)(\d{1,2})[xX](\d{2,3})(?!\d)"), # 1x02, 12x05 |
| 54 | re.compile(r"[Ss]eason[\._ -]?(\d{1,2})[\._ -]?" |
| 55 | r"[Ee]pisode[\._ -]?(\d{1,3})"), # Season 1 Episode 2 |
| 56 | ] |
| 57 | # Lighter fallback: a lone episode marker, treated as season 1. |
| 58 | _EP_ONLY = re.compile(r"(?:^|[\._ -])[Ee](\d{1,3})(?:[\._ -]|$)") |
| 59 | |
| 60 | |
| 61 | def episode_key(name): |
| 62 | """Return (season, episode) parsed from *name*, or None.""" |
| 63 | stem = os.path.splitext(name)[0] |
| 64 | for pat in _EP_PATTERNS: |
| 65 | m = pat.search(stem) |
| 66 | if m: |
| 67 | return (int(m.group(1)), int(m.group(2))) |
| 68 | m = _EP_ONLY.search(stem) |
| 69 | if m: |
| 70 | return (1, int(m.group(1))) |
| 71 | return None |
| 72 | |
| 73 | |
| 74 | def split_lang(subname): |
| 75 | """Split a subtitle filename into (stem_without_lang, lang_or_None, ext). |
| 76 | |
| 77 | The token immediately before the extension is treated as a language code |
| 78 | only if it appears in LANG_CODES. |
| 79 | """ |
| 80 | root, ext = os.path.splitext(subname) |
| 81 | ext = ext.lower() |
| 82 | stem, sep, last = root.rpartition(".") |
| 83 | if sep and last.lower() in LANG_CODES: |
| 84 | return stem, last, ext |
| 85 | return root, None, ext |
| 86 | |
| 87 | |
| 88 | def gather_files(paths): |
| 89 | """Resolve *paths* (files/dirs) into a flat, de-duplicated file list.""" |
| 90 | files = [] |
| 91 | seen = set() |
| 92 | |
| 93 | def add(p): |
| 94 | ap = os.path.abspath(p) |
| 95 | if ap not in seen and os.path.isfile(ap): |
| 96 | seen.add(ap) |
| 97 | files.append(ap) |
| 98 | |
| 99 | for p in paths: |
| 100 | if os.path.isdir(p): |
| 101 | for entry in sorted(os.listdir(p)): |
| 102 | add(os.path.join(p, entry)) |
| 103 | elif os.path.isfile(p): |
| 104 | add(p) |
| 105 | return files |
| 106 | |
| 107 | |
| 108 | def classify(files): |
| 109 | videos, subs = [], [] |
| 110 | for f in files: |
| 111 | ext = os.path.splitext(f)[1].lower() |
| 112 | if ext in VIDEO_EXTS: |
| 113 | videos.append(f) |
| 114 | elif ext in SUBTITLE_EXTS: |
| 115 | subs.append(f) |
| 116 | return videos, subs |
| 117 | |
| 118 | |
| 119 | def plan_renames(files): |
| 120 | """Return (renames, skips). |
| 121 | |
| 122 | renames: list of (src_abs, dst_abs, old_name, new_name). |
| 123 | skips: list of (name, reason). |
| 124 | """ |
| 125 | videos, subs = classify(files) |
| 126 | renames, skips = [], [] |
| 127 | |
| 128 | # Map episode key -> video (skip ambiguous duplicate keys). |
| 129 | video_by_key = {} |
| 130 | dup_keys = set() |
| 131 | for v in videos: |
| 132 | k = episode_key(os.path.basename(v)) |
| 133 | if k is None: |
| 134 | continue |
| 135 | if k in video_by_key: |
| 136 | dup_keys.add(k) |
| 137 | else: |
| 138 | video_by_key[k] = v |
| 139 | for k in dup_keys: |
| 140 | video_by_key.pop(k, None) |
| 141 | |
| 142 | single_fallback = (len(videos) == 1 and len(subs) == 1 |
| 143 | and episode_key(os.path.basename(subs[0])) is None |
| 144 | and episode_key(os.path.basename(videos[0])) is None) |
| 145 | |
| 146 | planned_dsts = set() |
| 147 | for sub in subs: |
| 148 | subname = os.path.basename(sub) |
| 149 | subkey = episode_key(subname) |
| 150 | |
| 151 | if subkey is not None and subkey in video_by_key: |
| 152 | video = video_by_key[subkey] |
| 153 | elif subkey is not None and subkey in dup_keys: |
| 154 | skips.append((subname, "multiple videos share this episode number")) |
| 155 | continue |
| 156 | elif single_fallback: |
| 157 | video = videos[0] |
| 158 | else: |
| 159 | skips.append((subname, "no matching video found")) |
| 160 | continue |
| 161 | |
| 162 | _, lang, sub_ext = split_lang(subname) |
| 163 | video_stem = os.path.splitext(os.path.basename(video))[0] |
| 164 | new_name = video_stem + (("." + lang) if lang else "") + sub_ext |
| 165 | dst = os.path.join(os.path.dirname(sub), new_name) |
| 166 | |
| 167 | if os.path.abspath(dst) == os.path.abspath(sub): |
| 168 | skips.append((subname, "already named correctly")) |
| 169 | continue |
| 170 | if os.path.exists(dst) or dst in planned_dsts: |
| 171 | skips.append((subname, "target '%s' already exists" % new_name)) |
| 172 | continue |
| 173 | |
| 174 | planned_dsts.add(dst) |
| 175 | renames.append((sub, dst, subname, new_name)) |
| 176 | |
| 177 | return renames, skips |
| 178 | |
| 179 | |
| 180 | def build_message(renames, skips): |
| 181 | lines = [] |
| 182 | if renames: |
| 183 | lines.append("The following subtitles will be renamed:\n") |
| 184 | for _, _, old, new in renames: |
| 185 | lines.append(" %s\n → %s\n" % (old, new)) |
| 186 | if skips: |
| 187 | lines.append("\nSkipped:") |
| 188 | for name, reason in skips: |
| 189 | lines.append(" %s (%s)" % (name, reason)) |
| 190 | return "\n".join(lines) |
| 191 | |
| 192 | |
| 193 | def confirm(message, use_zenity): |
| 194 | """Return True if the user approves the plan.""" |
| 195 | if use_zenity: |
| 196 | rc = subprocess.run( |
| 197 | ["zenity", "--question", "--no-wrap", |
| 198 | "--title", "Rename subtitles to match videos", |
| 199 | "--ok-label", "Rename", "--cancel-label", "Cancel", |
| 200 | "--text", message], |
| 201 | ).returncode |
| 202 | return rc == 0 |
| 203 | print(message) |
| 204 | try: |
| 205 | return input("\nProceed with rename? [y/N] ").strip().lower() in ("y", "yes") |
| 206 | except EOFError: |
| 207 | return False |
| 208 | |
| 209 | |
| 210 | def notify(message, use_zenity, kind="info"): |
| 211 | if use_zenity: |
| 212 | subprocess.run( |
| 213 | ["zenity", "--" + kind, "--no-wrap", |
| 214 | "--title", "Rename subtitles to match videos", |
| 215 | "--text", message], |
| 216 | ) |
| 217 | else: |
| 218 | print(message) |
| 219 | |
| 220 | |
| 221 | def main(): |
| 222 | env = os.environ.get("NAUTILUS_SCRIPT_SELECTED_FILE_PATHS", "") |
| 223 | if env.strip(): |
| 224 | paths = [p for p in env.split("\n") if p] |
| 225 | elif len(sys.argv) > 1: |
| 226 | paths = sys.argv[1:] |
| 227 | else: |
| 228 | paths = [os.getcwd()] |
| 229 | |
| 230 | use_zenity = shutil.which("zenity") is not None and bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) |
| 231 | |
| 232 | files = gather_files(paths) |
| 233 | if not files: |
| 234 | notify("No files found to process.", use_zenity, "warning") |
| 235 | return 1 |
| 236 | |
| 237 | renames, skips = plan_renames(files) |
| 238 | |
| 239 | if not renames: |
| 240 | msg = "No subtitles needed renaming." |
| 241 | if skips: |
| 242 | msg += "\n\n" + build_message([], skips) |
| 243 | notify(msg, use_zenity, "info") |
| 244 | return 0 |
| 245 | |
| 246 | if not confirm(build_message(renames, skips), use_zenity): |
| 247 | return 0 |
| 248 | |
| 249 | done, failed = 0, [] |
| 250 | for src, dst, old, new in renames: |
| 251 | try: |
| 252 | # Final guard against overwriting a file created in the meantime. |
| 253 | if os.path.exists(dst): |
| 254 | failed.append("%s (target appeared)" % old) |
| 255 | continue |
| 256 | os.rename(src, dst) |
| 257 | done += 1 |
| 258 | except OSError as exc: |
| 259 | failed.append("%s (%s)" % (old, exc)) |
| 260 | |
| 261 | summary = "Renamed %d subtitle%s." % (done, "" if done == 1 else "s") |
| 262 | if skips: |
| 263 | summary += "\nSkipped %d." % len(skips) |
| 264 | if failed: |
| 265 | summary += "\nFailed %d:\n %s" % (len(failed), "\n ".join(failed)) |
| 266 | notify(summary, use_zenity, "error" if failed else "info") |
| 267 | return 1 if failed else 0 |
| 268 | |
| 269 | |
| 270 | if __name__ == "__main__": |
| 271 | sys.exit(main()) |
| 272 |