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,138 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ """Media streaming to stdout."""
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ import textwrap
9
+
10
+ import requests
11
+ from plexapi.server import PlexServer
12
+
13
+ from plexdo.console import output_format
14
+ from plexdo.constants import LOG
15
+ from plexdo.titles import display_title, fetch_item
16
+
17
+
18
+ def _stream_to_stdout(url: str, label: str) -> None:
19
+ """Stream a URL to binary stdout, handling a closed pipe gracefully."""
20
+ if sys.stdout.isatty():
21
+ LOG.warning(
22
+ "stdout is a terminal - pipe into a player, e.g.: "
23
+ "plexdo read <lib> <key> | mpv -"
24
+ )
25
+ LOG.info("Streaming: %s", label)
26
+ try:
27
+ with requests.get(url, stream=True, timeout=30) as resp:
28
+ resp.raise_for_status()
29
+ for chunk in resp.iter_content(chunk_size=65536):
30
+ sys.stdout.buffer.write(chunk)
31
+ sys.stdout.buffer.flush()
32
+ except BrokenPipeError:
33
+ # Downstream player exited (e.g. user quit mpv) - not an error.
34
+ # Point stdout at the null device so the interpreter's final flush
35
+ # does not print "BrokenPipeError ignored" to stderr on exit.
36
+ LOG.debug("Downstream closed pipe; stream ended.")
37
+ try:
38
+ os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
39
+ except OSError:
40
+ pass
41
+ except requests.HTTPError as exc:
42
+ sys.exit(f"HTTP error while streaming: {exc}")
43
+
44
+
45
+ def cmd_read(plex: PlexServer, args: argparse.Namespace) -> None:
46
+ """Stream a media file to stdout by library ID and ratingKey."""
47
+ if output_format(args) != "table":
48
+ LOG.warning(
49
+ "Output format flags have no effect on the read command; "
50
+ "binary media data goes to stdout."
51
+ )
52
+
53
+ rating_key = int(args.rating_key)
54
+ item = fetch_item(plex, rating_key)
55
+
56
+ item_lib_id = int(getattr(item, "librarySectionID", 0))
57
+ if item_lib_id != args.library_id:
58
+ sys.exit(
59
+ f"ratingKey {rating_key} belongs to library {item_lib_id}, "
60
+ f"not library {args.library_id}."
61
+ )
62
+
63
+ if not getattr(item, "media", None):
64
+ sys.exit(f"No media attached to ratingKey: {rating_key}")
65
+
66
+ parts = item.media[0].parts
67
+ if not parts:
68
+ sys.exit(f"No parts found for ratingKey: {rating_key}")
69
+
70
+ if len(item.media) > 1 or len(parts) > 1:
71
+ LOG.warning(
72
+ "Multiple media/parts found; streaming first part only. "
73
+ "Use list-show or show-metadata to inspect."
74
+ )
75
+
76
+ part = parts[0]
77
+ label = display_title(item)
78
+ url = plex.url(part.key, includeToken=True)
79
+
80
+ LOG.info("File : %s", part.file or "(no server path)")
81
+ LOG.info("URL : %s", url)
82
+
83
+ if args.dry_run:
84
+ print(f"title : {label}", file=sys.stderr)
85
+ print(f"file : {part.file or '(none)'}", file=sys.stderr)
86
+ print(f"url : {url}", file=sys.stderr)
87
+ return
88
+
89
+ _stream_to_stdout(url, label)
90
+
91
+
92
+ def register(
93
+ sub: "argparse._SubParsersAction",
94
+ parents: "List[argparse.ArgumentParser]",
95
+ ) -> None:
96
+ """Register the read subparser."""
97
+ parser = sub.add_parser(
98
+ "read", parents=parents,
99
+ help=(
100
+ "Stream a media file to stdout. Pipe into a player: "
101
+ "plexdo read <lib> <key> | mpv -"
102
+ ),
103
+ description=textwrap.fill(
104
+ "Stream a media file to standard output, for piping into a player "
105
+ "or redirecting to a file. Quitting the player early is not an "
106
+ "error.",
107
+ width=78,
108
+ ),
109
+ epilog=(
110
+ "examples:\n"
111
+ " plexdo read 3 12345 > episode.mkv\n"
112
+ " plexdo read 3 12345 | mpv -\n"
113
+ "\n"
114
+ "On Linux with a display attached but no desktop environment, mpv\n"
115
+ "can draw straight to the console through the kernel modesetting\n"
116
+ "driver, so playback needs neither X nor Wayland:\n"
117
+ "\n"
118
+ " plexdo read 3 12345 | mpv --vo=drm -\n"
119
+ "\n"
120
+ "Run it from a virtual terminal rather than a terminal emulator,\n"
121
+ "and make sure your user can reach the DRM device (usually via the\n"
122
+ "video group)."
123
+ ),
124
+ formatter_class=argparse.RawDescriptionHelpFormatter,
125
+ )
126
+ parser.add_argument(
127
+ "library_id", metavar="LIBRARY",
128
+ help="Library ID (int) or library title (str). Obtain both with list-libraries.",
129
+ )
130
+ parser.add_argument(
131
+ "rating_key", type=int,
132
+ help="Item ratingKey (int). Obtain with list-titles or list-show.",
133
+ )
134
+
135
+
136
+ COMMANDS = {"read": cmd_read}
137
+
138
+ REQUIRES_PLEX = frozenset(COMMANDS)
@@ -0,0 +1,39 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ """User listing command."""
4
+
5
+ import argparse
6
+
7
+ from plexapi.server import PlexServer
8
+
9
+ from plexdo.accounts import account_type
10
+ from plexdo.cache import write_cache
11
+ from plexdo.console import output
12
+
13
+
14
+ def cmd_list_users(plex: PlexServer, args: argparse.Namespace) -> None:
15
+ """List managed/home users visible to the admin token."""
16
+ account = plex.myPlexAccount()
17
+ rows = [
18
+ {
19
+ "id": int(u.id),
20
+ "type": account_type(u),
21
+ "title": u.title,
22
+ }
23
+ for u in account.users()
24
+ ]
25
+ write_cache("users", rows)
26
+ output(rows, args)
27
+
28
+
29
+ def register(
30
+ sub: "argparse._SubParsersAction",
31
+ parents: "List[argparse.ArgumentParser]",
32
+ ) -> None:
33
+ """Register the user listing subparser."""
34
+ sub.add_parser("list-users", parents=parents, help="List all managed/home users (id, type, title).")
35
+
36
+
37
+ COMMANDS = {"list-users": cmd_list_users}
38
+
39
+ REQUIRES_PLEX = frozenset(COMMANDS)
@@ -0,0 +1,295 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ """Watched-state synchronisation between two users."""
4
+
5
+ from typing import Any, Dict, List, NamedTuple, Optional, Sequence, Tuple
6
+ import argparse
7
+ import datetime
8
+ import sys
9
+
10
+ from plexapi.server import PlexServer
11
+
12
+ from plexdo.accounts import server_for_user
13
+ from plexdo.console import output, output_format
14
+ from plexdo.constants import LOG
15
+ from plexdo.convert import parse_date
16
+ from plexdo.sections import resolve_sections
17
+ from plexdo.throttle import paced
18
+ from plexdo.titles import display_title, fetch_item
19
+
20
+
21
+ # Library type -> the leaf libtype that actually carries watch state.
22
+ # Photo libraries have none and are skipped entirely.
23
+ _WATCHABLE_LIBTYPES = {"movie": "movie", "show": "episode", "artist": "track"}
24
+
25
+
26
+ # Plex rewrites viewOffset continuously during playback, so offsets within
27
+ # this many milliseconds are treated as identical rather than as a change.
28
+ _OFFSET_TOLERANCE_MS = 10_000
29
+
30
+
31
+ class WatchState(NamedTuple):
32
+ """One item's watch progress as seen by one user."""
33
+
34
+ played: bool
35
+ offset: int
36
+ last_viewed: Optional[datetime.datetime]
37
+ item: Any
38
+
39
+
40
+ class PlannedChange(NamedTuple):
41
+ """A single pending watch-state update."""
42
+
43
+ rating_key: int
44
+ title: str
45
+ action: str
46
+ target: str
47
+ winner: WatchState
48
+ loser: WatchState
49
+
50
+
51
+ def _call_first_method(item: Any, names: Sequence[str], *args: Any) -> bool:
52
+ """Call the first method in names that exists; return True if one ran.
53
+
54
+ plexapi renamed these (markWatched -> markPlayed, etc.), so both
55
+ spellings are attempted for compatibility across library versions.
56
+ """
57
+ for name in names:
58
+ method = getattr(item, name, None)
59
+ if callable(method):
60
+ method(*args)
61
+ return True
62
+ LOG.debug("None of %s available on %r", list(names), type(item).__name__)
63
+ return False
64
+
65
+
66
+ def _item_is_played(item: Any) -> bool:
67
+ """Return True if the item is marked fully played for this user."""
68
+ for attr in ("isPlayed", "isWatched"):
69
+ value = getattr(item, attr, None)
70
+ if isinstance(value, bool):
71
+ return value
72
+ return bool(getattr(item, "viewCount", 0))
73
+
74
+
75
+ def _watch_state(item: Any) -> WatchState:
76
+ """Capture the watch state of a single item."""
77
+ return WatchState(
78
+ played=_item_is_played(item),
79
+ offset=int(getattr(item, "viewOffset", 0) or 0),
80
+ last_viewed=parse_date(getattr(item, "lastViewedAt", None)),
81
+ item=item,
82
+ )
83
+
84
+
85
+ def _has_watch_data(state: WatchState) -> bool:
86
+ """Return True if the user has played or partially played the item."""
87
+ return state.played or state.offset > 0
88
+
89
+
90
+ def _states_match(first: WatchState, second: WatchState) -> bool:
91
+ """Return True if two states are close enough to need no update."""
92
+ if first.played != second.played:
93
+ return False
94
+ if first.played:
95
+ return True
96
+ return abs(first.offset - second.offset) <= _OFFSET_TOLERANCE_MS
97
+
98
+
99
+ def _viewed_sort_key(
100
+ state: WatchState, prefer_earliest: bool
101
+ ) -> datetime.datetime:
102
+ """Timestamp used to pick a winner; undated states never win."""
103
+ if state.last_viewed is not None:
104
+ return state.last_viewed
105
+ return datetime.datetime.max if prefer_earliest else datetime.datetime.min
106
+
107
+
108
+ def _select_winner(
109
+ first: WatchState, second: WatchState, unwatch: bool
110
+ ) -> Optional[Tuple[WatchState, WatchState]]:
111
+ """Return (winner, loser), or None when no update is needed."""
112
+ first_data, second_data = _has_watch_data(first), _has_watch_data(second)
113
+ if not (first_data or second_data) or _states_match(first, second):
114
+ return None
115
+
116
+ if first_data != second_data:
117
+ # Exactly one side has data. The default sync propagates the watched
118
+ # state outward; --unwatch propagates the unwatched state instead.
119
+ first_wins = first_data == (not unwatch)
120
+ return (first, second) if first_wins else (second, first)
121
+
122
+ first_key = _viewed_sort_key(first, unwatch)
123
+ second_key = _viewed_sort_key(second, unwatch)
124
+ first_wins = (
125
+ first_key <= second_key if unwatch else first_key >= second_key
126
+ )
127
+ return (first, second) if first_wins else (second, first)
128
+
129
+
130
+ def _planned_action(winner: WatchState) -> str:
131
+ """Return the action label needed to adopt the winner's state."""
132
+ if winner.played:
133
+ return "markPlayed"
134
+ if winner.offset > 0:
135
+ return "setProgress"
136
+ return "markUnplayed"
137
+
138
+
139
+ def _apply_action(change: PlannedChange) -> None:
140
+ """Perform one planned watch-state change on the target user's item."""
141
+ item = change.loser.item
142
+ if change.action == "markPlayed":
143
+ _call_first_method(item, ("markPlayed", "markWatched"))
144
+ return
145
+ if change.loser.played:
146
+ _call_first_method(item, ("markUnplayed", "markUnwatched"))
147
+ if change.action == "setProgress":
148
+ _call_first_method(item, ("updateProgress",), change.winner.offset)
149
+
150
+
151
+ def _watchable_sections(user_plex: PlexServer, library_id: Optional[int]) -> List[Any]:
152
+ """Return the library sections that carry watch state."""
153
+ sections = resolve_sections(user_plex, library_id)
154
+ return [s for s in sections if s.type in _WATCHABLE_LIBTYPES]
155
+
156
+
157
+ def _collect_watch_states(
158
+ user_plex: PlexServer, library_id: Optional[int], rating_key: Optional[int]
159
+ ) -> Dict[int, WatchState]:
160
+ """Return ratingKey -> WatchState for one user, honouring the filters."""
161
+ states: Dict[int, WatchState] = {}
162
+
163
+ if rating_key is not None:
164
+ item = fetch_item(user_plex, rating_key)
165
+ states[int(item.ratingKey)] = _watch_state(item)
166
+ return states
167
+
168
+ for section in _watchable_sections(user_plex, library_id):
169
+ libtype = _WATCHABLE_LIBTYPES[section.type]
170
+ # section.all(libtype=...) hits the plain listing endpoint; the search
171
+ # endpoint needs a non-empty query and would silently return nothing.
172
+ for item in section.all(libtype=libtype):
173
+ states[int(item.ratingKey)] = _watch_state(item)
174
+ return states
175
+
176
+
177
+ def _build_sync_plan(
178
+ states_a: Dict[int, WatchState],
179
+ states_b: Dict[int, WatchState],
180
+ args: argparse.Namespace,
181
+ ) -> List[PlannedChange]:
182
+ """Compare both users' states and return the changes required."""
183
+ plan: List[PlannedChange] = []
184
+ for rating_key in sorted(set(states_a) & set(states_b)):
185
+ first, second = states_a[rating_key], states_b[rating_key]
186
+ selected = _select_winner(first, second, args.unwatch)
187
+ if selected is None:
188
+ continue
189
+ winner, loser = selected
190
+ if args.one_way and loser is first:
191
+ LOG.debug("--one-way: skipping write to first user for %d", rating_key)
192
+ continue
193
+ plan.append(PlannedChange(
194
+ rating_key=rating_key,
195
+ title=display_title(loser.item),
196
+ action=_planned_action(winner),
197
+ target="a" if loser is first else "b",
198
+ winner=winner,
199
+ loser=loser,
200
+ ))
201
+ return plan
202
+
203
+
204
+ def _plan_rows(
205
+ plan: List[PlannedChange], args: argparse.Namespace
206
+ ) -> List[Dict[str, Any]]:
207
+ """Build display rows describing the planned changes."""
208
+ labels = {"a": f"user {args.user_a}", "b": f"user {args.user_b}"}
209
+ return [
210
+ {
211
+ "ratingKey": change.rating_key,
212
+ "title": change.title,
213
+ "action": change.action,
214
+ "target": labels[change.target],
215
+ }
216
+ for change in plan
217
+ ]
218
+
219
+
220
+ def cmd_copy_watched(plex: PlexServer, args: argparse.Namespace) -> None:
221
+ """Synchronise watched state and resume points between two users."""
222
+ if args.user_a == args.user_b:
223
+ sys.exit("The two user IDs must differ.")
224
+
225
+ states_a = _collect_watch_states(
226
+ server_for_user(plex, args.user_a), args.library_id, args.rating_key
227
+ )
228
+ states_b = _collect_watch_states(
229
+ server_for_user(plex, args.user_b), args.library_id, args.rating_key
230
+ )
231
+ LOG.info(
232
+ "Collected %d item(s) for user %d and %d for user %d",
233
+ len(states_a), args.user_a, len(states_b), args.user_b,
234
+ )
235
+
236
+ plan = _build_sync_plan(states_a, states_b, args)
237
+ if not plan:
238
+ LOG.info("Watch state already in sync.")
239
+ if output_format(args) == "table":
240
+ print("Watch state already in sync.", file=sys.stderr)
241
+ else:
242
+ output([], args)
243
+ return
244
+
245
+ output(_plan_rows(plan, args), args)
246
+
247
+ if args.dry_run:
248
+ LOG.info("--dry-run: no watch state was changed.")
249
+ return
250
+
251
+ for change in paced(plan, args, "updates"):
252
+ _apply_action(change)
253
+ LOG.info("Applied %d change(s).", len(plan))
254
+
255
+
256
+ def register(
257
+ sub: "argparse._SubParsersAction",
258
+ parents: "List[argparse.ArgumentParser]",
259
+ ) -> None:
260
+ """Register the copy-watched subparser."""
261
+ uid_help = (
262
+ "user ID (int) or user title (str); use 0 for the admin account. "
263
+ "Obtain both with list-users."
264
+ )
265
+ parser = sub.add_parser(
266
+ "copy-watched", parents=parents,
267
+ help="Synchronise watched state and resume points between two users.",
268
+ )
269
+ parser.add_argument("user_a", metavar="USER", help=f"First {uid_help}")
270
+ parser.add_argument("user_b", metavar="USER", help=f"Second {uid_help}")
271
+ parser.add_argument(
272
+ "-1", "--one-way", dest="one_way", action="store_true", default=False,
273
+ help="Only write to the second user; never modify the first.",
274
+ )
275
+ parser.add_argument(
276
+ "-l", "--library", dest="library_id", default=None, metavar="LIBRARY",
277
+ help="Restrict to one library, by ID or title. Obtain both with list-libraries.",
278
+ )
279
+ parser.add_argument(
280
+ "-t", "--title", dest="rating_key", type=int, default=None, metavar="KEY",
281
+ help="Restrict to a single item ratingKey. Obtain with list-titles or search.",
282
+ )
283
+ parser.add_argument(
284
+ "--unwatch", action="store_true", default=False,
285
+ help=(
286
+ "Invert the sync: if either user has an item unwatched, mark it "
287
+ "unwatched for both; if both have progress, the earliest "
288
+ "lastViewedAt wins instead of the most recent."
289
+ ),
290
+ )
291
+
292
+
293
+ COMMANDS = {"copy-watched": cmd_copy_watched}
294
+
295
+ REQUIRES_PLEX = frozenset(COMMANDS)
plexdo/config.py ADDED
@@ -0,0 +1,153 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ """Config file loading, token reading, and permission checks."""
4
+
5
+ from functools import lru_cache
6
+ from pathlib import Path
7
+ from typing import Optional
8
+ import configparser
9
+ import os
10
+ import re
11
+ import sys
12
+
13
+ from plexapi.server import PlexServer
14
+
15
+ from plexdo.constants import CONFIG_PATH, LOG, PERMISSIVE_MODE_MASK
16
+ from plexdo.tokens import admin_token
17
+
18
+
19
+ def check_file_permissions(path: Path, label: str) -> None:
20
+ """Warn if a secret-bearing file is readable by group or other.
21
+
22
+ Emitted at WARNING level so it surfaces without --verbose, and on stderr
23
+ so it can never contaminate --json output.
24
+ """
25
+ if os.name == "nt":
26
+ # Windows has no POSIX mode bits; os.stat synthesises 0o666, which
27
+ # would trip this check on every run with advice (chmod) that cannot
28
+ # help. Access there is governed by ACLs instead.
29
+ LOG.debug("Skipping POSIX permission check for %s on Windows", path)
30
+ return
31
+ try:
32
+ mode = path.stat().st_mode
33
+ except OSError:
34
+ return
35
+ leaked = mode & PERMISSIVE_MODE_MASK
36
+ if leaked:
37
+ LOG.warning(
38
+ "SECURITY: %s at %s has mode %04o - readable by %s. "
39
+ "Tighten it with: chmod 600 %s",
40
+ label, path, mode & 0o7777,
41
+ "group and others" if leaked & 0o007 else "group",
42
+ path,
43
+ )
44
+
45
+
46
+ def _expand_environment(cfg: configparser.ConfigParser) -> None:
47
+ """Expand environment variables in every [plex] value, in place.
48
+
49
+ os.path.expandvars follows the platform: $VAR and ${VAR} everywhere, plus
50
+ %VAR% on Windows. It leaves an unset variable as literal text, which would
51
+ surface later as a baffling "no such file" for a path like
52
+ "$XDG_RUNTIME_DIR/.plex.token", so an unresolved name is warned about here
53
+ in whichever spelling the platform accepts.
54
+ """
55
+ if not cfg.has_section("plex"):
56
+ return
57
+ for key, raw in cfg.items("plex"):
58
+ expanded = os.path.expandvars(raw)
59
+ if expanded != raw:
60
+ LOG.debug("Expanded environment variables in [plex] %s", key)
61
+ unresolved = re.findall(r"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?", expanded)
62
+ if os.name == "nt":
63
+ unresolved += re.findall(r"%([A-Za-z_][A-Za-z0-9_]*)%", expanded)
64
+ for name in unresolved:
65
+ LOG.warning(
66
+ "[plex] %s references the environment variable %s, which is "
67
+ "not set; leaving it literal. Set it, or use an absolute path "
68
+ "in %s.",
69
+ key, name, CONFIG_PATH,
70
+ )
71
+ cfg.set("plex", key, expanded)
72
+
73
+
74
+ def section_optional(
75
+ cfg: configparser.ConfigParser, section: str, key: str
76
+ ) -> Optional[str]:
77
+ """Return a stripped optional value from any section, or None."""
78
+ value = cfg.get(section, key, fallback=None)
79
+ if value is None:
80
+ return None
81
+ value = value.strip()
82
+ return value or None
83
+
84
+
85
+ @lru_cache(maxsize=1)
86
+ def cached_config() -> configparser.ConfigParser:
87
+ """Return the parsed config, reading the file at most once per process.
88
+
89
+ Memoised so the fallback login path can reach the per-user sections
90
+ without re-reading the file, and re-emitting its permission warning, once
91
+ per user in a loop.
92
+ """
93
+ return load_config()
94
+
95
+
96
+ def config_optional(cfg: configparser.ConfigParser, key: str) -> Optional[str]:
97
+ """Return a stripped optional value from the [plex] section, or None."""
98
+ value = cfg.get("plex", key, fallback=None)
99
+ if value is None:
100
+ return None
101
+ value = value.strip()
102
+ return value or None
103
+
104
+
105
+ def load_config() -> configparser.ConfigParser:
106
+ """Load and return the INI config, failing fast if absent."""
107
+ if not CONFIG_PATH.exists():
108
+ sys.exit(
109
+ f"Config not found: {CONFIG_PATH}\n"
110
+ "Run `plexdo write-config-example` to create a template."
111
+ )
112
+ check_file_permissions(CONFIG_PATH, "config file")
113
+ # interpolation=None: a password containing "%" would otherwise raise
114
+ # InterpolationSyntaxError before it could ever be used.
115
+ cfg = configparser.ConfigParser(interpolation=None)
116
+ cfg.read(CONFIG_PATH, encoding="utf-8")
117
+ _expand_environment(cfg)
118
+ LOG.debug("Loaded config from %s", CONFIG_PATH)
119
+ return cfg
120
+
121
+
122
+ def token_store_path(cfg: configparser.ConfigParser) -> Path:
123
+ """Return the expanded path of the JSON token store."""
124
+ return Path(cfg.get("plex", "token_path")).expanduser()
125
+
126
+
127
+ def read_token(token_path_raw: str, username: Optional[str] = None) -> str:
128
+ """Return the admin token from the JSON token store."""
129
+ token_path = Path(token_path_raw).expanduser()
130
+ if not token_path.exists():
131
+ sys.exit(
132
+ f"Token file not found: {token_path}\n"
133
+ "Run `plexdo login` to authenticate and create it."
134
+ )
135
+ check_file_permissions(token_path, "token file")
136
+ token = admin_token(token_path, username)
137
+ if not token:
138
+ sys.exit(
139
+ f"No admin token in {token_path}.\n"
140
+ "Run `plexdo login` to authenticate, or set [plex] username to "
141
+ "the account whose token should be used."
142
+ )
143
+ LOG.debug("Admin token loaded from %s", token_path)
144
+ return token
145
+
146
+
147
+ def connect_plex(cfg: configparser.ConfigParser) -> PlexServer:
148
+ """Create and return a connected PlexServer instance."""
149
+ url = cfg.get("plex", "url")
150
+ token_path = cfg.get("plex", "token_path")
151
+ token = read_token(token_path, config_optional(cfg, "username"))
152
+ LOG.info("Connecting to Plex at %s", url)
153
+ return PlexServer(url, token)