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
@@ -0,0 +1,63 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ """Command registry.
4
+
5
+ Every command module exposes three names:
6
+
7
+ ``register(sub, parents)``
8
+ Adds the module's subparsers to the top-level ``add_subparsers`` action.
9
+ Every subparser must pass ``parents=parents`` so it inherits the global
10
+ flags and accepts them after the command name as well as before it.
11
+ ``COMMANDS``
12
+ Maps each command name to its handler function.
13
+ ``REQUIRES_PLEX``
14
+ The subset of those names needing a connected server. Commands outside it
15
+ (``login``, ``write-config-example``) run before a token necessarily exists.
16
+
17
+ Adding a command means adding a module here - ``cli`` needs no edit.
18
+ """
19
+
20
+ import argparse
21
+ from typing import Any, Callable, Dict, FrozenSet, List, Tuple
22
+
23
+ from plexdo.commands import (auth, build, copy, libraries, metadata, missing,
24
+ playlists, rescan, search, status, stream, users,
25
+ watched)
26
+
27
+ # Order determines the order subcommands appear in --help.
28
+ MODULES = (
29
+ libraries,
30
+ missing,
31
+ search,
32
+ users,
33
+ playlists,
34
+ metadata,
35
+ stream,
36
+ rescan,
37
+ status,
38
+ build,
39
+ copy,
40
+ watched,
41
+ auth,
42
+ )
43
+
44
+ Handler = Callable[[Any, argparse.Namespace], None]
45
+
46
+
47
+ def register_all(
48
+ sub: "argparse._SubParsersAction", # pylint: disable=protected-access
49
+ parents: List[argparse.ArgumentParser],
50
+ ) -> None:
51
+ """Register every command module's subparsers."""
52
+ for module in MODULES:
53
+ module.register(sub, parents)
54
+
55
+
56
+ def build_registry() -> Tuple[Dict[str, Handler], FrozenSet[str]]:
57
+ """Return the merged command map and the set needing a Plex connection."""
58
+ handlers: Dict[str, Handler] = {}
59
+ needs_plex: set = set()
60
+ for module in MODULES:
61
+ handlers.update(module.COMMANDS)
62
+ needs_plex.update(module.REQUIRES_PLEX)
63
+ return handlers, frozenset(needs_plex)
@@ -0,0 +1,201 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ """Authentication and config bootstrap commands."""
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Optional, Tuple
7
+ import argparse
8
+ import configparser
9
+ import getpass
10
+ import os
11
+ import sys
12
+ import textwrap
13
+
14
+ from plexapi.exceptions import BadRequest, Unauthorized
15
+ from plexapi.myplex import MyPlexAccount
16
+ from plexapi.server import PlexServer
17
+
18
+ from plexdo.config import config_optional, load_config
19
+ from plexdo.console import output, output_format
20
+ from plexdo.constants import CONFIG_EXAMPLE, CONFIG_PATH, LOG
21
+ from plexdo.tokens import ADMIN_KEY, store_token
22
+
23
+
24
+ def _prompt_username(supplied: Optional[str]) -> str:
25
+ """Return the username, prompting if it was not supplied."""
26
+ username = supplied or input("Plex username or email: ").strip()
27
+ if not username:
28
+ sys.exit("Username is required.")
29
+ return username
30
+
31
+
32
+ def _prompt_password() -> str:
33
+ """Read the password without echoing it to the terminal."""
34
+ password = getpass.getpass("Plex password: ")
35
+ if not password:
36
+ sys.exit("Password is required.")
37
+ return password
38
+
39
+
40
+ def resolve_credentials(
41
+ cfg: configparser.ConfigParser, args: argparse.Namespace
42
+ ) -> Tuple[str, str]:
43
+ """Resolve (username, password) from arguments and config.
44
+
45
+ Precedence rules:
46
+ * --username given: that username is used and the config password is
47
+ ignored entirely, because a config password paired with a different
48
+ username would be a silent credential mismatch. The password comes
49
+ from --password, or an interactive prompt.
50
+ * --username absent: fall back to the config username, and to the
51
+ config password alongside it. --password still overrides.
52
+ * Neither present: prompt for both.
53
+ """
54
+ cfg_user = config_optional(cfg, "username")
55
+ cfg_pass = config_optional(cfg, "password")
56
+
57
+ if args.username:
58
+ if cfg_pass:
59
+ LOG.debug("--username supplied; ignoring config password.")
60
+ return args.username, args.password or _prompt_password()
61
+
62
+ if cfg_user:
63
+ LOG.debug("Using username from config: %s", cfg_user)
64
+ return cfg_user, args.password or cfg_pass or _prompt_password()
65
+
66
+ return _prompt_username(None), args.password or _prompt_password()
67
+
68
+
69
+ def _obtain_token(username: str, password: str, code: Optional[str]) -> str:
70
+ """Authenticate against plex.tv and return the account auth token."""
71
+ kwargs: Dict[str, Any] = {"username": username, "password": password}
72
+ if code:
73
+ kwargs["code"] = code
74
+ try:
75
+ account = MyPlexAccount(**kwargs)
76
+ except Unauthorized:
77
+ sys.exit(
78
+ "Authentication failed: bad username/password, or a two-factor "
79
+ "code is required (retry with --code)."
80
+ )
81
+ except BadRequest as exc:
82
+ sys.exit(f"Login rejected by plex.tv: {exc}")
83
+ return account.authenticationToken
84
+
85
+
86
+ def _save_token(token: str, token_path_raw: str, username: Optional[str]) -> Path:
87
+ """Add this account's token to the JSON store, keeping any others."""
88
+ path = Path(token_path_raw).expanduser()
89
+ store_token(path, username or ADMIN_KEY, token)
90
+ if os.name == "nt":
91
+ # chmod on Windows only toggles the read-only bit, so claiming 0600
92
+ # would be misleading; NTFS ACLs govern access instead.
93
+ LOG.debug("Token written to %s (POSIX mode not applicable)", path)
94
+ else:
95
+ LOG.debug("Token written to %s (mode 0600)", path)
96
+ return path
97
+
98
+
99
+ def _verify_token(url: str, token: str) -> bool:
100
+ """Return True if the token can connect to the configured server."""
101
+ try:
102
+ PlexServer(url, token)
103
+ return True
104
+ except Exception as exc: # pylint: disable=broad-except
105
+ LOG.warning("Token saved but could not connect to %s: %s", url, exc)
106
+ return False
107
+
108
+
109
+ def cmd_login(_plex: Optional[PlexServer], args: argparse.Namespace) -> None:
110
+ """Authenticate with plex.tv and save the token to token_path."""
111
+ cfg = load_config()
112
+ token_path_raw = cfg.get("plex", "token_path")
113
+ url = cfg.get("plex", "url")
114
+
115
+ username, password = resolve_credentials(cfg, args)
116
+ code: Optional[str] = args.code
117
+ if args.two_factor and not code:
118
+ code = input("Two-factor code: ").strip() or None
119
+
120
+ LOG.info("Authenticating %s against plex.tv", username)
121
+ token = _obtain_token(username, password, code)
122
+
123
+ if args.dry_run:
124
+ LOG.info("--dry-run: not writing token.")
125
+ print(f"Authenticated. Token would be written to: "
126
+ f"{Path(token_path_raw).expanduser()}", file=sys.stderr)
127
+ return
128
+
129
+ path = _save_token(token, token_path_raw, username)
130
+ verified = _verify_token(url, token)
131
+
132
+ if output_format(args) == "table":
133
+ print(f"Token saved to: {path}")
134
+ if verified:
135
+ print(f"Verified against: {url}")
136
+ else:
137
+ output({"token_path": str(path), "verified": verified}, args)
138
+
139
+
140
+ def cmd_write_config_example(_plex: Optional[PlexServer], _args: argparse.Namespace) -> None:
141
+ """Write a template config file."""
142
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
143
+ CONFIG_PATH.write_text(CONFIG_EXAMPLE, encoding="utf-8")
144
+ if os.name != "nt":
145
+ CONFIG_PATH.chmod(0o600)
146
+ print(f"Config example written to: {CONFIG_PATH}")
147
+
148
+
149
+ def register(
150
+ sub: "argparse._SubParsersAction",
151
+ parents: "List[argparse.ArgumentParser]",
152
+ ) -> None:
153
+ """Register the login and config bootstrap subparsers."""
154
+ p_login = sub.add_parser(
155
+ "login", parents=parents,
156
+ help="Authenticate with plex.tv and save the token to token_path.",
157
+ )
158
+ p_login.add_argument(
159
+ "-u", "--username", default=None, metavar="USER",
160
+ help="Plex username or email (str). Prompted for if omitted.",
161
+ )
162
+ p_login.add_argument(
163
+ "-p", "--password", default=None, metavar="PASS",
164
+ help=(
165
+ "Plex password (str). INSECURE: visible in shell history and "
166
+ "briefly in `ps`. Prefer the config file or the interactive prompt."
167
+ ),
168
+ )
169
+ p_login.add_argument(
170
+ "-c", "--code", default=None, metavar="CODE",
171
+ help="Two-factor authentication code (str), if the account requires one.",
172
+ )
173
+ p_login.add_argument(
174
+ "-2", "--two-factor", dest="two_factor", action="store_true", default=False,
175
+ help="Prompt for a two-factor code interactively.",
176
+ )
177
+
178
+ sub.add_parser(
179
+ "write-config-example", parents=parents,
180
+ help="Write a template config file.",
181
+ description=textwrap.fill(
182
+ f"Write a template config file to {CONFIG_PATH} with mode 0600, "
183
+ "creating parent directories as needed. An existing file at that "
184
+ "path will be overwritten.",
185
+ width=78,
186
+ ),
187
+ epilog=(
188
+ "template that would be written:\n\n"
189
+ + textwrap.indent(CONFIG_EXAMPLE, " ")
190
+ ),
191
+ formatter_class=argparse.RawDescriptionHelpFormatter,
192
+ )
193
+
194
+
195
+ COMMANDS = {
196
+ "login": cmd_login,
197
+ "write-config-example": cmd_write_config_example,
198
+ }
199
+
200
+ # login and write-config-example both run before a token necessarily exists.
201
+ REQUIRES_PLEX: frozenset = frozenset()
@@ -0,0 +1,194 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ """Playlist building commands."""
4
+
5
+ from typing import Iterator, List, Optional, Tuple
6
+ import argparse
7
+ import datetime
8
+ import sys
9
+
10
+ from plexapi.playlist import Playlist
11
+ from plexapi.server import PlexServer
12
+ from plexapi.video import Episode, Movie, Show
13
+
14
+ from plexdo.accounts import server_for_user
15
+ from plexdo.airdates import episodes_in_same_season, prompt_for_date, resolve_episode_date
16
+ from plexdo.constants import LOG, MediaItem
17
+ from plexdo.convert import parse_date
18
+ from plexdo.m3u import write_m3u
19
+ from plexdo.paths import add_prefix_argument, mapper_for
20
+ from plexdo.playlists import finalize_playlist, resolve_playlist
21
+ from plexdo.titles import fetch_show, non_special_episodes, shuffle_list
22
+
23
+
24
+ def _round_robin(episode_lists: List[List[Episode]]) -> Iterator[Episode]:
25
+ """Yield episodes in round-robin order across multiple lists."""
26
+ queues = [list(eps) for eps in episode_lists if eps]
27
+ while queues:
28
+ exhausted = []
29
+ for queue in queues:
30
+ if queue:
31
+ yield queue.pop(0)
32
+ if not queue:
33
+ exhausted.append(queue)
34
+ for q in exhausted:
35
+ queues.remove(q)
36
+
37
+
38
+ def cmd_build_interleaved(plex: PlexServer, args: argparse.Namespace) -> None:
39
+ """Build a round-robin interleaved playlist from multiple shows."""
40
+ rating_keys = [int(k) for k in args.rating_keys]
41
+ episode_lists: List[List[Episode]] = []
42
+
43
+ for rk in rating_keys:
44
+ show = fetch_show(plex, rk)
45
+ eps = non_special_episodes(show)
46
+ LOG.info("Show '%s': %d episodes", show.title, len(eps))
47
+ episode_lists.append(eps)
48
+
49
+ items: List[MediaItem] = list(_round_robin(episode_lists))
50
+ finalize_playlist(plex, args.name, items, args)
51
+
52
+ if args.m3u:
53
+ write_m3u(items, args.m3u, mapper_for(plex, args))
54
+
55
+
56
+ def _chronological_sort_key(
57
+ item_date: Tuple[MediaItem, datetime.datetime]
58
+ ) -> datetime.datetime:
59
+ return item_date[1]
60
+
61
+
62
+ def _build_chronological_items(
63
+ plex: PlexServer,
64
+ rating_keys: List[int],
65
+ ) -> List[Tuple[MediaItem, datetime.datetime]]:
66
+ """Build (item, resolved_datetime) pairs for all given shows/movies."""
67
+ dated_items: List[Tuple[MediaItem, datetime.datetime]] = []
68
+ last_used_date: Optional[datetime.datetime] = None
69
+
70
+ for rk in rating_keys:
71
+ media_item = plex.fetchItem(rk)
72
+ LOG.debug("Processing ratingKey=%d type=%s", rk, type(media_item).__name__)
73
+
74
+ if isinstance(media_item, Show):
75
+ all_eps = non_special_episodes(media_item)
76
+ for ep in all_eps:
77
+ season_peers = episodes_in_same_season(ep, all_eps)
78
+ resolved = resolve_episode_date(ep, season_peers, last_used_date)
79
+ last_used_date = resolved
80
+ dated_items.append((ep, resolved))
81
+
82
+ elif isinstance(media_item, Movie):
83
+ dt = parse_date(media_item.originallyAvailableAt)
84
+ if dt is None:
85
+ dt = prompt_for_date(media_item, last_used_date) # type: ignore[arg-type]
86
+ last_used_date = dt
87
+ dated_items.append((media_item, dt))
88
+
89
+ else:
90
+ sys.exit(
91
+ f"ratingKey {rk} is type '{type(media_item).__name__}' "
92
+ "- only Show and Movie are supported."
93
+ )
94
+
95
+ return dated_items
96
+
97
+
98
+ def cmd_build_chronological(plex: PlexServer, args: argparse.Namespace) -> None:
99
+ """Build a date-sorted playlist from shows and/or movies."""
100
+ rating_keys = [int(k) for k in args.rating_keys]
101
+ dated_items = _build_chronological_items(plex, rating_keys)
102
+
103
+ dated_items.sort(key=_chronological_sort_key)
104
+ items: List[MediaItem] = [item for item, _ in dated_items]
105
+
106
+ finalize_playlist(plex, args.name, items, args)
107
+
108
+ if args.m3u:
109
+ write_m3u(items, args.m3u, mapper_for(plex, args))
110
+
111
+
112
+ def cmd_build_randomize(plex: PlexServer, args: argparse.Namespace) -> None:
113
+ """Randomize a playlist and save to a new destination playlist."""
114
+ user_plex = server_for_user(plex, args.user_id)
115
+ src: Playlist = resolve_playlist(user_plex, args.source)
116
+
117
+ all_items: List[MediaItem] = list(src.items())
118
+ randomized: List[MediaItem] = shuffle_list(all_items)
119
+
120
+ LOG.info("Randomized %d items", len(randomized))
121
+ finalize_playlist(user_plex, args.dest, randomized, args)
122
+
123
+ if args.m3u:
124
+ write_m3u(randomized, args.m3u, mapper_for(user_plex, args))
125
+
126
+
127
+ def register(
128
+ sub: "argparse._SubParsersAction",
129
+ parents: "List[argparse.ArgumentParser]",
130
+ ) -> None:
131
+ """Register the playlist building subparsers."""
132
+ p_bi = sub.add_parser(
133
+ "build-interleaved", parents=parents,
134
+ help="Round-robin interleaved playlist from multiple shows.",
135
+ )
136
+ p_bi.add_argument("name", help="Name for the new playlist (str).")
137
+ p_bi.add_argument(
138
+ "rating_keys", nargs="+", type=int, metavar="ratingKey",
139
+ help="One or more Show ratingKeys (int). Obtain with list-titles.",
140
+ )
141
+ p_bi.add_argument(
142
+ "-o", "--overwrite", action="store_true", default=False,
143
+ help="Replace an existing playlist of the same name instead of failing.",
144
+ )
145
+ p_bi.add_argument(
146
+ "--m3u", metavar="PATH",
147
+ help="Also export an M3U file at PATH using Plex server filesystem paths.",
148
+ )
149
+ add_prefix_argument(p_bi)
150
+
151
+ p_bc = sub.add_parser(
152
+ "build-chronological", parents=parents,
153
+ help="Date-sorted playlist from shows and/or movies.",
154
+ )
155
+ p_bc.add_argument("name", help="Name for the new playlist (str).")
156
+ p_bc.add_argument(
157
+ "rating_keys", nargs="+", type=int, metavar="ratingKey",
158
+ help="One or more Show/Movie ratingKeys (int). Obtain with list-titles.",
159
+ )
160
+ p_bc.add_argument(
161
+ "-o", "--overwrite", action="store_true", default=False,
162
+ help="Replace an existing playlist of the same name instead of failing.",
163
+ )
164
+ p_bc.add_argument(
165
+ "--m3u", metavar="PATH",
166
+ help="Also export an M3U file at PATH using Plex server filesystem paths.",
167
+ )
168
+ add_prefix_argument(p_bc)
169
+
170
+ p_br = sub.add_parser(
171
+ "build-randomize", parents=parents,
172
+ help="Randomize a source playlist into a new destination playlist.",
173
+ )
174
+ p_br.add_argument("user_id", metavar="USER", help="User ID (int) or user title (str); use 0 for the admin account. Obtain both with list-users.")
175
+ p_br.add_argument("source", help="Playlist name (str) or ratingKey (int). Obtain either with list-playlists.")
176
+ p_br.add_argument("dest", help="Destination playlist title (str).")
177
+ p_br.add_argument(
178
+ "-o", "--overwrite", action="store_true", default=False,
179
+ help="Replace an existing playlist of the same name instead of failing.",
180
+ )
181
+ p_br.add_argument(
182
+ "--m3u", metavar="PATH",
183
+ help="Also export an M3U file at PATH using Plex server filesystem paths.",
184
+ )
185
+ add_prefix_argument(p_br)
186
+
187
+
188
+ COMMANDS = {
189
+ "build-interleaved": cmd_build_interleaved,
190
+ "build-chronological": cmd_build_chronological,
191
+ "build-randomize": cmd_build_randomize,
192
+ }
193
+
194
+ REQUIRES_PLEX = frozenset(COMMANDS)
@@ -0,0 +1,169 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ """Playlist copying commands."""
4
+
5
+ from typing import Any, Dict, List
6
+ import argparse
7
+ import sys
8
+
9
+ from plexapi.myplex import MyPlexUser
10
+ from plexapi.playlist import Playlist
11
+ from plexapi.server import PlexServer
12
+
13
+ from plexdo.accounts import UserAccessError, server_for_user
14
+ from plexdo.console import clean_text, output, output_format, print_table, table_limit
15
+ from plexdo.constants import LOG, MediaItem
16
+ from plexdo.playlists import copy_playlist_to, preview_rows, resolve_playlist
17
+ from plexdo.throttle import paced
18
+
19
+
20
+ def _copy_to_one_user(
21
+ plex: PlexServer,
22
+ user: MyPlexUser,
23
+ src_items: List[MediaItem],
24
+ args: argparse.Namespace,
25
+ ) -> Dict[str, Any]:
26
+ """Copy to a single user, converting any failure into a result record."""
27
+ user_id = int(user.id)
28
+ label = f"user {user.title!r} (id={user_id})"
29
+ try:
30
+ user_plex = server_for_user(plex, user_id)
31
+ status, final_name, detail = copy_playlist_to(
32
+ src_items, user_plex, args.source_playlist, args,
33
+ target_label=label, preview=False,
34
+ )
35
+ except UserAccessError as exc:
36
+ status, final_name, detail = "skipped", "", exc.summary
37
+ except Exception as exc: # pylint: disable=broad-except
38
+ # One user's failure must not end the run over the remaining users.
39
+ LOG.debug("Copy failed for %s: %s", label, exc)
40
+ status, final_name, detail = "failed", "", str(exc)
41
+ return {
42
+ "user": clean_text(user.title or ""),
43
+ "id": user_id,
44
+ "status": status,
45
+ "playlist": final_name,
46
+ "detail": detail,
47
+ }
48
+
49
+
50
+ def _report_line(record: Dict[str, Any]) -> str:
51
+ """Format one per-user outcome as a single line."""
52
+ line = f" {record['status']:<8} {record['user']}"
53
+ extra = record["detail"] or (
54
+ record["playlist"] if record["playlist"] else ""
55
+ )
56
+ return f"{line} ({extra})" if extra else line
57
+
58
+
59
+ def cmd_copy_playlist_all_users(plex: PlexServer, args: argparse.Namespace) -> None:
60
+ """Copy a playlist from any user to all managed users."""
61
+ src_plex = server_for_user(plex, args.source_user_id)
62
+ src: Playlist = resolve_playlist(src_plex, args.source_playlist)
63
+ src_items: List[MediaItem] = list(src.items())
64
+ if not src_items:
65
+ sys.exit(f"Source playlist {src.title!r} is empty - nothing to copy.")
66
+
67
+ # The item list is identical for every user, so it is shown once here and
68
+ # suppressed per user; each user then costs a single line of output.
69
+ table_mode = output_format(args) == "table"
70
+ if table_mode:
71
+ print(f"{src.title} ({len(src_items)} items)")
72
+ print_table(preview_rows(src_items), table_limit(args))
73
+ print()
74
+
75
+ results: List[Dict[str, Any]] = []
76
+ for user in paced(list(plex.myPlexAccount().users()), args, "users"):
77
+ user_id = int(user.id)
78
+ if user_id == args.source_user_id:
79
+ record = {
80
+ "user": clean_text(user.title or ""), "id": user_id,
81
+ "status": "skipped", "playlist": "", "detail": "source user",
82
+ }
83
+ else:
84
+ record = _copy_to_one_user(plex, user, src_items, args)
85
+ results.append(record)
86
+ if table_mode:
87
+ # Printed as each user completes, so a long run shows progress.
88
+ print(_report_line(record))
89
+
90
+ if not table_mode:
91
+ output(results, args)
92
+
93
+
94
+ def cmd_copy_playlist_to_user(plex: PlexServer, args: argparse.Namespace) -> None:
95
+ """Copy a playlist from any user to a specific user."""
96
+ src_plex = server_for_user(plex, args.source_user_id)
97
+ src: Playlist = resolve_playlist(src_plex, args.source_playlist)
98
+
99
+ src_items: List[MediaItem] = list(src.items())
100
+ user_plex = server_for_user(plex, args.user_id)
101
+ status, final_name, detail = copy_playlist_to(
102
+ src_items, user_plex, args.dest, args,
103
+ target_label=f"user id={args.user_id}",
104
+ )
105
+ record = {
106
+ "user": str(args.user_id), "id": args.user_id,
107
+ "status": status, "playlist": final_name, "detail": detail,
108
+ }
109
+ if output_format(args) == "table":
110
+ print(_report_line(record))
111
+ else:
112
+ output(record, args)
113
+
114
+
115
+ _SRC_UID_HELP = (
116
+ "Source user ID (int) or user title (str); use 0 for the admin "
117
+ "account. Obtain both with list-users."
118
+ )
119
+ _OVERWRITE_HELP = (
120
+ "Overwrite an existing playlist of the same name on the destination "
121
+ "instead of appending ' admin copy'."
122
+ )
123
+
124
+
125
+ def register(
126
+ sub: "argparse._SubParsersAction",
127
+ parents: "List[argparse.ArgumentParser]",
128
+ ) -> None:
129
+ """Register the playlist copying subparsers."""
130
+ playlist_help = (
131
+ "Playlist name (str) or ratingKey (int). Obtain either with "
132
+ "list-playlists."
133
+ )
134
+ uid_help = (
135
+ "User ID (int) or user title (str); use 0 for the admin account. "
136
+ "Obtain both with list-users."
137
+ )
138
+
139
+ p_all = sub.add_parser(
140
+ "copy-playlist-all-users", parents=parents,
141
+ help="Copy a playlist from any user to all managed users.",
142
+ )
143
+ p_all.add_argument("source_user_id", metavar="USER", help=_SRC_UID_HELP)
144
+ p_all.add_argument("source_playlist", help="Source. " + playlist_help)
145
+ p_all.add_argument(
146
+ "-o", "--overwrite", action="store_true", default=False,
147
+ help=_OVERWRITE_HELP,
148
+ )
149
+
150
+ p_one = sub.add_parser(
151
+ "copy-playlist-to-user", parents=parents,
152
+ help="Copy a playlist from any user to a specific user.",
153
+ )
154
+ p_one.add_argument("source_user_id", metavar="USER", help=_SRC_UID_HELP)
155
+ p_one.add_argument("source_playlist", help="Source. " + playlist_help)
156
+ p_one.add_argument("user_id", metavar="USER", help="Target " + uid_help)
157
+ p_one.add_argument("dest", help="Destination playlist title (str).")
158
+ p_one.add_argument(
159
+ "-o", "--overwrite", action="store_true", default=False,
160
+ help=_OVERWRITE_HELP,
161
+ )
162
+
163
+
164
+ COMMANDS = {
165
+ "copy-playlist-all-users": cmd_copy_playlist_all_users,
166
+ "copy-playlist-to-user": cmd_copy_playlist_to_user,
167
+ }
168
+
169
+ REQUIRES_PLEX = frozenset(COMMANDS)