Merge duplicate TV show folders into existing show on rename

This commit is contained in:
justin committed 2026-08-20 20:00:59 -07:00
1 parent ea7a92472b
commit aa08736e22
2 files changed
+169

No files matched your search

+2
View File
@@ -12,6 +12,8 @@ This guide assumes you've never used Python or GitHub before. If you already kno
- Matches your existing folder names against TMDb and renames them to `Movie Name (Year)` / `Show Name (Year)` format - Matches your existing folder names against TMDb and renames them to `Movie Name (Year)` / `Show Name (Year)` format
- 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 files sitting directly in the share root (no folder of their own) and organizes them into a proper movie 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
- 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)).
- Optionally cleans up junk files/folders (samples, `.nfo`, `.txt`, screenshots, unwanted-language subtitles) into a local trash folder. Nothing is deleted permanently, and every cleanup can be reversed. - Optionally cleans up junk files/folders (samples, `.nfo`, `.txt`, screenshots, unwanted-language subtitles) into a local trash folder. Nothing is deleted permanently, and every cleanup can be reversed.
- Fully customizable naming convention (`names.yaml`): change `S01` to `Season 01`, use dots instead of spaces, uppercase everything, rename the "Subs" folder to something else, etc. - Fully customizable naming convention (`names.yaml`): change `S01` to `Season 01`, use dots instead of spaces, uppercase everything, rename the "Subs" folder to something else, etc.
+167
View File
@@ -1343,6 +1343,149 @@ def rename_season_folder_files(folder, final_name, log):
return renamed, skipped return renamed, skipped
def infer_season_from_files(folder):
for item in sorted(folder.rglob("*")):
if not item.is_file() or item.suffix.lower().lstrip(".") not in VIDEO_EXTENSIONS:
continue
se = parse_season_episode(item.name)
if se:
return se[0]
season = parse_season_only(item.name)
if season is not None:
return season
return None
def merge_duplicate_show_folder(source_folder, target_folder, raw_name, final_name, log):
moved = 0
skipped = 0
for sub in list_subfolders(source_folder):
season = parse_season_folder_name(sub.name)
if season is None:
continue
organize_subtitle_folder(sub, log)
for item in list_video_files(sub):
ext = item.suffix.lower().lstrip(".")
file_season, episode, marker = resolve_season_folder_file(item, season)
item_season = file_season if file_season is not None else season
item_season_folder = season_folder_name(item_season)
item_target_dir = target_folder / item_season_folder
subtitles = gather_video_subtitles(item, True)
new_name = item.name if episode is None else episode_file_name(
final_name, item_season, episode, ext, detect_resolution(item.name)
)
dest = item_target_dir / new_name
if dest.exists() and not same_existing_path(dest, item):
print(f"Skipping (target already exists): {item.name}")
skipped += 1
continue
item_target_dir.mkdir(exist_ok=True)
item.rename(dest)
log.record(item, dest)
print(f"Moved: {item.name} -> {target_folder.name}/{item_season_folder}/{new_name}")
moved += 1
moved += rename_consolidated_subtitles(subtitles, dest, log)
try:
if sub.exists() and not any(sub.iterdir()):
sub.rmdir()
except OSError:
pass
folder_season = parse_season_folder_name(raw_name)
if folder_season is None:
folder_season = infer_season_from_files(source_folder)
organize_subtitle_folder(source_folder, log)
for item in list_video_files(source_folder):
ext = item.suffix.lower().lstrip(".")
file_season, episode, marker = resolve_season_folder_file(item, folder_season)
item_season = file_season if file_season is not None else folder_season
subtitles = gather_video_subtitles(item, True)
if item_season is None or episode is None:
print(f"No season/episode found, skipping: {item.name}")
skipped += 1
continue
item_season_folder = season_folder_name(item_season)
item_target_dir = target_folder / item_season_folder
new_name = episode_file_name(final_name, item_season, episode, ext, detect_resolution(item.name))
dest = item_target_dir / new_name
if dest.exists() and not same_existing_path(dest, item):
print(f"Skipping (target already exists): {item.name}")
skipped += 1
continue
item_target_dir.mkdir(exist_ok=True)
item.rename(dest)
log.record(item, dest)
print(f"Moved: {item.name} -> {target_folder.name}/{item_season_folder}/{new_name}")
moved += 1
moved += rename_consolidated_subtitles(subtitles, dest, log)
try:
if source_folder.exists() and not any(source_folder.iterdir()):
source_folder.rmdir()
print(f"Removed empty folder: {source_folder}")
except OSError:
pass
return moved, skipped
def preview_merge_duplicate_show_folder(source_folder, target_folder, raw_name, final_name):
for sub in list_subfolders(source_folder):
season = parse_season_folder_name(sub.name)
if season is None:
continue
preview_subtitle_folder(sub)
for item in list_video_files(sub):
ext = item.suffix.lower().lstrip(".")
file_season, episode, marker = resolve_season_folder_file(item, season)
item_season = file_season if file_season is not None else season
item_season_folder = season_folder_name(item_season)
subtitles = gather_video_subtitles(item, True)
new_name = item.name if episode is None else episode_file_name(
final_name, item_season, episode, ext, detect_resolution(item.name)
)
dest = target_folder / item_season_folder / new_name
print(f"Move: {item.name} -> {target_folder.name}/{item_season_folder}/{new_name}")
preview_consolidated_subtitles(subtitles, dest)
folder_season = parse_season_folder_name(raw_name)
if folder_season is None:
folder_season = infer_season_from_files(source_folder)
preview_subtitle_folder(source_folder)
for item in list_video_files(source_folder):
ext = item.suffix.lower().lstrip(".")
file_season, episode, marker = resolve_season_folder_file(item, folder_season)
item_season = file_season if file_season is not None else folder_season
subtitles = gather_video_subtitles(item, True)
if item_season is None or episode is None:
print(f"No season/episode found, skipping: {item.name}")
continue
item_season_folder = season_folder_name(item_season)
new_name = episode_file_name(final_name, item_season, episode, ext, detect_resolution(item.name))
dest = target_folder / item_season_folder / new_name
print(f"Move: {item.name} -> {target_folder.name}/{item_season_folder}/{new_name}")
preview_consolidated_subtitles(subtitles, dest)
def preview_video_files(folder, media_type, final_name, api_key): def preview_video_files(folder, media_type, final_name, api_key):
files = list_video_files(folder) files = list_video_files(folder)
@@ -2066,6 +2209,7 @@ def run_scan(args, log):
folders_skipped = 0 folders_skipped = 0
files_renamed = 0 files_renamed = 0
files_skipped = 0 files_skipped = 0
simulated_tv_folder_names = set()
total_folders = len(subfolders) total_folders = len(subfolders)
@@ -2093,11 +2237,22 @@ def run_scan(args, log):
if test_mode: if test_mode:
new_folder = folder.parent / folder_name new_folder = folder.parent / folder_name
needs_rename = new_folder != folder needs_rename = new_folder != folder
target_already_taken = (
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)
examples_shown += 1
continue
if needs_rename: if needs_rename:
print(f"Renamed folder: {raw_name} -> {folder_name}") print(f"Renamed folder: {raw_name} -> {folder_name}")
else: else:
print(f"Skipping renaming (already correctly named): {raw_name}") print(f"Skipping renaming (already correctly named): {raw_name}")
if media_type == "tv": if media_type == "tv":
simulated_tv_folder_names.add(folder_name)
preview_season_folders(folder) preview_season_folders(folder)
preview_season_folder_files(folder, final_name) preview_season_folder_files(folder, final_name)
preview_video_files(folder, media_type, final_name, api_key) preview_video_files(folder, media_type, final_name, api_key)
@@ -2107,6 +2262,18 @@ def run_scan(args, log):
new_folder = folder.parent / folder_name new_folder = folder.parent / folder_name
needs_rename = new_folder != folder needs_rename = new_folder != folder
if needs_rename and media_type == "tv" and new_folder.is_dir() and not same_existing_path(new_folder, folder):
if not args.yes and not confirm(f"Merge '{raw_name}' into existing show folder '{folder_name}'?"):
print(f"Skipped: {raw_name}")
folders_skipped += 1
continue
merged, merge_skipped = merge_duplicate_show_folder(folder, new_folder, raw_name, final_name, log)
files_renamed += merged
files_skipped += merge_skipped
folders_renamed += 1
continue
if needs_rename: if needs_rename:
if not args.yes and not confirm(f"Rename '{raw_name}' -> '{folder_name}'?"): if not args.yes and not confirm(f"Rename '{raw_name}' -> '{folder_name}'?"):
print(f"Skipped: {raw_name}") print(f"Skipped: {raw_name}")