plextool 3.1.0__tar.gz → 3.2.0__tar.gz
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-3.1.0 → plextool-3.2.0}/PKG-INFO +26 -15
- {plextool-3.1.0 → plextool-3.2.0}/README.md +24 -13
- {plextool-3.1.0 → plextool-3.2.0}/plextool/__init__.py +1 -1
- {plextool-3.1.0 → plextool-3.2.0}/plextool/auth.py +81 -24
- {plextool-3.1.0 → plextool-3.2.0}/plextool/cli.py +5 -1
- {plextool-3.1.0 → plextool-3.2.0}/plextool/common.py +42 -10
- plextool-3.2.0/plextool/devices.py +179 -0
- plextool-3.2.0/plextool/info.py +546 -0
- plextool-3.2.0/plextool/tokens.py +451 -0
- plextool-3.2.0/plextool/users.py +578 -0
- {plextool-3.1.0 → plextool-3.2.0}/plextool.egg-info/PKG-INFO +26 -15
- {plextool-3.1.0 → plextool-3.2.0}/plextool.egg-info/SOURCES.txt +4 -0
- {plextool-3.1.0 → plextool-3.2.0}/pyproject.toml +2 -2
- {plextool-3.1.0 → plextool-3.2.0}/tests/test_auth.py +3 -1
- {plextool-3.1.0 → plextool-3.2.0}/tests/test_server.py +2 -2
- plextool-3.2.0/tests/test_tools.py +1303 -0
- plextool-3.1.0/tests/test_tools.py +0 -391
- {plextool-3.1.0 → plextool-3.2.0}/LICENSE +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/plextool/__main__.py +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/plextool/audit.py +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/plextool/dupes.py +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/plextool/ondemand.py +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/plextool/playlists.py +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/plextool/recent.py +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/plextool/resume.py +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/plextool/scan.py +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/plextool/server.py +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/plextool/stale.py +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/plextool/status.py +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/plextool/subs.py +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/plextool/watched.py +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/plextool.egg-info/dependency_links.txt +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/plextool.egg-info/entry_points.txt +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/plextool.egg-info/requires.txt +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/plextool.egg-info/top_level.txt +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/setup.cfg +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/tests/test_ondemand.py +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/tests/test_plextool.py +0 -0
- {plextool-3.1.0 → plextool-3.2.0}/tests/test_subs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: plextool
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.2.0
|
|
4
4
|
Summary: Maintenance helpers for a Plex Media Server
|
|
5
5
|
Author: Milkmansson
|
|
6
6
|
License-Expression: MIT
|
|
@@ -19,7 +19,7 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
19
19
|
Classifier: Programming Language :: Python :: 3.12
|
|
20
20
|
Classifier: Topic :: Multimedia :: Video
|
|
21
21
|
Classifier: Topic :: System :: Systems Administration
|
|
22
|
-
Requires-Python: >=3.
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
23
|
Description-Content-Type: text/markdown
|
|
24
24
|
License-File: LICENSE
|
|
25
25
|
Requires-Dist: plexapi>=4.18
|
|
@@ -37,16 +37,12 @@ Dynamic: license-file
|
|
|
37
37
|
> [!WARNING]
|
|
38
38
|
> Still in development/testing... please raise an issue if one is found!
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
server through its API. No access is made to the Plex database file, which is
|
|
43
|
-
too risky to edit by hand.
|
|
40
|
+
Swiss army knife command line tool for Plex library maintenance. Built on [python-plexapi](https://python-plexapi.readthedocs.io/), which talks to the
|
|
41
|
+
server through its API. No accesses is made to the Plex database file which is too risky to edit by hand.
|
|
44
42
|
|
|
45
43
|
## What it is useful for
|
|
46
44
|
|
|
47
|
-
plextool is a
|
|
48
|
-
things Plex's web interface can't, and finding problems across a large library.
|
|
49
|
-
Typical jobs:
|
|
45
|
+
plextool is a CLI toolkit for looking after a Plex Media Server: fixing things Plex's web interface can't, giving CLI methods to things that would otherwise need many clicks through the web interface for, for server maintenance over SSH, and for finding and fixing problems in large libraries. Typical jobs:
|
|
50
46
|
|
|
51
47
|
- **Fix the Recently Added order.** This is what it was first built for. Plex has no way to
|
|
52
48
|
edit the Recently Added lists, so after the database is rebuilt, duplicate
|
|
@@ -59,10 +55,20 @@ Typical jobs:
|
|
|
59
55
|
$ plextool recent --recent 20 --release # preview: put the 20 newest back to their release dates
|
|
60
56
|
$ plextool recent --title "Some Show" --shift=-30d --commit
|
|
61
57
|
```
|
|
58
|
+
- **Manage who has access.** [`plextool users`](https://plextool.readthedocs.io/en/latest/commands/users.html) lists who can use the server, invites people, changes
|
|
59
|
+
what they can see and removes them, with a preview of exactly what would be granted or lost.
|
|
60
|
+
- **Tidy the apps authorised on the account.** [`plextool devices`](https://plextool.readthedocs.io/en/latest/commands/devices.html) lists every app and device signed in
|
|
61
|
+
to the Plex account and removes the old ones by ID, age, app or name. It never removes a Plex Media Server.
|
|
62
|
+
- **Give another tool its own token.** [`plextool token`](https://plextool.readthedocs.io/en/latest/commands/token.html) makes, lists and revokes tokens for a program such as a Plex MCP
|
|
63
|
+
server, so it does not need the saved login. Whether it is full access (`--rw`) or a shared user's (`--ro`)
|
|
64
|
+
has to be stated, and the output says what the token can do.
|
|
62
65
|
- **Find and remove duplicate movies and episodes.** [`plextool dupes`](https://plextool.readthedocs.io/en/latest/commands/dupes.html)
|
|
63
66
|
lists every item Plex holds more than one file for, and can delete the extras that are not wanted.
|
|
64
67
|
- **Free up disk space.** [`plextool stale`](https://plextool.readthedocs.io/en/latest/commands/stale.html) finds large, never-watched or
|
|
65
68
|
long-idle movies and shows.
|
|
69
|
+
- **Look up an item, or compare several.** [`plextool info`](https://plextool.readthedocs.io/en/latest/commands/info.html) shows what Plex holds about a movie, show
|
|
70
|
+
or episode: the people, the tags and IDs, and the technical side (paths, video, audio and subtitle
|
|
71
|
+
tracks). Giving more than one item shows them side by side with the differences marked.
|
|
66
72
|
- **Audit a library.** [`plextool audit`](https://plextool.readthedocs.io/en/latest/commands/audit.html) reports unmatched items, missing files,
|
|
67
73
|
missing posters or summaries, low-resolution video, and missing subtitle or audio languages.
|
|
68
74
|
- **Copy, back up and restore watched status.** [`plextool watched`](https://plextool.readthedocs.io/en/latest/commands/watched.html) moves watched
|
|
@@ -79,18 +85,23 @@ Typical jobs:
|
|
|
79
85
|
- **Script Plex.** Reports can print JSON, and exports can go through a pipe, so the output
|
|
80
86
|
feeds other tools.
|
|
81
87
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
> [!IMPORTANT]
|
|
89
|
+
> Everything that changes the server is a preview until `--commit` is passed. plextool uses Plex's API and
|
|
90
|
+
> never edits the Plex database file. It signs in through a browser with
|
|
91
|
+
> [`plextool auth`](https://plextool.readthedocs.io/en/latest/commands/auth.html), and works against a server on the same machine, across the
|
|
92
|
+
> internet, or directly with no internet access ([`plextool server`](https://plextool.readthedocs.io/en/latest/commands/server.html)).
|
|
86
93
|
|
|
87
94
|
## Commands
|
|
88
95
|
|
|
89
96
|
| Command | What it does |
|
|
90
97
|
| --- | --- |
|
|
91
98
|
| [`plextool auth`](https://plextool.readthedocs.io/en/latest/commands/auth.html) | Sign in to Plex with a link or QR code, list and remove old sign-ins from the account, and wipe the saved login and keys. |
|
|
99
|
+
| [`plextool users`](https://plextool.readthedocs.io/en/latest/commands/users.html) | See who has access to the server, and invite, change and remove people: libraries, restrictions, downloads, invitations. Previews until `--commit`. |
|
|
100
|
+
| [`plextool devices`](https://plextool.readthedocs.io/en/latest/commands/devices.html) | List the apps and devices authorised on the Plex account, and sign out the ones no longer wanted, by ID or by age, app or name. Plex Media Servers are never removed. |
|
|
101
|
+
| [`plextool token`](https://plextool.readthedocs.io/en/latest/commands/token.html) | Make, list and revoke tokens for other tools (such as an MCP server) without sharing the saved login: a separate full-access token that can be revoked on its own, or a shared user's token. Also shows who has access to the server. |
|
|
92
102
|
| [`plextool server`](https://plextool.readthedocs.io/en/latest/commands/server.html) | Find servers on the account or network and choose the one to use. Covers working offline or directly against the server. |
|
|
93
103
|
| [`plextool status`](https://plextool.readthedocs.io/en/latest/commands/status.html) | Server version, library sizes, who is watching what. |
|
|
104
|
+
| [`plextool info`](https://plextool.readthedocs.io/en/latest/commands/info.html) | Everything Plex holds about one item: people, tags, playback, and every file, path, video, audio and subtitle track. Several items can be compared side by side. |
|
|
94
105
|
| [`plextool scan`](https://plextool.readthedocs.io/en/latest/commands/scan.html) | Scan libraries or a single folder, refresh metadata, empty trash, optimize. |
|
|
95
106
|
| [`plextool recent`](https://plextool.readthedocs.io/en/latest/commands/recent.html) | Change "added at" dates to reorder Recently Added. Works on movies, shows, seasons and episodes. |
|
|
96
107
|
| [`plextool dupes`](https://plextool.readthedocs.io/en/latest/commands/dupes.html) | List movies and episodes that have more than one file, and optionally delete the extras. |
|
|
@@ -185,8 +196,8 @@ A fuller page for each command is on [Read the Docs](https://plextool.readthedoc
|
|
|
185
196
|
|
|
186
197
|
## Install
|
|
187
198
|
|
|
188
|
-
plextool
|
|
189
|
-
|
|
199
|
+
plextool needs Python 3.10 or later, because plexapi 4.18 does. It is tested on
|
|
200
|
+
Python 3.12. If something doesn't work on another version, please
|
|
190
201
|
[open an issue](https://github.com/milkmansson/plextool/issues) with the Python
|
|
191
202
|
version.
|
|
192
203
|
|
|
@@ -3,16 +3,12 @@
|
|
|
3
3
|
> [!WARNING]
|
|
4
4
|
> Still in development/testing... please raise an issue if one is found!
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
server through its API. No access is made to the Plex database file, which is
|
|
9
|
-
too risky to edit by hand.
|
|
6
|
+
Swiss army knife command line tool for Plex library maintenance. Built on [python-plexapi](https://python-plexapi.readthedocs.io/), which talks to the
|
|
7
|
+
server through its API. No accesses is made to the Plex database file which is too risky to edit by hand.
|
|
10
8
|
|
|
11
9
|
## What it is useful for
|
|
12
10
|
|
|
13
|
-
plextool is a
|
|
14
|
-
things Plex's web interface can't, and finding problems across a large library.
|
|
15
|
-
Typical jobs:
|
|
11
|
+
plextool is a CLI toolkit for looking after a Plex Media Server: fixing things Plex's web interface can't, giving CLI methods to things that would otherwise need many clicks through the web interface for, for server maintenance over SSH, and for finding and fixing problems in large libraries. Typical jobs:
|
|
16
12
|
|
|
17
13
|
- **Fix the Recently Added order.** This is what it was first built for. Plex has no way to
|
|
18
14
|
edit the Recently Added lists, so after the database is rebuilt, duplicate
|
|
@@ -25,10 +21,20 @@ Typical jobs:
|
|
|
25
21
|
$ plextool recent --recent 20 --release # preview: put the 20 newest back to their release dates
|
|
26
22
|
$ plextool recent --title "Some Show" --shift=-30d --commit
|
|
27
23
|
```
|
|
24
|
+
- **Manage who has access.** [`plextool users`](https://plextool.readthedocs.io/en/latest/commands/users.html) lists who can use the server, invites people, changes
|
|
25
|
+
what they can see and removes them, with a preview of exactly what would be granted or lost.
|
|
26
|
+
- **Tidy the apps authorised on the account.** [`plextool devices`](https://plextool.readthedocs.io/en/latest/commands/devices.html) lists every app and device signed in
|
|
27
|
+
to the Plex account and removes the old ones by ID, age, app or name. It never removes a Plex Media Server.
|
|
28
|
+
- **Give another tool its own token.** [`plextool token`](https://plextool.readthedocs.io/en/latest/commands/token.html) makes, lists and revokes tokens for a program such as a Plex MCP
|
|
29
|
+
server, so it does not need the saved login. Whether it is full access (`--rw`) or a shared user's (`--ro`)
|
|
30
|
+
has to be stated, and the output says what the token can do.
|
|
28
31
|
- **Find and remove duplicate movies and episodes.** [`plextool dupes`](https://plextool.readthedocs.io/en/latest/commands/dupes.html)
|
|
29
32
|
lists every item Plex holds more than one file for, and can delete the extras that are not wanted.
|
|
30
33
|
- **Free up disk space.** [`plextool stale`](https://plextool.readthedocs.io/en/latest/commands/stale.html) finds large, never-watched or
|
|
31
34
|
long-idle movies and shows.
|
|
35
|
+
- **Look up an item, or compare several.** [`plextool info`](https://plextool.readthedocs.io/en/latest/commands/info.html) shows what Plex holds about a movie, show
|
|
36
|
+
or episode: the people, the tags and IDs, and the technical side (paths, video, audio and subtitle
|
|
37
|
+
tracks). Giving more than one item shows them side by side with the differences marked.
|
|
32
38
|
- **Audit a library.** [`plextool audit`](https://plextool.readthedocs.io/en/latest/commands/audit.html) reports unmatched items, missing files,
|
|
33
39
|
missing posters or summaries, low-resolution video, and missing subtitle or audio languages.
|
|
34
40
|
- **Copy, back up and restore watched status.** [`plextool watched`](https://plextool.readthedocs.io/en/latest/commands/watched.html) moves watched
|
|
@@ -45,18 +51,23 @@ Typical jobs:
|
|
|
45
51
|
- **Script Plex.** Reports can print JSON, and exports can go through a pipe, so the output
|
|
46
52
|
feeds other tools.
|
|
47
53
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
> [!IMPORTANT]
|
|
55
|
+
> Everything that changes the server is a preview until `--commit` is passed. plextool uses Plex's API and
|
|
56
|
+
> never edits the Plex database file. It signs in through a browser with
|
|
57
|
+
> [`plextool auth`](https://plextool.readthedocs.io/en/latest/commands/auth.html), and works against a server on the same machine, across the
|
|
58
|
+
> internet, or directly with no internet access ([`plextool server`](https://plextool.readthedocs.io/en/latest/commands/server.html)).
|
|
52
59
|
|
|
53
60
|
## Commands
|
|
54
61
|
|
|
55
62
|
| Command | What it does |
|
|
56
63
|
| --- | --- |
|
|
57
64
|
| [`plextool auth`](https://plextool.readthedocs.io/en/latest/commands/auth.html) | Sign in to Plex with a link or QR code, list and remove old sign-ins from the account, and wipe the saved login and keys. |
|
|
65
|
+
| [`plextool users`](https://plextool.readthedocs.io/en/latest/commands/users.html) | See who has access to the server, and invite, change and remove people: libraries, restrictions, downloads, invitations. Previews until `--commit`. |
|
|
66
|
+
| [`plextool devices`](https://plextool.readthedocs.io/en/latest/commands/devices.html) | List the apps and devices authorised on the Plex account, and sign out the ones no longer wanted, by ID or by age, app or name. Plex Media Servers are never removed. |
|
|
67
|
+
| [`plextool token`](https://plextool.readthedocs.io/en/latest/commands/token.html) | Make, list and revoke tokens for other tools (such as an MCP server) without sharing the saved login: a separate full-access token that can be revoked on its own, or a shared user's token. Also shows who has access to the server. |
|
|
58
68
|
| [`plextool server`](https://plextool.readthedocs.io/en/latest/commands/server.html) | Find servers on the account or network and choose the one to use. Covers working offline or directly against the server. |
|
|
59
69
|
| [`plextool status`](https://plextool.readthedocs.io/en/latest/commands/status.html) | Server version, library sizes, who is watching what. |
|
|
70
|
+
| [`plextool info`](https://plextool.readthedocs.io/en/latest/commands/info.html) | Everything Plex holds about one item: people, tags, playback, and every file, path, video, audio and subtitle track. Several items can be compared side by side. |
|
|
60
71
|
| [`plextool scan`](https://plextool.readthedocs.io/en/latest/commands/scan.html) | Scan libraries or a single folder, refresh metadata, empty trash, optimize. |
|
|
61
72
|
| [`plextool recent`](https://plextool.readthedocs.io/en/latest/commands/recent.html) | Change "added at" dates to reorder Recently Added. Works on movies, shows, seasons and episodes. |
|
|
62
73
|
| [`plextool dupes`](https://plextool.readthedocs.io/en/latest/commands/dupes.html) | List movies and episodes that have more than one file, and optionally delete the extras. |
|
|
@@ -151,8 +162,8 @@ A fuller page for each command is on [Read the Docs](https://plextool.readthedoc
|
|
|
151
162
|
|
|
152
163
|
## Install
|
|
153
164
|
|
|
154
|
-
plextool
|
|
155
|
-
|
|
165
|
+
plextool needs Python 3.10 or later, because plexapi 4.18 does. It is tested on
|
|
166
|
+
Python 3.12. If something doesn't work on another version, please
|
|
156
167
|
[open an issue](https://github.com/milkmansson/plextool/issues) with the Python
|
|
157
168
|
version.
|
|
158
169
|
|
|
@@ -88,12 +88,12 @@ def _require_jwt_support():
|
|
|
88
88
|
return MyPlexJWTLogin
|
|
89
89
|
|
|
90
90
|
|
|
91
|
-
def _headers(client_id: str) -> dict:
|
|
91
|
+
def _headers(client_id: str, device_name: str | None = None) -> dict:
|
|
92
92
|
return {
|
|
93
93
|
"X-Plex-Client-Identifier": client_id,
|
|
94
94
|
"X-Plex-Product": "plextool",
|
|
95
95
|
"X-Plex-Version": __version__,
|
|
96
|
-
"X-Plex-Device-Name": f"plextool on {socket.gethostname()}",
|
|
96
|
+
"X-Plex-Device-Name": device_name or f"plextool on {socket.gethostname()}",
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
|
|
@@ -191,22 +191,23 @@ KIND_BLURB = {
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
|
|
194
|
-
def show_link(url: str, kind: str) -> None:
|
|
194
|
+
def show_link(url: str, kind: str, file=None) -> None:
|
|
195
195
|
# The JWT sign-in is known to work with the trimmed link. The classic token sign-in is known to
|
|
196
196
|
# work with the full link, so it gets that (and a bigger QR code).
|
|
197
197
|
link = _short_link(url) if kind == "jwt" else url
|
|
198
|
-
|
|
199
|
-
print("
|
|
200
|
-
print(
|
|
198
|
+
out = file or sys.stdout
|
|
199
|
+
print(paint(f"Signing in for {KIND_BLURB[kind]}.", "bold") + "\n", file=out)
|
|
200
|
+
print("Open this link in a browser and sign in to Plex:\n", file=out)
|
|
201
|
+
print(f" {link}\n", file=out)
|
|
201
202
|
try:
|
|
202
203
|
import segno
|
|
203
204
|
except ImportError:
|
|
204
|
-
print("(Install 'segno' to see a QR code here as well: pip install segno)\n")
|
|
205
|
+
print("(Install 'segno' to see a QR code here as well: pip install segno)\n", file=out)
|
|
205
206
|
else:
|
|
206
|
-
if
|
|
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...")
|
|
207
|
+
if out.isatty():
|
|
208
|
+
segno.make(link, error="l").terminal(compact=True, out=out)
|
|
209
|
+
print("Or scan the code above with your phone.\n", file=out)
|
|
210
|
+
print("Waiting for you to approve it...", file=out)
|
|
210
211
|
|
|
211
212
|
|
|
212
213
|
def _new_client_id() -> str:
|
|
@@ -250,13 +251,18 @@ def login_jwt(timeout: int) -> None:
|
|
|
250
251
|
print("Once you have chosen a server, 'plextool server test' shows whether it accepts a JWT.")
|
|
251
252
|
|
|
252
253
|
|
|
253
|
-
def
|
|
254
|
+
def request_token(timeout: int, device_name: str | None = None, file=None) -> tuple[str, str]:
|
|
255
|
+
"""Run the browser sign-in for a classic token; returns (token, device ID). Saves nothing.
|
|
256
|
+
|
|
257
|
+
The sign-in appears under Authorized Devices as `device_name` (default 'plextool on <host>').
|
|
258
|
+
The link and prompts go to `file` (default standard output).
|
|
259
|
+
"""
|
|
254
260
|
from plexapi.myplex import MyPlexPinLogin
|
|
255
261
|
client_id = _new_client_id()
|
|
256
|
-
login = MyPlexPinLogin(headers=_headers(client_id), oauth=True)
|
|
262
|
+
login = MyPlexPinLogin(headers=_headers(client_id, device_name), oauth=True)
|
|
257
263
|
try:
|
|
258
264
|
login.run(timeout=timeout)
|
|
259
|
-
show_link(login.oauthUrl(), "token")
|
|
265
|
+
show_link(login.oauthUrl(), "token", file)
|
|
260
266
|
approved = login.waitForLogin()
|
|
261
267
|
except RequestException as e:
|
|
262
268
|
raise PlexToolError(f"Could not reach plex.tv: {e}") from None
|
|
@@ -264,11 +270,16 @@ def login_token(timeout: int, print_only: bool) -> None:
|
|
|
264
270
|
login.stop()
|
|
265
271
|
if not approved:
|
|
266
272
|
raise PlexToolError("Sign-in was not approved in time. Nothing was saved.")
|
|
273
|
+
return login.token, client_id
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def login_token(timeout: int, print_only: bool) -> None:
|
|
277
|
+
token, client_id = request_token(timeout)
|
|
267
278
|
if print_only:
|
|
268
|
-
print(
|
|
279
|
+
print(token)
|
|
269
280
|
return
|
|
270
281
|
directory = config_dir()
|
|
271
|
-
save_state({"client_id": client_id, "kind": "token", "token":
|
|
282
|
+
save_state({"client_id": client_id, "kind": "token", "token": token})
|
|
272
283
|
for name in KEY_FILES: # a JWT login's keys are no use any more
|
|
273
284
|
(directory / name).unlink(missing_ok=True)
|
|
274
285
|
print(paint("\nSigned in with a classic token.", "green") + f" Saved to {directory}")
|
|
@@ -282,6 +293,8 @@ def login_token(timeout: int, print_only: bool) -> None:
|
|
|
282
293
|
# ---------------------------------------------------------------------------
|
|
283
294
|
|
|
284
295
|
PRODUCT = "plextool"
|
|
296
|
+
NAMED_TOKEN_PREFIX = "plextool/" # devices made by `plextool token new --rw --name X`
|
|
297
|
+
OLD_NAMED_TOKEN_PREFIX = "plextool: " # what early versions used; still recognised
|
|
285
298
|
|
|
286
299
|
|
|
287
300
|
def account_devices(token: str, everything: bool = False) -> list:
|
|
@@ -305,8 +318,29 @@ def _short(device) -> str:
|
|
|
305
318
|
return (device.clientIdentifier or "")[:8]
|
|
306
319
|
|
|
307
320
|
|
|
321
|
+
def is_named_token(device) -> bool:
|
|
322
|
+
"""True for a device made by 'plextool token new --rw', which belongs to another tool."""
|
|
323
|
+
return (device.name or "").startswith((NAMED_TOKEN_PREFIX, OLD_NAMED_TOKEN_PREFIX))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def is_server(device) -> bool:
|
|
327
|
+
"""True for a Plex Media Server's own entry, which plextool never removes."""
|
|
328
|
+
provides = [p.strip() for p in (getattr(device, "provides", None) or "").split(",")]
|
|
329
|
+
return (device.product or "").lower() == "plex media server" or "server" in provides
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def token_label(device_name: str) -> str:
|
|
333
|
+
"""The name a token was given, without plextool's prefix."""
|
|
334
|
+
for prefix in (NAMED_TOKEN_PREFIX, OLD_NAMED_TOKEN_PREFIX):
|
|
335
|
+
if device_name.startswith(prefix):
|
|
336
|
+
return device_name[len(prefix):]
|
|
337
|
+
return device_name
|
|
338
|
+
|
|
339
|
+
|
|
308
340
|
def describe_device(device, current_id: str | None) -> str:
|
|
309
341
|
mark = paint(" (this login)", "green") if current_id and device.clientIdentifier == current_id else ""
|
|
342
|
+
if is_named_token(device):
|
|
343
|
+
mark = paint(" (token for another tool; see 'plextool token list')", "yellow")
|
|
310
344
|
return (f" {_short(device):<10}{device.name or '?':<40}created {_when(device.createdAt)} "
|
|
311
345
|
f"last seen {_when(device.lastSeenAt)}{mark}")
|
|
312
346
|
|
|
@@ -326,15 +360,28 @@ def list_devices(args: argparse.Namespace) -> None:
|
|
|
326
360
|
|
|
327
361
|
|
|
328
362
|
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
|
|
331
|
-
"from 'plextool auth devices'.")
|
|
363
|
+
if not (args.ids or args.others or args.this or args.unused_for is not None):
|
|
364
|
+
raise PlexToolError("Say what to remove: --others, --this, --unused-for DAYS, or one or more device "
|
|
365
|
+
"IDs from 'plextool auth devices'.")
|
|
366
|
+
if args.unused_for is not None and args.unused_for < 1:
|
|
367
|
+
raise PlexToolError("--unused-for must be at least 1 day.")
|
|
332
368
|
token, _ = resolve_token(args)
|
|
333
|
-
|
|
369
|
+
everything = account_devices(token, everything=True)
|
|
370
|
+
devices = [d for d in everything if (d.product or "").lower() == PRODUCT]
|
|
334
371
|
current = load_state().get("client_id")
|
|
335
372
|
chosen = {}
|
|
336
373
|
if args.others:
|
|
337
|
-
|
|
374
|
+
kept = [d for d in devices if is_named_token(d)]
|
|
375
|
+
if kept:
|
|
376
|
+
print(f"Keeping {plural(len(kept), 'token')} made for other tools with 'plextool token'. "
|
|
377
|
+
"'plextool token revoke NAME' removes those.")
|
|
378
|
+
chosen.update({d.clientIdentifier: d for d in devices
|
|
379
|
+
if d.clientIdentifier != current and not is_named_token(d)})
|
|
380
|
+
if args.unused_for is not None:
|
|
381
|
+
cutoff = datetime.now() - timedelta(days=args.unused_for)
|
|
382
|
+
chosen.update({d.clientIdentifier: d for d in devices
|
|
383
|
+
if d.clientIdentifier != current and not is_named_token(d)
|
|
384
|
+
and (d.lastSeenAt is None or d.lastSeenAt < cutoff)})
|
|
338
385
|
if args.this:
|
|
339
386
|
if not current:
|
|
340
387
|
raise PlexToolError("No saved login, so there is no device of this computer to remove.")
|
|
@@ -342,8 +389,14 @@ def revoke_devices(args: argparse.Namespace) -> None:
|
|
|
342
389
|
for prefix in args.ids:
|
|
343
390
|
matches = [d for d in devices if (d.clientIdentifier or "").startswith(prefix)]
|
|
344
391
|
if not matches:
|
|
345
|
-
|
|
346
|
-
|
|
392
|
+
other = [d for d in everything if (d.clientIdentifier or "").startswith(prefix)]
|
|
393
|
+
if other:
|
|
394
|
+
raise PlexToolError(
|
|
395
|
+
f"'{prefix}' is '{other[0].name or '?'}' ({other[0].product or 'unknown app'}), which plextool "
|
|
396
|
+
f"did not make, so 'auth revoke' will not remove it. "
|
|
397
|
+
f"'plextool devices remove {prefix}' removes other apps.")
|
|
398
|
+
raise PlexToolError(f"No device on the account starts with '{prefix}'. "
|
|
399
|
+
"'plextool token list' shows them all.")
|
|
347
400
|
if len(matches) > 1:
|
|
348
401
|
raise PlexToolError(f"'{prefix}' matches {len(matches)} devices; give more of the ID.")
|
|
349
402
|
chosen[matches[0].clientIdentifier] = matches[0]
|
|
@@ -475,10 +528,11 @@ def add_parser(subparsers) -> None:
|
|
|
475
528
|
revoke = add_command(
|
|
476
529
|
sub, "revoke", "remove plextool sign-ins from your Plex account",
|
|
477
530
|
"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
|
|
531
|
+
"plextool's own entries can be removed; other Plex apps and devices are removed with 'plextool devices remove'. "
|
|
479
532
|
"Nothing is removed unless you pass --commit.",
|
|
480
533
|
["plextool auth revoke --others",
|
|
481
534
|
"plextool auth revoke --others --commit",
|
|
535
|
+
"plextool auth revoke --unused-for 7 --commit",
|
|
482
536
|
"plextool auth revoke 3895d339 --commit",
|
|
483
537
|
"plextool auth revoke --this --commit"])
|
|
484
538
|
add_token_args(revoke)
|
|
@@ -488,6 +542,9 @@ def add_parser(subparsers) -> None:
|
|
|
488
542
|
help="every plextool device except the one this login uses")
|
|
489
543
|
revoke.add_argument("--this", action="store_true",
|
|
490
544
|
help="the device this login uses (do this before 'auth wipe')")
|
|
545
|
+
revoke.add_argument("--unused-for", type=int, metavar="DAYS",
|
|
546
|
+
help="every plextool sign-in not used in the last DAYS days (never this login, "
|
|
547
|
+
"and never tokens made with 'plextool token')")
|
|
491
548
|
revoke.add_argument("--commit", action="store_true", help="actually remove them")
|
|
492
549
|
|
|
493
550
|
wipe_parser = add_command(
|
|
@@ -10,7 +10,9 @@ from . import (
|
|
|
10
10
|
__version__,
|
|
11
11
|
audit,
|
|
12
12
|
auth,
|
|
13
|
+
devices,
|
|
13
14
|
dupes,
|
|
15
|
+
info,
|
|
14
16
|
playlists,
|
|
15
17
|
recent,
|
|
16
18
|
resume,
|
|
@@ -19,6 +21,8 @@ from . import (
|
|
|
19
21
|
stale,
|
|
20
22
|
status,
|
|
21
23
|
subs,
|
|
24
|
+
tokens,
|
|
25
|
+
users,
|
|
22
26
|
watched,
|
|
23
27
|
)
|
|
24
28
|
from .common import PlexToolError, enable_verbose, format_duration
|
|
@@ -38,7 +42,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
38
42
|
parser.add_argument("-v", "--verbose", dest="verbose_first", action="store_true",
|
|
39
43
|
help="print each request sent to Plex, with its reply, on stderr (also accepted after the command)")
|
|
40
44
|
subparsers = parser.add_subparsers(dest="command", metavar="command")
|
|
41
|
-
for module in (auth, server, status, scan, recent, dupes, stale, audit, watched, resume, playlists, subs):
|
|
45
|
+
for module in (auth, tokens, devices, users, server, status, scan, recent, dupes, stale, info, audit, watched, resume, playlists, subs):
|
|
42
46
|
module.add_parser(subparsers)
|
|
43
47
|
return parser
|
|
44
48
|
|
|
@@ -24,6 +24,7 @@ from plexapi.server import PlexServer
|
|
|
24
24
|
from requests.exceptions import RequestException
|
|
25
25
|
|
|
26
26
|
DEFAULT_URL = "http://localhost:32400"
|
|
27
|
+
DEFAULT_TIMEOUT = 10 # seconds to wait for the default address, which may not be a Plex server at all
|
|
27
28
|
TOKEN_ENV = "PLEX_TOKEN"
|
|
28
29
|
TOKEN_CMD_ENV = "PLEX_TOKEN_CMD"
|
|
29
30
|
SIGN_IN_AGAIN = ("It may have been revoked or have expired. "
|
|
@@ -203,11 +204,12 @@ class Working:
|
|
|
203
204
|
|
|
204
205
|
|
|
205
206
|
@contextmanager
|
|
206
|
-
def working(label: str):
|
|
207
|
+
def working(label: str, first: float | None = None):
|
|
207
208
|
"""Tell the user on stderr, now and then, that a slow step is still going.
|
|
208
209
|
|
|
209
210
|
Quick steps print nothing. After FIRST_NOTICE seconds a line like
|
|
210
211
|
'still reading TV Shows... 10s' appears, then another every NOTICE_EVERY seconds.
|
|
212
|
+
`first` replaces FIRST_NOTICE for steps that should speak up sooner.
|
|
211
213
|
stderr is used so --json output on stdout stays clean.
|
|
212
214
|
"""
|
|
213
215
|
state = Working()
|
|
@@ -215,7 +217,7 @@ def working(label: str):
|
|
|
215
217
|
started = time.monotonic()
|
|
216
218
|
|
|
217
219
|
def notify():
|
|
218
|
-
wait = FIRST_NOTICE
|
|
220
|
+
wait = FIRST_NOTICE if first is None else first
|
|
219
221
|
while not done.wait(wait):
|
|
220
222
|
detail = f" ({state.detail})" if state.detail else ""
|
|
221
223
|
print(f" still {label}... {int(time.monotonic() - started)}s{detail}", file=sys.stderr, flush=True)
|
|
@@ -347,6 +349,20 @@ def _find_token(args: argparse.Namespace) -> tuple[str, str]:
|
|
|
347
349
|
f"or set ${TOKEN_ENV}.")
|
|
348
350
|
|
|
349
351
|
|
|
352
|
+
def short_reason(error: Exception) -> str:
|
|
353
|
+
"""A one-line reason for a failed connection, without requests' long wording."""
|
|
354
|
+
text = str(error)
|
|
355
|
+
if isinstance(error, requests.exceptions.Timeout) or "timed out" in text:
|
|
356
|
+
return "no answer (timed out)"
|
|
357
|
+
if "Connection refused" in text:
|
|
358
|
+
return "connection refused (nothing is listening there)"
|
|
359
|
+
if "Name or service not known" in text or "Temporary failure in name resolution" in text:
|
|
360
|
+
return "the name could not be resolved"
|
|
361
|
+
if "No route to host" in text or "Network is unreachable" in text:
|
|
362
|
+
return "no route to the host"
|
|
363
|
+
return text if len(text) <= 150 else text[:147] + "..."
|
|
364
|
+
|
|
365
|
+
|
|
350
366
|
def connect(args: argparse.Namespace) -> PlexServer:
|
|
351
367
|
"""Connect to the server named in args, with friendly errors.
|
|
352
368
|
|
|
@@ -355,18 +371,26 @@ def connect(args: argparse.Namespace) -> PlexServer:
|
|
|
355
371
|
"""
|
|
356
372
|
args.token, source = resolve_token(args)
|
|
357
373
|
url = args.base_url or os.environ.get(URL_ENV)
|
|
374
|
+
chosen = bool(url)
|
|
358
375
|
try:
|
|
359
376
|
if url:
|
|
360
377
|
plex = PlexServer(url, args.token)
|
|
361
378
|
else:
|
|
362
379
|
from . import server # imported here because server itself uses this module
|
|
363
380
|
saved = server.load_saved()
|
|
364
|
-
|
|
381
|
+
chosen = bool(saved)
|
|
382
|
+
if saved:
|
|
383
|
+
plex = server.connect_saved(saved, args.token)
|
|
384
|
+
else:
|
|
385
|
+
with working(f"trying {DEFAULT_URL}, as no server is chosen (see 'plextool server use')", first=3):
|
|
386
|
+
plex = PlexServer(DEFAULT_URL, args.token, timeout=DEFAULT_TIMEOUT)
|
|
365
387
|
except Unauthorized:
|
|
366
388
|
hint = SIGN_IN_AGAIN if source == "saved login" else "Check that token, or use 'plextool auth login'."
|
|
367
389
|
raise PlexToolError(f"The Plex server rejected the token from {source} (401). {hint}") from None
|
|
368
390
|
except RequestException as e:
|
|
369
|
-
|
|
391
|
+
hint = "" if chosen else (" No server is chosen, so this default was tried. "
|
|
392
|
+
"Run 'plextool server find', then 'plextool server use'.")
|
|
393
|
+
raise PlexToolError(f"Could not reach {url or DEFAULT_URL}: {short_reason(e)}.{hint}") from None
|
|
370
394
|
args.base_url = plex._baseurl
|
|
371
395
|
return plex
|
|
372
396
|
|
|
@@ -379,14 +403,12 @@ def plex_account(args: argparse.Namespace) -> MyPlexAccount:
|
|
|
379
403
|
raise PlexToolError(f"Could not reach plex.tv: {e}") from None
|
|
380
404
|
|
|
381
405
|
|
|
382
|
-
def
|
|
383
|
-
"""
|
|
384
|
-
if not name:
|
|
385
|
-
return admin
|
|
406
|
+
def user_access_token(args: argparse.Namespace, admin: PlexServer, name: str) -> str | None:
|
|
407
|
+
"""The named user's access token for this server, or None if `name` is the owner."""
|
|
386
408
|
account = plex_account(args)
|
|
387
409
|
own_names = {n.lower() for n in (account.username, account.email, account.title) if n}
|
|
388
410
|
if name.lower() in own_names:
|
|
389
|
-
return
|
|
411
|
+
return None
|
|
390
412
|
try:
|
|
391
413
|
user = account.user(name)
|
|
392
414
|
except NotFound:
|
|
@@ -394,9 +416,19 @@ def user_server(args: argparse.Namespace, admin: PlexServer, name: str | None) -
|
|
|
394
416
|
token = user.get_token(admin.machineIdentifier)
|
|
395
417
|
if token:
|
|
396
418
|
_SECRETS.add(token)
|
|
397
|
-
|
|
419
|
+
else:
|
|
398
420
|
raise PlexToolError(
|
|
399
421
|
f"Could not get an access token for '{name}'. Is this server shared with them?")
|
|
422
|
+
return token
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def user_server(args: argparse.Namespace, admin: PlexServer, name: str | None) -> PlexServer:
|
|
426
|
+
"""Open the server as the named user (the owner if name is None or is the owner)."""
|
|
427
|
+
if not name:
|
|
428
|
+
return admin
|
|
429
|
+
token = user_access_token(args, admin, name)
|
|
430
|
+
if token is None:
|
|
431
|
+
return admin
|
|
400
432
|
try:
|
|
401
433
|
return PlexServer(args.base_url, token)
|
|
402
434
|
except RequestException as e:
|