Add --suggestions/-s recommendation feature with history cache and --clear-suggestions
This commit is contained in:
1 parent
a254264610
commit
ad40d1dadc
2 files changed
+299
-5
No files matched your search
@@ -11,11 +11,13 @@ This guide assumes you've never used Python or GitHub before. If you already kno
|
||||
## What it does
|
||||
|
||||
- Matches your existing folder names against TMDb and renames them to `Movie Name (Year)` / `Show Name (Year)` format
|
||||
- Renames video files to match, and organizes TV episodes into `S01`, `S02`, etc. season folders
|
||||
- Renames video files to match, and organizes TV episodes into `S01`, `S02`, etc. season folders. A "Specials" folder is recognized as season 0 and renamed to `S00` the same way, so cleanup won't mistake it for junk if you've added "Specials" to `delete.yaml`
|
||||
- Detects loose movie or TV episode files sitting directly in the share root (no folder of their own) and organizes them into a proper movie or show folder
|
||||
- Detects a duplicate show folder for a show you already have (e.g. a separately-downloaded "Show S02" folder) and merges its episodes into the existing show folder instead of creating a second one
|
||||
- Detects a duplicate movie folder (a re-download that resolves to a movie you already have) and moves it into a `DUPLICATES` folder at the top of the share, rather than overwriting or leaving it loose. Cleanup (`-c`) picks up `DUPLICATES` and moves it to trash the same as anything else
|
||||
- Detects a movie "bundle" folder (a box set, trilogy, or franchise rip with several different movies in one folder) and splits each movie out into its own correctly named folder, moving its matching subtitles along with it. This checks every movie folder with more than one video file, even if the folder's own name already matches a movie (e.g. a franchise folder named after just the first film)
|
||||
- Checks your organized TV shows against TMDb's episode list and reports any already-aired episodes you're missing (`-e`), without changing anything
|
||||
- Suggests new movies or TV shows based on what's already in your library, using TMDb recommendations (`-s` / `--suggestions [N]`, defaults to 5). Remembers what it's already suggested so it won't repeat itself; clear that history with `--clear-suggestions`
|
||||
- Understands anime-style absolute episode numbering (episodes numbered 1, 2, 3... straight through instead of per-season) when TMDb has that show's episode order data, converting them to the correct season/episode automatically
|
||||
- Finds subtitle files, figures out which one matches your primary language (by filename, and by reading the file's content/metadata if the filename doesn't say), and renames it to match the video. Defaults to English, but follows whatever language you've set for TMDb results (see [Non-English users](#non-english-users)).
|
||||
- Optionally cleans up junk files/folders (samples, `.nfo`, `.txt`, screenshots, unwanted-language subtitles) into a local trash folder. Nothing is deleted permanently, and every cleanup can be reversed.
|
||||
@@ -144,6 +146,8 @@ python plex-library-tool.py -r "/path/to/your/Movies" -t
|
||||
| `-r`, `--rename [PATH]` | Scan and rename a share. Pass a path to skip the share-selection prompt. |
|
||||
| `-c`, `--cleanup [PATH]` | Move junk files/folders (per `delete.yaml`) to a local trash folder. |
|
||||
| `-e`, `--episodes [PATH]` | Check TV shows against TMDb's episode list and report any missing (already-aired) episodes. Read-only, makes no changes. |
|
||||
| `-s`, `--suggestions [N]` | Suggest N new movies or TV shows (default 5) based on what's already in your library, via TMDb recommendations. Read-only, makes no changes. |
|
||||
| `--clear-suggestions` | Clear the suggestions history so previously suggested titles can come up again. |
|
||||
| `-t`, `--test [N]` | Preview only. No changes are made. Optionally limit how many folders are shown. |
|
||||
| `-y`, `--yes` | Don't ask for confirmation before each rename. |
|
||||
| `-f`, `--force` | Force a full scan even if nothing looks like it changed since the last run. |
|
||||
@@ -184,7 +188,7 @@ The service reuses your saved TMDb API key and language from `.env`, so run the
|
||||
These live alongside the script and are all optional. The script works out of the box with sensible defaults. Every file is fully documented with comments and examples inside it.
|
||||
|
||||
- **`names.yaml`**: customize the naming convention. Folder/file name format, season folder naming (`S01` vs `Season 01`), separators (spaces vs dots vs underscores), uppercase/lowercase, whether to include resolution tags like `1080p`, and the subtitle folder name (e.g. rename "Subs" to "Subtitles" or any word in your own language).
|
||||
- **`delete.yaml`**: what cleanup moves to trash. Folder name patterns (e.g. `sample`, `extras`), file name/extension patterns, and subtitle language rules (e.g. "only keep Spanish subtitles").
|
||||
- **`delete.yaml`**: what cleanup moves to trash. Folder name patterns (e.g. `sample`, `extras`), file name/extension patterns, subtitle language rules (e.g. "only keep Spanish subtitles"), and an optional `sample_videos: true` switch to catch loose sample video files sitting next to the real one (video files are otherwise never touched by cleanup).
|
||||
|
||||
---
|
||||
|
||||
|
||||
+293
-3
@@ -10,6 +10,7 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
@@ -36,6 +37,23 @@ 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
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
VERBOSE = False
|
||||
|
||||
@@ -611,6 +629,28 @@ def tmdb_search(api_key, media_type, query):
|
||||
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_alternative_titles(api_key, media_type, tmdb_id):
|
||||
v4 = is_v4_token(api_key)
|
||||
|
||||
@@ -957,6 +997,48 @@ def purge_stray_metadata(path):
|
||||
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))
|
||||
@@ -964,10 +1046,16 @@ def safe_move(src, dest):
|
||||
except OSError as e:
|
||||
src_path = Path(src)
|
||||
dest_path = Path(dest)
|
||||
if src_path.is_dir() and dest_path.exists():
|
||||
if src_path.is_dir():
|
||||
purge_stray_metadata(src_path)
|
||||
purge_empty_subdirs(src_path)
|
||||
try:
|
||||
shutil.rmtree(str(src_path))
|
||||
if dest_path.exists():
|
||||
ok, err = rmtree_retrying(src_path)
|
||||
if not ok:
|
||||
raise OSError(err)
|
||||
else:
|
||||
shutil.move(str(src_path), str(dest_path))
|
||||
return True, None
|
||||
except OSError as e2:
|
||||
return False, str(e2)
|
||||
@@ -3372,6 +3460,21 @@ def run_cleanup(args, log):
|
||||
skipped_files += 1
|
||||
continue
|
||||
|
||||
if kind == "folder" and item.name == DUPLICATES_FOLDER_NAME:
|
||||
purge_stray_metadata(item)
|
||||
purge_empty_subdirs(item)
|
||||
try:
|
||||
is_empty = not any(item.iterdir())
|
||||
except OSError:
|
||||
is_empty = False
|
||||
if is_empty:
|
||||
ok, err = rmdir_retrying(item)
|
||||
if ok:
|
||||
print(f"Removed empty {DUPLICATES_FOLDER_NAME} folder: {item}")
|
||||
moved_folders += 1
|
||||
continue
|
||||
vprint(f" Could not remove empty {DUPLICATES_FOLDER_NAME} folder yet ({err}), falling back to trash move: {item}")
|
||||
|
||||
dest = trash_path_for(share, item, timestamp)
|
||||
if dest.exists():
|
||||
print(f"Skipping (trash target already exists): {item}")
|
||||
@@ -4378,6 +4481,171 @@ def run_restore(log_arg):
|
||||
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 run_suggestions(args):
|
||||
api_key = get_api_key()
|
||||
get_tmdb_language()
|
||||
|
||||
share = resolve_share(None)
|
||||
media_type = determine_media_type(share, getattr(args, "type", None))
|
||||
|
||||
count = args.suggestions if isinstance(args.suggestions, int) and args.suggestions > 0 else DEFAULT_SUGGESTIONS_COUNT
|
||||
|
||||
print()
|
||||
print(f"Performing action: Suggestions ({'Movies' if media_type == 'movie' else 'TV Shows'})")
|
||||
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:
|
||||
print("No matched titles found in this library yet. Run a rename scan first.")
|
||||
return
|
||||
|
||||
print(f"Library has {len(owned_ids)} matched title(s) to base suggestions on.")
|
||||
|
||||
suggestions_cache = load_suggestions_cache()
|
||||
already_suggested = {int(k) for k in suggestions_cache.get(media_type, {}).keys()}
|
||||
|
||||
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
|
||||
|
||||
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]
|
||||
|
||||
genre_map = MOVIE_GENRES if media_type == "movie" else TV_GENRES
|
||||
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 "----"
|
||||
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(genre_names) if 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 build_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Scan SMB media shares, match against TMDb, and rename folders/files into Plex-friendly structure."
|
||||
@@ -4443,6 +4711,19 @@ def build_parser():
|
||||
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=int, default=None, metavar="N",
|
||||
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. "
|
||||
"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(
|
||||
"--service",
|
||||
nargs="?", const="", default=None, metavar="start|stop|SECONDS|PATH",
|
||||
@@ -4459,7 +4740,7 @@ def build_parser():
|
||||
return parser
|
||||
|
||||
|
||||
BUNDLABLE_FLAGS = {"y", "f", "t", "r", "v", "c", "e"}
|
||||
BUNDLABLE_FLAGS = {"y", "f", "t", "r", "v", "c", "e", "s"}
|
||||
PATH_TAKING_FLAGS = {"r", "c", "e"}
|
||||
|
||||
|
||||
@@ -4817,6 +5098,7 @@ def main():
|
||||
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
|
||||
)
|
||||
if other_args_used:
|
||||
print("--undo cannot be combined with any other argument.")
|
||||
@@ -4829,6 +5111,10 @@ def main():
|
||||
|
||||
log = RenameLog()
|
||||
|
||||
if args.clear_suggestions:
|
||||
run_clear_suggestions()
|
||||
return
|
||||
|
||||
if args.restore:
|
||||
run_restore(args.restore)
|
||||
return
|
||||
@@ -4845,6 +5131,10 @@ def main():
|
||||
run_episode_check(args)
|
||||
return
|
||||
|
||||
if args.suggestions is not None:
|
||||
run_suggestions(args)
|
||||
return
|
||||
|
||||
if args.cleanup and args.rename:
|
||||
path_arg = None
|
||||
if isinstance(args.rename, str):
|
||||
|
||||
Reference in new issue
Block a user