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 cba9a20215447b402e625c170546709fc81a7470

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
22from xml.sax.saxutils import escape as _xml_escape
23
24VIDEO_EXTS = {
25 ".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv",
26 ".flv", ".webm", ".mpg", ".mpeg", ".ts", ".m2ts",
27}
28SUBTITLE_EXTS = {
29 ".srt", ".sub", ".ssa", ".ass", ".vtt", ".idx", ".sup",
30}
31
32# Language codes we recognise as a trailing token and preserve.
33LANG_CODES = {
34 # ISO 639-1 (2-letter) — common subset
35 "en", "fr", "es", "de", "it", "nl", "pt", "sv", "da", "no", "fi",
36 "pl", "ru", "cs", "hu", "ro", "tr", "el", "he", "ar", "ja", "ko",
37 "zh", "hi", "th", "vi", "id", "uk", "bg", "hr", "sr", "sk", "sl",
38 "et", "lv", "lt", "fa",
39 # ISO 639-2 (3-letter) and common English names
40 "eng", "english", "fre", "fra", "french", "spa", "esp", "spanish",
41 "ger", "deu", "german", "ita", "italian", "dut", "nld", "dutch",
42 "por", "portuguese", "swe", "swedish", "dan", "danish", "nor",
43 "norwegian", "fin", "finnish", "pol", "polish", "rus", "russian",
44 "cze", "ces", "czech", "hun", "hungarian", "rum", "ron", "romanian",
45 "tur", "turkish", "gre", "ell", "greek", "heb", "hebrew", "ara",
46 "arabic", "jpn", "japanese", "kor", "korean", "chi", "zho", "chinese",
47 "hin", "hindi", "tha", "thai", "vie", "vietnamese", "ind", "indonesian",
48 "ukr", "ukrainian",
49}
50
51# Episode-key regexes, tried in order. Each must yield (season, episode).
52_EP_PATTERNS = [
53 re.compile(r"[Ss](\d{1,2})[\._ -]?[Ee](\d{1,3})"), # S01E02, s1e2, S01.E02
54 re.compile(r"(?<!\d)(\d{1,2})[xX](\d{2,3})(?!\d)"), # 1x02, 12x05
55 re.compile(r"[Ss]eason[\._ -]?(\d{1,2})[\._ -]?"
56 r"[Ee]pisode[\._ -]?(\d{1,3})"), # Season 1 Episode 2
57]
58
59
60def episode_key(name):
61 """Return (season, episode) parsed from *name*, or None."""
62 stem = os.path.splitext(name)[0]
63 for pat in _EP_PATTERNS:
64 m = pat.search(stem)
65 if m:
66 return (int(m.group(1)), int(m.group(2)))
67 return None
68
69
70def split_lang(subname):
71 """Split a subtitle filename into (stem_without_lang, lang_or_None, ext).
72
73 The token immediately before the extension is treated as a language code
74 only if it appears in LANG_CODES.
75 """
76 root, ext = os.path.splitext(subname)
77 ext = ext.lower()
78 stem, sep, last = root.rpartition(".")
79 if sep and last.lower() in LANG_CODES:
80 return stem, last, ext
81 return root, None, ext
82
83
84def gather_files(paths):
85 """Resolve *paths* (files/dirs) into a flat, de-duplicated file list."""
86 files = []
87 seen = set()
88
89 def add(p):
90 ap = os.path.abspath(p)
91 if ap not in seen and os.path.isfile(ap):
92 seen.add(ap)
93 files.append(ap)
94
95 for p in paths:
96 if os.path.isdir(p):
97 for entry in sorted(os.listdir(p)):
98 add(os.path.join(p, entry))
99 elif os.path.isfile(p):
100 add(p)
101 return files
102
103
104def classify(files):
105 videos, subs = [], []
106 for f in files:
107 ext = os.path.splitext(f)[1].lower()
108 if ext in VIDEO_EXTS:
109 videos.append(f)
110 elif ext in SUBTITLE_EXTS:
111 subs.append(f)
112 return videos, subs
113
114
115def plan_renames(files):
116 """Return (renames, skips).
117
118 renames: list of (src_abs, dst_abs, old_name, new_name).
119 skips: list of (name, reason).
120 """
121 videos, subs = classify(files)
122 renames, skips = [], []
123
124 # Map episode key -> video (skip ambiguous duplicate keys).
125 video_by_key = {}
126 dup_keys = set()
127 for v in videos:
128 k = episode_key(os.path.basename(v))
129 if k is None:
130 continue
131 if k in video_by_key:
132 dup_keys.add(k)
133 else:
134 video_by_key[k] = v
135 for k in dup_keys:
136 video_by_key.pop(k, None)
137
138 # One video + one subtitle: pair them directly when the subtitle carries no
139 # episode marker to match on. The video may still have one (e.g. a lone
140 # `Show.S01E01.mkv` next to a bare `english.srt`).
141 single_fallback = (len(videos) == 1 and len(subs) == 1
142 and episode_key(os.path.basename(subs[0])) is None)
143
144 planned_dsts = set()
145 for sub in subs:
146 subname = os.path.basename(sub)
147 subkey = episode_key(subname)
148
149 if subkey is not None and subkey in video_by_key:
150 video = video_by_key[subkey]
151 elif subkey is not None and subkey in dup_keys:
152 skips.append((subname, "multiple videos share this episode number"))
153 continue
154 elif single_fallback:
155 video = videos[0]
156 else:
157 skips.append((subname, "no matching video found"))
158 continue
159
160 _, lang, sub_ext = split_lang(subname)
161 video_stem = os.path.splitext(os.path.basename(video))[0]
162 new_name = video_stem + (("." + lang) if lang else "") + sub_ext
163 dst = os.path.join(os.path.dirname(sub), new_name)
164
165 if os.path.abspath(dst) == os.path.abspath(sub):
166 skips.append((subname, "already named correctly"))
167 continue
168 if os.path.exists(dst) or dst in planned_dsts:
169 skips.append((subname, "target '%s' already exists" % new_name))
170 continue
171
172 planned_dsts.add(dst)
173 renames.append((sub, dst, subname, new_name))
174
175 return renames, skips
176
177
178def build_message(renames, skips):
179 lines = []
180 if renames:
181 lines.append("The following subtitles will be renamed:\n")
182 for _, _, old, new in renames:
183 lines.append(" %s\n → %s\n" % (old, new))
184 if skips:
185 lines.append("\nSkipped:")
186 for name, reason in skips:
187 lines.append(" %s (%s)" % (name, reason))
188 return "\n".join(lines)
189
190
191def _desktop_notify(title, body):
192 """Best-effort desktop notification when no dialog/console is available."""
193 if shutil.which("notify-send"):
194 subprocess.run(["notify-send", title, body])
195
196
197def confirm(message, use_zenity):
198 """Return True if the user approves the plan."""
199 if use_zenity:
200 rc = subprocess.run(
201 ["zenity", "--question", "--no-wrap",
202 "--title", "Rename subtitles to match videos",
203 "--ok-label", "Rename", "--cancel-label", "Cancel",
204 "--text", _xml_escape(message)],
205 ).returncode
206 return rc == 0
207 print(message)
208 try:
209 return input("\nProceed with rename? [y/N] ").strip().lower() in ("y", "yes")
210 except EOFError:
211 return False
212
213
214def notify(message, use_zenity, kind="info"):
215 if use_zenity:
216 subprocess.run(
217 ["zenity", "--" + kind, "--no-wrap",
218 "--title", "Rename subtitles to match videos",
219 "--text", _xml_escape(message)],
220 )
221 else:
222 print(message)
223
224
225def main():
226 env = os.environ.get("NAUTILUS_SCRIPT_SELECTED_FILE_PATHS", "")
227 if env.strip():
228 paths = [p for p in env.split("\n") if p]
229 elif len(sys.argv) > 1:
230 paths = sys.argv[1:]
231 else:
232 paths = [os.getcwd()]
233
234 use_zenity = shutil.which("zenity") is not None and bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
235
236 # Without zenity we prompt on the console, but a GUI launch (e.g. the
237 # Nautilus Scripts menu) has no console: confirm() would read EOF and abort
238 # with no feedback at all. Surface a desktop notification instead of
239 # silently doing nothing.
240 if not use_zenity and not sys.stdin.isatty():
241 _desktop_notify(
242 "Rename subtitles to match videos",
243 "This script needs 'zenity' to show its confirmation dialog.\n"
244 "Install it (e.g. 'sudo dnf install zenity') and try again.",
245 )
246 return 1
247
248 files = gather_files(paths)
249 if not files:
250 notify("No files found to process.", use_zenity, "warning")
251 return 1
252
253 renames, skips = plan_renames(files)
254
255 if not renames:
256 msg = "No subtitles needed renaming."
257 if skips:
258 msg += "\n\n" + build_message([], skips)
259 notify(msg, use_zenity, "info")
260 return 0
261
262 if not confirm(build_message(renames, skips), use_zenity):
263 return 0
264
265 done, failed = 0, []
266 for src, dst, old, new in renames:
267 try:
268 # Final guard against overwriting a file created in the meantime.
269 if os.path.exists(dst):
270 failed.append("%s (target appeared)" % old)
271 continue
272 os.rename(src, dst)
273 done += 1
274 except OSError as exc:
275 failed.append("%s (%s)" % (old, exc))
276
277 summary = "Renamed %d subtitle%s." % (done, "" if done == 1 else "s")
278 if skips:
279 summary += "\nSkipped %d." % len(skips)
280 if failed:
281 summary += "\nFailed %d:\n %s" % (len(failed), "\n ".join(failed))
282 notify(summary, use_zenity, "error" if failed else "info")
283 return 1 if failed else 0
284
285
286if __name__ == "__main__":
287 sys.exit(main())
288