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,174 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
|
|
3
|
+
"""Playlist inspection and mutation commands."""
|
|
4
|
+
|
|
5
|
+
from typing import List
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from plexapi.exceptions import NotFound
|
|
10
|
+
from plexapi.playlist import Playlist
|
|
11
|
+
from plexapi.server import PlexServer
|
|
12
|
+
|
|
13
|
+
from plexdo.accounts import server_for_user
|
|
14
|
+
from plexdo.cache import write_cache
|
|
15
|
+
from plexdo.console import output
|
|
16
|
+
from plexdo.constants import LOG, MediaItem
|
|
17
|
+
from plexdo.m3u import write_m3u
|
|
18
|
+
from plexdo.paths import add_prefix_argument, mapper_for
|
|
19
|
+
from plexdo.playlists import resolve_playlist
|
|
20
|
+
from plexdo.titles import display_title
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def cmd_list_playlists(plex: PlexServer, args: argparse.Namespace) -> None:
|
|
24
|
+
"""List playlists for a given user."""
|
|
25
|
+
user_plex = server_for_user(plex, args.user_id)
|
|
26
|
+
rows = [
|
|
27
|
+
{
|
|
28
|
+
"ratingKey": int(pl.ratingKey),
|
|
29
|
+
"title": pl.title,
|
|
30
|
+
"items": pl.leafCount,
|
|
31
|
+
}
|
|
32
|
+
for pl in user_plex.playlists()
|
|
33
|
+
]
|
|
34
|
+
write_cache(f"playlists.{args.user_id}", rows)
|
|
35
|
+
output(rows, args)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cmd_list_playlist(plex: PlexServer, args: argparse.Namespace) -> None:
|
|
39
|
+
"""List items inside a specific playlist for a user."""
|
|
40
|
+
user_plex = server_for_user(plex, args.user_id)
|
|
41
|
+
playlist: Playlist = resolve_playlist(user_plex, args.playlist)
|
|
42
|
+
|
|
43
|
+
items: List[MediaItem] = list(playlist.items())
|
|
44
|
+
rows = [
|
|
45
|
+
{
|
|
46
|
+
"index": i + 1,
|
|
47
|
+
"ratingKey": int(item.ratingKey),
|
|
48
|
+
"title": display_title(item),
|
|
49
|
+
}
|
|
50
|
+
for i, item in enumerate(items)
|
|
51
|
+
]
|
|
52
|
+
output(rows, args)
|
|
53
|
+
|
|
54
|
+
if args.m3u:
|
|
55
|
+
write_m3u(items, args.m3u, mapper_for(plex, args))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cmd_export_playlist(plex: PlexServer, args: argparse.Namespace) -> None:
|
|
59
|
+
"""Export an existing playlist to an M3U file."""
|
|
60
|
+
user_plex = server_for_user(plex, args.user_id)
|
|
61
|
+
playlist: Playlist = resolve_playlist(user_plex, args.playlist)
|
|
62
|
+
|
|
63
|
+
items: List[MediaItem] = list(playlist.items())
|
|
64
|
+
if not items:
|
|
65
|
+
sys.exit(f"Playlist '{args.playlist}' is empty - nothing to export.")
|
|
66
|
+
|
|
67
|
+
LOG.info("Exporting %d items from '%s' to %s", len(items), args.playlist, args.m3u)
|
|
68
|
+
write_m3u(items, args.m3u, mapper_for(plex, args))
|
|
69
|
+
print(f"Exported {len(items)} items to: {args.m3u}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_remove_playlist(plex: PlexServer, args: argparse.Namespace) -> None:
|
|
73
|
+
"""Delete a playlist from a user's account."""
|
|
74
|
+
user_plex = server_for_user(plex, args.user_id)
|
|
75
|
+
playlist: Playlist = resolve_playlist(user_plex, args.playlist)
|
|
76
|
+
|
|
77
|
+
LOG.info("Removing playlist '%s' for user_id=%d", args.playlist, args.user_id)
|
|
78
|
+
if args.dry_run:
|
|
79
|
+
LOG.info("--dry-run: skipping playlist deletion.")
|
|
80
|
+
return
|
|
81
|
+
playlist.delete()
|
|
82
|
+
print(f"Deleted playlist: {args.playlist!r}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cmd_append_playlist(plex: PlexServer, args: argparse.Namespace) -> None:
|
|
86
|
+
"""Append one or more items to an existing playlist."""
|
|
87
|
+
user_plex = server_for_user(plex, args.user_id)
|
|
88
|
+
playlist: Playlist = resolve_playlist(user_plex, args.playlist)
|
|
89
|
+
|
|
90
|
+
rating_keys = [int(k) for k in args.rating_keys]
|
|
91
|
+
new_items: List[MediaItem] = []
|
|
92
|
+
for rk in rating_keys:
|
|
93
|
+
try:
|
|
94
|
+
new_items.append(user_plex.fetchItem(rk))
|
|
95
|
+
except NotFound:
|
|
96
|
+
sys.exit(f"ratingKey not found: {rk}")
|
|
97
|
+
|
|
98
|
+
if not new_items:
|
|
99
|
+
sys.exit("No items to append.")
|
|
100
|
+
|
|
101
|
+
LOG.info("Appending %d item(s) to '%s'", len(new_items), args.playlist)
|
|
102
|
+
|
|
103
|
+
preview_rows = [
|
|
104
|
+
{
|
|
105
|
+
"index": i + 1,
|
|
106
|
+
"ratingKey": int(item.ratingKey),
|
|
107
|
+
"title": display_title(item),
|
|
108
|
+
}
|
|
109
|
+
for i, item in enumerate(new_items)
|
|
110
|
+
]
|
|
111
|
+
output(preview_rows, args)
|
|
112
|
+
|
|
113
|
+
if args.dry_run:
|
|
114
|
+
LOG.info("--dry-run: skipping append.")
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
playlist.addItems(new_items)
|
|
118
|
+
LOG.info("Appended %d item(s) to '%s'.", len(new_items), args.playlist)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def register(
|
|
122
|
+
sub: "argparse._SubParsersAction",
|
|
123
|
+
parents: "List[argparse.ArgumentParser]",
|
|
124
|
+
) -> None:
|
|
125
|
+
"""Register the playlist inspection and mutation subparsers."""
|
|
126
|
+
p_lpl = sub.add_parser("list-playlists", parents=parents, help="List playlists for a user.")
|
|
127
|
+
p_lpl.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.")
|
|
128
|
+
|
|
129
|
+
p_lp = sub.add_parser("list-playlist", parents=parents, help="List items in a specific playlist.")
|
|
130
|
+
p_lp.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.")
|
|
131
|
+
p_lp.add_argument(
|
|
132
|
+
"playlist", type=str,
|
|
133
|
+
help="Playlist name (str) or ratingKey (int). Obtain either with list-playlists.",
|
|
134
|
+
)
|
|
135
|
+
p_lp.add_argument(
|
|
136
|
+
"--m3u", metavar="PATH",
|
|
137
|
+
help="Also export an M3U file at PATH using Plex server filesystem paths.",
|
|
138
|
+
)
|
|
139
|
+
add_prefix_argument(p_lp)
|
|
140
|
+
|
|
141
|
+
p_ep = sub.add_parser("export-playlist", parents=parents, help="Export an existing playlist to an M3U file.")
|
|
142
|
+
p_ep.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.")
|
|
143
|
+
p_ep.add_argument("playlist", help="To export. " + "Playlist name (str) or ratingKey (int). Obtain either with list-playlists.")
|
|
144
|
+
p_ep.add_argument(
|
|
145
|
+
"m3u", metavar="PATH",
|
|
146
|
+
help="Destination M3U file path.",
|
|
147
|
+
)
|
|
148
|
+
add_prefix_argument(p_ep)
|
|
149
|
+
|
|
150
|
+
p_rp = sub.add_parser("remove-playlist", parents=parents, help="Delete a playlist from a user's account.")
|
|
151
|
+
p_rp.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.")
|
|
152
|
+
p_rp.add_argument("playlist", help="To delete. " + "Playlist name (str) or ratingKey (int). Obtain either with list-playlists.")
|
|
153
|
+
|
|
154
|
+
p_ap = sub.add_parser(
|
|
155
|
+
"append-playlist", parents=parents,
|
|
156
|
+
help="Append one or more items to an existing playlist.",
|
|
157
|
+
)
|
|
158
|
+
p_ap.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.")
|
|
159
|
+
p_ap.add_argument("playlist", help="To append to. " + "Playlist name (str) or ratingKey (int). Obtain either with list-playlists.")
|
|
160
|
+
p_ap.add_argument(
|
|
161
|
+
"rating_keys", nargs="+", type=int, metavar="ratingKey",
|
|
162
|
+
help="One or more item ratingKeys to append (int). Obtain with list-titles or list-show.",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
COMMANDS = {
|
|
167
|
+
"list-playlists": cmd_list_playlists,
|
|
168
|
+
"list-playlist": cmd_list_playlist,
|
|
169
|
+
"export-playlist": cmd_export_playlist,
|
|
170
|
+
"remove-playlist": cmd_remove_playlist,
|
|
171
|
+
"append-playlist": cmd_append_playlist,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
REQUIRES_PLEX = frozenset(COMMANDS)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
|
|
3
|
+
"""Library rescan and scan-status commands."""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from plexapi.server import PlexServer
|
|
10
|
+
|
|
11
|
+
from plexdo.console import clean_text, output
|
|
12
|
+
from plexdo.constants import LOG
|
|
13
|
+
from plexdo.sections import resolve_section
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _cancel_all_scans(plex: PlexServer) -> None:
|
|
17
|
+
"""Cancel any running or queued scan on every library section."""
|
|
18
|
+
for section in plex.library.sections():
|
|
19
|
+
try:
|
|
20
|
+
section.cancelUpdate()
|
|
21
|
+
LOG.debug("Cancelled scan on '%s'", section.title)
|
|
22
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
23
|
+
LOG.debug("cancelUpdate on '%s' raised %s (may already be idle)", section.title, exc)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _activity_rows(plex: PlexServer) -> List[Dict[str, Any]]:
|
|
27
|
+
"""Return scan-related activity rows from the Plex server."""
|
|
28
|
+
rows = []
|
|
29
|
+
for act in plex.activities:
|
|
30
|
+
rows.append({
|
|
31
|
+
"type": clean_text(getattr(act, "type", "") or ""),
|
|
32
|
+
"title": clean_text(getattr(act, "title", "") or ""),
|
|
33
|
+
"subtitle": clean_text(getattr(act, "subtitle", "") or ""),
|
|
34
|
+
"progress": getattr(act, "progress", ""),
|
|
35
|
+
"uuid": clean_text(getattr(act, "uuid", "") or ""),
|
|
36
|
+
})
|
|
37
|
+
return rows
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_rescan(plex: PlexServer, args: argparse.Namespace) -> None:
|
|
41
|
+
"""Trigger a library rescan, optionally cancelling pending scans first."""
|
|
42
|
+
if args.status:
|
|
43
|
+
rows = _activity_rows(plex)
|
|
44
|
+
if not rows:
|
|
45
|
+
print("No active scan jobs.", file=sys.stderr)
|
|
46
|
+
else:
|
|
47
|
+
output(rows, args)
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
if args.library_id is None:
|
|
51
|
+
sys.exit("library_id is required unless --status / -s is used.")
|
|
52
|
+
|
|
53
|
+
section = resolve_section(plex, args.library_id)
|
|
54
|
+
|
|
55
|
+
if args.now:
|
|
56
|
+
LOG.info("Cancelling all pending scans before rescan.")
|
|
57
|
+
if not args.dry_run:
|
|
58
|
+
_cancel_all_scans(plex)
|
|
59
|
+
|
|
60
|
+
LOG.info("Triggering rescan of library '%s' (id=%d)", section.title, args.library_id)
|
|
61
|
+
if args.dry_run:
|
|
62
|
+
LOG.info("--dry-run: skipping scan trigger.")
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
section.update()
|
|
66
|
+
print(f"Rescan triggered for library: {section.title!r}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def register(
|
|
70
|
+
sub: "argparse._SubParsersAction",
|
|
71
|
+
parents: "List[argparse.ArgumentParser]",
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Register the rescan subparser."""
|
|
74
|
+
parser = sub.add_parser(
|
|
75
|
+
"rescan", parents=parents,
|
|
76
|
+
help="Trigger a library rescan, with optional status view or flush-and-rescan.",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"library_id", metavar="LIBRARY", nargs="?", default=None,
|
|
80
|
+
help="Library ID (int) or title (str). Obtain both with list-libraries. "
|
|
81
|
+
"Required unless -s / --status is used.",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"-s", "--status", action="store_true", default=False,
|
|
85
|
+
help="Print all active scan jobs with their type, progress, and subtitle.",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"-n", "--now", action="store_true", default=False,
|
|
89
|
+
help="Cancel all pending scans on every library before triggering the rescan.",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
COMMANDS = {"rescan": cmd_rescan}
|
|
94
|
+
|
|
95
|
+
REQUIRES_PLEX = frozenset(COMMANDS)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
|
|
3
|
+
"""Library search command."""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
import argparse
|
|
7
|
+
|
|
8
|
+
from plexapi.server import PlexServer
|
|
9
|
+
|
|
10
|
+
from plexdo.accounts import server_for_user
|
|
11
|
+
from plexdo.console import output
|
|
12
|
+
from plexdo.constants import LOG
|
|
13
|
+
from plexdo.sections import resolve_sections
|
|
14
|
+
from plexdo.titles import display_title
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
_SEARCH_MEDIA_TYPES = ("movie", "show", "episode", "track", "photo", "album", "artist")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _search_result_row(item: Any) -> Dict[str, Any]:
|
|
21
|
+
"""Build a result row from a search hit, resolving the library id."""
|
|
22
|
+
# plexapi attaches a librarySectionID attribute to all fetched items.
|
|
23
|
+
lib_id = int(
|
|
24
|
+
getattr(item, "librarySectionID", 0)
|
|
25
|
+
)
|
|
26
|
+
return {
|
|
27
|
+
"ratingKey": int(item.ratingKey),
|
|
28
|
+
"libraryId": lib_id,
|
|
29
|
+
"type": item.type,
|
|
30
|
+
"title": display_title(item),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _search_in_section(
|
|
35
|
+
section: Any,
|
|
36
|
+
query: str,
|
|
37
|
+
media_type: Optional[str],
|
|
38
|
+
) -> List[Dict[str, Any]]:
|
|
39
|
+
"""Search a single library section, optionally filtered by media type."""
|
|
40
|
+
kwargs: Dict[str, Any] = {}
|
|
41
|
+
if media_type:
|
|
42
|
+
kwargs["libtype"] = media_type
|
|
43
|
+
try:
|
|
44
|
+
hits = section.search(query, **kwargs)
|
|
45
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
46
|
+
LOG.warning("Search failed in library '%s': %s", section.title, exc)
|
|
47
|
+
return []
|
|
48
|
+
return [_search_result_row(item) for item in hits] # type: ignore[arg-type]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _search_all_sections(
|
|
52
|
+
user_plex: PlexServer,
|
|
53
|
+
query: str,
|
|
54
|
+
media_type: Optional[str],
|
|
55
|
+
library_id: Optional[int],
|
|
56
|
+
) -> List[Dict[str, Any]]:
|
|
57
|
+
"""Run the search across one or all library sections."""
|
|
58
|
+
sections = resolve_sections(user_plex, library_id)
|
|
59
|
+
rows: List[Dict[str, Any]] = []
|
|
60
|
+
for section in sections:
|
|
61
|
+
rows.extend(_search_in_section(section, query, media_type))
|
|
62
|
+
return rows
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_search(plex: PlexServer, args: argparse.Namespace) -> None:
|
|
66
|
+
"""Search Plex for titles matching a query string."""
|
|
67
|
+
user_plex = server_for_user(plex, args.user_id)
|
|
68
|
+
library_id: Optional[int] = args.library_id
|
|
69
|
+
media_type: Optional[str] = args.media_type
|
|
70
|
+
|
|
71
|
+
LOG.info(
|
|
72
|
+
"Searching for %r (type=%s library=%s)", args.query, media_type, library_id
|
|
73
|
+
)
|
|
74
|
+
rows = _search_all_sections(user_plex, args.query, media_type, library_id)
|
|
75
|
+
|
|
76
|
+
if not rows:
|
|
77
|
+
LOG.info("No results found.")
|
|
78
|
+
output(rows, args)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def register(
|
|
82
|
+
sub: "argparse._SubParsersAction",
|
|
83
|
+
parents: "List[argparse.ArgumentParser]",
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Register the search subparser."""
|
|
86
|
+
parser = sub.add_parser(
|
|
87
|
+
"search", parents=parents,
|
|
88
|
+
help="Search Plex for titles matching a query string.",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"user_id", metavar="USER",
|
|
92
|
+
help="User ID (int) or user title (str); use 0 for the admin account. Obtain both with list-users.",
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"query",
|
|
96
|
+
help="Search string (str). Plex performs a prefix/substring match.",
|
|
97
|
+
)
|
|
98
|
+
parser.add_argument(
|
|
99
|
+
"--media-type", dest="media_type",
|
|
100
|
+
choices=list(_SEARCH_MEDIA_TYPES), default=None, metavar="TYPE",
|
|
101
|
+
help=(
|
|
102
|
+
"Restrict results to a single media type. "
|
|
103
|
+
f"Choices: {', '.join(_SEARCH_MEDIA_TYPES)}."
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
parser.add_argument(
|
|
107
|
+
"--library-id", dest="library_id", default=None, metavar="LIBRARY",
|
|
108
|
+
help="Restrict search to one library, by ID or title. Obtain both with list-libraries.",
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
COMMANDS = {"search": cmd_search}
|
|
113
|
+
|
|
114
|
+
REQUIRES_PLEX = frozenset(COMMANDS)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
|
|
3
|
+
"""Consolidated server status: sessions, users, activity, and identity."""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from plexapi.server import PlexServer
|
|
10
|
+
|
|
11
|
+
from plexdo.accounts import account_type
|
|
12
|
+
from plexdo.console import clean_text, output, output_format, print_metadata, print_table, table_limit
|
|
13
|
+
from plexdo.constants import LOG
|
|
14
|
+
from plexdo.convert import format_duration, parse_date
|
|
15
|
+
from plexdo.formats import render
|
|
16
|
+
from plexdo.titles import display_title
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# Activity types Plex uses for library scanning, as opposed to the other
|
|
20
|
+
# background work (metadata refresh, subscriptions, media analysis).
|
|
21
|
+
_SCAN_MARKERS = ("scan", "refresh", "library")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _server_record(plex: PlexServer) -> Dict[str, Any]:
|
|
25
|
+
"""Identity and version details for the server itself."""
|
|
26
|
+
updated = parse_date(getattr(plex, "updatedAt", None))
|
|
27
|
+
return {
|
|
28
|
+
"name": clean_text(getattr(plex, "friendlyName", "") or ""),
|
|
29
|
+
"version": clean_text(getattr(plex, "version", "") or ""),
|
|
30
|
+
"machineIdentifier": clean_text(getattr(plex, "machineIdentifier", "") or ""),
|
|
31
|
+
"platform": clean_text(getattr(plex, "platform", "") or ""),
|
|
32
|
+
"platformVersion": clean_text(getattr(plex, "platformVersion", "") or ""),
|
|
33
|
+
"updatedAt": updated.isoformat(sep=" ") if updated else "",
|
|
34
|
+
"myPlexUsername": clean_text(getattr(plex, "myPlexUsername", "") or ""),
|
|
35
|
+
"myPlexSubscription": bool(getattr(plex, "myPlexSubscription", False)),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _format_offset(milliseconds: Any, duration: Any) -> str:
|
|
40
|
+
"""Render a playback position as elapsed / total."""
|
|
41
|
+
elapsed = format_duration(milliseconds) or "-"
|
|
42
|
+
total = format_duration(duration) or "-"
|
|
43
|
+
return f"{elapsed} / {total}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _session_rows(plex: PlexServer) -> List[Dict[str, Any]]:
|
|
47
|
+
"""One row per item currently being played."""
|
|
48
|
+
rows: List[Dict[str, Any]] = []
|
|
49
|
+
for item in plex.sessions():
|
|
50
|
+
players = getattr(item, "players", []) or []
|
|
51
|
+
player = players[0] if players else None
|
|
52
|
+
usernames = getattr(item, "usernames", []) or []
|
|
53
|
+
rows.append({
|
|
54
|
+
"user": clean_text(usernames[0] if usernames else ""),
|
|
55
|
+
"title": display_title(item),
|
|
56
|
+
"state": clean_text(getattr(player, "state", "") or ""),
|
|
57
|
+
"player": clean_text(getattr(player, "title", "") or ""),
|
|
58
|
+
"platform": clean_text(getattr(player, "platform", "") or ""),
|
|
59
|
+
"address": clean_text(getattr(player, "address", "") or ""),
|
|
60
|
+
"progress": _format_offset(
|
|
61
|
+
getattr(item, "viewOffset", 0), getattr(item, "duration", 0)
|
|
62
|
+
),
|
|
63
|
+
})
|
|
64
|
+
return rows
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _user_rows(plex: PlexServer) -> List[Dict[str, Any]]:
|
|
68
|
+
"""Shared and managed users visible to the admin token."""
|
|
69
|
+
return [
|
|
70
|
+
{
|
|
71
|
+
"id": int(user.id),
|
|
72
|
+
"type": account_type(user),
|
|
73
|
+
"title": clean_text(user.title or ""),
|
|
74
|
+
"username": clean_text(getattr(user, "username", "") or ""),
|
|
75
|
+
"email": clean_text(getattr(user, "email", "") or ""),
|
|
76
|
+
}
|
|
77
|
+
for user in plex.myPlexAccount().users()
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _system_account_rows(plex: PlexServer) -> List[Dict[str, Any]]:
|
|
82
|
+
"""Accounts the server itself knows about, which is not the same list."""
|
|
83
|
+
return [
|
|
84
|
+
{
|
|
85
|
+
"id": int(account.id),
|
|
86
|
+
"name": clean_text(account.name or ""),
|
|
87
|
+
"audioLanguage": clean_text(getattr(account, "defaultAudioLanguage", "") or ""),
|
|
88
|
+
"subtitleLanguage": clean_text(
|
|
89
|
+
getattr(account, "defaultSubtitleLanguage", "") or ""
|
|
90
|
+
),
|
|
91
|
+
}
|
|
92
|
+
for account in plex.systemAccounts()
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _connection_rows(plex: PlexServer) -> List[Dict[str, Any]]:
|
|
97
|
+
"""Addresses plex.tv advertises for this server."""
|
|
98
|
+
try:
|
|
99
|
+
resource = plex.myPlexAccount().resource(plex.friendlyName)
|
|
100
|
+
connections = list(resource.connections)
|
|
101
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
102
|
+
# Needs a plex.tv round trip, which a local-only or offline server
|
|
103
|
+
# cannot satisfy; the rest of the report is still worth printing.
|
|
104
|
+
LOG.debug("Could not list connections: %s", exc)
|
|
105
|
+
return []
|
|
106
|
+
return [
|
|
107
|
+
{
|
|
108
|
+
"uri": clean_text(conn.uri or ""),
|
|
109
|
+
"address": clean_text(conn.address or ""),
|
|
110
|
+
"port": conn.port,
|
|
111
|
+
"protocol": clean_text(conn.protocol or ""),
|
|
112
|
+
"local": bool(conn.local),
|
|
113
|
+
"relay": bool(conn.relay),
|
|
114
|
+
"ipv6": bool(getattr(conn, "ipv6", False)),
|
|
115
|
+
}
|
|
116
|
+
for conn in connections
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _is_scan(activity: Any) -> bool:
|
|
121
|
+
"""True when an activity is library scanning rather than other work."""
|
|
122
|
+
kind = str(getattr(activity, "type", "")).lower()
|
|
123
|
+
return any(marker in kind for marker in _SCAN_MARKERS)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _split_activity_rows(plex: PlexServer, scans: bool) -> List[Dict[str, Any]]:
|
|
127
|
+
"""In-progress activities, split into scans and everything else."""
|
|
128
|
+
return [
|
|
129
|
+
{
|
|
130
|
+
"type": clean_text(getattr(act, "type", "") or ""),
|
|
131
|
+
"title": clean_text(getattr(act, "title", "") or ""),
|
|
132
|
+
"subtitle": clean_text(getattr(act, "subtitle", "") or ""),
|
|
133
|
+
"progress": getattr(act, "progress", ""),
|
|
134
|
+
"cancellable": bool(getattr(act, "cancellable", False)),
|
|
135
|
+
}
|
|
136
|
+
for act in plex.activities
|
|
137
|
+
if _is_scan(act) is scans
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _butler_rows(plex: PlexServer) -> List[Dict[str, Any]]:
|
|
142
|
+
"""Scheduled maintenance tasks and whether each is enabled."""
|
|
143
|
+
try:
|
|
144
|
+
tasks = plex.butlerTasks()
|
|
145
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
146
|
+
LOG.debug("Could not list butler tasks: %s", exc)
|
|
147
|
+
return []
|
|
148
|
+
return [
|
|
149
|
+
{
|
|
150
|
+
"name": clean_text(task.name or ""),
|
|
151
|
+
"title": clean_text(getattr(task, "title", "") or ""),
|
|
152
|
+
"enabled": bool(getattr(task, "enabled", False)),
|
|
153
|
+
"interval": getattr(task, "interval", ""),
|
|
154
|
+
}
|
|
155
|
+
for task in tasks
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# Section name -> (heading, collector). Order sets the order of the report.
|
|
160
|
+
_SECTIONS: Dict[str, Any] = {
|
|
161
|
+
"server": ("Server", _server_record),
|
|
162
|
+
"sessions": ("Active sessions", _session_rows),
|
|
163
|
+
"users": ("Shared users", _user_rows),
|
|
164
|
+
"accounts": ("System accounts", _system_account_rows),
|
|
165
|
+
"connections": ("Reachable addresses", _connection_rows),
|
|
166
|
+
"scans": ("Library scans in progress", lambda p: _split_activity_rows(p, True)),
|
|
167
|
+
"activities": ("Other background activity", lambda p: _split_activity_rows(p, False)),
|
|
168
|
+
"tasks": ("Scheduled background tasks", _butler_rows),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
SECTION_NAMES = tuple(_SECTIONS)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _collect(plex: PlexServer, names: List[str]) -> Dict[str, Any]:
|
|
175
|
+
"""Gather the requested sections, tolerating a failure in any one."""
|
|
176
|
+
gathered: Dict[str, Any] = {}
|
|
177
|
+
for name in names:
|
|
178
|
+
_, collector = _SECTIONS[name]
|
|
179
|
+
try:
|
|
180
|
+
gathered[name] = collector(plex)
|
|
181
|
+
except Exception as exc: # pylint: disable=broad-except
|
|
182
|
+
# One unavailable section must not cost the whole report.
|
|
183
|
+
LOG.warning("Could not read %s: %s", name, exc)
|
|
184
|
+
gathered[name] = [] if name != "server" else {}
|
|
185
|
+
return gathered
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _print_report(gathered: Dict[str, Any], limit: Optional[int]) -> None:
|
|
189
|
+
"""Print the full multi-section report for the table renderer."""
|
|
190
|
+
for name, payload in gathered.items():
|
|
191
|
+
heading, _ = _SECTIONS[name]
|
|
192
|
+
print(f"\n{heading}")
|
|
193
|
+
if isinstance(payload, dict):
|
|
194
|
+
print_metadata(payload, limit)
|
|
195
|
+
elif payload:
|
|
196
|
+
print_table(payload, limit)
|
|
197
|
+
else:
|
|
198
|
+
print(" (none)")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def cmd_status(plex: PlexServer, args: argparse.Namespace) -> None:
|
|
202
|
+
"""Show a consolidated view of what the server is doing."""
|
|
203
|
+
names = [args.section] if args.section else list(SECTION_NAMES)
|
|
204
|
+
chosen = output_format(args)
|
|
205
|
+
|
|
206
|
+
# csv and clixml are flat by nature and cannot carry eight differently
|
|
207
|
+
# shaped sections, so one must be named.
|
|
208
|
+
if chosen in ("csv", "clixml") and not args.section:
|
|
209
|
+
sys.exit(
|
|
210
|
+
f"--format {chosen} needs a single section: pass --section with "
|
|
211
|
+
f"one of {', '.join(SECTION_NAMES)}."
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
gathered = _collect(plex, names)
|
|
215
|
+
|
|
216
|
+
if args.section:
|
|
217
|
+
output(gathered[args.section], args)
|
|
218
|
+
return
|
|
219
|
+
if chosen == "table":
|
|
220
|
+
_print_report(gathered, table_limit(args))
|
|
221
|
+
return
|
|
222
|
+
print(render(gathered, chosen))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def register(
|
|
226
|
+
sub: "argparse._SubParsersAction",
|
|
227
|
+
parents: "List[argparse.ArgumentParser]",
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Register the status subparser."""
|
|
230
|
+
parser = sub.add_parser(
|
|
231
|
+
"status", parents=parents,
|
|
232
|
+
help="Show server identity, sessions, users, scans, and background tasks.",
|
|
233
|
+
)
|
|
234
|
+
parser.add_argument(
|
|
235
|
+
"--section", choices=list(SECTION_NAMES), default=None, metavar="SECTION",
|
|
236
|
+
help=(
|
|
237
|
+
"Show only one section: " + ", ".join(SECTION_NAMES) + ". "
|
|
238
|
+
"Required for --format csv and --format clixml."
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
COMMANDS = {"status": cmd_status}
|
|
244
|
+
|
|
245
|
+
REQUIRES_PLEX = frozenset(COMMANDS)
|