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/__init__.py
ADDED
plextool/__main__.py
ADDED
plextool/audit.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""audit: find metadata and file problems across libraries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from .common import (
|
|
9
|
+
FILE_LEVEL,
|
|
10
|
+
PlexToolError,
|
|
11
|
+
add_command,
|
|
12
|
+
add_connection_args,
|
|
13
|
+
add_library_arg,
|
|
14
|
+
connect,
|
|
15
|
+
describe,
|
|
16
|
+
paint,
|
|
17
|
+
pick_sections,
|
|
18
|
+
plural,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
LOW_RES = {"sd", "480", "576", "360", "240"}
|
|
22
|
+
UNMATCHED_PREFIXES = ("local://", "com.plexapp.agents.none")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Each check takes (item, lang) and returns a short reason string, or None if the item is fine.
|
|
26
|
+
|
|
27
|
+
def check_unmatched(item, lang):
|
|
28
|
+
if str(item.guid).startswith(UNMATCHED_PREFIXES):
|
|
29
|
+
return "not matched to an online source"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def check_no_poster(item, lang):
|
|
33
|
+
if not item.thumb:
|
|
34
|
+
return "no poster"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def check_no_summary(item, lang):
|
|
38
|
+
if not item.summary:
|
|
39
|
+
return "no summary"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def check_missing_files(item, lang):
|
|
43
|
+
bad = [part.file for media in item.media for part in media.parts if part.exists is False]
|
|
44
|
+
if bad:
|
|
45
|
+
return f"file missing on disk: {bad[0]}" + (f" (+{len(bad) - 1} more)" if len(bad) > 1 else "")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def check_low_res(item, lang):
|
|
49
|
+
resolutions = {m.videoResolution for m in item.media if m.videoResolution}
|
|
50
|
+
if resolutions and resolutions <= LOW_RES:
|
|
51
|
+
return f"low resolution ({', '.join(sorted(resolutions))})"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _streams(item, kind):
|
|
55
|
+
return [s for media in item.media for part in media.parts for s in getattr(part, kind)()]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def check_no_subs(item, lang):
|
|
59
|
+
if not any(s.languageCode == lang for s in _streams(item, "subtitleStreams")):
|
|
60
|
+
return f"no '{lang}' subtitles"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def check_no_audio(item, lang):
|
|
64
|
+
if not any(s.languageCode == lang for s in _streams(item, "audioStreams")):
|
|
65
|
+
return f"no '{lang}' audio"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# name -> (function, works on files (movie/episode) rather than the library's top level, needs --lang,
|
|
69
|
+
# needs the full item loaded to see its streams)
|
|
70
|
+
CHECKS = {
|
|
71
|
+
"unmatched": (check_unmatched, False, False, False),
|
|
72
|
+
"no-poster": (check_no_poster, False, False, False),
|
|
73
|
+
"no-summary": (check_no_summary, False, False, False),
|
|
74
|
+
"missing-files": (check_missing_files, True, False, False),
|
|
75
|
+
"low-res": (check_low_res, True, False, False),
|
|
76
|
+
"no-subs": (check_no_subs, True, True, True),
|
|
77
|
+
"no-audio": (check_no_audio, True, True, True),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def run_check(name: str, items: list, lang: str | None) -> list[dict]:
|
|
82
|
+
"""Apply one check to items; returns [{'item': description, 'reason': ...}]."""
|
|
83
|
+
func, _, _, needs_full = CHECKS[name]
|
|
84
|
+
found = []
|
|
85
|
+
for item in items:
|
|
86
|
+
if needs_full:
|
|
87
|
+
item.reload()
|
|
88
|
+
reason = func(item, lang)
|
|
89
|
+
if reason:
|
|
90
|
+
found.append({"item": describe(item), "reason": reason})
|
|
91
|
+
return found
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def add_parser(subparsers) -> None:
|
|
95
|
+
parser = add_command(
|
|
96
|
+
subparsers, 'audit',
|
|
97
|
+
'find unmatched items, missing files, missing subtitles and more',
|
|
98
|
+
"Report problems in your libraries. Read-only. Checks: unmatched, no-poster, no-summary, missing-files, low-res, no-subs, no-audio. With no --check, every check that doesn't need --lang runs. no-subs and no-audio read every item's tracks and are slow on large libraries.",
|
|
99
|
+
['plextool audit',
|
|
100
|
+
'plextool audit -c missing-files -c unmatched',
|
|
101
|
+
"plextool audit -c no-subs --lang eng -l 'TV Shows'"])
|
|
102
|
+
add_connection_args(parser)
|
|
103
|
+
add_library_arg(parser)
|
|
104
|
+
parser.add_argument("-c", "--check", action="append", choices=list(CHECKS), metavar="CHECK",
|
|
105
|
+
help="check to run; repeat for several (default: all that don't need --lang)")
|
|
106
|
+
parser.add_argument("--lang", help="3-letter language code, as Plex shows it, for no-subs / no-audio, eg eng")
|
|
107
|
+
parser.add_argument("-n", "--limit", type=int, metavar="N", help="show at most N problems per check")
|
|
108
|
+
parser.add_argument("--json", action="store_true", help="print JSON instead of text")
|
|
109
|
+
parser.set_defaults(run=run)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def run(args: argparse.Namespace) -> int:
|
|
113
|
+
names = args.check or [n for n, (_, _, needs_lang, _) in CHECKS.items() if not needs_lang]
|
|
114
|
+
for name in names:
|
|
115
|
+
if CHECKS[name][2] and not args.lang:
|
|
116
|
+
raise PlexToolError(f"Check '{name}' needs --lang, eg --lang eng")
|
|
117
|
+
if args.limit is not None and args.limit < 1:
|
|
118
|
+
raise PlexToolError("--limit must be at least 1")
|
|
119
|
+
|
|
120
|
+
plex = connect(args)
|
|
121
|
+
report: dict = {}
|
|
122
|
+
for section in pick_sections(plex, args.library):
|
|
123
|
+
cache: dict[str, list] = {}
|
|
124
|
+
for name in names:
|
|
125
|
+
level = FILE_LEVEL[section.TYPE] if CHECKS[name][1] else section.TYPE
|
|
126
|
+
if level not in cache:
|
|
127
|
+
cache[level] = section.search(libtype=level)
|
|
128
|
+
report.setdefault(name, {})[section.title] = run_check(name, cache[level], args.lang)
|
|
129
|
+
|
|
130
|
+
if args.json:
|
|
131
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
total = 0
|
|
135
|
+
for name, libs in report.items():
|
|
136
|
+
for title, problems in libs.items():
|
|
137
|
+
print(paint(f"\n== {name} in {title}: {plural(len(problems), 'problem')} ==", "cyan"))
|
|
138
|
+
for problem in problems[:args.limit]:
|
|
139
|
+
print(f" {problem['item']}: {problem['reason']}")
|
|
140
|
+
total += len(problems)
|
|
141
|
+
print(f"\n{plural(total, 'problem')} found.")
|
|
142
|
+
return 0
|
plextool/auth.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
1
|
+
"""auth: sign in to Plex without hunting for a token, and look after what is saved."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import socket
|
|
9
|
+
import sys
|
|
10
|
+
import uuid
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import parse_qs, urlencode, urlsplit
|
|
14
|
+
|
|
15
|
+
from plexapi.exceptions import BadRequest, NotFound, Unauthorized
|
|
16
|
+
from plexapi.myplex import MyPlexAccount
|
|
17
|
+
from requests.exceptions import RequestException
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .common import (
|
|
21
|
+
SIGN_IN_AGAIN,
|
|
22
|
+
PlexToolError,
|
|
23
|
+
add_command,
|
|
24
|
+
add_token_args,
|
|
25
|
+
confirm_typed,
|
|
26
|
+
paint,
|
|
27
|
+
plural,
|
|
28
|
+
resolve_token,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
CONFIG_DIR_ENV = "PLEXTOOL_CONFIG_DIR"
|
|
32
|
+
STATE_FILE = "auth.json"
|
|
33
|
+
SERVER_FILE = "server.json"
|
|
34
|
+
PRIVATE_KEY_FILE = "jwt_private.key"
|
|
35
|
+
PUBLIC_KEY_FILE = "jwt_public.key"
|
|
36
|
+
KEY_FILES = (PRIVATE_KEY_FILE, PUBLIC_KEY_FILE)
|
|
37
|
+
ALL_FILES = (STATE_FILE, *KEY_FILES, SERVER_FILE)
|
|
38
|
+
|
|
39
|
+
# A JWT is renewed when it has less than this left.
|
|
40
|
+
REFRESH_WITHIN = timedelta(days=1)
|
|
41
|
+
LOGIN_TIMEOUT = 300
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Where things are kept
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def config_dir() -> Path:
|
|
49
|
+
"""The folder holding the saved login and keys: $PLEXTOOL_CONFIG_DIR, else ~/.config/plextool."""
|
|
50
|
+
if os.environ.get(CONFIG_DIR_ENV):
|
|
51
|
+
return Path(os.environ[CONFIG_DIR_ENV]).expanduser()
|
|
52
|
+
base = os.environ.get("XDG_CONFIG_HOME") or "~/.config"
|
|
53
|
+
return Path(base).expanduser() / "plextool"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def write_private(path: Path, data: bytes) -> None:
|
|
57
|
+
"""Write a file that only its owner can read, never briefly readable by anyone else."""
|
|
58
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
59
|
+
temp = path.with_name(path.name + ".tmp")
|
|
60
|
+
fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
61
|
+
with os.fdopen(fd, "wb") as f:
|
|
62
|
+
f.write(data)
|
|
63
|
+
os.replace(temp, path)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_state() -> dict:
|
|
67
|
+
path = config_dir() / STATE_FILE
|
|
68
|
+
try:
|
|
69
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
return {}
|
|
72
|
+
except (OSError, ValueError) as e:
|
|
73
|
+
raise PlexToolError(f"Could not read {path}: {e}. 'plextool auth wipe' clears it.") from None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def save_state(state: dict) -> None:
|
|
77
|
+
write_private(config_dir() / STATE_FILE, json.dumps(state, indent=2).encode())
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _require_jwt_support():
|
|
81
|
+
try:
|
|
82
|
+
import jwt # noqa: F401
|
|
83
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519 # noqa: F401
|
|
84
|
+
from plexapi.myplex import MyPlexJWTLogin
|
|
85
|
+
except ImportError:
|
|
86
|
+
raise PlexToolError("JWT sign-in needs plexapi 4.18 or later with PyJWT and cryptography. "
|
|
87
|
+
"Install them with: pip install 'plexapi>=4.18' 'pyjwt[crypto]'") from None
|
|
88
|
+
return MyPlexJWTLogin
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _headers(client_id: str) -> dict:
|
|
92
|
+
return {
|
|
93
|
+
"X-Plex-Client-Identifier": client_id,
|
|
94
|
+
"X-Plex-Product": "plextool",
|
|
95
|
+
"X-Plex-Version": __version__,
|
|
96
|
+
"X-Plex-Device-Name": f"plextool on {socket.gethostname()}",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _new_keypair() -> tuple[bytes, bytes]:
|
|
101
|
+
"""A fresh ED25519 keypair as raw bytes, the form plexapi expects."""
|
|
102
|
+
from cryptography.hazmat.primitives import serialization
|
|
103
|
+
from cryptography.hazmat.primitives.asymmetric import ed25519
|
|
104
|
+
private = ed25519.Ed25519PrivateKey.generate()
|
|
105
|
+
return (
|
|
106
|
+
private.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw,
|
|
107
|
+
serialization.NoEncryption()),
|
|
108
|
+
private.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw),
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _jwt_expiry(token: str) -> datetime | None:
|
|
113
|
+
"""When a JWT runs out, read without checking the signature. None if it can't be read."""
|
|
114
|
+
import jwt
|
|
115
|
+
try:
|
|
116
|
+
exp = jwt.decode(token, options={"verify_signature": False}).get("exp")
|
|
117
|
+
except jwt.InvalidTokenError:
|
|
118
|
+
return None
|
|
119
|
+
return datetime.fromtimestamp(exp, timezone.utc) if exp else None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# The token other commands use
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def stored_token() -> str | None:
|
|
127
|
+
"""The token from the saved login, renewing a JWT that is close to expiring. None if not signed in."""
|
|
128
|
+
state = load_state()
|
|
129
|
+
token = state.get("token")
|
|
130
|
+
if not token:
|
|
131
|
+
return None
|
|
132
|
+
if state.get("kind") != "jwt":
|
|
133
|
+
return token
|
|
134
|
+
expiry = _jwt_expiry(token)
|
|
135
|
+
now = datetime.now(timezone.utc)
|
|
136
|
+
if expiry and expiry - now > REFRESH_WITHIN:
|
|
137
|
+
return token
|
|
138
|
+
try:
|
|
139
|
+
return _refresh(state)
|
|
140
|
+
except (BadRequest, RequestException, PlexToolError) as e:
|
|
141
|
+
if expiry and expiry > now:
|
|
142
|
+
print(f"warning: could not renew the Plex JWT ({e}); using the current one until "
|
|
143
|
+
f"{expiry:%Y-%m-%d %H:%M} UTC.", file=sys.stderr)
|
|
144
|
+
return token
|
|
145
|
+
raise PlexToolError(f"The saved Plex JWT has expired and could not be renewed ({e}). "
|
|
146
|
+
"Run 'plextool auth login' again.") from None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def use_saved_identity() -> None:
|
|
150
|
+
"""Make plexapi's requests carry the saved device ID.
|
|
151
|
+
|
|
152
|
+
A JWT is issued to one device and names it in the token, so a server may expect requests
|
|
153
|
+
that use the JWT to come from that same X-Plex-Client-Identifier.
|
|
154
|
+
"""
|
|
155
|
+
import plexapi
|
|
156
|
+
state = load_state()
|
|
157
|
+
if state.get("kind") == "jwt" and state.get("client_id"):
|
|
158
|
+
plexapi.BASE_HEADERS["X-Plex-Client-Identifier"] = state["client_id"]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _refresh(state: dict) -> str:
|
|
162
|
+
login_class = _require_jwt_support()
|
|
163
|
+
directory = config_dir()
|
|
164
|
+
try:
|
|
165
|
+
keys = tuple((directory / name).read_bytes() for name in KEY_FILES)
|
|
166
|
+
except OSError as e:
|
|
167
|
+
raise PlexToolError(f"The signing keys are missing ({e}). Run 'plextool auth login' again.") from None
|
|
168
|
+
login = login_class(headers=_headers(state["client_id"]), jwtToken=state["token"], keypair=keys)
|
|
169
|
+
state["token"] = login.refreshJWT()
|
|
170
|
+
save_state(state)
|
|
171
|
+
return state["token"]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Signing in
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def _short_link(url: str) -> str:
|
|
179
|
+
"""Trim plexapi's sign-in link to what Plex needs, so its QR code stays small enough to scan."""
|
|
180
|
+
query = parse_qs(urlsplit(url).fragment.partition("?")[2])
|
|
181
|
+
keep = {k: query[k][0] for k in ("clientID", "code", "context[device][product]") if k in query}
|
|
182
|
+
return f"https://app.plex.tv/auth/#!?{urlencode(keep)}"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
NEXT_STEP = "Next, choose your server with 'plextool server find' (the default is http://localhost:32400)."
|
|
186
|
+
|
|
187
|
+
KIND_BLURB = {
|
|
188
|
+
"jwt": "a JWT: short-lived and renews itself using a signing key kept on this computer. "
|
|
189
|
+
"Plex accepts it, but Plex Media Servers may not yet",
|
|
190
|
+
"token": "a classic token: it never expires until you revoke it in your Plex account",
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def show_link(url: str, kind: str) -> None:
|
|
195
|
+
# The JWT sign-in is known to work with the trimmed link. The classic token sign-in is known to
|
|
196
|
+
# work with the full link, so it gets that (and a bigger QR code).
|
|
197
|
+
link = _short_link(url) if kind == "jwt" else url
|
|
198
|
+
print(paint(f"Signing in for {KIND_BLURB[kind]}.", "bold") + "\n")
|
|
199
|
+
print("Open this link in a browser and sign in to Plex:\n")
|
|
200
|
+
print(f" {link}\n")
|
|
201
|
+
try:
|
|
202
|
+
import segno
|
|
203
|
+
except ImportError:
|
|
204
|
+
print("(Install 'segno' to see a QR code here as well: pip install segno)\n")
|
|
205
|
+
else:
|
|
206
|
+
if sys.stdout.isatty():
|
|
207
|
+
segno.make(link, error="l").terminal(compact=True)
|
|
208
|
+
print("Or scan the code above with your phone.\n")
|
|
209
|
+
print("Waiting for you to approve it...")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _new_client_id() -> str:
|
|
213
|
+
"""A device ID for one sign-in. Every login gets its own: Plex's sign-in page refuses a device ID
|
|
214
|
+
that already has a different key or token type registered, so IDs are never reused."""
|
|
215
|
+
return str(uuid.uuid4())
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def login_jwt(timeout: int) -> None:
|
|
219
|
+
login_class = _require_jwt_support()
|
|
220
|
+
client_id = _new_client_id()
|
|
221
|
+
private, public = _new_keypair()
|
|
222
|
+
login = login_class(headers=_headers(client_id), oauth=True, keypair=(private, public))
|
|
223
|
+
try:
|
|
224
|
+
login.run(timeout=timeout)
|
|
225
|
+
show_link(login.oauthUrl(), "jwt")
|
|
226
|
+
approved = login.waitForLogin()
|
|
227
|
+
except RequestException as e:
|
|
228
|
+
raise PlexToolError(f"Could not reach plex.tv: {e}") from None
|
|
229
|
+
finally:
|
|
230
|
+
login.stop()
|
|
231
|
+
if not approved:
|
|
232
|
+
raise PlexToolError("Sign-in was not approved in time. Nothing was saved.")
|
|
233
|
+
directory = config_dir()
|
|
234
|
+
write_private(directory / PRIVATE_KEY_FILE, private)
|
|
235
|
+
write_private(directory / PUBLIC_KEY_FILE, public)
|
|
236
|
+
save_state({"client_id": client_id, "kind": "jwt", "token": login.jwtToken})
|
|
237
|
+
expiry = _jwt_expiry(login.jwtToken)
|
|
238
|
+
print(paint("\nSigned in with a JWT.", "green") + f" Saved to {directory}")
|
|
239
|
+
if expiry:
|
|
240
|
+
print(f"This token runs out on {expiry:%Y-%m-%d %H:%M} UTC and renews itself when used.")
|
|
241
|
+
print(NEXT_STEP)
|
|
242
|
+
use_saved_identity()
|
|
243
|
+
from . import server # imported here because server itself uses this module
|
|
244
|
+
outcome = server.check_saved(login.jwtToken)
|
|
245
|
+
if outcome == "ok":
|
|
246
|
+
print(paint("Your saved server accepted this JWT.", "green"))
|
|
247
|
+
elif outcome:
|
|
248
|
+
print(paint(outcome, "yellow"))
|
|
249
|
+
else:
|
|
250
|
+
print("Once you have chosen a server, 'plextool server test' shows whether it accepts a JWT.")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def login_token(timeout: int, print_only: bool) -> None:
|
|
254
|
+
from plexapi.myplex import MyPlexPinLogin
|
|
255
|
+
client_id = _new_client_id()
|
|
256
|
+
login = MyPlexPinLogin(headers=_headers(client_id), oauth=True)
|
|
257
|
+
try:
|
|
258
|
+
login.run(timeout=timeout)
|
|
259
|
+
show_link(login.oauthUrl(), "token")
|
|
260
|
+
approved = login.waitForLogin()
|
|
261
|
+
except RequestException as e:
|
|
262
|
+
raise PlexToolError(f"Could not reach plex.tv: {e}") from None
|
|
263
|
+
finally:
|
|
264
|
+
login.stop()
|
|
265
|
+
if not approved:
|
|
266
|
+
raise PlexToolError("Sign-in was not approved in time. Nothing was saved.")
|
|
267
|
+
if print_only:
|
|
268
|
+
print(login.token)
|
|
269
|
+
return
|
|
270
|
+
directory = config_dir()
|
|
271
|
+
save_state({"client_id": client_id, "kind": "token", "token": login.token})
|
|
272
|
+
for name in KEY_FILES: # a JWT login's keys are no use any more
|
|
273
|
+
(directory / name).unlink(missing_ok=True)
|
|
274
|
+
print(paint("\nSigned in with a classic token.", "green") + f" Saved to {directory}")
|
|
275
|
+
print(paint("This token does not expire.", "yellow") +
|
|
276
|
+
" Revoke it in your Plex account under Authorized Devices ('plextool').")
|
|
277
|
+
print(NEXT_STEP)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
# Devices on the Plex account
|
|
282
|
+
# ---------------------------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
PRODUCT = "plextool"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def account_devices(token: str, everything: bool = False) -> list:
|
|
288
|
+
"""The devices on the Plex account: just plextool's own, unless everything is True."""
|
|
289
|
+
try:
|
|
290
|
+
devices = MyPlexAccount(token=token).devices()
|
|
291
|
+
except Unauthorized:
|
|
292
|
+
raise PlexToolError(f"plex.tv rejected the token. {SIGN_IN_AGAIN}") from None
|
|
293
|
+
except (RequestException, BadRequest) as e:
|
|
294
|
+
raise PlexToolError(f"Could not reach plex.tv: {e}") from None
|
|
295
|
+
if not everything:
|
|
296
|
+
devices = [d for d in devices if (d.product or "").lower() == PRODUCT]
|
|
297
|
+
return sorted(devices, key=lambda d: d.lastSeenAt or datetime.min, reverse=True)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _when(value) -> str:
|
|
301
|
+
return f"{value:%Y-%m-%d %H:%M}" if value else "never"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _short(device) -> str:
|
|
305
|
+
return (device.clientIdentifier or "")[:8]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def describe_device(device, current_id: str | None) -> str:
|
|
309
|
+
mark = paint(" (this login)", "green") if current_id and device.clientIdentifier == current_id else ""
|
|
310
|
+
return (f" {_short(device):<10}{device.name or '?':<40}created {_when(device.createdAt)} "
|
|
311
|
+
f"last seen {_when(device.lastSeenAt)}{mark}")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def list_devices(args: argparse.Namespace) -> None:
|
|
315
|
+
token, _ = resolve_token(args)
|
|
316
|
+
devices = account_devices(token, args.all)
|
|
317
|
+
current = load_state().get("client_id")
|
|
318
|
+
print(paint("Devices on your Plex account" + ("" if args.all else " signed in through plextool"), "cyan"))
|
|
319
|
+
for device in devices:
|
|
320
|
+
extra = f" [{device.product}]" if args.all else ""
|
|
321
|
+
print(describe_device(device, current) + extra)
|
|
322
|
+
if not devices:
|
|
323
|
+
print(" none")
|
|
324
|
+
elif not args.all:
|
|
325
|
+
print("\nRemove old ones with 'plextool auth revoke --others --commit'.")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def revoke_devices(args: argparse.Namespace) -> None:
|
|
329
|
+
if not (args.ids or args.others or args.this):
|
|
330
|
+
raise PlexToolError("Say what to remove: --others, --this, or one or more device IDs "
|
|
331
|
+
"from 'plextool auth devices'.")
|
|
332
|
+
token, _ = resolve_token(args)
|
|
333
|
+
devices = account_devices(token)
|
|
334
|
+
current = load_state().get("client_id")
|
|
335
|
+
chosen = {}
|
|
336
|
+
if args.others:
|
|
337
|
+
chosen.update({d.clientIdentifier: d for d in devices if d.clientIdentifier != current})
|
|
338
|
+
if args.this:
|
|
339
|
+
if not current:
|
|
340
|
+
raise PlexToolError("No saved login, so there is no device of this computer to remove.")
|
|
341
|
+
chosen.update({d.clientIdentifier: d for d in devices if d.clientIdentifier == current})
|
|
342
|
+
for prefix in args.ids:
|
|
343
|
+
matches = [d for d in devices if (d.clientIdentifier or "").startswith(prefix)]
|
|
344
|
+
if not matches:
|
|
345
|
+
raise PlexToolError(f"No plextool device starts with '{prefix}'. "
|
|
346
|
+
"'plextool auth devices' lists them.")
|
|
347
|
+
if len(matches) > 1:
|
|
348
|
+
raise PlexToolError(f"'{prefix}' matches {len(matches)} devices; give more of the ID.")
|
|
349
|
+
chosen[matches[0].clientIdentifier] = matches[0]
|
|
350
|
+
|
|
351
|
+
if not chosen:
|
|
352
|
+
print("Nothing to remove.")
|
|
353
|
+
return
|
|
354
|
+
removing_current = current in chosen
|
|
355
|
+
verb = "Removing" if args.commit else "Would remove"
|
|
356
|
+
print(f"{verb} {plural(len(chosen), 'device')}:")
|
|
357
|
+
for device in chosen.values():
|
|
358
|
+
print(describe_device(device, current))
|
|
359
|
+
if removing_current:
|
|
360
|
+
print(paint("\nThat includes this login, which plex.tv will stop accepting. Sign in again with "
|
|
361
|
+
"'plextool auth login'.", "yellow"))
|
|
362
|
+
if not args.commit:
|
|
363
|
+
print("\nDry run only. Re-run with --commit to remove.")
|
|
364
|
+
return
|
|
365
|
+
failed = 0
|
|
366
|
+
for device in chosen.values():
|
|
367
|
+
try:
|
|
368
|
+
device.delete()
|
|
369
|
+
except (BadRequest, NotFound, RequestException) as e:
|
|
370
|
+
failed += 1
|
|
371
|
+
print(paint(f" could not remove {_short(device)}: {e}", "red"))
|
|
372
|
+
print(paint(f"Removed {len(chosen) - failed}.", "green") if not failed else f"{failed} failed.")
|
|
373
|
+
if len(chosen) > failed:
|
|
374
|
+
print("plex.tv rejects a removed device's token straight away, but a Plex server can keep "
|
|
375
|
+
"accepting it for a while.")
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def wipe(assume_yes: bool) -> None:
|
|
379
|
+
directory = config_dir()
|
|
380
|
+
present = [directory / name for name in ALL_FILES if (directory / name).exists()]
|
|
381
|
+
if not present:
|
|
382
|
+
print(f"Nothing to remove in {directory}.")
|
|
383
|
+
return
|
|
384
|
+
print("This removes:")
|
|
385
|
+
for path in present:
|
|
386
|
+
print(f" {path}")
|
|
387
|
+
confirm_typed("WIPE", "The saved login, signing keys and chosen server are deleted from this computer.",
|
|
388
|
+
assume_yes)
|
|
389
|
+
for path in present:
|
|
390
|
+
path.unlink()
|
|
391
|
+
try:
|
|
392
|
+
directory.rmdir()
|
|
393
|
+
except OSError:
|
|
394
|
+
pass # something else lives there
|
|
395
|
+
print(paint("Removed.", "green") +
|
|
396
|
+
" If you haven't revoked this sign-in, Plex still lists it under Authorized Devices: use "
|
|
397
|
+
"'plextool auth revoke --this --commit' before wiping next time, or remove it in your Plex account.")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def status() -> None:
|
|
401
|
+
directory = config_dir()
|
|
402
|
+
state = load_state()
|
|
403
|
+
print(paint("Config folder", "cyan") + f" {directory}")
|
|
404
|
+
for name in ALL_FILES:
|
|
405
|
+
path = directory / name
|
|
406
|
+
if path.exists():
|
|
407
|
+
mode = path.stat().st_mode & 0o777
|
|
408
|
+
note = "" if mode == 0o600 else paint(" (should be 0600)", "yellow")
|
|
409
|
+
print(f" {name:<16} {mode:04o}{note}")
|
|
410
|
+
else:
|
|
411
|
+
print(f" {name:<16} not present")
|
|
412
|
+
if not state.get("token"):
|
|
413
|
+
print("\nNot signed in. Run 'plextool auth login'.")
|
|
414
|
+
return
|
|
415
|
+
print(paint("\nSaved login", "cyan"))
|
|
416
|
+
if state.get("kind") == "jwt":
|
|
417
|
+
expiry = _jwt_expiry(state["token"])
|
|
418
|
+
print(" type jwt (short-lived; renews itself using the signing keys)")
|
|
419
|
+
if expiry:
|
|
420
|
+
print(f" expires {expiry:%Y-%m-%d %H:%M} UTC")
|
|
421
|
+
else:
|
|
422
|
+
print(" type token (classic; never expires until revoked in your Plex account)")
|
|
423
|
+
print(f" device {state.get('client_id')}")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def add_parser(subparsers) -> None:
|
|
427
|
+
parser = add_command(
|
|
428
|
+
subparsers, "auth",
|
|
429
|
+
"sign in to Plex and manage the saved login",
|
|
430
|
+
"Sign in through your browser (or by scanning a QR code with your phone) so you never have "
|
|
431
|
+
"to find or paste a token. 'login' saves the token in the config folder (~/.config/plextool, "
|
|
432
|
+
"or $PLEXTOOL_CONFIG_DIR). With --type jwt it saves a short-lived JWT plus the signing keys "
|
|
433
|
+
"that renew it, which never leave your computer. 'wipe' deletes everything saved. Other commands use the saved login unless "
|
|
434
|
+
"you give --token, --token-file or set $PLEX_TOKEN.",
|
|
435
|
+
["plextool auth login",
|
|
436
|
+
"plextool auth login --type token --print",
|
|
437
|
+
"plextool auth status",
|
|
438
|
+
"plextool auth devices",
|
|
439
|
+
"plextool auth revoke --others --commit",
|
|
440
|
+
"plextool auth wipe"])
|
|
441
|
+
sub = parser.add_subparsers(dest="action", metavar="action", required=True)
|
|
442
|
+
|
|
443
|
+
login = add_command(
|
|
444
|
+
sub, "login", "sign in and save the login",
|
|
445
|
+
"Prints a link (and a QR code, if 'segno' is installed) to approve in a browser. Both types "
|
|
446
|
+
"use the same browser sign-in; they differ in what you get back. --type token (the default) "
|
|
447
|
+
"gives the classic Plex token, which never expires until you revoke it, and which Plex Media "
|
|
448
|
+
"Servers accept. --type jwt gives a short-lived JWT that renews itself with a signing key kept "
|
|
449
|
+
"in the config folder. Plex accepts it, but Plex Media Servers may not yet: after a JWT login "
|
|
450
|
+
"your saved server is checked and you are told.",
|
|
451
|
+
["plextool auth login",
|
|
452
|
+
"plextool auth login --type jwt",
|
|
453
|
+
"plextool auth login --type token --print > plex-token.txt"])
|
|
454
|
+
login.add_argument("--type", dest="kind", choices=("jwt", "token"), default="token",
|
|
455
|
+
help="token: classic Plex token that never expires (default). "
|
|
456
|
+
"jwt: short-lived, renews itself, but servers may not accept it")
|
|
457
|
+
login.add_argument("--print", dest="print_only", action="store_true",
|
|
458
|
+
help="with --type token, print the token and save nothing")
|
|
459
|
+
login.add_argument("--timeout", type=int, default=LOGIN_TIMEOUT, metavar="SECONDS",
|
|
460
|
+
help=f"how long to wait for approval (default {LOGIN_TIMEOUT})")
|
|
461
|
+
|
|
462
|
+
add_command(sub, "status", "show what is saved and when it expires",
|
|
463
|
+
"Show where the login and keys are kept, their permissions and when the token runs "
|
|
464
|
+
"out. The token itself is never shown.", ["plextool auth status"])
|
|
465
|
+
|
|
466
|
+
devices = add_command(
|
|
467
|
+
sub, "devices", "list the plextool sign-ins on your Plex account",
|
|
468
|
+
"Every login adds a device to your Plex account (Authorized Devices). This lists the ones "
|
|
469
|
+
"plextool made, newest first, and marks the one this computer's saved login uses.",
|
|
470
|
+
["plextool auth devices", "plextool auth devices --all"])
|
|
471
|
+
add_token_args(devices)
|
|
472
|
+
devices.add_argument("--all", action="store_true",
|
|
473
|
+
help="list every device on the account, not just plextool's (read-only)")
|
|
474
|
+
|
|
475
|
+
revoke = add_command(
|
|
476
|
+
sub, "revoke", "remove plextool sign-ins from your Plex account",
|
|
477
|
+
"Remove plextool devices from your Plex account so plex.tv rejects their tokens. Only "
|
|
478
|
+
"plextool's own entries can be removed; other Plex apps and devices are never touched. "
|
|
479
|
+
"Nothing is removed unless you pass --commit.",
|
|
480
|
+
["plextool auth revoke --others",
|
|
481
|
+
"plextool auth revoke --others --commit",
|
|
482
|
+
"plextool auth revoke 3895d339 --commit",
|
|
483
|
+
"plextool auth revoke --this --commit"])
|
|
484
|
+
add_token_args(revoke)
|
|
485
|
+
revoke.add_argument("ids", nargs="*", metavar="ID",
|
|
486
|
+
help="start of a device ID shown by 'plextool auth devices'")
|
|
487
|
+
revoke.add_argument("--others", action="store_true",
|
|
488
|
+
help="every plextool device except the one this login uses")
|
|
489
|
+
revoke.add_argument("--this", action="store_true",
|
|
490
|
+
help="the device this login uses (do this before 'auth wipe')")
|
|
491
|
+
revoke.add_argument("--commit", action="store_true", help="actually remove them")
|
|
492
|
+
|
|
493
|
+
wipe_parser = add_command(
|
|
494
|
+
sub, "wipe", "delete the saved login and signing keys",
|
|
495
|
+
"Delete the saved login and the signing keys from the config folder. To also revoke access "
|
|
496
|
+
"on Plex's side, remove plextool under Authorized Devices in your Plex account.",
|
|
497
|
+
["plextool auth wipe", "plextool auth wipe --assume-yes"])
|
|
498
|
+
wipe_parser.add_argument("-y", "--assume-yes", action="store_true", help="don't ask for confirmation")
|
|
499
|
+
parser.set_defaults(run=run)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def run(args: argparse.Namespace) -> int:
|
|
503
|
+
if args.action == "login":
|
|
504
|
+
if args.print_only and args.kind != "token":
|
|
505
|
+
raise PlexToolError("--print only works with --type token; a JWT is renewed from the saved keys.")
|
|
506
|
+
if args.kind == "token":
|
|
507
|
+
login_token(args.timeout, args.print_only)
|
|
508
|
+
else:
|
|
509
|
+
login_jwt(args.timeout)
|
|
510
|
+
elif args.action == "wipe":
|
|
511
|
+
wipe(args.assume_yes)
|
|
512
|
+
elif args.action == "devices":
|
|
513
|
+
list_devices(args)
|
|
514
|
+
elif args.action == "revoke":
|
|
515
|
+
revoke_devices(args)
|
|
516
|
+
else:
|
|
517
|
+
status()
|
|
518
|
+
return 0
|