| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | find_and_handle_duplicates_04.py |
| 4 | |
| 5 | Added hierarchical TV‑show aware scanning: |
| 6 | - Scan only *season* folders inside each show. |
| 7 | - Compare episode identifiers only within that season folder. |
| 8 | """ |
| 9 | |
| 10 | import os |
| 11 | import re |
| 12 | from pathlib import Path |
| 13 | from collections import defaultdict |
| 14 | |
| 15 | |
| 16 | def normalize_basename(name): |
| 17 | """Normalize a base name by lowercasing and removing non‑alphanumeric characters.""" |
| 18 | return re.sub(r"[^a-z0-9]", "", name.lower()) |
| 19 | |
| 20 | |
| 21 | def extract_episode_id(name): |
| 22 | """ |
| 23 | Extract the SxxExx episode identifier from a filename, if present. |
| 24 | Returns the identifier (e.g. 's01e01') or None if not found. |
| 25 | Handles formats: S01E01, s01e01, S01E01E02, etc. |
| 26 | """ |
| 27 | match = re.search(r"s\d{1,2}e\d{1,2}", name, re.IGNORECASE) |
| 28 | if match: |
| 29 | return match.group(0).lower() |
| 30 | return None |
| 31 | |
| 32 | |
| 33 | def get_file_size_mb(filepath): |
| 34 | """Get file size in MB.""" |
| 35 | size_bytes = os.path.getsize(filepath) |
| 36 | return size_bytes / (1024 * 1024) |
| 37 | |
| 38 | |
| 39 | def find_and_handle_duplicates(folder_path): |
| 40 | """Find duplicate episode files within each season folder and let user choose which to keep.""" |
| 41 | |
| 42 | # Check if folder exists |
| 43 | if not os.path.exists(folder_path): |
| 44 | print(f"Error: Folder '{folder_path}' does not exist.") |
| 45 | return |
| 46 | |
| 47 | # ------------------------------------------------------------------ |
| 48 | # 1️⃣ Walk the TV‑shows tree (show → season) |
| 49 | for show_root, _dirs, _files in os.walk(folder_path): |
| 50 | # We only care about *direct* children of `folder_path` that look like shows |
| 51 | if Path(show_root).resolve() == Path(folder_path).resolve(): |
| 52 | continue # skip the root itself |
| 53 | |
| 54 | # A show folder should contain sub‑folders named "Season XX" or similar. |
| 55 | for season_name in os.listdir(show_root): |
| 56 | season_path = os.path.join(show_root, season_name) |
| 57 | |
| 58 | if not os.path.isdir(season_path): |
| 59 | continue # ignore non‑folder items (e.g. stray files) |
| 60 | |
| 61 | # ────────────────────── NEW ────────────────────── |
| 62 | # Walk *inside* the season folder – this is where we look for duplicates. |
| 63 | files_by_key = defaultdict(list) |
| 64 | key_type = {} # key -> 'episode' or 'movie' |
| 65 | |
| 66 | for root, _dirs, files in os.walk(season_path): |
| 67 | for item in files: |
| 68 | if item.lower().endswith(".srt"): |
| 69 | continue |
| 70 | filepath = os.path.join(root, item) |
| 71 | if os.path.isfile(filepath): # sanity check – should always be true |
| 72 | basename = Path(item).stem |
| 73 | episode_id = extract_episode_id(basename) |
| 74 | |
| 75 | if episode_id: |
| 76 | key = episode_id |
| 77 | key_type[key] = "episode" |
| 78 | else: |
| 79 | key = normalize_basename(basename) |
| 80 | key_type[key] = "movie" |
| 81 | |
| 82 | files_by_key[key].append(filepath) |
| 83 | # ────────────────────── END NEW ────────────────────── |
| 84 | |
| 85 | # Find and process duplicates inside this season |
| 86 | duplicates_found = False |
| 87 | total_deleted_mb = 0.0 |
| 88 | total_kept_mb = 0.0 |
| 89 | |
| 90 | for key, filepaths in files_by_key.items(): |
| 91 | if len(filepaths) > 1: |
| 92 | duplicates_found = True |
| 93 | print(f"\n{'='*60}") |
| 94 | |
| 95 | if key_type[key] == "episode": |
| 96 | print( |
| 97 | f"Found {len(filepaths)} files for episode: '{key.upper()}' " |
| 98 | f"in season folder: '{season_path}'" |
| 99 | ) |
| 100 | else: |
| 101 | print( |
| 102 | f"Found {len(filepaths)} files with base name: '{key}' (normalized) " |
| 103 | f"in season folder: '{season_path}'" |
| 104 | ) |
| 105 | print("=" * 60) |
| 106 | |
| 107 | # Create list of (filepath, size) tuples and sort by size |
| 108 | file_info = [(fp, get_file_size_mb(fp)) for fp in filepaths] |
| 109 | file_info.sort(key=lambda x: x[1]) # Sort smallest to largest |
| 110 | |
| 111 | # Display files with their sizes |
| 112 | for idx, (filepath, size_mb) in enumerate(file_info, 1): |
| 113 | filename = os.path.basename(filepath) |
| 114 | print(f"{idx}. {filename}") |
| 115 | print(f" Size: {size_mb:.2f} MB") |
| 116 | |
| 117 | # For TV episodes, offer a one-key shortcut to keep the smallest file |
| 118 | if key_type[key] == "episode": |
| 119 | print( |
| 120 | f"\n[Tip] File #1 is the smallest. Press Enter to keep it and delete the rest." |
| 121 | ) |
| 122 | |
| 123 | # Ask user which to keep |
| 124 | while True: |
| 125 | prompt = f"\nWhich file do you want to KEEP? (1-{len(file_info)}, or 's' to skip): " |
| 126 | choice = input(prompt).strip().lower() |
| 127 | |
| 128 | # Empty Enter = keep smallest (episode shortcut) |
| 129 | if choice == "" and key_type[key] == "episode": |
| 130 | choice = "1" |
| 131 | |
| 132 | if choice == "s": |
| 133 | print("Skipping this group.") |
| 134 | for filepath, size_mb in file_info: |
| 135 | total_kept_mb += size_mb |
| 136 | break |
| 137 | |
| 138 | try: |
| 139 | choice_idx = int(choice) - 1 |
| 140 | if 0 <= choice_idx < len(file_info): |
| 141 | kept_file = file_info[choice_idx][0] |
| 142 | kept_size = file_info[choice_idx][1] |
| 143 | total_kept_mb += kept_size |
| 144 | print(f"\nKeeping: {os.path.basename(kept_file)}") |
| 145 | |
| 146 | for idx, (filepath, size_mb) in enumerate(file_info): |
| 147 | if idx != choice_idx: |
| 148 | try: |
| 149 | os.remove(filepath) |
| 150 | total_deleted_mb += size_mb |
| 151 | print( |
| 152 | f"Deleted: {os.path.basename(filepath)}" |
| 153 | ) |
| 154 | except Exception as e: |
| 155 | print( |
| 156 | f"Error deleting {os.path.basename(filepath)}: {e}" |
| 157 | ) |
| 158 | break |
| 159 | else: |
| 160 | print( |
| 161 | f"Please enter a number between 1 and {len(file_info)}." |
| 162 | ) |
| 163 | except ValueError: |
| 164 | print( |
| 165 | "Invalid input. Please enter a number or 's' to skip." |
| 166 | ) |
| 167 | |
| 168 | if not duplicates_found: |
| 169 | print( |
| 170 | "\nNo duplicate filenames found in this season folder (ignoring extensions)." |
| 171 | ) |
| 172 | |
| 173 | # Print summary stats for this season |
| 174 | total_original_mb = total_deleted_mb + total_kept_mb |
| 175 | pct_saved = ( |
| 176 | (total_deleted_mb / total_original_mb * 100) |
| 177 | if total_original_mb > 0 |
| 178 | else 0 |
| 179 | ) |
| 180 | total_space_saved_mb = total_deleted_mb - total_kept_mb |
| 181 | |
| 182 | print(f"\n{'='*60}") |
| 183 | print("SUMMARY") |
| 184 | print(f"Total deleted: {total_deleted_mb:>10.2f} MB") |
| 185 | print(f"Total kept/skipped: {total_kept_mb:>10.2f} MB") |
| 186 | print( |
| 187 | f"Total space saved: {total_space_saved_mb:>10.2f} MB ({pct_saved:.1f}%)" |
| 188 | ) |
| 189 | # ────────────────────── END PER‑SEASON LOOP ────────────────────── |
| 190 | # ------------------------------------------------------------------ |
| 191 | |
| 192 | |
| 193 | def main(): |
| 194 | folder_path = input("Enter the folder path to scan: ").strip() |
| 195 | |
| 196 | # Remove quotes if present (helpful for copy‑pasted paths) |
| 197 | folder_path = folder_path.strip('"').strip("'") |
| 198 | |
| 199 | print(f"\nScanning folder: {folder_path}\n") |
| 200 | find_and_handle_duplicates(folder_path) |
| 201 | print("\n" + "=" * 60) |
| 202 | print("Done!") |
| 203 | |
| 204 | |
| 205 | if __name__ == "__main__": |
| 206 | main() |
| 207 |