plextool 3.0.1__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.
plextool/subs.py ADDED
@@ -0,0 +1,338 @@
1
+ """subs: extract embedded subtitle tracks from MKV and MP4 files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ from pathlib import Path
11
+
12
+ from .common import (
13
+ PlexToolError,
14
+ add_command,
15
+ add_connection_args,
16
+ connect,
17
+ paint,
18
+ pick_sections,
19
+ plural,
20
+ )
21
+
22
+ # mkv subtitle codec_id -> file extension
23
+ CODEC_EXT = {
24
+ "S_TEXT/UTF8": "srt",
25
+ "S_TEXT/ASCII": "srt",
26
+ "S_TEXT/ASS": "ass",
27
+ "S_TEXT/SSA": "ass",
28
+ "S_TEXT/WEBVTT": "vtt",
29
+ "S_HDMV/PGS": "sup",
30
+ "S_VOBSUB": "sub", # also produces an .idx file
31
+ "S_TEXT/USF": "usf",
32
+ }
33
+
34
+ # ffprobe codec_name -> (file extension, ffmpeg subtitle codec). Text formats only: ffmpeg
35
+ # can't turn the image-based ones found in MP4 files into usable sidecar files.
36
+ FFMPEG_TEXT = {
37
+ "mov_text": ("srt", "srt"),
38
+ "subrip": ("srt", "srt"),
39
+ "srt": ("srt", "srt"),
40
+ "text": ("srt", "srt"),
41
+ "ass": ("ass", "ass"),
42
+ "ssa": ("ass", "ass"),
43
+ "webvtt": ("vtt", "webvtt"),
44
+ }
45
+
46
+ # Minimal ISO 639-2 -> 639-1 mapping for common languages; others stay 3-letter.
47
+ ISO3_TO_ISO1 = {
48
+ "eng": "en", "spa": "es", "fre": "fr", "fra": "fr", "ger": "de", "deu": "de", "ita": "it",
49
+ "jpn": "ja", "chi": "zh", "zho": "zh", "kor": "ko", "rus": "ru", "por": "pt", "dut": "nl",
50
+ "nld": "nl", "ara": "ar", "pol": "pl", "swe": "sv", "nor": "no", "dan": "da", "fin": "fi",
51
+ }
52
+
53
+ FORCED_NAME = re.compile(r"\bforced\b", re.IGNORECASE)
54
+ SDH_NAME = re.compile(r"\bsdh\b|hearing[ -]impaired|closed[ -]caption|\bcc\b", re.IGNORECASE)
55
+
56
+
57
+ def codec_ext(codec_id: str) -> str:
58
+ return CODEC_EXT.get(codec_id, "sub")
59
+
60
+
61
+ def lang_code(ietf: str | None, iso3: str | None) -> str:
62
+ """Short language code for a track. Prefers the BCP-47 tag ('pt-BR' -> 'pt'), else maps ISO 639-2."""
63
+ if ietf and ietf != "und":
64
+ return ietf.split("-")[0]
65
+ if not iso3 or iso3 == "und":
66
+ return "und"
67
+ return ISO3_TO_ISO1.get(iso3, iso3)
68
+
69
+
70
+ def flags_for(name: str | None, forced: bool, sdh: bool) -> tuple[bool, bool]:
71
+ """Combine a track's real flags with hints in its name ('English (SDH)', 'Forced')."""
72
+ name = name or ""
73
+ return (bool(forced) or bool(FORCED_NAME.search(name)),
74
+ bool(sdh) or bool(SDH_NAME.search(name)))
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Reading tracks (each returns [{'id', 'lang', 'forced', 'sdh', 'ext', 'codec', ...}])
79
+ # ---------------------------------------------------------------------------
80
+
81
+ def mkv_tracks(identify: dict) -> list[dict]:
82
+ """Subtitle tracks from `mkvmerge -J` output."""
83
+ tracks = []
84
+ for track in identify.get("tracks", []):
85
+ if track.get("type") != "subtitles":
86
+ continue
87
+ props = track.get("properties", {})
88
+ forced, sdh = flags_for(props.get("track_name"), props.get("forced_track"),
89
+ props.get("flag_hearing_impaired"))
90
+ codec = props.get("codec_id", "")
91
+ tracks.append({
92
+ "id": track["id"],
93
+ "codec": codec,
94
+ "ext": codec_ext(codec),
95
+ "lang": lang_code(props.get("language_ietf"), props.get("language")),
96
+ "forced": forced,
97
+ "sdh": sdh,
98
+ })
99
+ return tracks
100
+
101
+
102
+ def mp4_tracks(probe: dict) -> list[dict]:
103
+ """Subtitle streams from `ffprobe -show_streams` output; 'ext' is None if not extractable."""
104
+ tracks = []
105
+ for stream in probe.get("streams", []):
106
+ if stream.get("codec_type") != "subtitle":
107
+ continue
108
+ tags = stream.get("tags", {})
109
+ disposition = stream.get("disposition", {})
110
+ forced, sdh = flags_for(tags.get("title") or tags.get("handler_name"),
111
+ disposition.get("forced"), disposition.get("hearing_impaired"))
112
+ codec = stream.get("codec_name", "")
113
+ ext, ff_codec = FFMPEG_TEXT.get(codec, (None, None))
114
+ tracks.append({
115
+ "id": stream["index"],
116
+ "codec": codec,
117
+ "ext": ext,
118
+ "ffmpeg_codec": ff_codec,
119
+ "lang": lang_code(None, tags.get("language")),
120
+ "forced": forced,
121
+ "sdh": sdh,
122
+ })
123
+ return tracks
124
+
125
+
126
+ def output_names(video: Path, tracks: list[dict]) -> list[Path]:
127
+ """Where each track goes: <video>.<lang>[.forced][.sdh].<ext>.
128
+
129
+ Plex reads the language and flags from the end of the name. Tracks that would get the same
130
+ name (same language and flags) are told apart with a number in front of the language:
131
+ <video>.2.<lang>.<ext>, <video>.3.<lang>.<ext>.
132
+ """
133
+ seen: dict[tuple, int] = {}
134
+ names = []
135
+ for track in tracks:
136
+ key = (track["lang"], track["forced"], track["sdh"])
137
+ count = seen.get(key, 0)
138
+ seen[key] = count + 1
139
+ middle = track["lang"] if count == 0 else f"{count + 1}.{track['lang']}"
140
+ if track["forced"]:
141
+ middle += ".forced"
142
+ if track["sdh"]:
143
+ middle += ".sdh"
144
+ names.append(video.with_name(f"{video.stem}.{middle}.{track['ext']}"))
145
+ return names
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Calling the external tools
150
+ # ---------------------------------------------------------------------------
151
+
152
+ def _run(cmd: list[str]):
153
+ return subprocess.run(cmd, check=False, capture_output=True, text=True)
154
+
155
+
156
+ def probe_mkv(path: Path) -> list[dict]:
157
+ result = _run(["mkvmerge", "-J", str(path)])
158
+ if result.returncode >= 2: # 1 is just warnings
159
+ raise PlexToolError(result.stderr.strip() or result.stdout.strip() or "mkvmerge failed")
160
+ return mkv_tracks(json.loads(result.stdout))
161
+
162
+
163
+ def extract_mkv(path: Path, track: dict, out: Path) -> str | None:
164
+ result = _run(["mkvextract", "tracks", str(path), f"{track['id']}:{out}"])
165
+ return result.stdout.strip() or "mkvextract failed" if result.returncode >= 2 else None
166
+
167
+
168
+ def probe_mp4(path: Path) -> list[dict]:
169
+ result = _run(["ffprobe", "-v", "error", "-print_format", "json", "-show_streams",
170
+ "-select_streams", "s", str(path)])
171
+ if result.returncode != 0:
172
+ raise PlexToolError(result.stderr.strip() or "ffprobe failed")
173
+ return mp4_tracks(json.loads(result.stdout))
174
+
175
+
176
+ def extract_mp4(path: Path, track: dict, out: Path) -> str | None:
177
+ result = _run(["ffmpeg", "-nostdin", "-v", "error", "-y", "-i", str(path),
178
+ "-map", f"0:{track['id']}", "-c:s", track["ffmpeg_codec"], str(out)])
179
+ return result.stderr.strip() or "ffmpeg failed" if result.returncode != 0 else None
180
+
181
+
182
+ # What each kind of video file needs. A kind whose tools are missing is skipped, not fatal.
183
+ KINDS = {
184
+ "mkv": {"suffixes": (".mkv",), "tools": ("mkvmerge", "mkvextract"), "package": "MKVToolNix",
185
+ "probe": probe_mkv, "extract": extract_mkv},
186
+ "mp4": {"suffixes": (".mp4", ".m4v"), "tools": ("ffmpeg", "ffprobe"), "package": "FFmpeg",
187
+ "probe": probe_mp4, "extract": extract_mp4},
188
+ }
189
+
190
+
191
+ def find_videos(root: Path, kinds: list[str]) -> dict[str, list[Path]]:
192
+ """Files under root, grouped by kind ('mkv', 'mp4')."""
193
+ found: dict[str, list[Path]] = {k: [] for k in kinds}
194
+ for path in sorted(root.rglob("*")):
195
+ if not path.is_file():
196
+ continue
197
+ for kind in kinds:
198
+ if path.suffix.lower() in KINDS[kind]["suffixes"]:
199
+ found[kind].append(path)
200
+ return found
201
+
202
+
203
+ def missing_tools(kind: str) -> list[str]:
204
+ return [tool for tool in KINDS[kind]["tools"] if not shutil.which(tool)]
205
+
206
+
207
+ def extract_file(kind: str, video: Path, commit: bool, force: bool) -> int:
208
+ """Extract (or preview extracting) every subtitle track; returns how many were written/planned."""
209
+ print(f">> {video}")
210
+ try:
211
+ tracks = KINDS[kind]["probe"](video)
212
+ except (PlexToolError, ValueError) as e:
213
+ print(paint(f" ! cannot read this file, skipping: {e}", "yellow"))
214
+ return 0
215
+ if not tracks:
216
+ print(" (no subtitle tracks)")
217
+ return 0
218
+
219
+ usable = []
220
+ for track in tracks:
221
+ if track["ext"] is None:
222
+ print(paint(f" track {track['id']} ({track['codec']}): can't be extracted, skipped", "yellow"))
223
+ else:
224
+ usable.append(track)
225
+
226
+ count = 0
227
+ for track, out in zip(usable, output_names(video, usable)):
228
+ print(f" track {track['id']} ({track['codec']}, lang={track['lang']}) -> {out.name}")
229
+ if out.exists() and not force:
230
+ print(" [exists] skipped (use --force to overwrite)")
231
+ continue
232
+ if commit:
233
+ error = KINDS[kind]["extract"](video, track, out)
234
+ if error:
235
+ print(paint(f" ! failed: {error}", "red"))
236
+ continue
237
+ count += 1
238
+ return count
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Command
243
+ # ---------------------------------------------------------------------------
244
+
245
+ def parse_types(text: str) -> list[str]:
246
+ types = [t.strip().lower() for t in text.split(",") if t.strip()]
247
+ bad = [t for t in types if t not in KINDS]
248
+ if bad or not types:
249
+ raise argparse.ArgumentTypeError(f"types must be from: {', '.join(KINDS)}")
250
+ return types
251
+
252
+
253
+ def add_parser(subparsers) -> None:
254
+ parser = add_command(
255
+ subparsers, "subs", "extract embedded subtitles from MKV and MP4 files",
256
+ "Work with subtitles. 'extract' pulls embedded tracks out of video files: MKV files need "
257
+ "mkvmerge and mkvextract (from MKVToolNix), MP4 files need ffmpeg and ffprobe, and you "
258
+ "only need the tools for the types you use. 'export' saves subtitles Plex downloaded on "
259
+ "demand next to the video, and 'fetch' searches for new ones.",
260
+ ["plextool subs extract /mnt/media/Movies # embedded tracks, preview",
261
+ "plextool subs export -t 'Some Show' --commit # Plex's downloaded subs",
262
+ "plextool subs fetch -t 'Some Movie' --lang en --list # browse available subs"])
263
+ sub = parser.add_subparsers(dest="subs_command", required=True, metavar="action")
264
+
265
+ extract = add_command(
266
+ sub, "extract", "extract every subtitle track next to its video",
267
+ "Recursively find .mkv, .mp4 and .m4v files under DIRECTORY and save each subtitle track "
268
+ "beside its video, named so Plex finds it: Movie.en.srt, with .forced or .sdh after the "
269
+ "language for tracks flagged that way (Movie.en.forced.srt). Flags come from the track's "
270
+ "own flags or from its name. If two tracks would get the same name, the later ones are "
271
+ "numbered: Movie.2.en.srt. Existing files are skipped unless --force. Nothing is "
272
+ "written unless --commit is given.\n\nImage-based subtitles in MP4 files can't be "
273
+ "extracted and are reported. With --scan LIBRARY, ask Plex to scan afterwards so the new "
274
+ "files show up. That is the only part that needs a Plex connection.",
275
+ ["plextool subs extract /mnt/media/Movies",
276
+ "plextool subs extract /mnt/media/Movies --commit",
277
+ "plextool subs extract . --types mkv --commit",
278
+ "plextool subs extract . --commit --scan Movies --scan-path /data/Movies"])
279
+ extract.add_argument("directory", nargs="?", default=".", help="where to search (default: current directory)")
280
+ extract.add_argument("--types", type=parse_types, default=list(KINDS), metavar="LIST",
281
+ help="file types to process, comma-separated from: "
282
+ f"{', '.join(KINDS)} (default: all)")
283
+ extract.add_argument("-f", "--force", action="store_true", help="overwrite subtitle files that already exist")
284
+ extract.add_argument("-y", "--commit", action="store_true", help="write the files (default is a dry run)")
285
+ extract.add_argument("--scan", metavar="LIBRARY", help="after extracting, scan this Plex library")
286
+ extract.add_argument("--scan-path", metavar="PATH",
287
+ help="folder to scan, as the Plex server sees it (default: DIRECTORY)")
288
+ add_connection_args(extract)
289
+ extract.set_defaults(run=run_extract)
290
+
291
+ from . import ondemand # imported here because ondemand builds on this module
292
+
293
+ ondemand.add_actions(sub)
294
+
295
+
296
+ def run_extract(args: argparse.Namespace) -> int:
297
+ root = Path(args.directory)
298
+ if not root.is_dir():
299
+ raise PlexToolError(f"Not a directory: {root}")
300
+
301
+ groups = find_videos(root, args.types)
302
+ if not any(groups.values()):
303
+ print(f"No {'/'.join(args.types)} files found under {root}.")
304
+ return 0
305
+
306
+ total = videos = 0
307
+ skipped: list[str] = []
308
+ for kind, files in groups.items():
309
+ if not files:
310
+ continue
311
+ absent = missing_tools(kind)
312
+ if absent:
313
+ skipped.append(f"{plural(len(files), kind + ' file')} skipped: {' and '.join(absent)} "
314
+ f"not found (install {KINDS[kind]['package']})")
315
+ continue
316
+ for video in files:
317
+ total += extract_file(kind, video, args.commit, args.force)
318
+ videos += len(files)
319
+
320
+ for note in skipped:
321
+ print(paint(f"\n! {note}", "yellow"))
322
+ if skipped and not videos:
323
+ raise PlexToolError("None of the video files found can be processed with the tools installed.")
324
+
325
+ verb = "Extracted" if args.commit else "Would extract"
326
+ print(f"\n{verb} {plural(total, 'subtitle file')} from {plural(videos, 'video')}.")
327
+ if not args.commit and total:
328
+ print("Dry run only. Re-run with --commit to write them.")
329
+
330
+ if args.scan and args.commit and total:
331
+ plex = connect(args)
332
+ (section,) = pick_sections(plex, [args.scan])
333
+ path = args.scan_path or str(root.resolve())
334
+ section.update(path=path)
335
+ print(f"Asked Plex to scan {path} in '{section.title}'.")
336
+ elif args.scan and not args.commit:
337
+ print(f"(With --commit, Plex library '{args.scan}' would be scanned.)")
338
+ return 0
plextool/watched.py ADDED
@@ -0,0 +1,249 @@
1
+ """watched: copy, back up, restore and bulk-set watched state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from datetime import datetime
7
+
8
+ from plexapi.exceptions import NotFound
9
+ from plexapi.myplex import MyPlexAccount
10
+ from plexapi.server import PlexServer
11
+ from requests.exceptions import RequestException
12
+
13
+ from .common import (
14
+ SECTION_LEVELS,
15
+ PlexToolError,
16
+ add_command,
17
+ add_connection_args,
18
+ add_library_arg,
19
+ connect,
20
+ describe,
21
+ paint,
22
+ pick_sections,
23
+ plural,
24
+ read_json,
25
+ write_json,
26
+ )
27
+
28
+ # Each kind of watched item: (name in files and flags, library type, item type)
29
+ KINDS = (("movies", "movie", "movie"), ("episodes", "show", "episode"))
30
+ FORMAT_VERSION = 1
31
+
32
+
33
+ def user_server(args: argparse.Namespace, admin: PlexServer, name: str | None) -> PlexServer:
34
+ """Open the server as the named user (the owner if name is None or is the owner)."""
35
+ if not name:
36
+ return admin
37
+ try:
38
+ account = MyPlexAccount(token=args.token)
39
+ except RequestException as e:
40
+ raise PlexToolError(f"Could not reach plex.tv: {e}") from None
41
+ own_names = {n.lower() for n in (account.username, account.email, account.title) if n}
42
+ if name.lower() in own_names:
43
+ return admin
44
+ try:
45
+ user = account.user(name)
46
+ except NotFound:
47
+ raise PlexToolError(f"'{name}' is not a user on this account.") from None
48
+ token = user.get_token(admin.machineIdentifier)
49
+ if not token:
50
+ raise PlexToolError(
51
+ f"Could not get an access token for '{name}'. Is this server shared with them?")
52
+ try:
53
+ return PlexServer(args.base_url, token)
54
+ except RequestException as e:
55
+ raise PlexToolError(f"Could not connect as '{name}': {e}") from None
56
+
57
+
58
+ def wanted_kinds(args: argparse.Namespace) -> list[tuple[str, str, str]]:
59
+ both = not (args.movies or args.shows)
60
+ return [k for k in KINDS if both or (k[0] == "movies" and args.movies)
61
+ or (k[0] == "episodes" and args.shows)]
62
+
63
+
64
+ def collect_watched(server: PlexServer, kinds) -> dict:
65
+ """{'movies': {guid: title}, 'episodes': {guid: title}} of what this user has watched."""
66
+ found = {}
67
+ for label, section_type, libtype in kinds:
68
+ found[label] = {}
69
+ for section in server.library.sections():
70
+ if section.TYPE == section_type:
71
+ for item in section.search(libtype=libtype, unwatched=False):
72
+ found[label][item.guid] = describe(item)
73
+ return found
74
+
75
+
76
+ def apply_watched(server: PlexServer, watched: dict, kinds, commit: bool) -> int:
77
+ """Mark items watched where `watched` says they should be; returns how many."""
78
+ count = 0
79
+ for label, section_type, libtype in kinds:
80
+ guids = watched.get(label, {})
81
+ if not guids:
82
+ continue
83
+ print(paint(f"\n{label.upper()}", "cyan"))
84
+ for section in server.library.sections():
85
+ if section.TYPE != section_type:
86
+ continue
87
+ for item in section.search(libtype=libtype, unwatched=True):
88
+ if item.guid not in guids:
89
+ continue
90
+ if commit:
91
+ item.markPlayed()
92
+ print(f" [MARKED] {describe(item)}")
93
+ else:
94
+ print(f" [DRY RUN] Would mark watched: {describe(item)}")
95
+ count += 1
96
+ return count
97
+
98
+
99
+ def report(total: int, who: str, commit: bool) -> None:
100
+ verb = "Marked" if commit else "Would mark"
101
+ print(f"\n{verb} {total} item(s) as watched for {who}.")
102
+ if not commit and total:
103
+ print("Dry run only. Re-run with --commit to apply.")
104
+
105
+
106
+ def to_file_data(user: str, watched: dict) -> dict:
107
+ return {
108
+ "version": FORMAT_VERSION,
109
+ "user": user,
110
+ "exported": datetime.now().replace(microsecond=0).isoformat(),
111
+ **{label: [{"guid": g, "title": t} for g, t in items.items()] for label, items in watched.items()},
112
+ }
113
+
114
+
115
+ def from_file_data(data: dict) -> dict:
116
+ if data.get("version") != FORMAT_VERSION:
117
+ raise PlexToolError("Unrecognised file: not a plextool watched export.")
118
+ return {label: {e["guid"]: e["title"] for e in data.get(label, [])} for label, _, _ in KINDS}
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Commands
123
+ # ---------------------------------------------------------------------------
124
+
125
+ def run_copy(args: argparse.Namespace) -> int:
126
+ admin = connect(args)
127
+ if args.source.lower() == args.dest.lower():
128
+ raise PlexToolError("--from and --to are the same user.")
129
+ kinds = wanted_kinds(args)
130
+ print(f"Source: {args.source} Destination: {args.dest} "
131
+ f"Mode: {'COMMIT' if args.commit else 'DRY RUN'}")
132
+ watched = collect_watched(user_server(args, admin, args.source), kinds)
133
+ for label, items in watched.items():
134
+ print(f" Source has watched {plural(len(items), label.rstrip('s'))}.")
135
+ total = apply_watched(user_server(args, admin, args.dest), watched, kinds, args.commit)
136
+ report(total, args.dest, args.commit)
137
+ return 0
138
+
139
+
140
+ def run_export(args: argparse.Namespace) -> int:
141
+ admin = connect(args)
142
+ watched = collect_watched(user_server(args, admin, args.user), wanted_kinds(args))
143
+ data = to_file_data(args.user or "owner", watched)
144
+ out = write_json(args.file, data)
145
+ print(f"Wrote {sum(len(v) for v in watched.values())} watched item(s) to "
146
+ f"{'standard output' if args.file == '-' else args.file}", file=out)
147
+ return 0
148
+
149
+
150
+ def run_import(args: argparse.Namespace) -> int:
151
+ watched = from_file_data(read_json(args.file))
152
+ admin = connect(args)
153
+ total = apply_watched(user_server(args, admin, args.user), watched, wanted_kinds(args), args.commit)
154
+ report(total, args.user or "the owner", args.commit)
155
+ return 0
156
+
157
+
158
+ def run_mark(args: argparse.Namespace) -> int:
159
+ admin = connect(args)
160
+ server = user_server(args, admin, args.user)
161
+ # The user's view of the library, so match sections by name on their connection.
162
+ names = [s.title for s in pick_sections(admin, args.library)]
163
+ count = 0
164
+ for name in names:
165
+ section = server.library.section(name)
166
+ level = args.level or section.TYPE
167
+ if level not in SECTION_LEVELS[section.TYPE]:
168
+ continue
169
+ for item in section.search(title=args.title, libtype=level):
170
+ action = "unwatched" if args.unwatched else "watched"
171
+ print(f" {'[MARKED]' if args.commit else '[DRY RUN]'} {describe(item)} -> {action}")
172
+ if args.commit:
173
+ item.markUnplayed() if args.unwatched else item.markPlayed()
174
+ count += 1
175
+ print(f"\n{plural(count, 'item')} {'updated' if args.commit else 'would be updated'}.")
176
+ if not args.commit and count:
177
+ print("Dry run only. Re-run with --commit to apply.")
178
+ return 0
179
+
180
+
181
+ def _kind_flags(parser) -> None:
182
+ parser.add_argument("--movies", action="store_true", help="movies only")
183
+ parser.add_argument("--shows", action="store_true", help="TV episodes only")
184
+
185
+
186
+ def add_parser(subparsers) -> None:
187
+ parser = add_command(
188
+ subparsers, 'watched',
189
+ 'copy, back up, restore or bulk-set watched state',
190
+ "Work with watched state. --token must be the server owner's. Where an action takes a user, the owner can be named too; leaving --user off means the owner. Only the watched flag moves: view counts, last-watched dates and resume positions do not. Watchlists live on plex.tv and are not covered. Everything is a dry run unless you pass --commit.",
191
+ ['plextool watched copy --from alice --to bob --commit',
192
+ 'plextool watched export backup.json',
193
+ "plextool watched mark -t 'Some Show' --commit"])
194
+ sub = parser.add_subparsers(dest="watched_command", required=True, metavar="action")
195
+
196
+ copy = add_command(
197
+ sub, 'copy',
198
+ 'copy watched state from one user to another',
199
+ 'Mark items watched for --to where --from has watched them. Users can be a username, email or Home user name. Useful when splitting a shared account, or when someone closes and reopens their Plex account.',
200
+ ['plextool watched copy --from alice --to bob # preview',
201
+ 'plextool watched copy --from alice --to bob --movies --commit'])
202
+ add_connection_args(copy)
203
+ copy.add_argument("--from", dest="source", required=True, metavar="USER",
204
+ help="user to copy from (username, email, or Home user name)")
205
+ copy.add_argument("--to", dest="dest", required=True, metavar="USER", help="user to copy to")
206
+ _kind_flags(copy)
207
+ copy.add_argument("-y", "--commit", action="store_true", help="write the changes (default is a dry run)")
208
+ copy.set_defaults(run=run_copy)
209
+
210
+ export = add_command(
211
+ sub, 'export',
212
+ "save a user's watched state to a JSON file",
213
+ 'Write what a user has watched to a file, as a backup or to move to another server. Read-only on the server.',
214
+ ['plextool watched export backup.json',
215
+ 'plextool watched export alice.json --user alice --shows'])
216
+ add_connection_args(export)
217
+ export.add_argument("file", help="file to write ('-' for standard output)")
218
+ export.add_argument("--user", metavar="USER", help="user to export (default: the token owner)")
219
+ _kind_flags(export)
220
+ export.set_defaults(run=run_export)
221
+
222
+ imp = add_command(
223
+ sub, 'import',
224
+ 'restore watched state from a JSON file',
225
+ "Mark matching unwatched items watched, from a file made by 'watched export'. It never marks anything unwatched.",
226
+ ['plextool watched import backup.json # preview',
227
+ 'plextool watched import alice.json --user alice --commit'])
228
+ add_connection_args(imp)
229
+ imp.add_argument("file", help="file written by 'watched export' ('-' for standard input)")
230
+ imp.add_argument("--user", metavar="USER", help="user to import into (default: the token owner)")
231
+ _kind_flags(imp)
232
+ imp.add_argument("-y", "--commit", action="store_true", help="write the changes (default is a dry run)")
233
+ imp.set_defaults(run=run_import)
234
+
235
+ mark = add_command(
236
+ sub, 'mark',
237
+ 'mark movies, shows, seasons or episodes watched/unwatched',
238
+ 'Mark everything matching a title watched or unwatched, such as a whole series or season. Title matching is by substring, so check the preview.',
239
+ ["plextool watched mark -l 'TV Shows' -t 'Some Show' # preview",
240
+ "plextool watched mark -t 'Some Show' --level season --unwatched --commit"])
241
+ add_connection_args(mark)
242
+ add_library_arg(mark)
243
+ mark.add_argument("-t", "--title", required=True, help="title to match (substring)")
244
+ mark.add_argument("--level", choices=sorted({lv for lvs in SECTION_LEVELS.values() for lv in lvs}),
245
+ help="item type (default: movie in movie libraries, show in TV libraries)")
246
+ mark.add_argument("--unwatched", action="store_true", help="mark unwatched instead of watched")
247
+ mark.add_argument("--user", metavar="USER", help="user to change (default: the token owner)")
248
+ mark.add_argument("-y", "--commit", action="store_true", help="write the changes (default is a dry run)")
249
+ mark.set_defaults(run=run_mark)