| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | Recursively finds files with double extensions in a folder and removes the first |
| 4 | (false) extension, keeping only the true/last extension. |
| 5 | |
| 6 | Example: |
| 7 | "The Office S08E24.mov.mp4" -> "The Office S08E24.mp4" |
| 8 | |
| 9 | # Preview changes without renaming anything (recommended first step) |
| 10 | python fix_double_extensions.py /path/to/folder --dry-run |
| 11 | |
| 12 | # Actually rename the files |
| 13 | python fix_double_extensions.py /path/to/folder |
| 14 | """ |
| 15 | |
| 16 | import os |
| 17 | import argparse |
| 18 | |
| 19 | |
| 20 | # Common media/file extensions to recognize as "real" extensions |
| 21 | KNOWN_EXTENSIONS = { |
| 22 | ".mp4", |
| 23 | ".mkv", |
| 24 | ".avi", |
| 25 | ".mov", |
| 26 | ".wmv", |
| 27 | ".flv", |
| 28 | ".webm", |
| 29 | ".m4v", |
| 30 | ".mp3", |
| 31 | ".aac", |
| 32 | ".flac", |
| 33 | ".wav", |
| 34 | ".ogg", |
| 35 | ".m4a", |
| 36 | ".wma", |
| 37 | ".jpg", |
| 38 | ".jpeg", |
| 39 | ".png", |
| 40 | ".gif", |
| 41 | ".bmp", |
| 42 | ".tiff", |
| 43 | ".webp", |
| 44 | ".heic", |
| 45 | ".pdf", |
| 46 | ".doc", |
| 47 | ".docx", |
| 48 | ".xls", |
| 49 | ".xlsx", |
| 50 | ".ppt", |
| 51 | ".pptx", |
| 52 | ".zip", |
| 53 | ".tar", |
| 54 | ".gz", |
| 55 | ".rar", |
| 56 | ".7z", |
| 57 | ".py", |
| 58 | ".js", |
| 59 | ".ts", |
| 60 | ".html", |
| 61 | ".css", |
| 62 | ".json", |
| 63 | ".xml", |
| 64 | ".csv", |
| 65 | ".txt", |
| 66 | ".srt", |
| 67 | ".sub", |
| 68 | ".ass", |
| 69 | ".vtt", |
| 70 | } |
| 71 | |
| 72 | |
| 73 | def find_double_extension_files(root_dir): |
| 74 | """Yield (filepath, new_name) tuples for files with double extensions.""" |
| 75 | for dirpath, _, filenames in os.walk(root_dir): |
| 76 | for filename in filenames: |
| 77 | name, ext1 = os.path.splitext(filename) |
| 78 | if not ext1: |
| 79 | continue |
| 80 | _, ext2 = os.path.splitext(name) |
| 81 | if ext2 and ext2.lower() in KNOWN_EXTENSIONS: |
| 82 | # Double extension detected: ext2 is the false one, ext1 is the real one |
| 83 | new_name = name[: -len(ext2)] + ext1 |
| 84 | filepath = os.path.join(dirpath, filename) |
| 85 | yield filepath, new_name |
| 86 | |
| 87 | |
| 88 | def main(): |
| 89 | parser = argparse.ArgumentParser( |
| 90 | description="Remove false double extensions from filenames recursively." |
| 91 | ) |
| 92 | parser.add_argument("folder", help="Root folder to search") |
| 93 | parser.add_argument( |
| 94 | "--dry-run", |
| 95 | action="store_true", |
| 96 | help="Show what would be renamed without actually renaming", |
| 97 | ) |
| 98 | args = parser.parse_args() |
| 99 | |
| 100 | if not os.path.isdir(args.folder): |
| 101 | print(f"Error: '{args.folder}' is not a valid directory.") |
| 102 | return |
| 103 | |
| 104 | matches = list(find_double_extension_files(args.folder)) |
| 105 | |
| 106 | if not matches: |
| 107 | print("No files with double extensions found.") |
| 108 | return |
| 109 | |
| 110 | print( |
| 111 | f"{'DRY RUN — ' if args.dry_run else ''}Found {len(matches)} file(s) to rename:\n" |
| 112 | ) |
| 113 | |
| 114 | for filepath, new_name in matches: |
| 115 | dirpath = os.path.dirname(filepath) |
| 116 | new_path = os.path.join(dirpath, new_name) |
| 117 | old_name = os.path.basename(filepath) |
| 118 | |
| 119 | print(f" {old_name}") |
| 120 | print(f" -> {new_name}\n") |
| 121 | |
| 122 | if not args.dry_run: |
| 123 | if os.path.exists(new_path): |
| 124 | print(f" [SKIPPED] Target already exists: {new_path}\n") |
| 125 | else: |
| 126 | os.rename(filepath, new_path) |
| 127 | |
| 128 | if args.dry_run: |
| 129 | print( |
| 130 | "Dry run complete. No files were renamed. Remove --dry-run to apply changes." |
| 131 | ) |
| 132 | else: |
| 133 | print("Done.") |
| 134 | |
| 135 | |
| 136 | if __name__ == "__main__": |
| 137 | main() |
| 138 |