From 712bd24e91050c73b7a83cabc818fcf6198fc27b Mon Sep 17 00:00:00 2001 From: Justin Oro Date: Wed, 26 Aug 2026 09:40:12 -0700 Subject: [PATCH] merge orphaned subtitle-only duplicate folders into existing episodes --- plex-library-tool.py | 120 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 2 deletions(-) diff --git a/plex-library-tool.py b/plex-library-tool.py index c2f40af..12666a8 100755 --- a/plex-library-tool.py +++ b/plex-library-tool.py @@ -773,9 +773,9 @@ def confirm_delete_choice(prompt): JUNK_TOKENS = { "bluray", "blueray", "bdrip", "brrip", "bdremux", "remux", "webrip", "webdl", "web", "dl", "hdtv", "hdrip", "dvdrip", "dvd", - "hevc", "x264", "x265", "h264", "h265", "avc", + "hevc", "x264", "x265", "h264", "h265", "avc", "xvid", "divx", "aac", "ac3", "eac3", "dts", "atmos", "ddp", "dd", - "proper", "repack", "extended", "unrated", "uncut", "directors", "cut", + "proper", "repack", "extended", "unrated", "uncut", "director", "directors", "cut", "internal", "limited", "theatrical", "multi", "dual", "audio", "hdr", "sdr", "4k", "uhd", "10bit", "8bit", "hi10p", "hi444pp", "deluxe", "boxset", "box", "set", "extras", "hd", @@ -1671,6 +1671,114 @@ def infer_season_from_files(folder): return None +def find_matching_episode_video(target_season_dir, episode): + for vid in list_video_files(target_season_dir): + v_se = parse_season_episode(vid.name) + v_episode = v_se[1] if v_se else parse_episode_only(vid.name) + if v_episode == episode: + return vid + return None + + +def find_subtitle_pack_folder(container_folder): + canonical_name = subtitle_folder_name() + try: + for entry in sorted(container_folder.iterdir()): + if entry.is_dir() and (entry.name == canonical_name or entry.name.lower() in SUBTITLE_FOLDER_NAMES): + return entry + except OSError: + pass + return None + + +def resolve_subtitle_pack_episode(pack_name, fallback_season): + se = parse_season_episode(pack_name) + if se: + return se[0], se[1] + return parse_season_only(pack_name) or fallback_season, parse_episode_only(pack_name) + + +def merge_orphaned_subtitle_packs(container_folder, target_folder, season, log): + moved = 0 + subs_folder = find_subtitle_pack_folder(container_folder) + if subs_folder is None: + return moved + + for pack in sorted(p for p in subs_folder.iterdir() if p.is_dir()): + target_season, episode = resolve_subtitle_pack_episode(pack.name, season) + if episode is None: + print(f"Skipping subtitle pack (no episode number found): {pack.name}") + continue + if target_season is None: + print(f"Skipping subtitle pack (unknown season): {pack.name}") + continue + + target_season_dir = target_folder / season_folder_name(target_season) + if not target_season_dir.is_dir(): + print(f"Skipping subtitle pack (no matching season in library): {pack.name}") + continue + + video_match = find_matching_episode_video(target_season_dir, episode) + if video_match is None: + print(f"Skipping subtitle pack (no matching episode in library): {pack.name}") + continue + + subtitle_files = [ + f for f in sorted(pack.rglob("*")) + if f.is_file() and f.suffix.lower().lstrip(".") in SUBTITLE_EXTENSIONS + ] + if not subtitle_files: + continue + + moved += rename_consolidated_subtitles(subtitle_files, video_match, log) + + try: + for leftover in sorted(pack.rglob("*"), reverse=True): + if leftover.is_dir() and not any(leftover.iterdir()): + leftover.rmdir() + if pack.exists() and not any(pack.iterdir()): + pack.rmdir() + except OSError: + pass + + try: + if subs_folder.exists() and not any(subs_folder.iterdir()): + subs_folder.rmdir() + print(f"Removed empty folder: {subs_folder}") + except OSError: + pass + + return moved + + +def preview_orphaned_subtitle_packs(container_folder, target_folder, season): + subs_folder = find_subtitle_pack_folder(container_folder) + if subs_folder is None: + return + + for pack in sorted(p for p in subs_folder.iterdir() if p.is_dir()): + target_season, episode = resolve_subtitle_pack_episode(pack.name, season) + if episode is None or target_season is None: + continue + + target_season_dir = target_folder / season_folder_name(target_season) + if not target_season_dir.is_dir(): + continue + + video_match = find_matching_episode_video(target_season_dir, episode) + if video_match is None: + continue + + subtitle_files = [ + f for f in sorted(pack.rglob("*")) + if f.is_file() and f.suffix.lower().lstrip(".") in SUBTITLE_EXTENSIONS + ] + if not subtitle_files: + continue + + preview_consolidated_subtitles(subtitle_files, video_match) + + def merge_duplicate_show_folder(source_folder, target_folder, raw_name, final_name, log): moved = 0 skipped = 0 @@ -1710,6 +1818,8 @@ def merge_duplicate_show_folder(source_folder, target_folder, raw_name, final_na moved += 1 moved += rename_consolidated_subtitles(subtitles, dest, log) + moved += merge_orphaned_subtitle_packs(sub, target_folder, season, log) + try: if sub.exists() and not any(sub.iterdir()): sub.rmdir() @@ -1754,6 +1864,8 @@ def merge_duplicate_show_folder(source_folder, target_folder, raw_name, final_na moved += 1 moved += rename_consolidated_subtitles(subtitles, dest, log) + moved += merge_orphaned_subtitle_packs(source_folder, target_folder, folder_season, log) + try: if source_folder.exists() and not any(source_folder.iterdir()): source_folder.rmdir() @@ -1786,6 +1898,8 @@ def preview_merge_duplicate_show_folder(source_folder, target_folder, raw_name, print(f"Move: {item.name} -> {target_folder.name}/{item_season_folder}/{new_name}") preview_consolidated_subtitles(subtitles, dest) + preview_orphaned_subtitle_packs(sub, target_folder, season) + folder_season = parse_season_folder_name(raw_name) if folder_season is None: folder_season = infer_season_from_files(source_folder) @@ -1808,6 +1922,8 @@ def preview_merge_duplicate_show_folder(source_folder, target_folder, raw_name, print(f"Move: {item.name} -> {target_folder.name}/{item_season_folder}/{new_name}") preview_consolidated_subtitles(subtitles, dest) + preview_orphaned_subtitle_packs(source_folder, target_folder, folder_season) + def preview_video_files(folder, media_type, final_name, api_key, tmdb_id=None): files = list_video_files(folder)