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