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