Compare commits

...
10 Commits
2 changed files with 673 additions and 37 deletions

No files matched your search

+12 -4
View File
@@ -11,11 +11,14 @@ This guide assumes you've never used Python or GitHub before. If you already kno
## What it does ## What it does
- 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. A "Specials" folder is recognized as season 0 and renamed to `S00` the same way, so cleanup won't mistake it for junk if you've added "Specials" to `delete.yaml`
- 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 - 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. It's dot-prefixed on purpose so Plex's scanner treats it as hidden and leaves it alone. Cleanup (`-c`) picks up `.DUPLICATES` and moves it to trash the same as anything else. If you have an older, non-dot `DUPLICATES` folder from a previous version, it's migrated automatically the next time you run a scan or cleanup
- Detects a movie "bundle" folder (a box set, trilogy, or franchise rip with several different movies in one folder) and splits each movie out into its own correctly named folder, moving its matching subtitles along with it. This checks every movie folder with more than one video file, even if the folder's own name already matches a movie (e.g. a franchise folder named after just the first film)
- 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
- Suggests new movies or TV shows based on what's already in your library, using TMDb recommendations (`-s` / `--suggestions [N]`, defaults to 5). Pass a genre list instead of a number (e.g. `--suggestions action,sci-fi`) to suggest purely from that genre instead of basing it on your library. Remembers what it's already suggested so it won't repeat itself; clear that history with `--clear-suggestions`
- Exports a plain list of your movie or TV show titles and release years to a file (`--export txt|csv|json`), for a quick backup/reference of what you own. TV shows are listed by series name and year only, no episodes
- 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)).
- 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.
@@ -113,6 +116,8 @@ python plex-library-tool.py -r
The first time you run it, it'll ask you to paste in the TMDb API key from step 3, and offer to save it to a local `.env` file so you're never asked again. That file stays on your computer and is never uploaded anywhere. The first time you run it, it'll ask you to paste in the TMDb API key from step 3, and offer to save it to a local `.env` file so you're never asked again. That file stays on your computer and is never uploaded anywhere.
Prefer not to remember flags? Just run it with no arguments (`python3 plex-library-tool.py`) and it'll walk you through picking a share and then an action (Rename, Clean, Rename and Clean, Episodes, or Suggestions) interactively.
### 5. Point it at your media ### 5. Point it at your media
After the API key step, the script will either: After the API key step, the script will either:
@@ -144,6 +149,9 @@ python plex-library-tool.py -r "/path/to/your/Movies" -t
| `-r`, `--rename [PATH]` | Scan and rename a share. Pass a path to skip the share-selection prompt. | | `-r`, `--rename [PATH]` | Scan and rename a share. Pass a path to skip the share-selection prompt. |
| `-c`, `--cleanup [PATH]` | Move junk files/folders (per `delete.yaml`) to a local trash folder. | | `-c`, `--cleanup [PATH]` | Move junk files/folders (per `delete.yaml`) to a local trash folder. |
| `-e`, `--episodes [PATH]` | Check TV shows against TMDb's episode list and report any missing (already-aired) episodes. Read-only, makes no changes. | | `-e`, `--episodes [PATH]` | Check TV shows against TMDb's episode list and report any missing (already-aired) episodes. Read-only, makes no changes. |
| `-s`, `--suggestions [N\|genre1,genre2]` | Suggest N new movies or TV shows (default 5) based on what's already in your library, via TMDb recommendations. Pass a genre list instead of a number to suggest purely from that genre. Read-only, makes no changes. |
| `--clear-suggestions` | Clear the suggestions history so previously suggested titles can come up again. |
| `--export txt\|csv\|json` | Export a list of your movie or TV show titles and years to `exports/`. Read-only, makes no changes. |
| `-t`, `--test [N]` | Preview only. No changes are made. Optionally limit how many folders are shown. | | `-t`, `--test [N]` | Preview only. No changes are made. Optionally limit how many folders are shown. |
| `-y`, `--yes` | Don't ask for confirmation before each rename. | | `-y`, `--yes` | Don't ask for confirmation before each rename. |
| `-f`, `--force` | Force a full scan even if nothing looks like it changed since the last run. | | `-f`, `--force` | Force a full scan even if nothing looks like it changed since the last run. |
@@ -184,7 +192,7 @@ The service reuses your saved TMDb API key and language from `.env`, so run the
These live alongside the script and are all optional. The script works out of the box with sensible defaults. Every file is fully documented with comments and examples inside it. These live alongside the script and are all optional. The script works out of the box with sensible defaults. Every file is fully documented with comments and examples inside it.
- **`names.yaml`**: customize the naming convention. Folder/file name format, season folder naming (`S01` vs `Season 01`), separators (spaces vs dots vs underscores), uppercase/lowercase, whether to include resolution tags like `1080p`, and the subtitle folder name (e.g. rename "Subs" to "Subtitles" or any word in your own language). - **`names.yaml`**: customize the naming convention. Folder/file name format, season folder naming (`S01` vs `Season 01`), separators (spaces vs dots vs underscores), uppercase/lowercase, whether to include resolution tags like `1080p`, and the subtitle folder name (e.g. rename "Subs" to "Subtitles" or any word in your own language).
- **`delete.yaml`**: what cleanup moves to trash. Folder name patterns (e.g. `sample`, `extras`), file name/extension patterns, and subtitle language rules (e.g. "only keep Spanish subtitles"). - **`delete.yaml`**: what cleanup moves to trash. Folder name patterns (e.g. `sample`, `extras`), file name/extension patterns, subtitle language rules (e.g. "only keep Spanish subtitles"), and an optional `sample_videos: true` switch to catch loose sample video files sitting next to the real one (video files are otherwise never touched by cleanup).
--- ---
@@ -205,7 +213,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. - **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`.
+661 -33
View File
@@ -2,6 +2,7 @@
import argparse import argparse
import concurrent.futures import concurrent.futures
import csv
import ctypes import ctypes
import datetime import datetime
import difflib import difflib
@@ -10,6 +11,7 @@ import hashlib
import json import json
import os import os
import platform import platform
import random
import re import re
import shutil import shutil
import signal import signal
@@ -32,10 +34,65 @@ 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" DUPLICATES_FOLDER_NAME = ".DUPLICATES"
LEGACY_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
SUGGESTIONS_CACHE_FILE = SCRIPT_DIR / "suggestions_cache.json"
DEFAULT_SUGGESTIONS_COUNT = 5
SUGGESTION_SEED_SAMPLE_SIZE = 25
EXPORTS_DIR = SCRIPT_DIR / "exports"
MOVIE_GENRES = {
28: "Action", 12: "Adventure", 16: "Animation", 35: "Comedy", 80: "Crime",
99: "Documentary", 18: "Drama", 10751: "Family", 14: "Fantasy", 36: "History",
27: "Horror", 10402: "Music", 9648: "Mystery", 10749: "Romance",
878: "Science Fiction", 10770: "TV Movie", 53: "Thriller", 10752: "War", 37: "Western",
}
TV_GENRES = {
10759: "Action & Adventure", 16: "Animation", 35: "Comedy", 80: "Crime",
99: "Documentary", 18: "Drama", 10751: "Family", 10762: "Kids",
9648: "Mystery", 10763: "News", 10764: "Reality", 10765: "Sci-Fi & Fantasy",
10766: "Soap", 10767: "Talk", 10768: "War & Politics", 37: "Western",
}
GENRE_NAME_ALIASES = {
"scifi": "science fiction",
"sci fi": "science fiction",
"sciencefiction": "science fiction",
}
def normalize_genre_key(name):
return re.sub(r'[^a-z0-9]', '', name.lower())
def resolve_genre_ids(genre_map, raw_names):
normalized_map = {}
for gid, gname in genre_map.items():
normalized_map[normalize_genre_key(gname)] = gid
resolved = []
unresolved = []
for raw in raw_names:
key = normalize_genre_key(raw)
gid = normalized_map.get(key)
if gid is None:
gid = next((v for k, v in normalized_map.items() if key and (key in k or k in key)), None)
if gid is None:
alias = GENRE_NAME_ALIASES.get(key)
if alias:
alias_key = normalize_genre_key(alias)
gid = normalized_map.get(alias_key)
if gid is None:
gid = next((v for k, v in normalized_map.items() if alias_key in k or k in alias_key), None)
if gid is not None and gid not in resolved:
resolved.append(gid)
elif gid is None:
unresolved.append(raw)
return resolved, unresolved
VERBOSE = False VERBOSE = False
@@ -119,7 +176,7 @@ def is_effectively_empty(folder):
return all(entry.is_file() and is_ignorable_junk_file(entry) for entry in entries) return all(entry.is_file() and is_ignorable_junk_file(entry) for entry in entries)
SEASON_EP_PATTERNS = [ SEASON_EP_PATTERNS = [
(re.compile(r'[Ss](\d{1,2})[Ee](\d{1,2})'), 'E'), (re.compile(r'[Ss](\d{1,2})[Ee](\d{1,3})'), 'E'),
(re.compile(r'[Ss](\d{1,2})[Xx](\d{1,2})'), 'X'), (re.compile(r'[Ss](\d{1,2})[Xx](\d{1,2})'), 'X'),
(re.compile(r'[Ss](\d{1,2})[Mm](\d{1,2})'), 'M'), (re.compile(r'[Ss](\d{1,2})[Mm](\d{1,2})'), 'M'),
(re.compile(r'(?<![A-Za-z0-9])(\d{1,2})[xX](\d{1,2})(?![A-Za-z0-9])'), 'E'), (re.compile(r'(?<![A-Za-z0-9])(\d{1,2})[xX](\d{1,2})(?![A-Za-z0-9])'), 'E'),
@@ -246,6 +303,8 @@ def parse_season_episode(filename):
if m: if m:
season = int(m.group(1)) season = int(m.group(1))
episode = int(m.group(2)) episode = int(m.group(2))
if marker == 'E' and episode >= 100 and episode // 100 == season:
episode = episode % 100
return season, episode, marker return season, episode, marker
if parse_season_only(filename) is None and parse_episode_only(filename) is None: if parse_season_only(filename) is None and parse_episode_only(filename) is None:
@@ -569,6 +628,32 @@ def normalize_media_type_arg(value):
raise argparse.ArgumentTypeError(f"Invalid type: {value!r}. Use 'movies' or 'tv'.") raise argparse.ArgumentTypeError(f"Invalid type: {value!r}. Use 'movies' or 'tv'.")
def parse_suggestions_arg(value):
try:
return int(value)
except ValueError:
return value
def normalize_export_format_arg(value):
v = value.strip().lower()
if v in ("txt", "text"):
return "txt"
if v in ("csv", "json"):
return v
raise argparse.ArgumentTypeError(f"Invalid export format: {value!r}. Use 'txt', 'csv', or 'json'.")
FOLDER_TITLE_YEAR_PATTERN = re.compile(r'^(.*?)\s*\((19\d{2}|20\d{2})\)\s*$')
def parse_title_and_year(folder_name):
m = FOLDER_TITLE_YEAR_PATTERN.match(folder_name)
if m:
return m.group(1).strip(), m.group(2)
return folder_name.strip(), None
def determine_media_type(share, override=None): def determine_media_type(share, override=None):
if override: if override:
label = "Movies" if override == "movie" else "TV Shows" label = "Movies" if override == "movie" else "TV Shows"
@@ -609,6 +694,56 @@ def tmdb_search(api_key, media_type, query):
return data.get("results", []), None return data.get("results", []), None
def tmdb_related(api_key, media_type, tmdb_id, kind="recommendations"):
v4 = is_v4_token(api_key)
params = {"language": get_tmdb_language()}
if not v4:
params["api_key"] = api_key
section = "movie" if media_type == "movie" else "tv"
url = f"{TMDB_BASE}/{section}/{tmdb_id}/{kind}?{urllib.parse.urlencode(params)}"
headers = {"Authorization": f"Bearer {api_key}"} if v4 else {}
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError):
return []
return data.get("results", [])
def tmdb_discover(api_key, media_type, extra_params, page=1):
v4 = is_v4_token(api_key)
params = {
"language": get_tmdb_language(),
"sort_by": "popularity.desc",
"vote_count.gte": 50,
"page": page,
}
params.update(extra_params)
if not v4:
params["api_key"] = api_key
section = "movie" if media_type == "movie" else "tv"
url = f"{TMDB_BASE}/discover/{section}?{urllib.parse.urlencode(params)}"
headers = {"Authorization": f"Bearer {api_key}"} if v4 else {}
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError):
return []
return data.get("results", [])
def tmdb_alternative_titles(api_key, media_type, tmdb_id): def tmdb_alternative_titles(api_key, media_type, tmdb_id):
v4 = is_v4_token(api_key) v4 = is_v4_token(api_key)
@@ -848,10 +983,6 @@ def best_match(media_type, results, year, query=None):
if not query_norm: if not query_norm:
return None return None
exact_matches = [r for r in results if is_exact_title(r)]
if len(exact_matches) == 1:
return exact_matches[0]
def near_title_similarity(r): def near_title_similarity(r):
return difflib.SequenceMatcher( return difflib.SequenceMatcher(
None, query_norm, clean_and_squeeze(result_title(media_type, r) or "").lower() None, query_norm, clean_and_squeeze(result_title(media_type, r) or "").lower()
@@ -866,6 +997,11 @@ def best_match(media_type, results, year, query=None):
threshold = YEAR_MATCH_MIN_SIMILARITY if len(near_year_matches) == 1 else NEAR_YEAR_MATCH_MIN_SIMILARITY threshold = YEAR_MATCH_MIN_SIMILARITY if len(near_year_matches) == 1 else NEAR_YEAR_MATCH_MIN_SIMILARITY
if near_title_similarity(best) >= threshold: if near_title_similarity(best) >= threshold:
return best return best
exact_matches = [r for r in results if is_exact_title(r)]
if len(exact_matches) == 1:
return exact_matches[0]
return None return None
if not query_norm: if not query_norm:
return year_matches[0] return year_matches[0]
@@ -955,6 +1091,48 @@ def purge_stray_metadata(path):
pass pass
def purge_empty_subdirs(path):
try:
entries = list(Path(path).iterdir())
except OSError:
return
for entry in entries:
if not entry.is_dir():
continue
purge_empty_subdirs(entry)
try:
if not any(entry.iterdir()):
entry.rmdir()
except OSError:
pass
def rmdir_retrying(path, attempts=5, delay=1.0):
last_err = None
for i in range(attempts):
try:
path.rmdir()
return True, None
except OSError as e:
last_err = e
if i < attempts - 1:
time.sleep(delay)
return False, str(last_err)
def rmtree_retrying(path, attempts=5, delay=1.0):
last_err = None
for i in range(attempts):
try:
shutil.rmtree(str(path))
return True, None
except OSError as e:
last_err = e
if i < attempts - 1:
time.sleep(delay)
return False, str(last_err)
def safe_move(src, dest): def safe_move(src, dest):
try: try:
shutil.move(str(src), str(dest)) shutil.move(str(src), str(dest))
@@ -962,10 +1140,16 @@ def safe_move(src, dest):
except OSError as e: except OSError as e:
src_path = Path(src) src_path = Path(src)
dest_path = Path(dest) dest_path = Path(dest)
if src_path.is_dir() and dest_path.exists(): if src_path.is_dir():
purge_stray_metadata(src_path) purge_stray_metadata(src_path)
purge_empty_subdirs(src_path)
try: try:
shutil.rmtree(str(src_path)) if dest_path.exists():
ok, err = rmtree_retrying(src_path)
if not ok:
raise OSError(err)
else:
shutil.move(str(src_path), str(dest_path))
return True, None return True, None
except OSError as e2: except OSError as e2:
return False, str(e2) return False, str(e2)
@@ -977,6 +1161,19 @@ def confirm(prompt):
return answer in ("y", "yes") return answer in ("y", "yes")
def confirm_or_all(prompt, args):
while True:
answer = input(f"{prompt} [y/N/a] ").strip().lower()
if answer in ("y", "yes"):
return True
if answer in ("a", "all"):
args.yes = True
return True
if answer in ("n", "no", ""):
return False
print("Please enter y (yes), n (no), or a (all).")
def confirm_delete_choice(prompt): def confirm_delete_choice(prompt):
while True: while True:
answer = input(f"{prompt} [y/N/a] ").strip().lower() answer = input(f"{prompt} [y/N/a] ").strip().lower()
@@ -1846,11 +2043,21 @@ def duplicate_file_staging_path(share, context_name, item):
return unique_destination(dest_dir / item.name) return unique_destination(dest_dir / item.name)
def cleanup_empty_staging_dir(share, context_name):
dest_dir = Path(share) / DUPLICATES_FOLDER_NAME / context_name
try:
if dest_dir.is_dir() and not any(dest_dir.iterdir()):
dest_dir.rmdir()
except OSError:
pass
def stage_duplicate_episode(share, context_name, item, log): def stage_duplicate_episode(share, context_name, item, log):
staged_dest = duplicate_file_staging_path(share, context_name, item) staged_dest = duplicate_file_staging_path(share, context_name, item)
ok, err = safe_move(item, staged_dest) ok, err = safe_move(item, staged_dest)
if not ok: if not ok:
print(f"Skipping (could not move to {DUPLICATES_FOLDER_NAME}/: {err}): {item.name}") print(f"Skipping (could not move to {DUPLICATES_FOLDER_NAME}/: {err}): {item.name}")
cleanup_empty_staging_dir(share, context_name)
return False return False
log.record(item, staged_dest) log.record(item, staged_dest)
print(f"Moved duplicate episode to {DUPLICATES_FOLDER_NAME}/{context_name}/: {item.name}") print(f"Moved duplicate episode to {DUPLICATES_FOLDER_NAME}/{context_name}/: {item.name}")
@@ -1959,6 +2166,41 @@ def unique_destination(dest):
counter += 1 counter += 1
def migrate_legacy_duplicates_folder(share):
legacy = Path(share) / LEGACY_DUPLICATES_FOLDER_NAME
if not legacy.is_dir():
return
current = Path(share) / DUPLICATES_FOLDER_NAME
if not current.exists():
try:
legacy.rename(current)
print(
f"Renamed {LEGACY_DUPLICATES_FOLDER_NAME}/ to {DUPLICATES_FOLDER_NAME}/ "
"(dot-prefixed so Plex's scanner skips it, avoiding lock conflicts)"
)
except OSError as e:
vprint(f" Could not migrate legacy {LEGACY_DUPLICATES_FOLDER_NAME}/ folder: {e}")
return
print(f"Merging legacy {LEGACY_DUPLICATES_FOLDER_NAME}/ into {DUPLICATES_FOLDER_NAME}/")
for entry in list(legacy.iterdir()):
dest = current / entry.name
if dest.exists():
dest = unique_destination(dest)
try:
entry.rename(dest)
except OSError as e:
vprint(f" Could not migrate {entry}: {e}")
try:
if not any(legacy.iterdir()):
legacy.rmdir()
except OSError:
pass
def is_already_disambiguated(item, dest): def is_already_disambiguated(item, dest):
if item.parent != dest.parent or item.suffix.lower() != dest.suffix.lower(): if item.parent != dest.parent or item.suffix.lower() != dest.suffix.lower():
return False return False
@@ -2073,7 +2315,9 @@ def preview_subtitle_folder(folder):
def is_library_content_folder(p): def is_library_content_folder(p):
return p.is_dir() and not p.name.startswith(".") and p.name.upper() != DUPLICATES_FOLDER_NAME if not p.is_dir() or p.name.startswith("."):
return False
return p.name.upper() != LEGACY_DUPLICATES_FOLDER_NAME.upper()
def list_subfolders(folder): def list_subfolders(folder):
@@ -2118,6 +2362,11 @@ def resolve_season_folder_file(item, season, api_key=None, tmdb_id=None):
if se: if se:
return se[0], se[1], se[2] return se[0], se[1], se[2]
if season is not None:
legacy = LEGACY_SEE_PATTERN.search(item.name)
if legacy and int(legacy.group(1)) == season:
return season, int(legacy.group(2)), 'E'
file_season = parse_season_only(item.name) file_season = parse_season_only(item.name)
episode = parse_episode_only(item.name) episode = parse_episode_only(item.name)
if episode is None: if episode is None:
@@ -2778,6 +3027,11 @@ def handle_movie_bundle_folder(share, folder, api_key, log, test_mode, args, nee
own_subs = gather_video_subtitles(video, True) own_subs = gather_video_subtitles(video, True)
dest_video = target_folder / new_video_name dest_video = target_folder / new_video_name
if dest_video == video:
print(f"Skipping renaming (already correctly named and placed): {video.name}")
files_moved += rename_consolidated_subtitles(own_subs, video, log)
continue
if test_mode: if test_mode:
print(f"Would split bundle member: {folder.name}/{video.name} -> {target_folder_name}/{new_video_name}") print(f"Would split bundle member: {folder.name}/{video.name} -> {target_folder_name}/{new_video_name}")
preview_consolidated_subtitles(own_subs, dest_video) preview_consolidated_subtitles(own_subs, dest_video)
@@ -2789,7 +3043,7 @@ def handle_movie_bundle_folder(share, folder, api_key, log, test_mode, args, nee
files_skipped += 1 files_skipped += 1
continue continue
if not args.yes and not confirm(f"Split '{video.name}' out of bundle '{folder.name}' into '{target_folder_name}'?"): if not args.yes and not confirm_or_all(f"Split '{video.name}' out of bundle '{folder.name}' into '{target_folder_name}'?", args):
print(f"Skipped: {video.name}") print(f"Skipped: {video.name}")
files_skipped += 1 files_skipped += 1
continue continue
@@ -2805,6 +3059,7 @@ def handle_movie_bundle_folder(share, folder, api_key, log, test_mode, args, nee
files_moved += 1 files_moved += 1
else: else:
print(f"Skipping (could not move to {DUPLICATES_FOLDER_NAME}/: {err}): {video.name}") print(f"Skipping (could not move to {DUPLICATES_FOLDER_NAME}/: {err}): {video.name}")
cleanup_empty_staging_dir(share, target_folder_name)
files_skipped += 1 files_skipped += 1
continue continue
@@ -3143,9 +3398,6 @@ def find_cleanup_targets(share, delete_folder_names, delete_file_patterns, keep_
share_root = Path(share) share_root = Path(share)
duplicates_dir = share_root / DUPLICATES_FOLDER_NAME 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}")
@@ -3200,10 +3452,14 @@ def remove_empty_folders(share, confirm_all, dry_run=False):
skipped = 0 skipped = 0
removed_paths = set() removed_paths = set()
share_path = Path(share).resolve() share_path = Path(share).resolve()
duplicates_path = (share_path / DUPLICATES_FOLDER_NAME).resolve()
for root, dirs, files in os.walk(share, topdown=False): for root, dirs, files in os.walk(share, topdown=False):
root_path = Path(root) root_path = Path(root)
if root_path.resolve() == share_path: if root_path.resolve() == share_path:
continue continue
resolved_root = root_path.resolve()
if resolved_root == duplicates_path or duplicates_path in resolved_root.parents:
continue
try: try:
remaining = [ remaining = [
c for c in root_path.iterdir() c for c in root_path.iterdir()
@@ -3236,6 +3492,9 @@ def remove_empty_folders(share, confirm_all, dry_run=False):
print(f"Removed empty folder: {root_path}") print(f"Removed empty folder: {root_path}")
removed_paths.add(root_path) removed_paths.add(root_path)
removed += 1 removed += 1
except FileNotFoundError:
vprint(f" Already gone: {root_path}")
removed_paths.add(root_path)
except OSError as e: except OSError as e:
vprint(f" Could not remove {root_path}: {e}") vprint(f" Could not remove {root_path}: {e}")
return removed, skipped return removed, skipped
@@ -3246,6 +3505,8 @@ def run_cleanup(args, log):
share = resolve_share(path_arg) share = resolve_share(path_arg)
log.set_label(f"{Path(share).name}-cleanup") log.set_label(f"{Path(share).name}-cleanup")
migrate_legacy_duplicates_folder(share)
print() print()
print(f"Performing action: Cleanup{' (Test Mode)' if args.test is not None else ''}") print(f"Performing action: Cleanup{' (Test Mode)' if args.test is not None else ''}")
@@ -3258,11 +3519,9 @@ def run_cleanup(args, log):
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'}")
vprint(f"Sample video cleanup enabled: {sample_videos_enabled}") vprint(f"Sample video cleanup enabled: {sample_videos_enabled}")
has_duplicates_folder = (Path(share) / DUPLICATES_FOLDER_NAME).is_dir()
if ( if (
not delete_folder_names and not delete_file_patterns and not keep_languages not delete_folder_names and not delete_file_patterns and not keep_languages
and not delete_languages and not has_duplicates_folder and not sample_videos_enabled and not delete_languages and not sample_videos_enabled
): ):
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.")
@@ -3485,7 +3744,7 @@ def process_loose_movie_files(share, api_key, log, test_mode, test_limit, args,
files_renamed += 1 files_renamed += 1
continue continue
if not args.yes and not confirm(f"Create '{folder_name}' and move '{item.name}' into it?"): if not args.yes and not confirm_or_all(f"Create '{folder_name}' and move '{item.name}' into it?", args):
print(f"Skipped: {item.name}") print(f"Skipped: {item.name}")
folders_skipped += 1 folders_skipped += 1
continue continue
@@ -3579,7 +3838,7 @@ def process_loose_tv_files(share, api_key, log, test_mode, test_limit, args, nee
files_renamed += 1 files_renamed += 1
continue continue
if not args.yes and not confirm(f"Move '{item.name}' into show folder '{folder_name}'?"): if not args.yes and not confirm_or_all(f"Move '{item.name}' into show folder '{folder_name}'?", args):
print(f"Skipped: {item.name}") print(f"Skipped: {item.name}")
files_skipped += 1 files_skipped += 1
continue continue
@@ -3686,7 +3945,7 @@ def process_loose_subtitle_files(share, media_type, log, test_mode, args, needs_
renamed += 1 renamed += 1
continue continue
if not args.yes and not confirm(f"Move '{item.name}' to match '{folder.name}/{video.name}'?"): if not args.yes and not confirm_or_all(f"Move '{item.name}' to match '{folder.name}/{video.name}'?", args):
print(f"Skipped: {item.name}") print(f"Skipped: {item.name}")
skipped += 1 skipped += 1
continue continue
@@ -3765,6 +4024,8 @@ def run_scan(args, log):
share = resolve_share(path_arg) share = resolve_share(path_arg)
log.set_label(Path(share).name) log.set_label(Path(share).name)
migrate_legacy_duplicates_folder(share)
cache = load_scan_cache() cache = load_scan_cache()
share_key = str(Path(share).resolve()) share_key = str(Path(share).resolve())
baseline = cache.get(share_key) baseline = cache.get(share_key)
@@ -3841,6 +4102,16 @@ def run_scan(args, log):
print(f"[{index}/{total_folders}] {raw_name}") print(f"[{index}/{total_folders}] {raw_name}")
vprint(f"Processing folder: {folder}") vprint(f"Processing folder: {folder}")
if media_type == "movie" and len(list_video_files(folder)) >= 2:
bundle_result = handle_movie_bundle_folder(share, folder, api_key, log, test_mode, args, needs_attention)
if bundle_result is not None:
b_folders, b_moved, b_skipped = bundle_result
folders_renamed += b_folders
files_renamed += b_moved
files_skipped += b_skipped
examples_shown += 1
continue
if folder in lookup_cache: if folder in lookup_cache:
hint_year, (final_name, match_year, match_id, error) = lookup_cache[folder] hint_year, (final_name, match_year, match_id, error) = lookup_cache[folder]
if hint_year is not None: if hint_year is not None:
@@ -3853,15 +4124,6 @@ def run_scan(args, log):
final_name, match_year, match_id, error = lookup_folder(api_key, media_type, raw_name, hint_year) final_name, match_year, match_id, error = lookup_folder(api_key, media_type, raw_name, hint_year)
if error: if error:
if media_type == "movie" and len(list_video_files(folder)) >= 2:
bundle_result = handle_movie_bundle_folder(share, folder, api_key, log, test_mode, args, needs_attention)
if bundle_result is not None:
b_folders, b_moved, b_skipped = bundle_result
folders_renamed += b_folders
files_renamed += b_moved
files_skipped += b_skipped
examples_shown += 1
continue
print(error) print(error)
examples_shown += 1 examples_shown += 1
folders_skipped += 1 folders_skipped += 1
@@ -3905,7 +4167,7 @@ def run_scan(args, log):
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 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}'?"): if not args.yes and not confirm_or_all(f"Merge '{raw_name}' into existing show folder '{folder_name}'?", args):
print(f"Skipped: {raw_name}") print(f"Skipped: {raw_name}")
folders_skipped += 1 folders_skipped += 1
continue continue
@@ -3917,14 +4179,14 @@ def run_scan(args, log):
continue 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_or_all(f"Rename '{raw_name}' -> '{folder_name}'?", args):
print(f"Skipped: {raw_name}") print(f"Skipped: {raw_name}")
folders_skipped += 1 folders_skipped += 1
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 list_video_files(new_folder):
if args.yes or confirm(f"'{folder_name}' already exists. Move duplicate folder '{raw_name}' to {DUPLICATES_FOLDER_NAME}/?"): if args.yes or confirm_or_all(f"'{folder_name}' already exists. Move duplicate folder '{raw_name}' to {DUPLICATES_FOLDER_NAME}/?", args):
staged_dest = duplicate_folder_staging_path(share, folder) staged_dest = duplicate_folder_staging_path(share, folder)
ok, err = safe_move(folder, staged_dest) ok, err = safe_move(folder, staged_dest)
if ok: if ok:
@@ -4365,6 +4627,281 @@ def run_restore(log_arg):
print(f"Restored: {restored}, skipped: {skipped}") print(f"Restored: {restored}, skipped: {skipped}")
def load_suggestions_cache():
if SUGGESTIONS_CACHE_FILE.exists():
try:
data = json.loads(SUGGESTIONS_CACHE_FILE.read_text())
if isinstance(data, dict):
data.setdefault("movie", {})
data.setdefault("tv", {})
return data
except (json.JSONDecodeError, OSError):
pass
return {"movie": {}, "tv": {}}
def save_suggestions_cache(cache):
SUGGESTIONS_CACHE_FILE.write_text(json.dumps(cache, indent=2))
def run_clear_suggestions():
if SUGGESTIONS_CACHE_FILE.exists():
SUGGESTIONS_CACHE_FILE.unlink()
print("Cleared suggestions history.")
else:
print("No suggestions history to clear.")
def resolve_suggestions_library_index(share, media_type, api_key):
share_path = Path(share)
folders = sorted(p for p in share_path.iterdir() if is_library_content_folder(p))
share_key = str(share_path.resolve())
cache = load_scan_cache()
cache_key = f"suggestions_library::{media_type}::{share_key}"
index = cache.get(cache_key)
if not isinstance(index, dict):
index = {}
current_names = {f.name for f in folders}
index = {name: v for name, v in index.items() if name in current_names}
to_resolve = [f for f in folders if f.name not in index]
if to_resolve:
print(f"Resolving {len(to_resolve)} title(s) against TMDb (not cached yet)...")
with concurrent.futures.ThreadPoolExecutor(max_workers=LOOKUP_WORKERS) as executor:
futures = {}
for folder in to_resolve:
raw_name = folder.name
hint_year = extract_year(raw_name)
futures[executor.submit(_buffered_lookup, api_key, media_type, raw_name, hint_year)] = folder
for future in concurrent.futures.as_completed(futures):
folder = futures[future]
try:
result, buffer = future.result()
except Exception as e:
result, buffer = (None, None, None, f"Lookup failed: {folder.name} ({e})"), []
if VERBOSE and buffer:
print(f"-- {folder.name} --")
for line in buffer:
print(line)
final_name, match_year, match_id, error = result
if match_id:
index[folder.name] = {"id": match_id, "title": final_name, "year": match_year}
print()
cache[cache_key] = index
save_scan_cache(cache)
return index
def gather_recommendation_candidates(api_key, media_type, owned_ids, already_suggested):
seed_ids = list(owned_ids)
random.shuffle(seed_ids)
seed_ids = seed_ids[:SUGGESTION_SEED_SAMPLE_SIZE]
print(f"Checking TMDb recommendations from {len(seed_ids)} title(s)...")
print()
candidates = {}
with concurrent.futures.ThreadPoolExecutor(max_workers=LOOKUP_WORKERS) as executor:
futures = {
executor.submit(tmdb_related, api_key, media_type, seed_id, "recommendations"): seed_id
for seed_id in seed_ids
}
for future in concurrent.futures.as_completed(futures):
try:
results = future.result()
except Exception:
results = []
for r in results:
rid = r.get("id")
if not rid or rid in owned_ids or rid in already_suggested:
continue
entry = candidates.setdefault(rid, {"data": r, "score": 0})
entry["score"] += 1
return candidates
def gather_genre_candidates(api_key, media_type, genre_ids, owned_ids, already_suggested, target_count):
with_genres = "|".join(str(g) for g in genre_ids)
candidates = {}
page = 1
max_pages = 5
while len(candidates) < target_count * 3 and page <= max_pages:
results = tmdb_discover(api_key, media_type, {"with_genres": with_genres}, page=page)
if not results:
break
for r in results:
rid = r.get("id")
if not rid or rid in owned_ids or rid in already_suggested or rid in candidates:
continue
candidates[rid] = {"data": r, "score": 0}
page += 1
return candidates
def run_suggestions(args, share=None):
api_key = get_api_key()
get_tmdb_language()
if share is None:
share = resolve_share(None)
media_type = determine_media_type(share, getattr(args, "type", None))
genre_map = MOVIE_GENRES if media_type == "movie" else TV_GENRES
genre_names = None
count = DEFAULT_SUGGESTIONS_COUNT
if isinstance(args.suggestions, int):
count = args.suggestions if args.suggestions > 0 else DEFAULT_SUGGESTIONS_COUNT
elif isinstance(args.suggestions, str):
genre_names = [g.strip() for g in args.suggestions.split(",") if g.strip()]
genre_ids = []
if genre_names:
genre_ids, unresolved = resolve_genre_ids(genre_map, genre_names)
if unresolved:
print(f"Unrecognized genre(s): {', '.join(unresolved)}")
if not genre_ids:
valid = ", ".join(sorted(genre_map.values()))
print(f"No valid genres recognized. Available genres: {valid}")
return
print()
label = "Movies" if media_type == "movie" else "TV Shows"
if genre_ids:
label += f" ({', '.join(genre_map[g] for g in genre_ids)})"
print(f"Performing action: Suggestions ({label})")
print()
print(f"Scanning: {share}")
print()
index = resolve_suggestions_library_index(share, media_type, api_key)
owned_ids = {int(v["id"]) for v in index.values() if v.get("id")}
if not owned_ids and not genre_ids:
print("No matched titles found in this library yet. Run a rename scan first.")
return
if owned_ids:
print(f"Library has {len(owned_ids)} matched title(s).")
suggestions_cache = load_suggestions_cache()
already_suggested = {int(k) for k in suggestions_cache.get(media_type, {}).keys()}
if genre_ids:
candidates = gather_genre_candidates(api_key, media_type, genre_ids, owned_ids, already_suggested, count)
else:
candidates = gather_recommendation_candidates(api_key, media_type, owned_ids, already_suggested)
if not candidates:
print("No new suggestions found. Try again later, or clear your suggestions history with --clear-suggestions.")
return
ranked = sorted(
candidates.values(),
key=lambda c: (c["score"], c["data"].get("vote_average") or 0, c["data"].get("popularity") or 0),
reverse=True,
)
chosen = ranked[:count]
newly_suggested = {}
for c in chosen:
r = c["data"]
rid = r.get("id")
title = r.get("title") or r.get("name") or "Unknown"
date = r.get("release_date") or r.get("first_air_date") or ""
year = date[:4] if date else "----"
item_genre_names = [genre_map.get(gid, str(gid)) for gid in r.get("genre_ids", [])]
score = round((r.get("vote_average") or 0) * 10)
overview = r.get("overview") or "No overview available."
print(f"Name: {title} ({year})")
print(f"Genre: {', '.join(item_genre_names) if item_genre_names else 'Unknown'}")
print(f"Score: {score}%")
print("Overview:")
print(overview)
print()
newly_suggested[str(rid)] = {
"title": title,
"year": year,
"suggested_at": datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
}
suggestions_cache.setdefault(media_type, {}).update(newly_suggested)
save_suggestions_cache(suggestions_cache)
print(f"Shown {len(chosen)} suggestion(s). Recorded to history so they won't repeat next time.")
print("Clear history any time with --clear-suggestions.")
def write_export_file(export_path, export_format, entries):
if export_format == "txt":
export_path.write_text("\n".join(f"{title} ({year})" for title, year in entries) + "\n")
elif export_format == "csv":
with export_path.open("w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
for title, year in entries:
writer.writerow([f"{title} ({year})"])
elif export_format == "json":
data = [
{"Name": title, "Year": int(year) if year.isdigit() else year}
for title, year in entries
]
export_path.write_text(json.dumps(data, indent=2))
def run_export(args):
export_format = args.export
share = resolve_share(None)
media_type = determine_media_type(share, getattr(args, "type", None))
print()
print(f"Performing action: Export ({'Movies' if media_type == 'movie' else 'TV Shows'})")
print()
print(f"Scanning: {share}")
print()
share_path = Path(share)
folders = sorted(p for p in share_path.iterdir() if is_library_content_folder(p))
entries = []
skipped = []
for folder in folders:
title, year = parse_title_and_year(folder.name)
if year is None:
skipped.append(folder.name)
continue
entries.append((title, year))
if not entries:
print("No organized titles found to export. Run a rename scan first.")
return
EXPORTS_DIR.mkdir(exist_ok=True)
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
label = re.sub(r'[^A-Za-z0-9]+', '_', Path(share).name).strip('_')
export_path = EXPORTS_DIR / f"{timestamp}-{label}-export.{export_format}"
counter = 1
while export_path.exists():
export_path = EXPORTS_DIR / f"{timestamp}-{label}-export-{counter}.{export_format}"
counter += 1
write_export_file(export_path, export_format, entries)
print(f"Exported {len(entries)} title(s) to: {export_path}")
if skipped:
print()
print(f"Skipped {len(skipped)} folder(s) without a recognizable '(Year)' suffix (not yet organized):")
for name in skipped:
print(f" {name}")
def build_parser(): def build_parser():
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="Scan SMB media shares, match against TMDb, and rename folders/files into Plex-friendly structure." description="Scan SMB media shares, match against TMDb, and rename folders/files into Plex-friendly structure."
@@ -4430,6 +4967,28 @@ def build_parser():
help="Manually specify the library type (movies or tv) instead of auto-detecting from the share name " help="Manually specify the library type (movies or tv) instead of auto-detecting from the share name "
"or prompting." "or prompting."
) )
parser.add_argument(
"-s", "--suggestions",
nargs="?", const=DEFAULT_SUGGESTIONS_COUNT, type=parse_suggestions_arg, default=None, metavar="N|genre1,genre2",
help="Suggest new movies or TV shows based on what's already in your library, using TMDb "
f"recommendations. Defaults to {DEFAULT_SUGGESTIONS_COUNT} suggestions if N is not given. "
"Pass a comma-separated genre list instead of a number (e.g. --suggestions action,sci-fi) "
"to suggest purely from that genre, ignoring your library as the basis (still excludes "
"titles you already have). Previously suggested titles are remembered and won't repeat; "
"clear that history with --clear-suggestions."
)
parser.add_argument(
"--clear-suggestions",
action="store_true",
help="Clear the suggestions history so previously suggested titles can come up again."
)
parser.add_argument(
"--export",
type=normalize_export_format_arg, default=None, metavar="txt|csv|json",
help="Export a list of your movies or TV shows (name and year only, no episodes) to a file "
"under exports/. Choose 'txt' (one per line), 'csv' (one per row), or 'json' "
"([{Name, Year}]). Read-only, makes no changes to your library."
)
parser.add_argument( parser.add_argument(
"--service", "--service",
nargs="?", const="", default=None, metavar="start|stop|SECONDS|PATH", nargs="?", const="", default=None, metavar="start|stop|SECONDS|PATH",
@@ -4446,7 +5005,7 @@ def build_parser():
return parser return parser
BUNDLABLE_FLAGS = {"y", "f", "t", "r", "v", "c", "e"} BUNDLABLE_FLAGS = {"y", "f", "t", "r", "v", "c", "e", "s"}
PATH_TAKING_FLAGS = {"r", "c", "e"} PATH_TAKING_FLAGS = {"r", "c", "e"}
@@ -4784,9 +5343,63 @@ def run_service_worker():
time.sleep(interval) time.sleep(interval)
INTERACTIVE_MENU_ACTIONS = {
"1": "Rename",
"2": "Clean",
"3": "Rename and Clean",
"4": "Episodes",
"5": "Suggestions",
}
def select_interactive_action():
print()
print("Select an action to perform:")
for key in sorted(INTERACTIVE_MENU_ACTIONS, key=int):
print(f" {key}) {INTERACTIVE_MENU_ACTIONS[key]}")
while True:
sel = input(f"Select an action [1-{len(INTERACTIVE_MENU_ACTIONS)}]: ").strip()
if sel in INTERACTIVE_MENU_ACTIONS:
return sel
print("Invalid selection.")
def run_interactive_menu():
share = resolve_share(None)
action = select_interactive_action()
args = build_parser().parse_args([])
global VERBOSE
VERBOSE = args.verbose
log = RenameLog()
if action == "1":
args.rename = share
run_scan(args, log)
elif action == "2":
args.cleanup = share
run_cleanup(args, log)
elif action == "3":
args.rename = share
args.cleanup = share
original_yes = args.yes
run_scan(args, log)
args.yes = original_yes
print()
run_cleanup(args, log)
elif action == "4":
args.episodes = share
run_episode_check(args)
elif action == "5":
args.suggestions = DEFAULT_SUGGESTIONS_COUNT
run_suggestions(args, share=share)
def main(): def main():
if len(sys.argv) == 1: if len(sys.argv) == 1:
build_parser().print_help() run_interactive_menu()
return return
args = parse_args() args = parse_args()
@@ -4804,6 +5417,7 @@ def main():
args.yes or args.force or args.verbose or args.test is not None args.yes or args.force or args.verbose or args.test is not None
or args.rename or args.manual_rename or args.restore or args.rename or args.manual_rename or args.restore
or args.backup or args.cleanup or args.episodes or args.type or args.backup or args.cleanup or args.episodes or args.type
or args.suggestions is not None or args.clear_suggestions or args.export
) )
if other_args_used: if other_args_used:
print("--undo cannot be combined with any other argument.") print("--undo cannot be combined with any other argument.")
@@ -4816,6 +5430,10 @@ def main():
log = RenameLog() log = RenameLog()
if args.clear_suggestions:
run_clear_suggestions()
return
if args.restore: if args.restore:
run_restore(args.restore) run_restore(args.restore)
return return
@@ -4832,6 +5450,14 @@ def main():
run_episode_check(args) run_episode_check(args)
return return
if args.suggestions is not None:
run_suggestions(args)
return
if args.export:
run_export(args)
return
if args.cleanup and args.rename: if args.cleanup and args.rename:
path_arg = None path_arg = None
if isinstance(args.rename, str): if isinstance(args.rename, str):
@@ -4841,7 +5467,9 @@ def main():
share = resolve_share(path_arg) share = resolve_share(path_arg)
args.rename = share args.rename = share
args.cleanup = share args.cleanup = share
original_yes = args.yes
run_scan(args, log) run_scan(args, log)
args.yes = original_yes
print() print()
run_cleanup(args, log) run_cleanup(args, log)
return return