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 531bd523b0612bee65747d544f0ad742875a091a

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