From f0525dcefad011be9058ad6a183590effcd4c855 Mon Sep 17 00:00:00 2001 From: Justin Oro Date: Sun, 30 Aug 2026 16:21:56 -0700 Subject: [PATCH] move duplicate movie folders to a DUPLICATES staging folder on the share instead of trashing directly from rename; cleanup wipes it --- README.md | 2 + plex-library-tool.py | 172 +++++++++++++++++++++++++++++++------------ 2 files changed, 126 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index c5dff18..bbc9dd6 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This guide assumes you've never used Python or GitHub before. If you already kno - Renames video files to match, and organizes TV episodes into `S01`, `S02`, etc. season folders - Detects loose movie or TV episode files sitting directly in the share root (no folder of their own) and organizes them into a proper movie or show folder - Detects a duplicate show folder for a show you already have (e.g. a separately-downloaded "Show S02" folder) and merges its episodes into the existing show folder instead of creating a second one +- Detects a duplicate movie folder (a re-download that resolves to a movie you already have) and moves it into a `DUPLICATES` folder at the top of the share, rather than overwriting or leaving it loose. Cleanup (`-c`) picks up `DUPLICATES` and moves it to trash the same as anything else - Checks your organized TV shows against TMDb's episode list and reports any already-aired episodes you're missing (`-e`), without changing anything - Understands anime-style absolute episode numbering (episodes numbered 1, 2, 3... straight through instead of per-season) when TMDb has that show's episode order data, converting them to the correct season/episode automatically - Finds subtitle files, figures out which one matches your primary language (by filename, and by reading the file's content/metadata if the filename doesn't say), and renames it to match the video. Defaults to English, but follows whatever language you've set for TMDb results (see [Non-English users](#non-english-users)). @@ -204,6 +205,7 @@ Use any TMDb-supported language code (ISO 639-1, optionally with a region, e.g. - **Nothing is renamed without asking first**, unless you pass `-y`. - **Every rename is logged** to a `logs/` folder created next to the script. - **Cleanup never permanently deletes anything.** Matched files/folders are moved to a local `.trash/` folder, not deleted, and you're prompted per item unless `-y` is used. +- **Rename never deletes anything either.** A duplicate movie folder is moved into a `DUPLICATES` folder on the share, not removed. It only leaves the share once cleanup (`-c`) moves it to trash, same as everything else. - **Undo anytime** with `-u` (most recent run) or `--restore ` (any past run). - **Your TMDb API key is never uploaded anywhere.** It's stored locally in a `.env` file, which is excluded from Git via `.gitignore`. diff --git a/plex-library-tool.py b/plex-library-tool.py index fdfcd93..ed34c7d 100755 --- a/plex-library-tool.py +++ b/plex-library-tool.py @@ -32,6 +32,7 @@ TMDB_BASE = "https://api.themoviedb.org/3" DELETE_FILE = SCRIPT_DIR / "delete.yaml" NAMES_FILE = SCRIPT_DIR / "names.yaml" CLEANUP_TRASH_DIR = SCRIPT_DIR / ".trash" +DUPLICATES_FOLDER_NAME = "DUPLICATES" SERVICE_CONFIG_FILE = SCRIPT_DIR / "service.json" SERVICE_LOG_FILE = LOG_DIR / "service.log" DEFAULT_SERVICE_INTERVAL = 300 @@ -1335,19 +1336,37 @@ def lookup_folder(api_key, media_type, raw_name, hint_year=None): query = hyphen_query if not match and year: - words = query.split() - if len(words) > 1: - broadened_query = " ".join(words[:-1]) - vprint(f" No match, retrying with broadened query (possible title typo): query={broadened_query!r}") + broadened_words = query.split() + while not match and len(broadened_words) > 1: + broadened_words = broadened_words[:-1] + broadened_query = " ".join(broadened_words) + vprint(f" No match, retrying with broadened query (possible title typo or trailing extra words): query={broadened_query!r}") broad_results, broad_err = tmdb_search(api_key, media_type, broadened_query) - if not broad_err: - vprint(f" TMDb returned {len(broad_results)} result(s)") - fuzzy = fuzzy_title_match(media_type, broad_results, query, year) - if fuzzy: - fuzzy_match, ratio = fuzzy - vprint(f" Fuzzy title match: {result_title(media_type, fuzzy_match)!r} (ratio={ratio:.2f}, id={fuzzy_match.get('id')})") - match = fuzzy_match - results = broad_results + if broad_err: + break + vprint(f" TMDb returned {len(broad_results)} result(s)") + fuzzy = fuzzy_title_match(media_type, broad_results, broadened_query, year) + if fuzzy: + fuzzy_match, ratio = fuzzy + vprint(f" Fuzzy title match: {result_title(media_type, fuzzy_match)!r} (ratio={ratio:.2f}, id={fuzzy_match.get('id')})") + match = fuzzy_match + results = broad_results + + if not match and not year: + broadened_words = query.split() + while not match and len(broadened_words) > 1: + broadened_words = broadened_words[:-1] + broadened_query = " ".join(broadened_words) + vprint(f" No match, retrying with broadened query (no year, possible extra words): query={broadened_query!r}") + broad_results, broad_err = tmdb_search(api_key, media_type, broadened_query) + if broad_err: + break + vprint(f" TMDb returned {len(broad_results)} result(s)") + candidate = best_match(media_type, broad_results, None, broadened_query) + if candidate: + vprint(f" Best match: {result_title(media_type, candidate)!r} (id={candidate.get('id')})") + match = candidate + results = broad_results if not match and year: numeric_stripped_query = squeeze_spaces(re.sub(r'\b\d{1,2}\b', ' ', query)) @@ -1757,6 +1776,12 @@ def duplicate_trash_path(item): return unique_destination(dest_dir / item.name) +def duplicate_folder_staging_path(share, item): + dest_dir = Path(share) / DUPLICATES_FOLDER_NAME + dest_dir.mkdir(parents=True, exist_ok=True) + return unique_destination(dest_dir / item.name) + + def compute_consolidated_subtitle_pairs(subtitles, new_video_path): new_stem = new_video_path.stem target_dir = new_video_path.parent / subtitle_folder_name() @@ -1972,6 +1997,10 @@ def preview_subtitle_folder(folder): print(f"Renamed folder: {entry.name} -> {canonical_name}") +def is_library_content_folder(p): + return p.is_dir() and not p.name.startswith(".") and p.name.upper() != DUPLICATES_FOLDER_NAME + + def list_subfolders(folder): return sorted(p for p in folder.iterdir() if p.is_dir() and not p.name.startswith(".")) @@ -2150,7 +2179,7 @@ def find_matching_episode_video(target_season_dir, episode): def build_movie_folder_index(share): index = [] - for folder in sorted(p for p in Path(share).iterdir() if p.is_dir() and not p.name.startswith(".")): + for folder in sorted(p for p in Path(share).iterdir() if is_library_content_folder(p)): videos = list_video_files(folder) if len(videos) != 1: continue @@ -2161,27 +2190,36 @@ def build_movie_folder_index(share): def match_movie_from_index(index, query, year): - best = None - best_video = None - best_ratio = 0.0 + words = query.split() - for folder, video, folder_year, folder_query in index: - if year and folder_year and abs(int(folder_year) - int(year)) > NEAR_YEAR_MAX_DIFF: - continue - ratio = difflib.SequenceMatcher(None, query.lower(), folder_query.lower()).ratio() - if ratio > best_ratio: - best_ratio = ratio - best = folder - best_video = video + while words: + candidate = " ".join(words) + best = None + best_video = None + best_ratio = 0.0 + + for folder, video, folder_year, folder_query in index: + if year and folder_year and abs(int(folder_year) - int(year)) > NEAR_YEAR_MAX_DIFF: + continue + ratio = difflib.SequenceMatcher(None, candidate.lower(), folder_query.lower()).ratio() + if ratio > best_ratio: + best_ratio = ratio + best = folder + best_video = video + + if best is not None and best_ratio >= NEAR_YEAR_MATCH_MIN_SIMILARITY: + return best, best_video + + if len(words) <= 2: + break + words = words[:-1] - if best is not None and best_ratio >= NEAR_YEAR_MATCH_MIN_SIMILARITY: - return best, best_video return None, None def build_show_folder_index(share): index = [] - for folder in sorted(p for p in Path(share).iterdir() if p.is_dir() and not p.name.startswith(".")): + for folder in sorted(p for p in Path(share).iterdir() if is_library_content_folder(p)): folder_year = extract_year(folder.name) folder_query = build_query(folder.name, folder_year) index.append((folder, folder_year, folder_query)) @@ -2189,19 +2227,28 @@ def build_show_folder_index(share): def match_show_from_index(index, query, year): - best = None - best_ratio = 0.0 + words = query.split() - for folder, folder_year, folder_query in index: - if year and folder_year and abs(int(folder_year) - int(year)) > NEAR_YEAR_MAX_DIFF: - continue - ratio = difflib.SequenceMatcher(None, query.lower(), folder_query.lower()).ratio() - if ratio > best_ratio: - best_ratio = ratio - best = folder + while words: + candidate = " ".join(words) + best = None + best_ratio = 0.0 + + for folder, folder_year, folder_query in index: + if year and folder_year and abs(int(folder_year) - int(year)) > NEAR_YEAR_MAX_DIFF: + continue + ratio = difflib.SequenceMatcher(None, candidate.lower(), folder_query.lower()).ratio() + if ratio > best_ratio: + best_ratio = ratio + best = folder + + if best is not None and best_ratio >= NEAR_YEAR_MATCH_MIN_SIMILARITY: + return best + + if len(words) <= 2: + break + words = words[:-1] - if best is not None and best_ratio >= NEAR_YEAR_MATCH_MIN_SIMILARITY: - return best return None @@ -2909,6 +2956,11 @@ def find_cleanup_targets(share, delete_folder_names, delete_file_patterns, keep_ unchanged_folder_names = unchanged_folder_names or set() share_root = Path(share) + duplicates_dir = share_root / DUPLICATES_FOLDER_NAME + if duplicates_dir.is_dir(): + vprint(f" Match (duplicates staging folder): {duplicates_dir}") + targets.append(("folder", duplicates_dir)) + def walk(folder): vprint(f"Scanning folder: {folder}") try: @@ -2918,6 +2970,8 @@ def find_cleanup_targets(share, delete_folder_names, delete_file_patterns, keep_ return for entry in entries: if entry.is_dir(): + if entry == duplicates_dir: + continue if matches_folder_name(entry.name, delete_folder_names): vprint(f" Match (folder name): {entry}") targets.append(("folder", entry)) @@ -3012,7 +3066,9 @@ def run_cleanup(args, log): vprint(f"Loaded {len(delete_file_patterns)} file pattern(s) from {DELETE_FILE.name}: {delete_file_patterns}") vprint(f"Loaded subtitle rules: keep={keep_languages or 'none'}, delete={delete_languages or 'none'}") - if not delete_folder_names and not delete_file_patterns and not keep_languages and not delete_languages: + has_duplicates_folder = (Path(share) / DUPLICATES_FOLDER_NAME).is_dir() + + if not delete_folder_names and not delete_file_patterns and not keep_languages and not delete_languages and not has_duplicates_folder: print("No cleanup rules configured.") print(f"Edit {DELETE_FILE.name} to enable cleanup.") return @@ -3030,7 +3086,7 @@ def run_cleanup(args, log): share_path = Path(share) try: top_level_folders = [ - p for p in share_path.iterdir() if p.is_dir() and not p.name.startswith(".") + p for p in share_path.iterdir() if is_library_content_folder(p) ] except OSError: top_level_folders = [] @@ -3500,7 +3556,7 @@ def run_scan(args, log): subfolders = [share_path] else: subfolders = sorted( - p for p in share_path.iterdir() if p.is_dir() and not p.name.startswith(".") + p for p in share_path.iterdir() if is_library_content_folder(p) ) loose_files = [] if (is_single_show or is_single_movie) else list_loose_video_files(share) @@ -3570,9 +3626,14 @@ def run_scan(args, log): new_folder.is_dir() or folder_name in simulated_tv_folder_names ) and not same_existing_path(new_folder, folder) - if media_type == "tv" and needs_rename and target_already_taken: - print(f"Would merge into existing show folder: {raw_name} -> {folder_name}") - preview_merge_duplicate_show_folder(folder, new_folder, raw_name, final_name) + if needs_rename and target_already_taken: + if media_type == "tv": + print(f"Would merge into existing show folder: {raw_name} -> {folder_name}") + preview_merge_duplicate_show_folder(folder, new_folder, raw_name, final_name) + elif list_video_files(new_folder): + print(f"Would move duplicate folder to {DUPLICATES_FOLDER_NAME}/: {raw_name} (already exists as {folder_name})") + else: + print(f"Skipping (target already exists): {raw_name} -> {folder_name}") examples_shown += 1 continue @@ -3610,8 +3671,23 @@ def run_scan(args, log): continue if new_folder.exists() and not same_existing_path(new_folder, folder): - print(f"Skipping (target already exists): {raw_name} -> {folder_name}") - folders_skipped += 1 + if list_video_files(new_folder): + if args.yes or confirm(f"'{folder_name}' already exists. Move duplicate folder '{raw_name}' to {DUPLICATES_FOLDER_NAME}/?"): + staged_dest = duplicate_folder_staging_path(share, folder) + ok, err = safe_move(folder, staged_dest) + if ok: + log.record(folder, staged_dest) + print(f"Moved duplicate folder to {DUPLICATES_FOLDER_NAME}/: {raw_name}") + folders_renamed += 1 + else: + print(f"Skipping (could not move to {DUPLICATES_FOLDER_NAME}/: {err}): {raw_name}") + folders_skipped += 1 + else: + print(f"Skipped: {raw_name}") + folders_skipped += 1 + else: + print(f"Skipping (target already exists): {raw_name} -> {folder_name}") + folders_skipped += 1 continue ok, err = safe_rename(folder, new_folder) @@ -3667,7 +3743,7 @@ def run_scan(args, log): print(f"Log saved: {log_path}") current_names = { - p.name for p in Path(share).iterdir() if p.is_dir() and not p.name.startswith(".") + p.name for p in Path(share).iterdir() if is_library_content_folder(p) } baseline = {name: sig for name, sig in baseline.items() if name in current_names} cache[share_key] = baseline @@ -3830,7 +3906,7 @@ def run_episode_check(args): shows = [share_path] else: shows = sorted( - p for p in share_path.iterdir() if p.is_dir() and not p.name.startswith(".") + p for p in share_path.iterdir() if is_library_content_folder(p) ) checked = 0