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()) | |