Last active 3 months ago

GNOME/Nautilus script (Python 3) that renames subtitle files to match their video files, paired by episode number, preserving language codes.

Revision 339f8475dd14ed39ee4c3f1edcc8b581c8831d6d

README.md Raw

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 lone E02).
  • 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.srt matched to Movie.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
rename-subtitles Raw
1#!/usr/bin/env python3
2"""Rename subtitle files to match their corresponding video files.
3
4Designed as a GNOME/Nautilus right-click script, but also runnable from a
5terminal. Subtitles are paired to videos by episode number (S01E02, 1x02, ...)
6with a fallback to 1-to-1 matching when a folder holds a single video + single
7subtitle. Trailing language codes (e.g. `.en`, `.eng`) are preserved and
8existing files are never overwritten. A Zenity dialog lists the planned renames
9for confirmation before anything happens.
10
11Invocation:
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
17import os
18import re
19import shutil
20import subprocess
21import sys
22
23VIDEO_EXTS = {
24 ".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv",
25 ".flv", ".webm", ".mpg", ".mpeg", ".ts", ".m2ts",
26}
27SUBTITLE_EXTS = {
28 ".srt", ".sub", ".ssa", ".ass", ".vtt", ".idx", ".sup",
29}
30
31# Language codes we recognise as a trailing token and preserve.
32LANG_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
61def 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
74def 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
88def 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
108def 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
119def 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
180def 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
193def 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
210def 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
221def 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
270if __name__ == "__main__":
271 sys.exit(main())
272