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/ondemand.py ADDED
@@ -0,0 +1,382 @@
1
+ """subs export / subs fetch: work with subtitles Plex downloads on demand."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+
10
+ import requests
11
+ from plexapi.exceptions import BadRequest, NotFound
12
+
13
+ from .common import (
14
+ FILE_LEVEL,
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
+ )
25
+ from .recent import find_by_guid
26
+ from .subs import ISO3_TO_ISO1, lang_code, output_names
27
+
28
+ # Plex's search options: 0 = prefer not, 1 = prefer, 2 = only, 3 = exclude
29
+ PREFERENCE = {"prefer": 1, "only": 2, "exclude": 3}
30
+ EXT_ALIASES = {"subrip": "srt", "ass": "ass", "ssa": "ass", "webvtt": "vtt"}
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Small helpers
35
+ # ---------------------------------------------------------------------------
36
+
37
+ def short_lang(code: str | None) -> str:
38
+ """Two-letter language code from a 2- or 3-letter one, eg 'eng' -> 'en'."""
39
+ code = (code or "").lower()
40
+ return ISO3_TO_ISO1.get(code, code) if len(code) == 3 else code
41
+
42
+
43
+ def stream_lang(stream) -> str:
44
+ return short_lang(stream.languageTag or lang_code(None, stream.languageCode))
45
+
46
+
47
+ def stream_track(stream) -> dict:
48
+ """The fields subs.output_names needs, from a plexapi SubtitleStream."""
49
+ ext = (stream.format or stream.codec or "srt").lower()
50
+ return {"lang": stream_lang(stream), "forced": bool(stream.forced),
51
+ "sdh": bool(stream.hearingImpaired), "ext": EXT_ALIASES.get(ext, ext)}
52
+
53
+
54
+ def is_downloaded(stream, include_external: bool = False) -> bool:
55
+ """A subtitle Plex fetched or was given (not embedded in the video).
56
+
57
+ Embedded tracks have no stream URL. Ones from a provider carry provider details.
58
+ With include_external, any stream that has a URL counts, sidecar files included.
59
+ """
60
+ if not stream.key:
61
+ return False
62
+ return include_external or bool(stream.providerTitle or stream.userID or stream.sourceKey)
63
+
64
+
65
+ def parse_map(text: str) -> tuple[str, str]:
66
+ if "=" not in text:
67
+ raise argparse.ArgumentTypeError("use SERVER_PATH=LOCAL_PATH, eg /data/media=/mnt/media")
68
+ server, local = text.split("=", 1)
69
+ return server.rstrip("/"), local.rstrip("/")
70
+
71
+
72
+ def map_path(path: str, mappings: list[tuple[str, str]]) -> Path:
73
+ """Translate a path as the Plex server sees it into one on this machine."""
74
+ for server, local in mappings or []:
75
+ if path == server or path.startswith(server + "/"):
76
+ return Path(local + path[len(server):])
77
+ return Path(path)
78
+
79
+
80
+ def http_fetch(plex, stream) -> bytes:
81
+ response = requests.get(plex.url(stream.key, includeToken=True), timeout=30)
82
+ response.raise_for_status()
83
+ return response.content
84
+
85
+
86
+ def expand_to_files(item) -> list:
87
+ """Episodes of a show or season; movies and episodes as they are."""
88
+ if item.type in ("show", "season"):
89
+ return item.episodes()
90
+ return [item]
91
+
92
+
93
+ def select_files(plex, args: argparse.Namespace) -> list[tuple]:
94
+ """(section, movie-or-episode) pairs chosen by --title, --guid, --recent or --all."""
95
+ pairs = []
96
+ for section in pick_sections(plex, args.library):
97
+ if args.title:
98
+ items = section.search(title=args.title, libtype=section.TYPE)
99
+ elif args.guid:
100
+ items = find_by_guid(section, section.TYPE, args.guid)
101
+ elif args.recent:
102
+ items = section.recentlyAdded(maxresults=args.recent, libtype=FILE_LEVEL[section.TYPE])
103
+ else:
104
+ items = section.search(libtype=FILE_LEVEL[section.TYPE])
105
+ for item in items:
106
+ pairs += [(section, f) for f in expand_to_files(item)]
107
+ return pairs
108
+
109
+
110
+ def add_selector_args(parser: argparse.ArgumentParser) -> None:
111
+ add_library_arg(parser)
112
+ group = parser.add_mutually_exclusive_group(required=True)
113
+ group.add_argument("-t", "--title", help="movies or shows whose title contains this (shows: all episodes)")
114
+ group.add_argument("-g", "--guid", metavar="GUID", help="one movie or show, by Plex ID or imdb://, tmdb://, tvdb://")
115
+ group.add_argument("-n", "--recent", type=int, metavar="N", help="the N most recently added items per library")
116
+ group.add_argument("--all", action="store_true", help="every movie and episode (slow on big libraries)")
117
+
118
+
119
+ def add_path_args(parser: argparse.ArgumentParser) -> None:
120
+ parser.add_argument("--map", action="append", type=parse_map, default=[], metavar="SERVER=LOCAL",
121
+ help="translate a server path to the matching path on this machine; "
122
+ "repeat for several, eg /data/media=/mnt/media")
123
+ parser.add_argument("-f", "--force", action="store_true", help="overwrite subtitle files that already exist")
124
+ parser.add_argument("--scan", action="store_true",
125
+ help="afterwards ask Plex to scan the folders that got new files")
126
+ parser.add_argument("-y", "--commit", action="store_true", help="write changes (default is a dry run)")
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # export
131
+ # ---------------------------------------------------------------------------
132
+
133
+ def lazy_header(item):
134
+ """A print function that first prints the item's name, but only if something is printed."""
135
+ state = {"shown": False}
136
+
137
+ def emit(message: str) -> None:
138
+ if not state["shown"]:
139
+ print(f">> {describe(item)}")
140
+ state["shown"] = True
141
+ print(message)
142
+
143
+ return emit
144
+
145
+
146
+ def export_item(plex, item, args: argparse.Namespace, fetch=None,
147
+ only_ids: set | None = None, emit=print) -> tuple[int, set]:
148
+ """Save an item's downloaded subtitles beside its video. Returns (files, server folders written)."""
149
+ fetch = fetch or http_fetch
150
+ written = 0
151
+ folders: set[str] = set()
152
+ item.subtitleStreams() # loads the full item if it was only a search result
153
+ for media in item.media:
154
+ for part in media.parts:
155
+ streams = [s for s in part.subtitleStreams()
156
+ if is_downloaded(s, getattr(args, "all_external", False))
157
+ and (only_ids is None or s.id in only_ids)
158
+ and (not getattr(args, "lang", None) or stream_lang(s) == short_lang(args.lang))]
159
+ if not streams:
160
+ continue
161
+ video = map_path(part.file, args.map)
162
+ for stream, out in zip(streams, output_names(video, [stream_track(s) for s in streams])):
163
+ if out.exists() and not args.force:
164
+ emit(f" {out.name}: exists, skipped (use --force)")
165
+ continue
166
+ emit(f" {stream.displayTitle or stream.title} ({stream.providerTitle or 'external'}) -> {out}")
167
+ if args.commit:
168
+ if not out.parent.is_dir():
169
+ emit(paint(f" ! {out.parent} not found here; use --map", "red"))
170
+ continue
171
+ out.write_bytes(fetch(plex, stream))
172
+ written += 1
173
+ folders.add(str(Path(part.file).parent))
174
+ return written, folders
175
+
176
+
177
+ def note_folders(touched: dict, section, folders: set) -> None:
178
+ """Remember which server folders got new files, per library (keyed by title)."""
179
+ entry = touched.setdefault(section.title, (section, set()))
180
+ entry[1].update(folders)
181
+
182
+
183
+ def rescan(touched: dict) -> None:
184
+ for section, folders in touched.values():
185
+ for folder in sorted(folders):
186
+ section.update(path=folder)
187
+ print(f"Asked Plex to scan {folder} in '{section.title}'.")
188
+
189
+
190
+ def run_export(args: argparse.Namespace) -> int:
191
+ plex = connect(args)
192
+ total = 0
193
+ touched: dict = {}
194
+ pairs = select_files(plex, args)
195
+ for section, item in pairs:
196
+ count, folders = export_item(plex, item, args, emit=lazy_header(item))
197
+ total += count
198
+ if count:
199
+ note_folders(touched, section, folders)
200
+ verb = "Saved" if args.commit else "Would save"
201
+ print(f"\n{verb} {plural(total, 'subtitle file')} from {plural(len(pairs), 'item')} checked.")
202
+ if not args.commit and total:
203
+ print("Dry run only. Re-run with --commit to write them.")
204
+ if args.commit and args.scan and touched:
205
+ rescan(touched)
206
+ return 0
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # fetch
211
+ # ---------------------------------------------------------------------------
212
+
213
+ def has_language(item, lang: str) -> bool:
214
+ return any(stream_lang(s) == lang for s in item.subtitleStreams())
215
+
216
+
217
+ def rank(candidates: list, min_score: int = 0) -> list:
218
+ """Best first: perfect matches, then most downloaded. Drops low scorers (but never perfect matches)."""
219
+ kept = [c for c in candidates if c.perfectMatch or (c.score or 0) >= min_score]
220
+ return sorted(kept, key=lambda c: (bool(c.perfectMatch), c.score or 0), reverse=True)
221
+
222
+
223
+ def candidate_line(number: int, c) -> str:
224
+ flags = " ".join(f for f, on in (("forced", c.forced), ("sdh", c.hearingImpaired)) if on)
225
+ perfect = "*" if c.perfectMatch else " "
226
+ return (f" {number:>3} {perfect} score {c.score or 0:<7} {c.providerTitle or '?':<16} "
227
+ f"{(c.format or c.codec or '?'):<5} {flags:<11} {c.title or c.displayTitle or ''}")
228
+
229
+
230
+ def ask_choice(count: int) -> int | None:
231
+ """Prompt for a candidate. Returns its index, None to skip this item; raises on quit."""
232
+ while True:
233
+ answer = input(f"Pick 1-{count}, Enter for #1, s to skip, q to quit: ").strip().lower()
234
+ if answer == "":
235
+ return 0
236
+ if answer == "s":
237
+ return None
238
+ if answer == "q":
239
+ raise PlexToolError("Stopped at your request.")
240
+ if answer.isdigit() and 1 <= int(answer) <= count:
241
+ return int(answer) - 1
242
+ print(" Not a valid choice.")
243
+
244
+
245
+ def wait_for_new(item, before: set, lang: str, timeout: float, interval: float = 2.0) -> set:
246
+ """Ids of subtitle streams that appeared on item since `before`, in lang (empty if none arrive)."""
247
+ deadline = time.monotonic() + timeout
248
+ while True:
249
+ item.reload()
250
+ new = {s.id for s in item.subtitleStreams() if s.id not in before and stream_lang(s) == lang}
251
+ if new or time.monotonic() >= deadline:
252
+ return new
253
+ time.sleep(interval)
254
+
255
+
256
+ def run_fetch(args: argparse.Namespace) -> int:
257
+ if args.export and not args.commit:
258
+ print("(Dry run: nothing will be downloaded or exported.)")
259
+ if args.choose and not sys.stdin.isatty():
260
+ raise PlexToolError("--choose needs a terminal to ask you on.")
261
+ lang = short_lang(args.lang)
262
+ options = {"hearingImpaired": PREFERENCE.get(args.sdh, 0), "forced": PREFERENCE.get(args.forced, 0)}
263
+
264
+ plex = connect(args)
265
+ pairs = select_files(plex, args)
266
+ print(f"Checking {plural(len(pairs), 'item')} for '{lang}' subtitles...")
267
+ requested = [] # (section, item, ids before download)
268
+ skipped_present = no_candidates = 0
269
+ for section, item in pairs:
270
+ if not args.include_present and has_language(item, lang):
271
+ skipped_present += 1
272
+ continue
273
+ try:
274
+ candidates = rank(item.searchSubtitles(language=lang, **options), args.min_score)
275
+ except (BadRequest, NotFound) as e:
276
+ print(paint(f"{describe(item)}: subtitle search failed ({e})", "yellow"))
277
+ continue
278
+ if not candidates:
279
+ no_candidates += 1
280
+ print(f"{describe(item)}: no subtitles found")
281
+ continue
282
+
283
+ shown = candidates[:args.candidates]
284
+ print(paint(f"\n{describe(item)}: {plural(len(candidates), 'candidate')}", "cyan"))
285
+ if args.list or args.choose:
286
+ for number, c in enumerate(shown, start=1):
287
+ print(candidate_line(number, c))
288
+ if args.list:
289
+ continue
290
+ pick = ask_choice(len(shown)) if args.choose else 0
291
+ if pick is None:
292
+ continue
293
+ chosen = shown[pick]
294
+ print(f" {'Downloading' if args.commit else 'Would download'}: "
295
+ f"{chosen.providerTitle} score {chosen.score or 0}"
296
+ f"{' (perfect match)' if chosen.perfectMatch else ''}")
297
+ if args.commit:
298
+ before = {s.id for s in item.subtitleStreams()}
299
+ item.downloadSubtitles(chosen)
300
+ requested.append((section, item, before))
301
+
302
+ print(f"\nRequested {len(requested)} download(s); {skipped_present} already had '{lang}'; "
303
+ f"{no_candidates} had nothing available.")
304
+ if not args.commit and not args.list:
305
+ print("Dry run only. Re-run with --commit to download.")
306
+ if not (args.commit and args.export and requested):
307
+ return 0
308
+
309
+ print("Waiting for Plex to finish downloading...")
310
+ total = 0
311
+ touched: dict = {}
312
+ for section, item, before in requested:
313
+ new = wait_for_new(item, before, lang, args.wait)
314
+ if not new:
315
+ print(paint(f"{describe(item)}: subtitle hasn't arrived yet; run 'subs export' later", "yellow"))
316
+ continue
317
+ print(f">> {describe(item)}")
318
+ count, folders = export_item(plex, item, args, only_ids=new)
319
+ total += count
320
+ if count:
321
+ note_folders(touched, section, folders)
322
+ print(f"\nSaved {plural(total, 'subtitle file')}.")
323
+ if args.scan and touched:
324
+ rescan(touched)
325
+ return 0
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # Registering the commands
330
+ # ---------------------------------------------------------------------------
331
+
332
+ def add_actions(sub) -> None:
333
+ export = add_command(
334
+ sub, "export", "save Plex's downloaded subtitles next to the videos",
335
+ "Subtitles that Plex downloads on demand live in its database, not beside the video. "
336
+ "This saves them as files next to the video, named like 'subs extract' names tracks "
337
+ "(Movie.en.srt, Movie.en.forced.srt, Movie.en.sdh.srt, Movie.2.en.srt). Nothing is "
338
+ "written unless --commit is given. Every selected item is loaded from the server, so "
339
+ "--all is slow on large libraries.\n\nThis script has to be able to reach the video "
340
+ "files. If Plex sees them under a different path than this machine does, translate "
341
+ "with --map SERVER=LOCAL.",
342
+ ["plextool subs export -t 'Some Show'",
343
+ "plextool subs export --all --lang eng --commit --scan",
344
+ "plextool subs export -l Movies -n 20 --map /data/media=/mnt/media --commit"])
345
+ add_connection_args(export)
346
+ add_selector_args(export)
347
+ export.add_argument("--lang", help="only this language, eg en or eng")
348
+ export.add_argument("--all-external", action="store_true",
349
+ help="also export subtitle files that were already beside the video (normally "
350
+ "only ones downloaded from a provider are exported)")
351
+ add_path_args(export)
352
+ export.set_defaults(run=run_export)
353
+
354
+ fetch = add_command(
355
+ sub, "fetch", "search for subtitles in a language and download one",
356
+ "For movies and episodes with no subtitle in --lang, ask Plex's subtitle search for "
357
+ "candidates and download one into Plex. Candidates are ranked perfect matches first, "
358
+ "then by download count (score), but the top one isn't always the best. Use --list to "
359
+ "see what is available, or --choose to be shown the candidates for each item and pick "
360
+ "yourself. Add --export to also save the result beside the video.\n\nNothing is "
361
+ "downloaded unless --commit is given. Downloads happen in the background on the server.",
362
+ ["plextool subs fetch -t 'Some Movie' --lang en --list # just look",
363
+ "plextool subs fetch -t 'Some Show' --lang fr --choose --commit",
364
+ "plextool subs fetch -n 20 --lang en --sdh prefer --export --commit"])
365
+ add_connection_args(fetch)
366
+ add_selector_args(fetch)
367
+ fetch.add_argument("--lang", required=True, help="language to look for, eg en or eng")
368
+ fetch.add_argument("--sdh", choices=PREFERENCE, help="SDH (hearing impaired) subtitles: prefer, only or exclude")
369
+ fetch.add_argument("--forced", choices=PREFERENCE, help="forced subtitles: prefer, only or exclude")
370
+ fetch.add_argument("--min-score", type=int, default=0, metavar="N",
371
+ help="ignore candidates with a lower download count (perfect matches are always kept)")
372
+ fetch.add_argument("--candidates", type=int, default=10, metavar="N", help="show at most N candidates (default 10)")
373
+ how = fetch.add_mutually_exclusive_group()
374
+ how.add_argument("--list", action="store_true", help="show the candidates and stop; downloads nothing")
375
+ how.add_argument("--choose", action="store_true", help="show the candidates for each item and ask which to download")
376
+ fetch.add_argument("--include-present", action="store_true",
377
+ help="also search items that already have a subtitle in this language")
378
+ fetch.add_argument("--export", action="store_true", help="after downloading, save the subtitle beside the video")
379
+ fetch.add_argument("--wait", type=float, default=30.0, metavar="SECONDS",
380
+ help="with --export, how long to wait for each download to arrive (default 30)")
381
+ add_path_args(fetch)
382
+ fetch.set_defaults(run=run_fetch)
plextool/playlists.py ADDED
@@ -0,0 +1,153 @@
1
+ """playlists: list, back up and restore video playlists."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from plexapi.exceptions import BadRequest
8
+
9
+ from .common import (
10
+ PlexToolError,
11
+ add_command,
12
+ add_connection_args,
13
+ connect,
14
+ describe,
15
+ paint,
16
+ plural,
17
+ read_json,
18
+ write_json,
19
+ )
20
+
21
+ FORMAT_VERSION = 1
22
+ VIDEO_TYPES = ("movie", "episode")
23
+
24
+
25
+ def playlist_data(playlist) -> dict:
26
+ """A plain-data description of a playlist; smart playlists are recorded without items."""
27
+ items = [] if playlist.smart else [
28
+ {"guid": i.guid, "title": describe(i), "type": i.type} for i in playlist.items()]
29
+ return {"title": playlist.title, "type": playlist.playlistType, "smart": bool(playlist.smart),
30
+ "items": items}
31
+
32
+
33
+ def guid_lookup(plex, guids: set) -> dict:
34
+ """Find the server's movies and episodes for a set of GUIDs."""
35
+ found = {}
36
+ wanted = {"movie": "movie", "show": "episode"}
37
+ for section in plex.library.sections():
38
+ libtype = wanted.get(section.TYPE)
39
+ if libtype:
40
+ for item in section.search(libtype=libtype):
41
+ if item.guid in guids:
42
+ found[item.guid] = item
43
+ return found
44
+
45
+
46
+ def add_parser(subparsers) -> None:
47
+ parser = add_command(
48
+ subparsers, 'playlists',
49
+ 'list, export or import video playlists',
50
+ "Back up and restore the token owner's video playlists. Items are matched by Plex GUID. Smart playlists are listed but not exported or recreated.",
51
+ ['plextool playlists list',
52
+ 'plextool playlists export playlists.json',
53
+ 'plextool playlists import playlists.json --commit'])
54
+ sub = parser.add_subparsers(dest="playlists_command", required=True, metavar="action")
55
+
56
+ ls = add_command(
57
+ sub, 'list',
58
+ 'list playlists',
59
+ "List the token owner's playlists with their type and size. Read-only.",
60
+ ['plextool playlists list'])
61
+ add_connection_args(ls)
62
+ ls.set_defaults(run=run_list)
63
+
64
+ export = add_command(
65
+ sub, 'export',
66
+ 'save playlists to a JSON file',
67
+ 'Write playlists and the GUIDs of their items to a file. Smart playlists are recorded without items. Read-only on the server.',
68
+ ['plextool playlists export playlists.json',
69
+ 'plextool playlists export christmas.json --name Christmas'])
70
+ add_connection_args(export)
71
+ export.add_argument("file", help="file to write ('-' for standard output)")
72
+ export.add_argument("--name", action="append", help="playlist to export; repeat for several (default: all)")
73
+ export.set_defaults(run=run_export)
74
+
75
+ imp = add_command(
76
+ sub, 'import',
77
+ 'recreate playlists from a JSON file',
78
+ "Recreate playlists from a file made by 'playlists export'. Items not found on this server are skipped. Playlists that already exist are skipped unless you pass --replace, which deletes and recreates them.",
79
+ ['plextool playlists import playlists.json # preview',
80
+ 'plextool playlists import playlists.json --replace --commit'])
81
+ add_connection_args(imp)
82
+ imp.add_argument("file", help="file written by 'playlists export' ('-' for standard input)")
83
+ imp.add_argument("--replace", action="store_true", help="delete and recreate playlists that already exist")
84
+ imp.add_argument("-y", "--commit", action="store_true", help="write the changes (default is a dry run)")
85
+ imp.set_defaults(run=run_import)
86
+
87
+
88
+ def run_list(args: argparse.Namespace) -> int:
89
+ plex = connect(args)
90
+ playlists = plex.playlists()
91
+ for p in playlists:
92
+ kind = "smart" if p.smart else p.playlistType
93
+ print(f" {p.title} ({kind}, {plural(p.leafCount or 0, 'item')})")
94
+ print(f"\n{plural(len(playlists), 'playlist')}.")
95
+ return 0
96
+
97
+
98
+ def run_export(args: argparse.Namespace) -> int:
99
+ plex = connect(args)
100
+ chosen = [p for p in plex.playlists() if not args.name or p.title in args.name]
101
+ if args.name:
102
+ missing = set(args.name) - {p.title for p in chosen}
103
+ if missing:
104
+ raise PlexToolError(f"No such playlist: {', '.join(sorted(missing))}")
105
+ data = {"version": FORMAT_VERSION, "playlists": [playlist_data(p) for p in chosen]}
106
+ out = write_json(args.file, data)
107
+ print(f"Wrote {plural(len(chosen), 'playlist')} to "
108
+ f"{'standard output' if args.file == '-' else args.file}", file=out)
109
+ return 0
110
+
111
+
112
+ def run_import(args: argparse.Namespace) -> int:
113
+ data = read_json(args.file)
114
+ if data.get("version") != FORMAT_VERSION:
115
+ raise PlexToolError("Unrecognised file: not a plextool playlist export.")
116
+
117
+ plex = connect(args)
118
+ existing = {p.title: p for p in plex.playlists()}
119
+ todo = []
120
+ for entry in data["playlists"]:
121
+ if entry["smart"]:
122
+ print(paint(f" skip {entry['title']}: smart playlists can't be recreated", "yellow"))
123
+ elif entry["type"] != "video":
124
+ print(paint(f" skip {entry['title']}: only video playlists are supported", "yellow"))
125
+ elif entry["title"] in existing and not args.replace:
126
+ print(paint(f" skip {entry['title']}: already exists (use --replace)", "yellow"))
127
+ else:
128
+ todo.append(entry)
129
+
130
+ wanted = {i["guid"] for e in todo for i in e["items"]}
131
+ found = guid_lookup(plex, wanted) if wanted else {}
132
+ created = 0
133
+ for entry in todo:
134
+ items = [found[i["guid"]] for i in entry["items"] if i["guid"] in found]
135
+ lost = len(entry["items"]) - len(items)
136
+ note = f", {lost} not found on this server" if lost else ""
137
+ if not items:
138
+ print(paint(f" skip {entry['title']}: none of its items are on this server", "yellow"))
139
+ continue
140
+ verb = "Created" if args.commit else "Would create"
141
+ print(f" {verb} {entry['title']}: {plural(len(items), 'item')}{note}")
142
+ if args.commit:
143
+ if entry["title"] in existing:
144
+ existing[entry["title"]].delete()
145
+ try:
146
+ plex.createPlaylist(entry["title"], items=items)
147
+ except BadRequest as e:
148
+ raise PlexToolError(f"Server refused playlist '{entry['title']}': {e}") from None
149
+ created += 1
150
+ print(f"\n{plural(created, 'playlist')} {'created' if args.commit else 'would be created'}.")
151
+ if not args.commit and created:
152
+ print("Dry run only. Re-run with --commit to apply.")
153
+ return 0