plextool 3.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
plextool/cli.py ADDED
@@ -0,0 +1,52 @@
1
+ """Command line entry point: `plextool <command> ...`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from . import (
9
+ __version__,
10
+ audit,
11
+ auth,
12
+ dupes,
13
+ playlists,
14
+ recent,
15
+ resume,
16
+ scan,
17
+ server,
18
+ stale,
19
+ status,
20
+ subs,
21
+ watched,
22
+ )
23
+ from .common import PlexToolError
24
+
25
+
26
+ def build_parser() -> argparse.ArgumentParser:
27
+ parser = argparse.ArgumentParser(
28
+ prog="plextool", description="Maintenance helpers for a Plex Media Server.",
29
+ epilog="Run 'plextool COMMAND --help' for a command's options and examples.\n"
30
+ "Run 'plextool auth login' once to sign in, then 'plextool server find' if the server isn't on localhost.\n"
31
+ "Anything that changes your server is a dry run unless you pass --commit.",
32
+ formatter_class=argparse.RawDescriptionHelpFormatter)
33
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
34
+ subparsers = parser.add_subparsers(dest="command", metavar="command")
35
+ for module in (auth, server, status, scan, recent, dupes, stale, audit, watched, resume, playlists, subs):
36
+ module.add_parser(subparsers)
37
+ return parser
38
+
39
+
40
+ def main(argv: list[str] | None = None) -> int:
41
+ parser = build_parser()
42
+ args = parser.parse_args(argv)
43
+ if not args.command:
44
+ parser.print_help()
45
+ return 0
46
+ try:
47
+ return args.run(args)
48
+ except PlexToolError as e:
49
+ print(f"error: {e}", file=sys.stderr)
50
+ return 2
51
+ except KeyboardInterrupt:
52
+ return 130
plextool/common.py ADDED
@@ -0,0 +1,274 @@
1
+ """Helpers shared by the plextool commands: connection, output styling, naming."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import shlex
9
+ import subprocess
10
+ import sys
11
+ import textwrap
12
+ from pathlib import Path
13
+ from typing import TextIO
14
+
15
+ from plexapi.exceptions import NotFound, Unauthorized
16
+ from plexapi.server import PlexServer
17
+ from requests.exceptions import RequestException
18
+
19
+ DEFAULT_URL = "http://localhost:32400"
20
+ TOKEN_ENV = "PLEX_TOKEN"
21
+ TOKEN_CMD_ENV = "PLEX_TOKEN_CMD"
22
+ SIGN_IN_AGAIN = ("It may have been revoked or have expired. "
23
+ "Sign in again with 'plextool auth login'.")
24
+ URL_ENV = "PLEX_URL"
25
+
26
+ # Which item types ('libtype') each kind of library section can be worked at.
27
+ SECTION_LEVELS = {
28
+ "movie": ("movie",),
29
+ "show": ("show", "season", "episode"),
30
+ }
31
+
32
+
33
+ # The item type that carries the media files in each kind of library.
34
+ FILE_LEVEL = {"movie": "movie", "show": "episode"}
35
+
36
+
37
+ class PlexToolError(Exception):
38
+ """A problem to report to the user without a traceback."""
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Output styling
43
+ # ---------------------------------------------------------------------------
44
+
45
+ _CODES = {
46
+ "bold": "1",
47
+ "red": "91",
48
+ "yellow": "93",
49
+ "green": "92",
50
+ "cyan": "96",
51
+ }
52
+
53
+
54
+ def _color_enabled(stream=None) -> bool:
55
+ stream = stream or sys.stdout
56
+ return stream.isatty() and "NO_COLOR" not in os.environ
57
+
58
+
59
+ def paint(text: str, *styles: str, stream=None) -> str:
60
+ """Wrap text in ANSI styles, but only when writing to a colour-capable terminal."""
61
+ if not styles or not _color_enabled(stream):
62
+ return text
63
+ codes = ";".join(_CODES[s] for s in styles)
64
+ return f"\033[{codes}m{text}\033[0m"
65
+
66
+
67
+ def pretty_size(num_bytes: int | None) -> str:
68
+ """Format a byte count for humans, eg 1536 -> '1.5 KB'."""
69
+ size = float(num_bytes or 0)
70
+ for unit in ("B", "KB", "MB", "GB"):
71
+ if size < 1024:
72
+ return f"{int(size)} B" if unit == "B" else f"{size:.1f} {unit}"
73
+ size /= 1024
74
+ return f"{size:.1f} TB"
75
+
76
+
77
+ def plural(count: int, word: str) -> str:
78
+ return f"{count} {word}{'' if count == 1 else 's'}"
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Describing items
83
+ # ---------------------------------------------------------------------------
84
+
85
+ def _num(value) -> int:
86
+ return value if isinstance(value, int) else 0
87
+
88
+
89
+ def describe(item) -> str:
90
+ """One-line human name for a movie, show, season or episode."""
91
+ kind = getattr(item, "type", None)
92
+ if kind == "season":
93
+ return f"{item.parentTitle} S{_num(item.index):02d}"
94
+ if kind == "episode":
95
+ return (f"{item.grandparentTitle} S{_num(item.seasonNumber):02d}"
96
+ f"E{_num(item.episodeNumber):02d} - {item.title}")
97
+ year = getattr(item, "year", None)
98
+ return f"{item.title} ({year})" if year else str(item.title)
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # Connection
103
+ # ---------------------------------------------------------------------------
104
+
105
+ def add_command(subparsers, name: str, summary: str, description: str, examples: list[str]):
106
+ """Add a (sub)command whose --help shows a wrapped description and worked examples."""
107
+ description = "\n\n".join(textwrap.fill(p, 78) for p in description.split("\n\n"))
108
+ epilog = "examples:\n" + "\n".join(f" {e}" for e in examples)
109
+ return subparsers.add_parser(
110
+ name, help=summary, description=description, epilog=epilog,
111
+ formatter_class=argparse.RawDescriptionHelpFormatter)
112
+
113
+
114
+ def add_token_args(parser: argparse.ArgumentParser) -> None:
115
+ """Add --token and --token-file."""
116
+ parser.add_argument(
117
+ "-T", "--token", dest="token", default=None,
118
+ help="Plex token. Visible in your shell history and process list; "
119
+ "prefer 'plextool auth login', --token-file or $PLEX_TOKEN")
120
+ parser.add_argument(
121
+ "--token-file", dest="token_file", metavar="FILE", default=None,
122
+ help="read the Plex token from FILE ('-' reads standard input)")
123
+
124
+
125
+ def add_connection_args(parser: argparse.ArgumentParser) -> None:
126
+ """Add --token, --token-file and --base-url, which every command needs."""
127
+ add_token_args(parser)
128
+ parser.add_argument(
129
+ "-b", "--base-url", dest="base_url", default=None,
130
+ help=f"Plex server URL (default: ${URL_ENV}, else the server chosen with "
131
+ f"'plextool server use', else {DEFAULT_URL})")
132
+
133
+
134
+ def _token_from_file(path: str) -> str:
135
+ try:
136
+ text = sys.stdin.read() if path == "-" else Path(path).read_text(encoding="utf-8")
137
+ except OSError as e:
138
+ raise PlexToolError(f"Could not read the token file: {e}") from None
139
+ token = text.strip()
140
+ if not token:
141
+ raise PlexToolError(f"The token file {path} is empty.")
142
+ return token
143
+
144
+
145
+ def _token_from_command(command: str) -> str:
146
+ try:
147
+ done = subprocess.run(shlex.split(command), check=False, capture_output=True,
148
+ text=True, timeout=30)
149
+ except (OSError, subprocess.SubprocessError, ValueError) as e:
150
+ raise PlexToolError(f"${TOKEN_CMD_ENV} failed: {e}") from None
151
+ token = done.stdout.strip()
152
+ if done.returncode != 0 or not token:
153
+ raise PlexToolError(f"${TOKEN_CMD_ENV} gave no token (exit status {done.returncode}).")
154
+ return token
155
+
156
+
157
+ def resolve_token(args: argparse.Namespace) -> tuple[str, str]:
158
+ """Find the Plex token to use, as (token, where it came from).
159
+
160
+ Order: --token, --token-file, $PLEX_TOKEN, the output of $PLEX_TOKEN_CMD, then the
161
+ login saved by 'plextool auth login'.
162
+ """
163
+ if args.token:
164
+ print("warning: --token leaves the token in your shell history and process list. "
165
+ f"Use 'plextool auth login', --token-file or ${TOKEN_ENV} instead.", file=sys.stderr)
166
+ return args.token, "--token"
167
+ if getattr(args, "token_file", None):
168
+ return _token_from_file(args.token_file), "--token-file"
169
+ if os.environ.get(TOKEN_ENV):
170
+ return os.environ[TOKEN_ENV], f"${TOKEN_ENV}"
171
+ if os.environ.get(TOKEN_CMD_ENV):
172
+ return _token_from_command(os.environ[TOKEN_CMD_ENV]), f"${TOKEN_CMD_ENV}"
173
+ from . import auth # imported here because auth itself uses this module
174
+ token = auth.stored_token()
175
+ if token:
176
+ auth.use_saved_identity()
177
+ return token, "saved login"
178
+ raise PlexToolError("No Plex token found. Run 'plextool auth login', or use --token-file "
179
+ f"or set ${TOKEN_ENV}.")
180
+
181
+
182
+ def connect(args: argparse.Namespace) -> PlexServer:
183
+ """Connect to the server named in args, with friendly errors.
184
+
185
+ The server is --base-url, else $PLEX_URL, else the one saved by 'plextool server use',
186
+ else the default on this machine. args.base_url is set to the address that worked.
187
+ """
188
+ args.token, source = resolve_token(args)
189
+ url = args.base_url or os.environ.get(URL_ENV)
190
+ try:
191
+ if url:
192
+ plex = PlexServer(url, args.token)
193
+ else:
194
+ from . import server # imported here because server itself uses this module
195
+ saved = server.load_saved()
196
+ plex = server.connect_saved(saved, args.token) if saved else PlexServer(DEFAULT_URL, args.token)
197
+ except Unauthorized:
198
+ hint = SIGN_IN_AGAIN if source == "saved login" else "Check that token, or use 'plextool auth login'."
199
+ raise PlexToolError(f"The Plex server rejected the token from {source} (401). {hint}") from None
200
+ except RequestException as e:
201
+ raise PlexToolError(f"Could not reach {url or DEFAULT_URL}: {e}") from None
202
+ args.base_url = plex._baseurl
203
+ return plex
204
+
205
+
206
+ def write_json(path: str, data: dict) -> TextIO:
207
+ """Write data as JSON to a file, or to standard output if path is '-'.
208
+
209
+ Returns the stream a status message should go to: standard error when the data is
210
+ on standard output, so the message can't end up inside it.
211
+ """
212
+ if path == "-":
213
+ json.dump(data, sys.stdout, indent=2, ensure_ascii=False)
214
+ sys.stdout.write("\n")
215
+ return sys.stderr
216
+ try:
217
+ with open(path, "w", encoding="utf-8") as f:
218
+ json.dump(data, f, indent=2, ensure_ascii=False)
219
+ except OSError as e:
220
+ raise PlexToolError(f"Could not write {path}: {e}") from None
221
+ return sys.stdout
222
+
223
+
224
+ def read_json(path: str) -> dict:
225
+ """Read JSON from a file, or from standard input if path is '-'."""
226
+ try:
227
+ if path == "-":
228
+ return json.load(sys.stdin)
229
+ with open(path, encoding="utf-8") as f:
230
+ return json.load(f)
231
+ except (OSError, ValueError) as e:
232
+ raise PlexToolError(f"Could not read {'standard input' if path == '-' else path}: {e}") from None
233
+
234
+
235
+ def pick_sections(plex: PlexServer, names: list[str] | None) -> list:
236
+ """Return the named library sections, or every movie and TV section if none named."""
237
+ if names:
238
+ sections = []
239
+ for name in names:
240
+ try:
241
+ sections.append(plex.library.section(name))
242
+ except NotFound:
243
+ available = ", ".join(s.title for s in plex.library.sections())
244
+ raise PlexToolError(
245
+ f"No library called '{name}'. Available: {available}") from None
246
+ unsupported = [s for s in sections if s.TYPE not in SECTION_LEVELS]
247
+ if unsupported:
248
+ raise PlexToolError(
249
+ f"Library '{unsupported[0].title}' is a {unsupported[0].TYPE} library; "
250
+ "only movie and TV libraries are supported.")
251
+ return sections
252
+ return [s for s in plex.library.sections() if s.TYPE in SECTION_LEVELS]
253
+
254
+
255
+ def add_library_arg(parser: argparse.ArgumentParser) -> None:
256
+ parser.add_argument("-l", "--library", action="append", metavar="NAME",
257
+ help="library name; repeat for several (default: all movie and TV libraries)")
258
+
259
+
260
+ def item_size(item) -> int:
261
+ """Total bytes of every file attached to a movie or episode, across all versions."""
262
+ return sum(part.size or 0 for media in item.media for part in media.parts)
263
+
264
+
265
+ def confirm_typed(word: str, message: str, assume_yes: bool = False) -> None:
266
+ """Make the user type `word` before a destructive step; raise PlexToolError if they don't."""
267
+ if assume_yes:
268
+ return
269
+ if not sys.stdin.isatty():
270
+ raise PlexToolError("Refusing to continue without a terminal to confirm on. "
271
+ "Pass --assume-yes to skip the prompt.")
272
+ answer = input(f"{message}\nType {word} to continue: ")
273
+ if answer.strip() != word:
274
+ raise PlexToolError("Not confirmed; nothing was changed.")
plextool/dupes.py ADDED
@@ -0,0 +1,190 @@
1
+ """dupes: list items that Plex holds more than one file/version for."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+
8
+ from plexapi.exceptions import BadRequest, NotFound
9
+
10
+ from .common import (
11
+ FILE_LEVEL,
12
+ PlexToolError,
13
+ add_command,
14
+ add_connection_args,
15
+ add_library_arg,
16
+ confirm_typed,
17
+ connect,
18
+ describe,
19
+ paint,
20
+ pick_sections,
21
+ plural,
22
+ pretty_size,
23
+ )
24
+
25
+
26
+ def find_duplicates(section) -> list:
27
+ """Items in a section with more than one media version."""
28
+ level = FILE_LEVEL[section.TYPE]
29
+ try:
30
+ return section.search(libtype=level, duplicate=True)
31
+ except (BadRequest, NotFound):
32
+ # The server didn't accept the duplicate filter; check every item instead.
33
+ return [item for item in section.search(libtype=level) if len(item.media) > 1]
34
+
35
+
36
+ def summarise(item) -> dict:
37
+ """A plain-data description of an item and each of its versions."""
38
+ versions = []
39
+ for index, media in enumerate(item.media):
40
+ parts = [{"file": p.file, "size": p.size or 0} for p in media.parts]
41
+ versions.append({
42
+ "index": index,
43
+ "resolution": media.videoResolution,
44
+ "codec": media.videoCodec,
45
+ "size": sum(p["size"] for p in parts),
46
+ "height": media.height or 0,
47
+ "parts": parts,
48
+ })
49
+ sizes = [v["size"] for v in versions]
50
+ return {
51
+ "title": describe(item),
52
+ "plex_id": item.guid,
53
+ "versions": versions,
54
+ # Space freed by keeping only the largest version.
55
+ "reclaimable": sum(sizes) - max(sizes, default=0),
56
+ }
57
+
58
+
59
+ KEEP_KEYS = {
60
+ "largest": lambda v: v["size"],
61
+ "smallest": lambda v: -v["size"],
62
+ "best-resolution": lambda v: (v["height"], v["size"]),
63
+ }
64
+
65
+
66
+ def choose_deletions(entry: dict, keep: str) -> tuple[list[int], str | None]:
67
+ """Pick which versions of an item to delete, keeping one.
68
+
69
+ Returns (indices to delete, reason the item was skipped or None). Items with a
70
+ multi-part version are skipped, because deleting one part would break it.
71
+ """
72
+ versions = entry["versions"]
73
+ if any(len(v["parts"]) > 1 for v in versions):
74
+ return [], "has a multi-part version"
75
+ best = max(versions, key=KEEP_KEYS[keep])
76
+ return [v["index"] for v in versions if v is not best], None
77
+
78
+
79
+ def print_summary(number: int, entry: dict) -> None:
80
+ print(f"{paint(str(number).rjust(5), 'cyan')} {paint(entry['title'], 'bold')}")
81
+ for version in entry["versions"]:
82
+ quality = " ".join(filter(None, (version["resolution"], version["codec"])))
83
+ label = f" [{quality}]" if quality else ""
84
+ parts = version["parts"]
85
+ if len(parts) == 1:
86
+ print(f" - [{version['index']}] {parts[0]['file']} {pretty_size(version['size'])}{label}")
87
+ else:
88
+ print(f" - [{version['index']}] {plural(len(parts), 'part')}, "
89
+ f"{pretty_size(version['size'])} total{label}")
90
+ for part in parts:
91
+ print(f" {part['file']} {pretty_size(part['size'])}")
92
+ print(paint(" (multi-part version: these files play as one item; "
93
+ "deleting one part breaks it)", "yellow"))
94
+
95
+
96
+ def add_parser(subparsers) -> None:
97
+ parser = add_command(
98
+ subparsers, 'dupes',
99
+ 'list items with more than one file (and optionally delete extras)',
100
+ 'Report movies and episodes that Plex has more than one version of. Read-only unless --keep is given. Duplicates are whatever Plex has matched together, right or wrong.\n\n--keep deletes the other versions of every duplicate from disk. It is a preview unless you add --commit, and then you must type DELETE to confirm.',
101
+ ['plextool dupes',
102
+ 'plextool dupes -l Movies --sort size -n 20',
103
+ 'plextool dupes -l Movies --keep best-resolution # preview deletions',
104
+ 'plextool dupes -l Movies --keep best-resolution --commit # really delete'])
105
+ add_connection_args(parser)
106
+ add_library_arg(parser)
107
+ parser.add_argument("-n", "--limit", type=int, metavar="N", help="show at most N items per library")
108
+ parser.add_argument("--sort", choices=("title", "size"), default="title",
109
+ help="order by title, or by reclaimable space (largest first)")
110
+ parser.add_argument("--json", action="store_true", help="print JSON instead of text")
111
+ parser.add_argument("--keep", choices=list(KEEP_KEYS),
112
+ help="DELETES the other versions of every duplicate, keeping the largest, "
113
+ "smallest or highest-resolution one. Dry run unless --commit.")
114
+ parser.add_argument("-y", "--commit", action="store_true", help="with --keep: really delete files")
115
+ parser.add_argument("--assume-yes", action="store_true", help="with --commit: skip the typed confirmation")
116
+ parser.set_defaults(run=run)
117
+
118
+
119
+ def delete_extras(found: list, keep: str, commit: bool, assume_yes: bool) -> int:
120
+ """Delete (or preview deleting) all but one version of each (item, entry) pair."""
121
+ plan = []
122
+ for item, entry in found:
123
+ indices, skipped = choose_deletions(entry, keep)
124
+ if skipped:
125
+ print(paint(f" skip {entry['title']}: {skipped}", "yellow"))
126
+ continue
127
+ plan.append((item, entry, indices))
128
+
129
+ freed = 0
130
+ print(paint(f"\nKeeping the {keep} version of each item:", "cyan"))
131
+ for item, entry, indices in plan:
132
+ print(f" {entry['title']}")
133
+ for v in entry["versions"]:
134
+ action = paint("DELETE", "red") if v["index"] in indices else paint("keep ", "green")
135
+ print(f" {action} {v['parts'][0]['file']} {pretty_size(v['size'])}")
136
+ if v["index"] in indices:
137
+ freed += v["size"]
138
+ count = sum(len(i) for _, _, i in plan)
139
+ print(f"\n{plural(count, 'file')} would be deleted, freeing {pretty_size(freed)}.")
140
+ if not commit or not count:
141
+ if count:
142
+ print("Dry run only. Re-run with --commit to delete.")
143
+ return 0
144
+
145
+ confirm_typed("DELETE", f"This permanently deletes {plural(count, 'file')} from disk.", assume_yes)
146
+ for item, entry, indices in plan:
147
+ victims = [item.media[i] for i in indices]
148
+ for media in victims:
149
+ try:
150
+ media.delete()
151
+ except BadRequest:
152
+ raise PlexToolError(
153
+ "The server refused to delete. Enable Settings > Library > "
154
+ "'Allow media deletion' and try again.") from None
155
+ print(f" deleted {media.parts[0].file}")
156
+ return 0
157
+
158
+
159
+ def run(args: argparse.Namespace) -> int:
160
+ if args.limit is not None and args.limit < 1:
161
+ raise PlexToolError("--limit must be at least 1")
162
+ if args.commit and not args.keep:
163
+ raise PlexToolError("--commit needs --keep")
164
+ plex = connect(args)
165
+ report = {}
166
+ pairs = []
167
+ for section in pick_sections(plex, args.library):
168
+ found = [(item, summarise(item)) for item in find_duplicates(section)]
169
+ if args.sort == "size":
170
+ found.sort(key=lambda p: p[1]["reclaimable"], reverse=True)
171
+ found = found[:args.limit] if args.limit else found
172
+ pairs += found
173
+ report[section.title] = [entry for _, entry in found]
174
+
175
+ if args.json:
176
+ print(json.dumps(report, indent=2, ensure_ascii=False))
177
+ return 0 if not args.keep else delete_extras(pairs, args.keep, args.commit, args.assume_yes)
178
+
179
+ total = reclaimable = 0
180
+ for title, entries in report.items():
181
+ print(paint(f"\n== {title}: {plural(len(entries), 'item')} with duplicates ==", "cyan"))
182
+ for number, entry in enumerate(entries, start=1):
183
+ print_summary(number, entry)
184
+ total += len(entries)
185
+ reclaimable += sum(e["reclaimable"] for e in entries)
186
+ print(f"\n{plural(total, 'item')} with duplicates; "
187
+ f"{pretty_size(reclaimable)} could be freed by keeping only the largest version of each.")
188
+ if args.keep:
189
+ return delete_extras(pairs, args.keep, args.commit, args.assume_yes)
190
+ return 0