Add genre filtering to --suggestions via TMDb discover
This commit is contained in:
1 parent
ad40d1dadc
commit
6e1ca40dbe
2 files changed
+153
-35
No files matched your search
@@ -17,7 +17,7 @@ This guide assumes you've never used Python or GitHub before. If you already kno
|
|||||||
- Detects a duplicate movie folder (a re-download that resolves to a movie you already have) and moves it into a `DUPLICATES` folder at the top of the share, rather than overwriting or leaving it loose. Cleanup (`-c`) picks up `DUPLICATES` and moves it to trash the same as anything else
|
- Detects a duplicate movie folder (a re-download that resolves to a movie you already have) and moves it into a `DUPLICATES` folder at the top of the share, rather than overwriting or leaving it loose. 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)
|
- Detects a movie "bundle" folder (a box set, trilogy, or franchise rip with several different movies in one folder) and splits each movie out into its own correctly named folder, moving its matching subtitles along with it. This checks every movie folder with more than one video file, even if the folder's own name already matches a movie (e.g. a franchise folder named after just the first film)
|
||||||
- Checks your organized TV shows against TMDb's episode list and reports any already-aired episodes you're missing (`-e`), without changing anything
|
- Checks your organized TV shows against TMDb's episode list and reports any already-aired episodes you're missing (`-e`), without changing anything
|
||||||
- Suggests new movies or TV shows based on what's already in your library, using TMDb recommendations (`-s` / `--suggestions [N]`, defaults to 5). Remembers what it's already suggested so it won't repeat itself; clear that history with `--clear-suggestions`
|
- Suggests new movies or TV shows based on what's already in your library, using TMDb recommendations (`-s` / `--suggestions [N]`, defaults to 5). Pass a genre list instead of a number (e.g. `--suggestions action,sci-fi`) to suggest purely from that genre instead of basing it on your library. Remembers what it's already suggested so it won't repeat itself; clear that history with `--clear-suggestions`
|
||||||
- Understands anime-style absolute episode numbering (episodes numbered 1, 2, 3... straight through instead of per-season) when TMDb has that show's episode order data, converting them to the correct season/episode automatically
|
- Understands anime-style absolute episode numbering (episodes numbered 1, 2, 3... straight through instead of per-season) when TMDb has that show's episode order data, converting them to the correct season/episode automatically
|
||||||
- Finds subtitle files, figures out which one matches your primary language (by filename, and by reading the file's content/metadata if the filename doesn't say), and renames it to match the video. Defaults to English, but follows whatever language you've set for TMDb results (see [Non-English users](#non-english-users)).
|
- Finds subtitle files, figures out which one matches your primary language (by filename, and by reading the file's content/metadata if the filename doesn't say), and renames it to match the video. Defaults to English, but follows whatever language you've set for TMDb results (see [Non-English users](#non-english-users)).
|
||||||
- Optionally cleans up junk files/folders (samples, `.nfo`, `.txt`, screenshots, unwanted-language subtitles) into a local trash folder. Nothing is deleted permanently, and every cleanup can be reversed.
|
- Optionally cleans up junk files/folders (samples, `.nfo`, `.txt`, screenshots, unwanted-language subtitles) into a local trash folder. Nothing is deleted permanently, and every cleanup can be reversed.
|
||||||
@@ -146,7 +146,7 @@ python plex-library-tool.py -r "/path/to/your/Movies" -t
|
|||||||
| `-r`, `--rename [PATH]` | Scan and rename a share. Pass a path to skip the share-selection prompt. |
|
| `-r`, `--rename [PATH]` | Scan and rename a share. Pass a path to skip the share-selection prompt. |
|
||||||
| `-c`, `--cleanup [PATH]` | Move junk files/folders (per `delete.yaml`) to a local trash folder. |
|
| `-c`, `--cleanup [PATH]` | Move junk files/folders (per `delete.yaml`) to a local trash folder. |
|
||||||
| `-e`, `--episodes [PATH]` | Check TV shows against TMDb's episode list and report any missing (already-aired) episodes. Read-only, makes no changes. |
|
| `-e`, `--episodes [PATH]` | Check TV shows against TMDb's episode list and report any missing (already-aired) episodes. Read-only, makes no changes. |
|
||||||
| `-s`, `--suggestions [N]` | 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. |
|
| `-s`, `--suggestions [N\|genre1,genre2]` | Suggest N new movies or TV shows (default 5) based on what's already in your library, via TMDb recommendations. Pass a genre list instead of a number to suggest purely from that genre. Read-only, makes no changes. |
|
||||||
| `--clear-suggestions` | Clear the suggestions history so previously suggested titles can come up again. |
|
| `--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. |
|
| `-t`, `--test [N]` | Preview only. No changes are made. Optionally limit how many folders are shown. |
|
||||||
| `-y`, `--yes` | Don't ask for confirmation before each rename. |
|
| `-y`, `--yes` | Don't ask for confirmation before each rename. |
|
||||||
|
|||||||
+151
-33
@@ -55,6 +55,42 @@ TV_GENRES = {
|
|||||||
10766: "Soap", 10767: "Talk", 10768: "War & Politics", 37: "Western",
|
10766: "Soap", 10767: "Talk", 10768: "War & Politics", 37: "Western",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GENRE_NAME_ALIASES = {
|
||||||
|
"scifi": "science fiction",
|
||||||
|
"sci fi": "science fiction",
|
||||||
|
"sciencefiction": "science fiction",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_genre_key(name):
|
||||||
|
return re.sub(r'[^a-z0-9]', '', name.lower())
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_genre_ids(genre_map, raw_names):
|
||||||
|
normalized_map = {}
|
||||||
|
for gid, gname in genre_map.items():
|
||||||
|
normalized_map[normalize_genre_key(gname)] = gid
|
||||||
|
|
||||||
|
resolved = []
|
||||||
|
unresolved = []
|
||||||
|
for raw in raw_names:
|
||||||
|
key = normalize_genre_key(raw)
|
||||||
|
gid = normalized_map.get(key)
|
||||||
|
if gid is None:
|
||||||
|
gid = next((v for k, v in normalized_map.items() if key and (key in k or k in key)), None)
|
||||||
|
if gid is None:
|
||||||
|
alias = GENRE_NAME_ALIASES.get(key)
|
||||||
|
if alias:
|
||||||
|
alias_key = normalize_genre_key(alias)
|
||||||
|
gid = normalized_map.get(alias_key)
|
||||||
|
if gid is None:
|
||||||
|
gid = next((v for k, v in normalized_map.items() if alias_key in k or k in alias_key), None)
|
||||||
|
if gid is not None and gid not in resolved:
|
||||||
|
resolved.append(gid)
|
||||||
|
elif gid is None:
|
||||||
|
unresolved.append(raw)
|
||||||
|
return resolved, unresolved
|
||||||
|
|
||||||
VERBOSE = False
|
VERBOSE = False
|
||||||
|
|
||||||
_vprint_local = threading.local()
|
_vprint_local = threading.local()
|
||||||
@@ -589,6 +625,13 @@ def normalize_media_type_arg(value):
|
|||||||
raise argparse.ArgumentTypeError(f"Invalid type: {value!r}. Use 'movies' or 'tv'.")
|
raise argparse.ArgumentTypeError(f"Invalid type: {value!r}. Use 'movies' or 'tv'.")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_suggestions_arg(value):
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def determine_media_type(share, override=None):
|
def determine_media_type(share, override=None):
|
||||||
if override:
|
if override:
|
||||||
label = "Movies" if override == "movie" else "TV Shows"
|
label = "Movies" if override == "movie" else "TV Shows"
|
||||||
@@ -651,6 +694,34 @@ def tmdb_related(api_key, media_type, tmdb_id, kind="recommendations"):
|
|||||||
return data.get("results", [])
|
return data.get("results", [])
|
||||||
|
|
||||||
|
|
||||||
|
def tmdb_discover(api_key, media_type, extra_params, page=1):
|
||||||
|
v4 = is_v4_token(api_key)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"language": get_tmdb_language(),
|
||||||
|
"sort_by": "popularity.desc",
|
||||||
|
"vote_count.gte": 50,
|
||||||
|
"page": page,
|
||||||
|
}
|
||||||
|
params.update(extra_params)
|
||||||
|
if not v4:
|
||||||
|
params["api_key"] = api_key
|
||||||
|
|
||||||
|
section = "movie" if media_type == "movie" else "tv"
|
||||||
|
url = f"{TMDB_BASE}/discover/{section}?{urllib.parse.urlencode(params)}"
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bearer {api_key}"} if v4 else {}
|
||||||
|
req = urllib.request.Request(url, headers=headers)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
data = json.loads(resp.read().decode("utf-8"))
|
||||||
|
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, OSError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
return data.get("results", [])
|
||||||
|
|
||||||
|
|
||||||
def tmdb_alternative_titles(api_key, media_type, tmdb_id):
|
def tmdb_alternative_titles(api_key, media_type, tmdb_id):
|
||||||
v4 = is_v4_token(api_key)
|
v4 = is_v4_token(api_key)
|
||||||
|
|
||||||
@@ -4550,33 +4621,7 @@ def resolve_suggestions_library_index(share, media_type, api_key):
|
|||||||
return index
|
return index
|
||||||
|
|
||||||
|
|
||||||
def run_suggestions(args):
|
def gather_recommendation_candidates(api_key, media_type, owned_ids, already_suggested):
|
||||||
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)
|
seed_ids = list(owned_ids)
|
||||||
random.shuffle(seed_ids)
|
random.shuffle(seed_ids)
|
||||||
seed_ids = seed_ids[:SUGGESTION_SEED_SAMPLE_SIZE]
|
seed_ids = seed_ids[:SUGGESTION_SEED_SAMPLE_SIZE]
|
||||||
@@ -4601,6 +4646,78 @@ def run_suggestions(args):
|
|||||||
continue
|
continue
|
||||||
entry = candidates.setdefault(rid, {"data": r, "score": 0})
|
entry = candidates.setdefault(rid, {"data": r, "score": 0})
|
||||||
entry["score"] += 1
|
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):
|
||||||
|
api_key = get_api_key()
|
||||||
|
get_tmdb_language()
|
||||||
|
|
||||||
|
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:
|
if not candidates:
|
||||||
print("No new suggestions found. Try again later, or clear your suggestions history with --clear-suggestions.")
|
print("No new suggestions found. Try again later, or clear your suggestions history with --clear-suggestions.")
|
||||||
@@ -4613,7 +4730,6 @@ def run_suggestions(args):
|
|||||||
)
|
)
|
||||||
chosen = ranked[:count]
|
chosen = ranked[:count]
|
||||||
|
|
||||||
genre_map = MOVIE_GENRES if media_type == "movie" else TV_GENRES
|
|
||||||
newly_suggested = {}
|
newly_suggested = {}
|
||||||
|
|
||||||
for c in chosen:
|
for c in chosen:
|
||||||
@@ -4622,12 +4738,12 @@ def run_suggestions(args):
|
|||||||
title = r.get("title") or r.get("name") or "Unknown"
|
title = r.get("title") or r.get("name") or "Unknown"
|
||||||
date = r.get("release_date") or r.get("first_air_date") or ""
|
date = r.get("release_date") or r.get("first_air_date") or ""
|
||||||
year = date[:4] if date else "----"
|
year = date[:4] if date else "----"
|
||||||
genre_names = [genre_map.get(gid, str(gid)) for gid in r.get("genre_ids", [])]
|
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)
|
score = round((r.get("vote_average") or 0) * 10)
|
||||||
overview = r.get("overview") or "No overview available."
|
overview = r.get("overview") or "No overview available."
|
||||||
|
|
||||||
print(f"Name: {title} ({year})")
|
print(f"Name: {title} ({year})")
|
||||||
print(f"Genre: {', '.join(genre_names) if genre_names else 'Unknown'}")
|
print(f"Genre: {', '.join(item_genre_names) if item_genre_names else 'Unknown'}")
|
||||||
print(f"Score: {score}%")
|
print(f"Score: {score}%")
|
||||||
print("Overview:")
|
print("Overview:")
|
||||||
print(overview)
|
print(overview)
|
||||||
@@ -4713,11 +4829,13 @@ def build_parser():
|
|||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-s", "--suggestions",
|
"-s", "--suggestions",
|
||||||
nargs="?", const=DEFAULT_SUGGESTIONS_COUNT, type=int, default=None, metavar="N",
|
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 "
|
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. "
|
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 "
|
"Pass a comma-separated genre list instead of a number (e.g. --suggestions action,sci-fi) "
|
||||||
"--clear-suggestions."
|
"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(
|
parser.add_argument(
|
||||||
"--clear-suggestions",
|
"--clear-suggestions",
|
||||||
|
|||||||
Reference in new issue
Block a user