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
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)
|