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.
Files changed (39) hide show
  1. {plextool-3.1.0 → plextool-3.2.0}/PKG-INFO +26 -15
  2. {plextool-3.1.0 → plextool-3.2.0}/README.md +24 -13
  3. {plextool-3.1.0 → plextool-3.2.0}/plextool/__init__.py +1 -1
  4. {plextool-3.1.0 → plextool-3.2.0}/plextool/auth.py +81 -24
  5. {plextool-3.1.0 → plextool-3.2.0}/plextool/cli.py +5 -1
  6. {plextool-3.1.0 → plextool-3.2.0}/plextool/common.py +42 -10
  7. plextool-3.2.0/plextool/devices.py +179 -0
  8. plextool-3.2.0/plextool/info.py +546 -0
  9. plextool-3.2.0/plextool/tokens.py +451 -0
  10. plextool-3.2.0/plextool/users.py +578 -0
  11. {plextool-3.1.0 → plextool-3.2.0}/plextool.egg-info/PKG-INFO +26 -15
  12. {plextool-3.1.0 → plextool-3.2.0}/plextool.egg-info/SOURCES.txt +4 -0
  13. {plextool-3.1.0 → plextool-3.2.0}/pyproject.toml +2 -2
  14. {plextool-3.1.0 → plextool-3.2.0}/tests/test_auth.py +3 -1
  15. {plextool-3.1.0 → plextool-3.2.0}/tests/test_server.py +2 -2
  16. plextool-3.2.0/tests/test_tools.py +1303 -0
  17. plextool-3.1.0/tests/test_tools.py +0 -391
  18. {plextool-3.1.0 → plextool-3.2.0}/LICENSE +0 -0
  19. {plextool-3.1.0 → plextool-3.2.0}/plextool/__main__.py +0 -0
  20. {plextool-3.1.0 → plextool-3.2.0}/plextool/audit.py +0 -0
  21. {plextool-3.1.0 → plextool-3.2.0}/plextool/dupes.py +0 -0
  22. {plextool-3.1.0 → plextool-3.2.0}/plextool/ondemand.py +0 -0
  23. {plextool-3.1.0 → plextool-3.2.0}/plextool/playlists.py +0 -0
  24. {plextool-3.1.0 → plextool-3.2.0}/plextool/recent.py +0 -0
  25. {plextool-3.1.0 → plextool-3.2.0}/plextool/resume.py +0 -0
  26. {plextool-3.1.0 → plextool-3.2.0}/plextool/scan.py +0 -0
  27. {plextool-3.1.0 → plextool-3.2.0}/plextool/server.py +0 -0
  28. {plextool-3.1.0 → plextool-3.2.0}/plextool/stale.py +0 -0
  29. {plextool-3.1.0 → plextool-3.2.0}/plextool/status.py +0 -0
  30. {plextool-3.1.0 → plextool-3.2.0}/plextool/subs.py +0 -0
  31. {plextool-3.1.0 → plextool-3.2.0}/plextool/watched.py +0 -0
  32. {plextool-3.1.0 → plextool-3.2.0}/plextool.egg-info/dependency_links.txt +0 -0
  33. {plextool-3.1.0 → plextool-3.2.0}/plextool.egg-info/entry_points.txt +0 -0
  34. {plextool-3.1.0 → plextool-3.2.0}/plextool.egg-info/requires.txt +0 -0
  35. {plextool-3.1.0 → plextool-3.2.0}/plextool.egg-info/top_level.txt +0 -0
  36. {plextool-3.1.0 → plextool-3.2.0}/setup.cfg +0 -0
  37. {plextool-3.1.0 → plextool-3.2.0}/tests/test_ondemand.py +0 -0
  38. {plextool-3.1.0 → plextool-3.2.0}/tests/test_plextool.py +0 -0
  39. {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.1.0
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.9
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
- Command line tool for Plex library maintenance, built on
41
- [python-plexapi](https://python-plexapi.readthedocs.io/). It talks to the
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 command line toolkit for looking after a Plex Media Server: fixing
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
- Everything that changes the server is a preview until `--commit` is passed. plextool uses Plex's API and
83
- never edits the Plex database file. It signs in through a browser with
84
- [`plextool auth`](https://plextool.readthedocs.io/en/latest/commands/auth.html), and works against a server on the same machine, across the
85
- internet, or directly with no internet access ([`plextool server`](https://plextool.readthedocs.io/en/latest/commands/server.html)).
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 is tested on Python 3.12. It declares Python 3.9 or later, so pip will
189
- install it on older versions. If something doesn't work on one, please
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
- Command line tool for Plex library maintenance, built on
7
- [python-plexapi](https://python-plexapi.readthedocs.io/). It talks to the
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 command line toolkit for looking after a Plex Media Server: fixing
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
- Everything that changes the server is a preview until `--commit` is passed. plextool uses Plex's API and
49
- never edits the Plex database file. It signs in through a browser with
50
- [`plextool auth`](https://plextool.readthedocs.io/en/latest/commands/auth.html), and works against a server on the same machine, across the
51
- internet, or directly with no internet access ([`plextool server`](https://plextool.readthedocs.io/en/latest/commands/server.html)).
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 is tested on Python 3.12. It declares Python 3.9 or later, so pip will
155
- install it on older versions. If something doesn't work on one, please
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
 
@@ -1,3 +1,3 @@
1
1
  """plextool: maintenance helpers for a Plex Media Server, built on python-plexapi."""
2
2
 
3
- __version__ = "3.1.0"
3
+ __version__ = "3.2.0"
@@ -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
- 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")
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 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...")
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 login_token(timeout: int, print_only: bool) -> None:
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(login.token)
279
+ print(token)
269
280
  return
270
281
  directory = config_dir()
271
- save_state({"client_id": client_id, "kind": "token", "token": login.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 IDs "
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
- devices = account_devices(token)
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
- chosen.update({d.clientIdentifier: d for d in devices if d.clientIdentifier != current})
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
- raise PlexToolError(f"No plextool device starts with '{prefix}'. "
346
- "'plextool auth devices' lists them.")
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 never touched. "
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
- plex = server.connect_saved(saved, args.token) if saved else PlexServer(DEFAULT_URL, args.token)
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
- raise PlexToolError(f"Could not reach {url or DEFAULT_URL}: {e}") from None
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 user_server(args: argparse.Namespace, admin: PlexServer, name: str | None) -> PlexServer:
383
- """Open the server as the named user (the owner if name is None or is the owner)."""
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 admin
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
- if not token:
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: