move duplicate movie folders to a DUPLICATES staging folder on the share instead of trashing directly from rename; cleanup wipes it

This commit is contained in:
Justin Oro
2026-08-30 16:21:56 -07:00
parent b1d713903b
commit f0525dcefa
2 changed files with 126 additions and 48 deletions
+2
View File
@@ -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 - 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 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 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 - 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 - 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)). - 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`. - **Nothing is renamed without asking first**, unless you pass `-y`.
- **Every rename is logged** to a `logs/` folder created next to the script. - **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. - **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 <logfile>` (any past run). - **Undo anytime** with `-u` (most recent run) or `--restore <logfile>` (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`. - **Your TMDb API key is never uploaded anywhere.** It's stored locally in a `.env` file, which is excluded from Git via `.gitignore`.
+92 -16
View File
@@ -32,6 +32,7 @@ TMDB_BASE = "https://api.themoviedb.org/3"
DELETE_FILE = SCRIPT_DIR / "delete.yaml" DELETE_FILE = SCRIPT_DIR / "delete.yaml"
NAMES_FILE = SCRIPT_DIR / "names.yaml" NAMES_FILE = SCRIPT_DIR / "names.yaml"
CLEANUP_TRASH_DIR = SCRIPT_DIR / ".trash" CLEANUP_TRASH_DIR = SCRIPT_DIR / ".trash"
DUPLICATES_FOLDER_NAME = "DUPLICATES"
SERVICE_CONFIG_FILE = SCRIPT_DIR / "service.json" SERVICE_CONFIG_FILE = SCRIPT_DIR / "service.json"
SERVICE_LOG_FILE = LOG_DIR / "service.log" SERVICE_LOG_FILE = LOG_DIR / "service.log"
DEFAULT_SERVICE_INTERVAL = 300 DEFAULT_SERVICE_INTERVAL = 300
@@ -1335,20 +1336,38 @@ def lookup_folder(api_key, media_type, raw_name, hint_year=None):
query = hyphen_query query = hyphen_query
if not match and year: if not match and year:
words = query.split() broadened_words = query.split()
if len(words) > 1: while not match and len(broadened_words) > 1:
broadened_query = " ".join(words[:-1]) broadened_words = broadened_words[:-1]
vprint(f" No match, retrying with broadened query (possible title typo): query={broadened_query!r}") 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) broad_results, broad_err = tmdb_search(api_key, media_type, broadened_query)
if not broad_err: if broad_err:
break
vprint(f" TMDb returned {len(broad_results)} result(s)") vprint(f" TMDb returned {len(broad_results)} result(s)")
fuzzy = fuzzy_title_match(media_type, broad_results, query, year) fuzzy = fuzzy_title_match(media_type, broad_results, broadened_query, year)
if fuzzy: if fuzzy:
fuzzy_match, ratio = 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')})") vprint(f" Fuzzy title match: {result_title(media_type, fuzzy_match)!r} (ratio={ratio:.2f}, id={fuzzy_match.get('id')})")
match = fuzzy_match match = fuzzy_match
results = broad_results 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: if not match and year:
numeric_stripped_query = squeeze_spaces(re.sub(r'\b\d{1,2}\b', ' ', query)) numeric_stripped_query = squeeze_spaces(re.sub(r'\b\d{1,2}\b', ' ', query))
if numeric_stripped_query and numeric_stripped_query != query: if numeric_stripped_query and numeric_stripped_query != query:
@@ -1757,6 +1776,12 @@ def duplicate_trash_path(item):
return unique_destination(dest_dir / item.name) 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): def compute_consolidated_subtitle_pairs(subtitles, new_video_path):
new_stem = new_video_path.stem new_stem = new_video_path.stem
target_dir = new_video_path.parent / subtitle_folder_name() 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}") 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): def list_subfolders(folder):
return sorted(p for p in folder.iterdir() if p.is_dir() and not p.name.startswith(".")) 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): def build_movie_folder_index(share):
index = [] 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) videos = list_video_files(folder)
if len(videos) != 1: if len(videos) != 1:
continue continue
@@ -2161,6 +2190,10 @@ def build_movie_folder_index(share):
def match_movie_from_index(index, query, year): def match_movie_from_index(index, query, year):
words = query.split()
while words:
candidate = " ".join(words)
best = None best = None
best_video = None best_video = None
best_ratio = 0.0 best_ratio = 0.0
@@ -2168,7 +2201,7 @@ def match_movie_from_index(index, query, year):
for folder, video, folder_year, folder_query in index: 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: if year and folder_year and abs(int(folder_year) - int(year)) > NEAR_YEAR_MAX_DIFF:
continue continue
ratio = difflib.SequenceMatcher(None, query.lower(), folder_query.lower()).ratio() ratio = difflib.SequenceMatcher(None, candidate.lower(), folder_query.lower()).ratio()
if ratio > best_ratio: if ratio > best_ratio:
best_ratio = ratio best_ratio = ratio
best = folder best = folder
@@ -2176,12 +2209,17 @@ def match_movie_from_index(index, query, year):
if best is not None and best_ratio >= NEAR_YEAR_MATCH_MIN_SIMILARITY: if best is not None and best_ratio >= NEAR_YEAR_MATCH_MIN_SIMILARITY:
return best, best_video return best, best_video
if len(words) <= 2:
break
words = words[:-1]
return None, None return None, None
def build_show_folder_index(share): def build_show_folder_index(share):
index = [] 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_year = extract_year(folder.name)
folder_query = build_query(folder.name, folder_year) folder_query = build_query(folder.name, folder_year)
index.append((folder, folder_year, folder_query)) 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): def match_show_from_index(index, query, year):
words = query.split()
while words:
candidate = " ".join(words)
best = None best = None
best_ratio = 0.0 best_ratio = 0.0
for folder, folder_year, folder_query in index: for folder, folder_year, folder_query in index:
if year and folder_year and abs(int(folder_year) - int(year)) > NEAR_YEAR_MAX_DIFF: if year and folder_year and abs(int(folder_year) - int(year)) > NEAR_YEAR_MAX_DIFF:
continue continue
ratio = difflib.SequenceMatcher(None, query.lower(), folder_query.lower()).ratio() ratio = difflib.SequenceMatcher(None, candidate.lower(), folder_query.lower()).ratio()
if ratio > best_ratio: if ratio > best_ratio:
best_ratio = ratio best_ratio = ratio
best = folder best = folder
if best is not None and best_ratio >= NEAR_YEAR_MATCH_MIN_SIMILARITY: if best is not None and best_ratio >= NEAR_YEAR_MATCH_MIN_SIMILARITY:
return best return best
if len(words) <= 2:
break
words = words[:-1]
return None 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() unchanged_folder_names = unchanged_folder_names or set()
share_root = Path(share) 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): def walk(folder):
vprint(f"Scanning folder: {folder}") vprint(f"Scanning folder: {folder}")
try: try:
@@ -2918,6 +2970,8 @@ def find_cleanup_targets(share, delete_folder_names, delete_file_patterns, keep_
return return
for entry in entries: for entry in entries:
if entry.is_dir(): if entry.is_dir():
if entry == duplicates_dir:
continue
if matches_folder_name(entry.name, delete_folder_names): if matches_folder_name(entry.name, delete_folder_names):
vprint(f" Match (folder name): {entry}") vprint(f" Match (folder name): {entry}")
targets.append(("folder", 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 {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'}") 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("No cleanup rules configured.")
print(f"Edit {DELETE_FILE.name} to enable cleanup.") print(f"Edit {DELETE_FILE.name} to enable cleanup.")
return return
@@ -3030,7 +3086,7 @@ def run_cleanup(args, log):
share_path = Path(share) share_path = Path(share)
try: try:
top_level_folders = [ 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: except OSError:
top_level_folders = [] top_level_folders = []
@@ -3500,7 +3556,7 @@ def run_scan(args, log):
subfolders = [share_path] subfolders = [share_path]
else: else:
subfolders = sorted( 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) 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 new_folder.is_dir() or folder_name in simulated_tv_folder_names
) and not same_existing_path(new_folder, folder) ) and not same_existing_path(new_folder, folder)
if media_type == "tv" and needs_rename and target_already_taken: if needs_rename and target_already_taken:
if media_type == "tv":
print(f"Would merge into existing show folder: {raw_name} -> {folder_name}") print(f"Would merge into existing show folder: {raw_name} -> {folder_name}")
preview_merge_duplicate_show_folder(folder, new_folder, raw_name, final_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 examples_shown += 1
continue continue
@@ -3610,6 +3671,21 @@ def run_scan(args, log):
continue continue
if new_folder.exists() and not same_existing_path(new_folder, folder): if new_folder.exists() and not same_existing_path(new_folder, folder):
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}") print(f"Skipping (target already exists): {raw_name} -> {folder_name}")
folders_skipped += 1 folders_skipped += 1
continue continue
@@ -3667,7 +3743,7 @@ def run_scan(args, log):
print(f"Log saved: {log_path}") print(f"Log saved: {log_path}")
current_names = { 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} baseline = {name: sig for name, sig in baseline.items() if name in current_names}
cache[share_key] = baseline cache[share_key] = baseline
@@ -3830,7 +3906,7 @@ def run_episode_check(args):
shows = [share_path] shows = [share_path]
else: else:
shows = sorted( 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 checked = 0