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/server.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""server: choose which Plex server the other commands talk to."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
import plexapi
|
|
12
|
+
import requests
|
|
13
|
+
from plexapi.exceptions import BadRequest, Unauthorized
|
|
14
|
+
from plexapi.myplex import MyPlexAccount
|
|
15
|
+
from plexapi.server import PlexServer
|
|
16
|
+
from requests.exceptions import RequestException
|
|
17
|
+
|
|
18
|
+
from . import auth
|
|
19
|
+
from .common import (
|
|
20
|
+
DEFAULT_URL,
|
|
21
|
+
SIGN_IN_AGAIN,
|
|
22
|
+
URL_ENV,
|
|
23
|
+
PlexToolError,
|
|
24
|
+
add_command,
|
|
25
|
+
add_token_args,
|
|
26
|
+
paint,
|
|
27
|
+
resolve_token,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
CONNECT_TIMEOUT = 10
|
|
31
|
+
PROBE_TIMEOUT = 6
|
|
32
|
+
DNS_WORDS = ("name or service not known", "nameresolutionerror", "getaddrinfo",
|
|
33
|
+
"temporary failure in name resolution", "no address associated")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# What is saved
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
def load_saved() -> dict:
|
|
41
|
+
"""The server chosen with 'server use', or {} if none."""
|
|
42
|
+
path = auth.config_dir() / auth.SERVER_FILE
|
|
43
|
+
try:
|
|
44
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
45
|
+
except FileNotFoundError:
|
|
46
|
+
return {}
|
|
47
|
+
except (OSError, ValueError) as e:
|
|
48
|
+
raise PlexToolError(f"Could not read {path}: {e}. 'plextool server forget' clears it.") from None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def save(saved: dict) -> None:
|
|
52
|
+
auth.write_private(auth.config_dir() / auth.SERVER_FILE, json.dumps(saved, indent=2).encode())
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def forget() -> bool:
|
|
56
|
+
path = auth.config_dir() / auth.SERVER_FILE
|
|
57
|
+
existed = path.exists()
|
|
58
|
+
path.unlink(missing_ok=True)
|
|
59
|
+
return existed
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Finding servers
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
def account_servers(token: str) -> list:
|
|
67
|
+
"""The servers on the signed-in Plex account, owned or shared, as plexapi resources."""
|
|
68
|
+
try:
|
|
69
|
+
resources = MyPlexAccount(token=token).resources()
|
|
70
|
+
except Unauthorized:
|
|
71
|
+
raise PlexToolError(f"plex.tv rejected the token. {SIGN_IN_AGAIN}") from None
|
|
72
|
+
except (RequestException, BadRequest) as e:
|
|
73
|
+
raise PlexToolError(f"Could not reach plex.tv: {e}") from None
|
|
74
|
+
return [r for r in resources if "server" in (r.provides or "")]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def lan_servers() -> list[dict]:
|
|
78
|
+
"""Servers answering on this network (Plex's GDM broadcast). Needs no login and no internet."""
|
|
79
|
+
from plexapi.gdm import GDM
|
|
80
|
+
try:
|
|
81
|
+
entries = GDM().all()
|
|
82
|
+
except OSError:
|
|
83
|
+
return []
|
|
84
|
+
found = []
|
|
85
|
+
for entry in entries:
|
|
86
|
+
data = entry["data"]
|
|
87
|
+
found.append({
|
|
88
|
+
"name": data.get("Name", "?"),
|
|
89
|
+
"url": f"http://{entry['from'][0]}:{data.get('Port', '32400')}",
|
|
90
|
+
"version": data.get("Version", ""),
|
|
91
|
+
"machine_id": data.get("Resource-Identifier", ""),
|
|
92
|
+
})
|
|
93
|
+
return found
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def explain_error(error: Exception) -> str:
|
|
97
|
+
"""A short, plain reason for a failed request."""
|
|
98
|
+
text = str(error).lower()
|
|
99
|
+
if isinstance(error, requests.exceptions.Timeout):
|
|
100
|
+
return "no response (timed out)"
|
|
101
|
+
if isinstance(error, requests.exceptions.SSLError):
|
|
102
|
+
return "TLS/certificate error"
|
|
103
|
+
if isinstance(error, requests.exceptions.ConnectionError):
|
|
104
|
+
if any(word in text for word in DNS_WORDS):
|
|
105
|
+
return "DNS lookup failed"
|
|
106
|
+
if "refused" in text:
|
|
107
|
+
return "connection refused"
|
|
108
|
+
return "could not connect"
|
|
109
|
+
return type(error).__name__
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def probe(url: str, token: str, timeout: float = PROBE_TIMEOUT) -> tuple[bool, str]:
|
|
113
|
+
"""Ask a server for its root page with a token, as connecting does. Returns (worked, short reason).
|
|
114
|
+
|
|
115
|
+
The root page needs a valid token. /identity doesn't, so it would say "ok" for a bad token.
|
|
116
|
+
"""
|
|
117
|
+
try:
|
|
118
|
+
response = requests.get(f"{url.rstrip('/')}/", headers={**plexapi.BASE_HEADERS, "X-Plex-Token": token},
|
|
119
|
+
timeout=timeout)
|
|
120
|
+
except requests.exceptions.RequestException as e:
|
|
121
|
+
return False, explain_error(e)
|
|
122
|
+
if response.status_code == 200:
|
|
123
|
+
return True, "ok"
|
|
124
|
+
if response.status_code == 401:
|
|
125
|
+
return False, "rejected the token (401)"
|
|
126
|
+
return False, f"HTTP {response.status_code}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _short_address(uri: str) -> str:
|
|
130
|
+
"""'https://114-23-165-200.abc.plex.direct:21351' -> '114-23-165-200:21351'."""
|
|
131
|
+
parts = urlsplit(uri)
|
|
132
|
+
host = parts.hostname or ""
|
|
133
|
+
if host.endswith(".plex.direct"):
|
|
134
|
+
host = host.split(".", 1)[0]
|
|
135
|
+
return f"{host}:{parts.port}" if parts.port else host
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def probe_resource(resource, account_token: str) -> list[dict]:
|
|
139
|
+
"""Try every address plex.tv lists for a server, in Plex's preferred order, with the server's
|
|
140
|
+
own token and with the login token."""
|
|
141
|
+
by_uri = {c.uri: c for c in resource.connections}
|
|
142
|
+
ordered = [by_uri[u] for u in resource.preferred_connections() if u in by_uri] or list(by_uri.values())
|
|
143
|
+
|
|
144
|
+
def one(conn):
|
|
145
|
+
return {
|
|
146
|
+
"kind": "relay" if conn.relay else "local" if conn.local else "remote",
|
|
147
|
+
"host": _short_address(conn.uri),
|
|
148
|
+
"uri": conn.uri,
|
|
149
|
+
"server_token": probe(conn.uri, resource.accessToken),
|
|
150
|
+
"login_token": probe(conn.uri, account_token),
|
|
151
|
+
"same_token": resource.accessToken == account_token,
|
|
152
|
+
}
|
|
153
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
154
|
+
return list(pool.map(one, ordered))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def advise(rows: list[dict]) -> str:
|
|
158
|
+
"""One suggestion for what the results point to, or '' if there is nothing to say."""
|
|
159
|
+
if not rows:
|
|
160
|
+
return "Plex lists no addresses for this server."
|
|
161
|
+
if any(r["server_token"][0] for r in rows):
|
|
162
|
+
if not any(r["login_token"][0] for r in rows):
|
|
163
|
+
return "The server accepts its own access token but not your login token."
|
|
164
|
+
return ""
|
|
165
|
+
reasons = {r["server_token"][1] for r in rows}
|
|
166
|
+
if reasons == {"rejected the token (401)"} and {r["login_token"][1] for r in rows} == reasons:
|
|
167
|
+
same = all(r.get("same_token") for r in rows)
|
|
168
|
+
return ("The server answered but rejected the token"
|
|
169
|
+
+ (" (Plex gave the same token for the server as for your login)" if same else "")
|
|
170
|
+
+ ". The token may have been revoked or have expired: sign in again with "
|
|
171
|
+
"'plextool auth login'. If it is a JWT, Plex Media Server may not accept JWTs yet "
|
|
172
|
+
"(1.43.5.11079 did not); a classic token, the default, works.")
|
|
173
|
+
if reasons == {"DNS lookup failed"}:
|
|
174
|
+
return ("Every address failed DNS lookup. Your router or DNS resolver may be blocking Plex's "
|
|
175
|
+
"*.plex.direct names (DNS rebinding protection). Allow plex.direct there, or use the "
|
|
176
|
+
"server's address directly: plextool server use http://HOST:32400")
|
|
177
|
+
if reasons == {"no response (timed out)"}:
|
|
178
|
+
return ("Nothing answered. This computer may be on a different network, or a firewall is "
|
|
179
|
+
"blocking it. If you are on the server's network, use its address directly: "
|
|
180
|
+
"plextool server use http://HOST:32400")
|
|
181
|
+
if "TLS/certificate error" in reasons:
|
|
182
|
+
return "A TLS/certificate error can come from a proxy or antivirus inspecting HTTPS traffic."
|
|
183
|
+
return ""
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def pick_row(rows: list[dict], resource_token: str, login_token: str):
|
|
187
|
+
"""The address to use: the first in Plex's order that accepts the server's own token, else the
|
|
188
|
+
login token. Returns (row, token) or (None, None)."""
|
|
189
|
+
for row in rows:
|
|
190
|
+
if row["server_token"][0]:
|
|
191
|
+
return row, resource_token
|
|
192
|
+
if row["login_token"][0]:
|
|
193
|
+
return row, login_token
|
|
194
|
+
return None, None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def check_saved(token: str) -> str | None:
|
|
198
|
+
"""Does the saved server accept this token? 'ok', a warning to show, or None if no server is saved."""
|
|
199
|
+
saved = load_saved()
|
|
200
|
+
if not saved:
|
|
201
|
+
return None
|
|
202
|
+
try:
|
|
203
|
+
connect_saved(saved, token)
|
|
204
|
+
except (Unauthorized, RequestException, PlexToolError):
|
|
205
|
+
return ("Your saved server did not accept this JWT. Plex accepts it, but Plex Media Servers may "
|
|
206
|
+
"not yet. Run 'plextool server test' for details, or sign in with 'plextool auth login' "
|
|
207
|
+
"(a classic token) instead.")
|
|
208
|
+
return "ok"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def format_rows(rows: list[dict], used: dict | None = None) -> list[str]:
|
|
212
|
+
def cell(result):
|
|
213
|
+
return paint(result[1], "green" if result[0] else "red")
|
|
214
|
+
width = max((len(r["host"]) for r in rows), default=0) + 2
|
|
215
|
+
return [f" {r['kind']:<7}{r['host']:<{width}}server token: {cell(r['server_token'])} "
|
|
216
|
+
f"login token: {cell(r['login_token'])}"
|
|
217
|
+
+ (paint(" <- used", "green") if used is not None and r is used else "") for r in rows]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def connect_saved(saved: dict, token: str) -> PlexServer:
|
|
221
|
+
"""Open the server chosen with 'server use'.
|
|
222
|
+
|
|
223
|
+
A server chosen by name is looked up on plex.tv each time, so a changed address is
|
|
224
|
+
followed. If plex.tv can't be reached, the address that last worked is tried.
|
|
225
|
+
"""
|
|
226
|
+
if saved.get("kind") == "url":
|
|
227
|
+
return PlexServer(saved["url"], token)
|
|
228
|
+
last_url = saved.get("last_url")
|
|
229
|
+
try:
|
|
230
|
+
resource = next((r for r in account_servers(token)
|
|
231
|
+
if r.clientIdentifier == saved.get("machine_id")), None)
|
|
232
|
+
except PlexToolError:
|
|
233
|
+
if last_url:
|
|
234
|
+
return PlexServer(last_url, token)
|
|
235
|
+
raise
|
|
236
|
+
if not resource:
|
|
237
|
+
raise PlexToolError(f"'{saved.get('name')}' is no longer on your Plex account. "
|
|
238
|
+
"Pick another with 'plextool server find' and 'plextool server use'.")
|
|
239
|
+
rows = probe_resource(resource, token)
|
|
240
|
+
row, use_token = pick_row(rows, resource.accessToken, token)
|
|
241
|
+
plex = PlexServer(row["uri"], use_token, timeout=CONNECT_TIMEOUT) if row else None
|
|
242
|
+
if not plex:
|
|
243
|
+
if last_url:
|
|
244
|
+
return PlexServer(last_url, token)
|
|
245
|
+
lines = [f"Could not reach '{resource.name}' on any address plex.tv lists:", *format_rows(rows)]
|
|
246
|
+
if advice := advise(rows):
|
|
247
|
+
lines.append(advice)
|
|
248
|
+
lines.append("'plextool server test' repeats this check.")
|
|
249
|
+
raise PlexToolError("\n".join(lines))
|
|
250
|
+
if plex._baseurl != last_url or saved.get("last_kind") != row["kind"]:
|
|
251
|
+
save({**saved, "last_url": plex._baseurl, "last_kind": row["kind"]})
|
|
252
|
+
return plex
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# Commands
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
|
|
259
|
+
def describe_connection(conn) -> str:
|
|
260
|
+
kind = "relay" if conn.relay else "local" if conn.local else "remote"
|
|
261
|
+
return f"{kind} {conn.uri}"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def run_find(args: argparse.Namespace) -> int:
|
|
265
|
+
print(paint("On your Plex account", "cyan"))
|
|
266
|
+
try:
|
|
267
|
+
token, _ = resolve_token(args)
|
|
268
|
+
servers = account_servers(token)
|
|
269
|
+
except PlexToolError as e:
|
|
270
|
+
servers = None
|
|
271
|
+
print(f" not available: {e}")
|
|
272
|
+
print(" (Sign in with 'plextool auth login' to list the servers on your account.)")
|
|
273
|
+
if servers is not None:
|
|
274
|
+
for server in servers:
|
|
275
|
+
owner = "owned" if server.owned else f"shared by {server.sourceTitle}"
|
|
276
|
+
state = "online" if server.presence else "offline"
|
|
277
|
+
print(f" {paint(server.name, 'bold')} {owner}, {state}")
|
|
278
|
+
for conn in server.connections:
|
|
279
|
+
print(f" {describe_connection(conn)}")
|
|
280
|
+
if not servers:
|
|
281
|
+
print(" no servers found")
|
|
282
|
+
|
|
283
|
+
if not args.no_lan:
|
|
284
|
+
print(paint("\nOn this network", "cyan"))
|
|
285
|
+
found = lan_servers()
|
|
286
|
+
for entry in found:
|
|
287
|
+
print(f" {paint(entry['name'], 'bold')} {entry['url']} {entry['version']}".rstrip())
|
|
288
|
+
if not found:
|
|
289
|
+
print(" none answered (this only works on the same network, and some networks block it)")
|
|
290
|
+
|
|
291
|
+
print("\nChoose one with 'plextool server use NAME' (from your account) or "
|
|
292
|
+
"'plextool server use http://HOST:32400'.")
|
|
293
|
+
return 0
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def run_use(args: argparse.Namespace) -> int:
|
|
297
|
+
target = args.server
|
|
298
|
+
if target.lower().startswith(("http://", "https://")):
|
|
299
|
+
saved = {"kind": "url", "url": target.rstrip("/")}
|
|
300
|
+
if not args.no_check:
|
|
301
|
+
token, _ = resolve_token(args)
|
|
302
|
+
try:
|
|
303
|
+
saved["name"] = PlexServer(saved["url"], token).friendlyName
|
|
304
|
+
except Unauthorized:
|
|
305
|
+
raise PlexToolError("That server rejected the token (401). "
|
|
306
|
+
"Use --no-check to save the address anyway.") from None
|
|
307
|
+
except RequestException as e:
|
|
308
|
+
raise PlexToolError(f"Could not reach {target}: {e}. "
|
|
309
|
+
"Use --no-check to save the address anyway.") from None
|
|
310
|
+
save(saved)
|
|
311
|
+
print(f"Using {saved.get('name', target)} at {saved['url']}")
|
|
312
|
+
return 0
|
|
313
|
+
|
|
314
|
+
token, _ = resolve_token(args)
|
|
315
|
+
servers = account_servers(token)
|
|
316
|
+
matches = [s for s in servers if s.name.lower() == target.lower()]
|
|
317
|
+
if not matches:
|
|
318
|
+
names = ", ".join(s.name for s in servers) or "none"
|
|
319
|
+
raise PlexToolError(f"No server called '{target}' on your account. Available: {names}")
|
|
320
|
+
server = matches[0]
|
|
321
|
+
save({"kind": "account", "name": server.name, "machine_id": server.clientIdentifier})
|
|
322
|
+
print(f"Using {server.name} from your Plex account. "
|
|
323
|
+
"Its address is looked up each time, so it can change.")
|
|
324
|
+
return 0
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def run_test(args: argparse.Namespace) -> int:
|
|
328
|
+
token, _ = resolve_token(args)
|
|
329
|
+
target = args.server
|
|
330
|
+
if target and target.lower().startswith(("http://", "https://")):
|
|
331
|
+
ok, reason = probe(target, token)
|
|
332
|
+
print(f"{target} {paint(reason, 'green' if ok else 'red')}")
|
|
333
|
+
return 0 if ok else 1
|
|
334
|
+
saved = load_saved()
|
|
335
|
+
name = target or saved.get("name")
|
|
336
|
+
if not target and saved.get("kind") == "url":
|
|
337
|
+
ok, reason = probe(saved["url"], token)
|
|
338
|
+
print(f"{saved['url']} {paint(reason, 'green' if ok else 'red')}")
|
|
339
|
+
return 0 if ok else 1
|
|
340
|
+
if not name:
|
|
341
|
+
raise PlexToolError("No server given and none saved. Try 'plextool server test NAME'.")
|
|
342
|
+
matches = [s for s in account_servers(token) if s.name.lower() == name.lower()]
|
|
343
|
+
if not matches:
|
|
344
|
+
raise PlexToolError(f"No server called '{name}' on your account.")
|
|
345
|
+
rows = probe_resource(matches[0], token)
|
|
346
|
+
used, _ = pick_row(rows, matches[0].accessToken, token)
|
|
347
|
+
print(paint(matches[0].name, "bold"))
|
|
348
|
+
print("\n".join(format_rows(rows, used)))
|
|
349
|
+
if advice := advise(rows):
|
|
350
|
+
print(f"\n{advice}")
|
|
351
|
+
return 0 if any(r["server_token"][0] for r in rows) else 1
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def run_show(args: argparse.Namespace) -> int:
|
|
355
|
+
saved = load_saved()
|
|
356
|
+
print(paint("Config folder", "cyan") + f" {auth.config_dir()}")
|
|
357
|
+
if saved.get("kind") == "url":
|
|
358
|
+
label = f"{saved['name']} at " if saved.get("name") else ""
|
|
359
|
+
print(f"Saved server {label}{saved['url']}")
|
|
360
|
+
elif saved:
|
|
361
|
+
print(f"Saved server {saved.get('name')} (looked up on your Plex account)")
|
|
362
|
+
if saved.get("last_url"):
|
|
363
|
+
kind = f"{saved['last_kind']} " if saved.get("last_kind") else ""
|
|
364
|
+
print(f"Last address {kind}{saved['last_url']}")
|
|
365
|
+
else:
|
|
366
|
+
print("Saved server none")
|
|
367
|
+
if os.environ.get(URL_ENV):
|
|
368
|
+
print(f"${URL_ENV} is set to {os.environ[URL_ENV]}, which takes priority over the saved server.")
|
|
369
|
+
elif not saved:
|
|
370
|
+
print(f"Commands will use {DEFAULT_URL}.")
|
|
371
|
+
return 0
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def run_forget(args: argparse.Namespace) -> int:
|
|
375
|
+
print("Forgot the saved server." if forget() else "No server was saved.")
|
|
376
|
+
return 0
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def add_parser(subparsers) -> None:
|
|
380
|
+
parser = add_command(
|
|
381
|
+
subparsers, "server",
|
|
382
|
+
"choose which Plex server to use",
|
|
383
|
+
"Find Plex servers and save the one the other commands should use, so you don't need "
|
|
384
|
+
"--base-url every time. Servers come from your Plex account (which also follows address "
|
|
385
|
+
"changes and works from outside your network) and from a scan of this network. The order "
|
|
386
|
+
"commands use is --base-url, then $PLEX_URL, then the saved server, then "
|
|
387
|
+
f"{DEFAULT_URL}. Nothing here needs the internet except the account list.",
|
|
388
|
+
["plextool server find",
|
|
389
|
+
"plextool server use \"My Server\"",
|
|
390
|
+
"plextool server use http://192.168.1.20:32400",
|
|
391
|
+
"plextool server show",
|
|
392
|
+
"plextool server forget"])
|
|
393
|
+
sub = parser.add_subparsers(dest="action", metavar="action", required=True)
|
|
394
|
+
|
|
395
|
+
find = add_command(
|
|
396
|
+
sub, "find", "list servers on your account and on this network",
|
|
397
|
+
"Lists the servers on your Plex account with every address Plex knows for them, then scans "
|
|
398
|
+
"this network for servers. The scan needs no login and no internet.",
|
|
399
|
+
["plextool server find", "plextool server find --no-lan"])
|
|
400
|
+
add_token_args(find)
|
|
401
|
+
find.add_argument("--no-lan", action="store_true", help="skip the scan of this network")
|
|
402
|
+
find.set_defaults(run=run_find)
|
|
403
|
+
|
|
404
|
+
use = add_command(
|
|
405
|
+
sub, "use", "save the server to use",
|
|
406
|
+
"Save a server by name (from your Plex account) or by address. A name is looked up on "
|
|
407
|
+
"plex.tv each time, so it keeps working if the address changes. An address is used as "
|
|
408
|
+
"given. The server is contacted once to check it, unless you pass --no-check.",
|
|
409
|
+
["plextool server use \"My Server\"",
|
|
410
|
+
"plextool server use http://192.168.1.20:32400",
|
|
411
|
+
"plextool server use https://plex.example.com:32400 --no-check"])
|
|
412
|
+
add_token_args(use)
|
|
413
|
+
use.add_argument("server", metavar="NAME_OR_URL", help="server name, or an http(s):// address")
|
|
414
|
+
use.add_argument("--no-check", action="store_true",
|
|
415
|
+
help="save an address without contacting it (and without a token)")
|
|
416
|
+
use.set_defaults(run=run_use)
|
|
417
|
+
|
|
418
|
+
test = add_command(
|
|
419
|
+
sub, "test", "check every address of a server and say why any fail",
|
|
420
|
+
"Tries each address Plex lists for a server (or one address you give) and reports whether it "
|
|
421
|
+
"answers, using both the server's own access token and your login token. Failures say why: "
|
|
422
|
+
"DNS lookup failed, timed out, TLS error, token rejected. Use it when a command can't reach "
|
|
423
|
+
"your server. With no argument it tests the saved server.",
|
|
424
|
+
["plextool server test", "plextool server test \"My Server\"",
|
|
425
|
+
"plextool server test http://192.168.1.20:32400"])
|
|
426
|
+
add_token_args(test)
|
|
427
|
+
test.add_argument("server", nargs="?", metavar="NAME_OR_URL",
|
|
428
|
+
help="server name or address (default: the saved server)")
|
|
429
|
+
test.set_defaults(run=run_test)
|
|
430
|
+
|
|
431
|
+
show = add_command(sub, "show", "show the saved server", "Show which server commands will use.",
|
|
432
|
+
["plextool server show"])
|
|
433
|
+
show.set_defaults(run=run_show)
|
|
434
|
+
|
|
435
|
+
forgot = add_command(sub, "forget", "forget the saved server",
|
|
436
|
+
"Delete the saved server. Commands go back to $PLEX_URL or "
|
|
437
|
+
f"{DEFAULT_URL}.", ["plextool server forget"])
|
|
438
|
+
forgot.set_defaults(run=run_forget)
|
plextool/stale.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""stale: find what is taking up space and not being watched."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
|
|
9
|
+
from .common import (
|
|
10
|
+
FILE_LEVEL,
|
|
11
|
+
PlexToolError,
|
|
12
|
+
add_command,
|
|
13
|
+
add_connection_args,
|
|
14
|
+
add_library_arg,
|
|
15
|
+
connect,
|
|
16
|
+
describe,
|
|
17
|
+
item_size,
|
|
18
|
+
paint,
|
|
19
|
+
pick_sections,
|
|
20
|
+
plural,
|
|
21
|
+
pretty_size,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def build_rows(items: list, section_type: str) -> list[dict]:
|
|
26
|
+
"""One row per movie, or per show (summing its episodes), from movie/episode items."""
|
|
27
|
+
rows: dict[str, dict] = {}
|
|
28
|
+
for item in items:
|
|
29
|
+
name = describe(item) if section_type == "movie" else item.grandparentTitle
|
|
30
|
+
row = rows.setdefault(name, {"name": name, "size": 0, "added": None, "last_viewed": None,
|
|
31
|
+
"views": 0, "count": 0})
|
|
32
|
+
row["size"] += item_size(item)
|
|
33
|
+
row["count"] += 1
|
|
34
|
+
row["views"] += item.viewCount or 0
|
|
35
|
+
if item.addedAt and (row["added"] is None or item.addedAt < row["added"]):
|
|
36
|
+
row["added"] = item.addedAt
|
|
37
|
+
if item.lastViewedAt and (row["last_viewed"] is None or item.lastViewedAt > row["last_viewed"]):
|
|
38
|
+
row["last_viewed"] = item.lastViewedAt
|
|
39
|
+
return list(rows.values())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def filter_rows(rows: list[dict], now: datetime, unwatched: bool = False,
|
|
43
|
+
idle_days: int | None = None, age_days: int | None = None,
|
|
44
|
+
min_size: int = 0) -> list[dict]:
|
|
45
|
+
"""Keep rows matching every given condition."""
|
|
46
|
+
kept = []
|
|
47
|
+
for row in rows:
|
|
48
|
+
if row["size"] < min_size:
|
|
49
|
+
continue
|
|
50
|
+
if unwatched and row["views"] > 0:
|
|
51
|
+
continue
|
|
52
|
+
if idle_days is not None and row["last_viewed"] and row["last_viewed"] > now - timedelta(days=idle_days):
|
|
53
|
+
continue
|
|
54
|
+
if age_days is not None and (row["added"] is None or row["added"] > now - timedelta(days=age_days)):
|
|
55
|
+
continue
|
|
56
|
+
kept.append(row)
|
|
57
|
+
return kept
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_size(text: str) -> int:
|
|
61
|
+
"""'500MB', '2GB', '1.5TB' or plain bytes."""
|
|
62
|
+
units = {"KB": 1024, "MB": 1024**2, "GB": 1024**3, "TB": 1024**4, "B": 1}
|
|
63
|
+
cleaned = text.strip().upper()
|
|
64
|
+
for suffix, factor in units.items():
|
|
65
|
+
if cleaned.endswith(suffix):
|
|
66
|
+
try:
|
|
67
|
+
return int(float(cleaned[:-len(suffix)]) * factor)
|
|
68
|
+
except ValueError:
|
|
69
|
+
break
|
|
70
|
+
else:
|
|
71
|
+
if cleaned.isdigit():
|
|
72
|
+
return int(cleaned)
|
|
73
|
+
raise argparse.ArgumentTypeError(f"'{text}' is not a size; use eg 500MB or 2GB")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def add_parser(subparsers) -> None:
|
|
77
|
+
parser = add_command(
|
|
78
|
+
subparsers, 'stale',
|
|
79
|
+
'find large, unwatched or idle content',
|
|
80
|
+
"List movies and shows by disk use, filtered by how long they have gone unwatched. Conditions combine with AND. Watch history is the token owner's only. Read-only.",
|
|
81
|
+
['plextool stale --unwatched --age 365 --min-size 5GB',
|
|
82
|
+
'plextool stale -l Movies --idle 730 -n 20'])
|
|
83
|
+
add_connection_args(parser)
|
|
84
|
+
add_library_arg(parser)
|
|
85
|
+
parser.add_argument("--unwatched", action="store_true", help="only things never watched")
|
|
86
|
+
parser.add_argument("--idle", type=int, metavar="DAYS", help="only things not watched in the last DAYS days")
|
|
87
|
+
parser.add_argument("--age", type=int, metavar="DAYS", help="only things added more than DAYS days ago")
|
|
88
|
+
parser.add_argument("--min-size", type=parse_size, default=0, metavar="SIZE", help="ignore anything smaller, eg 1GB")
|
|
89
|
+
parser.add_argument("-n", "--limit", type=int, default=50, metavar="N", help="show the N biggest (default 50)")
|
|
90
|
+
parser.add_argument("--json", action="store_true", help="print JSON instead of text")
|
|
91
|
+
parser.set_defaults(run=run)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _iso(value):
|
|
95
|
+
return value.isoformat() if value else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def run(args: argparse.Namespace) -> int:
|
|
99
|
+
if args.limit < 1:
|
|
100
|
+
raise PlexToolError("--limit must be at least 1")
|
|
101
|
+
plex = connect(args)
|
|
102
|
+
now = datetime.now()
|
|
103
|
+
report = {}
|
|
104
|
+
for section in pick_sections(plex, args.library):
|
|
105
|
+
items = section.search(libtype=FILE_LEVEL[section.TYPE])
|
|
106
|
+
rows = filter_rows(build_rows(items, section.TYPE), now, args.unwatched,
|
|
107
|
+
args.idle, args.age, args.min_size)
|
|
108
|
+
rows.sort(key=lambda r: r["size"], reverse=True)
|
|
109
|
+
report[section.title] = rows
|
|
110
|
+
|
|
111
|
+
if args.json:
|
|
112
|
+
out = {lib: [{**r, "added": _iso(r["added"]), "last_viewed": _iso(r["last_viewed"])}
|
|
113
|
+
for r in rows[:args.limit]] for lib, rows in report.items()}
|
|
114
|
+
print(json.dumps(out, indent=2, ensure_ascii=False))
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
for title, rows in report.items():
|
|
118
|
+
total = sum(r["size"] for r in rows)
|
|
119
|
+
print(paint(f"\n== {title}: {plural(len(rows), 'match')}, {pretty_size(total)} ==", "cyan"))
|
|
120
|
+
for number, row in enumerate(rows[:args.limit], start=1):
|
|
121
|
+
seen = row["last_viewed"].strftime("%Y-%m-%d") if row["last_viewed"] else "never"
|
|
122
|
+
added = row["added"].strftime("%Y-%m-%d") if row["added"] else "?"
|
|
123
|
+
files = f" ({row['count']} files)" if row["count"] > 1 else ""
|
|
124
|
+
print(f"{number:>5} {pretty_size(row['size']):>9} {row['name']}{files}")
|
|
125
|
+
print(f"{'':>17}added {added}, last watched {seen}")
|
|
126
|
+
return 0
|
plextool/status.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""status: a quick look at the server, its libraries and who is watching."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from .common import add_command, add_connection_args, connect, describe, paint, plural, pretty_size
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def collect(plex) -> dict:
|
|
12
|
+
libraries = []
|
|
13
|
+
for section in plex.library.sections():
|
|
14
|
+
libraries.append({
|
|
15
|
+
"title": section.title,
|
|
16
|
+
"type": section.TYPE,
|
|
17
|
+
"items": section.totalSize,
|
|
18
|
+
"storage": section.totalStorage,
|
|
19
|
+
"scanning": bool(section.refreshing),
|
|
20
|
+
})
|
|
21
|
+
sessions = []
|
|
22
|
+
for item in plex.sessions():
|
|
23
|
+
users = getattr(item, "usernames", None) or ["?"]
|
|
24
|
+
players = getattr(item, "players", None) or []
|
|
25
|
+
sessions.append({
|
|
26
|
+
"user": users[0],
|
|
27
|
+
"title": describe(item),
|
|
28
|
+
"state": players[0].state if players else None,
|
|
29
|
+
"player": players[0].title if players else None,
|
|
30
|
+
})
|
|
31
|
+
return {
|
|
32
|
+
"name": plex.friendlyName,
|
|
33
|
+
"version": plex.version,
|
|
34
|
+
"platform": plex.platform,
|
|
35
|
+
"libraries": libraries,
|
|
36
|
+
"sessions": sessions,
|
|
37
|
+
"transcodes": len(plex.transcodeSessions()),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def add_parser(subparsers) -> None:
|
|
42
|
+
parser = add_command(
|
|
43
|
+
subparsers, 'status',
|
|
44
|
+
'show server, library and playback status',
|
|
45
|
+
"Show the server version, each library's size and what is playing now. Read-only.",
|
|
46
|
+
['plextool status',
|
|
47
|
+
'plextool status --json'])
|
|
48
|
+
add_connection_args(parser)
|
|
49
|
+
parser.add_argument("--json", action="store_true", help="print JSON instead of text")
|
|
50
|
+
parser.set_defaults(run=run)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def run(args: argparse.Namespace) -> int:
|
|
54
|
+
info = collect(connect(args))
|
|
55
|
+
if args.json:
|
|
56
|
+
print(json.dumps(info, indent=2, ensure_ascii=False))
|
|
57
|
+
return 0
|
|
58
|
+
print(paint(f"{info['name']}", "bold") + f" Plex Media Server {info['version']} on {info['platform']}")
|
|
59
|
+
print(paint("\nLibraries", "cyan"))
|
|
60
|
+
for lib in info["libraries"]:
|
|
61
|
+
flag = paint(" (scanning)", "yellow") if lib["scanning"] else ""
|
|
62
|
+
print(f" {lib['title']:<24} {lib['type']:<8} {plural(lib['items'] or 0, 'item'):<14} "
|
|
63
|
+
f"{pretty_size(lib['storage'])}{flag}")
|
|
64
|
+
print(paint(f"\nNow playing ({plural(len(info['sessions']), 'session')}, "
|
|
65
|
+
f"{info['transcodes']} transcoding)", "cyan"))
|
|
66
|
+
for s in info["sessions"]:
|
|
67
|
+
print(f" {s['user']}: {s['title']} [{s['state']}] on {s['player']}")
|
|
68
|
+
return 0
|