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,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)
|
plexdo/commands/users.py
ADDED
|
@@ -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)
|