Files
plex-library-tool/plex-library-tool.py

5495 lines
188 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
import argparse
import concurrent.futures
import csv
import ctypes
import datetime
import difflib
import fnmatch
import hashlib
import json
import os
import platform
import random
import re
import shutil
import signal
import subprocess
import sys
import threading
import time
import unicodedata
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
SCRIPT_DIR = Path(__file__).resolve().parent
API_KEY_FILE = SCRIPT_DIR / ".env"
LOG_DIR = SCRIPT_DIR / "logs"
BACKUP_DIR = SCRIPT_DIR / "backups"
CACHE_FILE = SCRIPT_DIR / "cache.json"
TMDB_BASE = "https://api.themoviedb.org/3"
DELETE_FILE = SCRIPT_DIR / "delete.yaml"
NAMES_FILE = SCRIPT_DIR / "names.yaml"
CLEANUP_TRASH_DIR = SCRIPT_DIR / ".trash"
DUPLICATES_FOLDER_NAME = ".DUPLICATES"
LEGACY_DUPLICATES_FOLDER_NAME = "DUPLICATES"
SERVICE_CONFIG_FILE = SCRIPT_DIR / "service.json"
SERVICE_LOG_FILE = LOG_DIR / "service.log"
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
_vprint_local = threading.local()
def vprint(*args, **kwargs):
if not VERBOSE:
return
buffer = getattr(_vprint_local, "buffer", None)
if buffer is not None:
text = kwargs.get("sep", " ").join(str(a) for a in args)
buffer.append(text)
return
print(*args, **kwargs)
VIDEO_EXTENSIONS = {
"mp4", "mkv", "avi", "mov", "wmv", "m4v", "mpg", "mpeg", "flv", "ts", "m2ts", "webm",
}
SAMPLE_VIDEO_PATTERN = re.compile(r'(?<![A-Za-z0-9])sample(?![A-Za-z0-9])', re.IGNORECASE)
SUBTITLE_EXTENSIONS = {"srt", "sub"}
SUBTITLE_FOLDER_NAMES = {"subs", "subtitles"}
IGNORABLE_JUNK_FILENAMES = {".ds_store", "thumbs.db", "desktop.ini", ".localized"}
FILE_STABILITY_AGE_THRESHOLD = 30
FILE_STABILITY_WAIT_SECONDS = 2
def is_file_stable(path):
try:
stat1 = path.stat()
except OSError:
return False
age = time.time() - stat1.st_mtime
if age > FILE_STABILITY_AGE_THRESHOLD:
return True
vprint(f" {path.name}: modified {age:.0f}s ago, checking if still being written")
size1 = stat1.st_size
time.sleep(FILE_STABILITY_WAIT_SECONDS)
try:
stat2 = path.stat()
except OSError:
return False
stable = stat2.st_size == size1
if not stable:
vprint(f" {path.name}: size changed ({size1} -> {stat2.st_size}), still being written")
return stable
def is_ignorable_junk_file(path):
name = path.name.lower()
return name in IGNORABLE_JUNK_FILENAMES or name.startswith("._")
def purge_ignorable_junk(folder):
try:
entries = list(folder.iterdir())
except OSError:
return
for entry in entries:
if entry.is_file() and is_ignorable_junk_file(entry):
try:
entry.unlink()
except OSError:
pass
def is_effectively_empty(folder):
try:
entries = list(folder.iterdir())
except OSError:
return False
return all(entry.is_file() and is_ignorable_junk_file(entry) for entry in entries)
SEASON_EP_PATTERNS = [
(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})[Mm](\d{1,2})'), 'M'),
(re.compile(r'(?<![A-Za-z0-9])(\d{1,2})[xX](\d{1,2})(?![A-Za-z0-9])'), 'E'),
]
SEASON_ONLY_PATTERN = re.compile(r'(?<![A-Za-z0-9])[Ss](\d{1,2})')
SEASON_FOLDER_PATTERN = re.compile(r'season\s*0*(\d{1,3})\b', re.IGNORECASE)
SEASON_FOLDER_CANONICAL_PATTERN = re.compile(r'^[Ss](\d{1,3})$')
SPECIALS_FOLDER_PATTERN = re.compile(r'^specials?$', re.IGNORECASE)
EPISODE_ONLY_PATTERN = re.compile(r'\b[Ee](?:p(?:isode)?)?\.?\s*0*(\d{1,3})\b')
LEADING_NUMBER_PATTERN = re.compile(r'^0*(\d{1,3})[\s._-]')
LEGACY_SEE_PATTERN = re.compile(r'(?<![A-Za-z0-9])([1-9])(\d{2})(?![A-Za-z0-9])')
class RenameLog:
def __init__(self):
self.entries = []
self.label = None
self.log_path = None
def set_label(self, label):
self.label = label
def _ensure_path(self):
if self.log_path is not None:
return
LOG_DIR.mkdir(exist_ok=True)
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
suffix = f"-{re.sub(r'[^A-Za-z0-9]+', '_', self.label).strip('_')}" if self.label else ""
log_path = LOG_DIR / f"{timestamp}{suffix}.json"
counter = 1
while log_path.exists():
log_path = LOG_DIR / f"{timestamp}{suffix}-{counter}.json"
counter += 1
self.log_path = log_path
def record(self, from_path, to_path):
self.entries.append({"from": str(from_path), "to": str(to_path)})
self._ensure_path()
self.log_path.write_text(json.dumps(self.entries, indent=2), encoding="utf-8")
def save(self, label=None):
if not self.entries:
return None
if label and self.label is None:
self.set_label(label)
self._ensure_path()
self.log_path.write_text(json.dumps(self.entries, indent=2), encoding="utf-8")
return self.log_path
SUPERSCRIPT_DIGITS = {
'⁰': ' 0', '¹': ' 1', '²': ' 2', '³': ' 3',
'⁴': ' 4', '⁵': ' 5', '⁶': ' 6', '⁷': ' 7',
'⁸': ' 8', '⁹': ' 9',
}
VULGAR_FRACTIONS = {
'¼': ' 1/4', '½': ' 1/2', '¾': ' 3/4',
'⅓': ' 1/3', '⅔': ' 2/3',
'⅕': ' 1/5', '⅖': ' 2/5', '⅗': ' 3/5', '⅘': ' 4/5',
'⅙': ' 1/6', '⅚': ' 5/6',
'⅐': ' 1/7',
'⅛': ' 1/8', '⅜': ' 3/8', '⅝': ' 5/8', '⅞': ' 7/8',
'⅑': ' 1/9',
'⅒': ' 1/10',
}
def normalize_special_chars(name):
for special, normal in SUPERSCRIPT_DIGITS.items():
name = name.replace(special, normal)
for special, normal in VULGAR_FRACTIONS.items():
name = name.replace(special, normal)
name = name.replace('⁄', '/')
decomposed = unicodedata.normalize('NFKD', name)
return ''.join(c for c in decomposed if not unicodedata.combining(c))
def clean_name(name):
name = normalize_special_chars(name)
name = re.sub(r'[.,/-]+', ' ', name)
return re.sub(r'[^A-Za-z0-9 ]', '', name)
def squeeze_spaces(name):
return re.sub(r'\s+', ' ', name).strip()
def clean_and_squeeze(name):
return squeeze_spaces(clean_name(name))
def clean_name_keep_hyphens(name):
name = normalize_special_chars(name)
name = re.sub(r'[.,/]+', ' ', name)
return re.sub(r'[^A-Za-z0-9 \-]', '', name)
def clean_and_squeeze_keep_hyphens(name):
return squeeze_spaces(clean_name_keep_hyphens(name))
def clean_display_title(name):
return squeeze_spaces(normalize_special_chars(name))
def extract_year(name):
paren_years = re.findall(r'\((19\d{2}|20\d{2})\)', name)
if paren_years:
return paren_years[-1]
bare_years = re.findall(r'(?<![A-Za-z0-9])(19\d{2}|20\d{2})(?![A-Za-z0-9])', name)
if bare_years:
return bare_years[-1]
return None
def parse_season_episode(filename):
for pat, marker in SEASON_EP_PATTERNS:
m = pat.search(filename)
if m:
season = int(m.group(1))
episode = int(m.group(2))
if marker == 'E' and episode >= 100 and episode // 100 == season:
episode = episode % 100
return season, episode, marker
if parse_season_only(filename) is None and parse_episode_only(filename) is None:
m = LEGACY_SEE_PATTERN.search(filename)
if m:
season = int(m.group(1))
episode = int(m.group(2))
return season, episode, 'E'
return None
def parse_season_only(filename):
m = SEASON_ONLY_PATTERN.search(filename)
if m:
return int(m.group(1))
return None
def parse_episode_only(filename):
m = EPISODE_ONLY_PATTERN.search(filename)
if m:
return int(m.group(1))
m = LEADING_NUMBER_PATTERN.search(filename)
if m:
return int(m.group(1))
return None
TRAILING_TAG_PATTERN = re.compile(r'[\[\(][^\[\]\(\)]*[\]\)]\s*$')
TRAILING_NUMBER_PATTERN = re.compile(r'(?<![A-Za-z0-9])0*(\d{1,3})\s*$')
def parse_trailing_episode_number(filename):
stem = Path(filename).stem
stripped = stem
for _ in range(3):
new_stripped = TRAILING_TAG_PATTERN.sub('', stripped).rstrip()
if new_stripped == stripped:
break
stripped = new_stripped
m = TRAILING_NUMBER_PATTERN.search(stripped)
if m:
return int(m.group(1))
return None
def parse_season_folder_name(name):
stripped = name.strip()
if SPECIALS_FOLDER_PATTERN.match(stripped):
return 0
m = SEASON_FOLDER_CANONICAL_PATTERN.match(stripped)
if m:
return int(m.group(1))
m = SEASON_FOLDER_PATTERN.search(stripped)
if m:
return int(m.group(1))
m = SEASON_ONLY_PATTERN.search(stripped)
if m:
return int(m.group(1))
return None
def parse_env_file(path):
if not path.exists():
return {}
values = {}
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
values[key.strip()] = value.strip().strip('"').strip("'")
return values
def save_env_value(key, value):
values = parse_env_file(API_KEY_FILE)
values[key] = value
lines = [f"{k}={v}" for k, v in values.items()]
API_KEY_FILE.write_text("\n".join(lines) + "\n")
try:
os.chmod(API_KEY_FILE, 0o600)
except OSError:
pass
def get_api_key():
env_key = os.environ.get("TMDB_API_KEY")
if env_key:
return env_key.strip()
key = parse_env_file(API_KEY_FILE).get("TMDB_API_KEY", "").strip()
if key:
return key
print("TMDb API key not found.")
print("Get a free key:")
print(" 1. Log in (or create a free account): https://www.themoviedb.org/login")
print(" 2. Request an API key: https://www.themoviedb.org/settings/api/request")
print(" 3. Choose 'Developer'")
print(" 4. On the settings page, copy the 'API Key' value")
print(" (NOT the longer 'API Read Access Token')")
print()
key = input("Enter your TMDb API key: ").strip()
if not key:
print("No API key provided. Exiting.")
sys.exit(1)
save = input("Save this key to .env for future runs? [Y/n] ").strip().lower()
if save in ("", "y", "yes"):
save_env_value("TMDB_API_KEY", key)
return key
def detect_os_language():
system = platform.system()
if system == "Windows":
try:
buf = ctypes.create_unicode_buffer(85)
ctypes.windll.kernel32.GetUserDefaultLocaleName(buf, 85)
if buf.value:
return buf.value
except (AttributeError, OSError) as e:
vprint(f" Could not detect Windows locale: {e}")
return None
for var in ("LC_ALL", "LC_MESSAGES", "LANG"):
value = os.environ.get(var)
if value:
lang = value.split(".")[0].replace("_", "-")
if lang and lang.lower() not in ("c", "posix"):
return lang
return None
_TMDB_LANGUAGE = None
def get_tmdb_language():
global _TMDB_LANGUAGE
if _TMDB_LANGUAGE is not None:
return _TMDB_LANGUAGE
env_lang = os.environ.get("TMDB_LANGUAGE")
if env_lang:
_TMDB_LANGUAGE = env_lang.strip()
return _TMDB_LANGUAGE
saved_lang = parse_env_file(API_KEY_FILE).get("TMDB_LANGUAGE", "").strip()
if saved_lang:
_TMDB_LANGUAGE = saved_lang
return _TMDB_LANGUAGE
detected = detect_os_language()
if detected:
answer = input(f"Detected system language: {detected}. Use this for TMDb results (movie/show titles)? [Y/n] ").strip().lower()
if answer in ("", "y", "yes"):
chosen = detected
else:
custom = input("Enter a TMDb language code instead (e.g. en-US, es-MX, fr-FR), or press Enter for English: ").strip()
chosen = custom or "en-US"
else:
custom = input("Could not detect your system language. Enter a TMDb language code (e.g. en-US, es-MX, fr-FR), or press Enter for English: ").strip()
chosen = custom or "en-US"
save = input("Save this to .env for future runs? [Y/n] ").strip().lower()
if save in ("", "y", "yes"):
save_env_value("TMDB_LANGUAGE", chosen)
_TMDB_LANGUAGE = chosen
return chosen
def find_smb_mounts():
system = platform.system()
mounts = []
vprint(f"Detecting SMB mounts (platform: {system})")
if system == "Darwin":
try:
out = subprocess.run(
["mount"], capture_output=True, text=True, check=True
).stdout
for line in out.splitlines():
if "smbfs" in line:
m = re.search(r' on (.+?) \(', line)
if m:
mounts.append(m.group(1))
vprint(f" Found smbfs mount: {m.group(1)}")
except (subprocess.SubprocessError, OSError) as e:
vprint(f" 'mount' command failed: {e}")
if not mounts and os.path.isdir("/Volumes"):
vprint(" No smbfs mounts via 'mount', falling back to /Volumes listing")
mounts = [str(p) for p in Path("/Volumes").iterdir() if p.is_dir()]
for m in mounts:
vprint(f" Found /Volumes entry: {m}")
elif system == "Linux":
try:
with open("/proc/mounts") as f:
for line in f:
parts = line.split()
if len(parts) >= 3 and parts[2] == "cifs":
mounts.append(parts[1])
vprint(f" Found cifs mount: {parts[1]}")
except OSError as e:
vprint(f" Could not read /proc/mounts: {e}")
try:
gvfs_base = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
gvfs_dir = Path(gvfs_base) / "gvfs"
vprint(f" Checking GVFS directory: {gvfs_dir}")
if gvfs_dir.is_dir():
for entry in gvfs_dir.iterdir():
if entry.is_dir() and entry.name.startswith("smb-share:"):
mounts.append(str(entry))
vprint(f" Found GVFS smb-share mount: {entry}")
except OSError as e:
vprint(f" Could not read GVFS directory: {e}")
elif system == "Windows":
DRIVE_REMOTE = 4
try:
bitmask = ctypes.windll.kernel32.GetLogicalDrives()
for i in range(26):
if not (bitmask & (1 << i)):
continue
drive = f"{chr(ord('A') + i)}:\\"
drive_type = ctypes.windll.kernel32.GetDriveTypeW(ctypes.c_wchar_p(drive))
if drive_type == DRIVE_REMOTE:
mounts.append(drive)
vprint(f" Found mapped network drive: {drive}")
except (AttributeError, OSError) as e:
vprint(f" Could not enumerate drives via GetLogicalDrives: {e}")
try:
out = subprocess.run(
["net", "use"], capture_output=True, text=True, check=True
).stdout
for line in out.splitlines():
m = re.search(r'([A-Za-z]:)\s+(\\\\[^\s]+)', line)
if m:
unc = m.group(2)
if unc not in mounts:
mounts.append(unc)
vprint(f" Found 'net use' mapping: {unc}")
except (subprocess.SubprocessError, OSError) as e:
vprint(f" 'net use' command failed: {e}")
result = sorted(set(mounts))
vprint(f"Total SMB mounts found: {len(result)}")
return result
def select_mount(mounts):
print("Available SMB shares:")
for i, m in enumerate(mounts, 1):
print(f" {i}) {m}")
while True:
sel = input(f"Select a share to scan [1-{len(mounts)}]: ").strip()
if sel.isdigit() and 1 <= int(sel) <= len(mounts):
return mounts[int(sel) - 1]
print("Invalid selection.")
def resolve_share(path_arg=None):
if isinstance(path_arg, str):
vprint(f"Path given on the command line: {path_arg}")
if not Path(path_arg).is_dir():
print(f"Path not found: {path_arg}")
sys.exit(1)
return path_arg
mounts = find_smb_mounts()
if not mounts:
print("No SMB shares found.")
sys.exit(1)
return select_mount(mounts)
def select_media_type():
while True:
sel = input("Is this a Movies or TV Shows library? [M/T]: ").strip().lower()
if sel in ("m", "movie", "movies"):
return "movie"
if sel in ("t", "tv", "show", "shows", "tvshows"):
return "tv"
print("Please enter M or T.")
MOVIE_SHARE_KEYWORDS = ("movie", "film")
TV_SHARE_KEYWORDS = ("tv", "show", "series")
def infer_media_type(share):
path = Path(share)
candidates = [path.name]
if path.parent != path:
candidates.append(path.parent.name)
candidates = [c.lower() for c in candidates if c]
is_movie = any(any(k in c for k in MOVIE_SHARE_KEYWORDS) for c in candidates)
is_tv = any(any(k in c for k in TV_SHARE_KEYWORDS) for c in candidates)
if is_movie and not is_tv:
return "movie"
if is_tv and not is_movie:
return "tv"
return None
def normalize_media_type_arg(value):
v = value.strip().lower().replace(" ", "").replace("-", "").replace("_", "")
if v in ("m", "movie", "movies", "film", "films"):
return "movie"
if v in ("t", "tv", "tvshow", "tvshows", "show", "shows", "series"):
return "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):
if override:
label = "Movies" if override == "movie" else "TV Shows"
print(f"Using library type from --type: {label}")
return override
inferred = infer_media_type(share)
if inferred:
label = "Movies" if inferred == "movie" else "TV Shows"
print(f"Detected library type from share name: {label}")
return inferred
return select_media_type()
def is_v4_token(api_key):
return api_key.startswith("eyJ") or len(api_key) > 60
def tmdb_search(api_key, media_type, query):
v4 = is_v4_token(api_key)
params = {"query": query, "language": get_tmdb_language()}
if not v4:
params["api_key"] = api_key
endpoint = "search/movie" if media_type == "movie" else "search/tv"
url = f"{TMDB_BASE}/{endpoint}?{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) as e:
return None, str(e)
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):
v4 = is_v4_token(api_key)
params = {}
if not v4:
params["api_key"] = api_key
endpoint = f"movie/{tmdb_id}/alternative_titles" if media_type == "movie" else f"tv/{tmdb_id}/alternative_titles"
url = f"{TMDB_BASE}/{endpoint}"
if params:
url += f"?{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 []
key = "titles" if media_type == "movie" else "results"
entries = data.get(key, [])
return [e.get("title") or e.get("name") for e in entries if e.get("title") or e.get("name")]
def tmdb_tv_details(api_key, tv_id):
v4 = is_v4_token(api_key)
params = {"language": get_tmdb_language()}
if not v4:
params["api_key"] = api_key
url = f"{TMDB_BASE}/tv/{tv_id}?{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) as e:
return None, str(e)
return data, None
def tmdb_tv_season(api_key, tv_id, season_number):
v4 = is_v4_token(api_key)
params = {"language": get_tmdb_language()}
if not v4:
params["api_key"] = api_key
url = f"{TMDB_BASE}/tv/{tv_id}/season/{season_number}?{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) as e:
return None, str(e)
return data, None
def tmdb_tv_episode_groups(api_key, tv_id):
v4 = is_v4_token(api_key)
params = {}
if not v4:
params["api_key"] = api_key
url = f"{TMDB_BASE}/tv/{tv_id}/episode_groups"
if params:
url += f"?{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) as e:
return None, str(e)
return data, None
def tmdb_episode_group_details(api_key, group_id):
v4 = is_v4_token(api_key)
params = {}
if not v4:
params["api_key"] = api_key
url = f"{TMDB_BASE}/tv/episode_group/{group_id}"
if params:
url += f"?{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) as e:
return None, str(e)
return data, None
ABSOLUTE_EPISODE_GROUP_TYPE = 2
_ABSOLUTE_EPISODE_CACHE = {}
def get_absolute_episode_map(api_key, tv_id):
if tv_id in _ABSOLUTE_EPISODE_CACHE:
return _ABSOLUTE_EPISODE_CACHE[tv_id]
mapping = None
groups, err = tmdb_tv_episode_groups(api_key, tv_id)
if err:
vprint(f" Could not fetch episode groups for tv_id={tv_id}: {err}")
else:
candidates = [
g for g in groups.get("results", [])
if g.get("type") == ABSOLUTE_EPISODE_GROUP_TYPE
]
candidates.sort(key=lambda g: g.get("episode_count", 0), reverse=True)
if candidates:
group_id = candidates[0].get("id")
details, err = tmdb_episode_group_details(api_key, group_id)
if err:
vprint(f" Could not fetch absolute episode group details (id={group_id}): {err}")
else:
built = {}
absolute_number = 0
for group in details.get("groups", []):
for ep in group.get("episodes", []):
absolute_number += 1
season_number = ep.get("season_number")
episode_number = ep.get("episode_number")
if season_number is not None and episode_number is not None:
built[absolute_number] = (season_number, episode_number)
if built:
mapping = built
vprint(f" Loaded absolute episode order for tv_id={tv_id}: {len(built)} episode(s)")
if not mapping:
mapping = build_absolute_map_from_season_counts(api_key, tv_id)
_ABSOLUTE_EPISODE_CACHE[tv_id] = mapping
return mapping
def build_absolute_map_from_season_counts(api_key, tv_id):
details, err = tmdb_tv_details(api_key, tv_id)
if err:
vprint(f" Could not fetch tv details for tv_id={tv_id}: {err}")
return None
seasons = [
s for s in details.get("seasons", [])
if s.get("season_number", 0) > 0 and s.get("episode_count")
]
seasons.sort(key=lambda s: s.get("season_number"))
if not seasons:
return None
built = {}
absolute_number = 0
for s in seasons:
season_number = s.get("season_number")
for episode_number in range(1, s.get("episode_count") + 1):
absolute_number += 1
built[absolute_number] = (season_number, episode_number)
if built:
vprint(f" Built absolute episode order for tv_id={tv_id} from season episode counts: {len(built)} episode(s)")
return built
return None
def resolve_absolute_episode(api_key, tmdb_id, name):
if tmdb_id is None:
return None
absolute_number = parse_episode_only(name)
if absolute_number is None:
absolute_number = parse_trailing_episode_number(name)
if absolute_number is None:
return None
mapping = get_absolute_episode_map(api_key, tmdb_id)
if not mapping:
return None
return mapping.get(absolute_number)
def result_title(media_type, result):
return result.get("title") if media_type == "movie" else result.get("name")
def result_year(media_type, result):
date_field = result.get("release_date") if media_type == "movie" else result.get("first_air_date")
if date_field and len(date_field) >= 4:
return date_field[:4]
return None
def vprint_result_titles(media_type, results):
for r in results:
vprint(f" {result_title(media_type, r)!r} ({result_year(media_type, r)}) id={r.get('id')}")
YEAR_MATCH_MIN_SIMILARITY = 0.4
NEAR_YEAR_MAX_DIFF = 1
NEAR_YEAR_MATCH_MIN_SIMILARITY = 0.85
def best_match(media_type, results, year, query=None):
if not results:
return None
query_norm = clean_and_squeeze(query).lower() if query else None
def is_exact_title(r):
return query_norm is not None and clean_and_squeeze(result_title(media_type, r) or "").lower() == query_norm
if query_norm and year:
for r in results:
if is_exact_title(r) and result_year(media_type, r) == year:
return r
if year:
year_matches = [r for r in results if result_year(media_type, r) == year]
if not year_matches:
if not query_norm:
return None
def near_title_similarity(r):
return difflib.SequenceMatcher(
None, query_norm, clean_and_squeeze(result_title(media_type, r) or "").lower()
).ratio()
near_year_matches = [
r for r in results
if result_year(media_type, r) and abs(int(result_year(media_type, r)) - int(year)) <= NEAR_YEAR_MAX_DIFF
]
if near_year_matches:
best = max(near_year_matches, key=near_title_similarity)
threshold = YEAR_MATCH_MIN_SIMILARITY if len(near_year_matches) == 1 else NEAR_YEAR_MATCH_MIN_SIMILARITY
if near_title_similarity(best) >= threshold:
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
if not query_norm:
return year_matches[0]
def title_similarity(r):
return difflib.SequenceMatcher(
None, query_norm, clean_and_squeeze(result_title(media_type, r) or "").lower()
).ratio()
best = max(year_matches, key=title_similarity)
if title_similarity(best) >= YEAR_MATCH_MIN_SIMILARITY:
return best
return None
if query_norm:
for r in results:
if is_exact_title(r):
return r
def title_similarity(r):
return difflib.SequenceMatcher(
None, query_norm, clean_and_squeeze(result_title(media_type, r) or "").lower()
).ratio()
best = max(results, key=title_similarity)
if title_similarity(best) >= YEAR_MATCH_MIN_SIMILARITY:
return best
return None
return None
FUZZY_TITLE_MATCH_THRESHOLD = 0.85
def fuzzy_title_match(media_type, results, query, year):
if not query or not results or not year:
return None
query_norm = clean_and_squeeze(query).lower()
year_matches = [r for r in results if result_year(media_type, r) == year]
if not year_matches:
return None
def title_similarity(r):
title_norm = clean_and_squeeze(result_title(media_type, r) or "").lower()
if not title_norm:
return 0.0
return difflib.SequenceMatcher(None, query_norm, title_norm).ratio()
best = max(year_matches, key=title_similarity)
best_ratio = title_similarity(best)
threshold = YEAR_MATCH_MIN_SIMILARITY if len(year_matches) == 1 else FUZZY_TITLE_MATCH_THRESHOLD
if best_ratio >= threshold:
return best, best_ratio
return None
def same_existing_path(dest, src):
try:
return dest.exists() and src.exists() and dest.samefile(src)
except OSError:
return False
def safe_rename(src, dest):
try:
src.rename(dest)
return True, None
except FileExistsError:
return False, "target already exists"
except OSError as e:
return False, str(e)
def is_stray_metadata_name(name):
return name == ".DS_Store" or name.startswith("._")
def purge_stray_metadata(path):
for root, dirs, files in os.walk(str(path), topdown=False):
for name in files:
if is_stray_metadata_name(name):
try:
os.remove(os.path.join(root, name))
except OSError:
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):
try:
shutil.move(str(src), str(dest))
return True, None
except OSError as e:
src_path = Path(src)
dest_path = Path(dest)
if src_path.is_dir():
purge_stray_metadata(src_path)
purge_empty_subdirs(src_path)
try:
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
except OSError as e2:
return False, str(e2)
return False, str(e)
def confirm(prompt):
answer = input(f"{prompt} [y/N] ").strip().lower()
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):
while True:
answer = input(f"{prompt} [y/N/a] ").strip().lower()
if answer in ("y", "yes"):
return "y"
if answer in ("a", "all"):
return "a"
if answer in ("n", "no", ""):
return "n"
print("Please enter y (yes), n (no), or a (all).")
JUNK_TOKENS = {
"bluray", "blueray", "bdrip", "brrip", "bdremux", "remux",
"webrip", "webdl", "web", "dl", "hdtv", "hdrip", "dvdrip", "dvd",
"hevc", "x264", "x265", "h264", "h265", "avc", "xvid", "divx",
"aac", "ac3", "eac3", "dts", "atmos", "ddp", "dd", "rifftrax",
"proper", "repack", "extended", "unrated", "uncut", "explicit", "ultimate", "director", "directors", "cut",
"internal", "limited", "theatrical", "multi", "dual", "audio",
"hdr", "sdr", "4k", "uhd", "10bit", "8bit", "hi10p", "hi444pp",
"deluxe", "boxset", "box", "set", "extras", "hd",
"complete", "collection", "series",
"se", "ce", "dc", "remastered", "anniversary", "edition",
"amzn", "nf", "dsnp", "hmax", "atvp", "pcok", "hulu",
"mb", "gb",
"subbed", "dubbed", "subs", "multisub",
"telesync", "telecine", "camrip", "hdcam", "hdts", "hdtc",
"r5", "screener", "dvdscr", "workprint",
"retail",
}
SEASON_RANGE_PATTERN = re.compile(r'\bSeasons?\b(?:\s+\d{1,2})+', re.IGNORECASE)
IN_FORMAT_PATTERN = re.compile(r'\bin\s+(?:full\s+)?(?:hd|4k|uhd|sd)\b', re.IGNORECASE)
FILE_SIZE_PATTERN = re.compile(r'\b\d+(?:\.\d+)?\s?(?:MB|GB)\b', re.IGNORECASE)
BIT_DEPTH_PATTERN = re.compile(r'\b\d{1,2}bits?\b', re.IGNORECASE)
def strip_language_code_clusters(words):
filtered = []
i = 0
n = len(words)
while i < n:
j = i
while j < n and words[j].lower() in SHORT_LANGUAGE_CODE_TOKENS:
j += 1
if j - i >= 2:
i = j
continue
filtered.append(words[i])
i += 1
return filtered
VERSION_TAG_PATTERN = re.compile(r'\bv\d+(?:\.\d+)*\b', re.IGNORECASE)
LEADING_TRACK_NUMBER_PATTERN = re.compile(r'^0\d\s+')
GENRE_TAG_WORDS = {
"action", "adventure", "animation", "animated", "biography", "comedy",
"crime", "documentary", "drama", "family", "fantasy", "horror",
"musical", "mystery", "romance", "scifi", "sci-fi", "thriller",
"war", "western",
}
GENRE_TAG_PATTERN = re.compile(
r'-\s*(?:' + '|'.join(re.escape(w) for w in sorted(GENRE_TAG_WORDS, key=len, reverse=True)) + r')\s+(?=(?:19|20)\d{2}\b)',
re.IGNORECASE,
)
def strip_junk_tokens(text):
text = re.sub(r'\b\d{3,4}p\b', ' ', text, flags=re.IGNORECASE)
text = FILE_SIZE_PATTERN.sub(' ', text)
text = BIT_DEPTH_PATTERN.sub(' ', text)
text = SEASON_RANGE_PATTERN.sub(' ', text)
text = IN_FORMAT_PATTERN.sub(' ', text)
text = VERSION_TAG_PATTERN.sub(' ', text)
words = text.split()
kept = [w for w in words if w.lower() not in JUNK_TOKENS]
kept = strip_language_code_clusters(kept)
if len(kept) > 1 and kept[-1].lower() in ENGLISH_LANGUAGE_TOKENS:
kept = kept[:-1]
return squeeze_spaces(' '.join(kept))
SEASON_TRUNCATE_PATTERN = re.compile(r'\bSeasons?\b\s+\d', re.IGNORECASE)
BARE_SEASON_TRUNCATE_PATTERN = re.compile(r'(?<![A-Za-z0-9])[Ss]\d{1,2}(?:[Ee]\d{1,3})?(?![A-Za-z0-9])')
RELEASE_GROUP_SUFFIX_PATTERN = re.compile(
r'\b(x264|x265|h264|h265|hevc|avc|aac|ac3|eac3|dts|atmos|bluray|blueray|'
r'webrip|webdl|dvdrip|dvd|hdtv|hdrip|bdrip|brrip|remux)'
r'(?:[.\-_ ]+\w+)?[.\-_ ]+[A-Za-z0-9]+(?:\[[A-Za-z0-9]+\])?$',
re.IGNORECASE,
)
AUDIO_CHANNELS_PATTERN = re.compile(r'(?<![0-9])\d\.\d(?![0-9])')
AUDIO_CHANNEL_COUNT_PATTERN = re.compile(r'\b\d(?:ch|CH)\b')
AUDIO_CODEC_CHANNELS_PATTERN = re.compile(
r'\b(DDP?|EAC3|AC3|TrueHD|Atmos|DTS(?:-HD)?|FLAC|AAC)[.\s]?\d(?:[.\s]\d)?\b',
re.IGNORECASE,
)
VIDEO_CODEC_SPACED_PATTERN = re.compile(r'\b[Hh][.\s]?(26[45])\b')
LEADING_TAG_DASH_CHARS = '-' + chr(8211) + chr(8212)
LEADING_TAG_PATTERN = re.compile(r'^\s*\[[^\[\]]*\](?:\([^()]*\))?\s*[' + re.escape(LEADING_TAG_DASH_CHARS) + r']+\s*')
URL_PATTERN = re.compile(r'https?://\S+', re.IGNORECASE)
WWW_DOMAIN_PATTERN = re.compile(r'\bwww\.[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+\b', re.IGNORECASE)
TRAILING_PAREN_BLOCK_PATTERN = re.compile(r'\([^()]*\)\s*$')
TRAILING_BRACKET_TAG_PATTERN = re.compile(r'\[[^\[\]]*\]\s*$')
BARE_YEAR_PATTERN = re.compile(r'(19|20)\d{2}$')
def strip_leading_watermark(raw_name):
return LEADING_TAG_PATTERN.sub('', raw_name, count=1)
def strip_trailing_bracket_tags(name):
stripped = name
while True:
match = TRAILING_BRACKET_TAG_PATTERN.search(stripped)
if not match:
break
candidate = stripped[:match.start()].rstrip()
if not candidate:
break
stripped = candidate
return stripped
def strip_junk_trailing_paren(raw_name):
working = raw_name
bracket_match = TRAILING_BRACKET_TAG_PATTERN.search(working)
if bracket_match:
working = working[:bracket_match.start()]
match = TRAILING_PAREN_BLOCK_PATTERN.search(working)
if not match:
return raw_name
words = re.findall(r'[A-Za-z0-9]+', match.group(0))
if not words:
return raw_name
significant = [
w for w in words
if not re.fullmatch(r'\d{3,4}p', w, re.IGNORECASE)
and not BARE_YEAR_PATTERN.fullmatch(w)
and w.lower() not in JUNK_TOKENS
and w.lower() not in ENGLISH_LANGUAGE_TOKENS
and w.lower() not in OTHER_LANGUAGE_TOKENS
]
if 0 < len(significant) <= 1:
return working[:match.start()]
return raw_name
def build_query(raw_name, year, keep_hyphens=False):
raw_name = strip_leading_watermark(raw_name)
raw_name = LEADING_TRACK_NUMBER_PATTERN.sub('', raw_name)
raw_name = URL_PATTERN.sub(' ', raw_name)
raw_name = WWW_DOMAIN_PATTERN.sub(' ', raw_name)
raw_name = VERSION_TAG_PATTERN.sub(' ', raw_name)
raw_name = GENRE_TAG_PATTERN.sub(' ', raw_name)
cut_points = []
match = SEASON_TRUNCATE_PATTERN.search(raw_name)
if match:
cut_points.append(match.start())
match = BARE_SEASON_TRUNCATE_PATTERN.search(raw_name)
if match:
cut_points.append(match.start())
if cut_points:
cut = min(cut_points)
if cut > 0:
raw_name = raw_name[:cut]
raw_name = VIDEO_CODEC_SPACED_PATTERN.sub(r'H\1', raw_name)
raw_name = AUDIO_CODEC_CHANNELS_PATTERN.sub(' ', raw_name)
raw_name = AUDIO_CHANNELS_PATTERN.sub(' ', raw_name)
raw_name = AUDIO_CHANNEL_COUNT_PATTERN.sub(' ', raw_name)
raw_name = strip_junk_trailing_paren(raw_name)
match = RELEASE_GROUP_SUFFIX_PATTERN.search(raw_name)
if match:
raw_name = raw_name[:match.start()] + match.group(1)
raw_name = strip_trailing_bracket_tags(raw_name)
cleaner = clean_and_squeeze_keep_hyphens if keep_hyphens else clean_and_squeeze
cleaned = cleaner(raw_name)
if year:
without_year = squeeze_spaces(re.sub(rf'(?<!\d){year}(?!\d)', '', cleaned))
if without_year:
cleaned = without_year
cleaned = strip_junk_tokens(cleaned)
return cleaned
ALT_TITLE_CHECK_LIMIT = 20
DEFAULT_NAME_TEMPLATES = {
"movie_folder": "{title} ({year})",
"movie_folder_no_year": "{title}",
"show_folder": "{title} ({year})",
"show_folder_no_year": "{title}",
"movie_file": "{title}.{ext}",
"episode_file": "{title} S{season:02d}E{episode:02d}.{ext}",
"season_folder": "S{season:02d}",
"subtitle_file": "{stem}.{lang}.{ext}",
"subtitle_folder": "Subs",
"word_separator": " ",
"name_case": "",
}
FILESYSTEM_ILLEGAL_CHARS_PATTERN = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
EMPTY_GROUP_PATTERN = re.compile(r'[\(\[\{]\s*[\)\]\}]')
RESOLUTION_PATTERN = re.compile(r'\b(4320p|2160p|1080p|720p|480p|360p|4K|8K|UHD)\b', re.IGNORECASE)
RESOLUTION_CANONICAL = {
"4320p": "4320p", "2160p": "2160p", "1080p": "1080p", "720p": "720p",
"480p": "480p", "360p": "360p", "4k": "4K", "8k": "8K", "uhd": "UHD",
}
_NAME_TEMPLATES = None
def detect_resolution(name):
match = RESOLUTION_PATTERN.search(name)
if not match:
return None
return RESOLUTION_CANONICAL.get(match.group(1).lower())
def sanitize_rendered_name(name):
name = name.replace('/', '/').replace('\\', ' ')
name = FILESYSTEM_ILLEGAL_CHARS_PATTERN.sub('', name)
name = EMPTY_GROUP_PATTERN.sub('', name)
name = squeeze_spaces(name)
name = name.strip().rstrip('. ')
case = get_name_templates().get("name_case", "")
if case == "lower":
name = name.lower()
elif case == "upper":
name = name.upper()
return name
def load_name_templates():
templates = dict(DEFAULT_NAME_TEMPLATES)
if not NAMES_FILE.exists():
return templates
try:
lines = NAMES_FILE.read_text().splitlines()
except OSError as e:
vprint(f"Could not read {NAMES_FILE.name}: {e}")
return templates
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#") or ":" not in stripped:
continue
key, _, value = stripped.partition(":")
key = key.strip()
value = value.strip()
if key not in DEFAULT_NAME_TEMPLATES:
continue
quoted = len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'")
if quoted:
value = value[1:-1]
elif "#" in value:
value = value.split("#", 1)[0].strip()
if value or quoted:
templates[key] = value
vprint(f" Loaded naming template {key!r}: {value!r}")
return templates
def get_name_templates():
global _NAME_TEMPLATES
if _NAME_TEMPLATES is None:
_NAME_TEMPLATES = load_name_templates()
return _NAME_TEMPLATES
def render_name(template, fallback, **context):
try:
rendered = template.format(**context)
except (KeyError, ValueError, IndexError) as e:
vprint(f" Bad naming template {template!r}: {e}, falling back to default")
rendered = fallback.format(**context)
return sanitize_rendered_name(rendered)
def apply_word_separator(title):
templates = get_name_templates()
separator = templates.get("word_separator", " ")
if separator == " ":
return title
return title.replace(" ", separator)
def movie_folder_name(title, year, resolution=None):
templates = get_name_templates()
title = apply_word_separator(title)
resolution = resolution or ""
if year:
return render_name(templates["movie_folder"], DEFAULT_NAME_TEMPLATES["movie_folder"], title=title, year=int(year), resolution=resolution)
return render_name(templates["movie_folder_no_year"], DEFAULT_NAME_TEMPLATES["movie_folder_no_year"], title=title, resolution=resolution)
def show_folder_name(title, year, resolution=None):
templates = get_name_templates()
title = apply_word_separator(title)
resolution = resolution or ""
if year:
return render_name(templates["show_folder"], DEFAULT_NAME_TEMPLATES["show_folder"], title=title, year=int(year), resolution=resolution)
return render_name(templates["show_folder_no_year"], DEFAULT_NAME_TEMPLATES["show_folder_no_year"], title=title, resolution=resolution)
def movie_file_name(title, ext, resolution=None):
templates = get_name_templates()
title = apply_word_separator(title)
resolution = resolution or ""
return render_name(templates["movie_file"], DEFAULT_NAME_TEMPLATES["movie_file"], title=title, ext=ext, resolution=resolution)
def episode_file_name(title, season, episode, ext, resolution=None):
templates = get_name_templates()
title = apply_word_separator(title)
resolution = resolution or ""
return render_name(
templates["episode_file"], DEFAULT_NAME_TEMPLATES["episode_file"],
title=title, season=season, episode=episode, ext=ext, resolution=resolution,
)
def season_folder_name(season):
templates = get_name_templates()
return render_name(templates["season_folder"], DEFAULT_NAME_TEMPLATES["season_folder"], season=season)
def subtitle_file_name(stem, ext, lang):
templates = get_name_templates()
return render_name(templates["subtitle_file"], DEFAULT_NAME_TEMPLATES["subtitle_file"], stem=stem, ext=ext, lang=lang)
def subtitle_folder_name():
templates = get_name_templates()
return render_name(templates["subtitle_folder"], DEFAULT_NAME_TEMPLATES["subtitle_folder"])
def folder_target_name(media_type, final_name, match_year, raw_name=None):
resolution = detect_resolution(raw_name) if raw_name else None
if media_type == "movie":
return movie_folder_name(final_name, match_year, resolution)
return show_folder_name(final_name, match_year, resolution)
def infer_year_from_files(folder):
for path in sorted(folder.rglob("*")):
if path.is_file() and path.suffix.lower().lstrip(".") in VIDEO_EXTENSIONS:
year = extract_year(path.stem)
if year:
return year
return None
def lookup_folder(api_key, media_type, raw_name, hint_year=None):
year = extract_year(raw_name) or hint_year
query = build_query(raw_name, year)
vprint(f"Looking up: {raw_name!r} -> query={query!r}, year={year!r}, media_type={media_type!r}")
results, err = tmdb_search(api_key, media_type, query)
if err:
return None, None, None, f"Lookup failed: {raw_name} ({err})"
vprint(f" TMDb returned {len(results)} result(s)")
vprint_result_titles(media_type, results)
match = best_match(media_type, results, year, query)
if not match and "-" in raw_name:
hyphen_query = build_query(raw_name, year, keep_hyphens=True)
hyphen_query = hyphen_query.strip(" -")
if hyphen_query and hyphen_query != query:
quoted_query = f'"{hyphen_query}"'
vprint(f" No match, retrying as quoted phrase (TMDb treats bare hyphens as query operators): query={quoted_query!r}")
hyphen_results, hyphen_err = tmdb_search(api_key, media_type, quoted_query)
if not hyphen_err:
vprint(f" TMDb returned {len(hyphen_results)} result(s)")
vprint_result_titles(media_type, hyphen_results)
hyphen_match = best_match(media_type, hyphen_results, year, hyphen_query)
if hyphen_match:
match = hyphen_match
results = hyphen_results
query = hyphen_query
if not match and re.search(r'\band\b', query, re.IGNORECASE):
and_query = squeeze_spaces(re.sub(r'\band\b', '&', query, flags=re.IGNORECASE))
if and_query != query:
vprint(f" No match, retrying with 'and' as '&' (TMDb often stores the symbol): query={and_query!r}")
and_results, and_err = tmdb_search(api_key, media_type, and_query)
if not and_err:
vprint(f" TMDb returned {len(and_results)} result(s)")
vprint_result_titles(media_type, and_results)
and_match = best_match(media_type, and_results, year, and_query)
if and_match:
match = and_match
results = and_results
query = and_query
if not match and 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 (possible title typo or trailing 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)")
vprint_result_titles(media_type, broad_results)
fuzzy = fuzzy_title_match(media_type, broad_results, broadened_query, year)
if 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')})")
match = fuzzy_match
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)")
vprint_result_titles(media_type, broad_results)
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:
numeric_stripped_query = squeeze_spaces(re.sub(r'\b\d{1,2}\b', ' ', query))
if numeric_stripped_query and numeric_stripped_query != query:
vprint(f" No match, retrying with short numeric tokens removed (possible mistranscribed title number): query={numeric_stripped_query!r}")
numeric_results, numeric_err = tmdb_search(api_key, media_type, numeric_stripped_query)
if not numeric_err:
vprint(f" TMDb returned {len(numeric_results)} result(s)")
vprint_result_titles(media_type, numeric_results)
fuzzy = fuzzy_title_match(media_type, numeric_results, query, year)
if 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')})")
match = fuzzy_match
results = numeric_results
if not match:
return None, None, None, f"No match found (Ignoring): {raw_name}"
query_norm = clean_and_squeeze(query).lower()
title = result_title(media_type, match)
final_name = clean_display_title(title)
title_norm = clean_and_squeeze(title).lower()
vprint(f" Best match: {title!r} (id={match.get('id')})")
if title_norm != query_norm:
vprint(" Title differs from query, checking alternative titles...")
for candidate in results[:ALT_TITLE_CHECK_LIMIT]:
alt_titles = tmdb_alternative_titles(api_key, media_type, candidate.get("id"))
hit = next((a for a in alt_titles if clean_and_squeeze(a).lower() == query_norm), None)
if hit:
match = candidate
final_name = clean_display_title(hit)
vprint(f" Alternative title match: {hit!r} (id={candidate.get('id')})")
break
match_year = result_year(media_type, match)
match_id = match.get("id")
vprint(f" Resolved: {final_name!r} ({match_year})")
return final_name, match_year, match_id, None
def list_video_files(folder):
return [
item for item in sorted(folder.iterdir())
if item.is_file() and item.suffix.lower().lstrip(".") in VIDEO_EXTENSIONS
]
def stem_match_pattern(stem):
words = [w for w in re.split(r'[^A-Za-z0-9]+', stem) if w]
if not words:
return None
body = r'[\W_]*'.join(re.escape(w) for w in words)
return re.compile(r'^' + body + r'(?=[\W_])', re.IGNORECASE)
ENGLISH_LANGUAGE_TOKENS = {"en", "eng", "english"}
OTHER_LANGUAGE_TOKENS_RELIABLE = {
"spanish", "fre", "fra", "french",
"ger", "deu", "german", "ita", "italian",
"portuguese", "dut", "nld", "dutch",
"jpn", "japanese", "zho", "chinese", "cmn", "mandarin",
"kor", "korean", "rus", "russian", "ara", "arabic",
"swe", "swedish", "norwegian", "danish",
"finnish", "polish", "tur", "turkish",
"heb", "hebrew", "hin", "hindi", "tha", "thai",
"cze", "ces", "czech", "ell", "greek",
"hun", "hungarian", "romanian",
"vie", "vietnamese", "ind", "indonesian", "ukr", "ukrainian",
"malayalam", "tam", "tamil", "tel", "telugu", "kan", "kannada",
"bengali", "panjabi", "punjabi", "marathi",
"guj", "gujarati", "urd", "urdu", "odia", "oriya",
"asm", "assamese", "sanskrit", "nep", "nepali", "sinhala",
"fas", "persian", "farsi", "pashto",
"kur", "kurdish", "aze", "azerbaijani", "kaz", "kazakh",
"uzb", "uzbek", "tgk", "tajik", "tuk", "turkmen",
"tgl", "fil", "filipino", "tagalog", "msa", "malay",
"khm", "khmer", "lao", "laotian", "mya", "bur", "burmese", "myanmar",
"mongolian", "swa", "swahili", "amh", "amharic",
"hau", "hausa", "yor", "yoruba", "ibo", "igbo", "zul", "zulu",
"xho", "xhosa", "sna", "shona", "somali",
"sqi", "alb", "albanian", "hye", "armenian",
"eus", "baq", "basque", "belarusian", "bul", "bulgarian",
"catalan", "hrv", "croatian", "estonian",
"glg", "galician", "kat", "georgian", "isl", "icelandic",
"gle", "irish", "lav", "latvian", "lithuanian",
"ltz", "luxembourgish", "mkd", "macedonian", "mlt", "maltese",
"slk", "slo", "slovak", "slv", "slovenian",
"cym", "wel", "welsh", "yid", "yiddish", "haitian",
"srp", "serbian", "bos", "bosnian",
}
OTHER_LANGUAGE_TOKENS_RESTRICTED = {
"es", "fr", "de", "it", "pt", "nl", "ja", "zh", "ko", "ru", "ar",
"sv", "no", "da", "fi", "pl", "tr", "he", "hi", "th", "cs", "el",
"hu", "ro", "vi", "id", "uk",
"spa", "por", "chi", "dan", "pol", "fin", "gre",
"mal", "san", "sin", "per", "mon", "pan", "mar", "pus", "ron",
"may", "mac", "arm", "geo", "bel", "ice", "lit", "rum",
}
OTHER_LANGUAGE_TOKENS = OTHER_LANGUAGE_TOKENS_RELIABLE | OTHER_LANGUAGE_TOKENS_RESTRICTED
LANGUAGE_CANONICAL = {
"en": {"en", "eng", "english"},
"es": {"es", "spa", "spanish"},
"fr": {"fr", "fre", "fra", "french"},
"de": {"de", "ger", "deu", "german"},
"it": {"it", "ita", "italian"},
"pt": {"pt", "por", "portuguese"},
"nl": {"nl", "dut", "nld", "dutch"},
"ja": {"ja", "jpn", "japanese"},
"zh": {"zh", "chi", "zho", "chinese", "cmn", "mandarin"},
"ko": {"ko", "kor", "korean"},
"ru": {"ru", "rus", "russian"},
"ar": {"ar", "ara", "arabic"},
"sv": {"sv", "swe", "swedish"},
"no": {"no", "nor", "norwegian"},
"da": {"da", "dan", "danish"},
"fi": {"fi", "fin", "finnish"},
"pl": {"pl", "pol", "polish"},
"tr": {"tr", "tur", "turkish"},
"he": {"he", "heb", "hebrew"},
"hi": {"hi", "hin", "hindi"},
"th": {"th", "tha", "thai"},
"cs": {"cs", "cze", "ces", "czech"},
"el": {"el", "gre", "ell", "greek"},
"hu": {"hu", "hun", "hungarian"},
"ro": {"ro", "ron", "rum", "romanian"},
"vi": {"vi", "vie", "vietnamese"},
"id": {"id", "ind", "indonesian"},
"uk": {"uk", "ukr", "ukrainian"},
"ml": {"mal", "malayalam"},
"ta": {"tam", "tamil"},
"te": {"tel", "telugu"},
"kn": {"kan", "kannada"},
"bn": {"ben", "bengali"},
"pa": {"pan", "panjabi", "punjabi"},
"mr": {"mar", "marathi"},
"gu": {"guj", "gujarati"},
"ur": {"urd", "urdu"},
"or": {"ori", "odia", "oriya"},
"as": {"asm", "assamese"},
"sa": {"san", "sanskrit"},
"ne": {"nep", "nepali"},
"si": {"sin", "sinhala"},
"fa": {"per", "fas", "persian", "farsi"},
"ps": {"pus", "pashto"},
"ku": {"kur", "kurdish"},
"az": {"aze", "azerbaijani"},
"kk": {"kaz", "kazakh"},
"uz": {"uzb", "uzbek"},
"tg": {"tgk", "tajik"},
"tk": {"tuk", "turkmen"},
"tl": {"tgl", "fil", "filipino", "tagalog"},
"ms": {"may", "msa", "malay"},
"km": {"khm", "khmer"},
"lo": {"lao", "laotian"},
"my": {"mya", "bur", "burmese", "myanmar"},
"mn": {"mon", "mongolian"},
"sw": {"swa", "swahili"},
"am": {"amh", "amharic"},
"ha": {"hau", "hausa"},
"yo": {"yor", "yoruba"},
"ig": {"ibo", "igbo"},
"zu": {"zul", "zulu"},
"xh": {"xho", "xhosa"},
"sn": {"sna", "shona"},
"so": {"som", "somali"},
"sq": {"sqi", "alb", "albanian"},
"hy": {"hye", "arm", "armenian"},
"eu": {"eus", "baq", "basque"},
"be": {"bel", "belarusian"},
"bg": {"bul", "bulgarian"},
"ca": {"cat", "catalan"},
"hr": {"hrv", "croatian"},
"et": {"est", "estonian"},
"gl": {"glg", "galician"},
"ka": {"kat", "geo", "georgian"},
"is": {"isl", "ice", "icelandic"},
"ga": {"gle", "irish"},
"lv": {"lav", "latvian"},
"lt": {"lit", "lithuanian"},
"lb": {"ltz", "luxembourgish"},
"mk": {"mkd", "mac", "macedonian"},
"mt": {"mlt", "maltese"},
"sk": {"slk", "slo", "slovak"},
"sl": {"slv", "slovenian"},
"cy": {"cym", "wel", "welsh"},
"yi": {"yid", "yiddish"},
"ht": {"hat", "haitian"},
"sr": {"srp", "serbian"},
"bs": {"bos", "bosnian"},
}
LANGUAGE_ALIAS_TO_CODE = {
alias: code for code, aliases in LANGUAGE_CANONICAL.items() for alias in aliases
}
SHORT_LANGUAGE_CODE_TOKENS = {alias for alias in LANGUAGE_ALIAS_TO_CODE if len(alias) <= 3}
SUBTITLE_DESCRIPTOR_WORDS = {"forced", "sdh", "cc", "full", "commentary"}
def subtitle_descriptor_from_name(name):
stem = name.rsplit(".", 1)[0] if "." in name else name
segments = [s.lower() for s in re.split(r'[._\-\s]+', stem) if s]
if segments and segments[-1] in SUBTITLE_DESCRIPTOR_WORDS:
return segments[-1]
return None
def language_tag_from_name(name):
stem = name.rsplit(".", 1)[0] if "." in name else name
segments = [s.lower() for s in re.split(r'[._\-\s]+', stem) if s]
if not segments:
return None
trimmed = list(segments)
while len(trimmed) > 1 and trimmed[-1] in SUBTITLE_DESCRIPTOR_WORDS:
trimmed.pop()
if len(segments) == 1:
only = trimmed[-1] if trimmed else None
if only in ENGLISH_LANGUAGE_TOKENS:
return "en"
if only in OTHER_LANGUAGE_TOKENS_RELIABLE:
return "foreign"
return None
tag_window = set(segments[-2:])
last_only = {trimmed[-1]} if trimmed else set()
has_en = bool(tag_window & ENGLISH_LANGUAGE_TOKENS)
has_foreign = bool(tag_window & OTHER_LANGUAGE_TOKENS_RELIABLE) or bool(last_only & OTHER_LANGUAGE_TOKENS_RESTRICTED)
if has_en and not has_foreign:
return "en"
if has_foreign and not has_en:
return "foreign"
return None
def specific_language_from_name(name):
stem = name.rsplit(".", 1)[0] if "." in name else name
segments = [s.lower() for s in re.split(r'[._\-\s]+', stem) if s]
if not segments:
return None
trimmed = list(segments)
while len(trimmed) > 1 and trimmed[-1] in SUBTITLE_DESCRIPTOR_WORDS:
trimmed.pop()
if len(segments) == 1:
only = trimmed[-1] if trimmed else None
if only in ENGLISH_LANGUAGE_TOKENS:
return "en"
if only in OTHER_LANGUAGE_TOKENS_RELIABLE:
return LANGUAGE_ALIAS_TO_CODE.get(only)
return None
tag_window = segments[-2:]
last_only = trimmed[-1] if trimmed else None
for seg in tag_window:
if seg in ENGLISH_LANGUAGE_TOKENS:
return "en"
if seg in OTHER_LANGUAGE_TOKENS_RELIABLE:
return LANGUAGE_ALIAS_TO_CODE.get(seg)
if last_only and last_only in OTHER_LANGUAGE_TOKENS_RESTRICTED:
return LANGUAGE_ALIAS_TO_CODE.get(last_only)
return None
def resolve_subtitle_language(path):
hint = specific_language_from_name(path.name)
if hint:
return hint
detected = detect_subtitle_language(path)
if detected and detected != "unknown":
return LANGUAGE_ALIAS_TO_CODE.get(detected, detected)
return None
TRAILING_WORD_PATTERN = re.compile(r'[._\-\s]+([A-Za-z]+)\s*$')
def strip_subtitle_language_suffix(stem):
result = stem
while True:
m_trail = TRAILING_WORD_PATTERN.search(result)
if not m_trail:
break
word = m_trail.group(1).lower()
if word in SUBTITLE_DESCRIPTOR_WORDS or word in ENGLISH_LANGUAGE_TOKENS or word in OTHER_LANGUAGE_TOKENS:
result = result[:m_trail.start()]
else:
break
return result if result.strip() else stem
def gather_video_subtitles(video_path, multi):
folder = video_path.parent
pattern = stem_match_pattern(video_path.stem) if multi else None
matches = []
search_dirs = [folder]
try:
for entry in sorted(folder.iterdir()):
if entry.is_dir() and entry.name.lower() in SUBTITLE_FOLDER_NAMES:
search_dirs.append(entry)
except OSError:
pass
for search_dir in search_dirs:
try:
entries = sorted(search_dir.iterdir())
except OSError:
continue
for item in entries:
if not item.is_file() or item == video_path:
continue
ext = item.suffix.lower().lstrip(".")
if ext not in SUBTITLE_EXTENSIONS:
continue
if multi and (pattern is None or not pattern.match(item.name)):
continue
matches.append(item)
return matches
SDH_CUE_PATTERN = re.compile(r'[\[\(][A-Za-z][^\]\)]{1,40}[\]\)]')
FORCED_LINE_RATIO_THRESHOLD = 0.4
FORCED_MIN_MAX_LINES = 10
def read_subtitle_text(path):
try:
raw = path.read_bytes()
except OSError:
return None
for encoding in ("utf-8-sig", "utf-8", "cp1252", "latin-1"):
try:
return raw.decode(encoding)
except (UnicodeDecodeError, LookupError):
continue
return None
def subtitle_dialogue_lines(text):
lines = []
for line in text.splitlines():
line = line.strip()
if not line or line.isdigit() or "-->" in line:
continue
lines.append(line)
return lines
def count_subtitle_dialogue_lines(path):
text = read_subtitle_text(path)
if text is None:
return 0
return len(subtitle_dialogue_lines(text))
def detect_sdh_from_content(path):
text = read_subtitle_text(path)
if text is None:
return False
lines = subtitle_dialogue_lines(text)
dialogue_lines = len(lines)
cue_lines = sum(1 for line in lines if SDH_CUE_PATTERN.search(line))
if dialogue_lines < 5:
vprint(f" {path.name}: only {dialogue_lines} dialogue line(s), too little to classify as SDH")
return False
is_sdh = cue_lines >= 3 or (cue_lines / dialogue_lines) >= 0.15
vprint(f" {path.name}: {cue_lines}/{dialogue_lines} line(s) have sound cues -> SDH={is_sdh}")
return is_sdh
def subtitle_content_hash(path):
text = read_subtitle_text(path)
if text is None:
return None
lines = subtitle_dialogue_lines(text)
if not lines:
return None
normalized = "\n".join(lines).strip().lower()
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
def duplicate_trash_path(item):
dest_dir = CLEANUP_TRASH_DIR / "duplicate-subtitles"
dest_dir.mkdir(parents=True, exist_ok=True)
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 duplicate_file_staging_path(share, context_name, item):
dest_dir = Path(share) / DUPLICATES_FOLDER_NAME / context_name
dest_dir.mkdir(parents=True, exist_ok=True)
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):
staged_dest = duplicate_file_staging_path(share, context_name, item)
ok, err = safe_move(item, staged_dest)
if not ok:
print(f"Skipping (could not move to {DUPLICATES_FOLDER_NAME}/: {err}): {item.name}")
cleanup_empty_staging_dir(share, context_name)
return False
log.record(item, staged_dest)
print(f"Moved duplicate episode to {DUPLICATES_FOLDER_NAME}/{context_name}/: {item.name}")
return True
def compute_consolidated_subtitle_pairs(subtitles, new_video_path):
new_stem = new_video_path.stem
target_dir = new_video_path.parent / subtitle_folder_name()
primary_lang = get_tmdb_language().split("-")[0].lower()
by_ext = {}
for item in subtitles:
by_ext.setdefault(item.suffix.lower(), []).append(item)
canonical = {}
duplicates = set()
for ext, items in by_ext.items():
detected = {i: resolve_subtitle_language(i) for i in items}
group = [i for i in items if detected[i] == primary_lang]
if not group:
fallback = next((i for i in items if detected[i] is None), None)
if fallback:
group = [fallback]
descriptors = {}
if len(group) > 1:
for item in group:
descriptor = subtitle_descriptor_from_name(item.name)
if descriptor is None and detect_sdh_from_content(item):
descriptor = "sdh"
descriptors[item] = descriptor
undecided = [i for i in group if descriptors[i] is None]
if len(undecided) > 1:
line_counts = {i: count_subtitle_dialogue_lines(i) for i in undecided}
max_lines = max(line_counts.values())
if max_lines >= FORCED_MIN_MAX_LINES:
for i in undecided:
if 0 < line_counts[i] <= max_lines * FORCED_LINE_RATIO_THRESHOLD:
vprint(f" {i.name}: {line_counts[i]}/{max_lines} line(s) vs longest track -> forced")
descriptors[i] = "forced"
still_undecided = [i for i in group if descriptors[i] is None]
if len(still_undecided) > 1:
by_hash = {}
for i in still_undecided:
h = subtitle_content_hash(i)
if h is None:
continue
by_hash.setdefault(h, []).append(i)
for dupes in by_hash.values():
if len(dupes) < 2:
continue
keeper = min(dupes, key=lambda p: (len(p.name), p.name))
for d in dupes:
if d != keeper:
duplicates.add(d)
vprint(f" {d.name}: identical content to {keeper.name}, will move to trash")
used_names = set()
for item in group:
if item in duplicates:
continue
descriptor = descriptors.get(item)
lang_tag = f"{primary_lang}.{descriptor}" if descriptor else primary_lang
name = subtitle_file_name(new_stem, ext.lstrip("."), lang_tag)
if name in used_names:
continue
used_names.add(name)
canonical[item] = name
pairs = []
for item in subtitles:
if item in duplicates:
pairs.append((item, None))
continue
dest_name = canonical.get(item, item.name)
dest = target_dir / dest_name
pairs.append((item, dest))
if item.suffix.lower() == ".sub":
idx_item = item.with_suffix(".idx")
if idx_item.exists():
if item in duplicates:
idx_dest = None
elif item in canonical:
idx_dest = target_dir / (canonical[item].rsplit(".", 1)[0] + ".idx")
else:
idx_dest = target_dir / idx_item.name
pairs.append((idx_item, idx_dest))
return pairs
def unique_destination(dest):
if not dest.exists():
return dest
stem = dest.stem
ext = dest.suffix
counter = 2
while True:
candidate = dest.parent / f"{stem} ({counter}){ext}"
if not candidate.exists():
return candidate
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):
if item.parent != dest.parent or item.suffix.lower() != dest.suffix.lower():
return False
pattern = re.compile(r'^' + re.escape(dest.stem) + r' \(\d+\)$', re.IGNORECASE)
return bool(pattern.match(item.stem))
def rename_consolidated_subtitles(subtitles, new_video_path, log):
renamed = 0
source_dirs = set()
for item, dest in compute_consolidated_subtitle_pairs(subtitles, new_video_path):
if dest is None:
source_dir = item.parent
trash_dest = duplicate_trash_path(item)
ok, err = safe_move(item, trash_dest)
if not ok:
print(f"Skipping duplicate subtitle ({err}): {item.name}")
continue
log.record(item, trash_dest)
print(f"Moved duplicate subtitle to trash: {item.name}")
renamed += 1
if source_dir.name.lower() in SUBTITLE_FOLDER_NAMES:
source_dirs.add(source_dir)
continue
if dest == item:
continue
if dest.exists() and not same_existing_path(dest, item):
if is_already_disambiguated(item, dest):
continue
dest = unique_destination(dest)
dest.parent.mkdir(exist_ok=True)
source_dir = item.parent
ok, err = safe_rename(item, dest)
if not ok:
print(f"Skipping subtitle ({err}): {item.name}")
continue
log.record(item, dest)
label = "subtitle index" if dest.suffix.lower() == ".idx" else "subtitle"
if source_dir == dest.parent:
print(f"Renamed {label}: {item.name} -> {dest.name}")
else:
print(f"Moved {label}: {item.name} -> {dest.parent.name}/{dest.name}")
renamed += 1
if source_dir.name.lower() in SUBTITLE_FOLDER_NAMES:
source_dirs.add(source_dir)
for source_dir in source_dirs:
try:
if source_dir.exists() and is_effectively_empty(source_dir):
purge_ignorable_junk(source_dir)
source_dir.rmdir()
print(f"Removed empty folder: {source_dir}")
except OSError:
pass
return renamed
def preview_consolidated_subtitles(subtitles, new_video_path):
for item, dest in compute_consolidated_subtitle_pairs(subtitles, new_video_path):
if dest is None:
print(f"Would move duplicate subtitle to trash: {item.name}")
continue
if dest == item:
continue
label = "subtitle index" if dest.suffix.lower() == ".idx" else "subtitle"
if item.parent == dest.parent:
print(f"Renamed {label}: {item.name} -> {dest.name}")
else:
print(f"Moved {label}: {item.name} -> {dest.parent.name}/{dest.name}")
def organize_subtitle_folder(folder, log):
try:
entries = sorted(folder.iterdir())
except OSError:
return
canonical_name = subtitle_folder_name()
for entry in entries:
if not entry.is_dir() or entry.name == canonical_name:
continue
if entry.name.lower() not in SUBTITLE_FOLDER_NAMES:
continue
dest = folder / canonical_name
if dest.exists() and not same_existing_path(dest, entry):
print(f"Skipping (target already exists): {entry.name} -> {canonical_name}")
continue
ok, err = safe_rename(entry, dest)
if not ok:
print(f"Skipping ({err}): {entry.name} -> {canonical_name}")
continue
log.record(entry, dest)
print(f"Renamed folder: {entry.name} -> {canonical_name}")
def preview_subtitle_folder(folder):
try:
entries = sorted(folder.iterdir())
except OSError:
return
canonical_name = subtitle_folder_name()
for entry in entries:
if not entry.is_dir() or entry.name == canonical_name:
continue
if entry.name.lower() not in SUBTITLE_FOLDER_NAMES:
continue
print(f"Renamed folder: {entry.name} -> {canonical_name}")
def is_library_content_folder(p):
if not p.is_dir() or p.name.startswith("."):
return False
return p.name.upper() != LEGACY_DUPLICATES_FOLDER_NAME.upper()
def list_subfolders(folder):
return sorted(p for p in folder.iterdir() if p.is_dir() and not p.name.startswith("."))
def preview_season_folders(folder):
for sub in list_subfolders(folder):
season = parse_season_folder_name(sub.name)
if season is None:
continue
new_name = season_folder_name(season)
if new_name != sub.name:
print(f"Renamed folder: {sub.name} -> {new_name}")
def organize_season_folders(folder, log):
renamed = 0
for sub in list_subfolders(folder):
season = parse_season_folder_name(sub.name)
if season is None:
continue
new_name = season_folder_name(season)
if new_name == sub.name:
continue
dest = folder / new_name
if dest.exists() and not same_existing_path(dest, sub):
print(f"Skipping (target already exists): {sub.name} -> {new_name}")
continue
ok, err = safe_rename(sub, dest)
if not ok:
print(f"Skipping ({err}): {sub.name} -> {new_name}")
continue
log.record(sub, dest)
print(f"Renamed folder: {sub.name} -> {new_name}")
renamed += 1
return renamed
def resolve_season_folder_file(item, season, api_key=None, tmdb_id=None):
se = parse_season_episode(item.name)
if se:
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)
episode = parse_episode_only(item.name)
if episode is None:
resolved = resolve_absolute_episode(api_key, tmdb_id, item.name)
if resolved is not None:
return resolved[0], resolved[1], 'E'
return file_season, episode, 'E'
def preview_season_folder_files(folder, final_name, api_key=None, tmdb_id=None):
for sub in list_subfolders(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, api_key, tmdb_id)
target_season = file_season if file_season is not None else season
subtitles = gather_video_subtitles(item, True)
if episode is None:
if target_season != season:
target_season_folder = season_folder_name(target_season)
dest = folder / target_season_folder / item.name
print(f"Move: {item.name} -> {target_season_folder}/{item.name}")
preview_consolidated_subtitles(subtitles, dest)
else:
print(f"No episode found, already in {season_folder_name(season)}: {item.name}")
continue
new_name = episode_file_name(final_name, target_season, episode, ext, detect_resolution(item.name))
if target_season != season:
target_season_folder = season_folder_name(target_season)
dest = folder / target_season_folder / new_name
print(f"Move: {item.name} -> {target_season_folder}/{new_name}")
preview_consolidated_subtitles(subtitles, dest)
elif new_name != item.name:
dest = sub / new_name
print(f"Renamed file: {item.name} -> {new_name}")
preview_consolidated_subtitles(subtitles, dest)
else:
preview_consolidated_subtitles(subtitles, item)
preview_orphaned_subtitle_packs(folder, folder, None)
def rename_season_folder_files(share, folder, final_name, log, api_key=None, tmdb_id=None):
renamed = 0
skipped = 0
for sub in list_subfolders(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):
if not is_file_stable(item):
print(f"Skipping (still being copied): {item.name}")
skipped += 1
continue
ext = item.suffix.lower().lstrip(".")
file_season, episode, marker = resolve_season_folder_file(item, season, api_key, tmdb_id)
target_season = file_season if file_season is not None else season
target_season_folder = season_folder_name(target_season)
target_dir = folder / target_season_folder if target_season != season else sub
subtitles = gather_video_subtitles(item, True)
if episode is None:
if target_season == season:
print(f"No episode found, already in {season_folder_name(season)}: {item.name}")
skipped += 1
continue
new_name = item.name
else:
new_name = episode_file_name(final_name, target_season, episode, ext, detect_resolution(item.name))
dest = target_dir / new_name
if dest == item:
renamed += rename_consolidated_subtitles(subtitles, item, log)
continue
if dest.exists() and not same_existing_path(dest, item):
if stage_duplicate_episode(share, folder.name, item, log):
renamed += 1
else:
skipped += 1
continue
if target_dir != sub:
target_dir.mkdir(exist_ok=True)
ok, err = safe_rename(item, dest)
if not ok:
print(f"Skipping ({err}): {item.name}")
skipped += 1
continue
log.record(item, dest)
if target_dir != sub:
print(f"Moved: {item.name} -> {target_season_folder}/{new_name}")
else:
print(f"Renamed file: {item.name} -> {new_name}")
renamed += 1
renamed += rename_consolidated_subtitles(subtitles, dest, log)
renamed += merge_orphaned_subtitle_packs(folder, folder, None, log)
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 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 build_movie_folder_index(share):
index = []
for folder in sorted(p for p in Path(share).iterdir() if is_library_content_folder(p)):
videos = list_video_files(folder)
if len(videos) != 1:
continue
folder_year = extract_year(folder.name)
folder_query = build_query(folder.name, folder_year)
index.append((folder, videos[0], folder_year, folder_query))
return index
def match_movie_from_index(index, query, year):
words = query.split()
while words:
candidate = " ".join(words)
best = None
best_video = None
best_ratio = 0.0
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:
continue
ratio = difflib.SequenceMatcher(None, candidate.lower(), folder_query.lower()).ratio()
if ratio > best_ratio:
best_ratio = ratio
best = folder
best_video = video
if best is not None and best_ratio >= NEAR_YEAR_MATCH_MIN_SIMILARITY:
return best, best_video
if len(words) <= 2:
break
words = words[:-1]
return None, None
def build_show_folder_index(share):
index = []
for folder in sorted(p for p in Path(share).iterdir() if is_library_content_folder(p)):
folder_year = extract_year(folder.name)
folder_query = build_query(folder.name, folder_year)
index.append((folder, folder_year, folder_query))
return index
def match_show_from_index(index, query, year):
words = query.split()
while words:
candidate = " ".join(words)
best = None
best_ratio = 0.0
for folder, folder_year, folder_query in index:
if year and folder_year and abs(int(folder_year) - int(year)) > NEAR_YEAR_MAX_DIFF:
continue
ratio = difflib.SequenceMatcher(None, candidate.lower(), folder_query.lower()).ratio()
if ratio > best_ratio:
best_ratio = ratio
best = folder
if best is not None and best_ratio >= NEAR_YEAR_MATCH_MIN_SIMILARITY:
return best
if len(words) <= 2:
break
words = words[:-1]
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 is_effectively_empty(leftover):
purge_ignorable_junk(leftover)
leftover.rmdir()
if pack.exists() and is_effectively_empty(pack):
purge_ignorable_junk(pack)
pack.rmdir()
except OSError:
pass
try:
if subs_folder.exists() and is_effectively_empty(subs_folder):
purge_ignorable_junk(subs_folder)
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(share, 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):
if not is_file_stable(item):
print(f"Skipping (still being copied): {item.name}")
skipped += 1
continue
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):
if stage_duplicate_episode(share, target_folder.name, item, log):
moved += 1
else:
skipped += 1
continue
item_target_dir.mkdir(exist_ok=True)
ok, err = safe_rename(item, dest)
if not ok:
print(f"Skipping ({err}): {item.name}")
skipped += 1
continue
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)
moved += merge_orphaned_subtitle_packs(sub, target_folder, season, log)
try:
if sub.exists() and is_effectively_empty(sub):
purge_ignorable_junk(sub)
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):
if not is_file_stable(item):
print(f"Skipping (still being copied): {item.name}")
skipped += 1
continue
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):
if stage_duplicate_episode(share, target_folder.name, item, log):
moved += 1
else:
skipped += 1
continue
item_target_dir.mkdir(exist_ok=True)
ok, err = safe_rename(item, dest)
if not ok:
print(f"Skipping ({err}): {item.name}")
skipped += 1
continue
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)
moved += merge_orphaned_subtitle_packs(source_folder, target_folder, folder_season, log)
try:
if source_folder.exists() and is_effectively_empty(source_folder):
purge_ignorable_junk(source_folder)
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)
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)
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)
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)
if media_type == "movie":
preview_subtitle_folder(folder)
multi = len(files) > 1
for item in files:
ext = item.suffix.lower().lstrip(".")
name = final_name
if multi:
per_name, _, _, error = lookup_folder(api_key, media_type, item.stem)
if error:
print(error)
continue
name = per_name
new_name = movie_file_name(name, ext, detect_resolution(item.name))
dest = folder / new_name
subtitles = gather_video_subtitles(item, multi)
if new_name != item.name:
print(f"Renamed file: {item.name} -> {new_name}")
preview_consolidated_subtitles(subtitles, dest)
else:
preview_consolidated_subtitles(subtitles, item)
return
for item in files:
ext = item.suffix.lower().lstrip(".")
se = parse_season_episode(item.name)
subtitles = gather_video_subtitles(item, True)
if se:
season, episode, marker = se
new_name = episode_file_name(final_name, season, episode, ext, detect_resolution(item.name))
target_season_folder = season_folder_name(season)
print(f"Move: {item.name} -> {target_season_folder}/{new_name}")
preview_consolidated_subtitles(subtitles, folder / target_season_folder / new_name)
continue
season = parse_season_only(item.name)
if season is None:
resolved = resolve_absolute_episode(api_key, tmdb_id, item.name)
if resolved is None:
print(f"No season/episode found, skipping: {item.name}")
continue
season, episode = resolved
new_name = episode_file_name(final_name, season, episode, ext, detect_resolution(item.name))
target_season_folder = season_folder_name(season)
print(f"Move: {item.name} -> {target_season_folder}/{new_name}")
preview_consolidated_subtitles(subtitles, folder / target_season_folder / new_name)
continue
target_season_folder = season_folder_name(season)
print(f"Move: {item.name} -> {target_season_folder}/{item.name}")
preview_consolidated_subtitles(subtitles, folder / target_season_folder / item.name)
def rename_video_files(share, folder, media_type, final_name, log, api_key, tmdb_id=None, folder_year=None):
renamed = 0
skipped = 0
files = list_video_files(folder)
if media_type == "movie":
organize_subtitle_folder(folder, log)
multi = len(files) > 1
for item in files:
if not is_file_stable(item):
print(f"Skipping (still being copied): {item.name}")
skipped += 1
continue
ext = item.suffix.lower().lstrip(".")
name = final_name
if multi:
per_name, _, _, error = lookup_folder(api_key, media_type, item.stem, folder_year)
if error:
print(error)
skipped += 1
continue
name = per_name
new_name = movie_file_name(name, ext, detect_resolution(item.name))
dest = folder / new_name
subtitles = gather_video_subtitles(item, multi)
if dest == item:
renamed += rename_consolidated_subtitles(subtitles, item, log)
continue
if dest.exists() and not same_existing_path(dest, item):
print(f"Skipping (target already exists): {item.name}")
skipped += 1
continue
ok, err = safe_rename(item, dest)
if not ok:
print(f"Skipping ({err}): {item.name}")
skipped += 1
continue
log.record(item, dest)
print(f"Renamed file: {item.name} -> {new_name}")
renamed += 1
renamed += rename_consolidated_subtitles(subtitles, dest, log)
return renamed, skipped
for item in files:
if not is_file_stable(item):
print(f"Skipping (still being copied): {item.name}")
skipped += 1
continue
ext = item.suffix.lower().lstrip(".")
se = parse_season_episode(item.name)
subtitles = gather_video_subtitles(item, True)
if se:
season, episode, marker = se
new_name = episode_file_name(final_name, season, episode, ext, detect_resolution(item.name))
else:
season = parse_season_only(item.name)
if season is None:
resolved = resolve_absolute_episode(api_key, tmdb_id, item.name)
if resolved is None:
print(f"No season/episode found, skipping: {item.name}")
skipped += 1
continue
season, episode = resolved
new_name = episode_file_name(final_name, season, episode, ext, detect_resolution(item.name))
else:
new_name = item.name
target_season_folder = season_folder_name(season)
season_dir = folder / target_season_folder
dest = season_dir / new_name
if dest == item:
renamed += rename_consolidated_subtitles(subtitles, item, log)
continue
if dest.exists() and not same_existing_path(dest, item):
if stage_duplicate_episode(share, folder.name, item, log):
renamed += 1
else:
skipped += 1
continue
season_dir.mkdir(exist_ok=True)
ok, err = safe_rename(item, dest)
if not ok:
print(f"Skipping ({err}): {item.name}")
skipped += 1
continue
log.record(item, dest)
print(f"Moved: {item.name} -> {target_season_folder}/{new_name}")
renamed += 1
renamed += rename_consolidated_subtitles(subtitles, dest, log)
return renamed, skipped
def handle_movie_bundle_folder(share, folder, api_key, log, test_mode, args, needs_attention):
videos = list_video_files(folder)
per_file = []
for video in videos:
year = extract_year(video.stem)
final_name, match_year, match_id, error = lookup_folder(api_key, "movie", video.stem, year)
per_file.append((video, final_name, match_year, match_id, error))
distinct_ids = {m[3] for m in per_file if m[3] is not None}
if len(distinct_ids) < 2:
return None
vprint(f" {folder.name} looks like a multi-movie bundle ({len(distinct_ids)} distinct titles found), splitting")
folders_created = 0
files_moved = 0
files_skipped = 0
for video, final_name, match_year, match_id, error in per_file:
if error or not final_name:
print(f"No TMDb match for bundle member, leaving in place: {video.name}")
needs_attention.append(("No TMDb match", f"{folder.name}/{video.name}"))
files_skipped += 1
continue
target_folder_name = folder_target_name("movie", final_name, match_year, video.name)
target_folder = Path(share) / target_folder_name
new_video_name = movie_file_name(final_name, video.suffix.lstrip("."), detect_resolution(video.name))
own_subs = gather_video_subtitles(video, True)
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:
print(f"Would split bundle member: {folder.name}/{video.name} -> {target_folder_name}/{new_video_name}")
preview_consolidated_subtitles(own_subs, dest_video)
files_moved += 1
continue
if not is_file_stable(video):
print(f"Skipping (still being copied): {video.name}")
files_skipped += 1
continue
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}")
files_skipped += 1
continue
target_folder.mkdir(parents=True, exist_ok=True)
if dest_video.exists() and not same_existing_path(dest_video, video):
staged_dest = duplicate_file_staging_path(share, target_folder_name, video)
ok, err = safe_move(video, staged_dest)
if ok:
log.record(video, staged_dest)
print(f"Moved duplicate bundle member to {DUPLICATES_FOLDER_NAME}/: {video.name}")
files_moved += 1
else:
print(f"Skipping (could not move to {DUPLICATES_FOLDER_NAME}/: {err}): {video.name}")
cleanup_empty_staging_dir(share, target_folder_name)
files_skipped += 1
continue
ok, err = safe_rename(video, dest_video)
if not ok:
print(f"Skipping ({err}): {video.name}")
files_skipped += 1
needs_attention.append(("Error", f"{video.name} ({err})"))
continue
log.record(video, dest_video)
print(f"Split bundle member: {folder.name}/{video.name} -> {target_folder_name}/{new_video_name}")
folders_created += 1
files_moved += 1
files_moved += rename_consolidated_subtitles(own_subs, dest_video, log)
if not test_mode:
try:
if folder.exists() and is_effectively_empty(folder):
purge_ignorable_junk(folder)
folder.rmdir()
print(f"Removed empty bundle folder: {folder.name}")
except OSError:
pass
return folders_created, files_moved, files_skipped
def compute_folder_signature(folder):
entries = []
for root, dirs, files in os.walk(folder):
rel_root = os.path.relpath(root, folder)
for name in dirs + files:
entries.append(os.path.normpath(os.path.join(rel_root, name)))
hasher = hashlib.sha256()
for entry in sorted(entries):
hasher.update(entry.encode("utf-8"))
hasher.update(b"\n")
return hasher.hexdigest()
def load_scan_cache():
if CACHE_FILE.exists():
try:
return json.loads(CACHE_FILE.read_text())
except (json.JSONDecodeError, OSError):
return {}
return {}
def save_scan_cache(cache):
CACHE_FILE.write_text(json.dumps(cache, indent=2))
def compute_cleanup_rules_signature(delete_folder_names, delete_file_patterns, keep_languages, delete_languages, sample_videos_enabled=False):
hasher = hashlib.sha256()
for name in sorted(delete_folder_names):
hasher.update(f"f:{name.lower()}\n".encode("utf-8"))
for pattern in sorted(delete_file_patterns):
hasher.update(f"p:{pattern.lower()}\n".encode("utf-8"))
for lang in sorted(keep_languages or []):
hasher.update(f"k:{lang}\n".encode("utf-8"))
for lang in sorted(delete_languages or []):
hasher.update(f"d:{lang}\n".encode("utf-8"))
hasher.update(f"s:{bool(sample_videos_enabled)}\n".encode("utf-8"))
return hasher.hexdigest()
def parse_simple_yaml_list(path, key):
if not path.exists():
return []
items = []
in_list = False
for raw_line in path.read_text().splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith(f"{key}:"):
in_list = True
continue
if in_list and line.startswith("-"):
value = line[1:].strip().strip('"').strip("'")
if value:
items.append(value)
else:
in_list = False
return items
def load_delete_folder_names():
return [name.lower() for name in parse_simple_yaml_list(DELETE_FILE, "folders")]
def parse_simple_yaml_bool(path, key):
if not path.exists():
return False
for raw_line in path.read_text().splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith(f"{key}:"):
value = line.split(":", 1)[1].strip().strip('"').strip("'").lower()
return value in ("true", "yes", "1", "on")
return False
def load_delete_sample_videos_flag():
return parse_simple_yaml_bool(DELETE_FILE, "sample_videos")
TRAILING_WRAPPER_CHARS = ")]}'\""
def split_patterns(patterns):
include = []
exclude = []
for pattern in patterns:
if pattern.startswith("!"):
exclude.append(pattern[1:])
else:
include.append(pattern)
return include, exclude
def match_with_wrapper_stripping(name_lower, patterns):
if any(fnmatch.fnmatch(name_lower, pattern) for pattern in patterns):
return True
stripped = name_lower.rstrip(TRAILING_WRAPPER_CHARS)
if stripped and stripped != name_lower:
return any(fnmatch.fnmatch(stripped, pattern) for pattern in patterns)
return False
def matches_folder_name(name, patterns):
include, exclude = split_patterns(patterns)
if not match_with_wrapper_stripping(name.lower(), include):
return False
if exclude and match_with_wrapper_stripping(name.lower(), exclude):
return False
return True
def load_delete_file_patterns():
return [p.lower() for p in parse_simple_yaml_list(DELETE_FILE, "files")]
def load_delete_subtitle_rules():
keep_languages = set()
delete_languages = set()
for entry in parse_simple_yaml_list(DELETE_FILE, "subtitles"):
is_keep = entry.startswith("!")
name = entry[1:] if is_keep else entry
code = LANGUAGE_ALIAS_TO_CODE.get(name.strip().lower())
if not code:
continue
if is_keep:
keep_languages.add(code)
else:
delete_languages.add(code)
return keep_languages, delete_languages
def subtitle_language_cleanup_target(item, keep_languages, delete_languages):
if not keep_languages and not delete_languages:
return False
ext = item.suffix.lower().lstrip(".")
if ext not in SUBTITLE_EXTENSIONS and ext != "idx":
return False
lang = resolve_subtitle_language(item)
if keep_languages:
if lang is None:
return False
return lang not in keep_languages
if lang is None:
return False
return lang in delete_languages
def parse_idx_language(path):
try:
text = path.read_text(errors="ignore")
except OSError:
return None
match = re.search(r'^\s*id:\s*([a-zA-Z]{2,3})', text, re.MULTILINE)
if match:
return match.group(1).lower()
return None
def count_unicode_range(text, lo, hi):
return sum(1 for ch in text if lo <= ord(ch) <= hi)
SUBTITLE_SCRIPT_RANGES = {
"ru": (0x0400, 0x04FF),
"el": (0x0370, 0x03FF),
"he": (0x0590, 0x05FF),
"ar": (0x0600, 0x06FF),
"th": (0x0E00, 0x0E7F),
"hi": (0x0900, 0x097F),
"ko": (0xAC00, 0xD7A3),
"ja": (0x3040, 0x30FF),
"zh": (0x4E00, 0x9FFF),
}
SUBTITLE_STOPWORDS = {
"en": {"the", "and", "is", "of", "in", "to", "a", "that", "it", "you", "was",
"for", "on", "are", "with", "as", "this", "have", "be", "not", "but",
"he", "she", "they", "we", "his", "her", "at", "from", "by", "or",
"an", "if", "what", "so", "all", "can", "just", "one", "like", "get",
"know", "will", "would", "there", "when", "who", "how", "out", "up",
"about", "then", "them", "were", "been", "had", "do", "did", "yes", "no"},
"fr": {"le", "la", "les", "de", "et", "un", "une", "des", "est", "que", "qui",
"pas", "pour", "dans", "ce", "il", "elle", "vous", "je", "nous", "au",
"du", "se", "ne", "tu", "on", "avec", "sur", "son", "sa", "ses",
"mais", "comme", "tout", "ça", "oui", "non"},
"es": {"el", "la", "los", "las", "de", "y", "un", "una", "es", "que", "no",
"por", "con", "para", "en", "se", "su", "lo", "como", "más", "pero",
"le", "les", "yo", "tu", "este", "esta", "ese", "esa", "sí"},
"de": {"der", "die", "das", "und", "ist", "nicht", "ein", "eine", "zu", "den",
"mit", "auf", "für", "sich", "du", "ich", "er", "sie", "wir", "es",
"war", "sind", "aber", "was", "wie", "wenn", "ja", "nein"},
"it": {"il", "la", "di", "e", "un", "una", "che", "non", "per", "con", "del",
"della", "sono", "questo", "questa", "ma", "come", "io", "tu", "lui",
"lei", "noi", "sì", "no"},
"pt": {"o", "a", "os", "as", "de", "e", "um", "uma", "que", "não", "por",
"com", "para", "em", "se", "seu", "sua", "como", "mas", "eu", "tu",
"ele", "ela", "nós", "sim"},
"nl": {"de", "het", "een", "en", "is", "niet", "van", "dat", "je", "ik",
"hij", "zij", "wij", "met", "voor", "op", "aan", "maar", "zoals",
"ja", "nee"},
}
def detect_srt_language(path):
try:
raw = path.read_bytes()
except OSError:
return "unknown"
text = None
for encoding in ("utf-8-sig", "utf-8", "cp1252", "latin-1"):
try:
text = raw.decode(encoding)
break
except (UnicodeDecodeError, LookupError):
continue
if text is None:
return "unknown"
lines = []
for line in text.splitlines():
line = line.strip()
if not line or line.isdigit() or "-->" in line:
continue
lines.append(line)
sample = " ".join(lines)[:20000]
if not sample.strip():
return "unknown"
for lang, (lo, hi) in SUBTITLE_SCRIPT_RANGES.items():
if count_unicode_range(sample, lo, hi) >= 5:
vprint(f" {path.name}: detected non-Latin script -> {lang}")
return lang
words = re.findall(r"[a-zà-öø-ÿ]+", sample.lower())
total = len(words)
if total < 20:
vprint(f" {path.name}: only {total} word(s) sampled, too little to classify -> unknown")
return "unknown"
scores = {lang: sum(1 for w in words if w in stopwords) for lang, stopwords in SUBTITLE_STOPWORDS.items()}
best_lang = max(scores, key=scores.get)
best_score = scores[best_lang]
second_score = max((s for lang, s in scores.items() if lang != best_lang), default=0)
vprint(f" {path.name}: word count={total}, scores={scores}")
if best_score >= 5 and best_score >= second_score * 1.5 and best_score >= second_score + 3:
vprint(f" {path.name}: classified as {best_lang} (second_score={second_score}, {best_lang}_score={best_score})")
return best_lang
vprint(f" {path.name}: inconclusive (best={best_lang}:{best_score}, second={second_score}) -> unknown")
return "unknown"
def detect_subtitle_language(path):
ext = path.suffix.lower()
if ext == ".idx":
return parse_idx_language(path) or "unknown"
if ext == ".sub":
idx_path = path.with_suffix(".idx")
if idx_path.exists():
lang = parse_idx_language(idx_path)
if lang:
return lang
return "unknown"
if ext == ".srt":
return detect_srt_language(path)
return "unknown"
LANGUAGE_EXCLUDE_TOKEN = "language:en"
def subtitle_language_hint_from_name(name):
return language_tag_from_name(name)
def matches_file_name(name, patterns, path=None):
include, exclude = split_patterns(patterns)
if not match_with_wrapper_stripping(name.lower(), include):
return False
literal_exclude = [p for p in exclude if p != LANGUAGE_EXCLUDE_TOKEN]
if literal_exclude and match_with_wrapper_stripping(name.lower(), literal_exclude):
return False
if LANGUAGE_EXCLUDE_TOKEN in exclude and path is not None:
hint = subtitle_language_hint_from_name(name)
if hint == "en":
return False
if hint is None:
detected = detect_subtitle_language(path)
if detected in ("en", "unknown"):
return False
return True
def find_cleanup_targets(share, delete_folder_names, delete_file_patterns, keep_languages=None, delete_languages=None, unchanged_folder_names=None, sample_videos_enabled=False):
targets = []
keep_languages = keep_languages or set()
delete_languages = delete_languages or set()
unchanged_folder_names = unchanged_folder_names or set()
share_root = Path(share)
duplicates_dir = share_root / DUPLICATES_FOLDER_NAME
def walk(folder):
vprint(f"Scanning folder: {folder}")
try:
entries = sorted(folder.iterdir())
except OSError as e:
vprint(f" Could not read folder: {e}")
return
for entry in entries:
if entry.is_dir():
if entry == duplicates_dir:
continue
if matches_folder_name(entry.name, delete_folder_names):
vprint(f" Match (folder name): {entry}")
targets.append(("folder", entry))
continue
if entry.parent == share_root and entry.name in unchanged_folder_names:
vprint(f" Unchanged since last cleanup, skipping: {entry}")
continue
vprint(f" No match, descending into: {entry}")
walk(entry)
elif entry.is_file():
ext = entry.suffix.lower().lstrip(".")
if ext in VIDEO_EXTENSIONS:
if sample_videos_enabled and SAMPLE_VIDEO_PATTERN.search(entry.stem):
vprint(f" Match (sample video): {entry}")
targets.append(("file", entry))
continue
vprint(f" Skipping (protected video file): {entry}")
continue
if matches_file_name(entry.name, delete_file_patterns, entry):
vprint(f" Match (file pattern): {entry}")
targets.append(("file", entry))
elif subtitle_language_cleanup_target(entry, keep_languages, delete_languages):
vprint(f" Match (subtitle language): {entry}")
targets.append(("file", entry))
else:
vprint(f" No match: {entry}")
walk(Path(share))
return targets
def trash_path_for(share, item, timestamp):
rel = item.relative_to(share)
share_label = Path(share).name
return CLEANUP_TRASH_DIR / timestamp / share_label / rel
def remove_empty_folders(share, confirm_all, dry_run=False):
removed = 0
skipped = 0
removed_paths = set()
share_path = Path(share).resolve()
duplicates_path = (share_path / DUPLICATES_FOLDER_NAME).resolve()
for root, dirs, files in os.walk(share, topdown=False):
root_path = Path(root)
if root_path.resolve() == share_path:
continue
resolved_root = root_path.resolve()
if resolved_root == duplicates_path or duplicates_path in resolved_root.parents:
continue
try:
remaining = [
c for c in root_path.iterdir()
if c not in removed_paths and not (c.is_file() and is_ignorable_junk_file(c))
]
except OSError as e:
vprint(f" Could not read {root_path}: {e}")
continue
if remaining:
continue
if dry_run:
print(f"Would remove empty folder: {root_path}")
removed_paths.add(root_path)
removed += 1
continue
if not confirm_all:
choice = confirm_delete_choice(f"Remove empty folder: {root_path}?")
if choice == "a":
confirm_all = True
elif choice == "n":
print(f"Skipped: {root_path}")
skipped += 1
continue
try:
purge_ignorable_junk(root_path)
root_path.rmdir()
print(f"Removed empty folder: {root_path}")
removed_paths.add(root_path)
removed += 1
except FileNotFoundError:
vprint(f" Already gone: {root_path}")
removed_paths.add(root_path)
except OSError as e:
vprint(f" Could not remove {root_path}: {e}")
return removed, skipped
def run_cleanup(args, log):
path_arg = args.cleanup if isinstance(args.cleanup, str) else None
share = resolve_share(path_arg)
log.set_label(f"{Path(share).name}-cleanup")
migrate_legacy_duplicates_folder(share)
print()
print(f"Performing action: Cleanup{' (Test Mode)' if args.test is not None else ''}")
delete_folder_names = load_delete_folder_names()
delete_file_patterns = load_delete_file_patterns()
keep_languages, delete_languages = load_delete_subtitle_rules()
sample_videos_enabled = load_delete_sample_videos_flag()
vprint(f"Loaded {len(delete_folder_names)} folder pattern(s) from {DELETE_FILE.name}: {delete_folder_names}")
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"Sample video cleanup enabled: {sample_videos_enabled}")
if (
not delete_folder_names and not delete_file_patterns and not keep_languages
and not delete_languages and not sample_videos_enabled
):
print("No cleanup rules configured.")
print(f"Edit {DELETE_FILE.name} to enable cleanup.")
return
rules_signature = compute_cleanup_rules_signature(
delete_folder_names, delete_file_patterns, keep_languages, delete_languages, sample_videos_enabled
)
cache = load_scan_cache()
share_key = str(Path(share).resolve())
cleanup_cache_key = f"cleanup::{share_key}"
cleanup_baseline = cache.get(cleanup_cache_key)
if not isinstance(cleanup_baseline, dict):
cleanup_baseline = {}
share_path = Path(share)
try:
top_level_folders = [
p for p in share_path.iterdir() if is_library_content_folder(p)
]
except OSError:
top_level_folders = []
unchanged_folder_names = set()
if not getattr(args, "force", False):
for folder in top_level_folders:
sig = compute_folder_signature(folder)
combined = f"{sig}|{rules_signature}"
if cleanup_baseline.get(folder.name) == combined:
unchanged_folder_names.add(folder.name)
if unchanged_folder_names:
vprint(f"Skipping content scan for {len(unchanged_folder_names)} unchanged folder(s)")
print()
print(f"Scanning: {share}")
print()
targets = find_cleanup_targets(
share, delete_folder_names, delete_file_patterns, keep_languages, delete_languages,
unchanged_folder_names, sample_videos_enabled
)
vprint(f"Scan complete. {len(targets)} item(s) matched.")
test_mode = args.test is not None
test_limit = args.test or 0
if test_mode:
shown = 0
for kind, item in targets:
if test_limit > 0 and shown >= test_limit:
break
print(f"Would move to trash ({kind}): {item}")
shown += 1
empty_preview, _ = remove_empty_folders(share, True, dry_run=True)
print()
if not targets and not empty_preview:
print("Nothing to clean up.")
else:
print(
f"Test mode: {len(targets)} item(s) would be moved to trash, "
f"{empty_preview} empty folder(s) would be removed. No changes were made."
)
return
if targets:
print(f"Found {len(targets)} item(s) to move to trash:")
for kind, item in targets:
print(f" [{kind}] {item}")
print()
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
moved_folders = 0
moved_files = 0
skipped_folders = 0
skipped_files = 0
confirm_all = args.yes
for kind, item in targets:
if not item.exists():
if kind == "folder":
skipped_folders += 1
else:
skipped_files += 1
continue
if not confirm_all:
choice = confirm_delete_choice(f"Move to trash ({kind}): {item}?")
if choice == "a":
confirm_all = True
elif choice == "n":
print(f"Skipped: {item}")
if kind == "folder":
skipped_folders += 1
else:
skipped_files += 1
continue
dest = trash_path_for(share, item, timestamp)
if dest.exists():
print(f"Skipping (trash target already exists): {item}")
if kind == "folder":
skipped_folders += 1
else:
skipped_files += 1
continue
dest.parent.mkdir(parents=True, exist_ok=True)
vprint(f"Moving {item} -> {dest}")
ok, err = safe_move(item, dest)
if not ok:
print(f"Skipping (could not move to trash: {err}): {item}")
if kind == "folder":
skipped_folders += 1
else:
skipped_files += 1
continue
log.record(item, dest)
print(f"Moved to trash: {item}")
if kind == "folder":
moved_folders += 1
else:
moved_files += 1
empty_removed, empty_skipped = remove_empty_folders(share, confirm_all)
folders_deleted = moved_folders + empty_removed
folders_deleted_skipped = skipped_folders + empty_skipped
new_cleanup_baseline = {}
for folder in top_level_folders:
if folder.name in unchanged_folder_names:
new_cleanup_baseline[folder.name] = cleanup_baseline.get(folder.name)
continue
if not folder.exists():
continue
sig = compute_folder_signature(folder)
new_cleanup_baseline[folder.name] = f"{sig}|{rules_signature}"
cache[cleanup_cache_key] = new_cleanup_baseline
save_scan_cache(cache)
if not targets and not folders_deleted and not moved_files and not folders_deleted_skipped and not skipped_files:
print("Nothing to clean up.")
return
print()
print("=== Summary ===")
print(f"Folders deleted: {folders_deleted}, skipped: {folders_deleted_skipped}")
print(f"Files deleted: {moved_files}, skipped: {skipped_files}")
log_path = log.save(label=f"{Path(share).name}-cleanup")
if log_path:
print(f"Log saved: {log_path}")
print(f"Trash location: {CLEANUP_TRASH_DIR / timestamp}")
print(f"Run --restore {log_path.name} to undo, or delete the trash folder once you're confident.")
def list_loose_video_files(share):
return [
item for item in sorted(Path(share).iterdir())
if item.is_file() and item.suffix.lower().lstrip(".") in VIDEO_EXTENSIONS
]
def list_loose_subtitle_files(share):
return [
item for item in sorted(Path(share).iterdir())
if item.is_file() and (item.suffix.lower().lstrip(".") in SUBTITLE_EXTENSIONS or item.suffix.lower().lstrip(".") == "idx")
]
def process_loose_movie_files(share, api_key, log, test_mode, test_limit, args, needs_attention=None):
if needs_attention is None:
needs_attention = []
files = list_loose_video_files(share)
if not files:
return 0, 0, 0, 0
print(f"Found {len(files)} loose movie file(s) directly in {Path(share).name} with no folder of their own:")
print()
folders_renamed = 0
folders_skipped = 0
files_renamed = 0
files_skipped = 0
shown = 0
total = len(files)
for index, item in enumerate(files, start=1):
if test_mode and test_limit > 0 and shown >= test_limit:
break
if not is_file_stable(item):
print(f"[{index}/{total}] {item.name}")
print(f"Skipping (still being copied): {item.name}")
shown += 1
folders_skipped += 1
continue
print(f"[{index}/{total}] {item.name}")
final_name, match_year, _, error = lookup_folder(api_key, "movie", item.stem)
if error:
print(error)
shown += 1
folders_skipped += 1
needs_attention.append(("No TMDb match", item.name))
continue
folder_name = folder_target_name("movie", final_name, match_year, item.stem)
target_folder = Path(share) / folder_name
ext = item.suffix.lower().lstrip(".")
new_name = movie_file_name(final_name, ext, detect_resolution(item.name))
dest = target_folder / new_name
subtitles = gather_video_subtitles(item, True)
if test_mode:
print(f"Move: {item.name} -> {folder_name}/{new_name}")
preview_consolidated_subtitles(subtitles, dest)
shown += 1
folders_renamed += 1
files_renamed += 1
continue
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}")
folders_skipped += 1
continue
if dest.exists() and not same_existing_path(dest, item):
print(f"Skipping (target already exists): {item.name}")
folders_skipped += 1
needs_attention.append(("Target already exists", item.name))
continue
target_folder.mkdir(exist_ok=True)
ok, err = safe_rename(item, dest)
if not ok:
print(f"Skipping ({err}): {item.name}")
folders_skipped += 1
needs_attention.append(("Error", f"{item.name} ({err})"))
continue
log.record(item, dest)
print(f"Moved: {item.name} -> {folder_name}/{new_name}")
folders_renamed += 1
files_renamed += 1
files_renamed += rename_consolidated_subtitles(subtitles, dest, log)
print()
return folders_renamed, folders_skipped, files_renamed, files_skipped
def process_loose_tv_files(share, api_key, log, test_mode, test_limit, args, needs_attention=None):
if needs_attention is None:
needs_attention = []
files = list_loose_video_files(share)
if not files:
return 0, 0, 0, 0
print(f"Found {len(files)} loose TV episode file(s) directly in {Path(share).name} with no show folder of their own:")
print()
folders_renamed = 0
folders_skipped = 0
files_renamed = 0
files_skipped = 0
shown = 0
total = len(files)
for index, item in enumerate(files, start=1):
if test_mode and test_limit > 0 and shown >= test_limit:
break
if not is_file_stable(item):
print(f"[{index}/{total}] {item.name}")
print(f"Skipping (still being copied): {item.name}")
shown += 1
files_skipped += 1
continue
print(f"[{index}/{total}] {item.name}")
final_name, match_year, match_id, error = lookup_folder(api_key, "tv", item.stem)
if error:
print(error)
shown += 1
files_skipped += 1
needs_attention.append(("No TMDb match", item.name))
continue
se = parse_season_episode(item.name)
if se:
season, episode, marker = se
else:
resolved = resolve_absolute_episode(api_key, match_id, item.name)
if resolved is None:
print(f"No season/episode found, skipping: {item.name}")
shown += 1
files_skipped += 1
needs_attention.append(("Unresolved season/episode", item.name))
continue
season, episode = resolved
folder_name = folder_target_name("tv", final_name, match_year, item.stem)
target_folder = Path(share) / folder_name
target_season_folder_name = season_folder_name(season)
ext = item.suffix.lower().lstrip(".")
new_name = episode_file_name(final_name, season, episode, ext, detect_resolution(item.name))
dest = target_folder / target_season_folder_name / new_name
subtitles = gather_video_subtitles(item, True)
if test_mode:
print(f"Move: {item.name} -> {folder_name}/{target_season_folder_name}/{new_name}")
preview_consolidated_subtitles(subtitles, dest)
shown += 1
folders_renamed += 1
files_renamed += 1
continue
if not args.yes and not confirm_or_all(f"Move '{item.name}' into show folder '{folder_name}'?", args):
print(f"Skipped: {item.name}")
files_skipped += 1
continue
if dest.exists() and not same_existing_path(dest, item):
if stage_duplicate_episode(share, folder_name, item, log):
files_renamed += 1
else:
files_skipped += 1
needs_attention.append(("Error", f"{item.name} (could not move to {DUPLICATES_FOLDER_NAME}/)"))
continue
(target_folder / target_season_folder_name).mkdir(parents=True, exist_ok=True)
ok, err = safe_rename(item, dest)
if not ok:
print(f"Skipping ({err}): {item.name}")
files_skipped += 1
needs_attention.append(("Error", f"{item.name} ({err})"))
continue
log.record(item, dest)
print(f"Moved: {item.name} -> {folder_name}/{target_season_folder_name}/{new_name}")
folders_renamed += 1
files_renamed += 1
files_renamed += rename_consolidated_subtitles(subtitles, dest, log)
print()
return folders_renamed, folders_skipped, files_renamed, files_skipped
def process_loose_subtitle_files(share, media_type, log, test_mode, args, needs_attention=None):
if needs_attention is None:
needs_attention = []
files = list_loose_subtitle_files(share)
if not files:
return 0, 0
print(f"Found {len(files)} loose subtitle file(s) directly in {Path(share).name} with no video of their own:")
print()
movie_index = build_movie_folder_index(share) if media_type == "movie" else None
show_index = build_show_folder_index(share) if media_type != "movie" else None
renamed = 0
skipped = 0
total = len(files)
for index, item in enumerate(files, start=1):
print(f"[{index}/{total}] {item.name}")
if not is_file_stable(item):
print(f"Skipping (still being copied): {item.name}")
skipped += 1
continue
base = strip_subtitle_language_suffix(item.stem)
year = extract_year(base)
query = build_query(base, year)
if not query:
print(f"Skipping (could not determine title): {item.name}")
skipped += 1
needs_attention.append(("Subtitle not placed", item.name))
continue
video = None
folder = None
if media_type == "movie":
folder, video = match_movie_from_index(movie_index, query, year)
if not folder:
print(f"No matching movie found, skipping: {item.name}")
skipped += 1
needs_attention.append(("Subtitle not placed", item.name))
continue
else:
se = parse_season_episode(item.name)
if not se:
print(f"No season/episode found, skipping: {item.name}")
skipped += 1
needs_attention.append(("Subtitle not placed", item.name))
continue
season, episode, _ = se
folder = match_show_from_index(show_index, query, year)
if not folder:
print(f"No matching show found, skipping: {item.name}")
skipped += 1
needs_attention.append(("Subtitle not placed", item.name))
continue
season_dir = folder / season_folder_name(season)
if not season_dir.is_dir():
print(f"No matching season in '{folder.name}', skipping: {item.name}")
skipped += 1
needs_attention.append(("Subtitle not placed", item.name))
continue
video = find_matching_episode_video(season_dir, episode)
if not video:
print(f"No matching episode in '{folder.name}', skipping: {item.name}")
skipped += 1
needs_attention.append(("Subtitle not placed", item.name))
continue
if test_mode:
print(f"Would move: {item.name} -> matches {folder.name}/{video.name}")
preview_consolidated_subtitles([item], video)
renamed += 1
continue
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}")
skipped += 1
continue
renamed += rename_consolidated_subtitles([item], video, log)
print()
return renamed, skipped
LOOKUP_WORKERS = 8
def _buffered_lookup(api_key, media_type, raw_name, hint_year):
_vprint_local.buffer = []
try:
result = lookup_folder(api_key, media_type, raw_name, hint_year)
except Exception as e:
result = (None, None, None, f"Lookup failed: {raw_name} ({e})")
buffer = _vprint_local.buffer
_vprint_local.buffer = None
return result, buffer
def prefetch_lookups(api_key, media_type, folders):
lookup_cache = {}
if not folders:
return lookup_cache
print(f"Looking up {len(folders)} folder(s) on TMDb...")
with concurrent.futures.ThreadPoolExecutor(max_workers=LOOKUP_WORKERS) as executor:
futures = {}
for folder in folders:
raw_name = folder.name
hint_year = None
if extract_year(raw_name) is None and media_type == "movie":
hint_year = infer_year_from_files(folder)
futures[executor.submit(_buffered_lookup, api_key, media_type, raw_name, hint_year)] = (folder, hint_year)
for future in concurrent.futures.as_completed(futures):
folder, hint_year = futures[future]
try:
result, buffer = future.result()
except Exception as e:
result = (None, None, None, f"Lookup failed: {folder.name} ({e})")
buffer = []
if VERBOSE and buffer:
print(f"-- {folder.name} --")
for line in buffer:
print(line)
lookup_cache[folder] = (hint_year, result)
print()
return lookup_cache
def print_needs_attention(needs_attention):
if not needs_attention:
return
by_category = {}
for category, name in needs_attention:
by_category.setdefault(category, []).append(name)
print(f"=== Needs attention ({len(needs_attention)}) ===")
for category, names in by_category.items():
print(f"{category}:")
for name in names:
print(f" {name}")
print()
def run_scan(args, log):
path_arg = args.rename if isinstance(args.rename, str) else None
share = resolve_share(path_arg)
log.set_label(Path(share).name)
migrate_legacy_duplicates_folder(share)
cache = load_scan_cache()
share_key = str(Path(share).resolve())
baseline = cache.get(share_key)
if not isinstance(baseline, dict):
baseline = {}
vprint(f"Baseline has {len(baseline)} folder(s) recorded")
media_type = determine_media_type(share, getattr(args, "type", None))
print()
print(f"Performing action: Rename{' (Test Mode)' if args.test is not None else ''}")
print()
print(f"Scanning: {share}")
print()
api_key = get_api_key()
get_tmdb_language()
test_mode = args.test is not None
test_limit = args.test or 0
share_path = Path(share)
flatten_self_nested_folder(share_path, log)
is_single_show = media_type == "tv" and looks_like_single_show_folder(share_path)
is_single_movie = media_type == "movie" and looks_like_single_movie_folder(share_path)
if is_single_show or is_single_movie:
kind = "show" if is_single_show else "movie"
vprint(f" {share_path.name} looks like a single {kind}'s folder, processing it directly")
subfolders = [share_path]
else:
subfolders = sorted(
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)
unchanged_count = 0
if not args.force:
to_process = []
for folder in subfolders:
signature = compute_folder_signature(folder)
if baseline.get(folder.name) == signature:
vprint(f"Unchanged, skipping: {folder.name}")
unchanged_count += 1
continue
to_process.append(folder)
if not to_process and not loose_files:
print(f"No changes detected since last scan ({unchanged_count} folder(s) unchanged). Use --force to force a full scan.")
return
subfolders = to_process
if unchanged_count:
print(f"Skipping {unchanged_count} unchanged folder(s) ({len(subfolders)} to process). Use --force to force a full scan.")
print()
examples_shown = 0
folders_renamed = 0
folders_skipped = 0
files_renamed = 0
files_skipped = 0
simulated_tv_folder_names = set()
needs_attention = []
total_folders = len(subfolders)
prefetch_targets = subfolders[:test_limit] if (test_mode and test_limit > 0) else subfolders
lookup_cache = prefetch_lookups(api_key, media_type, prefetch_targets)
for index, folder in enumerate(subfolders, start=1):
if test_mode and test_limit > 0 and examples_shown >= test_limit:
break
raw_name = folder.name
print(f"[{index}/{total_folders}] {raw_name}")
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:
hint_year, (final_name, match_year, match_id, error) = lookup_cache[folder]
if hint_year is not None:
vprint(f" No year in folder name, inferred from files: {hint_year}")
else:
hint_year = None
if extract_year(raw_name) is None and media_type == "movie":
hint_year = infer_year_from_files(folder)
vprint(f" No year in folder name, inferred from files: {hint_year}")
final_name, match_year, match_id, error = lookup_folder(api_key, media_type, raw_name, hint_year)
if error:
print(error)
examples_shown += 1
folders_skipped += 1
needs_attention.append(("No TMDb match", raw_name))
continue
folder_name = folder_target_name(media_type, final_name, match_year, raw_name)
if test_mode:
new_folder = folder.parent / folder_name
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 needs_rename and target_already_taken:
if media_type == "tv":
print(f"Would merge into existing show folder: {raw_name} -> {folder_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}")
needs_attention.append(("Target already exists", raw_name))
examples_shown += 1
continue
if needs_rename:
print(f"Renamed folder: {raw_name} -> {folder_name}")
else:
print(f"Skipping renaming (already correctly named): {raw_name}")
if media_type == "tv":
simulated_tv_folder_names.add(folder_name)
preview_season_folders(folder)
preview_season_folder_files(folder, final_name, api_key, match_id)
preview_video_files(folder, media_type, final_name, api_key, match_id)
examples_shown += 1
continue
new_folder = folder.parent / folder_name
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_or_all(f"Merge '{raw_name}' into existing show folder '{folder_name}'?", args):
print(f"Skipped: {raw_name}")
folders_skipped += 1
continue
merged, merge_skipped = merge_duplicate_show_folder(share, folder, new_folder, raw_name, final_name, log)
files_renamed += merged
files_skipped += merge_skipped
folders_renamed += 1
continue
if needs_rename:
if not args.yes and not confirm_or_all(f"Rename '{raw_name}' -> '{folder_name}'?", args):
print(f"Skipped: {raw_name}")
folders_skipped += 1
continue
if new_folder.exists() and not same_existing_path(new_folder, folder):
if list_video_files(new_folder):
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)
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
needs_attention.append(("Error", f"{raw_name} ({err})"))
else:
print(f"Skipped: {raw_name}")
folders_skipped += 1
else:
print(f"Skipping (target already exists): {raw_name} -> {folder_name}")
folders_skipped += 1
needs_attention.append(("Target already exists", raw_name))
continue
ok, err = safe_rename(folder, new_folder)
if not ok:
print(f"Skipping ({err}): {raw_name} -> {folder_name}")
folders_skipped += 1
needs_attention.append(("Error", f"{raw_name} ({err})"))
continue
log.record(folder, new_folder)
print(f"Renamed folder: {raw_name} -> {folder_name}")
folders_renamed += 1
else:
print(f"Skipping renaming (already correctly named): {raw_name}")
folder = new_folder
if media_type == "tv":
organize_season_folders(folder, log)
sub_renamed, sub_skipped = rename_season_folder_files(share, folder, final_name, log, api_key, match_id)
files_renamed += sub_renamed
files_skipped += sub_skipped
renamed, skipped = rename_video_files(share, folder, media_type, final_name, log, api_key, match_id, match_year)
files_renamed += renamed
files_skipped += skipped
baseline[folder.name] = compute_folder_signature(folder)
if loose_files:
loose_processor = process_loose_movie_files if media_type == "movie" else process_loose_tv_files
loose_folders_renamed, loose_folders_skipped, loose_files_renamed, loose_files_skipped = loose_processor(
share, api_key, log, test_mode, test_limit, args, needs_attention
)
folders_renamed += loose_folders_renamed
folders_skipped += loose_folders_skipped
files_renamed += loose_files_renamed
files_skipped += loose_files_skipped
if not (is_single_show or is_single_movie):
sub_renamed, sub_skipped = process_loose_subtitle_files(share, media_type, log, test_mode, args, needs_attention)
files_renamed += sub_renamed
files_skipped += sub_skipped
print()
if test_mode:
print_needs_attention(needs_attention)
print("Test mode: no changes were made.")
return
print("=== Summary ===")
print(f"Folders renamed: {folders_renamed}, skipped: {folders_skipped}")
print(f"Files renamed: {files_renamed}, skipped: {files_skipped}")
print()
print_needs_attention(needs_attention)
log_path = log.save(label=Path(share).name)
if log_path:
print(f"Log saved: {log_path}")
current_names = {
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}
cache[share_key] = baseline
save_scan_cache(cache)
def collect_local_episodes(show_folder):
local = {}
for sub in list_subfolders(show_folder):
season = parse_season_folder_name(sub.name)
if season is None:
continue
for item in list_video_files(sub):
file_season, episode, marker = resolve_season_folder_file(item, season)
target_season = file_season if file_season is not None else season
if episode is not None:
local.setdefault(target_season, set()).add(episode)
for item in list_video_files(show_folder):
se = parse_season_episode(item.name)
if se:
local.setdefault(se[0], set()).add(se[1])
return local
def check_show_episodes(api_key, show_folder):
raw_name = show_folder.name
final_name, match_year, tmdb_id, error = lookup_folder(api_key, "tv", raw_name)
if error or not tmdb_id:
return None, error or f"Could not resolve on TMDb: {raw_name}"
details, err = tmdb_tv_details(api_key, tmdb_id)
if err:
return None, f"Could not fetch show details: {raw_name} ({err})"
local = collect_local_episodes(show_folder)
today = datetime.date.today().isoformat()
missing = {}
for season_info in details.get("seasons", []):
season_number = season_info.get("season_number")
if season_number is None or season_number == 0:
continue
season_data, err = tmdb_tv_season(api_key, tmdb_id, season_number)
if err:
vprint(f" Could not fetch season {season_number} for {raw_name}: {err}")
continue
local_episodes = local.get(season_number, set())
season_missing = []
for ep in season_data.get("episodes", []):
ep_number = ep.get("episode_number")
if ep_number is None or ep_number in local_episodes:
continue
air_date = ep.get("air_date")
if not air_date or air_date > today:
continue
season_missing.append(ep_number)
if season_missing:
missing[season_number] = sorted(season_missing)
return missing, None
def flatten_self_nested_folder(folder, log):
try:
children = [c for c in folder.iterdir() if not c.name.startswith(".")]
except OSError:
return False
nested = [c for c in children if c.is_dir() and c.name == folder.name]
if len(nested) != 1:
return False
inner = nested[0]
if any(c != inner for c in children):
return False
try:
inner_items = list(inner.iterdir())
except OSError:
return False
vprint(f" {folder.name} contains a self-nested duplicate folder, flattening it")
for item in inner_items:
dest = folder / item.name
if dest.exists():
print(f"Skipping flatten (target already exists): {item.name}")
continue
ok, err = safe_rename(item, dest)
if not ok:
print(f"Skipping flatten ({err}): {item.name}")
continue
log.record(item, dest)
print(f"Moved: {item.name} -> {folder.name}/{item.name}")
try:
purge_ignorable_junk(inner)
if inner.exists() and is_effectively_empty(inner):
inner.rmdir()
print(f"Removed empty folder: {inner}")
except OSError:
pass
return True
def looks_like_single_movie_folder(folder):
if not list_video_files(folder):
return False
subfolders = [
s for s in list_subfolders(folder)
if s.name.lower() not in SUBTITLE_FOLDER_NAMES
]
if not subfolders:
return True
return not any(list_video_files(s) for s in subfolders)
def looks_like_single_show_folder(folder):
subfolders = list_subfolders(folder)
if subfolders:
season_like = sum(
1 for s in subfolders
if parse_season_folder_name(s.name) is not None or SPECIALS_FOLDER_PATTERN.match(s.name)
)
return season_like > len(subfolders) / 2
return bool(list_video_files(folder))
def run_episode_check(args):
path_arg = args.episodes if isinstance(args.episodes, str) else None
share = resolve_share(path_arg)
media_type = getattr(args, "type", None) or infer_media_type(share)
if media_type == "movie":
print("This looks like a Movies share. --episodes only checks TV show libraries.")
return
print()
print("Performing action: Episode Check")
print()
print(f"Scanning: {share}")
print()
api_key = get_api_key()
get_tmdb_language()
share_path = Path(share)
if looks_like_single_show_folder(share_path):
vprint(f" {share_path.name} looks like a single show's folder (season subfolders found), checking it directly")
shows = [share_path]
else:
shows = sorted(
p for p in share_path.iterdir() if is_library_content_folder(p)
)
checked = 0
incomplete = 0
missing_episode_count = 0
lookup_errors = 0
for index, show_folder in enumerate(shows, start=1):
vprint(f"[{index}/{len(shows)}] Checking: {show_folder.name}")
missing, error = check_show_episodes(api_key, show_folder)
if error:
print(f"{show_folder.name}: {error}")
lookup_errors += 1
continue
checked += 1
if not missing:
continue
incomplete += 1
print(f"{show_folder.name}:")
for season_number in sorted(missing):
episodes = missing[season_number]
missing_episode_count += len(episodes)
ep_list = ", ".join(f"E{e:02d}" for e in episodes)
print(f" S{season_number:02d}: missing {ep_list}")
print()
print("=== Summary ===")
print(f"Shows checked: {checked}, incomplete: {incomplete}, missing episodes: {missing_episode_count}")
if lookup_errors:
print(f"Shows skipped due to lookup errors: {lookup_errors}")
def run_backup(args):
mounts = find_smb_mounts()
if not mounts:
print("No SMB shares found.")
sys.exit(1)
share = select_mount(mounts)
print()
print(f"Scanning: {share}")
print()
paths = []
for root, dirs, files in os.walk(share):
rel_root = os.path.relpath(root, share)
for name in sorted(dirs):
rel = name if rel_root == "." else os.path.join(rel_root, name)
rel = os.path.normpath(rel) + "/"
paths.append(rel)
vprint(f" Captured folder: {rel}")
for name in sorted(files):
rel = name if rel_root == "." else os.path.join(rel_root, name)
rel = os.path.normpath(rel)
paths.append(rel)
vprint(f" Captured file: {rel}")
paths.sort()
BACKUP_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('_')
backup_path = BACKUP_DIR / f"{timestamp}-{label}-backup.json"
counter = 1
while backup_path.exists():
vprint(f" Backup filename already exists, trying next: {backup_path}")
backup_path = BACKUP_DIR / f"{timestamp}-{label}-backup-{counter}.json"
counter += 1
backup_path.write_text(json.dumps({"share": share, "timestamp": timestamp, "paths": paths}, indent=2))
print(f"Captured {len(paths)} folder(s)/file(s).")
print(f"Backup saved: {backup_path}")
def run_manual_rename(current_name, new_name, log):
log.set_label("manual")
src = Path(current_name)
dst = Path(new_name)
if not src.exists():
print(f"Path not found: {current_name}")
sys.exit(1)
if dst.exists() and not same_existing_path(dst, src):
print(f"Target already exists: {new_name}")
sys.exit(1)
src.rename(dst)
log.record(src, dst)
print(f"Renamed: {current_name} -> {new_name}")
log_path = log.save(label="manual")
if log_path:
print(f"Log saved: {log_path}")
def format_log_timestamp(name):
m = re.match(r'^(\d{8}-\d{6})', name)
if not m:
return None
try:
dt = datetime.datetime.strptime(m.group(1), "%Y%m%d-%H%M%S")
except ValueError:
return None
hour = dt.strftime("%I").lstrip("0") or "12"
return dt.strftime(f"%Y-%m-%d {hour}:%M %p")
def select_log_file():
logs = sorted(LOG_DIR.glob("*.json"), key=lambda p: p.name, reverse=True) if LOG_DIR.exists() else []
if not logs:
print("No logs found.")
sys.exit(1)
print("Restore from log:")
for i, log_path in enumerate(logs, 1):
friendly = format_log_timestamp(log_path.name)
suffix = f" ({friendly})" if friendly else ""
print(f" {i}) {log_path.name}{suffix}")
while True:
sel = input(f"Select a log to restore [1-{len(logs)}]: ").strip()
if sel.isdigit() and 1 <= int(sel) <= len(logs):
return logs[int(sel) - 1]
print("Invalid selection.")
def find_latest_log():
logs = sorted(LOG_DIR.glob("*.json"), key=lambda p: p.name, reverse=True) if LOG_DIR.exists() else []
return logs[0] if logs else None
def run_undo():
latest = find_latest_log()
if not latest:
print("No logs found.")
sys.exit(1)
friendly = format_log_timestamp(latest.name)
suffix = f" ({friendly})" if friendly else ""
print(f"Undoing last action: {latest.name}{suffix}")
print()
run_restore(str(latest))
def run_restore(log_arg):
if isinstance(log_arg, str):
path = Path(log_arg)
if not path.is_absolute() and not path.exists():
candidate = LOG_DIR / log_arg
if candidate.exists():
path = candidate
if not path.exists():
print(f"Log file not found: {log_arg}")
sys.exit(1)
else:
path = select_log_file()
vprint(f"Restoring from log: {path}")
entries = json.loads(path.read_text())
vprint(f"Log contains {len(entries)} entry(ies), processing in reverse order")
restored = 0
skipped = 0
for entry in reversed(entries):
src = Path(entry["to"])
dst = Path(entry["from"])
if not src.exists():
print(f"Skipping (no longer exists): {src}")
skipped += 1
continue
if dst.exists():
print(f"Skipping (restore target already exists): {dst}")
skipped += 1
continue
dst.parent.mkdir(parents=True, exist_ok=True)
ok, err = safe_move(src, dst)
if not ok:
print(f"Skipping (could not restore: {err}): {src}")
skipped += 1
continue
print(f"Restored: {src} -> {dst}")
restored += 1
parent = src.parent
try:
if parent.exists() and is_effectively_empty(parent):
purge_ignorable_junk(parent)
parent.rmdir()
print(f"Removed empty folder: {parent}")
except OSError:
pass
print()
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():
parser = argparse.ArgumentParser(
description="Scan SMB media shares, match against TMDb, and rename folders/files into Plex-friendly structure."
)
parser.add_argument("-y", "--yes", action="store_true", help="Rename everything without prompting")
parser.add_argument(
"-f", "--force",
action="store_true",
help="Scan even if no changes were detected since the last run."
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="Print detailed information about everything the script is doing."
)
parser.add_argument(
"-t", "--test",
nargs="?", const=0, type=int, default=None, metavar="N",
help="Preview matches and renames without making changes. Optional N limits how many folders are previewed."
)
parser.add_argument(
"-r", "--rename",
nargs="?", const=True, default=False, metavar="PATH",
help="Scan a share, match against TMDb, and rename folders/files into Plex-friendly structure. "
"Optionally pass a path to skip the share-selection prompt."
)
parser.add_argument(
"-m", "--manual-rename",
nargs=2, metavar=("CURRENT_NAME", "NEW_NAME"),
help="Manually rename a specific folder or file, bypassing TMDb lookup."
)
parser.add_argument(
"--restore",
nargs="?", const=True, default=False, metavar="LOGFILE",
help="Restore original names from a saved rename log (filename or path). "
"If no logfile is given, choose from a list of available logs (most recent first)."
)
parser.add_argument(
"--backup",
action="store_true",
help="Snapshot the current folder/file names on a share to a log file, without making any changes."
)
parser.add_argument(
"-c", "--cleanup",
nargs="?", const=True, default=False, metavar="PATH",
help="Move junk folders/files (per delete.yaml) to a trash folder. "
"Optionally pass a path to skip the share-selection prompt."
)
parser.add_argument(
"-u", "--undo",
action="store_true",
help="Restore from the most recent log file, without prompting. Cannot be combined with any other argument."
)
parser.add_argument(
"-e", "--episodes",
nargs="?", const=True, default=False, metavar="PATH",
help="Check TV show folders against TMDb's episode list and report any missing (already-aired) episodes. "
"Read-only, makes no changes. Optionally pass a path to skip the share-selection prompt."
)
parser.add_argument(
"-T", "--type",
type=normalize_media_type_arg, default=None, metavar="movies|tv",
help="Manually specify the library type (movies or tv) instead of auto-detecting from the share name "
"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(
"--service",
nargs="?", const="", default=None, metavar="start|stop|SECONDS|PATH",
help="Run rename+cleanup automatically in the background on the configured share(s). "
"--service start starts it, --service stop stops it. --service N sets the interval "
"in seconds and starts it. --service PATH adds a share to the auto-scan list and "
"starts the service. --service with no value walks through selecting shares interactively."
)
parser.add_argument(
"--service-worker",
action="store_true",
help=argparse.SUPPRESS,
)
return parser
BUNDLABLE_FLAGS = {"y", "f", "t", "r", "v", "c", "e", "s"}
PATH_TAKING_FLAGS = {"r", "c", "e"}
def expand_bundled_flags(argv):
expanded = []
for token in argv:
if (
len(token) > 2
and token[0] == "-"
and token[1] != "-"
and all(c in BUNDLABLE_FLAGS for c in token[1:])
):
letters = token[1:]
path_flag = next((c for c in letters if c in PATH_TAKING_FLAGS), None)
if path_flag:
letters = letters.replace(path_flag, "") + path_flag
expanded.extend(f"-{c}" for c in letters)
else:
expanded.append(token)
return expanded
def parse_args():
return build_parser().parse_args(expand_bundled_flags(sys.argv[1:]))
def get_api_key_noninteractive():
env_key = os.environ.get("TMDB_API_KEY")
if env_key:
return env_key.strip()
key = parse_env_file(API_KEY_FILE).get("TMDB_API_KEY", "").strip()
return key or None
def load_service_config():
if SERVICE_CONFIG_FILE.exists():
try:
data = json.loads(SERVICE_CONFIG_FILE.read_text())
if isinstance(data, dict):
data.setdefault("paths", [])
data.setdefault("interval", DEFAULT_SERVICE_INTERVAL)
data.setdefault("pid", None)
return data
except (json.JSONDecodeError, OSError):
pass
return {"paths": [], "interval": DEFAULT_SERVICE_INTERVAL, "pid": None}
def save_service_config(config):
SERVICE_CONFIG_FILE.write_text(json.dumps(config, indent=2))
def is_process_running(pid):
if not pid:
return False
if platform.system() == "Windows":
try:
result = subprocess.run(
["tasklist", "/FI", f"PID eq {pid}"],
capture_output=True, text=True, timeout=5
)
return str(pid) in result.stdout
except (subprocess.SubprocessError, OSError):
return False
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
except OSError:
return False
return True
def terminate_process(pid):
if platform.system() == "Windows":
try:
subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, timeout=5)
return True
except (subprocess.SubprocessError, OSError):
return False
try:
os.kill(pid, signal.SIGTERM)
return True
except OSError:
return False
def stop_service():
config = load_service_config()
pid = config.get("pid")
if not pid or not is_process_running(pid):
print("Service is not running.")
config["pid"] = None
save_service_config(config)
return
ok = terminate_process(pid)
config["pid"] = None
save_service_config(config)
if ok:
print(f"Service stopped (PID {pid}).")
else:
print(f"Could not stop the service (PID {pid}). You may need to terminate it manually.")
def start_service_daemon(config):
existing_pid = config.get("pid")
if existing_pid and is_process_running(existing_pid):
print(f"Service already running (PID {existing_pid}), interval {config.get('interval', DEFAULT_SERVICE_INTERVAL)}s.")
print("Stop it first with --service stop if you want to change settings and restart.")
return
get_api_key()
get_tmdb_language()
LOG_DIR.mkdir(exist_ok=True)
log_fh = open(SERVICE_LOG_FILE, "a")
script_path = Path(__file__).resolve()
cmd = [sys.executable, str(script_path), "--service-worker"]
popen_kwargs = {"stdout": log_fh, "stderr": log_fh, "stdin": subprocess.DEVNULL}
if platform.system() == "Windows":
popen_kwargs["creationflags"] = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
else:
popen_kwargs["start_new_session"] = True
proc = subprocess.Popen(cmd, **popen_kwargs)
log_fh.close()
config["pid"] = proc.pid
save_service_config(config)
interval = config.get("interval", DEFAULT_SERVICE_INTERVAL)
print(f"Service started (PID {proc.pid}), interval {interval}s.")
if config.get("paths"):
print("Scanning:")
for p in config["paths"]:
print(f" {p}")
else:
print("No shares configured yet. Add one with --service <path>.")
print(f"Log: {SERVICE_LOG_FILE}")
print("Stop anytime with: --service stop")
def interactive_configure_service():
config = load_service_config()
paths = list(config.get("paths", []))
while True:
mounts = [m for m in find_smb_mounts() if m not in paths]
if mounts:
print("Available shares:")
for i, m in enumerate(mounts, 1):
print(f" {i}) {m}")
sel = input(f"Select a share to add [1-{len(mounts)}], type a path, or press Enter to stop adding: ").strip()
else:
sel = input("No additional SMB shares found. Type a path to add, or press Enter to stop adding: ").strip()
if not sel:
break
if sel.isdigit() and 1 <= int(sel) <= len(mounts):
chosen = mounts[int(sel) - 1]
else:
chosen = sel
if not Path(chosen).is_dir():
print(f"Path not found: {chosen}")
continue
if chosen not in paths:
paths.append(chosen)
print(f"Added: {chosen}")
else:
print(f"Already added: {chosen}")
again = input("Add another share? [y/N]: ").strip().lower()
if again != "y":
break
config["paths"] = paths
config.setdefault("interval", DEFAULT_SERVICE_INTERVAL)
save_service_config(config)
if not paths:
print("No shares configured. Service not started.")
return
interval = config["interval"]
resp = input(f"Start the service now with a {interval}s interval? [Y/n]: ").strip().lower()
if resp in ("", "y", "yes"):
start_service_daemon(config)
else:
print("Not started. Run --service start to start it later.")
def handle_service_command(value):
value = value.strip()
if value == "":
interactive_configure_service()
return
if value.lower() == "stop":
stop_service()
return
if value.lower() == "start":
config = load_service_config()
if not config.get("paths"):
print("No shares configured yet.")
interactive_configure_service()
return
start_service_daemon(config)
return
if re.fullmatch(r'-?\d+', value):
interval = int(value)
if interval <= 0:
print("Interval must be a positive number of seconds. Use --service stop to stop it.")
sys.exit(1)
config = load_service_config()
config["interval"] = interval
save_service_config(config)
existing_pid = config.get("pid")
if existing_pid and is_process_running(existing_pid):
print(f"Interval updated to {interval}s. The running service (PID {existing_pid}) will pick it up on its next cycle.")
return
if not config.get("paths"):
print(f"Interval set to {interval}s. No shares configured yet.")
interactive_configure_service()
return
start_service_daemon(config)
return
path = Path(value)
if not path.is_dir():
print(f"Path not found: {value}")
sys.exit(1)
config = load_service_config()
paths = config.get("paths", [])
resolved = str(path.resolve())
if resolved not in paths:
paths.append(resolved)
config["paths"] = paths
print(f"Added to service scan list: {resolved}")
else:
print(f"Already in service scan list: {resolved}")
save_service_config(config)
start_service_daemon(config)
def run_service_pass(path_str, media_type):
class ServiceArgs:
pass
args = ServiceArgs()
args.rename = path_str
args.cleanup = path_str
args.type = media_type
args.yes = True
args.force = False
args.verbose = False
args.test = None
log = RenameLog()
run_scan(args, log)
print()
run_cleanup(args, log)
def lower_process_priority():
try:
if platform.system() == "Windows":
BELOW_NORMAL_PRIORITY_CLASS = 0x00004000
handle = ctypes.windll.kernel32.GetCurrentProcess()
ctypes.windll.kernel32.SetPriorityClass(handle, BELOW_NORMAL_PRIORITY_CLASS)
else:
os.nice(10)
except (AttributeError, OSError):
pass
def run_service_worker():
global VERBOSE
VERBOSE = False
def ts():
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
lower_process_priority()
print(f"[{ts()}] Service worker started (PID {os.getpid()}).")
sys.stdout.flush()
while True:
config = load_service_config()
paths = config.get("paths", [])
interval = config.get("interval", DEFAULT_SERVICE_INTERVAL)
if not paths:
print(f"[{ts()}] No shares configured. Waiting {interval}s.")
for path_str in paths:
path = Path(path_str)
if not path.is_dir():
print(f"[{ts()}] Skipping missing path: {path_str}")
continue
media_type = infer_media_type(path)
if media_type is None:
print(f"[{ts()}] Skipping (cannot determine movies/tv from path name): {path_str}")
continue
if not get_api_key_noninteractive():
print(f"[{ts()}] No TMDb API key configured, skipping this cycle.")
break
print(f"[{ts()}] Scanning: {path_str} ({media_type})")
try:
run_service_pass(path_str, media_type)
except SystemExit:
pass
except Exception as e:
print(f"[{ts()}] Error scanning {path_str}: {e}")
sys.stdout.flush()
sys.stdout.flush()
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():
if len(sys.argv) == 1:
run_interactive_menu()
return
args = parse_args()
if args.service_worker:
run_service_worker()
return
if args.service is not None:
handle_service_command(args.service)
return
if args.undo:
other_args_used = (
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.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:
print("--undo cannot be combined with any other argument.")
sys.exit(1)
run_undo()
return
global VERBOSE
VERBOSE = args.verbose
log = RenameLog()
if args.clear_suggestions:
run_clear_suggestions()
return
if args.restore:
run_restore(args.restore)
return
if args.backup:
run_backup(args)
return
if args.manual_rename:
run_manual_rename(args.manual_rename[0], args.manual_rename[1], log)
return
if args.episodes:
run_episode_check(args)
return
if args.suggestions is not None:
run_suggestions(args)
return
if args.export:
run_export(args)
return
if args.cleanup and args.rename:
path_arg = None
if isinstance(args.rename, str):
path_arg = args.rename
elif isinstance(args.cleanup, str):
path_arg = args.cleanup
share = resolve_share(path_arg)
args.rename = share
args.cleanup = share
original_yes = args.yes
run_scan(args, log)
args.yes = original_yes
print()
run_cleanup(args, log)
return
if args.cleanup:
run_cleanup(args, log)
return
if args.rename:
run_scan(args, log)
return
build_parser().print_help()
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print()
print("Interrupted. Exiting.")
sys.exit(130)