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.
- plexdo/__init__.py +26 -0
- plexdo/__main__.py +24 -0
- plexdo/accounts.py +253 -0
- plexdo/airdates.py +136 -0
- plexdo/cache.py +38 -0
- plexdo/cli.py +157 -0
- plexdo/commands/__init__.py +63 -0
- plexdo/commands/auth.py +201 -0
- plexdo/commands/build.py +194 -0
- plexdo/commands/copy.py +169 -0
- plexdo/commands/libraries.py +231 -0
- plexdo/commands/metadata.py +135 -0
- plexdo/commands/missing.py +234 -0
- plexdo/commands/playlists.py +174 -0
- plexdo/commands/rescan.py +95 -0
- plexdo/commands/search.py +114 -0
- plexdo/commands/status.py +245 -0
- plexdo/commands/stream.py +138 -0
- plexdo/commands/users.py +39 -0
- plexdo/commands/watched.py +295 -0
- plexdo/config.py +153 -0
- plexdo/console.py +240 -0
- plexdo/constants.py +111 -0
- plexdo/convert.py +38 -0
- plexdo/data/_plexdo +359 -0
- plexdo/data/plexdo.1 +571 -0
- plexdo/data/plexdo.bash +597 -0
- plexdo/data/plexdo.fish +317 -0
- plexdo/data/plexdo.ps1 +371 -0
- plexdo/formats.py +181 -0
- plexdo/gallery.py +190 -0
- plexdo/identify.py +75 -0
- plexdo/logs.py +21 -0
- plexdo/m3u.py +34 -0
- plexdo/paths.py +105 -0
- plexdo/photos.py +74 -0
- plexdo/playlists.py +174 -0
- plexdo/py.typed +0 -0
- plexdo/records.py +136 -0
- plexdo/sections.py +58 -0
- plexdo/security.py +65 -0
- plexdo/sorting.py +50 -0
- plexdo/throttle.py +59 -0
- plexdo/titles.py +53 -0
- plexdo/tokens.py +88 -0
- plexdo-1.1.18.dist-info/METADATA +601 -0
- plexdo-1.1.18.dist-info/RECORD +51 -0
- plexdo-1.1.18.dist-info/WHEEL +5 -0
- plexdo-1.1.18.dist-info/entry_points.txt +2 -0
- plexdo-1.1.18.dist-info/licenses/LICENSE +232 -0
- 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)
|
plexdo/commands/auth.py
ADDED
|
@@ -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()
|
plexdo/commands/build.py
ADDED
|
@@ -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)
|
plexdo/commands/copy.py
ADDED
|
@@ -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)
|