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/__init__.py +3 -0
- plextool/__main__.py +5 -0
- plextool/audit.py +142 -0
- plextool/auth.py +518 -0
- plextool/cli.py +52 -0
- plextool/common.py +274 -0
- plextool/dupes.py +190 -0
- plextool/ondemand.py +382 -0
- plextool/playlists.py +153 -0
- plextool/recent.py +203 -0
- plextool/resume.py +61 -0
- plextool/scan.py +80 -0
- plextool/server.py +438 -0
- plextool/stale.py +126 -0
- plextool/status.py +68 -0
- plextool/subs.py +338 -0
- plextool/watched.py +249 -0
- plextool-3.0.1.dist-info/METADATA +232 -0
- plextool-3.0.1.dist-info/RECORD +23 -0
- plextool-3.0.1.dist-info/WHEEL +5 -0
- plextool-3.0.1.dist-info/entry_points.txt +2 -0
- plextool-3.0.1.dist-info/licenses/LICENSE +21 -0
- plextool-3.0.1.dist-info/top_level.txt +1 -0
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
|