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,231 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
|
|
3
|
+
"""Library listing and whole-library export commands."""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
import textwrap
|
|
9
|
+
|
|
10
|
+
from plexapi.server import PlexServer
|
|
11
|
+
from plexapi.video import Episode
|
|
12
|
+
|
|
13
|
+
from plexdo.cache import write_cache
|
|
14
|
+
from plexdo.console import output, output_format
|
|
15
|
+
from plexdo.constants import LOG
|
|
16
|
+
from plexdo.gallery import write_gallery_html
|
|
17
|
+
from plexdo.m3u import write_m3u
|
|
18
|
+
from plexdo.paths import add_prefix_argument, mapper_for
|
|
19
|
+
from plexdo.photos import collect_library_items, collect_photos
|
|
20
|
+
from plexdo.records import cache_row, loaded_fields, release_date, season_records, summary_row
|
|
21
|
+
from plexdo.sections import resolve_section
|
|
22
|
+
from plexdo.sorting import apply_sort
|
|
23
|
+
from plexdo.throttle import paced
|
|
24
|
+
from plexdo.titles import display_title, fetch_show, non_special_episodes
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def cmd_list_libraries(plex: PlexServer, args: argparse.Namespace) -> None:
|
|
28
|
+
"""List all Plex libraries."""
|
|
29
|
+
rows = [
|
|
30
|
+
{
|
|
31
|
+
"id": int(lib.key),
|
|
32
|
+
"type": lib.type,
|
|
33
|
+
"title": lib.title,
|
|
34
|
+
}
|
|
35
|
+
for lib in plex.library.sections()
|
|
36
|
+
]
|
|
37
|
+
write_cache("libraries", rows)
|
|
38
|
+
output(rows, args)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _detailed_row(item: Any, with_seasons: bool) -> Dict[str, Any]:
|
|
42
|
+
"""Every listed field for one item, plus its seasons for a show."""
|
|
43
|
+
record = loaded_fields(item)
|
|
44
|
+
record["releaseDate"] = release_date(item)
|
|
45
|
+
if with_seasons:
|
|
46
|
+
record["seasons"] = season_records(item)
|
|
47
|
+
return record
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cmd_list_titles(plex: PlexServer, args: argparse.Namespace) -> None:
|
|
51
|
+
"""List titles in a library, with optional album filter for photo libraries.
|
|
52
|
+
|
|
53
|
+
Table output stays compact; a machine-readable format carries every field
|
|
54
|
+
the listing returned, and for a show library also nests each show's
|
|
55
|
+
episodes under a seasons object.
|
|
56
|
+
"""
|
|
57
|
+
library_id = args.library_id
|
|
58
|
+
section = resolve_section(plex, library_id)
|
|
59
|
+
|
|
60
|
+
album: Optional[str] = getattr(args, "album", None)
|
|
61
|
+
if album and section.type != "photo":
|
|
62
|
+
LOG.warning("--album is only applicable to photo libraries; ignoring.")
|
|
63
|
+
album = None
|
|
64
|
+
|
|
65
|
+
if section.type == "photo":
|
|
66
|
+
items = collect_photos(section, album, args)
|
|
67
|
+
else:
|
|
68
|
+
items = list(section.all())
|
|
69
|
+
|
|
70
|
+
# The completion cache only ever needs the key and the title, whatever
|
|
71
|
+
# the caller asked to see.
|
|
72
|
+
write_cache(f"titles.{library_id}", [cache_row(item) for item in items])
|
|
73
|
+
|
|
74
|
+
if output_format(args) == "table":
|
|
75
|
+
output([summary_row(item) for item in items], args)
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
with_seasons = section.type == "show"
|
|
79
|
+
if with_seasons:
|
|
80
|
+
LOG.info(
|
|
81
|
+
"Collecting seasons and episodes for %d show(s); this walks the "
|
|
82
|
+
"whole library.", len(items),
|
|
83
|
+
)
|
|
84
|
+
if with_seasons:
|
|
85
|
+
rows = [_detailed_row(item, True) for item in paced(items, args, "shows")]
|
|
86
|
+
else:
|
|
87
|
+
rows = [_detailed_row(item, False) for item in items]
|
|
88
|
+
output(rows, args)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cmd_list_show(plex: PlexServer, args: argparse.Namespace) -> None:
|
|
92
|
+
"""List all episodes in a show, optionally exporting an M3U."""
|
|
93
|
+
rating_key = int(args.rating_key)
|
|
94
|
+
show = fetch_show(plex, rating_key)
|
|
95
|
+
episodes: List[Episode] = non_special_episodes(show)
|
|
96
|
+
|
|
97
|
+
rows = [
|
|
98
|
+
{
|
|
99
|
+
"index": i + 1,
|
|
100
|
+
"ratingKey": int(ep.ratingKey),
|
|
101
|
+
"season": ep.seasonNumber,
|
|
102
|
+
"episode": ep.index,
|
|
103
|
+
"title": display_title(ep),
|
|
104
|
+
}
|
|
105
|
+
for i, ep in enumerate(episodes)
|
|
106
|
+
]
|
|
107
|
+
output(rows, args)
|
|
108
|
+
|
|
109
|
+
if args.m3u:
|
|
110
|
+
write_m3u(episodes, args.m3u, mapper_for(plex, args)) # type: ignore[arg-type]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def cmd_export_titles(plex: PlexServer, args: argparse.Namespace) -> None:
|
|
114
|
+
"""Export an entire library to an M3U file."""
|
|
115
|
+
section = resolve_section(plex, args.library_id)
|
|
116
|
+
|
|
117
|
+
LOG.info(
|
|
118
|
+
"Collecting items from library '%s' (type=%s)", section.title, section.type
|
|
119
|
+
)
|
|
120
|
+
album: Optional[str] = getattr(args, "album", None)
|
|
121
|
+
if album and section.type != "photo":
|
|
122
|
+
LOG.warning("--album is only applicable to photo libraries; ignoring.")
|
|
123
|
+
album = None
|
|
124
|
+
|
|
125
|
+
items = collect_library_items(section, album, args)
|
|
126
|
+
|
|
127
|
+
if not items:
|
|
128
|
+
sys.exit(f"Library '{section.title}' contains no items.")
|
|
129
|
+
|
|
130
|
+
sorted_items = apply_sort(items, args.sort)
|
|
131
|
+
gallery_title = f"{section.title} - {album}" if album else section.title
|
|
132
|
+
LOG.info(
|
|
133
|
+
"Exporting %d item(s) sorted by '%s' to %s",
|
|
134
|
+
len(sorted_items), args.sort, args.output_path,
|
|
135
|
+
)
|
|
136
|
+
if section.type == "photo":
|
|
137
|
+
write_gallery_html(
|
|
138
|
+
sorted_items, args.output_path, gallery_title, mapper_for(plex, args)
|
|
139
|
+
)
|
|
140
|
+
else:
|
|
141
|
+
write_m3u(sorted_items, args.output_path, mapper_for(plex, args))
|
|
142
|
+
print(f"Exported {len(sorted_items)} items to: {args.output_path}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def register(
|
|
146
|
+
sub: "argparse._SubParsersAction",
|
|
147
|
+
parents: "List[argparse.ArgumentParser]",
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Register the library-related subparsers."""
|
|
150
|
+
sub.add_parser(
|
|
151
|
+
"list-libraries", parents=parents,
|
|
152
|
+
help="List all Plex libraries (id, type, title).",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
p_lt = sub.add_parser(
|
|
156
|
+
"list-titles", parents=parents, aliases=["list-library"],
|
|
157
|
+
help="List titles in a library.",
|
|
158
|
+
description=textwrap.fill(
|
|
159
|
+
"List the titles in a library. Table output shows the rating key, "
|
|
160
|
+
"title, release date, rating, and studio. A machine-readable "
|
|
161
|
+
"format (-f json, yaml, csv, clixml) carries every field the "
|
|
162
|
+
"server returned for each item, and for a show library nests each "
|
|
163
|
+
"show's episodes under a seasons object keyed by season name.",
|
|
164
|
+
width=78,
|
|
165
|
+
),
|
|
166
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
167
|
+
)
|
|
168
|
+
p_lt.add_argument(
|
|
169
|
+
"library_id", metavar="LIBRARY",
|
|
170
|
+
help="Library ID (int) or library title (str). Obtain both with list-libraries.",
|
|
171
|
+
)
|
|
172
|
+
p_lt.add_argument(
|
|
173
|
+
"--album", default=None, metavar="ALBUM",
|
|
174
|
+
help="Photo libraries only: restrict listing to a single album name.",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
p_ls = sub.add_parser(
|
|
178
|
+
"list-show", parents=parents,
|
|
179
|
+
help="List all episodes in a show (skipping specials), with optional M3U export.",
|
|
180
|
+
)
|
|
181
|
+
p_ls.add_argument(
|
|
182
|
+
"rating_key", type=int,
|
|
183
|
+
help="Show ratingKey (int). Obtain with list-titles.",
|
|
184
|
+
)
|
|
185
|
+
p_ls.add_argument(
|
|
186
|
+
"--m3u", metavar="PATH",
|
|
187
|
+
help="Also export an M3U file at PATH using Plex server filesystem paths.",
|
|
188
|
+
)
|
|
189
|
+
add_prefix_argument(p_ls)
|
|
190
|
+
|
|
191
|
+
p_et = sub.add_parser(
|
|
192
|
+
"export-titles", parents=parents,
|
|
193
|
+
help="Export an entire library to an M3U file or photo gallery.",
|
|
194
|
+
)
|
|
195
|
+
p_et.add_argument(
|
|
196
|
+
"library_id", metavar="LIBRARY",
|
|
197
|
+
help="Library ID (int) or library title (str). Obtain both with list-libraries.",
|
|
198
|
+
)
|
|
199
|
+
p_et.add_argument(
|
|
200
|
+
"output_path", metavar="PATH",
|
|
201
|
+
help=(
|
|
202
|
+
"Output file path. M3U for show/movie libraries; "
|
|
203
|
+
"HTML gallery for photo libraries."
|
|
204
|
+
),
|
|
205
|
+
)
|
|
206
|
+
p_et.add_argument(
|
|
207
|
+
"--sort", choices=["alpha", "date", "random"], default="alpha",
|
|
208
|
+
help=(
|
|
209
|
+
"Sort order: 'alpha' = alphabetical by title/show+episode (default), "
|
|
210
|
+
"'date' = by original air date (undated items last), "
|
|
211
|
+
"'random' = randomised."
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
p_et.add_argument(
|
|
215
|
+
"--album", default=None, metavar="ALBUM",
|
|
216
|
+
help="Photo libraries only: restrict export to a single album name.",
|
|
217
|
+
)
|
|
218
|
+
add_prefix_argument(p_et)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
COMMANDS = {
|
|
222
|
+
"list-libraries": cmd_list_libraries,
|
|
223
|
+
"list-titles": cmd_list_titles,
|
|
224
|
+
# argparse reports whichever spelling was typed, so the alias needs its
|
|
225
|
+
# own entry for dispatch to find a handler.
|
|
226
|
+
"list-library": cmd_list_titles,
|
|
227
|
+
"list-show": cmd_list_show,
|
|
228
|
+
"export-titles": cmd_export_titles,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
REQUIRES_PLEX = frozenset(COMMANDS)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
|
|
3
|
+
"""Per-item metadata display command."""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List
|
|
6
|
+
import argparse
|
|
7
|
+
|
|
8
|
+
from plexapi.audio import Track
|
|
9
|
+
from plexapi.server import PlexServer
|
|
10
|
+
from plexapi.video import Episode, Movie, Show
|
|
11
|
+
|
|
12
|
+
from plexdo.console import clean_text, output, output_format
|
|
13
|
+
from plexdo.convert import format_duration
|
|
14
|
+
from plexdo.records import file_paths
|
|
15
|
+
from plexdo.titles import display_title, fetch_item
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _base_metadata(item: Any) -> Dict[str, Any]:
|
|
19
|
+
"""Return metadata fields common to all media types."""
|
|
20
|
+
return {
|
|
21
|
+
"ratingKey": int(item.ratingKey),
|
|
22
|
+
"type": item.type,
|
|
23
|
+
"title": display_title(item),
|
|
24
|
+
"year": getattr(item, "year", "") or "",
|
|
25
|
+
"contentRating": clean_text(getattr(item, "contentRating", "") or ""),
|
|
26
|
+
"rating": getattr(item, "rating", "") or "",
|
|
27
|
+
"duration": format_duration(getattr(item, "duration", None)),
|
|
28
|
+
"addedAt": str(getattr(item, "addedAt", "") or ""),
|
|
29
|
+
"updatedAt": str(getattr(item, "updatedAt", "") or ""),
|
|
30
|
+
"summary": clean_text(getattr(item, "summary", "") or ""),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _episode_metadata(ep: Episode) -> Dict[str, Any]:
|
|
35
|
+
"""Return metadata fields specific to Episode items."""
|
|
36
|
+
record = _base_metadata(ep)
|
|
37
|
+
record.update({
|
|
38
|
+
"show": clean_text(ep.grandparentTitle or ""),
|
|
39
|
+
"season": ep.seasonNumber,
|
|
40
|
+
"episode": ep.index,
|
|
41
|
+
"airDate": str(ep.originallyAvailableAt or ""),
|
|
42
|
+
"studio": clean_text(getattr(ep, "studio", "") or ""),
|
|
43
|
+
})
|
|
44
|
+
return record
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _movie_metadata(movie: Movie) -> Dict[str, Any]:
|
|
48
|
+
"""Return metadata fields specific to Movie items."""
|
|
49
|
+
record = _base_metadata(movie)
|
|
50
|
+
record.update({
|
|
51
|
+
"studio": clean_text(getattr(movie, "studio", "") or ""),
|
|
52
|
+
"airDate": str(getattr(movie, "originallyAvailableAt", "") or ""),
|
|
53
|
+
"tagline": clean_text(getattr(movie, "tagline", "") or ""),
|
|
54
|
+
"genres": ", ".join(g.tag for g in getattr(movie, "genres", [])),
|
|
55
|
+
"directors": ", ".join(d.tag for d in getattr(movie, "directors", [])),
|
|
56
|
+
})
|
|
57
|
+
return record
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _show_metadata(show: Show) -> Dict[str, Any]:
|
|
61
|
+
"""Return metadata fields specific to Show items."""
|
|
62
|
+
record = _base_metadata(show)
|
|
63
|
+
record.update({
|
|
64
|
+
"studio": clean_text(getattr(show, "studio", "") or ""),
|
|
65
|
+
"firstAired": str(getattr(show, "originallyAvailableAt", "") or ""),
|
|
66
|
+
"seasons": getattr(show, "childCount", ""),
|
|
67
|
+
"episodes": getattr(show, "leafCount", ""),
|
|
68
|
+
"genres": ", ".join(g.tag for g in getattr(show, "genres", [])),
|
|
69
|
+
"network": clean_text(getattr(show, "network", "") or ""),
|
|
70
|
+
})
|
|
71
|
+
return record
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _track_metadata(track: Track) -> Dict[str, Any]:
|
|
75
|
+
"""Return metadata fields specific to Track items."""
|
|
76
|
+
record = _base_metadata(track)
|
|
77
|
+
record.update({
|
|
78
|
+
"album": clean_text(getattr(track, "parentTitle", "") or ""),
|
|
79
|
+
"artist": clean_text(getattr(track, "grandparentTitle", "") or ""),
|
|
80
|
+
"trackNumber": getattr(track, "index", ""),
|
|
81
|
+
"year": getattr(track, "year", "") or "",
|
|
82
|
+
})
|
|
83
|
+
return record
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
_METADATA_BUILDERS = {
|
|
87
|
+
"episode": _episode_metadata,
|
|
88
|
+
"movie": _movie_metadata,
|
|
89
|
+
"show": _show_metadata,
|
|
90
|
+
"track": _track_metadata,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _file_fields(paths: List[str]) -> Dict[str, str]:
|
|
95
|
+
"""One row per file, numbered only when there is more than one."""
|
|
96
|
+
if len(paths) == 1:
|
|
97
|
+
return {"file": paths[0]}
|
|
98
|
+
return {f"file {number}": path for number, path in enumerate(paths, 1)}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cmd_show_metadata(plex: PlexServer, args: argparse.Namespace) -> None:
|
|
102
|
+
"""Display metadata for a single item by ratingKey."""
|
|
103
|
+
item = fetch_item(plex, args.rating_key)
|
|
104
|
+
builder = _METADATA_BUILDERS.get(item.type, _base_metadata)
|
|
105
|
+
record: Dict[str, Any] = builder(item)
|
|
106
|
+
|
|
107
|
+
# A list reads naturally in a structured format, but the key/value table
|
|
108
|
+
# wants one row per file.
|
|
109
|
+
paths = file_paths(item)
|
|
110
|
+
if output_format(args) == "table":
|
|
111
|
+
record.update(_file_fields(paths))
|
|
112
|
+
else:
|
|
113
|
+
record["files"] = paths
|
|
114
|
+
|
|
115
|
+
output(record, args)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def register(
|
|
119
|
+
sub: "argparse._SubParsersAction",
|
|
120
|
+
parents: "List[argparse.ArgumentParser]",
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Register the metadata subparser."""
|
|
123
|
+
parser = sub.add_parser(
|
|
124
|
+
"show-metadata", parents=parents,
|
|
125
|
+
help="Display metadata for a single item by ratingKey.",
|
|
126
|
+
)
|
|
127
|
+
parser.add_argument(
|
|
128
|
+
"rating_key", type=int,
|
|
129
|
+
help="Item ratingKey (int). Obtain with list-titles or list-show.",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
COMMANDS = {"show-metadata": cmd_show_metadata}
|
|
134
|
+
|
|
135
|
+
REQUIRES_PLEX = frozenset(COMMANDS)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
2
|
+
|
|
3
|
+
"""Find gaps in the episode numbering of every season of every show."""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
6
|
+
import argparse
|
|
7
|
+
import textwrap
|
|
8
|
+
|
|
9
|
+
from plexapi.server import PlexServer
|
|
10
|
+
from plexapi.video import Show
|
|
11
|
+
|
|
12
|
+
from plexdo.console import clean_text, output
|
|
13
|
+
from plexdo.constants import LOG
|
|
14
|
+
from plexdo.identify import resolve_identifier
|
|
15
|
+
from plexdo.sections import resolve_sections
|
|
16
|
+
from plexdo.throttle import paced
|
|
17
|
+
from plexdo.titles import fetch_item
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def episode_gaps(numbers: List[int]) -> List[int]:
|
|
21
|
+
"""Return the episode numbers missing from a season.
|
|
22
|
+
|
|
23
|
+
The run starts at 0 when the season has an episode 0, otherwise at 1, and
|
|
24
|
+
ends at the highest number present: a season simply stopping early is not
|
|
25
|
+
a gap, since there is no way to tell an unaired episode from a missing
|
|
26
|
+
one.
|
|
27
|
+
"""
|
|
28
|
+
present = sorted({number for number in numbers if number is not None})
|
|
29
|
+
if not present:
|
|
30
|
+
return []
|
|
31
|
+
start = 0 if present[0] == 0 else 1
|
|
32
|
+
return [n for n in range(start, present[-1] + 1) if n not in present]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _season_row(show: Any, season: Any) -> Optional[Dict[str, Any]]:
|
|
36
|
+
"""Report one season, or None when its numbering is complete."""
|
|
37
|
+
episodes = season.episodes()
|
|
38
|
+
numbers = [vars(episode).get("index") for episode in episodes]
|
|
39
|
+
missing = episode_gaps([n for n in numbers if n is not None])
|
|
40
|
+
if not missing:
|
|
41
|
+
return None
|
|
42
|
+
present = sorted(n for n in numbers if n is not None)
|
|
43
|
+
return {
|
|
44
|
+
"show": clean_text(vars(show).get("title") or ""),
|
|
45
|
+
"ratingKey": int(show.ratingKey),
|
|
46
|
+
"season": vars(season).get("index"),
|
|
47
|
+
"missing": missing,
|
|
48
|
+
"missingCount": len(missing),
|
|
49
|
+
"have": len(present),
|
|
50
|
+
"highest": present[-1] if present else 0,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_seasons(text: str) -> Set[int]:
|
|
55
|
+
"""Parse "1" or "1,3,5" into a set of season numbers."""
|
|
56
|
+
numbers: Set[int] = set()
|
|
57
|
+
for part in text.split(","):
|
|
58
|
+
part = part.strip()
|
|
59
|
+
if not part:
|
|
60
|
+
continue
|
|
61
|
+
try:
|
|
62
|
+
numbers.add(int(part))
|
|
63
|
+
except ValueError:
|
|
64
|
+
raise SystemExit(
|
|
65
|
+
f"Not a season number: {part!r}. Give one number, or several "
|
|
66
|
+
"separated by commas, as in --season 1,3,5."
|
|
67
|
+
) from None
|
|
68
|
+
if not numbers:
|
|
69
|
+
raise SystemExit("--season needs at least one season number.")
|
|
70
|
+
return numbers
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _wanted(number: Any, chosen: Optional[Set[int]], include_specials: bool) -> bool:
|
|
74
|
+
"""Whether a season should be examined.
|
|
75
|
+
|
|
76
|
+
Naming a season explicitly overrides the usual skipping of season 0:
|
|
77
|
+
asking for --season 0 can only mean the specials.
|
|
78
|
+
"""
|
|
79
|
+
if chosen is not None:
|
|
80
|
+
return number in chosen
|
|
81
|
+
return include_specials or number != 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _show_rows(
|
|
85
|
+
show: Any, include_specials: bool, chosen: Optional[Set[int]] = None
|
|
86
|
+
) -> List[Dict[str, Any]]:
|
|
87
|
+
"""Report every incomplete season of one show."""
|
|
88
|
+
rows: List[Dict[str, Any]] = []
|
|
89
|
+
for season in show.seasons():
|
|
90
|
+
if not _wanted(vars(season).get("index"), chosen, include_specials):
|
|
91
|
+
continue
|
|
92
|
+
row = _season_row(show, season)
|
|
93
|
+
if row is not None:
|
|
94
|
+
rows.append(row)
|
|
95
|
+
return rows
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _show_sections(plex: PlexServer, library_id: Optional[str]) -> List[Any]:
|
|
99
|
+
"""Every show library, or the single one named."""
|
|
100
|
+
sections = [
|
|
101
|
+
section for section in resolve_sections(plex, library_id)
|
|
102
|
+
if section.type == "show"
|
|
103
|
+
]
|
|
104
|
+
if not sections:
|
|
105
|
+
where = f"library {library_id!r}" if library_id else "this server"
|
|
106
|
+
raise SystemExit(f"No show libraries found in {where}.")
|
|
107
|
+
return sections
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def resolve_show(plex: PlexServer, identifier: str, library_id: Optional[str]) -> Any:
|
|
111
|
+
"""Find one show by ratingKey or title.
|
|
112
|
+
|
|
113
|
+
Uses the same precedence as every other identifier in plexdo: a numeric
|
|
114
|
+
value is a ratingKey, a title matches exactly before case-insensitively,
|
|
115
|
+
and an ambiguous title aborts rather than guessing.
|
|
116
|
+
"""
|
|
117
|
+
roster: List[Tuple[int, str]] = []
|
|
118
|
+
for section in _show_sections(plex, library_id):
|
|
119
|
+
roster.extend(
|
|
120
|
+
(int(show.ratingKey), clean_text(vars(show).get("title") or ""))
|
|
121
|
+
for show in section.all()
|
|
122
|
+
)
|
|
123
|
+
key = resolve_identifier(roster, identifier, "show", "list-titles <library>")
|
|
124
|
+
show = fetch_item(plex, key)
|
|
125
|
+
if not isinstance(show, Show):
|
|
126
|
+
raise SystemExit(
|
|
127
|
+
f"ratingKey {key} is a {type(show).__name__.lower()}, not a show."
|
|
128
|
+
)
|
|
129
|
+
return show
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _flatten(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
133
|
+
"""Render the missing list as text, for the table renderer."""
|
|
134
|
+
return [dict(row, missing=", ".join(str(n) for n in row["missing"]))
|
|
135
|
+
for row in rows]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_find_missing(plex: PlexServer, args: argparse.Namespace) -> None:
|
|
139
|
+
"""Report seasons whose episode numbering has holes in it."""
|
|
140
|
+
chosen = parse_seasons(args.seasons) if args.seasons else None
|
|
141
|
+
|
|
142
|
+
if args.all_shows:
|
|
143
|
+
if args.show:
|
|
144
|
+
raise SystemExit(
|
|
145
|
+
f"Give a show or --all, not both; drop {args.show!r} to check "
|
|
146
|
+
"every show."
|
|
147
|
+
)
|
|
148
|
+
if chosen is not None:
|
|
149
|
+
raise SystemExit("--season applies to a single show, not to --all.")
|
|
150
|
+
rows = _scan_libraries(plex, args)
|
|
151
|
+
else:
|
|
152
|
+
if not args.show:
|
|
153
|
+
raise SystemExit(
|
|
154
|
+
"Name a show, by title or ratingKey, or pass --all to check "
|
|
155
|
+
"every show in every library."
|
|
156
|
+
)
|
|
157
|
+
show = resolve_show(plex, args.show, args.library_id)
|
|
158
|
+
rows = _show_rows(show, args.include_specials, chosen)
|
|
159
|
+
|
|
160
|
+
if not rows:
|
|
161
|
+
LOG.info("Every season examined is complete.")
|
|
162
|
+
print("No gaps found.")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
LOG.info("%d season(s) with gaps", len(rows))
|
|
166
|
+
output(_flatten(rows) if args.format == "table" else rows, args)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _scan_libraries(plex: PlexServer, args: argparse.Namespace) -> List[Dict[str, Any]]:
|
|
170
|
+
"""Walk every show of every selected library."""
|
|
171
|
+
rows: List[Dict[str, Any]] = []
|
|
172
|
+
for section in _show_sections(plex, args.library_id):
|
|
173
|
+
shows = section.all()
|
|
174
|
+
LOG.info("Checking %d show(s) in '%s'", len(shows), section.title)
|
|
175
|
+
for show in paced(shows, args, "shows"):
|
|
176
|
+
rows.extend(_show_rows(show, args.include_specials))
|
|
177
|
+
return rows
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def register(
|
|
181
|
+
sub: "argparse._SubParsersAction",
|
|
182
|
+
parents: "List[argparse.ArgumentParser]",
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Register the find-missing subparser."""
|
|
185
|
+
parser = sub.add_parser(
|
|
186
|
+
"find-missing", parents=parents,
|
|
187
|
+
help="Find gaps in the episode numbering of a show's seasons.",
|
|
188
|
+
description=textwrap.fill(
|
|
189
|
+
"Report the episode numbers missing between the start of a "
|
|
190
|
+
"season and its highest episode. Names one show by default; -A "
|
|
191
|
+
"checks every show. A season that simply stops early is not "
|
|
192
|
+
"reported, since an unaired episode cannot be told from a missing "
|
|
193
|
+
"one.",
|
|
194
|
+
width=78,
|
|
195
|
+
),
|
|
196
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
197
|
+
)
|
|
198
|
+
parser.add_argument(
|
|
199
|
+
"show", metavar="SHOW", nargs="?", default=None,
|
|
200
|
+
help=(
|
|
201
|
+
"Show title (str) or ratingKey (int) to check. Obtain either with "
|
|
202
|
+
"list-titles. Omit only when using -A."
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
parser.add_argument(
|
|
206
|
+
"-l", "--library", dest="library_id", metavar="LIBRARY", default=None,
|
|
207
|
+
help=(
|
|
208
|
+
"Library ID (int) or title (str) to look in. Obtain both with "
|
|
209
|
+
"list-libraries. Narrows the search for SHOW, or the sweep for -A."
|
|
210
|
+
),
|
|
211
|
+
)
|
|
212
|
+
parser.add_argument(
|
|
213
|
+
"-A", "--all", dest="all_shows", action="store_true", default=False,
|
|
214
|
+
help="Check every show, rather than one named show.",
|
|
215
|
+
)
|
|
216
|
+
parser.add_argument(
|
|
217
|
+
"-s", "--season", dest="seasons", metavar="SEASONS", default=None,
|
|
218
|
+
help=(
|
|
219
|
+
"Season number, or several separated by commas, as in 1,3,5. "
|
|
220
|
+
"Applies to a single show. Naming season 0 includes the specials."
|
|
221
|
+
),
|
|
222
|
+
)
|
|
223
|
+
parser.add_argument(
|
|
224
|
+
"--include-specials", action="store_true", default=False,
|
|
225
|
+
help=(
|
|
226
|
+
"Also check season 0 when no season is named. Specials are "
|
|
227
|
+
"numbered irregularly, so this is off by default."
|
|
228
|
+
),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
COMMANDS = {"find-missing": cmd_find_missing}
|
|
233
|
+
|
|
234
|
+
REQUIRES_PLEX = frozenset(COMMANDS)
|