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/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