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.

Grégoire Détrez revised this gist 3 months ago. Go to revision

1 file changed, 3 insertions

README.md

@@ -9,6 +9,9 @@ files, letting players (GNOME Videos, VLC) auto-load them.
9 9 `Season 1 Episode 2`, or a lone `E02`).
10 10 - Falls back to **1-to-1** matching when a folder has a single video and a
11 11 single subtitle with no episode markers.
12 + - **Handles still-downloading videos**: a `Show.S01E02.mkv.part` is matched as
13 + if already complete, so the subtitle is named for the finished file
14 + (`Show.S01E02.srt`, not `Show.S01E02.mkv.srt`).
12 15 - **Preserves language codes**: `sub.en.srt` matched to `Movie.mkv` →
13 16 `Movie.en.srt`.
14 17 - **Never overwrites** an existing file — such cases are reported as skipped.

Grégoire Détrez revised this gist 3 months ago. Go to revision

1 file changed, 30 insertions, 6 deletions

rename-subtitles

@@ -4,7 +4,9 @@
4 4 Designed as a GNOME/Nautilus right-click script, but also runnable from a
5 5 terminal. Subtitles are paired to videos by episode number (S01E02, 1x02, ...)
6 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
7 + subtitle. 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
8 10 existing files are never overwritten. A Zenity dialog lists the planned renames
9 11 for confirmation before anything happens.
10 12
@@ -29,6 +31,10 @@ SUBTITLE_EXTS = {
29 31 ".srt", ".sub", ".ssa", ".ass", ".vtt", ".idx", ".sup",
30 32 }
31 33
34 + # Suffix appended to still-downloading files (Firefox, wget, ...). A video
35 + # named `Show.S01E02.mkv.part` is treated as `Show.S01E02.mkv`.
36 + PART_EXT = ".part"
37 +
32 38 # Language codes we recognise as a trailing token and preserve.
33 39 LANG_CODES = {
34 40 # ISO 639-1 (2-letter) — common subset
@@ -101,14 +107,32 @@ def gather_files(paths):
101 107 return files
102 108
103 109
110 + def 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 +
104 123 def classify(files):
105 124 videos, subs = [], []
106 125 for f in files:
107 - ext = os.path.splitext(f)[1].lower()
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()
108 134 if ext in VIDEO_EXTS:
109 135 videos.append(f)
110 - elif ext in SUBTITLE_EXTS:
111 - subs.append(f)
112 136 return videos, subs
113 137
114 138
@@ -158,8 +182,8 @@ def plan_renames(files):
158 182 continue
159 183
160 184 _, 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
185 + stem = video_stem(video)
186 + new_name = stem + (("." + lang) if lang else "") + sub_ext
163 187 dst = os.path.join(os.path.dirname(sub), new_name)
164 188
165 189 if os.path.abspath(dst) == os.path.abspath(sub):

x revised this gist 3 months ago. Go to revision

1 file changed, 25 insertions, 9 deletions

rename-subtitles

@@ -19,6 +19,7 @@ import re
19 19 import shutil
20 20 import subprocess
21 21 import sys
22 + from xml.sax.saxutils import escape as _xml_escape
22 23
23 24 VIDEO_EXTS = {
24 25 ".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv",
@@ -54,8 +55,6 @@ _EP_PATTERNS = [
54 55 re.compile(r"[Ss]eason[\._ -]?(\d{1,2})[\._ -]?"
55 56 r"[Ee]pisode[\._ -]?(\d{1,3})"), # Season 1 Episode 2
56 57 ]
57 - # Lighter fallback: a lone episode marker, treated as season 1.
58 - _EP_ONLY = re.compile(r"(?:^|[\._ -])[Ee](\d{1,3})(?:[\._ -]|$)")
59 58
60 59
61 60 def episode_key(name):
@@ -65,9 +64,6 @@ def episode_key(name):
65 64 m = pat.search(stem)
66 65 if m:
67 66 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 67 return None
72 68
73 69
@@ -139,9 +135,11 @@ def plan_renames(files):
139 135 for k in dup_keys:
140 136 video_by_key.pop(k, None)
141 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`).
142 141 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)
142 + and episode_key(os.path.basename(subs[0])) is None)
145 143
146 144 planned_dsts = set()
147 145 for sub in subs:
@@ -190,6 +188,12 @@ def build_message(renames, skips):
190 188 return "\n".join(lines)
191 189
192 190
191 + def _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 +
193 197 def confirm(message, use_zenity):
194 198 """Return True if the user approves the plan."""
195 199 if use_zenity:
@@ -197,7 +201,7 @@ def confirm(message, use_zenity):
197 201 ["zenity", "--question", "--no-wrap",
198 202 "--title", "Rename subtitles to match videos",
199 203 "--ok-label", "Rename", "--cancel-label", "Cancel",
200 - "--text", message],
204 + "--text", _xml_escape(message)],
201 205 ).returncode
202 206 return rc == 0
203 207 print(message)
@@ -212,7 +216,7 @@ def notify(message, use_zenity, kind="info"):
212 216 subprocess.run(
213 217 ["zenity", "--" + kind, "--no-wrap",
214 218 "--title", "Rename subtitles to match videos",
215 - "--text", message],
219 + "--text", _xml_escape(message)],
216 220 )
217 221 else:
218 222 print(message)
@@ -229,6 +233,18 @@ def main():
229 233
230 234 use_zenity = shutil.which("zenity") is not None and bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
231 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 +
232 248 files = gather_files(paths)
233 249 if not files:
234 250 notify("No files found to process.", use_zenity, "warning")

x revised this gist 3 months ago. Go to revision

2 files changed, 318 insertions

README.md(file created)

@@ -0,0 +1,47 @@
1 + # rename-subtitles
2 +
3 + A GNOME/Nautilus script that renames subtitle files so they match their video
4 + files, letting players (GNOME Videos, VLC) auto-load them.
5 +
6 + ## What it does
7 +
8 + - Pairs subtitles to videos **by episode number** (`S01E02`, `1x02`,
9 + `Season 1 Episode 2`, or a lone `E02`).
10 + - Falls back to **1-to-1** matching when a folder has a single video and a
11 + single subtitle with no episode markers.
12 + - **Preserves language codes**: `sub.en.srt` matched to `Movie.mkv` →
13 + `Movie.en.srt`.
14 + - **Never overwrites** an existing file — such cases are reported as skipped.
15 + - Shows a **Zenity confirmation dialog** listing every planned rename before
16 + touching anything.
17 +
18 + Video extensions: mkv mp4 avi m4v mov wmv flv webm mpg mpeg ts m2ts
19 + Subtitle extensions: srt sub ssa ass vtt idx sup
20 +
21 + ## Install (Nautilus right-click)
22 +
23 + ```sh
24 + cp rename-subtitles ~/.local/share/nautilus/scripts/"Rename subtitles to match videos"
25 + chmod +x ~/.local/share/nautilus/scripts/"Rename subtitles to match videos"
26 + nautilus -q # reload the Scripts menu (quits open Files windows)
27 + ```
28 +
29 + Then in **Files**: select the folder (or the video + subtitle files),
30 + right-click → **Scripts → Rename subtitles to match videos**.
31 +
32 + ### Dependency
33 +
34 + The confirmation dialog needs **zenity**:
35 +
36 + ```sh
37 + sudo dnf install zenity # Fedora
38 + ```
39 +
40 + Without zenity the script still works from a terminal (it prompts on the
41 + console), but the right-click dialog will not appear.
42 +
43 + ## Command-line usage
44 +
45 + ```sh
46 + ./rename-subtitles [PATH ...] # files and/or folders; defaults to cwd
47 + ```

rename-subtitles(file created)

@@ -0,0 +1,271 @@
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())
Newer Older