#!/usr/bin/env python3 """Rename subtitle files to match their corresponding video files. Designed as a GNOME/Nautilus right-click script, but also runnable from a terminal. Subtitles are paired to videos by episode number (S01E02, 1x02, ...) with a fallback to 1-to-1 matching when a folder holds a single video + single subtitle. Trailing language codes (e.g. `.en`, `.eng`) are preserved and existing files are never overwritten. A Zenity dialog lists the planned renames for confirmation before anything happens. Invocation: * From Nautilus: reads NAUTILUS_SCRIPT_SELECTED_FILE_PATHS. * From a terminal: `./rename-subtitles [PATH ...]` (files and/or folders). With no arguments it operates on the current directory. """ import os import re import shutil import subprocess import sys from xml.sax.saxutils import escape as _xml_escape VIDEO_EXTS = { ".mkv", ".mp4", ".avi", ".m4v", ".mov", ".wmv", ".flv", ".webm", ".mpg", ".mpeg", ".ts", ".m2ts", } SUBTITLE_EXTS = { ".srt", ".sub", ".ssa", ".ass", ".vtt", ".idx", ".sup", } # Language codes we recognise as a trailing token and preserve. LANG_CODES = { # ISO 639-1 (2-letter) — common subset "en", "fr", "es", "de", "it", "nl", "pt", "sv", "da", "no", "fi", "pl", "ru", "cs", "hu", "ro", "tr", "el", "he", "ar", "ja", "ko", "zh", "hi", "th", "vi", "id", "uk", "bg", "hr", "sr", "sk", "sl", "et", "lv", "lt", "fa", # ISO 639-2 (3-letter) and common English names "eng", "english", "fre", "fra", "french", "spa", "esp", "spanish", "ger", "deu", "german", "ita", "italian", "dut", "nld", "dutch", "por", "portuguese", "swe", "swedish", "dan", "danish", "nor", "norwegian", "fin", "finnish", "pol", "polish", "rus", "russian", "cze", "ces", "czech", "hun", "hungarian", "rum", "ron", "romanian", "tur", "turkish", "gre", "ell", "greek", "heb", "hebrew", "ara", "arabic", "jpn", "japanese", "kor", "korean", "chi", "zho", "chinese", "hin", "hindi", "tha", "thai", "vie", "vietnamese", "ind", "indonesian", "ukr", "ukrainian", } # Episode-key regexes, tried in order. Each must yield (season, episode). _EP_PATTERNS = [ re.compile(r"[Ss](\d{1,2})[\._ -]?[Ee](\d{1,3})"), # S01E02, s1e2, S01.E02 re.compile(r"(? video (skip ambiguous duplicate keys). video_by_key = {} dup_keys = set() for v in videos: k = episode_key(os.path.basename(v)) if k is None: continue if k in video_by_key: dup_keys.add(k) else: video_by_key[k] = v for k in dup_keys: video_by_key.pop(k, None) # One video + one subtitle: pair them directly when the subtitle carries no # episode marker to match on. The video may still have one (e.g. a lone # `Show.S01E01.mkv` next to a bare `english.srt`). single_fallback = (len(videos) == 1 and len(subs) == 1 and episode_key(os.path.basename(subs[0])) is None) planned_dsts = set() for sub in subs: subname = os.path.basename(sub) subkey = episode_key(subname) if subkey is not None and subkey in video_by_key: video = video_by_key[subkey] elif subkey is not None and subkey in dup_keys: skips.append((subname, "multiple videos share this episode number")) continue elif single_fallback: video = videos[0] else: skips.append((subname, "no matching video found")) continue _, lang, sub_ext = split_lang(subname) video_stem = os.path.splitext(os.path.basename(video))[0] new_name = video_stem + (("." + lang) if lang else "") + sub_ext dst = os.path.join(os.path.dirname(sub), new_name) if os.path.abspath(dst) == os.path.abspath(sub): skips.append((subname, "already named correctly")) continue if os.path.exists(dst) or dst in planned_dsts: skips.append((subname, "target '%s' already exists" % new_name)) continue planned_dsts.add(dst) renames.append((sub, dst, subname, new_name)) return renames, skips def build_message(renames, skips): lines = [] if renames: lines.append("The following subtitles will be renamed:\n") for _, _, old, new in renames: lines.append(" %s\n → %s\n" % (old, new)) if skips: lines.append("\nSkipped:") for name, reason in skips: lines.append(" %s (%s)" % (name, reason)) return "\n".join(lines) def _desktop_notify(title, body): """Best-effort desktop notification when no dialog/console is available.""" if shutil.which("notify-send"): subprocess.run(["notify-send", title, body]) def confirm(message, use_zenity): """Return True if the user approves the plan.""" if use_zenity: rc = subprocess.run( ["zenity", "--question", "--no-wrap", "--title", "Rename subtitles to match videos", "--ok-label", "Rename", "--cancel-label", "Cancel", "--text", _xml_escape(message)], ).returncode return rc == 0 print(message) try: return input("\nProceed with rename? [y/N] ").strip().lower() in ("y", "yes") except EOFError: return False def notify(message, use_zenity, kind="info"): if use_zenity: subprocess.run( ["zenity", "--" + kind, "--no-wrap", "--title", "Rename subtitles to match videos", "--text", _xml_escape(message)], ) else: print(message) def main(): env = os.environ.get("NAUTILUS_SCRIPT_SELECTED_FILE_PATHS", "") if env.strip(): paths = [p for p in env.split("\n") if p] elif len(sys.argv) > 1: paths = sys.argv[1:] else: paths = [os.getcwd()] use_zenity = shutil.which("zenity") is not None and bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) # Without zenity we prompt on the console, but a GUI launch (e.g. the # Nautilus Scripts menu) has no console: confirm() would read EOF and abort # with no feedback at all. Surface a desktop notification instead of # silently doing nothing. if not use_zenity and not sys.stdin.isatty(): _desktop_notify( "Rename subtitles to match videos", "This script needs 'zenity' to show its confirmation dialog.\n" "Install it (e.g. 'sudo dnf install zenity') and try again.", ) return 1 files = gather_files(paths) if not files: notify("No files found to process.", use_zenity, "warning") return 1 renames, skips = plan_renames(files) if not renames: msg = "No subtitles needed renaming." if skips: msg += "\n\n" + build_message([], skips) notify(msg, use_zenity, "info") return 0 if not confirm(build_message(renames, skips), use_zenity): return 0 done, failed = 0, [] for src, dst, old, new in renames: try: # Final guard against overwriting a file created in the meantime. if os.path.exists(dst): failed.append("%s (target appeared)" % old) continue os.rename(src, dst) done += 1 except OSError as exc: failed.append("%s (%s)" % (old, exc)) summary = "Renamed %d subtitle%s." % (done, "" if done == 1 else "s") if skips: summary += "\nSkipped %d." % len(skips) if failed: summary += "\nFailed %d:\n %s" % (len(failed), "\n ".join(failed)) notify(summary, use_zenity, "error" if failed else "info") return 1 if failed else 0 if __name__ == "__main__": sys.exit(main())