Merge duplicate TV show folders into existing show on rename
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
Reference in New Issue
Block a user