plexdo 1.1.18__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.
Files changed (51) hide show
  1. plexdo/__init__.py +26 -0
  2. plexdo/__main__.py +24 -0
  3. plexdo/accounts.py +253 -0
  4. plexdo/airdates.py +136 -0
  5. plexdo/cache.py +38 -0
  6. plexdo/cli.py +157 -0
  7. plexdo/commands/__init__.py +63 -0
  8. plexdo/commands/auth.py +201 -0
  9. plexdo/commands/build.py +194 -0
  10. plexdo/commands/copy.py +169 -0
  11. plexdo/commands/libraries.py +231 -0
  12. plexdo/commands/metadata.py +135 -0
  13. plexdo/commands/missing.py +234 -0
  14. plexdo/commands/playlists.py +174 -0
  15. plexdo/commands/rescan.py +95 -0
  16. plexdo/commands/search.py +114 -0
  17. plexdo/commands/status.py +245 -0
  18. plexdo/commands/stream.py +138 -0
  19. plexdo/commands/users.py +39 -0
  20. plexdo/commands/watched.py +295 -0
  21. plexdo/config.py +153 -0
  22. plexdo/console.py +240 -0
  23. plexdo/constants.py +111 -0
  24. plexdo/convert.py +38 -0
  25. plexdo/data/_plexdo +359 -0
  26. plexdo/data/plexdo.1 +571 -0
  27. plexdo/data/plexdo.bash +597 -0
  28. plexdo/data/plexdo.fish +317 -0
  29. plexdo/data/plexdo.ps1 +371 -0
  30. plexdo/formats.py +181 -0
  31. plexdo/gallery.py +190 -0
  32. plexdo/identify.py +75 -0
  33. plexdo/logs.py +21 -0
  34. plexdo/m3u.py +34 -0
  35. plexdo/paths.py +105 -0
  36. plexdo/photos.py +74 -0
  37. plexdo/playlists.py +174 -0
  38. plexdo/py.typed +0 -0
  39. plexdo/records.py +136 -0
  40. plexdo/sections.py +58 -0
  41. plexdo/security.py +65 -0
  42. plexdo/sorting.py +50 -0
  43. plexdo/throttle.py +59 -0
  44. plexdo/titles.py +53 -0
  45. plexdo/tokens.py +88 -0
  46. plexdo-1.1.18.dist-info/METADATA +601 -0
  47. plexdo-1.1.18.dist-info/RECORD +51 -0
  48. plexdo-1.1.18.dist-info/WHEEL +5 -0
  49. plexdo-1.1.18.dist-info/entry_points.txt +2 -0
  50. plexdo-1.1.18.dist-info/licenses/LICENSE +232 -0
  51. plexdo-1.1.18.dist-info/top_level.txt +1 -0
plexdo/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ # plexdo - a command-line interface for Plex Media Server.
2
+ # Copyright (C) 2026 SidusNare
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify it
5
+ # under the terms of the GNU General Public License as published by the Free
6
+ # Software Foundation, either version 3 of the License, or (at your option)
7
+ # any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful, but WITHOUT
10
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
12
+ # more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License along
15
+ # with this program. If not, see <https://www.gnu.org/licenses/>.
16
+ #
17
+ # SPDX-License-Identifier: GPL-3.0-or-later
18
+
19
+ """plexdo - a command-line interface for Plex Media Server.
20
+
21
+ The public entry point is :func:`plexdo.cli.main`, exposed by the ``plexdo``
22
+ console script.
23
+ """
24
+
25
+ __version__ = "1.1.18"
26
+ __all__ = ["__version__"]
plexdo/__main__.py ADDED
@@ -0,0 +1,24 @@
1
+ # plexdo - a command-line interface for Plex Media Server.
2
+ # Copyright (C) 2026 SidusNare
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify it
5
+ # under the terms of the GNU General Public License as published by the Free
6
+ # Software Foundation, either version 3 of the License, or (at your option)
7
+ # any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful, but WITHOUT
10
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
12
+ # more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License along
15
+ # with this program. If not, see <https://www.gnu.org/licenses/>.
16
+ #
17
+ # SPDX-License-Identifier: GPL-3.0-or-later
18
+
19
+ """Allow the package to be run as ``python -m plexdo``."""
20
+
21
+ from plexdo.cli import main
22
+
23
+ if __name__ == "__main__":
24
+ main()
plexdo/accounts.py ADDED
@@ -0,0 +1,253 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ """User lookup, per-user servers, and account classification."""
4
+
5
+ from typing import Any, List, Optional, Tuple
6
+ import sys
7
+
8
+ from plexapi.exceptions import BadRequest, NotFound, Unauthorized
9
+ from plexapi.myplex import MyPlexAccount, MyPlexUser
10
+ from plexapi.server import PlexServer
11
+
12
+ from plexdo.config import cached_config, section_optional, token_store_path
13
+ from plexdo.console import clean_text
14
+ from plexdo.constants import LOG
15
+ from plexdo.identify import resolve_identifier
16
+ from plexdo.tokens import lookup, store_token
17
+
18
+
19
+ class UserAccessError(RuntimeError):
20
+ """The server refused to act on behalf of a user.
21
+
22
+ Deliberately an ordinary Exception rather than a sys.exit: it must be
23
+ catchable so copy-playlist-all-users can skip one inaccessible user and
24
+ carry on down the list. cli.main turns it into a clean exit for the
25
+ single-user commands.
26
+
27
+ Carries a one-line ``summary`` alongside the full explanation, so a loop
28
+ over many users can report each failure in one line instead of repeating
29
+ three paragraphs of advice per user.
30
+ """
31
+
32
+ def __init__(self, message: str, summary: str) -> None:
33
+ super().__init__(message)
34
+ self.summary = summary
35
+
36
+
37
+ def _access_denied(user_title: str, user_id: int) -> UserAccessError:
38
+ """Build the explanation shown when a user's token is rejected."""
39
+ return UserAccessError(
40
+ f"Access denied for user {user_title!r} (id={user_id}): the Plex "
41
+ f"server rejected that user's token.\n\n"
42
+ "Plex scopes every token to what that user can actually see, and "
43
+ "being the server admin does not override it. A user with no "
44
+ "libraries shared to them has no access to this server at all, so "
45
+ "there is nothing an admin token can do on their behalf.\n\n"
46
+ f"Share at least one library with {user_title!r} in Plex under "
47
+ "Settings > Users & Sharing, then try again.\n\n"
48
+ "If that user does have access under their own login, add their "
49
+ f"credentials to a [{user_id}] section of the config file and they "
50
+ "will be used automatically. Pass 0 to act as the admin account "
51
+ "itself.",
52
+ summary=(
53
+ f"access denied (401) - no libraries are shared with "
54
+ f"{user_title!r} on this server"
55
+ ),
56
+ )
57
+
58
+
59
+ def _connect_with(plex: PlexServer, token: str) -> Optional[PlexServer]:
60
+ """Return a server connected with a token, or None if it is refused."""
61
+ try:
62
+ # pylint: disable-next=protected-access
63
+ return PlexServer(plex._baseurl, token)
64
+ except Unauthorized as exc:
65
+ # plexapi attaches the server's HTML error page here; keep it for
66
+ # --debug so it never reaches the user's terminal.
67
+ LOG.debug("Token rejected by server: %s", exc)
68
+ return None
69
+
70
+
71
+ def _candidate_usernames(user_id: int, user: MyPlexUser) -> List[str]:
72
+ """Names this user's token might be filed under, most specific first."""
73
+ configured = section_optional(cached_config(), str(user_id), "username")
74
+ names = [
75
+ configured,
76
+ getattr(user, "username", None),
77
+ getattr(user, "email", None),
78
+ getattr(user, "title", None),
79
+ ]
80
+ seen: List[str] = []
81
+ for name in names:
82
+ text = clean_text(name or "")
83
+ if text and text not in seen:
84
+ seen.append(text)
85
+ return seen
86
+
87
+
88
+ def _connect_as_shared_user(plex: PlexServer, user: MyPlexUser) -> Optional[PlexServer]:
89
+ """Stage 1: the admin-issued, server-scoped token for this user."""
90
+ try:
91
+ token = user.get_token(plex.machineIdentifier)
92
+ except (BadRequest, NotFound, Unauthorized) as exc:
93
+ LOG.debug("get_token failed for %r: %s", user.title, exc)
94
+ return None
95
+ if not token:
96
+ LOG.debug("get_token returned nothing for %r", user.title)
97
+ return None
98
+ return _connect_with(plex, token)
99
+
100
+
101
+ def _connect_from_store(
102
+ plex: PlexServer, user: MyPlexUser, user_id: int
103
+ ) -> Optional[PlexServer]:
104
+ """Stage 2: a token saved previously for this user."""
105
+ path = token_store_path(cached_config())
106
+ for name in _candidate_usernames(user_id, user):
107
+ token = lookup(path, name)
108
+ if not token:
109
+ continue
110
+ server = _connect_with(plex, token)
111
+ if server is not None:
112
+ LOG.info("Using stored token for %r", name)
113
+ return server
114
+ LOG.debug("Stored token for %r was rejected; will try logging in", name)
115
+ return None
116
+
117
+
118
+ def _connect_by_login(
119
+ plex: PlexServer, user_id: int
120
+ ) -> Optional[PlexServer]:
121
+ """Stage 3: log in with the credentials in the [<user_id>] section."""
122
+ cfg = cached_config()
123
+ section = str(user_id)
124
+ username = section_optional(cfg, section, "username")
125
+ password = section_optional(cfg, section, "password")
126
+ if not (username and password):
127
+ LOG.debug("No username/password in config section [%s]", section)
128
+ return None
129
+
130
+ LOG.info("Logging in as %r for user id %s", username, section)
131
+ try:
132
+ account = MyPlexAccount(username=username, password=password)
133
+ except (Unauthorized, BadRequest) as exc:
134
+ LOG.warning("Login failed for %r: %s", username, exc)
135
+ return None
136
+
137
+ token = account.authenticationToken
138
+ server = _connect_with(plex, token)
139
+ if server is None:
140
+ LOG.warning(
141
+ "%r signed in to plex.tv but that account cannot reach this "
142
+ "server; nothing has been shared with it.", username,
143
+ )
144
+ return None
145
+
146
+ store_token(token_store_path(cfg), username, token)
147
+ LOG.info("Saved a token for %r for future runs", username)
148
+ return server
149
+
150
+
151
+ def server_for_user(plex: PlexServer, user_id: int) -> PlexServer:
152
+ """Return a PlexServer scoped to the given user.
153
+
154
+ Pass user_id=0 to use the admin account (the token from config).
155
+
156
+ Otherwise three sources are tried in turn, because a server will refuse
157
+ an admin-issued token for a user it has shared nothing with:
158
+
159
+ 1. the server-scoped token the admin can mint via get_token()
160
+ 2. a token already saved in the JSON token store for that user
161
+ 3. a fresh login using the username and password in the config section
162
+ named for the user ID, whose token is then saved for next time
163
+
164
+ Raises UserAccessError when none of them yields access.
165
+ """
166
+ if user_id == 0:
167
+ LOG.debug("user_id=0: using admin account")
168
+ return plex
169
+
170
+ account = plex.myPlexAccount()
171
+ user: MyPlexUser = _find_user_by_id(account, user_id)
172
+
173
+ for stage, connect in (
174
+ ("admin-issued token", lambda: _connect_as_shared_user(plex, user)),
175
+ ("stored token", lambda: _connect_from_store(plex, user, user_id)),
176
+ ("configured credentials", lambda: _connect_by_login(plex, user_id)),
177
+ ):
178
+ server = connect()
179
+ if server is not None:
180
+ LOG.debug("Connected as %r via %s", user.title, stage)
181
+ return server
182
+ LOG.debug("Could not connect as %r via %s", user.title, stage)
183
+
184
+ raise _access_denied(user.title, user_id)
185
+
186
+
187
+ def _find_user_by_id(account: MyPlexAccount, user_id: int) -> MyPlexUser:
188
+ """Locate a MyPlexUser by numeric id, failing fast if absent."""
189
+ for user in account.users():
190
+ if int(user.id) == user_id:
191
+ return user
192
+ sys.exit(f"User ID not found: {user_id}")
193
+
194
+
195
+ def _is_restricted(user: MyPlexUser) -> bool:
196
+ """Return True if the user is a restricted (managed) account.
197
+
198
+ plexapi exposes `restricted` as the raw XML string rather than a bool,
199
+ so a plain truth test would treat the common "0" value as True and
200
+ mislabel every account as managed.
201
+ """
202
+ raw = getattr(user, "restricted", None)
203
+ if isinstance(raw, bool):
204
+ return raw
205
+ return str(raw or "").strip().lower() not in ("", "0", "false")
206
+
207
+
208
+ def account_type(user: MyPlexUser) -> str:
209
+ """Classify a user account as managed, home, friend, or shared."""
210
+ if _is_restricted(user):
211
+ return "managed"
212
+ if getattr(user, "home", False):
213
+ return "home"
214
+ if getattr(user, "friend", False):
215
+ return "friend"
216
+ return "shared"
217
+
218
+
219
+ # Namespace attributes that hold a user identifier, resolved centrally in
220
+ # cli.main() so every command handler receives a plain numeric user ID.
221
+ USER_ID_ARGUMENTS = ("user_id", "user_a", "user_b", "source_user_id")
222
+
223
+
224
+ def _user_roster(plex: PlexServer) -> List[Tuple[int, str]]:
225
+ """Return (id, title) for the admin account and every shared user."""
226
+ account = plex.myPlexAccount()
227
+ roster = [(0, clean_text(getattr(account, "title", "") or "admin"))]
228
+ roster.extend(
229
+ (int(user.id), clean_text(user.title or ""))
230
+ for user in account.users()
231
+ )
232
+ return roster
233
+
234
+
235
+ def resolve_user_identifier(roster: List[Tuple[int, str]], value: Any) -> int:
236
+ """Resolve a numeric user ID or a user title to a numeric user ID."""
237
+ return resolve_identifier(roster, value, "user", "list-users")
238
+
239
+
240
+ def resolve_user_arguments(plex: PlexServer, args: "argparse.Namespace") -> None:
241
+ """Replace user ID/title arguments with numeric user IDs, in place.
242
+
243
+ The roster is fetched once per invocation, so a command taking two user
244
+ arguments costs a single extra API call rather than two.
245
+ """
246
+ present = [
247
+ name for name in USER_ID_ARGUMENTS if getattr(args, name, None) is not None
248
+ ]
249
+ if not present:
250
+ return
251
+ roster = _user_roster(plex)
252
+ for name in present:
253
+ setattr(args, name, resolve_user_identifier(roster, getattr(args, name)))
plexdo/airdates.py ADDED
@@ -0,0 +1,136 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ """Air-date estimation for episodes missing originallyAvailableAt."""
4
+
5
+ from typing import List, Optional, Tuple
6
+ import datetime
7
+ import statistics
8
+
9
+ from plexapi.video import Episode
10
+
11
+ from plexdo.constants import LOG
12
+ from plexdo.convert import parse_date
13
+
14
+
15
+ def episodes_in_same_season(ep: Episode, all_eps: List[Episode]) -> List[Episode]:
16
+ """Return all episodes in the same season as ep (excluding ep itself)."""
17
+ return [
18
+ e for e in all_eps
19
+ if e.seasonNumber == ep.seasonNumber
20
+ and int(e.ratingKey) != int(ep.ratingKey)
21
+ ]
22
+
23
+
24
+ def _collect_neighbors(
25
+ ep: Episode, season_eps: List[Episode]
26
+ ) -> Tuple[List[datetime.datetime], List[datetime.datetime]]:
27
+ """
28
+ Return (prev_dates, next_dates) - up to 6 known dates on each side.
29
+ Episodes are ordered by episodeNumber within the season.
30
+ """
31
+ ordered = sorted(
32
+ (e for e in season_eps if e.index is not None),
33
+ key=lambda e: e.index,
34
+ )
35
+ ep_index = ep.index or 0
36
+ prev_dates: List[datetime.datetime] = []
37
+ next_dates: List[datetime.datetime] = []
38
+
39
+ for e in reversed(ordered):
40
+ if e.index < ep_index:
41
+ dt = parse_date(e.originallyAvailableAt)
42
+ if dt is not None:
43
+ prev_dates.append(dt)
44
+ if len(prev_dates) >= 6:
45
+ break
46
+
47
+ for e in ordered:
48
+ if e.index > ep_index:
49
+ dt = parse_date(e.originallyAvailableAt)
50
+ if dt is not None:
51
+ next_dates.append(dt)
52
+ if len(next_dates) >= 6:
53
+ break
54
+
55
+ return prev_dates, next_dates
56
+
57
+
58
+ def _median_interval(dates: List[datetime.datetime]) -> Optional[datetime.timedelta]:
59
+ """Compute the median timedelta between adjacent sorted dates."""
60
+ sorted_dates = sorted(dates)
61
+ if len(sorted_dates) < 3: # need >=3 dates -> >=2 intervals
62
+ return None
63
+ intervals = [
64
+ (sorted_dates[i + 1] - sorted_dates[i]).total_seconds()
65
+ for i in range(len(sorted_dates) - 1)
66
+ ]
67
+ if len(intervals) < 2:
68
+ return None
69
+ return datetime.timedelta(seconds=statistics.median(intervals))
70
+
71
+
72
+ def _estimate_date(
73
+ prev_dates: List[datetime.datetime],
74
+ next_dates: List[datetime.datetime],
75
+ ) -> Optional[datetime.datetime]:
76
+ """Estimate a missing air date from neighboring known dates."""
77
+ all_known = prev_dates + next_dates
78
+ median_td = _median_interval(all_known)
79
+ if median_td is None:
80
+ return None
81
+
82
+ estimates: List[datetime.datetime] = []
83
+ if prev_dates:
84
+ latest_prev = max(prev_dates)
85
+ estimates.append(latest_prev + median_td)
86
+ if next_dates:
87
+ earliest_next = min(next_dates)
88
+ estimates.append(earliest_next - median_td)
89
+
90
+ if not estimates:
91
+ return None
92
+ if len(estimates) == 1:
93
+ return estimates[0]
94
+ avg_ts = sum(e.timestamp() for e in estimates) / len(estimates)
95
+ return datetime.datetime.fromtimestamp(avg_ts)
96
+
97
+
98
+ def prompt_for_date(ep: Episode, last_used: Optional[datetime.datetime]) -> datetime.datetime:
99
+ """Interactively ask the user for a missing air date."""
100
+ example = last_used.strftime("%Y-%m-%d") if last_used else "2000-01-01"
101
+ prompt = (
102
+ f"\nCannot resolve air date for: {ep.grandparentTitle} "
103
+ f"S{ep.seasonNumber:02d}E{ep.index:02d} - {ep.title}\n"
104
+ f"Enter date (YYYY-MM-DD) [example: {example}]: "
105
+ )
106
+ while True:
107
+ raw = input(prompt).strip()
108
+ try:
109
+ return datetime.datetime.strptime(raw, "%Y-%m-%d")
110
+ except ValueError:
111
+ print("Invalid format, please use YYYY-MM-DD.")
112
+
113
+
114
+ def resolve_episode_date(
115
+ ep: Episode,
116
+ season_peers: List[Episode],
117
+ last_used: Optional[datetime.datetime],
118
+ ) -> datetime.datetime:
119
+ """Return a resolved datetime for an episode, estimating or prompting if needed."""
120
+ dt = parse_date(ep.originallyAvailableAt)
121
+ if dt is not None:
122
+ return dt
123
+
124
+ prev_dates, next_dates = _collect_neighbors(ep, season_peers)
125
+ estimated = _estimate_date(prev_dates, next_dates)
126
+ if estimated is not None:
127
+ LOG.info(
128
+ "Estimated date for '%s' S%02dE%02d: %s",
129
+ ep.grandparentTitle,
130
+ ep.seasonNumber,
131
+ ep.index,
132
+ estimated.date(),
133
+ )
134
+ return estimated
135
+
136
+ return prompt_for_date(ep, last_used)
plexdo/cache.py ADDED
@@ -0,0 +1,38 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ """Completion cache written as a side effect of list commands."""
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List
7
+ import json
8
+
9
+ from plexdo.config import cached_config, config_optional
10
+ from plexdo.constants import CONFIG_PATH, DEFAULT_CACHE_DIR, LOG
11
+
12
+
13
+ def cache_dir() -> Path:
14
+ """Return the completion cache directory.
15
+
16
+ [plex] cache_dir overrides the platform default. The config is only
17
+ consulted when it exists, so this stays usable before `plexdo login` has
18
+ ever been run.
19
+ """
20
+ if CONFIG_PATH.exists():
21
+ configured = config_optional(cached_config(), "cache_dir")
22
+ if configured:
23
+ return Path(configured).expanduser()
24
+ return DEFAULT_CACHE_DIR
25
+
26
+
27
+ def write_cache(name: str, data: List[Dict[str, Any]]) -> None:
28
+ """Atomically write data to the completion cache, silently ignoring errors."""
29
+ try:
30
+ directory = cache_dir()
31
+ directory.mkdir(parents=True, exist_ok=True)
32
+ cache_file = directory / f"{name}.json"
33
+ tmp = cache_file.with_suffix(".tmp")
34
+ tmp.write_text(json.dumps(data), encoding="utf-8")
35
+ tmp.replace(cache_file)
36
+ LOG.debug("Cache updated: %s", cache_file)
37
+ except OSError:
38
+ pass
plexdo/cli.py ADDED
@@ -0,0 +1,157 @@
1
+ # plexdo - a command-line interface for Plex Media Server.
2
+ # Copyright (C) 2026 SidusNare
3
+ #
4
+ # This program is free software: you can redistribute it and/or modify it
5
+ # under the terms of the GNU General Public License as published by the Free
6
+ # Software Foundation, either version 3 of the License, or (at your option)
7
+ # any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful, but WITHOUT
10
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
11
+ # FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
12
+ # more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License along
15
+ # with this program. If not, see <https://www.gnu.org/licenses/>.
16
+ #
17
+ # SPDX-License-Identifier: GPL-3.0-or-later
18
+
19
+ """Top-level argument parsing and command dispatch."""
20
+
21
+ import argparse
22
+ import sys
23
+ from typing import List, Optional
24
+
25
+ from plexdo import __version__
26
+ from plexdo.accounts import UserAccessError, resolve_user_arguments
27
+ from plexdo.sections import resolve_library_arguments
28
+ from plexdo.commands import build_registry, register_all
29
+ from plexdo.formats import OUTPUT_FORMATS
30
+ from plexdo.throttle import DEFAULT_THROTTLE, THROTTLE_THRESHOLD
31
+ from plexdo.config import cached_config, connect_plex
32
+ from plexdo.logs import configure_logging
33
+ from plexdo.security import scrub_password_argument
34
+
35
+
36
+ def _add_global_flags(
37
+ parser: argparse.ArgumentParser, suppress: bool = False
38
+ ) -> None:
39
+ """Add the flags accepted by every command.
40
+
41
+ When *suppress* is set the flags default to ``argparse.SUPPRESS`` instead
42
+ of ``False``. That matters for the copies inherited by each subparser: a
43
+ subparser parses into its own namespace and then copies every attribute
44
+ onto the main one, so ordinary ``False`` defaults would clobber a flag
45
+ that was given before the subcommand. With SUPPRESS the attribute only
46
+ exists when the flag was actually passed, so either position works.
47
+ """
48
+ default = argparse.SUPPRESS if suppress else False
49
+ parser.add_argument(
50
+ "-f", "--format", dest="format", choices=list(OUTPUT_FORMATS),
51
+ default=argparse.SUPPRESS if suppress else "table", metavar="FORMAT",
52
+ help=(
53
+ "Output format: " + ", ".join(OUTPUT_FORMATS) + ". "
54
+ "Default is an aligned table."
55
+ ),
56
+ )
57
+ # A convenience alias writing to the same destination as --format, so
58
+ # there is only ever one attribute for a command to consult.
59
+ parser.add_argument(
60
+ "--json", dest="format", action="store_const", const="json",
61
+ default=argparse.SUPPRESS, help="Shorthand for --format json.",
62
+ )
63
+ parser.add_argument(
64
+ "-W", "--wide", action="store_true", default=default,
65
+ help=(
66
+ "Do not shrink table columns to the terminal width. By default "
67
+ "the widest column is truncated so a row fits on one line; "
68
+ "redirected output is never truncated."
69
+ ),
70
+ )
71
+ parser.add_argument(
72
+ "--throttle", type=float, metavar="SECONDS",
73
+ default=argparse.SUPPRESS if suppress else DEFAULT_THROTTLE,
74
+ help=(
75
+ "Seconds to wait between requests in operations that query once "
76
+ f"per item, when there are more than {THROTTLE_THRESHOLD} of them "
77
+ f"(default: {DEFAULT_THROTTLE}). Use 0 to disable."
78
+ ),
79
+ )
80
+ parser.add_argument(
81
+ "-V", "--version", action="version",
82
+ version=f"plexdo {__version__}",
83
+ help="Show the installed version and exit.",
84
+ )
85
+ parser.add_argument(
86
+ "-v", "--verbose", action="store_true", default=default,
87
+ help="Print high-level progress to stderr.",
88
+ )
89
+ parser.add_argument(
90
+ "--debug", action="store_true", default=default,
91
+ help="Print detailed internal logs to stderr.",
92
+ )
93
+ parser.add_argument(
94
+ "--dry-run", action="store_true", default=default, dest="dry_run",
95
+ help="Show what would happen without mutating Plex.",
96
+ )
97
+
98
+
99
+ def _global_flags_parent() -> argparse.ArgumentParser:
100
+ """Return a parent parser supplying the global flags to every subcommand."""
101
+ parent = argparse.ArgumentParser(add_help=False)
102
+ _add_global_flags(parent, suppress=True)
103
+ return parent
104
+
105
+
106
+ def build_parser() -> argparse.ArgumentParser:
107
+ """Construct and return the top-level argument parser."""
108
+ parser = argparse.ArgumentParser(
109
+ prog="plexdo",
110
+ description=(
111
+ f"Interact with a Plex Media Server via plexapi. (version {__version__})"
112
+ ),
113
+ epilog=(
114
+ "Global flags (-f/--format, --json, -v/--verbose, --debug, "
115
+ "--dry-run, -V/--version) may be given either before or after the "
116
+ "command name."
117
+ ),
118
+ formatter_class=argparse.RawDescriptionHelpFormatter,
119
+ )
120
+ _add_global_flags(parser)
121
+ parents: List[argparse.ArgumentParser] = [_global_flags_parent()]
122
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
123
+ register_all(sub, parents)
124
+ return parser
125
+
126
+
127
+ def main(argv: Optional[List[str]] = None) -> None:
128
+ """Entry point for the ``plexdo`` console script."""
129
+ parser = build_parser()
130
+ args = parser.parse_args(argv)
131
+
132
+ if args.command is None:
133
+ parser.print_help()
134
+ sys.exit(1)
135
+
136
+ configure_logging(args.verbose, args.debug)
137
+ scrub_password_argument(args)
138
+
139
+ handlers, needs_plex = build_registry()
140
+ handler = handlers.get(args.command)
141
+ if handler is None:
142
+ sys.exit(f"Unknown command: {args.command}")
143
+
144
+ if args.command in needs_plex:
145
+ plex = connect_plex(cached_config())
146
+ # Commands receive numeric user and library IDs; titles are resolved
147
+ # here so no handler has to care which form the user typed.
148
+ resolve_user_arguments(plex, args)
149
+ resolve_library_arguments(plex, args)
150
+ try:
151
+ handler(plex, args)
152
+ except UserAccessError as exc:
153
+ # Raised deep in accounts.server_for_user so the all-users loop
154
+ # can skip a user; for a single-user command it is fatal.
155
+ sys.exit(str(exc))
156
+ else:
157
+ handler(None, args)